feat: 기간별 단가 설정 기능 구현 - 자동 계산 시스템

- 선택항목 상세입력 컴포넌트 확장
  - 실시간 가격 계산 기능 추가 (할인율/할인금액, 반올림 방식)
  - 카테고리 값 기반 연산 매핑 시스템
  - 3단계 드릴다운 방식 설정 UI (메뉴 → 카테고리 → 값 매핑)

- 설정 가능한 계산 로직
  - autoCalculation 설정으로 계산 필드명 동적 지정
  - valueMapping으로 카테고리 코드와 연산 타입 매핑
  - 할인 방식: none/rate/amount
  - 반올림 방식: none/round/floor/ceil
  - 반올림 단위: 1/10/100/1000

- UI 개선
  - 입력 필드 가로 배치 (반응형 Grid)
  - 카테고리 타입 필드 옵션 로딩 개선
  - 계산 결과 필드 자동 표시 및 읽기 전용 처리
  - 날짜 입력 필드 네이티브 피커 지원

- API 연동
  - 2레벨 메뉴 목록 조회
  - 메뉴별 카테고리 컬럼 조회
  - 카테고리별 값 목록 조회

- 문서화
  - 기간별 단가 설정 가이드 작성
This commit is contained in:
kjs
2025-11-18 16:12:47 +09:00
parent 967b76591b
commit e1a5befdf7
10 changed files with 1966 additions and 186 deletions

View File

@@ -74,6 +74,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[dataRegistry, dataSourceId]
);
// 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능)
console.log("📦 [SelectedItemsDetailInput] 사용 가능한 모든 데이터:", {
keys: Object.keys(dataRegistry),
counts: Object.entries(dataRegistry).map(([key, data]: [string, any]) => ({
table: key,
count: data.length,
})),
});
const updateItemData = useModalDataStore((state) => state.updateItemData);
// 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터
@@ -138,39 +147,63 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
for (const field of codeFields) {
// 이미 codeCategory가 있으면 사용
let codeCategory = field.codeCategory;
// 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기
if (!codeCategory && targetTableColumns.length > 0) {
const columnMeta = targetTableColumns.find(
(col: any) => (col.columnName || col.column_name) === field.name
);
if (columnMeta) {
codeCategory = columnMeta.codeCategory || columnMeta.code_category;
console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory);
}
}
if (!codeCategory) {
console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`);
// 이미 로드된 옵션이면 스킵
if (newOptions[field.name]) {
console.log(`⏭️ 이미 로드된 옵션 (${field.name})`);
continue;
}
// 이미 로드된 옵션이면 스킵
if (newOptions[codeCategory]) continue;
try {
const response = await commonCodeApi.options.getOptions(codeCategory);
if (response.success && response.data) {
newOptions[codeCategory] = response.data.map((opt) => ({
label: opt.label,
value: opt.value,
}));
console.log(`✅ 코드 옵션 로드 완료: ${codeCategory}`, newOptions[codeCategory]);
// 🆕 category 타입이면 table_column_category_values에서 로드
if (field.inputType === "category" && targetTable) {
console.log(`🔄 카테고리 옵션 로드 시도 (${targetTable}.${field.name})`);
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const response = await getCategoryValues(targetTable, field.name, false);
console.log(`📥 getCategoryValues 응답:`, response);
if (response.success && response.data) {
newOptions[field.name] = response.data.map((item: any) => ({
label: item.value_label || item.valueLabel,
value: item.value_code || item.valueCode,
}));
console.log(`✅ 카테고리 옵션 로드 완료 (${field.name}):`, newOptions[field.name]);
} else {
console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음");
}
} else if (field.inputType === "code") {
// code 타입이면 기존대로 code_info에서 로드
// 이미 codeCategory가 있으면 사용
let codeCategory = field.codeCategory;
// 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기
if (!codeCategory && targetTableColumns.length > 0) {
const columnMeta = targetTableColumns.find(
(col: any) => (col.columnName || col.column_name) === field.name
);
if (columnMeta) {
codeCategory = columnMeta.codeCategory || columnMeta.code_category;
console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory);
}
}
if (!codeCategory) {
console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`);
continue;
}
const response = await commonCodeApi.options.getOptions(codeCategory);
if (response.success && response.data) {
newOptions[field.name] = response.data.map((opt) => ({
label: opt.label,
value: opt.value,
}));
console.log(`✅ 코드 옵션 로드 완료 (${codeCategory}):`, newOptions[field.name]);
}
}
} catch (error) {
console.error(` 코드 옵션 로드 실패: ${codeCategory}`, error);
console.error(`❌ 옵션 로드 실패 (${field.name}):`, error);
}
}
@@ -262,6 +295,51 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onClick?.();
};
// 🆕 실시간 단가 계산 함수 (설정 기반 + 카테고리 매핑)
const calculatePrice = useCallback((entry: GroupEntry): number => {
// 자동 계산 설정이 없으면 계산하지 않음
if (!componentConfig.autoCalculation) return 0;
const { inputFields, valueMapping } = componentConfig.autoCalculation;
// 기본 단가
const basePrice = parseFloat(entry[inputFields.basePrice] || "0");
if (basePrice === 0) return 0;
let price = basePrice;
// 1단계: 할인 적용
const discountTypeValue = entry[inputFields.discountType];
const discountValue = parseFloat(entry[inputFields.discountValue] || "0");
// 매핑을 통해 실제 연산 타입 결정
const discountOperation = valueMapping?.discountType?.[discountTypeValue] || "none";
if (discountOperation === "rate") {
price = price * (1 - discountValue / 100);
} else if (discountOperation === "amount") {
price = price - discountValue;
}
// 2단계: 반올림 적용
const roundingTypeValue = entry[inputFields.roundingType];
const roundingUnitValue = entry[inputFields.roundingUnit];
// 매핑을 통해 실제 연산 타입 결정
const roundingOperation = valueMapping?.roundingType?.[roundingTypeValue] || "none";
const unit = valueMapping?.roundingUnit?.[roundingUnitValue] || parseFloat(roundingUnitValue) || 1;
if (roundingOperation === "round") {
price = Math.round(price / unit) * unit;
} else if (roundingOperation === "floor") {
price = Math.floor(price / unit) * unit;
} else if (roundingOperation === "ceil") {
price = Math.ceil(price / unit) * unit;
}
return price;
}, [componentConfig.autoCalculation]);
// 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName
const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
setItems((prevItems) => {
@@ -274,10 +352,38 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (existingEntryIndex >= 0) {
// 기존 entry 업데이트 (항상 이 경로로만 진입)
const updatedEntries = [...groupEntries];
updatedEntries[existingEntryIndex] = {
const updatedEntry = {
...updatedEntries[existingEntryIndex],
[fieldName]: value,
};
// 🆕 가격 관련 필드가 변경되면 자동 계산
if (componentConfig.autoCalculation) {
const { inputFields, targetField } = componentConfig.autoCalculation;
const priceRelatedFields = [
inputFields.basePrice,
inputFields.discountType,
inputFields.discountValue,
inputFields.roundingType,
inputFields.roundingUnit,
];
if (priceRelatedFields.includes(fieldName)) {
const calculatedPrice = calculatePrice(updatedEntry);
updatedEntry[targetField] = calculatedPrice;
console.log("💰 [자동 계산]", {
basePrice: updatedEntry[inputFields.basePrice],
discountType: updatedEntry[inputFields.discountType],
discountValue: updatedEntry[inputFields.discountValue],
roundingType: updatedEntry[inputFields.roundingType],
roundingUnit: updatedEntry[inputFields.roundingUnit],
calculatedPrice,
targetField,
});
}
}
updatedEntries[existingEntryIndex] = updatedEntry;
return {
...item,
fieldGroups: {
@@ -292,7 +398,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
});
});
}, []);
}, [calculatePrice]);
// 🆕 품목 제거 핸들러
const handleRemoveItem = (itemId: string) => {
@@ -303,7 +409,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const handleAddGroupEntry = (itemId: string, groupId: string) => {
const newEntryId = `entry-${Date.now()}`;
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리)
setItems((prevItems) => {
return prevItems.map((item) => {
if (item.id !== itemId) return item;
@@ -311,6 +417,36 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const groupEntries = item.fieldGroups[groupId] || [];
const newEntry: GroupEntry = { id: newEntryId };
// 🆕 autoFillFrom 필드 자동 채우기 (tableName으로 직접 접근)
const groupFields = (componentConfig.additionalFields || []).filter(
(f) => f.groupId === groupId
);
groupFields.forEach((field) => {
if (!field.autoFillFrom) return;
// 데이터 소스 결정
let sourceData: any = null;
if (field.autoFillFromTable) {
// 특정 테이블에서 가져오기
const tableData = dataRegistry[field.autoFillFromTable];
if (tableData && tableData.length > 0) {
// 첫 번째 항목 사용 (또는 매칭 로직 추가 가능)
sourceData = tableData[0].originalData || tableData[0];
console.log(`✅ [autoFill] ${field.name}${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, sourceData[field.autoFillFrom]);
}
} else {
// 주 데이터 소스 (item.originalData) 사용
sourceData = item.originalData;
console.log(`✅ [autoFill] ${field.name}${field.autoFillFrom} (주 소스):`, sourceData[field.autoFillFrom]);
}
if (sourceData && sourceData[field.autoFillFrom] !== undefined) {
newEntry[field.name] = sourceData[field.autoFillFrom];
}
});
return {
...item,
fieldGroups: {
@@ -377,6 +513,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const renderField = (field: AdditionalFieldDefinition, itemId: string, groupId: string, entryId: string, entry: GroupEntry) => {
const value = entry[field.name] || field.defaultValue || "";
// 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반)
const isCalculatedField = componentConfig.autoCalculation?.targetField === field.name;
const commonProps = {
value: value || "",
disabled: componentConfig.disabled || componentConfig.readonly,
@@ -399,7 +538,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
maxLength={field.validation?.maxLength}
className="h-8 text-xs sm:h-10 sm:text-sm"
className="h-10 text-sm"
/>
);
@@ -409,6 +548,30 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
case "bigint":
case "decimal":
case "numeric":
// 🆕 계산된 단가는 천 단위 구분 및 강조 표시
if (isCalculatedField) {
const numericValue = parseFloat(value) || 0;
const formattedValue = new Intl.NumberFormat("ko-KR").format(numericValue);
return (
<div className="relative">
<Input
value={formattedValue}
readOnly
disabled
className={cn(
"h-10 text-sm",
"bg-primary/10 border-primary/30 font-semibold text-primary",
"cursor-not-allowed"
)}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-primary/70">
</div>
</div>
);
}
return (
<Input
{...commonProps}
@@ -416,7 +579,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="h-8 text-xs sm:h-10 sm:text-sm"
className="h-10 text-sm"
/>
);
@@ -428,7 +591,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{...commonProps}
type="date"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={(e) => {
// 날짜 선택기 강제 열기
const target = e.target as HTMLInputElement;
if (target && target.showPicker) {
target.showPicker();
}
}}
className="h-10 text-sm cursor-pointer"
/>
);
@@ -456,20 +626,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 🆕 추가 inputType들
case "code":
case "category":
// 🆕 codeCategory를 field.codeCategory 또는 codeOptions에서 찾기
// 🆕 옵션을 field.name 또는 field.codeCategory 키로 찾기
let categoryOptions = field.options; // 기본값
if (field.codeCategory && codeOptions[field.codeCategory]) {
// 1순위: 필드 이름으로 직접 찾기 (category 타입에서 사용)
if (codeOptions[field.name]) {
categoryOptions = codeOptions[field.name];
}
// 2순위: codeCategory로 찾기 (code 타입에서 사용)
else if (field.codeCategory && codeOptions[field.codeCategory]) {
categoryOptions = codeOptions[field.codeCategory];
} else {
// codeCategory가 없으면 모든 codeOptions에서 이 필드에 맞는 옵션 찾기
const matchedCategory = Object.keys(codeOptions).find((cat) => {
// 필드명과 매칭되는 카테고리 찾기 (예: currency_code → CURRENCY)
return field.name.toLowerCase().includes(cat.toLowerCase().replace('_', ''));
});
if (matchedCategory) {
categoryOptions = codeOptions[matchedCategory];
}
}
return (
@@ -478,7 +644,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
<SelectTrigger size="default" className="w-full">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
@@ -769,11 +935,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const isEditingThisEntry = isEditingThisGroup && editingDetailId === entry.id;
if (isEditingThisEntry) {
// 편집 모드: 입력 필드 표시
// 편집 모드: 입력 필드 표시 (가로 배치)
return (
<Card key={entry.id} className="border-dashed border-primary">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium"> </span>
<Button
type="button"
@@ -790,15 +956,18 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
</Button>
</div>
{groupFields.map((field) => (
<div key={field.name} className="space-y-1">
<label className="text-xs font-medium">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
</label>
{renderField(field, item.id, group.id, entry.id, entry)}
</div>
))}
{/* 🆕 가로 Grid 배치 (2~3열) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{groupFields.map((field) => (
<div key={field.name} className="space-y-1">
<label className="text-xs font-medium">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
</label>
{renderField(field, item.id, group.id, entry.id, entry)}
</div>
))}
</div>
</CardContent>
</Card>
);