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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user