From f04a3e3505eed5800a184a305c60de0a66172469 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 8 Dec 2025 14:34:18 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=ED=95=98=EA=B8=B0=20=EC=A0=84=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/controllers/tableHistoryController.ts | 8 ++++---- backend-node/src/services/dynamicFormService.ts | 10 +++++++++- frontend/components/common/TableHistoryModal.tsx | 9 ++++++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/backend-node/src/controllers/tableHistoryController.ts b/backend-node/src/controllers/tableHistoryController.ts index a32f31ad..8a506626 100644 --- a/backend-node/src/controllers/tableHistoryController.ts +++ b/backend-node/src/controllers/tableHistoryController.ts @@ -67,7 +67,7 @@ export class TableHistoryController { const whereClause = whereConditions.join(" AND "); - // 이력 조회 쿼리 + // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결) const historyQuery = ` SELECT log_id, @@ -84,7 +84,7 @@ export class TableHistoryController { full_row_after FROM ${logTableName} WHERE ${whereClause} - ORDER BY changed_at DESC + ORDER BY log_id DESC LIMIT ${limitParam} OFFSET ${offsetParam} `; @@ -196,7 +196,7 @@ export class TableHistoryController { const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; - // 이력 조회 쿼리 + // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결) const historyQuery = ` SELECT log_id, @@ -213,7 +213,7 @@ export class TableHistoryController { full_row_after FROM ${logTableName} ${whereClause} - ORDER BY changed_at DESC + ORDER BY log_id DESC LIMIT ${limitParam} OFFSET ${offsetParam} `; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 04586d65..88d620b5 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1160,7 +1160,15 @@ export class DynamicFormService { console.log("📝 실행할 DELETE SQL:", deleteQuery); console.log("📊 SQL 파라미터:", [id]); - const result = await query(deleteQuery, [id]); + // 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용) + const result = await transaction(async (client) => { + // 이력 트리거에서 사용할 사용자 정보 설정 + if (userId) { + await client.query(`SET LOCAL app.user_id = '${userId}'`); + } + const res = await client.query(deleteQuery, [id]); + return res.rows; + }); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx index 033c18ac..f16d0eb7 100644 --- a/frontend/components/common/TableHistoryModal.tsx +++ b/frontend/components/common/TableHistoryModal.tsx @@ -6,6 +6,11 @@ */ import React, { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, +} from "@/components/ui/dialog"; import { ResizableDialog, ResizableDialogContent, @@ -137,7 +142,9 @@ export function TableHistoryModal({ const formatDate = (dateString: string) => { try { - return format(new Date(dateString), "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko }); + // DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환 + const date = new Date(dateString); + return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko }); } catch { return dateString; } From 0c57609ee97831511c4f2ac14f61d248302efb4c Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 5 Dec 2025 12:59:03 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(UniversalFormModal):=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=ED=95=84=EB=93=9C=20=EA=B7=B8=EB=A3=B9=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LinkedFieldGroup, LinkedFieldMapping 타입 정의 - 소스 테이블 데이터 캐싱 및 드롭다운 렌더링 - 선택 시 여러 컬럼에 자동 값 매핑 처리 - 설정 패널에 연동 필드 그룹 관리 UI 추가 - 일반 섹션/반복 섹션 모두 지원 --- .../UniversalFormModalComponent.tsx | 224 ++++++++++++ .../UniversalFormModalConfigPanel.tsx | 323 ++++++++++++++++++ .../components/universal-form-modal/config.ts | 26 ++ .../components/universal-form-modal/types.ts | 31 ++ 4 files changed, 604 insertions(+) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 85133424..65e079ae 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -33,6 +33,7 @@ import { FormDataState, RepeatSectionItem, SelectOptionConfig, + LinkedFieldGroup, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; @@ -100,6 +101,11 @@ export function UniversalFormModalComponent({ [key: string]: { value: string; label: string }[]; }>({}); + // 연동 필드 그룹 데이터 캐시 (테이블별 데이터) + const [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{ + [tableKey: string]: Record[]; + }>({}); + // 로딩 상태 const [saving, setSaving] = useState(false); @@ -342,6 +348,125 @@ export function UniversalFormModalComponent({ [selectOptionsCache], ); + // 연동 필드 그룹 데이터 로드 + const loadLinkedFieldData = useCallback( + async (sourceTable: string): Promise[]> => { + // 캐시 확인 - 이미 배열로 캐시되어 있으면 반환 + if (Array.isArray(linkedFieldDataCache[sourceTable]) && linkedFieldDataCache[sourceTable].length > 0) { + return linkedFieldDataCache[sourceTable]; + } + + let data: Record[] = []; + + try { + console.log(`[연동필드] ${sourceTable} 데이터 로드 시작`); + // 현재 회사 기준으로 데이터 조회 (POST 메서드, autoFilter 사용) + const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, + size: 1000, + autoFilter: true, // 현재 회사 기준 자동 필터링 + }); + + console.log(`[연동필드] ${sourceTable} API 응답:`, response.data); + + if (response.data?.success) { + // data가 배열인지 확인 + const responseData = response.data?.data; + if (Array.isArray(responseData)) { + data = responseData; + } else if (responseData?.rows && Array.isArray(responseData.rows)) { + // { rows: [...], total: ... } 형태일 수 있음 + data = responseData.rows; + } + console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3)); + } + + // 캐시 저장 (빈 배열이라도 저장하여 중복 요청 방지) + setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: data })); + } catch (error) { + console.error(`연동 필드 데이터 로드 실패 (${sourceTable}):`, error); + // 실패해도 빈 배열로 캐시하여 무한 요청 방지 + setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: [] })); + } + + return data; + }, + [linkedFieldDataCache], + ); + + // 연동 필드 그룹 선택 시 매핑된 필드에 값 설정 + const handleLinkedFieldSelect = useCallback( + ( + group: LinkedFieldGroup, + selectedValue: string, + sectionId: string, + repeatItemId?: string + ) => { + // 캐시에서 데이터 찾기 + const sourceData = linkedFieldDataCache[group.sourceTable] || []; + const selectedRow = sourceData.find( + (row) => String(row[group.valueColumn]) === selectedValue + ); + + if (!selectedRow) { + console.warn("선택된 항목을 찾을 수 없습니다:", selectedValue); + return; + } + + // 매핑된 필드에 값 설정 + if (repeatItemId) { + // 반복 섹션 내 아이템 업데이트 + setRepeatSections((prev) => { + const sectionItems = prev[sectionId] || []; + const updatedItems = sectionItems.map((item) => { + if (item._id === repeatItemId) { + const updatedItem = { ...item }; + for (const mapping of group.mappings) { + updatedItem[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; + } + return updatedItem; + } + return item; + }); + return { ...prev, [sectionId]: updatedItems }; + }); + } else { + // 일반 섹션 필드 업데이트 + setFormData((prev) => { + const newData = { ...prev }; + for (const mapping of group.mappings) { + newData[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; + } + if (onChange) { + setTimeout(() => onChange(newData), 0); + } + return newData; + }); + } + }, + [linkedFieldDataCache, onChange], + ); + + // 연동 필드 그룹 표시 텍스트 생성 + const getLinkedFieldDisplayText = useCallback( + (group: LinkedFieldGroup, row: Record): string => { + const code = row[group.valueColumn] || ""; + const name = row[group.displayColumn] || ""; + + switch (group.displayFormat) { + case "name_only": + return name; + case "code_name": + return `${code} - ${name}`; + case "name_code": + return `${name} (${code})`; + default: + return name; + } + }, + [], + ); + // 필수 필드 검증 const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { const missingFields: string[] = []; @@ -729,6 +854,64 @@ export function UniversalFormModalComponent({ })(); }; + // 연동 필드 그룹 드롭다운 렌더링 + const renderLinkedFieldGroup = ( + group: LinkedFieldGroup, + sectionId: string, + repeatItemId?: string, + currentValue?: string, + sectionColumns: number = 2, + ) => { + const fieldKey = `linked_${group.id}_${repeatItemId || "main"}`; + const cachedData = linkedFieldDataCache[group.sourceTable]; + // 배열인지 확인하고, 아니면 빈 배열 사용 + const sourceData = Array.isArray(cachedData) ? cachedData : []; + const defaultSpan = Math.floor(12 / sectionColumns); + const actualGridSpan = sectionColumns === 1 ? 12 : group.gridSpan || defaultSpan; + + // 데이터 로드 (아직 없으면, 그리고 캐시에 없을 때만) + if (!cachedData && group.sourceTable) { + loadLinkedFieldData(group.sourceTable); + } + + return ( +
+ + +
+ ); + }; + // 섹션의 열 수에 따른 기본 gridSpan 계산 const getDefaultGridSpan = (sectionColumns: number = 2): number => { // 12칸 그리드 기준: 1열=12, 2열=6, 3열=4, 4열=3 @@ -806,6 +989,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -815,6 +999,18 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 */} + {(section.linkedFieldGroups || []).map((group) => { + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + undefined, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
@@ -827,6 +1023,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -836,6 +1033,19 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 */} + {(section.linkedFieldGroups || []).map((group) => { + // 매핑된 첫 번째 타겟 컬럼의 현재 값을 찾아서 선택 상태 표시 + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + undefined, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
@@ -885,6 +1095,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -894,6 +1105,19 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 (반복 섹션 내) */} + {(section.linkedFieldGroups || []).map((group) => { + // 반복 섹션 아이템 내의 매핑된 첫 번째 타겟 컬럼 값 + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? item[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + item._id, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
))} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 90c9d64b..dc35a77e 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -37,17 +37,23 @@ import { UniversalFormModalConfigPanelProps, FormSectionConfig, FormFieldConfig, + LinkedFieldGroup, + LinkedFieldMapping, FIELD_TYPE_OPTIONS, MODAL_SIZE_OPTIONS, SELECT_OPTION_TYPE_OPTIONS, + LINKED_FIELD_DISPLAY_FORMAT_OPTIONS, } from "./types"; import { defaultFieldConfig, defaultSectionConfig, defaultNumberingRuleConfig, defaultSelectOptionsConfig, + defaultLinkedFieldGroupConfig, + defaultLinkedFieldMappingConfig, generateSectionId, generateFieldId, + generateLinkedFieldGroupId, } from "./config"; // 도움말 텍스트 컴포넌트 @@ -87,6 +93,24 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.saveConfig.tableName]); + // 연동 필드 그룹의 소스 테이블 컬럼 로드 + useEffect(() => { + const allSourceTables = new Set(); + config.sections.forEach((section) => { + (section.linkedFieldGroups || []).forEach((group) => { + if (group.sourceTable) { + allSourceTables.add(group.sourceTable); + } + }); + }); + allSourceTables.forEach((tableName) => { + if (!tableColumns[tableName]) { + loadTableColumns(tableName); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.sections]); + const loadTables = async () => { try { const response = await apiClient.get("/table-management/tables"); @@ -842,6 +866,305 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} + {/* 연동 필드 그룹 설정 */} +
+
+ 연동 필드 그룹 + +
+

+ 부서코드/부서명 연동 저장 +

+ + {(selectedSection.linkedFieldGroups || []).length > 0 && ( +
+ {(selectedSection.linkedFieldGroups || []).map((group, groupIndex) => ( +
+
+ + #{groupIndex + 1} + + +
+ + {/* 라벨 */} +
+ + { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, label: e.target.value } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + placeholder="예: 겸직부서" + className="h-5 text-[9px] mt-0.5" + /> +
+ + {/* 소스 테이블 */} +
+ + +
+ + {/* 표시 형식 */} +
+ + +
+ + {/* 표시 컬럼 / 값 컬럼 */} +
+
+ + +
+
+ + +
+
+ + {/* 필드 매핑 */} +
+
+ + +
+ + {(group.mappings || []).map((mapping, mappingIndex) => ( +
+ + -> + + +
+ ))} +
+ + {/* 기타 옵션 */} +
+
+ { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, required: !!checked } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + className="h-3 w-3" + /> + +
+
+ + { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, gridSpan: parseInt(e.target.value) || 6 } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + className="h-4 w-8 text-[8px] px-1" + /> +
+
+
+ ))} +
+ )} +
+ {/* 필드 목록 */} diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 9da7b46c..5383512b 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -90,6 +90,27 @@ export const defaultSectionConfig = { itemTitle: "항목 {index}", confirmRemove: false, }, + linkedFieldGroups: [], +}; + +// 기본 연동 필드 그룹 설정 +export const defaultLinkedFieldGroupConfig = { + id: "", + label: "연동 필드", + sourceTable: "dept_info", + displayFormat: "code_name" as const, + displayColumn: "dept_name", + valueColumn: "dept_code", + mappings: [], + required: false, + placeholder: "선택하세요", + gridSpan: 6, +}; + +// 기본 연동 필드 매핑 설정 +export const defaultLinkedFieldMappingConfig = { + sourceColumn: "", + targetColumn: "", }; // 기본 채번규칙 설정 @@ -136,3 +157,8 @@ export const generateSectionId = (): string => { export const generateFieldId = (): string => { return generateUniqueId("field"); }; + +// 유틸리티: 연동 필드 그룹 ID 생성 +export const generateLinkedFieldGroupId = (): string => { + return generateUniqueId("linked"); +}; diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index e8d2ffd6..11ccfd25 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -96,6 +96,27 @@ export interface FormFieldConfig { }; } +// 연동 필드 매핑 설정 +export interface LinkedFieldMapping { + sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code") + targetColumn: string; // 저장할 컬럼 (예: "position_code") +} + +// 연동 필드 그룹 설정 (섹션 레벨) +// 하나의 드롭다운에서 선택 시 여러 컬럼에 자동 저장 +export interface LinkedFieldGroup { + id: string; + label: string; // 드롭다운 라벨 (예: "겸직부서") + sourceTable: string; // 소스 테이블 (예: "dept_info") + displayFormat: "name_only" | "code_name" | "name_code"; // 표시 형식 + displayColumn: string; // 표시할 컬럼 (예: "dept_name") + valueColumn: string; // 값으로 사용할 컬럼 (예: "dept_code") + mappings: LinkedFieldMapping[]; // 필드 매핑 목록 + required?: boolean; // 필수 여부 + placeholder?: string; // 플레이스홀더 + gridSpan?: number; // 그리드 스팬 (1-12) +} + // 반복 섹션 설정 export interface RepeatSectionConfig { minItems?: number; // 최소 항목 수 (기본: 0) @@ -119,6 +140,9 @@ export interface FormSectionConfig { repeatable?: boolean; repeatConfig?: RepeatSectionConfig; + // 연동 필드 그룹 (부서코드/부서명 등 연동 저장) + linkedFieldGroups?: LinkedFieldGroup[]; + // 섹션 레이아웃 columns?: number; // 필드 배치 컬럼 수 (기본: 2) gap?: string; // 필드 간 간격 @@ -257,3 +281,10 @@ export const SELECT_OPTION_TYPE_OPTIONS = [ { value: "table", label: "테이블 참조" }, { value: "code", label: "공통코드" }, ] as const; + +// 연동 필드 표시 형식 옵션 +export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [ + { value: "name_only", label: "이름만 (예: 영업부)" }, + { value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" }, + { value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" }, +] as const; From de1fe9865a31f4d721d777749739b419655f94a7 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 5 Dec 2025 17:25:12 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor(UniversalFormModal):=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=BB=AC=EB=9F=BC=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=ED=95=84=EB=93=9C=20=EB=A0=88=EB=B2=A8?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 섹션 레벨 linkedFieldGroups 제거, 필드 레벨 linkedFieldGroup으로 변경 - FormFieldConfig에 linkedFieldGroup 속성 추가 (enabled, sourceTable, displayColumn, displayFormat, mappings) - select 필드 렌더링에서 linkedFieldGroup 활성화 시 다중 컬럼 저장 처리 - API 응답 파싱 개선 (responseData.data 구조 지원) - 저장 실패 시 상세 에러 메시지 표시 - ConfigPanel에 다중 컬럼 저장 설정 UI 및 HelpText 추가 --- .../UniversalFormModalComponent.tsx | 299 ++++----- .../UniversalFormModalConfigPanel.tsx | 606 ++++++++---------- .../components/universal-form-modal/types.ts | 9 + 3 files changed, 384 insertions(+), 530 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 65e079ae..4f2f5c6b 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -33,7 +33,6 @@ import { FormDataState, RepeatSectionItem, SelectOptionConfig, - LinkedFieldGroup, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; @@ -121,6 +120,33 @@ export function UniversalFormModalComponent({ initializeForm(); }, [config, initialData]); + // 필드 레벨 linkedFieldGroup 데이터 로드 + useEffect(() => { + const loadData = async () => { + const tablesToLoad = new Set(); + + // 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집 + config.sections.forEach((section) => { + section.fields.forEach((field) => { + if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) { + tablesToLoad.add(field.linkedFieldGroup.sourceTable); + } + }); + }); + + // 각 테이블 데이터 로드 + for (const tableName of tablesToLoad) { + if (!linkedFieldDataCache[tableName]) { + console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`); + await loadLinkedFieldData(tableName); + } + } + }; + + loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.sections]); + // 폼 초기화 const initializeForm = useCallback(async () => { const newFormData: FormDataState = {}; @@ -364,18 +390,22 @@ export function UniversalFormModalComponent({ const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { page: 1, size: 1000, - autoFilter: true, // 현재 회사 기준 자동 필터링 + autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링 }); console.log(`[연동필드] ${sourceTable} API 응답:`, response.data); if (response.data?.success) { - // data가 배열인지 확인 + // data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] } const responseData = response.data?.data; if (Array.isArray(responseData)) { + // 직접 배열인 경우 data = responseData; + } else if (responseData?.data && Array.isArray(responseData.data)) { + // { data: [...], total: ... } 형태 (tableManagementService 응답) + data = responseData.data; } else if (responseData?.rows && Array.isArray(responseData.rows)) { - // { rows: [...], total: ... } 형태일 수 있음 + // { rows: [...], total: ... } 형태 (다른 API 응답) data = responseData.rows; } console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3)); @@ -394,79 +424,6 @@ export function UniversalFormModalComponent({ [linkedFieldDataCache], ); - // 연동 필드 그룹 선택 시 매핑된 필드에 값 설정 - const handleLinkedFieldSelect = useCallback( - ( - group: LinkedFieldGroup, - selectedValue: string, - sectionId: string, - repeatItemId?: string - ) => { - // 캐시에서 데이터 찾기 - const sourceData = linkedFieldDataCache[group.sourceTable] || []; - const selectedRow = sourceData.find( - (row) => String(row[group.valueColumn]) === selectedValue - ); - - if (!selectedRow) { - console.warn("선택된 항목을 찾을 수 없습니다:", selectedValue); - return; - } - - // 매핑된 필드에 값 설정 - if (repeatItemId) { - // 반복 섹션 내 아이템 업데이트 - setRepeatSections((prev) => { - const sectionItems = prev[sectionId] || []; - const updatedItems = sectionItems.map((item) => { - if (item._id === repeatItemId) { - const updatedItem = { ...item }; - for (const mapping of group.mappings) { - updatedItem[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; - } - return updatedItem; - } - return item; - }); - return { ...prev, [sectionId]: updatedItems }; - }); - } else { - // 일반 섹션 필드 업데이트 - setFormData((prev) => { - const newData = { ...prev }; - for (const mapping of group.mappings) { - newData[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; - } - if (onChange) { - setTimeout(() => onChange(newData), 0); - } - return newData; - }); - } - }, - [linkedFieldDataCache, onChange], - ); - - // 연동 필드 그룹 표시 텍스트 생성 - const getLinkedFieldDisplayText = useCallback( - (group: LinkedFieldGroup, row: Record): string => { - const code = row[group.valueColumn] || ""; - const name = row[group.displayColumn] || ""; - - switch (group.displayFormat) { - case "name_only": - return name; - case "code_name": - return `${code} - ${name}`; - case "name_code": - return `${name} (${code})`; - default: - return name; - } - }, - [], - ); - // 필수 필드 검증 const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { const missingFields: string[] = []; @@ -532,7 +489,13 @@ export function UniversalFormModalComponent({ } } catch (error: any) { console.error("저장 실패:", error); - toast.error(error.message || "저장에 실패했습니다."); + // axios 에러의 경우 서버 응답 메시지 추출 + const errorMessage = + error.response?.data?.message || + error.response?.data?.error?.details || + error.message || + "저장에 실패했습니다."; + toast.error(errorMessage); } finally { setSaving(false); } @@ -749,7 +712,88 @@ export function UniversalFormModalComponent({ ); - case "select": + case "select": { + // 다중 컬럼 저장이 활성화된 경우 + const lfgMappings = field.linkedFieldGroup?.mappings; + if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) { + const lfg = field.linkedFieldGroup; + const sourceTableName = lfg.sourceTable as string; + const cachedData = linkedFieldDataCache[sourceTableName]; + const sourceData = Array.isArray(cachedData) ? cachedData : []; + + // 첫 번째 매핑의 sourceColumn을 드롭다운 값으로 사용 + const valueColumn = lfgMappings[0].sourceColumn || ""; + + // 데이터 로드 (아직 없으면) + if (!cachedData && sourceTableName) { + loadLinkedFieldData(sourceTableName); + } + + // 표시 텍스트 생성 함수 + const getDisplayText = (row: Record): string => { + const displayVal = row[lfg.displayColumn || ""] || ""; + const valueVal = row[valueColumn] || ""; + switch (lfg.displayFormat) { + case "code_name": + return `${valueVal} - ${displayVal}`; + case "name_code": + return `${displayVal} (${valueVal})`; + case "name_only": + default: + return String(displayVal); + } + }; + + return ( + + ); + } + + // 일반 select 필드 return ( ); + } case "date": return ( @@ -854,64 +899,6 @@ export function UniversalFormModalComponent({ })(); }; - // 연동 필드 그룹 드롭다운 렌더링 - const renderLinkedFieldGroup = ( - group: LinkedFieldGroup, - sectionId: string, - repeatItemId?: string, - currentValue?: string, - sectionColumns: number = 2, - ) => { - const fieldKey = `linked_${group.id}_${repeatItemId || "main"}`; - const cachedData = linkedFieldDataCache[group.sourceTable]; - // 배열인지 확인하고, 아니면 빈 배열 사용 - const sourceData = Array.isArray(cachedData) ? cachedData : []; - const defaultSpan = Math.floor(12 / sectionColumns); - const actualGridSpan = sectionColumns === 1 ? 12 : group.gridSpan || defaultSpan; - - // 데이터 로드 (아직 없으면, 그리고 캐시에 없을 때만) - if (!cachedData && group.sourceTable) { - loadLinkedFieldData(group.sourceTable); - } - - return ( -
- - -
- ); - }; - // 섹션의 열 수에 따른 기본 gridSpan 계산 const getDefaultGridSpan = (sectionColumns: number = 2): number => { // 12칸 그리드 기준: 1열=12, 2열=6, 3열=4, 4열=3 @@ -999,18 +986,6 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} - {/* 연동 필드 그룹 렌더링 */} - {(section.linkedFieldGroups || []).map((group) => { - const firstMapping = group.mappings?.[0]; - const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; - return renderLinkedFieldGroup( - group, - section.id, - undefined, - currentValue ? String(currentValue) : undefined, - sectionColumns, - ); - })} @@ -1033,19 +1008,6 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} - {/* 연동 필드 그룹 렌더링 */} - {(section.linkedFieldGroups || []).map((group) => { - // 매핑된 첫 번째 타겟 컬럼의 현재 값을 찾아서 선택 상태 표시 - const firstMapping = group.mappings?.[0]; - const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; - return renderLinkedFieldGroup( - group, - section.id, - undefined, - currentValue ? String(currentValue) : undefined, - sectionColumns, - ); - })} @@ -1105,19 +1067,6 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} - {/* 연동 필드 그룹 렌더링 (반복 섹션 내) */} - {(section.linkedFieldGroups || []).map((group) => { - // 반복 섹션 아이템 내의 매핑된 첫 번째 타겟 컬럼 값 - const firstMapping = group.mappings?.[0]; - const currentValue = firstMapping ? item[firstMapping.targetColumn] : undefined; - return renderLinkedFieldGroup( - group, - section.id, - item._id, - currentValue ? String(currentValue) : undefined, - sectionColumns, - ); - })} ))} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index dc35a77e..acc53acc 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -37,7 +37,6 @@ import { UniversalFormModalConfigPanelProps, FormSectionConfig, FormFieldConfig, - LinkedFieldGroup, LinkedFieldMapping, FIELD_TYPE_OPTIONS, MODAL_SIZE_OPTIONS, @@ -49,11 +48,8 @@ import { defaultSectionConfig, defaultNumberingRuleConfig, defaultSelectOptionsConfig, - defaultLinkedFieldGroupConfig, - defaultLinkedFieldMappingConfig, generateSectionId, generateFieldId, - generateLinkedFieldGroupId, } from "./config"; // 도움말 텍스트 컴포넌트 @@ -93,13 +89,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.saveConfig.tableName]); - // 연동 필드 그룹의 소스 테이블 컬럼 로드 + // 다중 컬럼 저장의 소스 테이블 컬럼 로드 useEffect(() => { const allSourceTables = new Set(); config.sections.forEach((section) => { - (section.linkedFieldGroups || []).forEach((group) => { - if (group.sourceTable) { - allSourceTables.add(group.sourceTable); + // 필드 레벨의 linkedFieldGroup 확인 + section.fields.forEach((field) => { + if (field.linkedFieldGroup?.sourceTable) { + allSourceTables.add(field.linkedFieldGroup.sourceTable); } }); }); @@ -578,47 +575,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 겸직 등 반복 데이터가 있는 섹션 - - - -
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, typeColumn: e.target.value }, - }) - } - placeholder="employment_type" - className="h-6 text-[10px] mt-1" - /> - 메인/서브를 구분하는 컬럼명 -
-
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, mainTypeValue: e.target.value }, - }) - } - className="h-6 text-[10px] mt-1" - /> -
-
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, subTypeValue: e.target.value }, - }) - } - className="h-6 text-[10px] mt-1" - /> -
)} @@ -683,7 +639,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor { @@ -866,305 +822,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} - {/* 연동 필드 그룹 설정 */} -
-
- 연동 필드 그룹 - -
-

- 부서코드/부서명 연동 저장 -

- - {(selectedSection.linkedFieldGroups || []).length > 0 && ( -
- {(selectedSection.linkedFieldGroups || []).map((group, groupIndex) => ( -
-
- - #{groupIndex + 1} - - -
- - {/* 라벨 */} -
- - { - const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => - g.id === group.id ? { ...g, label: e.target.value } : g - ); - updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); - }} - placeholder="예: 겸직부서" - className="h-5 text-[9px] mt-0.5" - /> -
- - {/* 소스 테이블 */} -
- - -
- - {/* 표시 형식 */} -
- - -
- - {/* 표시 컬럼 / 값 컬럼 */} -
-
- - -
-
- - -
-
- - {/* 필드 매핑 */} -
-
- - -
- - {(group.mappings || []).map((mapping, mappingIndex) => ( -
- - -> - - -
- ))} -
- - {/* 기타 옵션 */} -
-
- { - const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => - g.id === group.id ? { ...g, required: !!checked } : g - ); - updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); - }} - className="h-3 w-3" - /> - -
-
- - { - const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => - g.id === group.id ? { ...g, gridSpan: parseInt(e.target.value) || 6 } : g - ); - updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); - }} - className="h-4 w-8 text-[8px] px-1" - /> -
-
-
- ))} -
- )} -
- {/* 필드 목록 */} @@ -1467,7 +1124,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* Select 옵션 설정 */} {selectedField.fieldType === "select" && (
- + + 드롭다운에 표시될 옵션 목록을 어디서 가져올지 설정합니다. + {selectedField.selectOptions?.type === "static" && ( + 직접 입력: 옵션을 수동으로 입력합니다. (현재 미구현 - 테이블 참조 사용 권장) + )} + {selectedField.selectOptions?.type === "table" && (
+ 테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.
- + + 예: dept_info (부서 테이블)
- + @@ -1530,12 +1194,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="code" + placeholder="dept_code" className="h-6 text-[10px] mt-1" /> + 선택 시 실제 저장되는 값 (예: D001)
- + @@ -1546,15 +1211,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="name" + placeholder="dept_name" className="h-6 text-[10px] mt-1" /> + 드롭다운에 보여질 텍스트 (예: 영업부)
)} {selectedField.selectOptions?.type === "code" && (
+ 공통코드: 공통코드 테이블에서 옵션을 가져옵니다. + 예: POSITION_CODE (직급), STATUS_CODE (상태) 등 +
+ )} +
+ )} + + {/* 다중 컬럼 저장 (select 타입만) */} + {selectedField.fieldType === "select" && ( +
+
+ 다중 컬럼 저장 + + updateField(selectedSection.id, selectedField.id, { + linkedFieldGroup: { + ...selectedField.linkedFieldGroup, + enabled: checked, + }, + }) + } + /> +
+ + 드롭다운 선택 시 여러 컬럼에 동시 저장합니다. +
예: 부서 선택 시 부서코드 + 부서명을 각각 다른 컬럼에 저장 +
+ + {selectedField.linkedFieldGroup?.enabled && ( +
+ {/* 소스 테이블 */} +
+ + + 드롭다운 옵션을 가져올 테이블 +
+ + {/* 표시 형식 */} +
+ + +
+ + {/* 표시 컬럼 / 값 컬럼 */} +
+
+ + + 사용자가 드롭다운에서 보게 될 텍스트 (예: 영업부, 개발부) +
+
+ + {/* 저장할 컬럼 매핑 */} +
+
+ + +
+ 드롭다운 선택 시 소스 테이블의 어떤 값을 어떤 컬럼에 저장할지 설정 + + {(selectedField.linkedFieldGroup?.mappings || []).map((mapping, mappingIndex) => ( +
+
+ 매핑 #{mappingIndex + 1} + +
+
+ + +
+
+ + +
+
+ ))} + + {(selectedField.linkedFieldGroup?.mappings || []).length === 0 && ( +

+ + 버튼을 눌러 매핑을 추가하세요 +

+ )} +
)}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 11ccfd25..de2526c2 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -74,6 +74,15 @@ export interface FormFieldConfig { // Select 옵션 selectOptions?: SelectOptionConfig; + // 다중 컬럼 저장 (드롭다운 선택 시 여러 컬럼에 동시 저장) + linkedFieldGroup?: { + enabled?: boolean; // 사용 여부 + sourceTable?: string; // 소스 테이블 (예: dept_info) + displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트 + displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식 + mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨) + }; + // 유효성 검사 validation?: FieldValidationConfig; From a5055cae154bc694adadcce8d2c8fd646a317b9b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 5 Dec 2025 18:15:20 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(SplitPanelLayout2):=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=EC=9D=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JoinTableConfig 타입 정의 (joinTable, joinType, mainColumn, joinColumn, selectColumns) - RightPanelConfig.joinTables 배열 추가로 다중 조인 지원 - loadJoinTableData(), mergeJoinData() 함수로 클라이언트 사이드 조인 처리 - JoinTableItem 컴포넌트로 조인 테이블 설정 UI 제공 - 표시 컬럼에 sourceTable 추가로 테이블별 컬럼 구분 - 메인+조인 테이블 컬럼 통합 로드 기능 --- .../SplitPanelLayout2Component.tsx | 110 +++- .../SplitPanelLayout2ConfigPanel.tsx | 563 ++++++++++++++++-- .../components/split-panel-layout2/types.ts | 33 + 3 files changed, 655 insertions(+), 51 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 0dd00543..e8400c49 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -7,6 +7,7 @@ import { ColumnConfig, DataTransferField, ActionButtonConfig, + JoinTableConfig, } from "./types"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; @@ -128,6 +129,94 @@ export const SplitPanelLayout2Component: React.FC> => { + const resultMap = new Map(); + if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) { + return resultMap; + } + + // 메인 데이터에서 조인할 키 값들 추출 + const joinKeys = [...new Set(mainData.map((item) => item[joinConfig.mainColumn]).filter(Boolean))]; + if (joinKeys.length === 0) return resultMap; + + try { + console.log(`[SplitPanelLayout2] 조인 테이블 로드: ${joinConfig.joinTable}, 키: ${joinKeys.length}개`); + + const response = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, { + page: 1, + size: 1000, + // 조인 키 값들로 필터링 + dataFilter: { + enabled: true, + matchType: "any", // OR 조건으로 여러 키 매칭 + filters: joinKeys.map((key, idx) => ({ + id: `join_key_${idx}`, + columnName: joinConfig.joinColumn, + operator: "equals", + value: String(key), + valueType: "static", + })), + }, + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (response.data.success) { + const joinData = response.data.data?.data || []; + // 조인 컬럼 값을 키로 하는 Map 생성 + joinData.forEach((item: any) => { + const key = item[joinConfig.joinColumn]; + if (key) { + resultMap.set(String(key), item); + } + }); + console.log(`[SplitPanelLayout2] 조인 테이블 로드 완료: ${joinData.length}건`); + } + } catch (error) { + console.error(`[SplitPanelLayout2] 조인 테이블 로드 실패 (${joinConfig.joinTable}):`, error); + } + + return resultMap; + }, []); + + // 메인 데이터에 조인 테이블 데이터 병합 + const mergeJoinData = useCallback(( + mainData: any[], + joinConfig: JoinTableConfig, + joinDataMap: Map + ): any[] => { + return mainData.map((item) => { + const joinKey = item[joinConfig.mainColumn]; + const joinRow = joinDataMap.get(String(joinKey)); + + if (joinRow && joinConfig.selectColumns) { + // 선택된 컬럼만 병합 + const mergedItem = { ...item }; + joinConfig.selectColumns.forEach((col) => { + // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명 + const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col; + // 메인 테이블에 같은 컬럼이 없으면 추가 + if (!(col in mergedItem)) { + mergedItem[col] = joinRow[col]; + } else if (joinConfig.alias) { + // 메인 테이블에 같은 컬럼이 있으면 alias로 추가 + mergedItem[targetKey] = joinRow[col]; + } + }); + return mergedItem; + } + + return item; + }); + }, []); + // 우측 데이터 로드 (좌측 선택 항목 기반) const loadRightData = useCallback(async (selectedItem: any) => { if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) { @@ -173,7 +262,24 @@ export const SplitPanelLayout2Component: React.FC 0 && data.length > 0) { + console.log(`[SplitPanelLayout2] 조인 테이블 처리 시작: ${joinTables.length}개`); + + for (const joinTableConfig of joinTables) { + const joinDataMap = await loadJoinTableData(joinTableConfig, data); + if (joinDataMap.size > 0) { + data = mergeJoinData(data, joinTableConfig, joinDataMap); + } + } + + console.log(`[SplitPanelLayout2] 조인 데이터 병합 완료`); + } + setRightData(data); console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`); } else { @@ -196,7 +302,7 @@ export const SplitPanelLayout2Component: React.FC { diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index da520d92..1a32f2ca 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -28,7 +28,7 @@ import { import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; -import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField } from "./types"; +import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig } from "./types"; // lodash set 대체 함수 const setPath = (obj: any, path: string, value: any): any => { @@ -245,6 +245,68 @@ export const SplitPanelLayout2ConfigPanel: React.FC { + const loadJoinTableColumns = async () => { + const joinTables = config.rightPanel?.joinTables || []; + if (joinTables.length === 0 || !config.rightPanel?.tableName) return; + + // 메인 테이블 컬럼 먼저 로드 + try { + const mainResponse = await apiClient.get(`/table-management/tables/${config.rightPanel.tableName}/columns?size=200`); + let mainColumns: ColumnInfo[] = []; + + if (mainResponse.data?.success) { + const columnList = mainResponse.data.data?.columns || mainResponse.data.data || []; + mainColumns = columnList.map((c: any) => ({ + column_name: c.columnName ?? c.column_name ?? c.name ?? "", + data_type: c.dataType ?? c.data_type ?? c.type ?? "", + column_comment: c.displayName ?? c.column_comment ?? c.label ?? "", + })); + } + + // 조인 테이블들의 선택된 컬럼 추가 + const joinColumns: ColumnInfo[] = []; + for (const jt of joinTables) { + if (jt.joinTable && jt.selectColumns && jt.selectColumns.length > 0) { + try { + const joinResponse = await apiClient.get(`/table-management/tables/${jt.joinTable}/columns?size=200`); + if (joinResponse.data?.success) { + const columnList = joinResponse.data.data?.columns || joinResponse.data.data || []; + const transformedColumns = columnList.map((c: any) => ({ + column_name: c.columnName ?? c.column_name ?? c.name ?? "", + data_type: c.dataType ?? c.data_type ?? c.type ?? "", + column_comment: c.displayName ?? c.column_comment ?? c.label ?? "", + })); + + // 선택된 컬럼 추가 (테이블명으로 구분) + jt.selectColumns.forEach((selCol) => { + const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol); + if (col) { + joinColumns.push({ + ...col, + column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`, + }); + } + }); + } + } catch (error) { + console.error(`조인 테이블 ${jt.joinTable} 컬럼 로드 실패:`, error); + } + } + } + + // 메인 + 조인 컬럼 합치기 + setRightColumns([...mainColumns, ...joinColumns]); + console.log(`[loadJoinTableColumns] 우측 컬럼 로드 완료: 메인 ${mainColumns.length}개 + 조인 ${joinColumns.length}개`); + } catch (error) { + console.error("조인 테이블 컬럼 로드 실패:", error); + } + }; + + loadJoinTableColumns(); + }, [config.rightPanel?.tableName, config.rightPanel?.joinTables]); + // 테이블 선택 컴포넌트 const TableSelect: React.FC<{ value: string; @@ -388,13 +450,28 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; placeholder: string; - }> = ({ columns, value, onValueChange, placeholder }) => { + showTableName?: boolean; // 테이블명 표시 여부 + tableName?: string; // 메인 테이블명 (조인 컬럼과 구분용) + }> = ({ columns, value, onValueChange, placeholder, showTableName = false, tableName }) => { // 현재 선택된 값의 라벨 찾기 const selectedColumn = columns.find((col) => col.column_name === value); const displayValue = selectedColumn ? selectedColumn.column_comment || selectedColumn.column_name : value || ""; + // 컬럼이 조인 테이블에서 온 것인지 확인 (column_comment에 괄호가 있으면 조인 테이블) + const isJoinColumn = (col: ColumnInfo) => col.column_comment?.includes("(") && col.column_comment?.includes(")"); + + // 컬럼 표시 텍스트 생성 + const getColumnDisplayText = (col: ColumnInfo) => { + const label = col.column_comment || col.column_name; + if (showTableName && tableName && !isJoinColumn(col)) { + // 메인 테이블 컬럼에 테이블명 추가 + return `${label} (${tableName})`; + } + return label; + }; + return ( onUpdate("joinType", value)} + > + + + + + LEFT JOIN (데이터 없어도 표시) + INNER JOIN (데이터 있어야만 표시) + + + + + {/* 조인 조건 */} +
+ +
+
+ + onUpdate("mainColumn", value)} + placeholder="메인 테이블 컬럼" + /> +
+
=
+
+ + onUpdate("joinColumn", value)} + placeholder="조인 테이블 컬럼" + /> +
+
+
+ + {/* 가져올 컬럼 선택 */} +
+
+ + +
+

+ 조인 테이블에서 표시할 컬럼들을 선택하세요 +

+
+ {(joinTable.selectColumns || []).map((col, colIndex) => ( +
+ { + const current = [...(joinTable.selectColumns || [])]; + current[colIndex] = value; + onUpdate("selectColumns", current); + }} + placeholder="컬럼 선택" + /> + +
+ ))} + {(joinTable.selectColumns || []).length === 0 && ( +
+ 가져올 컬럼을 추가하세요 +
+ )} +
+
+ + ); + }; + // 표시 컬럼 추가 const addDisplayColumn = (side: "left" | "right") => { const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; @@ -440,14 +742,25 @@ export const SplitPanelLayout2ConfigPanel: React.FC { + const updateDisplayColumn = ( + side: "left" | "right", + index: number, + fieldOrPartial: keyof ColumnConfig | Partial, + value?: any + ) => { const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; const currentColumns = side === "left" ? [...(config.leftPanel?.displayColumns || [])] : [...(config.rightPanel?.displayColumns || [])]; if (currentColumns[index]) { - currentColumns[index] = { ...currentColumns[index], [field]: value }; + if (typeof fieldOrPartial === "object") { + // 여러 필드를 한 번에 업데이트 + currentColumns[index] = { ...currentColumns[index], ...fieldOrPartial }; + } else { + // 단일 필드 업데이트 + currentColumns[index] = { ...currentColumns[index], [fieldOrPartial]: value }; + } updateConfig(path, currentColumns); } }; @@ -687,6 +1000,66 @@ export const SplitPanelLayout2ConfigPanel: React.FC + {/* 추가 조인 테이블 설정 */} +
+
+ + +
+

+ 다른 테이블을 조인하면 표시할 컬럼에서 해당 테이블의 컬럼도 선택할 수 있습니다. +

+
+ {(config.rightPanel?.joinTables || []).map((joinTable, index) => ( + { + const current = [...(config.rightPanel?.joinTables || [])]; + if (typeof fieldOrPartial === "object") { + // 여러 필드를 한 번에 업데이트 + current[index] = { ...current[index], ...fieldOrPartial }; + } else { + // 단일 필드 업데이트 + current[index] = { ...current[index], [fieldOrPartial]: value }; + } + updateConfig("rightPanel.joinTables", current); + }} + onRemove={() => { + const current = config.rightPanel?.joinTables || []; + updateConfig( + "rightPanel.joinTables", + current.filter((_, i) => i !== index) + ); + }} + /> + ))} +
+
+ {/* 표시 컬럼 */}
@@ -696,52 +1069,144 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+

+ 테이블을 선택한 후 해당 테이블의 컬럼을 선택하세요. +

- {(config.rightPanel?.displayColumns || []).map((col, index) => ( -
-
- 컬럼 {index + 1} - + {(config.rightPanel?.displayColumns || []).map((col, index) => { + // 선택 가능한 테이블 목록: 메인 테이블 + 조인 테이블들 + const availableTables = [ + config.rightPanel?.tableName, + ...(config.rightPanel?.joinTables || []).map((jt) => jt.joinTable), + ].filter(Boolean) as string[]; + + // 선택된 테이블의 컬럼만 필터링 + const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName; + const filteredColumns = rightColumns.filter((c) => { + // 조인 테이블 컬럼인지 확인 (column_comment에 테이블명 포함) + const isJoinColumn = c.column_comment?.includes("(") && c.column_comment?.includes(")"); + + if (selectedSourceTable === config.rightPanel?.tableName) { + // 메인 테이블 선택 시: 조인 컬럼 아닌 것만 + return !isJoinColumn; + } else { + // 조인 테이블 선택 시: 해당 테이블 컬럼만 + return c.column_comment?.includes(`(${selectedSourceTable})`); + } + }); + + // 테이블 라벨 가져오기 + const getTableLabel = (tableName: string) => { + const table = tables.find((t) => t.table_name === tableName); + return table?.table_comment || tableName; + }; + + return ( +
+
+ 컬럼 {index + 1} + +
+ + {/* 테이블 선택 */} +
+ + +
+ + {/* 컬럼 선택 */} +
+ + +
+ + {/* 표시 라벨 */} +
+ + updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
+ + {/* 표시 위치 */} +
+ + +
- updateDisplayColumn("right", index, "name", value)} - placeholder="컬럼 선택" - /> -
- - updateDisplayColumn("right", index, "label", e.target.value)} - placeholder="라벨명 (미입력 시 컬럼명 사용)" - className="h-8 text-xs" - /> -
-
- - -
-
- ))} + ); + })} {(config.rightPanel?.displayColumns || []).length === 0 && (
표시할 컬럼을 추가하세요 diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index 872563df..4c9f7cae 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -9,6 +9,7 @@ export interface ColumnConfig { name: string; // 컬럼명 label: string; // 표시 라벨 + sourceTable?: string; // 소스 테이블명 (메인 테이블 또는 조인 테이블) displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행) width?: number; // 너비 (px) bold?: boolean; // 굵게 표시 @@ -94,6 +95,17 @@ export interface RightPanelConfig { actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id) emptyMessage?: string; // 데이터 없을 때 메시지 + + /** + * 추가 조인 테이블 설정 + * 메인 테이블에 다른 테이블을 JOIN하여 추가 정보를 함께 표시합니다. + * + * 사용 예시: + * - 메인 테이블: user_dept (부서-사용자 관계) + * - 조인 테이블: user_info (사용자 개인정보) + * - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시 + */ + joinTables?: JoinTableConfig[]; } /** @@ -104,6 +116,27 @@ export interface JoinConfig { rightColumn: string; // 우측 테이블의 조인 컬럼 } +/** + * 추가 조인 테이블 설정 + * 우측 패널의 메인 테이블에 다른 테이블을 JOIN하여 추가 컬럼을 가져옵니다. + * + * 예시: user_dept (메인) + user_info (조인) → 부서관계 + 개인정보 함께 표시 + * + * - joinTable: 조인할 테이블명 (예: user_info) + * - joinType: 조인 방식 (LEFT JOIN 권장) + * - mainColumn: 메인 테이블의 조인 컬럼 (예: user_id) + * - joinColumn: 조인 테이블의 조인 컬럼 (예: user_id) + * - selectColumns: 조인 테이블에서 가져올 컬럼들 (예: email, cell_phone) + */ +export interface JoinTableConfig { + joinTable: string; // 조인할 테이블명 + joinType: "LEFT" | "INNER"; // 조인 타입 (LEFT: 없어도 표시, INNER: 있어야만 표시) + mainColumn: string; // 메인 테이블의 조인 컬럼 + joinColumn: string; // 조인 테이블의 조인 컬럼 + selectColumns: string[]; // 조인 테이블에서 가져올 컬럼들 + alias?: string; // 테이블 별칭 (중복 컬럼명 구분용) +} + /** * 메인 설정 */ From 892278853ce28b47174a9dd70ccb0db7e1d9be51 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 8 Dec 2025 11:33:35 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(UniversalFormModal):=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20API=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AC=EC=9B=90+=EB=B6=80=EC=84=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EC=A0=80=EC=9E=A5=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomApiSaveConfig 타입 정의 (apiType, mainDeptFields, subDeptFields) - saveWithCustomApi() 함수 추가로 테이블 직접 저장 대신 전용 API 호출 - adminController에 saveUserWithDept(), getUserWithDept() API 추가 - user_info + user_dept 트랜잭션 저장, 메인 부서 변경 시 자동 겸직 전환 - ConfigPanel에 전용 API 저장 설정 UI 추가 - SplitPanelLayout2: getColumnValue()로 조인 테이블 컬럼 값 추출 개선 - 검색 컬럼 선택 시 표시 컬럼 기반으로 변경 --- .../src/controllers/adminController.ts | 394 +++++++++++++++- backend-node/src/routes/adminRoutes.ts | 4 + frontend/lib/api/user.ts | 124 +++++ .../SplitPanelLayout2Component.tsx | 48 +- .../SplitPanelLayout2ConfigPanel.tsx | 145 ++++-- .../UniversalFormModalComponent.tsx | 310 +++++++++---- .../UniversalFormModalConfigPanel.tsx | 433 +++++++++++++++--- .../components/universal-form-modal/types.ts | 41 ++ 8 files changed, 1311 insertions(+), 188 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 3ac5d26b..5bcda820 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3,7 +3,7 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { Client } from "pg"; -import { query, queryOne } from "../database/db"; +import { query, queryOne, getPool } from "../database/db"; import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; @@ -3406,3 +3406,395 @@ export async function copyMenu( }); } } + +/** + * ============================================================ + * 사원 + 부서 통합 관리 API + * ============================================================ + * + * 사원 정보(user_info)와 부서 관계(user_dept)를 트랜잭션으로 동시 저장합니다. + * + * ## 핵심 기능 + * 1. user_info 테이블에 사원 개인정보 저장 + * 2. user_dept 테이블에 메인 부서 + 겸직 부서 저장 + * 3. 메인 부서 변경 시 기존 메인 → 겸직으로 자동 전환 + * 4. 트랜잭션으로 데이터 정합성 보장 + * + * ## 요청 데이터 구조 + * ```json + * { + * "userInfo": { + * "user_id": "string (필수)", + * "user_name": "string (필수)", + * "email": "string", + * "cell_phone": "string", + * "sabun": "string", + * ... + * }, + * "mainDept": { + * "dept_code": "string (필수)", + * "dept_name": "string", + * "position_name": "string" + * }, + * "subDepts": [ + * { + * "dept_code": "string (필수)", + * "dept_name": "string", + * "position_name": "string" + * } + * ] + * } + * ``` + */ + +// 사원 + 부서 저장 요청 타입 +interface UserWithDeptRequest { + userInfo: { + user_id: string; + user_name: string; + user_name_eng?: string; + user_password?: string; + email?: string; + tel?: string; + cell_phone?: string; + sabun?: string; + user_type?: string; + user_type_name?: string; + status?: string; + locale?: string; + // 메인 부서 정보 (user_info에도 저장) + dept_code?: string; + dept_name?: string; + position_code?: string; + position_name?: string; + }; + mainDept?: { + dept_code: string; + dept_name?: string; + position_name?: string; + }; + subDepts?: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + }>; + isUpdate?: boolean; // 수정 모드 여부 +} + +/** + * POST /api/admin/users/with-dept + * 사원 + 부서 통합 저장 API + */ +export const saveUserWithDept = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + const client = await getPool().connect(); + + try { + const { userInfo, mainDept, subDepts = [], isUpdate = false } = req.body as UserWithDeptRequest; + const companyCode = req.user?.companyCode || "*"; + const currentUserId = req.user?.userId; + + logger.info("사원+부서 통합 저장 요청", { + userId: userInfo?.user_id, + mainDept: mainDept?.dept_code, + subDeptsCount: subDepts.length, + isUpdate, + companyCode, + }); + + // 필수값 검증 + if (!userInfo?.user_id || !userInfo?.user_name) { + res.status(400).json({ + success: false, + message: "사용자 ID와 이름은 필수입니다.", + error: { code: "REQUIRED_FIELD_MISSING" }, + }); + return; + } + + // 트랜잭션 시작 + await client.query("BEGIN"); + + // 1. 기존 사용자 확인 + const existingUser = await client.query( + "SELECT user_id FROM user_info WHERE user_id = $1", + [userInfo.user_id] + ); + const isExistingUser = existingUser.rows.length > 0; + + // 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우) + let encryptedPassword = null; + if (userInfo.user_password) { + encryptedPassword = await EncryptUtil.encrypt(userInfo.user_password); + } + + // 3. user_info 저장 (UPSERT) + // mainDept가 있으면 user_info에도 메인 부서 정보 저장 + const deptCode = mainDept?.dept_code || userInfo.dept_code || null; + const deptName = mainDept?.dept_name || userInfo.dept_name || null; + const positionName = mainDept?.position_name || userInfo.position_name || null; + + if (isExistingUser) { + // 기존 사용자 수정 + const updateFields: string[] = []; + const updateValues: any[] = []; + let paramIndex = 1; + + // 동적으로 업데이트할 필드 구성 + const fieldsToUpdate: Record = { + user_name: userInfo.user_name, + user_name_eng: userInfo.user_name_eng, + email: userInfo.email, + tel: userInfo.tel, + cell_phone: userInfo.cell_phone, + sabun: userInfo.sabun, + user_type: userInfo.user_type, + user_type_name: userInfo.user_type_name, + status: userInfo.status || "active", + locale: userInfo.locale, + dept_code: deptCode, + dept_name: deptName, + position_code: userInfo.position_code, + position_name: positionName, + company_code: companyCode !== "*" ? companyCode : undefined, + }; + + // 비밀번호가 제공된 경우에만 업데이트 + if (encryptedPassword) { + fieldsToUpdate.user_password = encryptedPassword; + } + + for (const [key, value] of Object.entries(fieldsToUpdate)) { + if (value !== undefined) { + updateFields.push(`${key} = $${paramIndex}`); + updateValues.push(value); + paramIndex++; + } + } + + if (updateFields.length > 0) { + updateValues.push(userInfo.user_id); + await client.query( + `UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`, + updateValues + ); + } + } else { + // 새 사용자 등록 + await client.query( + `INSERT INTO user_info ( + user_id, user_name, user_name_eng, user_password, + email, tel, cell_phone, sabun, + user_type, user_type_name, status, locale, + dept_code, dept_name, position_code, position_name, + company_code, regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`, + [ + userInfo.user_id, + userInfo.user_name, + userInfo.user_name_eng || null, + encryptedPassword || null, + userInfo.email || null, + userInfo.tel || null, + userInfo.cell_phone || null, + userInfo.sabun || null, + userInfo.user_type || null, + userInfo.user_type_name || null, + userInfo.status || "active", + userInfo.locale || null, + deptCode, + deptName, + userInfo.position_code || null, + positionName, + companyCode !== "*" ? companyCode : null, + ] + ); + } + + // 4. user_dept 처리 + if (mainDept?.dept_code || subDepts.length > 0) { + // 4-1. 기존 부서 관계 조회 (메인 부서 변경 감지용) + const existingDepts = await client.query( + "SELECT dept_code, is_primary FROM user_dept WHERE user_id = $1", + [userInfo.user_id] + ); + const existingMainDept = existingDepts.rows.find((d: any) => d.is_primary === true); + + // 4-2. 메인 부서가 변경된 경우, 기존 메인 부서를 겸직으로 전환 + if (mainDept?.dept_code && existingMainDept && existingMainDept.dept_code !== mainDept.dept_code) { + logger.info("메인 부서 변경 감지 - 기존 메인을 겸직으로 전환", { + userId: userInfo.user_id, + oldMain: existingMainDept.dept_code, + newMain: mainDept.dept_code, + }); + + await client.query( + "UPDATE user_dept SET is_primary = false, updated_at = NOW() WHERE user_id = $1 AND dept_code = $2", + [userInfo.user_id, existingMainDept.dept_code] + ); + } + + // 4-3. 기존 겸직 부서 삭제 (메인 제외) + // 새로 입력받은 subDepts로 교체하기 위해 기존 겸직 삭제 + await client.query( + "DELETE FROM user_dept WHERE user_id = $1 AND is_primary = false", + [userInfo.user_id] + ); + + // 4-4. 메인 부서 저장 (UPSERT) + if (mainDept?.dept_code) { + await client.query( + `INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at) + VALUES ($1, $2, true, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (user_id, dept_code) DO UPDATE SET + is_primary = true, + dept_name = $3, + user_name = $4, + position_name = $5, + company_code = $6, + updated_at = NOW()`, + [ + userInfo.user_id, + mainDept.dept_code, + mainDept.dept_name || null, + userInfo.user_name, + mainDept.position_name || null, + companyCode !== "*" ? companyCode : null, + ] + ); + } + + // 4-5. 겸직 부서 저장 + for (const subDept of subDepts) { + if (!subDept.dept_code) continue; + + // 메인 부서와 같은 부서는 겸직으로 추가하지 않음 + if (mainDept?.dept_code === subDept.dept_code) continue; + + await client.query( + `INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at) + VALUES ($1, $2, false, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (user_id, dept_code) DO UPDATE SET + is_primary = false, + dept_name = $3, + user_name = $4, + position_name = $5, + company_code = $6, + updated_at = NOW()`, + [ + userInfo.user_id, + subDept.dept_code, + subDept.dept_name || null, + userInfo.user_name, + subDept.position_name || null, + companyCode !== "*" ? companyCode : null, + ] + ); + } + } + + // 트랜잭션 커밋 + await client.query("COMMIT"); + + logger.info("사원+부서 통합 저장 완료", { + userId: userInfo.user_id, + isUpdate: isExistingUser, + }); + + res.json({ + success: true, + message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.", + data: { + userId: userInfo.user_id, + isUpdate: isExistingUser, + }, + }); + } catch (error: any) { + // 트랜잭션 롤백 + await client.query("ROLLBACK"); + + logger.error("사원+부서 통합 저장 실패", { error: error.message, body: req.body }); + + // 중복 키 에러 처리 + if (error.code === "23505") { + res.status(400).json({ + success: false, + message: "이미 존재하는 사용자 ID입니다.", + error: { code: "DUPLICATE_USER_ID" }, + }); + return; + } + + res.status(500).json({ + success: false, + message: "사원 저장 중 오류가 발생했습니다.", + error: { code: "SAVE_ERROR", details: error.message }, + }); + } finally { + client.release(); + } +} + +/** + * GET /api/admin/users/:userId/with-dept + * 사원 + 부서 정보 조회 API (수정 모달용) + */ +export const getUserWithDept = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { userId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + logger.info("사원+부서 조회 요청", { userId, companyCode }); + + // 1. user_info 조회 + let userQuery = "SELECT * FROM user_info WHERE user_id = $1"; + const userParams: any[] = [userId]; + + // 최고 관리자가 아니면 회사 필터링 + if (companyCode !== "*") { + userQuery += " AND company_code = $2"; + userParams.push(companyCode); + } + + const userResult = await query(userQuery, userParams); + + if (userResult.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + error: { code: "USER_NOT_FOUND" }, + }); + return; + } + + const userInfo = userResult[0]; + + // 2. user_dept 조회 (메인 + 겸직) + let deptQuery = "SELECT * FROM user_dept WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC"; + const deptResult = await query(deptQuery, [userId]); + + const mainDept = deptResult.find((d: any) => d.is_primary === true); + const subDepts = deptResult.filter((d: any) => d.is_primary === false); + + res.json({ + success: true, + data: { + userInfo, + mainDept: mainDept || null, + subDepts, + }, + }); + } catch (error: any) { + logger.error("사원+부서 조회 실패", { error: error.message, userId: req.params.userId }); + res.status(500).json({ + success: false, + message: "사원 조회 중 오류가 발생했습니다.", + error: { code: "QUERY_ERROR", details: error.message }, + }); + } +} diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 188e5580..b9964962 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -18,6 +18,8 @@ import { getDepartmentList, // 부서 목록 조회 checkDuplicateUserId, // 사용자 ID 중복 체크 saveUser, // 사용자 등록/수정 + saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!) + getUserWithDept, // 사원 + 부서 조회 (NEW!) getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyByCode, // 회사 단건 조회 @@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 router.get("/users", getUserList); router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 +router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!) router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 router.post("/users", saveUser); // 사용자 등록/수정 (기존) +router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!) router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index 83c725c2..6a829042 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -183,6 +183,127 @@ export async function checkDuplicateUserId(userId: string) { return response.data; } +// ============================================================ +// 사원 + 부서 통합 관리 API +// ============================================================ + +/** + * 사원 + 부서 정보 저장 요청 타입 + */ +export interface SaveUserWithDeptRequest { + userInfo: { + user_id: string; + user_name: string; + user_name_eng?: string; + user_password?: string; + email?: string; + tel?: string; + cell_phone?: string; + sabun?: string; + user_type?: string; + user_type_name?: string; + status?: string; + locale?: string; + dept_code?: string; + dept_name?: string; + position_code?: string; + position_name?: string; + }; + mainDept?: { + dept_code: string; + dept_name?: string; + position_name?: string; + }; + subDepts?: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + }>; + isUpdate?: boolean; +} + +/** + * 사원 + 부서 정보 응답 타입 + */ +export interface UserWithDeptResponse { + userInfo: Record; + mainDept: { + dept_code: string; + dept_name?: string; + position_name?: string; + is_primary: boolean; + } | null; + subDepts: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + is_primary: boolean; + }>; +} + +/** + * 사원 + 부서 통합 저장 + * + * user_info와 user_dept 테이블에 트랜잭션으로 동시 저장합니다. + * - 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환 + * - 겸직 부서는 전체 삭제 후 재입력 방식 + * + * @param data 저장할 사원 및 부서 정보 + * @returns 저장 결과 + */ +export async function saveUserWithDept(data: SaveUserWithDeptRequest): Promise> { + try { + console.log("사원+부서 통합 저장 API 호출:", data); + + const response = await apiClient.post("/admin/users/with-dept", data); + + console.log("사원+부서 통합 저장 API 응답:", response.data); + return response.data; + } catch (error: any) { + console.error("사원+부서 통합 저장 API 오류:", error); + + // Axios 에러 응답 처리 + if (error.response?.data) { + return error.response.data; + } + + return { + success: false, + message: error.message || "사원 저장 중 오류가 발생했습니다.", + }; + } +} + +/** + * 사원 + 부서 정보 조회 (수정 모달용) + * + * user_info와 user_dept 정보를 함께 조회합니다. + * + * @param userId 조회할 사용자 ID + * @returns 사원 정보 및 부서 관계 정보 + */ +export async function getUserWithDept(userId: string): Promise> { + try { + console.log("사원+부서 조회 API 호출:", userId); + + const response = await apiClient.get(`/admin/users/${userId}/with-dept`); + + console.log("사원+부서 조회 API 응답:", response.data); + return response.data; + } catch (error: any) { + console.error("사원+부서 조회 API 오류:", error); + + if (error.response?.data) { + return error.response.data; + } + + return { + success: false, + message: error.message || "사원 조회 중 오류가 발생했습니다.", + }; + } +} + // 사용자 API 객체로 export export const userAPI = { getList: getUserList, @@ -195,4 +316,7 @@ export const userAPI = { getCompanyList: getCompanyList, getDepartmentList: getDepartmentList, checkDuplicateId: checkDuplicateUserId, + // 사원 + 부서 통합 관리 + saveWithDept: saveUserWithDept, + getWithDept: getUserWithDept, }; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index e8400c49..3bdd2015 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -200,7 +200,11 @@ export const SplitPanelLayout2Component: React.FC { - // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명 + // 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용) + const tableColumnKey = `${joinConfig.joinTable}.${col}`; + mergedItem[tableColumnKey] = joinRow[col]; + + // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성) const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col; // 메인 테이블에 같은 컬럼이 없으면 추가 if (!(col in mergedItem)) { @@ -210,6 +214,7 @@ export const SplitPanelLayout2Component: React.FC { + // col.name이 "테이블명.컬럼명" 형식인 경우 처리 + const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null; + const effectiveSourceTable = col.sourceTable || tableFromName; + + // sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우 + if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) { + // 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식) + const tableColumnKey = `${effectiveSourceTable}.${actualColName}`; + if (item[tableColumnKey] !== undefined) { + return item[tableColumnKey]; + } + // 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도 + const joinTable = config.rightPanel?.joinTables?.find(jt => jt.joinTable === effectiveSourceTable); + if (joinTable?.alias) { + const aliasKey = `${joinTable.alias}_${actualColName}`; + if (item[aliasKey] !== undefined) { + return item[aliasKey]; + } + } + // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) + if (item[actualColName] !== undefined) { + return item[actualColName]; + } + } + // 4. 기본: 컬럼명으로 직접 접근 + return item[actualColName]; + }, [config.rightPanel?.tableName, config.rightPanel?.joinTables]); + // 값 포맷팅 const formatValue = (value: any, format?: ColumnConfig["format"]): string => { if (value === null || value === undefined) return "-"; @@ -916,7 +952,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{nameRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -931,7 +967,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{infoRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -950,7 +986,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{nameRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; if (idx === 0) { return ( @@ -971,7 +1007,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{infoRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -1079,7 +1115,7 @@ export const SplitPanelLayout2Component: React.FC ( - {formatValue(item[col.name], col.format)} + {formatValue(getColumnValue(item, col), col.format)} ))} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 1a32f2ca..c875316a 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -279,12 +279,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC { const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol); if (col) { joinColumns.push({ ...col, + // 유니크 키를 위해 테이블명_컬럼명 형태로 저장 + column_name: `${jt.joinTable}.${col.column_name}`, column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`, }); } @@ -727,8 +729,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC { - // 조인 테이블 컬럼인지 확인 (column_comment에 테이블명 포함) - const isJoinColumn = c.column_comment?.includes("(") && c.column_comment?.includes(")"); + // 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태) + const isJoinColumn = c.column_name.includes("."); if (selectedSourceTable === config.rightPanel?.tableName) { // 메인 테이블 선택 시: 조인 컬럼 아닌 것만 return !isJoinColumn; } else { - // 조인 테이블 선택 시: 해당 테이블 컬럼만 - return c.column_comment?.includes(`(${selectedSourceTable})`); + // 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태) + return c.column_name.startsWith(`${selectedSourceTable}.`); } }); @@ -1163,11 +1170,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC { // 조인 컬럼의 경우 테이블명 제거하고 표시 const displayLabel = c.column_comment?.replace(/\s*\([^)]+\)$/, "") || c.column_name; + // 실제 컬럼명 (테이블명.컬럼명에서 컬럼명만 추출) + const actualColumnName = c.column_name.includes(".") + ? c.column_name.split(".")[1] + : c.column_name; return ( {displayLabel} - {c.column_name} + {actualColumnName} ); @@ -1231,6 +1242,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC { const current = config.rightPanel?.searchColumns || []; updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]); @@ -1240,36 +1252,99 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+

+ 표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요. +

- {(config.rightPanel?.searchColumns || []).map((searchCol, index) => ( -
- { - const current = [...(config.rightPanel?.searchColumns || [])]; - current[index] = { ...current[index], columnName: value }; - updateConfig("rightPanel.searchColumns", current); - }} - placeholder="컬럼 선택" - /> - + {(config.rightPanel?.searchColumns || []).map((searchCol, index) => { + // 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시 + const displayColumns = config.rightPanel?.displayColumns || []; + + // 유효한 컬럼만 필터링 (name이 있는 것만) + const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== ""); + + // 현재 선택된 컬럼의 표시 정보 + const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName); + const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName); + const selectedLabel = selectedDisplayCol?.label || + selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || + searchCol.columnName; + const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || ""; + const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName; + + return ( +
+ + +
+ ); + })} + {(config.rightPanel?.displayColumns || []).length === 0 && ( +
+ 먼저 표시할 컬럼을 추가하세요
- ))} - {(config.rightPanel?.searchColumns || []).length === 0 && ( + )} + {(config.rightPanel?.displayColumns || []).length > 0 && (config.rightPanel?.searchColumns || []).length === 0 && (
검색할 컬럼을 추가하세요
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 4f2f5c6b..3938645d 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -444,65 +444,8 @@ export function UniversalFormModalComponent({ return { valid: missingFields.length === 0, missingFields }; }, [config.sections, formData]); - // 저장 처리 - const handleSave = useCallback(async () => { - if (!config.saveConfig.tableName) { - toast.error("저장할 테이블이 설정되지 않았습니다."); - return; - } - - // 필수 필드 검증 - const { valid, missingFields } = validateRequiredFields(); - if (!valid) { - toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`); - return; - } - - setSaving(true); - - try { - const { multiRowSave } = config.saveConfig; - - if (multiRowSave?.enabled) { - // 다중 행 저장 - await saveMultipleRows(); - } else { - // 단일 행 저장 - await saveSingleRow(); - } - - // 저장 후 동작 - if (config.saveConfig.afterSave?.showToast) { - toast.success("저장되었습니다."); - } - - if (config.saveConfig.afterSave?.refreshParent) { - window.dispatchEvent(new CustomEvent("refreshParentData")); - } - - // onSave 콜백은 저장 완료 알림용으로만 사용 - // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows) - // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록 - // _saveCompleted 플래그를 포함하여 전달 - if (onSave) { - onSave({ ...formData, _saveCompleted: true }); - } - } catch (error: any) { - console.error("저장 실패:", error); - // axios 에러의 경우 서버 응답 메시지 추출 - const errorMessage = - error.response?.data?.message || - error.response?.data?.error?.details || - error.message || - "저장에 실패했습니다."; - toast.error(errorMessage); - } finally { - setSaving(false); - } - }, [config, formData, repeatSections, onSave, validateRequiredFields]); - // 단일 행 저장 - const saveSingleRow = async () => { + const saveSingleRow = useCallback(async () => { const dataToSave = { ...formData }; // 메타데이터 필드 제거 @@ -534,15 +477,15 @@ export function UniversalFormModalComponent({ if (!response.data?.success) { throw new Error(response.data?.message || "저장 실패"); } - }; + }, [config.sections, config.saveConfig.tableName, formData]); // 다중 행 저장 (겸직 등) - const saveMultipleRows = async () => { + const saveMultipleRows = useCallback(async () => { const { multiRowSave } = config.saveConfig; if (!multiRowSave) return; - let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = - multiRowSave; + let { commonFields = [], repeatSectionId = "" } = multiRowSave; + const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave; // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용 if (commonFields.length === 0) { @@ -563,56 +506,57 @@ export function UniversalFormModalComponent({ // 디버깅: 설정 확인 console.log("[UniversalFormModal] 다중 행 저장 설정:", { commonFields, - mainSectionFields, repeatSectionId, + mainSectionFields, typeColumn, mainTypeValue, subTypeValue, + repeatSections, + formData, }); - console.log("[UniversalFormModal] 현재 formData:", formData); - // 공통 필드 데이터 추출 - const commonData: Record = {}; - for (const fieldName of commonFields) { + // 반복 섹션 데이터 + const repeatItems = repeatSections[repeatSectionId] || []; + + // 저장할 행들 생성 + const rowsToSave: any[] = []; + + // 공통 데이터 (모든 행에 적용) + const commonData: any = {}; + commonFields.forEach((fieldName) => { if (formData[fieldName] !== undefined) { commonData[fieldName] = formData[fieldName]; } - } - console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData); + }); - // 메인 섹션 필드 데이터 추출 - const mainSectionData: Record = {}; - if (mainSectionFields && mainSectionFields.length > 0) { - for (const fieldName of mainSectionFields) { - if (formData[fieldName] !== undefined) { - mainSectionData[fieldName] = formData[fieldName]; - } + // 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등) + const mainSectionData: any = {}; + mainSectionFields.forEach((fieldName) => { + if (formData[fieldName] !== undefined) { + mainSectionData[fieldName] = formData[fieldName]; } - } - console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData); + }); - // 저장할 행들 준비 - const rowsToSave: Record[] = []; + console.log("[UniversalFormModal] 공통 데이터:", commonData); + console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData); + console.log("[UniversalFormModal] 반복 항목:", repeatItems); - // 1. 메인 행 생성 - const mainRow: Record = { - ...commonData, - ...mainSectionData, - }; + // 메인 행 (공통 데이터 + 메인 섹션 필드) + const mainRow: any = { ...commonData, ...mainSectionData }; if (typeColumn) { mainRow[typeColumn] = mainTypeValue || "main"; } rowsToSave.push(mainRow); - // 2. 반복 섹션 행들 생성 (겸직 등) - const repeatItems = repeatSections[repeatSectionId] || []; + // 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드) for (const item of repeatItems) { - const subRow: Record = { ...commonData }; + const subRow: any = { ...commonData }; - // 반복 섹션 필드 복사 - Object.keys(item).forEach((key) => { - if (!key.startsWith("_")) { - subRow[key] = item[key]; + // 반복 섹션의 필드 값 추가 + const repeatSection = config.sections.find((s) => s.id === repeatSectionId); + repeatSection?.fields.forEach((field) => { + if (item[field.columnName] !== undefined) { + subRow[field.columnName] = item[field.columnName]; } }); @@ -666,7 +610,187 @@ export function UniversalFormModalComponent({ } console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`); - }; + }, [config.sections, config.saveConfig, formData, repeatSections]); + + // 커스텀 API 저장 (사원+부서 통합 저장 등) + const saveWithCustomApi = useCallback(async () => { + const { customApiSave } = config.saveConfig; + if (!customApiSave) return; + + console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType); + + const saveUserWithDeptApi = async () => { + const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave; + + // 1. userInfo 데이터 구성 + const userInfo: Record = {}; + + // 모든 필드에서 user_info에 해당하는 데이터 추출 + config.sections.forEach((section) => { + if (section.repeatable) return; // 반복 섹션은 제외 + + section.fields.forEach((field) => { + const value = formData[field.columnName]; + if (value !== undefined && value !== null && value !== "") { + userInfo[field.columnName] = value; + } + }); + }); + + // 2. mainDept 데이터 구성 + let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined; + + if (mainDeptFields) { + const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"]; + if (deptCode) { + mainDept = { + dept_code: deptCode, + dept_name: formData[mainDeptFields.deptNameField || "dept_name"], + position_name: formData[mainDeptFields.positionNameField || "position_name"], + }; + } + } + + // 3. subDepts 데이터 구성 (반복 섹션에서) + const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = []; + + if (subDeptSectionId && repeatSections[subDeptSectionId]) { + const subDeptItems = repeatSections[subDeptSectionId]; + const deptCodeField = subDeptFields?.deptCodeField || "dept_code"; + const deptNameField = subDeptFields?.deptNameField || "dept_name"; + const positionNameField = subDeptFields?.positionNameField || "position_name"; + + subDeptItems.forEach((item) => { + const deptCode = item[deptCodeField]; + if (deptCode) { + subDepts.push({ + dept_code: deptCode, + dept_name: item[deptNameField], + position_name: item[positionNameField], + }); + } + }); + } + + // 4. API 호출 + console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts }); + + const { saveUserWithDept } = await import("@/lib/api/user"); + const response = await saveUserWithDept({ + userInfo: userInfo as any, + mainDept, + subDepts, + isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드 + }); + + if (!response.success) { + throw new Error(response.message || "사원 저장 실패"); + } + + console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data); + }; + + const saveWithGenericCustomApi = async () => { + if (!customApiSave.customEndpoint) { + throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다."); + } + + const dataToSave = { ...formData }; + + // 메타데이터 필드 제거 + Object.keys(dataToSave).forEach((key) => { + if (key.startsWith("_")) { + delete dataToSave[key]; + } + }); + + // 반복 섹션 데이터 포함 + if (Object.keys(repeatSections).length > 0) { + dataToSave._repeatSections = repeatSections; + } + + const method = customApiSave.customMethod || "POST"; + const response = method === "PUT" + ? await apiClient.put(customApiSave.customEndpoint, dataToSave) + : await apiClient.post(customApiSave.customEndpoint, dataToSave); + + if (!response.data?.success) { + throw new Error(response.data?.message || "저장 실패"); + } + }; + + switch (customApiSave.apiType) { + case "user-with-dept": + await saveUserWithDeptApi(); + break; + case "custom": + await saveWithGenericCustomApi(); + break; + default: + throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`); + } + }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + + // 저장 처리 + const handleSave = useCallback(async () => { + // 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크 + if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) { + toast.error("저장할 테이블이 설정되지 않았습니다."); + return; + } + + // 필수 필드 검증 + const { valid, missingFields } = validateRequiredFields(); + if (!valid) { + toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`); + return; + } + + setSaving(true); + + try { + const { multiRowSave, customApiSave } = config.saveConfig; + + // 커스텀 API 저장 모드 + if (customApiSave?.enabled) { + await saveWithCustomApi(); + } else if (multiRowSave?.enabled) { + // 다중 행 저장 + await saveMultipleRows(); + } else { + // 단일 행 저장 + await saveSingleRow(); + } + + // 저장 후 동작 + if (config.saveConfig.afterSave?.showToast) { + toast.success("저장되었습니다."); + } + + if (config.saveConfig.afterSave?.refreshParent) { + window.dispatchEvent(new CustomEvent("refreshParentData")); + } + + // onSave 콜백은 저장 완료 알림용으로만 사용 + // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows) + // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록 + // _saveCompleted 플래그를 포함하여 전달 + if (onSave) { + onSave({ ...formData, _saveCompleted: true }); + } + } catch (error: any) { + console.error("저장 실패:", error); + // axios 에러의 경우 서버 응답 메시지 추출 + const errorMessage = + error.response?.data?.message || + error.response?.data?.error?.details || + error.message || + "저장에 실패했습니다."; + toast.error(errorMessage); + } finally { + setSaving(false); + } + }, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]); // 폼 초기화 const handleReset = useCallback(() => { diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index acc53acc..8552cd6f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -416,62 +416,74 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* 저장 테이블 - Combobox */}
- - - - - - - - - 테이블을 찾을 수 없습니다 - - {tables.map((t) => ( - { - updateSaveConfig({ tableName: t.name }); - setTableSelectOpen(false); - }} - className="text-xs" - > - - {t.name} - {t.label !== t.name && ( - ({t.label}) - )} - - ))} - - - - - - {config.saveConfig.tableName && ( -

- 컬럼 {currentColumns.length}개 로드됨 -

+ {config.saveConfig.customApiSave?.enabled ? ( +
+ 전용 API 저장 모드에서는 API가 테이블 저장을 처리합니다. + {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( + 대상 테이블: user_info + user_dept + )} +
+ ) : ( + <> + + + + + + + + + 테이블을 찾을 수 없습니다 + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + setTableSelectOpen(false); + }} + className="text-xs" + > + + {t.name} + {t.label !== t.name && ( + ({t.label}) + )} + + ))} + + + + + + {config.saveConfig.tableName && ( +

+ 컬럼 {currentColumns.length}개 로드됨 +

+ )} + )}
- {/* 다중 행 저장 설정 */} + {/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */} + {!config.saveConfig.customApiSave?.enabled && (
다중 행 저장 @@ -578,6 +590,321 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
)}
+ )} + + {/* 커스텀 API 저장 설정 */} +
+
+ 전용 API 저장 + + updateSaveConfig({ + customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "user-with-dept" }, + }) + } + /> +
+ 테이블 직접 저장 대신 전용 백엔드 API를 사용합니다. 복잡한 비즈니스 로직(다중 테이블, 트랜잭션)에 적합합니다. + + {config.saveConfig.customApiSave?.enabled && ( +
+ {/* API 타입 선택 */} +
+ + +
+ + {/* 사원+부서 통합 저장 설정 */} + {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( +
+

+ user_info와 user_dept 테이블에 트랜잭션으로 저장합니다. + 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환됩니다. +

+ + {/* 메인 부서 필드 매핑 */} +
+ +
+
+ 부서코드: + +
+
+ 부서명: + +
+
+ 직급: + +
+
+
+ + {/* 겸직 부서 반복 섹션 */} +
+ + +
+ + {/* 겸직 부서 필드 매핑 */} + {config.saveConfig.customApiSave?.subDeptSectionId && ( +
+ +
+
+ 부서코드: + +
+
+ 부서명: + +
+
+ 직급: + +
+
+
+ )} +
+ )} + + {/* 커스텀 API 설정 */} + {config.saveConfig.customApiSave?.apiType === "custom" && ( +
+
+ + + updateSaveConfig({ + customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value }, + }) + } + placeholder="/api/custom/endpoint" + className="h-6 text-[10px] mt-1" + /> +
+
+ + +
+
+ )} +
+ )} +
{/* 저장 후 동작 */}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index de2526c2..04f7df0e 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -178,6 +178,9 @@ export interface SaveConfig { // 다중 행 저장 설정 multiRowSave?: MultiRowSaveConfig; + // 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용) + customApiSave?: CustomApiSaveConfig; + // 저장 후 동작 (간편 설정) showToast?: boolean; // 토스트 메시지 (기본: true) refreshParent?: boolean; // 부모 새로고침 (기본: true) @@ -191,6 +194,44 @@ export interface SaveConfig { }; } +/** + * 커스텀 API 저장 설정 + * + * 테이블 직접 저장 대신 전용 백엔드 API를 호출합니다. + * 복잡한 비즈니스 로직(다중 테이블 저장, 트랜잭션 등)에 사용합니다. + * + * ## 지원하는 API 타입 + * - `user-with-dept`: 사원 + 부서 통합 저장 (/api/admin/users/with-dept) + * + * ## 데이터 매핑 설정 + * - `userInfoFields`: user_info 테이블에 저장할 필드 매핑 + * - `mainDeptFields`: 메인 부서 정보 필드 매핑 + * - `subDeptSectionId`: 겸직 부서 반복 섹션 ID + */ +export interface CustomApiSaveConfig { + enabled: boolean; + apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입 + + // user-with-dept 전용 설정 + userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName) + mainDeptFields?: { + deptCodeField?: string; // 메인 부서코드 필드명 + deptNameField?: string; // 메인 부서명 필드명 + positionNameField?: string; // 메인 직급 필드명 + }; + subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID + subDeptFields?: { + deptCodeField?: string; // 겸직 부서코드 필드명 + deptNameField?: string; // 겸직 부서명 필드명 + positionNameField?: string; // 겸직 직급 필드명 + }; + + // 커스텀 API 전용 설정 + customEndpoint?: string; // 커스텀 API 엔드포인트 + customMethod?: "POST" | "PUT"; // HTTP 메서드 + customDataTransform?: string; // 데이터 변환 함수명 (추후 확장) +} + // 모달 설정 export interface ModalConfig { title: string; From 61c1f104957d3c218a869a94b19ae05165d53bb4 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 8 Dec 2025 15:34:19 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(ModalRepeaterTable):=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EA=B2=80=EC=83=89=20=EB=AA=A8=EB=8B=AC=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=9D=BC=EB=B2=A8=20=EC=84=A4=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sourceColumnLabels 타입 정의 (Record) - ConfigPanel에 소스 컬럼별 표시 라벨 입력 UI 추가 - columnLabels 생성 시 sourceColumnLabels 우선 적용 - 컬럼 삭제 시 해당 라벨도 함께 삭제 - 빈 상태 안내 메시지 추가 --- .../ModalRepeaterTableComponent.tsx | 10 ++- .../ModalRepeaterTableConfigPanel.tsx | 82 ++++++++++++++----- .../components/modal-repeater-table/types.ts | 1 + 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 3a5b43dd..6e0432d1 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -185,6 +185,9 @@ export function ModalRepeaterTableComponent({ const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || []; const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== ""); + // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨) + const sourceColumnLabels = componentConfig?.sourceColumnLabels || {}; + const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || []; const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색"; const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색"; @@ -546,11 +549,12 @@ export function ModalRepeaterTableComponent({ handleChange(newData); }; - // 컬럼명 -> 라벨명 매핑 생성 + // 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴) const columnLabels = columns.reduce((acc, col) => { - acc[col.field] = col.label; + // sourceColumnLabels에 정의된 라벨 우선 사용 + acc[col.field] = sourceColumnLabels[col.field] || col.label; return acc; - }, {} as Record); + }, { ...sourceColumnLabels } as Record); return (
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index 348ae045..507ab54d 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -520,7 +520,7 @@ export function ModalRepeaterTableConfigPanel({ {/* 소스 컬럼 */}
- +

- 모달 테이블에 표시할 컬럼들 + 모달 테이블에 표시할 컬럼과 헤더 라벨을 설정합니다

-
+
{(localConfig.sourceColumns || []).map((column, index) => ( -
- +
+
+ {/* 컬럼 선택 */} +
+ + +
+ {/* 라벨 입력 */} +
+ + { + const newLabels = { ...(localConfig.sourceColumnLabels || {}) }; + if (e.target.value) { + newLabels[column] = e.target.value; + } else { + delete newLabels[column]; + } + updateConfig({ sourceColumnLabels: newLabels }); + }} + placeholder={tableColumns.find(c => c.columnName === column)?.displayName || column || "라벨 입력"} + className="h-8 text-xs" + disabled={!column} + /> +
+
))} + {(localConfig.sourceColumns || []).length === 0 && ( +
+

+ "추가" 버튼을 클릭하여 모달에 표시할 컬럼을 추가하세요 +

+
+ )}
diff --git a/frontend/lib/registry/components/modal-repeater-table/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts index 180830ee..c0cac4a9 100644 --- a/frontend/lib/registry/components/modal-repeater-table/types.ts +++ b/frontend/lib/registry/components/modal-repeater-table/types.ts @@ -7,6 +7,7 @@ export interface ModalRepeaterTableProps { // 소스 데이터 (모달에서 가져올 데이터) sourceTable: string; // 검색할 테이블 (예: "item_info") sourceColumns: string[]; // 모달에 표시할 컬럼들 + sourceColumnLabels?: Record; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨) sourceSearchFields?: string[]; // 검색 가능한 필드들 // 🆕 저장 대상 테이블 설정 From ab1308efe891a7148291c7e57a7e98bdafa121d1 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 8 Dec 2025 16:01:59 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EB=B0=9C=ED=96=89=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/taxInvoiceController.ts | 331 +++++++ backend-node/src/middleware/errorHandler.ts | 12 +- backend-node/src/routes/taxInvoiceRoutes.ts | 40 + .../src/services/taxInvoiceService.ts | 612 +++++++++++++ .../tax-invoice/TaxInvoiceDetail.tsx | 621 +++++++++++++ .../components/tax-invoice/TaxInvoiceForm.tsx | 706 +++++++++++++++ .../components/tax-invoice/TaxInvoiceList.tsx | 818 ++++++++++++++++++ frontend/components/tax-invoice/index.ts | 4 + frontend/lib/api/taxInvoice.ts | 229 +++++ frontend/lib/registry/components/index.ts | 3 + .../TaxInvoiceListComponent.tsx | 48 + .../TaxInvoiceListConfigPanel.tsx | 166 ++++ .../TaxInvoiceListRenderer.tsx | 32 + .../components/tax-invoice-list/index.ts | 37 + .../components/tax-invoice-list/types.ts | 41 + 16 files changed, 3701 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/controllers/taxInvoiceController.ts create mode 100644 backend-node/src/routes/taxInvoiceRoutes.ts create mode 100644 backend-node/src/services/taxInvoiceService.ts create mode 100644 frontend/components/tax-invoice/TaxInvoiceDetail.tsx create mode 100644 frontend/components/tax-invoice/TaxInvoiceForm.tsx create mode 100644 frontend/components/tax-invoice/TaxInvoiceList.tsx create mode 100644 frontend/components/tax-invoice/index.ts create mode 100644 frontend/lib/api/taxInvoice.ts create mode 100644 frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListComponent.tsx create mode 100644 frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel.tsx create mode 100644 frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListRenderer.tsx create mode 100644 frontend/lib/registry/components/tax-invoice-list/index.ts create mode 100644 frontend/lib/registry/components/tax-invoice-list/types.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d214c19a..e01c9d0a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -74,6 +74,7 @@ import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 +import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -240,6 +241,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 +app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/taxInvoiceController.ts b/backend-node/src/controllers/taxInvoiceController.ts new file mode 100644 index 00000000..588a856c --- /dev/null +++ b/backend-node/src/controllers/taxInvoiceController.ts @@ -0,0 +1,331 @@ +/** + * 세금계산서 컨트롤러 + * 세금계산서 API 엔드포인트 처리 + */ + +import { Request, Response } from "express"; +import { TaxInvoiceService } from "../services/taxInvoiceService"; +import { logger } from "../utils/logger"; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + companyCode: string; + }; +} + +export class TaxInvoiceController { + /** + * 세금계산서 목록 조회 + * GET /api/tax-invoice + */ + static async getList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { + page = "1", + pageSize = "20", + invoice_type, + invoice_status, + start_date, + end_date, + search, + buyer_name, + } = req.query; + + const result = await TaxInvoiceService.getList(companyCode, { + page: parseInt(page as string, 10), + pageSize: parseInt(pageSize as string, 10), + invoice_type: invoice_type as "sales" | "purchase" | undefined, + invoice_status: invoice_status as string | undefined, + start_date: start_date as string | undefined, + end_date: end_date as string | undefined, + search: search as string | undefined, + buyer_name: buyer_name as string | undefined, + }); + + res.json({ + success: true, + data: result.data, + pagination: { + page: result.page, + pageSize: result.pageSize, + total: result.total, + totalPages: Math.ceil(result.total / result.pageSize), + }, + }); + } catch (error: any) { + logger.error("세금계산서 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 상세 조회 + * GET /api/tax-invoice/:id + */ + static async getById(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const result = await TaxInvoiceService.getById(id, companyCode); + + if (!result) { + res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." }); + return; + } + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("세금계산서 상세 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 조회 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 생성 + * POST /api/tax-invoice + */ + static async create(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const data = req.body; + + // 필수 필드 검증 + if (!data.invoice_type) { + res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." }); + return; + } + if (!data.invoice_date) { + res.status(400).json({ success: false, message: "작성일자는 필수입니다." }); + return; + } + if (data.supply_amount === undefined || data.supply_amount === null) { + res.status(400).json({ success: false, message: "공급가액은 필수입니다." }); + return; + } + + const result = await TaxInvoiceService.create(data, companyCode, userId); + + res.status(201).json({ + success: true, + data: result, + message: "세금계산서가 생성되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 생성 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 생성 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 수정 + * PUT /api/tax-invoice/:id + */ + static async update(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const data = req.body; + + const result = await TaxInvoiceService.update(id, data, companyCode, userId); + + if (!result) { + res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." }); + return; + } + + res.json({ + success: true, + data: result, + message: "세금계산서가 수정되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 수정 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 수정 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 삭제 + * DELETE /api/tax-invoice/:id + */ + static async delete(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const result = await TaxInvoiceService.delete(id, companyCode, userId); + + if (!result) { + res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." }); + return; + } + + res.json({ + success: true, + message: "세금계산서가 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 삭제 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 발행 + * POST /api/tax-invoice/:id/issue + */ + static async issue(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const result = await TaxInvoiceService.issue(id, companyCode, userId); + + if (!result) { + res.status(404).json({ + success: false, + message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.", + }); + return; + } + + res.json({ + success: true, + data: result, + message: "세금계산서가 발행되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 발행 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 발행 중 오류가 발생했습니다.", + }); + } + } + + /** + * 세금계산서 취소 + * POST /api/tax-invoice/:id/cancel + */ + static async cancel(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const { reason } = req.body; + + const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason); + + if (!result) { + res.status(404).json({ + success: false, + message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.", + }); + return; + } + + res.json({ + success: true, + data: result, + message: "세금계산서가 취소되었습니다.", + }); + } catch (error: any) { + logger.error("세금계산서 취소 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "세금계산서 취소 중 오류가 발생했습니다.", + }); + } + } + + /** + * 월별 통계 조회 + * GET /api/tax-invoice/stats/monthly + */ + static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { year, month } = req.query; + const now = new Date(); + const targetYear = year ? parseInt(year as string, 10) : now.getFullYear(); + const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1; + + const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth); + + res.json({ + success: true, + data: result, + period: { year: targetYear, month: targetMonth }, + }); + } catch (error: any) { + logger.error("월별 통계 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "통계 조회 중 오류가 발생했습니다.", + }); + } + } +} + diff --git a/backend-node/src/middleware/errorHandler.ts b/backend-node/src/middleware/errorHandler.ts index 611e5d08..54d8f0a2 100644 --- a/backend-node/src/middleware/errorHandler.ts +++ b/backend-node/src/middleware/errorHandler.ts @@ -28,6 +28,16 @@ export const errorHandler = ( // PostgreSQL 에러 처리 (pg 라이브러리) if ((err as any).code) { const pgError = err as any; + // 원본 에러 메시지 로깅 (디버깅용) + console.error("🔴 PostgreSQL Error:", { + code: pgError.code, + message: pgError.message, + detail: pgError.detail, + hint: pgError.hint, + table: pgError.table, + column: pgError.column, + constraint: pgError.constraint, + }); // PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html if (pgError.code === "23505") { // unique_violation @@ -42,7 +52,7 @@ export const errorHandler = ( // 기타 무결성 제약 조건 위반 error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400); } else { - error = new AppError("데이터베이스 오류가 발생했습니다.", 500); + error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500); } } diff --git a/backend-node/src/routes/taxInvoiceRoutes.ts b/backend-node/src/routes/taxInvoiceRoutes.ts new file mode 100644 index 00000000..aa663faf --- /dev/null +++ b/backend-node/src/routes/taxInvoiceRoutes.ts @@ -0,0 +1,40 @@ +/** + * 세금계산서 라우터 + * /api/tax-invoice 경로 처리 + */ + +import { Router } from "express"; +import { TaxInvoiceController } from "../controllers/taxInvoiceController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 목록 조회 +router.get("/", TaxInvoiceController.getList); + +// 월별 통계 (상세 조회보다 먼저 정의해야 함) +router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats); + +// 상세 조회 +router.get("/:id", TaxInvoiceController.getById); + +// 생성 +router.post("/", TaxInvoiceController.create); + +// 수정 +router.put("/:id", TaxInvoiceController.update); + +// 삭제 +router.delete("/:id", TaxInvoiceController.delete); + +// 발행 +router.post("/:id/issue", TaxInvoiceController.issue); + +// 취소 +router.post("/:id/cancel", TaxInvoiceController.cancel); + +export default router; + diff --git a/backend-node/src/services/taxInvoiceService.ts b/backend-node/src/services/taxInvoiceService.ts new file mode 100644 index 00000000..63e94d5e --- /dev/null +++ b/backend-node/src/services/taxInvoiceService.ts @@ -0,0 +1,612 @@ +/** + * 세금계산서 서비스 + * 세금계산서 CRUD 및 비즈니스 로직 처리 + */ + +import { query, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// 세금계산서 타입 정의 +export interface TaxInvoice { + id: string; + company_code: string; + invoice_number: string; + invoice_type: "sales" | "purchase"; // 매출/매입 + invoice_status: "draft" | "issued" | "sent" | "cancelled"; + + // 공급자 정보 + supplier_business_no: string; + supplier_name: string; + supplier_ceo_name: string; + supplier_address: string; + supplier_business_type: string; + supplier_business_item: string; + + // 공급받는자 정보 + buyer_business_no: string; + buyer_name: string; + buyer_ceo_name: string; + buyer_address: string; + buyer_email: string; + + // 금액 정보 + supply_amount: number; + tax_amount: number; + total_amount: number; + + // 날짜 정보 + invoice_date: string; + issue_date: string | null; + + // 기타 + remarks: string; + order_id: string | null; + customer_id: string | null; + + // 첨부파일 (JSON 배열로 저장) + attachments: TaxInvoiceAttachment[] | null; + + created_date: string; + updated_date: string; + writer: string; +} + +// 첨부파일 타입 +export interface TaxInvoiceAttachment { + id: string; + file_name: string; + file_path: string; + file_size: number; + file_type: string; + uploaded_at: string; + uploaded_by: string; +} + +export interface TaxInvoiceItem { + id: string; + tax_invoice_id: string; + company_code: string; + item_seq: number; + item_date: string; + item_name: string; + item_spec: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks: string; +} + +export interface CreateTaxInvoiceDto { + invoice_type: "sales" | "purchase"; + supplier_business_no?: string; + supplier_name?: string; + supplier_ceo_name?: string; + supplier_address?: string; + supplier_business_type?: string; + supplier_business_item?: string; + buyer_business_no?: string; + buyer_name?: string; + buyer_ceo_name?: string; + buyer_address?: string; + buyer_email?: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + invoice_date: string; + remarks?: string; + order_id?: string; + customer_id?: string; + items?: CreateTaxInvoiceItemDto[]; + attachments?: TaxInvoiceAttachment[]; // 첨부파일 +} + +export interface CreateTaxInvoiceItemDto { + item_date?: string; + item_name: string; + item_spec?: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks?: string; +} + +export interface TaxInvoiceListParams { + page?: number; + pageSize?: number; + invoice_type?: "sales" | "purchase"; + invoice_status?: string; + start_date?: string; + end_date?: string; + search?: string; + buyer_name?: string; +} + +export class TaxInvoiceService { + /** + * 세금계산서 번호 채번 + * 형식: YYYYMM-NNNNN (예: 202512-00001) + */ + static async generateInvoiceNumber(companyCode: string): Promise { + const now = new Date(); + const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`; + const prefix = `${yearMonth}-`; + + // 해당 월의 마지막 번호 조회 + const result = await query<{ max_num: string }>( + `SELECT invoice_number as max_num + FROM tax_invoice + WHERE company_code = $1 + AND invoice_number LIKE $2 + ORDER BY invoice_number DESC + LIMIT 1`, + [companyCode, `${prefix}%`] + ); + + let nextNum = 1; + if (result.length > 0 && result[0].max_num) { + const lastNum = parseInt(result[0].max_num.split("-")[1], 10); + nextNum = lastNum + 1; + } + + return `${prefix}${String(nextNum).padStart(5, "0")}`; + } + + /** + * 세금계산서 목록 조회 + */ + static async getList( + companyCode: string, + params: TaxInvoiceListParams + ): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> { + const { + page = 1, + pageSize = 20, + invoice_type, + invoice_status, + start_date, + end_date, + search, + buyer_name, + } = params; + + const offset = (page - 1) * pageSize; + const conditions: string[] = ["company_code = $1"]; + const values: any[] = [companyCode]; + let paramIndex = 2; + + if (invoice_type) { + conditions.push(`invoice_type = $${paramIndex}`); + values.push(invoice_type); + paramIndex++; + } + + if (invoice_status) { + conditions.push(`invoice_status = $${paramIndex}`); + values.push(invoice_status); + paramIndex++; + } + + if (start_date) { + conditions.push(`invoice_date >= $${paramIndex}`); + values.push(start_date); + paramIndex++; + } + + if (end_date) { + conditions.push(`invoice_date <= $${paramIndex}`); + values.push(end_date); + paramIndex++; + } + + if (search) { + conditions.push( + `(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})` + ); + values.push(`%${search}%`); + paramIndex++; + } + + if (buyer_name) { + conditions.push(`buyer_name ILIKE $${paramIndex}`); + values.push(`%${buyer_name}%`); + paramIndex++; + } + + const whereClause = conditions.join(" AND "); + + // 전체 개수 조회 + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`, + values + ); + const total = parseInt(countResult[0]?.count || "0", 10); + + // 데이터 조회 + values.push(pageSize, offset); + const data = await query( + `SELECT * FROM tax_invoice + WHERE ${whereClause} + ORDER BY created_date DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + values + ); + + return { data, total, page, pageSize }; + } + + /** + * 세금계산서 상세 조회 (품목 포함) + */ + static async getById( + id: string, + companyCode: string + ): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> { + const invoiceResult = await query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (invoiceResult.length === 0) { + return null; + } + + const items = await query( + `SELECT * FROM tax_invoice_item + WHERE tax_invoice_id = $1 AND company_code = $2 + ORDER BY item_seq`, + [id, companyCode] + ); + + return { invoice: invoiceResult[0], items }; + } + + /** + * 세금계산서 생성 + */ + static async create( + data: CreateTaxInvoiceDto, + companyCode: string, + userId: string + ): Promise { + return await transaction(async (client) => { + // 세금계산서 번호 채번 + const invoiceNumber = await this.generateInvoiceNumber(companyCode); + + // 세금계산서 생성 + const invoiceResult = await client.query( + `INSERT INTO tax_invoice ( + company_code, invoice_number, invoice_type, invoice_status, + supplier_business_no, supplier_name, supplier_ceo_name, supplier_address, + supplier_business_type, supplier_business_item, + buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email, + supply_amount, tax_amount, total_amount, invoice_date, + remarks, order_id, customer_id, attachments, writer + ) VALUES ( + $1, $2, $3, 'draft', + $4, $5, $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20, $21, $22, $23 + ) RETURNING *`, + [ + companyCode, + invoiceNumber, + data.invoice_type, + data.supplier_business_no || null, + data.supplier_name || null, + data.supplier_ceo_name || null, + data.supplier_address || null, + data.supplier_business_type || null, + data.supplier_business_item || null, + data.buyer_business_no || null, + data.buyer_name || null, + data.buyer_ceo_name || null, + data.buyer_address || null, + data.buyer_email || null, + data.supply_amount, + data.tax_amount, + data.total_amount, + data.invoice_date, + data.remarks || null, + data.order_id || null, + data.customer_id || null, + data.attachments ? JSON.stringify(data.attachments) : null, + userId, + ] + ); + + const invoice = invoiceResult.rows[0]; + + // 품목 생성 + if (data.items && data.items.length > 0) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + await client.query( + `INSERT INTO tax_invoice_item ( + tax_invoice_id, company_code, item_seq, + item_date, item_name, item_spec, quantity, unit_price, + supply_amount, tax_amount, remarks + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + invoice.id, + companyCode, + i + 1, + item.item_date || null, + item.item_name, + item.item_spec || null, + item.quantity, + item.unit_price, + item.supply_amount, + item.tax_amount, + item.remarks || null, + ] + ); + } + } + + logger.info("세금계산서 생성 완료", { + invoiceId: invoice.id, + invoiceNumber, + companyCode, + userId, + }); + + return invoice; + }); + } + + /** + * 세금계산서 수정 + */ + static async update( + id: string, + data: Partial, + companyCode: string, + userId: string + ): Promise { + return await transaction(async (client) => { + // 기존 세금계산서 확인 + const existing = await client.query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (existing.rows.length === 0) { + return null; + } + + // 발행된 세금계산서는 수정 불가 + if (existing.rows[0].invoice_status !== "draft") { + throw new Error("발행된 세금계산서는 수정할 수 없습니다."); + } + + // 세금계산서 수정 + const updateResult = await client.query( + `UPDATE tax_invoice SET + supplier_business_no = COALESCE($3, supplier_business_no), + supplier_name = COALESCE($4, supplier_name), + supplier_ceo_name = COALESCE($5, supplier_ceo_name), + supplier_address = COALESCE($6, supplier_address), + supplier_business_type = COALESCE($7, supplier_business_type), + supplier_business_item = COALESCE($8, supplier_business_item), + buyer_business_no = COALESCE($9, buyer_business_no), + buyer_name = COALESCE($10, buyer_name), + buyer_ceo_name = COALESCE($11, buyer_ceo_name), + buyer_address = COALESCE($12, buyer_address), + buyer_email = COALESCE($13, buyer_email), + supply_amount = COALESCE($14, supply_amount), + tax_amount = COALESCE($15, tax_amount), + total_amount = COALESCE($16, total_amount), + invoice_date = COALESCE($17, invoice_date), + remarks = COALESCE($18, remarks), + attachments = $19, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING *`, + [ + id, + companyCode, + data.supplier_business_no, + data.supplier_name, + data.supplier_ceo_name, + data.supplier_address, + data.supplier_business_type, + data.supplier_business_item, + data.buyer_business_no, + data.buyer_name, + data.buyer_ceo_name, + data.buyer_address, + data.buyer_email, + data.supply_amount, + data.tax_amount, + data.total_amount, + data.invoice_date, + data.remarks, + data.attachments ? JSON.stringify(data.attachments) : null, + ] + ); + + // 품목 업데이트 (기존 삭제 후 재생성) + if (data.items) { + await client.query( + `DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`, + [id, companyCode] + ); + + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + await client.query( + `INSERT INTO tax_invoice_item ( + tax_invoice_id, company_code, item_seq, + item_date, item_name, item_spec, quantity, unit_price, + supply_amount, tax_amount, remarks + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + id, + companyCode, + i + 1, + item.item_date || null, + item.item_name, + item.item_spec || null, + item.quantity, + item.unit_price, + item.supply_amount, + item.tax_amount, + item.remarks || null, + ] + ); + } + } + + logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId }); + + return updateResult.rows[0]; + }); + } + + /** + * 세금계산서 삭제 + */ + static async delete(id: string, companyCode: string, userId: string): Promise { + return await transaction(async (client) => { + // 기존 세금계산서 확인 + const existing = await client.query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (existing.rows.length === 0) { + return false; + } + + // 발행된 세금계산서는 삭제 불가 + if (existing.rows[0].invoice_status !== "draft") { + throw new Error("발행된 세금계산서는 삭제할 수 없습니다."); + } + + // 품목 삭제 + await client.query( + `DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`, + [id, companyCode] + ); + + // 세금계산서 삭제 + await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [ + id, + companyCode, + ]); + + logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId }); + + return true; + }); + } + + /** + * 세금계산서 발행 (상태 변경) + */ + static async issue(id: string, companyCode: string, userId: string): Promise { + const result = await query( + `UPDATE tax_invoice SET + invoice_status = 'issued', + issue_date = NOW(), + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft' + RETURNING *`, + [id, companyCode] + ); + + if (result.length === 0) { + return null; + } + + logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId }); + + return result[0]; + } + + /** + * 세금계산서 취소 + */ + static async cancel( + id: string, + companyCode: string, + userId: string, + reason?: string + ): Promise { + const result = await query( + `UPDATE tax_invoice SET + invoice_status = 'cancelled', + remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued') + RETURNING *`, + [id, companyCode, reason || null] + ); + + if (result.length === 0) { + return null; + } + + logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason }); + + return result[0]; + } + + /** + * 월별 통계 조회 + */ + static async getMonthlyStats( + companyCode: string, + year: number, + month: number + ): Promise<{ + sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + }> { + const startDate = `${year}-${String(month).padStart(2, "0")}-01`; + const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날 + + const result = await query<{ + invoice_type: string; + count: string; + supply_amount: string; + tax_amount: string; + total_amount: string; + }>( + `SELECT + invoice_type, + COUNT(*) as count, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE company_code = $1 + AND invoice_date >= $2 + AND invoice_date <= $3 + AND invoice_status != 'cancelled' + GROUP BY invoice_type`, + [companyCode, startDate, endDate] + ); + + const stats = { + sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 }, + purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 }, + }; + + for (const row of result) { + const type = row.invoice_type as "sales" | "purchase"; + stats[type] = { + count: parseInt(row.count, 10), + supply_amount: parseFloat(row.supply_amount), + tax_amount: parseFloat(row.tax_amount), + total_amount: parseFloat(row.total_amount), + }; + } + + return stats; + } +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceDetail.tsx b/frontend/components/tax-invoice/TaxInvoiceDetail.tsx new file mode 100644 index 00000000..9fe45e91 --- /dev/null +++ b/frontend/components/tax-invoice/TaxInvoiceDetail.tsx @@ -0,0 +1,621 @@ +"use client"; + +/** + * 세금계산서 상세 보기 컴포넌트 + * PDF 출력 및 첨부파일 다운로드 기능 포함 + */ + +import { useState, useEffect, useRef } from "react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { + Printer, + Download, + FileText, + Image, + File, + Loader2, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { toast } from "sonner"; + +import { + getTaxInvoiceById, + TaxInvoice, + TaxInvoiceItem, + TaxInvoiceAttachment, +} from "@/lib/api/taxInvoice"; +import { apiClient } from "@/lib/api/client"; + +interface TaxInvoiceDetailProps { + open: boolean; + onClose: () => void; + invoiceId: string; +} + +// 상태 라벨 +const statusLabels: Record = { + draft: "임시저장", + issued: "발행완료", + sent: "전송완료", + cancelled: "취소됨", +}; + +// 상태 색상 +const statusColors: Record = { + draft: "bg-gray-100 text-gray-800", + issued: "bg-green-100 text-green-800", + sent: "bg-blue-100 text-blue-800", + cancelled: "bg-red-100 text-red-800", +}; + +export function TaxInvoiceDetail({ open, onClose, invoiceId }: TaxInvoiceDetailProps) { + const [invoice, setInvoice] = useState(null); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [pdfLoading, setPdfLoading] = useState(false); + const printRef = useRef(null); + + // 데이터 로드 + useEffect(() => { + if (open && invoiceId) { + loadData(); + } + }, [open, invoiceId]); + + const loadData = async () => { + setLoading(true); + try { + const response = await getTaxInvoiceById(invoiceId); + if (response.success) { + setInvoice(response.data.invoice); + setItems(response.data.items); + } + } catch (error: any) { + toast.error("데이터 로드 실패", { description: error.message }); + } finally { + setLoading(false); + } + }; + + // 금액 포맷 + const formatAmount = (amount: number) => { + return new Intl.NumberFormat("ko-KR").format(amount); + }; + + // 날짜 포맷 + const formatDate = (dateString: string | null) => { + if (!dateString) return "-"; + try { + return format(new Date(dateString), "yyyy년 MM월 dd일", { locale: ko }); + } catch { + return dateString; + } + }; + + // 파일 미리보기 URL 생성 (objid 기반) - 이미지용 + const getFilePreviewUrl = (attachment: TaxInvoiceAttachment) => { + // objid가 숫자형이면 API를 통해 미리보기 + if (attachment.id && !attachment.id.includes("-")) { + // apiClient의 baseURL 사용 + const baseURL = apiClient.defaults.baseURL || ""; + return `${baseURL}/files/preview/${attachment.id}`; + } + return attachment.file_path; + }; + + // 공통 인쇄용 HTML 생성 함수 + const generatePrintHtml = (autoPrint: boolean = false) => { + if (!invoice) return ""; + + const invoiceTypeText = invoice.invoice_type === "sales" ? "매출" : "매입"; + const itemsHtml = items.map((item, index) => ` + + ${index + 1} + ${item.item_date?.split("T")[0] || "-"} + ${item.item_name} + ${item.item_spec || "-"} + ${item.quantity} + ${formatAmount(item.unit_price)} + ${formatAmount(item.supply_amount)} + ${formatAmount(item.tax_amount)} + + `).join(""); + + return ` + + + + 세금계산서_${invoice.invoice_number} + + + +
+
+

세금계산서 (${invoiceTypeText})

+
계산서번호: ${invoice.invoice_number}
+ ${statusLabels[invoice.invoice_status]} +
+ +
+
+

공급자

+
사업자번호${invoice.supplier_business_no || "-"}
+
상호${invoice.supplier_name || "-"}
+
대표자${invoice.supplier_ceo_name || "-"}
+
업태/종목${invoice.supplier_business_type || "-"} / ${invoice.supplier_business_item || "-"}
+
주소${invoice.supplier_address || "-"}
+
+
+

공급받는자

+
사업자번호${invoice.buyer_business_no || "-"}
+
상호${invoice.buyer_name || "-"}
+
대표자${invoice.buyer_ceo_name || "-"}
+
이메일${invoice.buyer_email || "-"}
+
주소${invoice.buyer_address || "-"}
+
+
+ +
+

품목 내역

+ + + + + + + + + + + + + + + ${itemsHtml || ''} + +
No일자품목명규격수량단가공급가액세액
품목 내역이 없습니다.
+
+ +
+
+
공급가액${formatAmount(invoice.supply_amount)}원
+
세액${formatAmount(invoice.tax_amount)}원
+
합계금액${formatAmount(invoice.total_amount)}원
+
+
+ + ${invoice.remarks ? `
비고: ${invoice.remarks}
` : ""} + + ${invoice.attachments && invoice.attachments.length > 0 ? ` +
+

첨부파일 (${invoice.attachments.length}개)

+
    + ${invoice.attachments.map(file => `
  • 📄 ${file.file_name}
  • `).join("")} +
+
+ ` : ""} + + +
+ + ${autoPrint ? `` : ""} + + + `; + }; + + // 인쇄 + const handlePrint = () => { + if (!invoice) return; + + const printWindow = window.open("", "_blank"); + if (!printWindow) { + toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요."); + return; + } + + printWindow.document.write(generatePrintHtml(true)); + printWindow.document.close(); + }; + + // PDF 다운로드 (인쇄 다이얼로그 사용) + const handleDownloadPdf = async () => { + if (!invoice) return; + + setPdfLoading(true); + try { + const printWindow = window.open("", "_blank"); + if (!printWindow) { + toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요."); + return; + } + + printWindow.document.write(generatePrintHtml(true)); + printWindow.document.close(); + toast.success("PDF 인쇄 창이 열렸습니다. 'PDF로 저장'을 선택하세요."); + } catch (error: any) { + console.error("PDF 생성 오류:", error); + toast.error("PDF 생성 실패", { description: error.message }); + } finally { + setPdfLoading(false); + } + }; + + // 파일 아이콘 + const getFileIcon = (fileType: string) => { + if (fileType.startsWith("image/")) return ; + if (fileType.includes("pdf")) return ; + return ; + }; + + // 파일 다운로드 (인증 토큰 포함) + const handleDownload = async (attachment: TaxInvoiceAttachment) => { + try { + // objid가 숫자형이면 API를 통해 다운로드 + if (attachment.id && !attachment.id.includes("-")) { + const response = await apiClient.get(`/files/download/${attachment.id}`, { + responseType: "blob", + }); + + // Blob으로 다운로드 + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = attachment.file_name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } else { + // 직접 경로로 다운로드 + window.open(attachment.file_path, "_blank"); + } + } catch (error: any) { + toast.error("파일 다운로드 실패", { description: error.message }); + } + }; + + if (loading) { + return ( + !o && onClose()}> + + + 세금계산서 상세 + +
+ 로딩 중... +
+
+
+ ); + } + + if (!invoice) { + return null; + } + + return ( + !o && onClose()}> + + + 세금계산서 상세 +
+ + +
+
+ + +
+
+ {/* 헤더 */} +
+

+ {invoice.invoice_type === "sales" ? "세금계산서 (매출)" : "세금계산서 (매입)"} +

+

+ 계산서번호: {invoice.invoice_number} +

+ + {statusLabels[invoice.invoice_status]} + +
+ + {/* 공급자 / 공급받는자 정보 */} +
+ {/* 공급자 */} +
+

공급자

+
+
+ 사업자번호 + {invoice.supplier_business_no || "-"} +
+
+ 상호 + {invoice.supplier_name || "-"} +
+
+ 대표자 + {invoice.supplier_ceo_name || "-"} +
+
+ 업태/종목 + + {invoice.supplier_business_type || "-"} / {invoice.supplier_business_item || "-"} + +
+
+ 주소 + {invoice.supplier_address || "-"} +
+
+
+ + {/* 공급받는자 */} +
+

공급받는자

+
+
+ 사업자번호 + {invoice.buyer_business_no || "-"} +
+
+ 상호 + {invoice.buyer_name || "-"} +
+
+ 대표자 + {invoice.buyer_ceo_name || "-"} +
+
+ 이메일 + {invoice.buyer_email || "-"} +
+
+ 주소 + {invoice.buyer_address || "-"} +
+
+
+
+ + {/* 품목 내역 */} +
+

품목 내역

+ + + + No + 일자 + 품목명 + 규격 + 수량 + 단가 + 공급가액 + 세액 + + + + {items.length > 0 ? ( + items.map((item, index) => ( + + {index + 1} + {item.item_date?.split("T")[0] || "-"} + {item.item_name} + {item.item_spec || "-"} + {item.quantity} + + {formatAmount(item.unit_price)} + + + {formatAmount(item.supply_amount)} + + + {formatAmount(item.tax_amount)} + + + )) + ) : ( + + + 품목 내역이 없습니다. + + + )} + +
+
+ + {/* 합계 */} +
+
+
+ 공급가액 + {formatAmount(invoice.supply_amount)}원 +
+
+ 세액 + {formatAmount(invoice.tax_amount)}원 +
+ +
+ 합계금액 + + {formatAmount(invoice.total_amount)}원 + +
+
+
+ + {/* 비고 */} + {invoice.remarks && ( +
+

비고

+

+ {invoice.remarks} +

+
+ )} + + {/* 날짜 정보 */} +
+ 작성일: {formatDate(invoice.invoice_date)} + {invoice.issue_date && 발행일: {formatDate(invoice.issue_date)}} +
+
+ + {/* 첨부파일 */} + {invoice.attachments && invoice.attachments.length > 0 && ( +
+ +

첨부파일 ({invoice.attachments.length}개)

+ + {/* 이미지 미리보기 */} + {invoice.attachments.some((f) => f.file_type?.startsWith("image/")) && ( +
+ {invoice.attachments + .filter((f) => f.file_type?.startsWith("image/")) + .map((file) => ( +
+ {file.file_name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+
+

{file.file_name}

+ +
+
+
+ ))} +
+ )} + + {/* 기타 파일 목록 */} + {invoice.attachments.some((f) => !f.file_type?.startsWith("image/")) && ( +
+ {invoice.attachments + .filter((f) => !f.file_type?.startsWith("image/")) + .map((file) => ( +
+
+ {getFileIcon(file.file_type)} + {file.file_name} +
+ +
+ ))} +
+ )} +
+ )} +
+
+
+
+ ); +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceForm.tsx b/frontend/components/tax-invoice/TaxInvoiceForm.tsx new file mode 100644 index 00000000..08c3fb37 --- /dev/null +++ b/frontend/components/tax-invoice/TaxInvoiceForm.tsx @@ -0,0 +1,706 @@ +"use client"; + +/** + * 세금계산서 작성/수정 폼 + * 파일 첨부 기능 포함 + */ + +import { useState, useEffect, useCallback } from "react"; +import { format } from "date-fns"; +import { + Plus, + Trash2, + Upload, + X, + FileText, + Image, + File, + Paperclip, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; + +import { + createTaxInvoice, + updateTaxInvoice, + getTaxInvoiceById, + TaxInvoice, + TaxInvoiceAttachment, + CreateTaxInvoiceDto, + CreateTaxInvoiceItemDto, +} from "@/lib/api/taxInvoice"; +import { apiClient } from "@/lib/api/client"; + +interface TaxInvoiceFormProps { + open: boolean; + onClose: () => void; + onSave: () => void; + invoice?: TaxInvoice | null; +} + +// 품목 초기값 +const emptyItem: CreateTaxInvoiceItemDto = { + item_date: format(new Date(), "yyyy-MM-dd"), + item_name: "", + item_spec: "", + quantity: 1, + unit_price: 0, + supply_amount: 0, + tax_amount: 0, + remarks: "", +}; + +export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFormProps) { + // 폼 상태 + const [formData, setFormData] = useState({ + invoice_type: "sales", + invoice_date: format(new Date(), "yyyy-MM-dd"), + supply_amount: 0, + tax_amount: 0, + total_amount: 0, + items: [{ ...emptyItem }], + }); + + // 첨부파일 상태 + const [attachments, setAttachments] = useState([]); + const [uploading, setUploading] = useState(false); + + const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState("basic"); + + // 수정 모드일 때 데이터 로드 + useEffect(() => { + if (invoice) { + loadInvoiceData(invoice.id); + } else { + // 새 세금계산서 + setFormData({ + invoice_type: "sales", + invoice_date: format(new Date(), "yyyy-MM-dd"), + supply_amount: 0, + tax_amount: 0, + total_amount: 0, + items: [{ ...emptyItem }], + }); + setAttachments([]); + } + }, [invoice]); + + // 세금계산서 데이터 로드 + const loadInvoiceData = async (id: string) => { + try { + const response = await getTaxInvoiceById(id); + if (response.success) { + const { invoice: inv, items } = response.data; + setFormData({ + invoice_type: inv.invoice_type, + invoice_date: inv.invoice_date?.split("T")[0] || "", + supplier_business_no: inv.supplier_business_no, + supplier_name: inv.supplier_name, + supplier_ceo_name: inv.supplier_ceo_name, + supplier_address: inv.supplier_address, + supplier_business_type: inv.supplier_business_type, + supplier_business_item: inv.supplier_business_item, + buyer_business_no: inv.buyer_business_no, + buyer_name: inv.buyer_name, + buyer_ceo_name: inv.buyer_ceo_name, + buyer_address: inv.buyer_address, + buyer_email: inv.buyer_email, + supply_amount: inv.supply_amount, + tax_amount: inv.tax_amount, + total_amount: inv.total_amount, + remarks: inv.remarks, + items: + items.length > 0 + ? items.map((item) => ({ + item_date: item.item_date?.split("T")[0] || "", + item_name: item.item_name, + item_spec: item.item_spec, + quantity: item.quantity, + unit_price: item.unit_price, + supply_amount: item.supply_amount, + tax_amount: item.tax_amount, + remarks: item.remarks, + })) + : [{ ...emptyItem }], + }); + setAttachments(inv.attachments || []); + } + } catch (error: any) { + toast.error("데이터 로드 실패", { description: error.message }); + } + }; + + // 필드 변경 + const handleChange = (field: keyof CreateTaxInvoiceDto, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + // 품목 변경 + const handleItemChange = (index: number, field: keyof CreateTaxInvoiceItemDto, value: any) => { + setFormData((prev) => { + const items = [...(prev.items || [])]; + items[index] = { ...items[index], [field]: value }; + + // 공급가액 자동 계산 + if (field === "quantity" || field === "unit_price") { + const qty = field === "quantity" ? value : items[index].quantity; + const price = field === "unit_price" ? value : items[index].unit_price; + items[index].supply_amount = qty * price; + items[index].tax_amount = Math.round(items[index].supply_amount * 0.1); + } + + // 총액 재계산 + const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0); + const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0); + + return { + ...prev, + items, + supply_amount: totalSupply, + tax_amount: totalTax, + total_amount: totalSupply + totalTax, + }; + }); + }; + + // 품목 추가 + const handleAddItem = () => { + setFormData((prev) => ({ + ...prev, + items: [...(prev.items || []), { ...emptyItem }], + })); + }; + + // 품목 삭제 + const handleRemoveItem = (index: number) => { + setFormData((prev) => { + const items = (prev.items || []).filter((_, i) => i !== index); + const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0); + const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0); + + return { + ...prev, + items: items.length > 0 ? items : [{ ...emptyItem }], + supply_amount: totalSupply, + tax_amount: totalTax, + total_amount: totalSupply + totalTax, + }; + }); + }; + + // 파일 업로드 + const handleFileUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + setUploading(true); + try { + for (const file of Array.from(files)) { + const formDataUpload = new FormData(); + formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files" + formDataUpload.append("category", "tax-invoice"); + + const response = await apiClient.post("/files/upload", formDataUpload, { + headers: { "Content-Type": "multipart/form-data" }, + }); + + if (response.data.success && response.data.files?.length > 0) { + const uploadedFile = response.data.files[0]; + const newAttachment: TaxInvoiceAttachment = { + id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + file_name: uploadedFile.realFileName || file.name, + file_path: uploadedFile.filePath, + file_size: uploadedFile.fileSize || file.size, + file_type: file.type, + uploaded_at: new Date().toISOString(), + uploaded_by: "", + }; + setAttachments((prev) => [...prev, newAttachment]); + toast.success(`'${file.name}' 업로드 완료`); + } + } + } catch (error: any) { + toast.error("파일 업로드 실패", { description: error.message }); + } finally { + setUploading(false); + // input 초기화 + e.target.value = ""; + } + }; + + // 첨부파일 삭제 + const handleRemoveAttachment = (id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }; + + // 파일 아이콘 + const getFileIcon = (fileType: string) => { + if (fileType.startsWith("image/")) return ; + if (fileType.includes("pdf")) return ; + return ; + }; + + // 파일 크기 포맷 + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + // 저장 + const handleSave = async () => { + // 유효성 검사 + if (!formData.invoice_date) { + toast.error("작성일자를 입력해주세요."); + return; + } + + setSaving(true); + try { + const dataToSave = { + ...formData, + attachments, + }; + + let response; + if (invoice) { + response = await updateTaxInvoice(invoice.id, dataToSave); + } else { + response = await createTaxInvoice(dataToSave); + } + + if (response.success) { + toast.success(response.message || "저장되었습니다."); + onSave(); + } + } catch (error: any) { + toast.error("저장 실패", { description: error.message }); + } finally { + setSaving(false); + } + }; + + // 금액 포맷 + const formatAmount = (amount: number) => { + return new Intl.NumberFormat("ko-KR").format(amount); + }; + + return ( + !o && onClose()}> + + + {invoice ? "세금계산서 수정" : "세금계산서 작성"} + 세금계산서 정보를 입력해주세요. + + + +
+ + + 기본정보 + 공급자 + 공급받는자 + + 첨부파일 + {attachments.length > 0 && ( + + {attachments.length} + + )} + + + + {/* 기본정보 탭 */} + +
+
+ + +
+
+ + handleChange("invoice_date", e.target.value)} + className="h-9" + /> +
+
+ + handleChange("remarks", e.target.value)} + className="h-9" + placeholder="비고 입력" + /> +
+
+ + {/* 품목 테이블 */} + + +
+ 품목 내역 + +
+
+ + + + + 일자 + 품목명 + 규격 + 수량 + 단가 + 공급가액 + 세액 + + + + + {(formData.items || []).map((item, index) => ( + + + + handleItemChange(index, "item_date", e.target.value) + } + className="h-8 text-xs" + /> + + + + handleItemChange(index, "item_name", e.target.value) + } + className="h-8 text-xs" + placeholder="품목명" + /> + + + + handleItemChange(index, "item_spec", e.target.value) + } + className="h-8 text-xs" + placeholder="규격" + /> + + + + handleItemChange(index, "quantity", parseFloat(e.target.value) || 0) + } + className="h-8 text-right text-xs" + min={0} + /> + + + + handleItemChange( + index, + "unit_price", + parseFloat(e.target.value) || 0 + ) + } + className="h-8 text-right text-xs" + min={0} + /> + + + {formatAmount(item.supply_amount || 0)} + + + {formatAmount(item.tax_amount || 0)} + + + + + + ))} + +
+
+
+ + {/* 합계 */} +
+
+
+ 공급가액 + {formatAmount(formData.supply_amount || 0)}원 +
+
+ 세액 + {formatAmount(formData.tax_amount || 0)}원 +
+
+ 합계 + + {formatAmount(formData.total_amount || 0)}원 + +
+
+
+
+ + {/* 공급자 탭 */} + +
+
+ + handleChange("supplier_business_no", e.target.value)} + className="h-9" + placeholder="000-00-00000" + /> +
+
+ + handleChange("supplier_name", e.target.value)} + className="h-9" + placeholder="상호명" + /> +
+
+ + handleChange("supplier_ceo_name", e.target.value)} + className="h-9" + placeholder="대표자명" + /> +
+
+ + handleChange("supplier_business_type", e.target.value)} + className="h-9" + placeholder="업태" + /> +
+
+ + handleChange("supplier_business_item", e.target.value)} + className="h-9" + placeholder="종목" + /> +
+
+ + handleChange("supplier_address", e.target.value)} + className="h-9" + placeholder="주소" + /> +
+
+
+ + {/* 공급받는자 탭 */} + +
+
+ + handleChange("buyer_business_no", e.target.value)} + className="h-9" + placeholder="000-00-00000" + /> +
+
+ + handleChange("buyer_name", e.target.value)} + className="h-9" + placeholder="상호명" + /> +
+
+ + handleChange("buyer_ceo_name", e.target.value)} + className="h-9" + placeholder="대표자명" + /> +
+
+ + handleChange("buyer_email", e.target.value)} + className="h-9" + placeholder="email@example.com" + /> +
+
+ + handleChange("buyer_address", e.target.value)} + className="h-9" + placeholder="주소" + /> +
+
+
+ + {/* 첨부파일 탭 */} + + {/* 업로드 영역 */} +
+ + +
+ + {/* 첨부파일 목록 */} + {attachments.length > 0 && ( +
+ +
+ {attachments.map((file) => ( +
+
+ {getFileIcon(file.file_type)} +
+

{file.file_name}

+

+ {formatFileSize(file.file_size)} +

+
+
+ +
+ ))} +
+
+ )} + + {attachments.length === 0 && ( +
+ + 첨부된 파일이 없습니다. +
+ )} +
+
+
+
+ + + + + +
+
+ ); +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceList.tsx b/frontend/components/tax-invoice/TaxInvoiceList.tsx new file mode 100644 index 00000000..a4c59822 --- /dev/null +++ b/frontend/components/tax-invoice/TaxInvoiceList.tsx @@ -0,0 +1,818 @@ +"use client"; + +/** + * 세금계산서 목록 컴포넌트 + * 세금계산서 목록 조회, 검색, 필터링 기능 + */ + +import { useState, useEffect, useCallback } from "react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { + Plus, + Search, + Filter, + FileText, + Eye, + Edit, + Trash2, + Send, + CheckCircle, + XCircle, + Clock, + RefreshCw, + Paperclip, + Image, + File, + ArrowUpDown, + ArrowUp, + ArrowDown, + X, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { toast } from "sonner"; + +import { + getTaxInvoiceList, + deleteTaxInvoice, + issueTaxInvoice, + cancelTaxInvoice, + TaxInvoice, + TaxInvoiceListParams, +} from "@/lib/api/taxInvoice"; +import { TaxInvoiceForm } from "./TaxInvoiceForm"; +import { TaxInvoiceDetail } from "./TaxInvoiceDetail"; + +// 상태 뱃지 색상 +const statusBadgeVariant: Record = { + draft: "outline", + issued: "default", + sent: "secondary", + cancelled: "destructive", +}; + +// 상태 라벨 +const statusLabels: Record = { + draft: "임시저장", + issued: "발행완료", + sent: "전송완료", + cancelled: "취소됨", +}; + +// 유형 라벨 +const typeLabels: Record = { + sales: "매출", + purchase: "매입", +}; + +// 컬럼 정의 +interface ColumnDef { + key: string; + label: string; + sortable?: boolean; + filterable?: boolean; + filterType?: "text" | "select"; + filterOptions?: { value: string; label: string }[]; + width?: string; + align?: "left" | "center" | "right"; +} + +const columns: ColumnDef[] = [ + { key: "invoice_number", label: "계산서번호", sortable: true, filterable: true, filterType: "text", width: "120px" }, + { key: "invoice_type", label: "유형", sortable: true, filterable: true, filterType: "select", + filterOptions: [{ value: "sales", label: "매출" }, { value: "purchase", label: "매입" }], width: "80px" }, + { key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select", + filterOptions: [ + { value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" }, + { value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" } + ], width: "100px" }, + { key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" }, + { key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" }, + { key: "attachments", label: "첨부", sortable: false, filterable: false, width: "60px", align: "center" }, + { key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" }, + { key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" }, + { key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" }, +]; + +export function TaxInvoiceList() { + // 상태 + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ + page: 1, + pageSize: 20, + total: 0, + totalPages: 0, + }); + + // 필터 상태 + const [filters, setFilters] = useState({ + page: 1, + pageSize: 20, + }); + const [searchText, setSearchText] = useState(""); + + // 정렬 상태 + const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + + // 컬럼별 필터 상태 + const [columnFilters, setColumnFilters] = useState>({}); + const [activeFilterColumn, setActiveFilterColumn] = useState(null); + + // 모달 상태 + const [showForm, setShowForm] = useState(false); + const [showDetail, setShowDetail] = useState(false); + const [selectedInvoice, setSelectedInvoice] = useState(null); + const [editMode, setEditMode] = useState(false); + + // 확인 다이얼로그 상태 + const [confirmDialog, setConfirmDialog] = useState<{ + open: boolean; + type: "delete" | "issue" | "cancel"; + invoice: TaxInvoice | null; + }>({ + open: false, + type: "delete", + invoice: null, + }); + + // 데이터 로드 + const loadData = useCallback(async () => { + setLoading(true); + try { + // 컬럼 필터를 API 파라미터에 추가 + const apiFilters: TaxInvoiceListParams = { + ...filters, + invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined, + invoice_status: columnFilters.invoice_status, + search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined, + }; + + const response = await getTaxInvoiceList(apiFilters); + if (response.success) { + let data = response.data; + + // 클라이언트 사이드 정렬 적용 + if (sortConfig) { + data = [...data].sort((a, b) => { + const aVal = a[sortConfig.key as keyof TaxInvoice]; + const bVal = b[sortConfig.key as keyof TaxInvoice]; + + if (aVal === null || aVal === undefined) return 1; + if (bVal === null || bVal === undefined) return -1; + + // 숫자 비교 + if (typeof aVal === "number" && typeof bVal === "number") { + return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal; + } + + // 문자열 비교 + const strA = String(aVal).toLowerCase(); + const strB = String(bVal).toLowerCase(); + if (sortConfig.direction === "asc") { + return strA.localeCompare(strB, "ko"); + } + return strB.localeCompare(strA, "ko"); + }); + } + + // 클라이언트 사이드 필터 적용 (날짜 필터) + if (columnFilters.invoice_date) { + data = data.filter((item) => + item.invoice_date?.includes(columnFilters.invoice_date) + ); + } + + setInvoices(data); + setPagination(response.pagination); + } + } catch (error: any) { + toast.error("데이터 로드 실패", { description: error.message }); + } finally { + setLoading(false); + } + }, [filters, sortConfig, columnFilters, searchText]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 정렬 핸들러 + const handleSort = (columnKey: string) => { + setSortConfig((prev) => { + if (prev?.key === columnKey) { + // 같은 컬럼 클릭: asc -> desc -> null 순환 + if (prev.direction === "asc") return { key: columnKey, direction: "desc" }; + return null; + } + // 새 컬럼: asc로 시작 + return { key: columnKey, direction: "asc" }; + }); + }; + + // 컬럼 필터 핸들러 + const handleColumnFilter = (columnKey: string, value: string) => { + setColumnFilters((prev) => { + if (!value) { + const { [columnKey]: _, ...rest } = prev; + return rest; + } + return { ...prev, [columnKey]: value }; + }); + setFilters((prev) => ({ ...prev, page: 1 })); // 필터 변경 시 첫 페이지로 + }; + + // 필터 초기화 + const clearColumnFilter = (columnKey: string) => { + setColumnFilters((prev) => { + const { [columnKey]: _, ...rest } = prev; + return rest; + }); + setActiveFilterColumn(null); + }; + + // 모든 필터 초기화 + const clearAllFilters = () => { + setColumnFilters({}); + setSortConfig(null); + setSearchText(""); + setFilters({ page: 1, pageSize: 20 }); + }; + + // 정렬 아이콘 렌더링 + const renderSortIcon = (columnKey: string) => { + if (sortConfig?.key !== columnKey) { + return ; + } + return sortConfig.direction === "asc" + ? + : ; + }; + + // 검색 + const handleSearch = () => { + setFilters((prev) => ({ ...prev, search: searchText, page: 1 })); + }; + + // 필터 변경 + const handleFilterChange = (key: keyof TaxInvoiceListParams, value: string | undefined) => { + setFilters((prev) => ({ + ...prev, + [key]: value === "all" ? undefined : value, + page: 1, + })); + }; + + // 새 세금계산서 + const handleNew = () => { + setSelectedInvoice(null); + setEditMode(false); + setShowForm(true); + }; + + // 상세 보기 + const handleView = (invoice: TaxInvoice) => { + setSelectedInvoice(invoice); + setShowDetail(true); + }; + + // 수정 + const handleEdit = (invoice: TaxInvoice) => { + if (invoice.invoice_status !== "draft") { + toast.warning("임시저장 상태의 세금계산서만 수정할 수 있습니다."); + return; + } + setSelectedInvoice(invoice); + setEditMode(true); + setShowForm(true); + }; + + // 삭제 확인 + const handleDeleteConfirm = (invoice: TaxInvoice) => { + if (invoice.invoice_status !== "draft") { + toast.warning("임시저장 상태의 세금계산서만 삭제할 수 있습니다."); + return; + } + setConfirmDialog({ open: true, type: "delete", invoice }); + }; + + // 발행 확인 + const handleIssueConfirm = (invoice: TaxInvoice) => { + if (invoice.invoice_status !== "draft") { + toast.warning("임시저장 상태의 세금계산서만 발행할 수 있습니다."); + return; + } + setConfirmDialog({ open: true, type: "issue", invoice }); + }; + + // 취소 확인 + const handleCancelConfirm = (invoice: TaxInvoice) => { + if (!["draft", "issued"].includes(invoice.invoice_status)) { + toast.warning("취소할 수 없는 상태입니다."); + return; + } + setConfirmDialog({ open: true, type: "cancel", invoice }); + }; + + // 확인 다이얼로그 실행 + const handleConfirmAction = async () => { + const { type, invoice } = confirmDialog; + if (!invoice) return; + + try { + if (type === "delete") { + const response = await deleteTaxInvoice(invoice.id); + if (response.success) { + toast.success("세금계산서가 삭제되었습니다."); + loadData(); + } + } else if (type === "issue") { + const response = await issueTaxInvoice(invoice.id); + if (response.success) { + toast.success("세금계산서가 발행되었습니다."); + loadData(); + } + } else if (type === "cancel") { + const response = await cancelTaxInvoice(invoice.id); + if (response.success) { + toast.success("세금계산서가 취소되었습니다."); + loadData(); + } + } + } catch (error: any) { + toast.error("작업 실패", { description: error.message }); + } finally { + setConfirmDialog({ open: false, type: "delete", invoice: null }); + } + }; + + // 폼 저장 완료 + const handleFormSave = () => { + setShowForm(false); + setSelectedInvoice(null); + loadData(); + }; + + // 금액 포맷 + const formatAmount = (amount: number) => { + return new Intl.NumberFormat("ko-KR").format(amount); + }; + + // 날짜 포맷 + const formatDate = (dateString: string) => { + try { + return format(new Date(dateString), "yyyy-MM-dd", { locale: ko }); + } catch { + return dateString; + } + }; + + return ( +
+ {/* 헤더 */} +
+

세금계산서 관리

+ +
+ + {/* 필터 영역 */} + + +
+ {/* 검색 */} +
+ +
+ setSearchText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + className="h-9" + /> + +
+
+ + {/* 유형 필터 */} +
+ + +
+ + {/* 상태 필터 */} +
+ + +
+ + {/* 새로고침 */} + + + {/* 필터 초기화 */} + {(Object.keys(columnFilters).length > 0 || sortConfig) && ( + + )} +
+ + {/* 활성 필터 표시 */} + {Object.keys(columnFilters).length > 0 && ( +
+ {Object.entries(columnFilters).map(([key, value]) => { + const column = columns.find((c) => c.key === key); + let displayValue = value; + if (column?.filterOptions) { + displayValue = column.filterOptions.find((o) => o.value === value)?.label || value; + } + return ( + + {column?.label}: {displayValue} + + + ); + })} +
+ )} +
+
+ + {/* 테이블 */} + + + + + + {columns.map((column) => ( + +
+ {/* 컬럼 필터 (filterable인 경우) */} + {column.filterable && ( + setActiveFilterColumn(open ? column.key : null)} + > + + + + +
+
{column.label} 필터
+ {column.filterType === "select" ? ( + + ) : ( + handleColumnFilter(column.key, e.target.value)} + onKeyDown={(e) => e.key === "Enter" && setActiveFilterColumn(null)} + className="h-8 text-xs" + autoFocus + /> + )} + {columnFilters[column.key] && ( + + )} +
+
+
+ )} + + {/* 컬럼 라벨 + 정렬 */} + {column.sortable ? ( + + ) : ( + {column.label} + )} +
+
+ ))} + 작업 +
+
+ + {loading ? ( + + + 로딩 중... + + + ) : invoices.length === 0 ? ( + + + + 세금계산서가 없습니다. + + + ) : ( + invoices.map((invoice) => ( + + {invoice.invoice_number} + + + {typeLabels[invoice.invoice_type]} + + + + + {statusLabels[invoice.invoice_status]} + + + {formatDate(invoice.invoice_date)} + + {invoice.buyer_name || "-"} + + + {invoice.attachments && invoice.attachments.length > 0 ? ( +
+ + + {invoice.attachments.length} + +
+ ) : ( + - + )} +
+ + {formatAmount(invoice.supply_amount)} + + + {formatAmount(invoice.tax_amount)} + + + {formatAmount(invoice.total_amount)} + + +
+ + {invoice.invoice_status === "draft" && ( + <> + + + + + )} + {invoice.invoice_status === "issued" && ( + + )} +
+
+
+ )) + )} +
+
+
+
+ + {/* 페이지네이션 */} + {pagination.totalPages > 1 && ( +
+
+ 총 {pagination.total}건 중 {(pagination.page - 1) * pagination.pageSize + 1}- + {Math.min(pagination.page * pagination.pageSize, pagination.total)}건 +
+
+ + +
+
+ )} + + {/* 세금계산서 작성/수정 폼 */} + {showForm && ( + setShowForm(false)} + onSave={handleFormSave} + invoice={editMode ? selectedInvoice : null} + /> + )} + + {/* 세금계산서 상세 */} + {showDetail && selectedInvoice && ( + setShowDetail(false)} + invoiceId={selectedInvoice.id} + /> + )} + + {/* 확인 다이얼로그 */} + !open && setConfirmDialog({ ...confirmDialog, open: false })} + > + + + + {confirmDialog.type === "delete" && "세금계산서 삭제"} + {confirmDialog.type === "issue" && "세금계산서 발행"} + {confirmDialog.type === "cancel" && "세금계산서 취소"} + + + {confirmDialog.type === "delete" && + "이 세금계산서를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."} + {confirmDialog.type === "issue" && + "이 세금계산서를 발행하시겠습니까? 발행 후에는 수정할 수 없습니다."} + {confirmDialog.type === "cancel" && "이 세금계산서를 취소하시겠습니까?"} + + + + + + + + +
+ ); +} + diff --git a/frontend/components/tax-invoice/index.ts b/frontend/components/tax-invoice/index.ts new file mode 100644 index 00000000..149e2812 --- /dev/null +++ b/frontend/components/tax-invoice/index.ts @@ -0,0 +1,4 @@ +export { TaxInvoiceList } from "./TaxInvoiceList"; +export { TaxInvoiceForm } from "./TaxInvoiceForm"; +export { TaxInvoiceDetail } from "./TaxInvoiceDetail"; + diff --git a/frontend/lib/api/taxInvoice.ts b/frontend/lib/api/taxInvoice.ts new file mode 100644 index 00000000..be41f24c --- /dev/null +++ b/frontend/lib/api/taxInvoice.ts @@ -0,0 +1,229 @@ +/** + * 세금계산서 API 클라이언트 + */ + +import { apiClient } from "./client"; + +// 세금계산서 타입 +export interface TaxInvoice { + id: string; + company_code: string; + invoice_number: string; + invoice_type: "sales" | "purchase"; + invoice_status: "draft" | "issued" | "sent" | "cancelled"; + supplier_business_no: string; + supplier_name: string; + supplier_ceo_name: string; + supplier_address: string; + supplier_business_type: string; + supplier_business_item: string; + buyer_business_no: string; + buyer_name: string; + buyer_ceo_name: string; + buyer_address: string; + buyer_email: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + invoice_date: string; + issue_date: string | null; + remarks: string; + order_id: string | null; + customer_id: string | null; + attachments: TaxInvoiceAttachment[] | null; + created_date: string; + updated_date: string; + writer: string; +} + +// 첨부파일 타입 +export interface TaxInvoiceAttachment { + id: string; + file_name: string; + file_path: string; + file_size: number; + file_type: string; + uploaded_at: string; + uploaded_by: string; +} + +// 세금계산서 품목 타입 +export interface TaxInvoiceItem { + id: string; + tax_invoice_id: string; + company_code: string; + item_seq: number; + item_date: string; + item_name: string; + item_spec: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks: string; +} + +// 생성 DTO +export interface CreateTaxInvoiceDto { + invoice_type: "sales" | "purchase"; + supplier_business_no?: string; + supplier_name?: string; + supplier_ceo_name?: string; + supplier_address?: string; + supplier_business_type?: string; + supplier_business_item?: string; + buyer_business_no?: string; + buyer_name?: string; + buyer_ceo_name?: string; + buyer_address?: string; + buyer_email?: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + invoice_date: string; + remarks?: string; + order_id?: string; + customer_id?: string; + items?: CreateTaxInvoiceItemDto[]; + attachments?: TaxInvoiceAttachment[]; +} + +// 품목 생성 DTO +export interface CreateTaxInvoiceItemDto { + item_date?: string; + item_name: string; + item_spec?: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks?: string; +} + +// 목록 조회 파라미터 +export interface TaxInvoiceListParams { + page?: number; + pageSize?: number; + invoice_type?: "sales" | "purchase"; + invoice_status?: string; + start_date?: string; + end_date?: string; + search?: string; + buyer_name?: string; +} + +// 목록 응답 +export interface TaxInvoiceListResponse { + success: boolean; + data: TaxInvoice[]; + pagination: { + page: number; + pageSize: number; + total: number; + totalPages: number; + }; +} + +// 상세 응답 +export interface TaxInvoiceDetailResponse { + success: boolean; + data: { + invoice: TaxInvoice; + items: TaxInvoiceItem[]; + }; +} + +// 월별 통계 응답 +export interface TaxInvoiceMonthlyStatsResponse { + success: boolean; + data: { + sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + }; + period: { year: number; month: number }; +} + +/** + * 세금계산서 목록 조회 + */ +export async function getTaxInvoiceList( + params?: TaxInvoiceListParams +): Promise { + const response = await apiClient.get("/tax-invoice", { params }); + return response.data; +} + +/** + * 세금계산서 상세 조회 + */ +export async function getTaxInvoiceById(id: string): Promise { + const response = await apiClient.get(`/tax-invoice/${id}`); + return response.data; +} + +/** + * 세금계산서 생성 + */ +export async function createTaxInvoice( + data: CreateTaxInvoiceDto +): Promise<{ success: boolean; data: TaxInvoice; message: string }> { + const response = await apiClient.post("/tax-invoice", data); + return response.data; +} + +/** + * 세금계산서 수정 + */ +export async function updateTaxInvoice( + id: string, + data: Partial +): Promise<{ success: boolean; data: TaxInvoice; message: string }> { + const response = await apiClient.put(`/tax-invoice/${id}`, data); + return response.data; +} + +/** + * 세금계산서 삭제 + */ +export async function deleteTaxInvoice( + id: string +): Promise<{ success: boolean; message: string }> { + const response = await apiClient.delete(`/tax-invoice/${id}`); + return response.data; +} + +/** + * 세금계산서 발행 + */ +export async function issueTaxInvoice( + id: string +): Promise<{ success: boolean; data: TaxInvoice; message: string }> { + const response = await apiClient.post(`/tax-invoice/${id}/issue`); + return response.data; +} + +/** + * 세금계산서 취소 + */ +export async function cancelTaxInvoice( + id: string, + reason?: string +): Promise<{ success: boolean; data: TaxInvoice; message: string }> { + const response = await apiClient.post(`/tax-invoice/${id}/cancel`, { reason }); + return response.data; +} + +/** + * 월별 통계 조회 + */ +export async function getTaxInvoiceMonthlyStats( + year?: number, + month?: number +): Promise { + const params: Record = {}; + if (year) params.year = year; + if (month) params.month = month; + const response = await apiClient.get("/tax-invoice/stats/monthly", { params }); + return response.data; +} + diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 2a5d45e4..485de6b9 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 // 🆕 범용 폼 모달 컴포넌트 import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원 +// 🆕 세금계산서 관리 컴포넌트 +import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListComponent.tsx b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListComponent.tsx new file mode 100644 index 00000000..43bad4f2 --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListComponent.tsx @@ -0,0 +1,48 @@ +"use client"; + +/** + * 세금계산서 목록 컴포넌트 (레지스트리용 래퍼) + */ + +import React from "react"; +import { TaxInvoiceList } from "@/components/tax-invoice"; +import { TaxInvoiceListConfig } from "./types"; + +interface TaxInvoiceListComponentProps { + config?: TaxInvoiceListConfig; + componentId?: string; + isEditMode?: boolean; +} + +export function TaxInvoiceListComponent({ + config, + componentId, + isEditMode, +}: TaxInvoiceListComponentProps) { + // 편집 모드에서는 플레이스홀더 표시 + if (isEditMode) { + return ( +
+
+
📄
+

세금계산서 목록

+

+ {config?.title || "세금계산서 관리"} +

+
+
+ ); + } + + return ( +
+ +
+ ); +} + +// 래퍼 컴포넌트 (레지스트리 호환용) +export function TaxInvoiceListWrapper(props: any) { + return ; +} + diff --git a/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel.tsx b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel.tsx new file mode 100644 index 00000000..fc3fd62b --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel.tsx @@ -0,0 +1,166 @@ +"use client"; + +/** + * 세금계산서 목록 설정 패널 + */ + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types"; + +interface TaxInvoiceListConfigPanelProps { + config: TaxInvoiceListConfig; + onChange: (config: TaxInvoiceListConfig) => void; +} + +export function TaxInvoiceListConfigPanel({ + config, + onChange, +}: TaxInvoiceListConfigPanelProps) { + const currentConfig = { ...defaultTaxInvoiceListConfig, ...config }; + + const handleChange = (key: keyof TaxInvoiceListConfig, value: any) => { + onChange({ ...currentConfig, [key]: value }); + }; + + return ( +
+ {/* 기본 설정 */} +
+

기본 설정

+ +
+ + handleChange("title", e.target.value)} + placeholder="세금계산서 관리" + className="h-8 text-xs" + /> +
+ +
+ + handleChange("showHeader", checked)} + /> +
+
+ + {/* 기본 필터 */} +
+

기본 필터

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* 권한 설정 */} +
+

권한 설정

+ +
+ + handleChange("canCreate", checked)} + /> +
+ +
+ + handleChange("canEdit", checked)} + /> +
+ +
+ + handleChange("canDelete", checked)} + /> +
+ +
+ + handleChange("canIssue", checked)} + /> +
+ +
+ + handleChange("canCancel", checked)} + /> +
+
+
+ ); +} + diff --git a/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListRenderer.tsx b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListRenderer.tsx new file mode 100644 index 00000000..7f4b2806 --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/TaxInvoiceListRenderer.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { TaxInvoiceListDefinition } from "./index"; +import { TaxInvoiceListComponent } from "./TaxInvoiceListComponent"; + +/** + * 세금계산서 목록 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class TaxInvoiceListRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = TaxInvoiceListDefinition; + + render(): React.ReactElement { + return ; + } +} + +// 자동 등록 실행 +TaxInvoiceListRenderer.registerSelf(); + +// 강제 등록 (디버깅용) +if (typeof window !== "undefined") { + setTimeout(() => { + try { + TaxInvoiceListRenderer.registerSelf(); + } catch (error) { + console.error("TaxInvoiceList 강제 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/tax-invoice-list/index.ts b/frontend/lib/registry/components/tax-invoice-list/index.ts new file mode 100644 index 00000000..b7a589cc --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/index.ts @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent"; +import { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel"; +import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types"; + +/** + * 세금계산서 목록 컴포넌트 정의 + * 세금계산서 CRUD, 발행, 취소 기능을 제공하는 컴포넌트 + */ +export const TaxInvoiceListDefinition = createComponentDefinition({ + id: "tax-invoice-list", + name: "세금계산서 목록", + nameEng: "Tax Invoice List", + description: "세금계산서 목록 조회, 작성, 발행, 취소 기능을 제공하는 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: TaxInvoiceListWrapper, + defaultConfig: defaultTaxInvoiceListConfig, + defaultSize: { width: 1200, height: 700 }, + configPanel: TaxInvoiceListConfigPanel, + icon: "FileText", + tags: ["세금계산서", "매출", "매입", "발행", "인보이스"], + version: "1.0.0", + author: "개발팀", + documentation: "", +}); + +// 타입 내보내기 +export type { TaxInvoiceListConfig } from "./types"; +export { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent"; +export { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel"; +export { TaxInvoiceListRenderer } from "./TaxInvoiceListRenderer"; + diff --git a/frontend/lib/registry/components/tax-invoice-list/types.ts b/frontend/lib/registry/components/tax-invoice-list/types.ts new file mode 100644 index 00000000..57ebb948 --- /dev/null +++ b/frontend/lib/registry/components/tax-invoice-list/types.ts @@ -0,0 +1,41 @@ +/** + * 세금계산서 목록 컴포넌트 타입 정의 + */ + +export interface TaxInvoiceListConfig { + // 기본 설정 + title?: string; + showHeader?: boolean; + + // 필터 설정 + defaultInvoiceType?: "all" | "sales" | "purchase"; + defaultStatus?: "all" | "draft" | "issued" | "sent" | "cancelled"; + + // 페이지네이션 + pageSize?: number; + + // 권한 설정 + canCreate?: boolean; + canEdit?: boolean; + canDelete?: boolean; + canIssue?: boolean; + canCancel?: boolean; + + // 스타일 + height?: string | number; +} + +export const defaultTaxInvoiceListConfig: TaxInvoiceListConfig = { + title: "세금계산서 관리", + showHeader: true, + defaultInvoiceType: "all", + defaultStatus: "all", + pageSize: 20, + canCreate: true, + canEdit: true, + canDelete: true, + canIssue: true, + canCancel: true, + height: "auto", +}; + From ed1626d3912dc167d0033d9c578468b0564b3089 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 8 Dec 2025 16:18:44 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/taxInvoiceController.ts | 34 ++ backend-node/src/routes/taxInvoiceRoutes.ts | 3 + .../src/services/taxInvoiceService.ts | 176 +++++++++- .../components/tax-invoice/CostTypeStats.tsx | 329 ++++++++++++++++++ .../components/tax-invoice/TaxInvoiceForm.tsx | 24 +- .../components/tax-invoice/TaxInvoiceList.tsx | 24 +- frontend/lib/api/taxInvoice.ts | 61 ++++ 7 files changed, 643 insertions(+), 8 deletions(-) create mode 100644 frontend/components/tax-invoice/CostTypeStats.tsx diff --git a/backend-node/src/controllers/taxInvoiceController.ts b/backend-node/src/controllers/taxInvoiceController.ts index 588a856c..5b7f4436 100644 --- a/backend-node/src/controllers/taxInvoiceController.ts +++ b/backend-node/src/controllers/taxInvoiceController.ts @@ -36,6 +36,7 @@ export class TaxInvoiceController { end_date, search, buyer_name, + cost_type, } = req.query; const result = await TaxInvoiceService.getList(companyCode, { @@ -47,6 +48,7 @@ export class TaxInvoiceController { end_date: end_date as string | undefined, search: search as string | undefined, buyer_name: buyer_name as string | undefined, + cost_type: cost_type as any, }); res.json({ @@ -327,5 +329,37 @@ export class TaxInvoiceController { }); } } + + /** + * 비용 유형별 통계 조회 + * GET /api/tax-invoice/stats/cost-type + */ + static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { year, month } = req.query; + const targetYear = year ? parseInt(year as string, 10) : undefined; + const targetMonth = month ? parseInt(month as string, 10) : undefined; + + const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth); + + res.json({ + success: true, + data: result, + period: { year: targetYear, month: targetMonth }, + }); + } catch (error: any) { + logger.error("비용 유형별 통계 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "통계 조회 중 오류가 발생했습니다.", + }); + } + } } diff --git a/backend-node/src/routes/taxInvoiceRoutes.ts b/backend-node/src/routes/taxInvoiceRoutes.ts index aa663faf..1a4bc6f0 100644 --- a/backend-node/src/routes/taxInvoiceRoutes.ts +++ b/backend-node/src/routes/taxInvoiceRoutes.ts @@ -18,6 +18,9 @@ router.get("/", TaxInvoiceController.getList); // 월별 통계 (상세 조회보다 먼저 정의해야 함) router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats); +// 비용 유형별 통계 +router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats); + // 상세 조회 router.get("/:id", TaxInvoiceController.getById); diff --git a/backend-node/src/services/taxInvoiceService.ts b/backend-node/src/services/taxInvoiceService.ts index 63e94d5e..73577bb0 100644 --- a/backend-node/src/services/taxInvoiceService.ts +++ b/backend-node/src/services/taxInvoiceService.ts @@ -6,6 +6,9 @@ import { query, transaction } from "../database/db"; import { logger } from "../utils/logger"; +// 비용 유형 타입 +export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other"; + // 세금계산서 타입 정의 export interface TaxInvoice { id: string; @@ -46,6 +49,9 @@ export interface TaxInvoice { // 첨부파일 (JSON 배열로 저장) attachments: TaxInvoiceAttachment[] | null; + // 비용 유형 (구매/설치/수리/유지보수/폐기/기타) + cost_type: CostType | null; + created_date: string; updated_date: string; writer: string; @@ -99,6 +105,7 @@ export interface CreateTaxInvoiceDto { customer_id?: string; items?: CreateTaxInvoiceItemDto[]; attachments?: TaxInvoiceAttachment[]; // 첨부파일 + cost_type?: CostType; // 비용 유형 } export interface CreateTaxInvoiceItemDto { @@ -121,6 +128,7 @@ export interface TaxInvoiceListParams { end_date?: string; search?: string; buyer_name?: string; + cost_type?: CostType; // 비용 유형 필터 } export class TaxInvoiceService { @@ -169,6 +177,7 @@ export class TaxInvoiceService { end_date, search, buyer_name, + cost_type, } = params; const offset = (page - 1) * pageSize; @@ -214,6 +223,12 @@ export class TaxInvoiceService { paramIndex++; } + if (cost_type) { + conditions.push(`cost_type = $${paramIndex}`); + values.push(cost_type); + paramIndex++; + } + const whereClause = conditions.join(" AND "); // 전체 개수 조회 @@ -282,13 +297,13 @@ export class TaxInvoiceService { supplier_business_type, supplier_business_item, buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email, supply_amount, tax_amount, total_amount, invoice_date, - remarks, order_id, customer_id, attachments, writer + remarks, order_id, customer_id, attachments, cost_type, writer ) VALUES ( $1, $2, $3, 'draft', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, - $19, $20, $21, $22, $23 + $19, $20, $21, $22, $23, $24 ) RETURNING *`, [ companyCode, @@ -313,6 +328,7 @@ export class TaxInvoiceService { data.order_id || null, data.customer_id || null, data.attachments ? JSON.stringify(data.attachments) : null, + data.cost_type || null, userId, ] ); @@ -402,6 +418,7 @@ export class TaxInvoiceService { invoice_date = COALESCE($17, invoice_date), remarks = COALESCE($18, remarks), attachments = $19, + cost_type = COALESCE($20, cost_type), updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING *`, @@ -425,6 +442,7 @@ export class TaxInvoiceService { data.invoice_date, data.remarks, data.attachments ? JSON.stringify(data.attachments) : null, + data.cost_type, ] ); @@ -608,5 +626,159 @@ export class TaxInvoiceService { return stats; } + + /** + * 비용 유형별 통계 조회 + */ + static async getCostTypeStats( + companyCode: string, + year?: number, + month?: number + ): Promise<{ + by_cost_type: Array<{ + cost_type: CostType | null; + count: number; + supply_amount: number; + tax_amount: number; + total_amount: number; + }>; + by_month: Array<{ + year_month: string; + cost_type: CostType | null; + count: number; + total_amount: number; + }>; + summary: { + total_count: number; + total_amount: number; + purchase_amount: number; + installation_amount: number; + repair_amount: number; + maintenance_amount: number; + disposal_amount: number; + other_amount: number; + }; + }> { + const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"]; + const values: any[] = [companyCode]; + let paramIndex = 2; + + // 연도/월 필터 + if (year && month) { + const startDate = `${year}-${String(month).padStart(2, "0")}-01`; + const endDate = new Date(year, month, 0).toISOString().split("T")[0]; + conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`); + values.push(startDate, endDate); + paramIndex += 2; + } else if (year) { + conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`); + values.push(year); + paramIndex++; + } + + const whereClause = conditions.join(" AND "); + + // 비용 유형별 집계 + const byCostType = await query<{ + cost_type: CostType | null; + count: string; + supply_amount: string; + tax_amount: string; + total_amount: string; + }>( + `SELECT + cost_type, + COUNT(*) as count, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE ${whereClause} + GROUP BY cost_type + ORDER BY total_amount DESC`, + values + ); + + // 월별 비용 유형 집계 + const byMonth = await query<{ + year_month: string; + cost_type: CostType | null; + count: string; + total_amount: string; + }>( + `SELECT + TO_CHAR(invoice_date, 'YYYY-MM') as year_month, + cost_type, + COUNT(*) as count, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE ${whereClause} + GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type + ORDER BY year_month DESC, cost_type`, + values + ); + + // 전체 요약 + const summaryResult = await query<{ + total_count: string; + total_amount: string; + purchase_amount: string; + installation_amount: string; + repair_amount: string; + maintenance_amount: string; + disposal_amount: string; + other_amount: string; + }>( + `SELECT + COUNT(*) as total_count, + COALESCE(SUM(total_amount), 0) as total_amount, + COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount, + COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount, + COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount, + COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount, + COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount, + COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount + FROM tax_invoice + WHERE ${whereClause}`, + values + ); + + const summary = summaryResult[0] || { + total_count: "0", + total_amount: "0", + purchase_amount: "0", + installation_amount: "0", + repair_amount: "0", + maintenance_amount: "0", + disposal_amount: "0", + other_amount: "0", + }; + + return { + by_cost_type: byCostType.map((row) => ({ + cost_type: row.cost_type, + count: parseInt(row.count, 10), + supply_amount: parseFloat(row.supply_amount), + tax_amount: parseFloat(row.tax_amount), + total_amount: parseFloat(row.total_amount), + })), + by_month: byMonth.map((row) => ({ + year_month: row.year_month, + cost_type: row.cost_type, + count: parseInt(row.count, 10), + total_amount: parseFloat(row.total_amount), + })), + summary: { + total_count: parseInt(summary.total_count, 10), + total_amount: parseFloat(summary.total_amount), + purchase_amount: parseFloat(summary.purchase_amount), + installation_amount: parseFloat(summary.installation_amount), + repair_amount: parseFloat(summary.repair_amount), + maintenance_amount: parseFloat(summary.maintenance_amount), + disposal_amount: parseFloat(summary.disposal_amount), + other_amount: parseFloat(summary.other_amount), + }, + }; + } } diff --git a/frontend/components/tax-invoice/CostTypeStats.tsx b/frontend/components/tax-invoice/CostTypeStats.tsx new file mode 100644 index 00000000..786c093a --- /dev/null +++ b/frontend/components/tax-invoice/CostTypeStats.tsx @@ -0,0 +1,329 @@ +"use client"; + +/** + * 비용 유형별 통계 대시보드 + * 구매/설치/수리/유지보수/폐기 등 비용 정산 현황 + */ + +import { useState, useEffect, useCallback } from "react"; +import { + BarChart3, + TrendingUp, + TrendingDown, + Package, + Wrench, + Settings, + Trash2, + DollarSign, + Calendar, + RefreshCw, +} from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { toast } from "sonner"; + +import { getCostTypeStats, CostTypeStatsResponse, CostType, costTypeLabels } from "@/lib/api/taxInvoice"; + +// 비용 유형별 아이콘 +const costTypeIcons: Record = { + purchase: , + installation: , + repair: , + maintenance: , + disposal: , + other: , +}; + +// 비용 유형별 색상 +const costTypeColors: Record = { + purchase: "bg-blue-500", + installation: "bg-green-500", + repair: "bg-orange-500", + maintenance: "bg-purple-500", + disposal: "bg-red-500", + other: "bg-gray-500", +}; + +export function CostTypeStats() { + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [selectedMonth, setSelectedMonth] = useState(undefined); + + // 연도 옵션 생성 (최근 5년) + const yearOptions = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i); + + // 월 옵션 생성 + const monthOptions = Array.from({ length: 12 }, (_, i) => i + 1); + + // 데이터 로드 + const loadStats = useCallback(async () => { + setLoading(true); + try { + const response = await getCostTypeStats(selectedYear, selectedMonth); + if (response.success) { + setStats(response.data); + } + } catch (error: any) { + toast.error("통계 로드 실패", { description: error.message }); + } finally { + setLoading(false); + } + }, [selectedYear, selectedMonth]); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + // 금액 포맷 + const formatAmount = (amount: number) => { + if (amount >= 100000000) { + return `${(amount / 100000000).toFixed(1)}억`; + } + if (amount >= 10000) { + return `${(amount / 10000).toFixed(0)}만`; + } + return new Intl.NumberFormat("ko-KR").format(amount); + }; + + // 전체 금액 대비 비율 계산 + const getPercentage = (amount: number) => { + if (!stats?.summary.total_amount || stats.summary.total_amount === 0) return 0; + return (amount / stats.summary.total_amount) * 100; + }; + + return ( +
+ {/* 헤더 */} +
+
+

비용 정산 현황

+

구매/설치/수리/유지보수/폐기 비용 통계

+
+
+ + + +
+
+ + {/* 요약 카드 */} +
+ + + 총 비용 + + + +
+ {formatAmount(stats?.summary.total_amount || 0)}원 +
+

+ {stats?.summary.total_count || 0}건 +

+
+
+ + + + 구매 비용 + + + +
+ {formatAmount(stats?.summary.purchase_amount || 0)}원 +
+ +
+
+ + + + 수리/유지보수 + + + +
+ {formatAmount((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}원 +
+ +
+
+ + + + 설치/폐기 + + + +
+ {formatAmount((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}원 +
+ +
+
+
+ + {/* 비용 유형별 상세 */} + + + 비용 유형별 상세 + 각 비용 유형별 금액 및 비율 + + +
+ {stats?.by_cost_type && stats.by_cost_type.length > 0 ? ( + stats.by_cost_type.map((item) => { + const costType = item.cost_type as CostType; + const percentage = getPercentage(item.total_amount); + return ( +
+
+ {costType && costTypeIcons[costType]} + + {costType ? costTypeLabels[costType] : "미분류"} + +
+
+
+
+
+
+ + {percentage.toFixed(1)}% + +
+
+
+
+ {formatAmount(item.total_amount)}원 +
+
{item.count}건
+
+
+ ); + }) + ) : ( +
+ 데이터가 없습니다. +
+ )} +
+ + + + {/* 월별 추이 */} + {!selectedMonth && stats?.by_month && stats.by_month.length > 0 && ( + + + 월별 비용 추이 + {selectedYear}년 월별 비용 현황 + + +
+ {/* 월별 그룹핑 */} + {Array.from(new Set(stats.by_month.map((item) => item.year_month))) + .sort() + .reverse() + .slice(0, 6) + .map((yearMonth) => { + const monthData = stats.by_month.filter((item) => item.year_month === yearMonth); + const monthTotal = monthData.reduce((sum, item) => sum + item.total_amount, 0); + const [year, month] = yearMonth.split("-"); + + return ( +
+
+ {month}월 +
+
+ {monthData.map((item) => { + const costType = item.cost_type as CostType; + const width = monthTotal > 0 ? (item.total_amount / monthTotal) * 100 : 0; + return ( +
+ ); + })} +
+
+ {formatAmount(monthTotal)}원 +
+
+ ); + })} +
+ + {/* 범례 */} +
+ {Object.entries(costTypeLabels).map(([key, label]) => ( +
+
+ {label} +
+ ))} +
+ + + )} +
+ ); +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceForm.tsx b/frontend/components/tax-invoice/TaxInvoiceForm.tsx index 08c3fb37..9112ad33 100644 --- a/frontend/components/tax-invoice/TaxInvoiceForm.tsx +++ b/frontend/components/tax-invoice/TaxInvoiceForm.tsx @@ -59,6 +59,8 @@ import { TaxInvoiceAttachment, CreateTaxInvoiceDto, CreateTaxInvoiceItemDto, + CostType, + costTypeLabels, } from "@/lib/api/taxInvoice"; import { apiClient } from "@/lib/api/client"; @@ -141,6 +143,7 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor tax_amount: inv.tax_amount, total_amount: inv.total_amount, remarks: inv.remarks, + cost_type: inv.cost_type || undefined, items: items.length > 0 ? items.map((item) => ({ @@ -344,7 +347,7 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor {/* 기본정보 탭 */} -
+
+
+ + +
({ value, label })), width: "90px" }, { key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select", filterOptions: [ { value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" }, { value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" } - ], width: "100px" }, + ], width: "90px" }, { key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" }, { key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" }, - { key: "attachments", label: "첨부", sortable: false, filterable: false, width: "60px", align: "center" }, + { key: "attachments", label: "첨부", sortable: false, filterable: false, width: "50px", align: "center" }, { key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" }, { key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" }, { key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" }, @@ -178,6 +182,7 @@ export function TaxInvoiceList() { ...filters, invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined, invoice_status: columnFilters.invoice_status, + cost_type: columnFilters.cost_type as CostType | undefined, search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined, }; @@ -614,13 +619,13 @@ export function TaxInvoiceList() { {loading ? ( - + 로딩 중... ) : invoices.length === 0 ? ( - + 세금계산서가 없습니다. @@ -634,6 +639,15 @@ export function TaxInvoiceList() { {typeLabels[invoice.invoice_type]} + + {invoice.cost_type ? ( + + {costTypeLabels[invoice.cost_type as CostType]} + + ) : ( + - + )} + {statusLabels[invoice.invoice_status]} diff --git a/frontend/lib/api/taxInvoice.ts b/frontend/lib/api/taxInvoice.ts index be41f24c..493f99a1 100644 --- a/frontend/lib/api/taxInvoice.ts +++ b/frontend/lib/api/taxInvoice.ts @@ -4,6 +4,19 @@ import { apiClient } from "./client"; +// 비용 유형 +export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other"; + +// 비용 유형 라벨 +export const costTypeLabels: Record = { + purchase: "구매", + installation: "설치", + repair: "수리", + maintenance: "유지보수", + disposal: "폐기", + other: "기타", +}; + // 세금계산서 타입 export interface TaxInvoice { id: string; @@ -31,6 +44,7 @@ export interface TaxInvoice { order_id: string | null; customer_id: string | null; attachments: TaxInvoiceAttachment[] | null; + cost_type: CostType | null; // 비용 유형 created_date: string; updated_date: string; writer: string; @@ -86,6 +100,7 @@ export interface CreateTaxInvoiceDto { customer_id?: string; items?: CreateTaxInvoiceItemDto[]; attachments?: TaxInvoiceAttachment[]; + cost_type?: CostType; // 비용 유형 } // 품목 생성 DTO @@ -110,6 +125,7 @@ export interface TaxInvoiceListParams { end_date?: string; search?: string; buyer_name?: string; + cost_type?: CostType; // 비용 유형 필터 } // 목록 응답 @@ -227,3 +243,48 @@ export async function getTaxInvoiceMonthlyStats( return response.data; } +// 비용 유형별 통계 응답 +export interface CostTypeStatsResponse { + success: boolean; + data: { + by_cost_type: Array<{ + cost_type: CostType | null; + count: number; + supply_amount: number; + tax_amount: number; + total_amount: number; + }>; + by_month: Array<{ + year_month: string; + cost_type: CostType | null; + count: number; + total_amount: number; + }>; + summary: { + total_count: number; + total_amount: number; + purchase_amount: number; + installation_amount: number; + repair_amount: number; + maintenance_amount: number; + disposal_amount: number; + other_amount: number; + }; + }; + period: { year?: number; month?: number }; +} + +/** + * 비용 유형별 통계 조회 + */ +export async function getCostTypeStats( + year?: number, + month?: number +): Promise { + const params: Record = {}; + if (year) params.year = year; + if (month) params.month = month; + const response = await apiClient.get("/tax-invoice/stats/cost-type", { params }); + return response.data; +} +