From 6f4c9b7fdd80615dc5e712f0ecef96031390649d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 7 Jan 2026 17:42:40 +0900 Subject: [PATCH 01/12] =?UTF-8?q?ix:=20=EB=B6=80=EB=AA=A8-=EC=9E=90?= =?UTF-8?q?=EC=8B=9D=20=EB=AA=A8=EB=8B=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20multiRowSave=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0=20InteractiveScreenView?= =?UTF-8?q?erDynamic:=20=EC=83=9D=EC=84=B1=20=EB=AA=A8=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20formData=EB=A5=BC=20initialData=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20UniversalFormModal:=20saveMultipleRows=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EB=B0=8F=20multiRowSave=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=84=EC=B2=B4=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?types/config:=20MultiRowSaveConfig=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EC=A0=9C=EA=B1=B0=20FieldDetailSettingsModal:=20re?= =?UTF-8?q?ceiveFromParent=20UI=20=EC=98=B5=EC=85=98=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20SaveSettingsModal:=20=EC=A0=80=EC=9E=A5=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EA=B0=9C=EC=84=A0=20DB:=20multiRowSave?= =?UTF-8?q?.enabled=3Dtrue=EC=9D=B8=20=ED=99=94=EB=A9=B4=203=EA=B0=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/InteractiveScreenViewerDynamic.tsx | 2 +- .../SplitPanelLayout2Component.tsx | 1 - .../UniversalFormModalComponent.tsx | 130 +----------------- .../UniversalFormModalConfigPanel.tsx | 1 - .../components/universal-form-modal/config.ts | 9 -- .../components/universal-form-modal/index.ts | 21 ++- .../modals/FieldDetailSettingsModal.tsx | 57 -------- .../modals/SaveSettingsModal.tsx | 32 +++-- .../components/universal-form-modal/types.ts | 16 --- 9 files changed, 40 insertions(+), 229 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 1dfdba14..d906a404 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -365,7 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC 0) ? originalData : formData} // ๐Ÿ†• originalData๊ฐ€ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ, ์—†์œผ๋ฉด formData ์‚ฌ์šฉ (์ƒ์„ฑ ๋ชจ๋“œ์—์„œ ๋ถ€๋ชจ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ) onFormDataChange={handleFormDataChange} screenId={screenInfo?.id} tableName={screenInfo?.tableName} diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 6e38e86e..a06c046f 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -654,7 +654,6 @@ export const SplitPanelLayout2Component: React.FC DynamicComponentRenderer์—์„œ ์ „๋‹ฌ๋œ prop const initialData = propInitialData || _initialData; + // ์„ค์ • ๋ณ‘ํ•ฉ const config: UniversalFormModalConfig = useMemo(() => { const componentConfig = component?.config || {}; @@ -155,11 +156,6 @@ export function UniversalFormModalComponent({ ...defaultConfig.saveConfig, ...propConfig?.saveConfig, ...componentConfig.saveConfig, - multiRowSave: { - ...defaultConfig.saveConfig.multiRowSave, - ...propConfig?.saveConfig?.multiRowSave, - ...componentConfig.saveConfig?.multiRowSave, - }, afterSave: { ...defaultConfig.saveConfig.afterSave, ...propConfig?.saveConfig?.afterSave, @@ -1504,118 +1500,6 @@ export function UniversalFormModalComponent({ formData, ]); - // ๋‹ค์ค‘ ํ–‰ ์ €์žฅ (๊ฒธ์ง ๋“ฑ) - const saveMultipleRows = useCallback(async () => { - const { multiRowSave } = config.saveConfig; - if (!multiRowSave) return; - - let { commonFields = [], repeatSectionId = "" } = multiRowSave; - const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave; - - // ๊ณตํ†ต ํ•„๋“œ๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ, ๊ธฐ๋ณธ์ •๋ณด ์„น์…˜์˜ ๋ชจ๋“  ํ•„๋“œ๋ฅผ ๊ณตํ†ต ํ•„๋“œ๋กœ ์‚ฌ์šฉ - if (commonFields.length === 0) { - const nonRepeatableSections = config.sections.filter((s) => !s.repeatable); - commonFields = nonRepeatableSections.flatMap((s) => (s.fields || []).map((f) => f.columnName)); - } - - // ๋ฐ˜๋ณต ์„น์…˜ ID๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ, ์ฒซ ๋ฒˆ์งธ ๋ฐ˜๋ณต ์„น์…˜ ์‚ฌ์šฉ - if (!repeatSectionId) { - const repeatableSection = config.sections.find((s) => s.repeatable); - if (repeatableSection) { - repeatSectionId = repeatableSection.id; - } - } - - // ๋ฐ˜๋ณต ์„น์…˜ ๋ฐ์ดํ„ฐ - const repeatItems = repeatSections[repeatSectionId] || []; - - // ์ €์žฅํ•  ํ–‰๋“ค ์ƒ์„ฑ - const rowsToSave: any[] = []; - - // ๊ณตํ†ต ๋ฐ์ดํ„ฐ (๋ชจ๋“  ํ–‰์— ์ ์šฉ) - const commonData: any = {}; - commonFields.forEach((fieldName) => { - if (formData[fieldName] !== undefined) { - commonData[fieldName] = formData[fieldName]; - } - }); - - // ๋ฉ”์ธ ์„น์…˜ ํ•„๋“œ ๋ฐ์ดํ„ฐ (๋ฉ”์ธ ํ–‰์—๋งŒ ์ ์šฉ๋˜๋Š” ๋ถ€์„œ/์ง๊ธ‰ ๋“ฑ) - const mainSectionData: any = {}; - mainSectionFields.forEach((fieldName) => { - if (formData[fieldName] !== undefined) { - mainSectionData[fieldName] = formData[fieldName]; - } - }); - - // ๋ฉ”์ธ ํ–‰ (๊ณตํ†ต ๋ฐ์ดํ„ฐ + ๋ฉ”์ธ ์„น์…˜ ํ•„๋“œ) - const mainRow: any = { ...commonData, ...mainSectionData }; - if (typeColumn) { - mainRow[typeColumn] = mainTypeValue || "main"; - } - rowsToSave.push(mainRow); - - // ๋ฐ˜๋ณต ์„น์…˜ ํ–‰๋“ค (๊ณตํ†ต ๋ฐ์ดํ„ฐ + ๋ฐ˜๋ณต ์„น์…˜ ํ•„๋“œ) - for (const item of repeatItems) { - const subRow: any = { ...commonData }; - - // ๋ฐ˜๋ณต ์„น์…˜์˜ ํ•„๋“œ ๊ฐ’ ์ถ”๊ฐ€ - const repeatSection = config.sections.find((s) => s.id === repeatSectionId); - (repeatSection?.fields || []).forEach((field) => { - if (item[field.columnName] !== undefined) { - subRow[field.columnName] = item[field.columnName]; - } - }); - - if (typeColumn) { - subRow[typeColumn] = subTypeValue || "concurrent"; - } - - rowsToSave.push(subRow); - } - - // ์ €์žฅ ์‹œ์  ์ฑ„๋ฒˆ๊ทœ์น™ ์ฒ˜๋ฆฌ (๋ฉ”์ธ ํ–‰๋งŒ) - for (const section of config.sections) { - if (section.repeatable || section.type === "table") continue; - - for (const field of section.fields || []) { - if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { - // generateOnSave ๋˜๋Š” generateOnOpen ๋ชจ๋‘ ์ €์žฅ ์‹œ ์‹ค์ œ ์ˆœ๋ฒˆ ํ• ๋‹น - const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen; - if (shouldAllocate) { - const response = await allocateNumberingCode(field.numberingRule.ruleId); - if (response.success && response.data?.generatedCode) { - // ๋ชจ๋“  ํ–‰์— ๋™์ผํ•œ ์ฑ„๋ฒˆ ๊ฐ’ ์ ์šฉ (๊ณตํ†ต ํ•„๋“œ์ธ ๊ฒฝ์šฐ) - if (commonFields.includes(field.columnName)) { - rowsToSave.forEach((row) => { - row[field.columnName] = response.data?.generatedCode; - }); - } else { - rowsToSave[0][field.columnName] = response.data?.generatedCode; - } - } - } - } - } - } - - // ๋ชจ๋“  ํ–‰ ์ €์žฅ - for (let i = 0; i < rowsToSave.length; i++) { - const row = rowsToSave[i]; - - // ๋นˆ ๊ฐ์ฒด ์ฒดํฌ - if (Object.keys(row).length === 0) { - continue; - } - - const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row); - - if (!response.data?.success) { - throw new Error(response.data?.message || `${i + 1}๋ฒˆ์งธ ํ–‰ ์ €์žฅ ์‹คํŒจ`); - } - } - }, [config.sections, config.saveConfig, formData, repeatSections]); - // ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ (๋ฒ”์šฉ) const saveWithMultiTable = useCallback(async () => { const { customApiSave } = config.saveConfig; @@ -1863,16 +1747,13 @@ export function UniversalFormModalComponent({ setSaving(true); try { - const { multiRowSave, customApiSave } = config.saveConfig; + const { customApiSave } = config.saveConfig; - // ์ปค์Šคํ…€ API ์ €์žฅ ๋ชจ๋“œ + // ์ปค์Šคํ…€ API ์ €์žฅ ๋ชจ๋“œ (๋‹ค์ค‘ ํ…Œ์ด๋ธ”) if (customApiSave?.enabled) { await saveWithCustomApi(); - } else if (multiRowSave?.enabled) { - // ๋‹ค์ค‘ ํ–‰ ์ €์žฅ - await saveMultipleRows(); } else { - // ๋‹จ์ผ ํ–‰ ์ €์žฅ + // ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ €์žฅ await saveSingleRow(); } @@ -1886,7 +1767,7 @@ export function UniversalFormModalComponent({ } // onSave ์ฝœ๋ฐฑ์€ ์ €์žฅ ์™„๋ฃŒ ์•Œ๋ฆผ์šฉ์œผ๋กœ๋งŒ ์‚ฌ์šฉ - // ์‹ค์ œ ์ €์žฅ์€ ์ด๋ฏธ ์œ„์—์„œ ์™„๋ฃŒ๋จ (saveSingleRow ๋˜๋Š” saveMultipleRows) + // ์‹ค์ œ ์ €์žฅ์€ ์ด๋ฏธ ์œ„์—์„œ ์™„๋ฃŒ๋จ // EditModal ๋“ฑ ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ์˜ ์ €์žฅ ๋กœ์ง์ด ๋‹ค์‹œ ์‹คํ–‰๋˜์ง€ ์•Š๋„๋ก // _saveCompleted ํ”Œ๋ž˜๊ทธ๋ฅผ ํฌํ•จํ•˜์—ฌ ์ „๋‹ฌ if (onSave) { @@ -1916,7 +1797,6 @@ export function UniversalFormModalComponent({ onSave, validateRequiredFields, saveSingleRow, - saveMultipleRows, saveWithCustomApi, ]); diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 7186ca7e..b060c4b4 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -885,7 +885,6 @@ export function UniversalFormModalConfigPanel({ tableColumns={tableColumns} numberingRules={numberingRules} onLoadTableColumns={loadTableColumns} - availableParentFields={availableParentFields} targetTableName={config.saveConfig?.tableName} targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []} /> diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 41c18043..78b1583e 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -45,15 +45,6 @@ export const defaultConfig: UniversalFormModalConfig = { saveConfig: { tableName: "", primaryKeyColumn: "id", - multiRowSave: { - enabled: false, - commonFields: [], - repeatSectionId: "", - typeColumn: "", - mainTypeValue: "main", - subTypeValue: "concurrent", - mainSectionFields: [], - }, afterSave: { closeModal: true, refreshParent: true, diff --git a/frontend/lib/registry/components/universal-form-modal/index.ts b/frontend/lib/registry/components/universal-form-modal/index.ts index f98bf438..40af1dfc 100644 --- a/frontend/lib/registry/components/universal-form-modal/index.ts +++ b/frontend/lib/registry/components/universal-form-modal/index.ts @@ -9,14 +9,14 @@ import { defaultConfig } from "./config"; /** * ๋ฒ”์šฉ ํผ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ ์ •์˜ * - * ์„น์…˜ ๊ธฐ๋ฐ˜ ํผ ๋ ˆ์ด์•„์›ƒ, ์ฑ„๋ฒˆ๊ทœ์น™, ๋‹ค์ค‘ ํ–‰ ์ €์žฅ์„ ์ง€์›ํ•˜๋Š” + * ์„น์…˜ ๊ธฐ๋ฐ˜ ํผ ๋ ˆ์ด์•„์›ƒ, ์ฑ„๋ฒˆ๊ทœ์น™์„ ์ง€์›ํ•˜๋Š” * ๋ฒ”์šฉ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. */ export const UniversalFormModalDefinition = createComponentDefinition({ id: "universal-form-modal", name: "๋ฒ”์šฉ ํผ ๋ชจ๋‹ฌ", nameEng: "Universal Form Modal", - description: "์„น์…˜ ๊ธฐ๋ฐ˜ ํผ ๋ ˆ์ด์•„์›ƒ, ์ฑ„๋ฒˆ๊ทœ์น™, ๋‹ค์ค‘ ํ–‰ ์ €์žฅ์„ ์ง€์›ํ•˜๋Š” ๋ฒ”์šฉ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ", + description: "์„น์…˜ ๊ธฐ๋ฐ˜ ํผ ๋ ˆ์ด์•„์›ƒ, ์ฑ„๋ฒˆ๊ทœ์น™์„ ์ง€์›ํ•˜๋Š” ๋ฒ”์šฉ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ", category: ComponentCategory.INPUT, webType: "form", component: UniversalFormModalComponent, @@ -28,7 +28,7 @@ export const UniversalFormModalDefinition = createComponentDefinition({ }, configPanel: UniversalFormModalConfigPanel, icon: "FormInput", - tags: ["ํผ", "๋ชจ๋‹ฌ", "์ž…๋ ฅ", "์ €์žฅ", "์ฑ„๋ฒˆ", "๊ฒธ์ง", "๋‹ค์ค‘ํ–‰"], + tags: ["ํผ", "๋ชจ๋‹ฌ", "์ž…๋ ฅ", "์ €์žฅ", "์ฑ„๋ฒˆ"], version: "1.0.0", author: "๊ฐœ๋ฐœํŒ€", documentation: ` @@ -36,22 +36,22 @@ export const UniversalFormModalDefinition = createComponentDefinition({ ### ์ฃผ์š” ๊ธฐ๋Šฅ - **์„น์…˜ ๊ธฐ๋ฐ˜ ๋ ˆ์ด์•„์›ƒ**: ๊ธฐ๋ณธ ์ •๋ณด, ์ถ”๊ฐ€ ์ •๋ณด ๋“ฑ ์„น์…˜๋ณ„๋กœ ํผ ๊ตฌ์„ฑ -- **๋ฐ˜๋ณต ์„น์…˜**: ๊ฒธ์ง์ฒ˜๋Ÿผ ๋™์ผํ•œ ํ•„๋“œ ๊ทธ๋ฃน์„ ์—ฌ๋Ÿฌ ๊ฐœ ์ถ”๊ฐ€ ๊ฐ€๋Šฅ +- **๋ฐ˜๋ณต ์„น์…˜**: ๋™์ผํ•œ ํ•„๋“œ ๊ทธ๋ฃน์„ ์—ฌ๋Ÿฌ ๊ฐœ ์ถ”๊ฐ€ ๊ฐ€๋Šฅ - **์ฑ„๋ฒˆ๊ทœ์น™ ์—ฐ๋™**: ์ž๋™ ์ฝ”๋“œ ์ƒ์„ฑ (๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ๋˜๋Š” ์ €์žฅ ์‹œ์ ) -- **๋‹ค์ค‘ ํ–‰ ์ €์žฅ**: ๊ณตํ†ต ํ•„๋“œ + ๊ฐœ๋ณ„ ํ•„๋“œ ์กฐํ•ฉ์œผ๋กœ ์—ฌ๋Ÿฌ ํ–‰ ๋™์‹œ ์ €์žฅ +- **๋‹จ์ผ/๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ**: ๋‹จ์ผ ํ…Œ์ด๋ธ” ๋˜๋Š” ๋ฉ”์ธ+์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ์ €์žฅ - **์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ์ˆ˜์‹ **: ๋ถ€๋ชจ ํ™”๋ฉด์—์„œ ์ „๋‹ฌ๋ฐ›์€ ๊ฐ’ ์ž๋™ ์ฑ„์›€ ### ์‚ฌ์šฉ ์˜ˆ์‹œ -1. ๋ถ€์„œ๊ด€๋ฆฌ ์‚ฌ์› ์ถ”๊ฐ€ + ๊ฒธ์ง ๋“ฑ๋ก -2. ํ’ˆ๋ชฉ ๋“ฑ๋ก + ๊ทœ๊ฒฉ ์˜ต์…˜ ์ถ”๊ฐ€ -3. ๊ฑฐ๋ž˜์ฒ˜ ๋“ฑ๋ก + ๋‹ด๋‹น์ž ์ •๋ณด ์ถ”๊ฐ€ +1. ์‚ฌ์› ๋“ฑ๋ก, ๋ถ€์„œ ๋“ฑ๋ก, ๊ฑฐ๋ž˜์ฒ˜ ๋“ฑ๋ก (๋‹จ์ผ ํ…Œ์ด๋ธ”) +2. ์ฃผ๋ฌธ ๋“ฑ๋ก + ์ฃผ๋ฌธ ์ƒ์„ธ (๋‹ค์ค‘ ํ…Œ์ด๋ธ”) +3. ํ’ˆ๋ชฉ ๋“ฑ๋ก + ๊ทœ๊ฒฉ ์˜ต์…˜ ์ถ”๊ฐ€ ### ์„ค์ • ๋ฐฉ๋ฒ• 1. ์ €์žฅ ํ…Œ์ด๋ธ” ์„ ํƒ -2. ์„น์…˜ ์ถ”๊ฐ€ (๊ธฐ๋ณธ ์ •๋ณด, ๊ฒธ์ง ์ •๋ณด ๋“ฑ) +2. ์„น์…˜ ์ถ”๊ฐ€ (๊ธฐ๋ณธ ์ •๋ณด ๋“ฑ) 3. ๊ฐ ์„น์…˜์— ํ•„๋“œ ์ถ”๊ฐ€ 4. ๋ฐ˜๋ณต ์„น์…˜ ์„ค์ • (ํ•„์š” ์‹œ) -5. ๋‹ค์ค‘ ํ–‰ ์ €์žฅ ์„ค์ • (ํ•„์š” ์‹œ) +5. ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ ์„ค์ • (ํ•„์š” ์‹œ) 6. ์ฑ„๋ฒˆ๊ทœ์น™ ์—ฐ๋™ (ํ•„์š” ์‹œ) `, }); @@ -69,7 +69,6 @@ export type { FormSectionConfig, FormFieldConfig, SaveConfig, - MultiRowSaveConfig, NumberingRuleConfig, SelectOptionConfig, FormDataState, diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index 8882d9bc..453429e7 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -65,8 +65,6 @@ interface FieldDetailSettingsModalProps { tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] }; numberingRules: { id: string; name: string }[]; onLoadTableColumns: (tableName: string) => void; - // ๋ถ€๋ชจ ํ™”๋ฉด์—์„œ ์ „๋‹ฌ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ ๋ชฉ๋ก (์„ ํƒ์‚ฌํ•ญ) - availableParentFields?: AvailableParentField[]; // ์ €์žฅ ํ…Œ์ด๋ธ” ์ •๋ณด (ํƒ€๊ฒŸ ์ปฌ๋Ÿผ ์„ ํƒ์šฉ) targetTableName?: string; targetTableColumns?: { name: string; type: string; label: string }[]; @@ -81,7 +79,6 @@ export function FieldDetailSettingsModal({ tableColumns, numberingRules, onLoadTableColumns, - availableParentFields = [], // targetTableName์€ ํƒ€๊ฒŸ ์ปฌ๋Ÿผ ์„ ํƒ ์‹œ ์ฐธ๊ณ ์šฉ์œผ๋กœ ์ „๋‹ฌ๋จ (ํ˜„์žฌ targetTableColumns๋งŒ ์‚ฌ์šฉ) targetTableName: _targetTableName, targetTableColumns = [], @@ -330,60 +327,6 @@ export function FieldDetailSettingsModal({ /> ํ™”๋ฉด์— ํ‘œ์‹œํ•˜์ง€ ์•Š์ง€๋งŒ ๊ฐ’์€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค - - - -
- ๋ถ€๋ชจ์—์„œ ๊ฐ’ ๋ฐ›๊ธฐ - updateField({ receiveFromParent: checked })} - /> -
- ๋ถ€๋ชจ ํ™”๋ฉด์—์„œ ์ „๋‹ฌ๋ฐ›์€ ๊ฐ’์œผ๋กœ ์ž๋™ ์ฑ„์›Œ์ง‘๋‹ˆ๋‹ค - - {/* ๋ถ€๋ชจ์—์„œ ๊ฐ’ ๋ฐ›๊ธฐ ํ™œ์„ฑํ™” ์‹œ ํ•„๋“œ ์„ ํƒ */} - {localField.receiveFromParent && ( -
- - {availableParentFields.length > 0 ? ( - - ) : ( -
- updateField({ parentFieldName: e.target.value })} - placeholder={`์˜ˆ: ${localField.columnName || "parent_field_name"}`} - className="h-8 text-xs" - /> -

- ๋ถ€๋ชจ ํ™”๋ฉด์—์„œ ์ „๋‹ฌ๋ฐ›์„ ํ•„๋“œ๋ช…์„ ์ž…๋ ฅํ•˜์„ธ์š”. ๋น„์›Œ๋‘๋ฉด "{localField.columnName}"์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. -

-
- )} -
- )} {/* Accordion์œผ๋กœ ๊ณ ๊ธ‰ ์„ค์ • */} diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index 11b3a8ae..01c87fcb 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -378,7 +378,11 @@ export function SaveSettingsModal({ ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ €์žฅ - ๋ชจ๋“  ํ•„๋“œ๋ฅผ ํ•˜๋‚˜์˜ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค (๊ธฐ๋ณธ ๋ฐฉ์‹) + + ํผ ๋ฐ์ดํ„ฐ๋ฅผ ํ•˜๋‚˜์˜ ํ…Œ์ด๋ธ”์— 1๊ฐœ ํ–‰์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. +
+ ์˜ˆ: ์‚ฌ์› ๋“ฑ๋ก, ๋ถ€์„œ ๋“ฑ๋ก, ๊ฑฐ๋ž˜์ฒ˜ ๋“ฑ๋ก ๋“ฑ ๋‹จ์ˆœ ๋“ฑ๋ก ํ™”๋ฉด +
@@ -387,9 +391,13 @@ export function SaveSettingsModal({
- ๋ฉ”์ธ ํ…Œ์ด๋ธ” + ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค + ํ•˜๋‚˜์˜ ํผ์œผ๋กœ ์—ฌ๋Ÿฌ ํ…Œ์ด๋ธ”์— ๋™์‹œ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. (ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฌถ์ž„)
- ์˜ˆ: ์ฃผ๋ฌธ(orders) + ์ฃผ๋ฌธ์ƒ์„ธ(order_items), ์‚ฌ์›(user_info) + ๋ถ€์„œ(user_dept) + ๋ฉ”์ธ ํ…Œ์ด๋ธ”: ํผ์˜ ๋ชจ๋“  ํ•„๋“œ ์ค‘ ํ•ด๋‹น ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ๊ณผ ์ผ์น˜ํ•˜๋Š” ๊ฒƒ ์ž๋™ ์ €์žฅ +
+ ์„œ๋ธŒ ํ…Œ์ด๋ธ”: ํ•„๋“œ ๋งคํ•‘์—์„œ ์ง€์ •ํ•œ ํ•„๋“œ๋งŒ ์ €์žฅ (๋ฉ”์ธ ํ…Œ์ด๋ธ”์˜ ํ‚ค ๊ฐ’์ด ์ž๋™ ์—ฐ๊ฒฐ๋จ) +
+ ์˜ˆ: ์‚ฌ์›+๋ถ€์„œ๋ฐฐ์ •(user_info+user_dept), ์ฃผ๋ฌธ+์ฃผ๋ฌธ์ƒ์„ธ(orders+order_items)
@@ -691,9 +699,11 @@ export function SaveSettingsModal({ - ๋ฐ˜๋ณต ์„น์…˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ„๋„ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + ํผ์—์„œ ์ž…๋ ฅํ•œ ํ•„๋“œ๋ฅผ ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ๋‚˜๋ˆ ์„œ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
- ์˜ˆ: ์ฃผ๋ฌธ์ƒ์„ธ(order_items), ๊ฒธ์ง๋ถ€์„œ(user_dept) + ๋ฉ”์ธ ํ…Œ์ด๋ธ”์˜ ํ‚ค ๊ฐ’(์˜ˆ: user_id)์ด ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ์ž๋™์œผ๋กœ ์—ฐ๊ฒฐ๋ฉ๋‹ˆ๋‹ค. +
+ ํ•„๋“œ ๋งคํ•‘์—์„œ ์ง€์ •ํ•œ ํ•„๋“œ๋งŒ ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).length === 0 ? ( @@ -802,13 +812,13 @@ export function SaveSettingsModal({
- + - ์ด ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•  ๋ฐ˜๋ณต ์„น์…˜์„ ์„ ํƒํ•˜์„ธ์š” + + ๋ฐ˜๋ณต ์„น์…˜: ํผ ์•ˆ์—์„œ ๋™์ ์œผ๋กœ ํ•ญ๋ชฉ์„ ์ถ”๊ฐ€/์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋Š” ์„น์…˜ (์˜ˆ: ์ฃผ๋ฌธ ํ’ˆ๋ชฉ ๋ชฉ๋ก) +
+ ๋ฐ˜๋ณต ์„น์…˜์ด ์žˆ์œผ๋ฉด ํ•ด๋‹น ์„น์…˜์˜ ๊ฐ ํ•ญ๋ชฉ์ด ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ์—ฌ๋Ÿฌ ํ–‰์œผ๋กœ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. +
+ ๋ฐ˜๋ณต ์„น์…˜ ์—†์ด ํ•„๋“œ ๋งคํ•‘๋งŒ ์‚ฌ์šฉํ•˜๋ฉด 1๊ฐœ ํ–‰๋งŒ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. +
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 9d54270b..ebd0b902 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -639,19 +639,6 @@ export interface TableCalculationRule { conditionalCalculation?: ConditionalCalculationConfig; } -// ๋‹ค์ค‘ ํ–‰ ์ €์žฅ ์„ค์ • -export interface MultiRowSaveConfig { - enabled?: boolean; // ์‚ฌ์šฉ ์—ฌ๋ถ€ (๊ธฐ๋ณธ: false) - commonFields?: string[]; // ๋ชจ๋“  ํ–‰์— ๊ณตํ†ต ์ €์žฅํ•  ํ•„๋“œ (columnName ๊ธฐ์ค€) - repeatSectionId?: string; // ๋ฐ˜๋ณต ์„น์…˜ ID - typeColumn?: string; // ๊ตฌ๋ถ„ ์ปฌ๋Ÿผ๋ช… (์˜ˆ: "employment_type") - mainTypeValue?: string; // ๋ฉ”์ธ ํ–‰ ๊ฐ’ (์˜ˆ: "main") - subTypeValue?: string; // ์„œ๋ธŒ ํ–‰ ๊ฐ’ (์˜ˆ: "concurrent") - - // ๋ฉ”์ธ ์„น์…˜ ํ•„๋“œ (๋ฐ˜๋ณต ์„น์…˜์ด ์•„๋‹Œ ๊ณณ์˜ ๋ถ€์„œ/์ง๊ธ‰ ๋“ฑ) - mainSectionFields?: string[]; // ๋ฉ”์ธ ํ–‰์—๋งŒ ์ €์žฅํ•  ํ•„๋“œ -} - /** * ์„น์…˜๋ณ„ ์ €์žฅ ๋ฐฉ์‹ ์„ค์ • * ๊ณตํ†ต ์ €์žฅ: ํ•ด๋‹น ์„น์…˜์˜ ํ•„๋“œ ๊ฐ’์ด ๋ชจ๋“  ํ’ˆ๋ชฉ ํ–‰์— ๋™์ผํ•˜๊ฒŒ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค (์˜ˆ: ์ˆ˜์ฃผ๋ฒˆํ˜ธ, ๊ฑฐ๋ž˜์ฒ˜) @@ -672,9 +659,6 @@ export interface SaveConfig { tableName: string; primaryKeyColumn?: string; // PK ์ปฌ๋Ÿผ (์ˆ˜์ • ์‹œ ์‚ฌ์šฉ) - // ๋‹ค์ค‘ ํ–‰ ์ €์žฅ ์„ค์ • - multiRowSave?: MultiRowSaveConfig; - // ์ปค์Šคํ…€ API ์ €์žฅ ์„ค์ • (ํ…Œ์ด๋ธ” ์ง์ ‘ ์ €์žฅ ๋Œ€์‹  ์ „์šฉ API ์‚ฌ์šฉ) customApiSave?: CustomApiSaveConfig; From 17498b1b2bf5fbc7c8a58f4ddf389d31f4e6659f Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 8 Jan 2026 10:04:05 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20UniversalFormModalComponent?= =?UTF-8?q?=20=EC=9E=90=EC=B2=B4=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20saveSingleRow,=20saveWithCustomApi,=20h?= =?UTF-8?q?andleSave,=20handleReset=20=ED=95=A8=EC=88=98=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20saving=20=EC=83=81=ED=83=9C=20=EB=B0=8F=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5/=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B2=84=ED=8A=BC=20UI?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20UI=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20ModalConfig=20=ED=83=80=EC=9E=85=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B4=80=EB=A0=A8=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=A0=80=EC=9E=A5=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20button-primary=20(action:=20save)=EB=A1=9C=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=20=EC=95=BD=20468=EC=A4=84=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalComponent.tsx | 426 +----------------- .../UniversalFormModalConfigPanel.tsx | 34 -- .../components/universal-form-modal/config.ts | 5 - .../components/universal-form-modal/types.ts | 7 - 4 files changed, 4 insertions(+), 468 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 5da98365..4ee024a4 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -19,11 +19,11 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react"; +import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; -import { generateNumberingCode, allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule"; +import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; @@ -190,9 +190,6 @@ export function UniversalFormModalComponent({ [tableKey: string]: Record[]; }>({}); - // ๋กœ๋”ฉ ์ƒํƒœ - const [saving, setSaving] = useState(false); - // ์ฑ„๋ฒˆ๊ทœ์น™ ์›๋ณธ ๊ฐ’ ์ถ”์  (์ˆ˜๋™ ๋ชจ๋“œ ๊ฐ์ง€์šฉ) // key: columnName, value: ์ž๋™ ์ƒ์„ฑ๋œ ์›๋ณธ ๊ฐ’ const [numberingOriginalValues, setNumberingOriginalValues] = useState>({}); @@ -610,7 +607,8 @@ export function UniversalFormModalComponent({ } const tableConfig = section.tableConfig; - const editConfig = tableConfig.editConfig; + // editConfig๋Š” ํƒ€์ž…์— ์ •์˜๋˜์ง€ ์•Š์•˜์ง€๋งŒ ๋Ÿฐํƒ€์ž„์— ์กด์žฌํ•  ์ˆ˜ ์žˆ์Œ + const editConfig = (tableConfig as any).editConfig; const saveConfig = tableConfig.saveConfig; console.log(`[initializeForm] ํ…Œ์ด๋ธ” ์„น์…˜ ${section.id} ๊ฒ€์‚ฌ:`, { @@ -1240,266 +1238,6 @@ export function UniversalFormModalComponent({ return { valid: missingFields.length === 0, missingFields }; }, [config.sections, formData]); - // ๋‹จ์ผ ํ–‰ ์ €์žฅ - const saveSingleRow = useCallback(async () => { - const dataToSave = { ...formData }; - - // ํ…Œ์ด๋ธ” ์„น์…˜ ๋ฐ์ดํ„ฐ ์ถ”์ถœ (๋ณ„๋„ ์ €์žฅ์šฉ) - const tableSectionData: Record = {}; - - // ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํ•„๋“œ ์ œ๊ฑฐ (์ฑ„๋ฒˆ ๊ทœ์น™ ID๋Š” ์œ ์ง€ - buttonActions.ts์—์„œ ์‚ฌ์šฉ) - Object.keys(dataToSave).forEach((key) => { - if (key.startsWith("_tableSection_")) { - // ํ…Œ์ด๋ธ” ์„น์…˜ ๋ฐ์ดํ„ฐ๋Š” ๋ณ„๋„๋กœ ์ €์žฅ - const sectionId = key.replace("_tableSection_", ""); - tableSectionData[sectionId] = dataToSave[key] || []; - delete dataToSave[key]; - } else if (key.startsWith("_") && !key.includes("_numberingRuleId")) { - delete dataToSave[key]; - } - }); - - // ์ €์žฅ ์‹œ์  ์ฑ„๋ฒˆ๊ทœ์น™ ์ฒ˜๋ฆฌ - for (const section of config.sections) { - // ํ…Œ์ด๋ธ” ํƒ€์ž… ์„น์…˜์€ ๊ฑด๋„ˆ๋›ฐ๊ธฐ - if (section.type === "table") continue; - - for (const field of section.fields || []) { - if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { - const ruleIdKey = `${field.columnName}_numberingRuleId`; - const hasRuleId = dataToSave[ruleIdKey]; // ์‚ฌ์šฉ์ž๊ฐ€ ์ˆ˜์ •ํ•˜์ง€ ์•Š์•˜์œผ๋ฉด ruleId ์œ ์ง€๋จ - - // ์ฑ„๋ฒˆ ๊ทœ์น™ ํ• ๋‹น ์กฐ๊ฑด - const shouldAllocate = - // 1. generateOnSave๊ฐ€ ON์ธ ๊ฒฝ์šฐ: ํ•ญ์ƒ ์ €์žฅ ์‹œ์ ์— ํ• ๋‹น - field.numberingRule.generateOnSave || - // 2. editable์ด OFF์ธ ๊ฒฝ์šฐ: ์‚ฌ์šฉ์ž ์ž…๋ ฅ ๋ฌด์‹œํ•˜๊ณ  ์ฑ„๋ฒˆ ๊ทœ์น™์œผ๋กœ ๋ฎ์–ด์”Œ์›€ - !field.numberingRule.editable || - // 3. editable์ด ON์ด๊ณ  ์‚ฌ์šฉ์ž๊ฐ€ ์ˆ˜์ •ํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ (ruleId ์œ ์ง€๋จ): ์‹ค์ œ ๋ฒˆํ˜ธ ํ• ๋‹น - (field.numberingRule.editable && hasRuleId); - - if (shouldAllocate) { - const response = await allocateNumberingCode(field.numberingRule.ruleId); - if (response.success && response.data?.generatedCode) { - dataToSave[field.columnName] = response.data.generatedCode; - let reason = "(์•Œ ์ˆ˜ ์—†์Œ)"; - if (field.numberingRule.generateOnSave) { - reason = "(generateOnSave)"; - } else if (!field.numberingRule.editable) { - reason = "(editable=OFF, ๊ฐ•์ œ ๋ฎ์–ด์”Œ์›€)"; - } else if (hasRuleId) { - reason = "(editable=ON, ์‚ฌ์šฉ์ž ๋ฏธ์ˆ˜์ •)"; - } - console.log(`[์ฑ„๋ฒˆ ํ• ๋‹น] ${field.columnName} = ${response.data.generatedCode} ${reason}`); - } else { - console.error(`[์ฑ„๋ฒˆ ์‹คํŒจ] ${field.columnName}:`, response.error); - } - } else { - console.log( - `[์ฑ„๋ฒˆ ์Šคํ‚ต] ${field.columnName}: ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ์ž…๋ ฅํ•œ ๊ฐ’ ์œ ์ง€ = ${dataToSave[field.columnName]}`, - ); - } - } - } - } - - // ๋ณ„๋„ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•ด์•ผ ํ•˜๋Š” ํ…Œ์ด๋ธ” ์„น์…˜ ๋ชฉ๋ก - const tableSectionsForSeparateTable = config.sections.filter( - (s) => - s.type === "table" && - s.tableConfig?.saveConfig?.targetTable && - s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName, - ); - - // ํ…Œ์ด๋ธ” ์„น์…˜์ด ์žˆ๊ณ  ๋ฉ”์ธ ํ…Œ์ด๋ธ”์— ํ’ˆ๋ชฉ๋ณ„๋กœ ์ €์žฅํ•˜๋Š” ๊ฒฝ์šฐ (๊ณตํ†ต + ๊ฐœ๋ณ„ ๋ณ‘ํ•ฉ ์ €์žฅ) - // targetTable์ด ์—†๊ฑฐ๋‚˜ ๋ฉ”์ธ ํ…Œ์ด๋ธ”๊ณผ ๊ฐ™์€ ๊ฒฝ์šฐ - const tableSectionsForMainTable = config.sections.filter( - (s) => - s.type === "table" && - (!s.tableConfig?.saveConfig?.targetTable || - s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName), - ); - - console.log("[saveSingleRow] ๋ฉ”์ธ ํ…Œ์ด๋ธ”:", config.saveConfig.tableName); - console.log( - "[saveSingleRow] ๋ฉ”์ธ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•  ํ…Œ์ด๋ธ” ์„น์…˜:", - tableSectionsForMainTable.map((s) => s.id), - ); - console.log( - "[saveSingleRow] ๋ณ„๋„ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•  ํ…Œ์ด๋ธ” ์„น์…˜:", - tableSectionsForSeparateTable.map((s) => s.id), - ); - console.log("[saveSingleRow] ํ…Œ์ด๋ธ” ์„น์…˜ ๋ฐ์ดํ„ฐ ํ‚ค:", Object.keys(tableSectionData)); - console.log("[saveSingleRow] dataToSave ํ‚ค:", Object.keys(dataToSave)); - - if (tableSectionsForMainTable.length > 0) { - // ๊ณตํ†ต ์ €์žฅ ํ•„๋“œ ์ˆ˜์ง‘ (sectionSaveModes ์„ค์ •์— ๋”ฐ๋ผ) - const commonFieldsData: Record = {}; - const { sectionSaveModes } = config.saveConfig; - - // ํ•„๋“œ ํƒ€์ž… ์„น์…˜์—์„œ ๊ณตํ†ต ์ €์žฅ ํ•„๋“œ ์ˆ˜์ง‘ - for (const section of config.sections) { - if (section.type === "table") continue; - - const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id); - const defaultMode = "common"; // ํ•„๋“œ ํƒ€์ž… ์„น์…˜์˜ ๊ธฐ๋ณธ๊ฐ’์€ ๊ณตํ†ต ์ €์žฅ - const sectionSaveMode = sectionMode?.saveMode || defaultMode; - - if (section.fields) { - for (const field of section.fields) { - const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); - const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; - - if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) { - commonFieldsData[field.columnName] = dataToSave[field.columnName]; - } - } - } - } - - // ๊ฐ ํ…Œ์ด๋ธ” ์„น์…˜์˜ ํ’ˆ๋ชฉ ๋ฐ์ดํ„ฐ์— ๊ณตํ†ต ํ•„๋“œ ๋ณ‘ํ•ฉํ•˜์—ฌ ์ €์žฅ - for (const tableSection of tableSectionsForMainTable) { - const sectionData = tableSectionData[tableSection.id] || []; - - if (sectionData.length > 0) { - // ํ’ˆ๋ชฉ๋ณ„๋กœ ํ–‰ ์ €์žฅ - for (const item of sectionData) { - const rowToSave = { ...commonFieldsData, ...item }; - - // _sourceData ๋“ฑ ๋‚ด๋ถ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ œ๊ฑฐ - Object.keys(rowToSave).forEach((key) => { - if (key.startsWith("_")) { - delete rowToSave[key]; - } - }); - - const response = await apiClient.post( - `/table-management/tables/${config.saveConfig.tableName}/add`, - rowToSave, - ); - - if (!response.data?.success) { - throw new Error(response.data?.message || "ํ’ˆ๋ชฉ ์ €์žฅ ์‹คํŒจ"); - } - } - - // ์ด๋ฏธ ์ €์žฅํ–ˆ์œผ๋ฏ€๋กœ ์•„๋ž˜ ๋กœ์ง์—์„œ ๋‹ค์‹œ ์ €์žฅํ•˜์ง€ ์•Š๋„๋ก ์ œ๊ฑฐ - delete tableSectionData[tableSection.id]; - } - } - - // ํ’ˆ๋ชฉ์ด ์—†์œผ๋ฉด ๊ณตํ†ต ๋ฐ์ดํ„ฐ๋งŒ ์ €์žฅํ•˜์ง€ ์•Š์Œ (ํ’ˆ๋ชฉ์ด ํ•„์š”ํ•œ ํ™”๋ฉด์ด๋ฏ€๋กœ) - // ๋‹ค๋ฅธ ํ…Œ์ด๋ธ” ์„น์…˜์ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ๋ฉ”์ธ ๋ฐ์ดํ„ฐ ์ €์žฅ - const hasOtherTableSections = Object.keys(tableSectionData).length > 0; - if (!hasOtherTableSections) { - return; // ๋ฉ”์ธ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•  ํ’ˆ๋ชฉ์ด ์—†์œผ๋ฉด ์ข…๋ฃŒ - } - } - - // ๋ฉ”์ธ ๋ฐ์ดํ„ฐ ์ €์žฅ (ํ…Œ์ด๋ธ” ์„น์…˜์ด ์—†๊ฑฐ๋‚˜ ๋ณ„๋„ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•˜๋Š” ๊ฒฝ์šฐ) - const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave); - - if (!response.data?.success) { - throw new Error(response.data?.message || "์ €์žฅ ์‹คํŒจ"); - } - - // ํ…Œ์ด๋ธ” ์„น์…˜ ๋ฐ์ดํ„ฐ ์ €์žฅ (๋ณ„๋„ ํ…Œ์ด๋ธ”์—) - for (const section of config.sections) { - if (section.type === "table" && section.tableConfig?.saveConfig?.targetTable) { - const sectionData = tableSectionData[section.id]; - if (sectionData && sectionData.length > 0) { - // ๋ฉ”์ธ ๋ ˆ์ฝ”๋“œ ID๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ (response.data์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ) - const mainRecordId = response.data?.data?.id; - - // ๊ณตํ†ต ์ €์žฅ ํ•„๋“œ ์ˆ˜์ง‘: ๋‹ค๋ฅธ ์„น์…˜(ํ•„๋“œ ํƒ€์ž…)์—์„œ ๊ณตํ†ต ์ €์žฅ์œผ๋กœ ์„ค์ •๋œ ํ•„๋“œ ๊ฐ’ - // ๊ธฐ๋ณธ๊ฐ’: ํ•„๋“œ ํƒ€์ž… ์„น์…˜์€ 'common', ํ…Œ์ด๋ธ” ํƒ€์ž… ์„น์…˜์€ 'individual' - const commonFieldsData: Record = {}; - const { sectionSaveModes } = config.saveConfig; - - // ๋‹ค๋ฅธ ์„น์…˜์—์„œ ๊ณตํ†ต ์ €์žฅ์œผ๋กœ ์„ค์ •๋œ ํ•„๋“œ ๊ฐ’ ์ˆ˜์ง‘ - for (const otherSection of config.sections) { - if (otherSection.id === section.id) continue; // ํ˜„์žฌ ํ…Œ์ด๋ธ” ์„น์…˜์€ ๊ฑด๋„ˆ๋›ฐ๊ธฐ - - const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id); - // ๊ธฐ๋ณธ๊ฐ’: ํ•„๋“œ ํƒ€์ž… ์„น์…˜์€ 'common', ํ…Œ์ด๋ธ” ํƒ€์ž… ์„น์…˜์€ 'individual' - const defaultMode = otherSection.type === "table" ? "individual" : "common"; - const sectionSaveMode = sectionMode?.saveMode || defaultMode; - - // ํ•„๋“œ ํƒ€์ž… ์„น์…˜์˜ ํ•„๋“œ๋“ค ์ฒ˜๋ฆฌ - if (otherSection.type !== "table" && otherSection.fields) { - for (const field of otherSection.fields) { - // ํ•„๋“œ๋ณ„ ์˜ค๋ฒ„๋ผ์ด๋“œ ํ™•์ธ - const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); - const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; - - // ๊ณตํ†ต ์ €์žฅ์ด๋ฉด formData์—์„œ ๊ฐ’์„ ๊ฐ€์ ธ์™€ ๋ชจ๋“  ํ’ˆ๋ชฉ์— ์ ์šฉ - if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) { - commonFieldsData[field.columnName] = formData[field.columnName]; - } - } - } - - // ๐Ÿ†• ์„ ํƒ์  ํ•„๋“œ ๊ทธ๋ฃน (optionalFieldGroups)๋„ ์ฒ˜๋ฆฌ - if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) { - for (const optGroup of otherSection.optionalFieldGroups) { - if (optGroup.fields) { - for (const field of optGroup.fields) { - // ์„ ํƒ์  ํ•„๋“œ ๊ทธ๋ฃน์€ ๊ธฐ๋ณธ์ ์œผ๋กœ common ์ €์žฅ - if (formData[field.columnName] !== undefined) { - commonFieldsData[field.columnName] = formData[field.columnName]; - } - } - } - } - } - } - - console.log("[saveSingleRow] ๋ณ„๋„ ํ…Œ์ด๋ธ” ์ €์žฅ - ๊ณตํ†ต ํ•„๋“œ:", Object.keys(commonFieldsData)); - - for (const item of sectionData) { - // ๊ณตํ†ต ํ•„๋“œ ๋ณ‘ํ•ฉ + ๊ฐœ๋ณ„ ํ’ˆ๋ชฉ ๋ฐ์ดํ„ฐ - const itemToSave = { ...commonFieldsData, ...item }; - - // saveToTarget: false์ธ ์ปฌ๋Ÿผ์€ ์ €์žฅ์—์„œ ์ œ์™ธ - const columns = section.tableConfig?.columns || []; - for (const col of columns) { - if (col.saveConfig?.saveToTarget === false && col.field in itemToSave) { - delete itemToSave[col.field]; - } - } - - // _sourceData ๋“ฑ ๋‚ด๋ถ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ œ๊ฑฐ - Object.keys(itemToSave).forEach((key) => { - if (key.startsWith("_")) { - delete itemToSave[key]; - } - }); - - // ๋ฉ”์ธ ๋ ˆ์ฝ”๋“œ์™€ ์—ฐ๊ฒฐ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ - if (mainRecordId && config.saveConfig.primaryKeyColumn) { - itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId; - } - - const saveResponse = await apiClient.post( - `/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`, - itemToSave, - ); - - if (!saveResponse.data?.success) { - throw new Error(saveResponse.data?.message || `${section.title || "ํ…Œ์ด๋ธ” ์„น์…˜"} ์ €์žฅ ์‹คํŒจ`); - } - } - } - } - } - }, [ - config.sections, - config.saveConfig.tableName, - config.saveConfig.primaryKeyColumn, - config.saveConfig.sectionSaveModes, - formData, - ]); - // ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ (๋ฒ”์šฉ) const saveWithMultiTable = useCallback(async () => { const { customApiSave } = config.saveConfig; @@ -1682,130 +1420,6 @@ export function UniversalFormModalComponent({ } }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); - // ์ปค์Šคํ…€ API ์ €์žฅ - const saveWithCustomApi = useCallback(async () => { - const { customApiSave } = config.saveConfig; - if (!customApiSave) return; - - 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 "multi-table": - await saveWithMultiTable(); - break; - case "custom": - await saveWithGenericCustomApi(); - break; - default: - throw new Error(`์ง€์›ํ•˜์ง€ ์•Š๋Š” API ํƒ€์ž…: ${customApiSave.apiType}`); - } - }, [config.saveConfig, formData, repeatSections, saveWithMultiTable]); - - // ์ €์žฅ ์ฒ˜๋ฆฌ - 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 { customApiSave } = config.saveConfig; - - // ์ปค์Šคํ…€ API ์ €์žฅ ๋ชจ๋“œ (๋‹ค์ค‘ ํ…Œ์ด๋ธ”) - if (customApiSave?.enabled) { - await saveWithCustomApi(); - } else { - // ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ €์žฅ - await saveSingleRow(); - } - - // ์ €์žฅ ํ›„ ๋™์ž‘ - if (config.saveConfig.afterSave?.showToast) { - toast.success("์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - } - - if (config.saveConfig.afterSave?.refreshParent) { - window.dispatchEvent(new CustomEvent("refreshParentData")); - } - - // onSave ์ฝœ๋ฐฑ์€ ์ €์žฅ ์™„๋ฃŒ ์•Œ๋ฆผ์šฉ์œผ๋กœ๋งŒ ์‚ฌ์šฉ - // ์‹ค์ œ ์ €์žฅ์€ ์ด๋ฏธ ์œ„์—์„œ ์™„๋ฃŒ๋จ - // EditModal ๋“ฑ ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ์˜ ์ €์žฅ ๋กœ์ง์ด ๋‹ค์‹œ ์‹คํ–‰๋˜์ง€ ์•Š๋„๋ก - // _saveCompleted ํ”Œ๋ž˜๊ทธ๋ฅผ ํฌํ•จํ•˜์—ฌ ์ „๋‹ฌ - if (onSave) { - onSave({ ...formData, _saveCompleted: true }); - } - - // ์ €์žฅ ์™„๋ฃŒ ํ›„ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ ์ด๋ฒคํŠธ ๋ฐœ์ƒ - if (config.saveConfig.afterSave?.closeModal !== false) { - window.dispatchEvent(new CustomEvent("closeEditModal")); - } - } 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, - saveWithCustomApi, - ]); - - // ํผ ์ดˆ๊ธฐํ™” - const handleReset = useCallback(() => { - initializeForm(); - toast.info("ํผ์ด ์ดˆ๊ธฐํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - }, [initializeForm]); - // ํ•„๋“œ ์š”์†Œ ๋ Œ๋”๋ง (์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ๋งŒ) // repeatContext: ๋ฐ˜๋ณต ์„น์…˜์ธ ๊ฒฝ์šฐ { sectionId, itemId }๋ฅผ ์ „๋‹ฌ const renderFieldElement = ( @@ -2544,38 +2158,6 @@ export function UniversalFormModalComponent({ {/* ์„น์…˜๋“ค */}
{config.sections.map((section) => renderSection(section))}
- {/* ๋ฒ„ํŠผ ์˜์—ญ - ์ €์žฅ ๋ฒ„ํŠผ์ด ํ‘œ์‹œ๋  ๋•Œ๋งŒ ๋ Œ๋”๋ง */} - {config.modal.showSaveButton !== false && ( -
- {config.modal.showResetButton && ( - - )} - -
- )} - {/* ์‚ญ์ œ ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ */} ๋ชจ๋‹ฌ ์ฐฝ์˜ ํฌ๊ธฐ๋ฅผ ์„ ํƒํ•˜์„ธ์š” - - {/* ์ €์žฅ ๋ฒ„ํŠผ ํ‘œ์‹œ ์„ค์ • */} -
-
- updateModalConfig({ showSaveButton: checked === true })} - /> - -
- ์ฒดํฌ ํ•ด์ œ ์‹œ ๋ชจ๋‹ฌ ํ•˜๋‹จ์˜ ์ €์žฅ ๋ฒ„ํŠผ์ด ์ˆจ๊ฒจ์ง‘๋‹ˆ๋‹ค -
- -
-
- - updateModalConfig({ saveButtonText: e.target.value })} - className="h-9 w-full max-w-full text-sm" - /> -
-
- - updateModalConfig({ cancelButtonText: e.target.value })} - className="h-9 w-full max-w-full text-sm" - /> -
-
diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 78b1583e..08baf766 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -23,11 +23,6 @@ export const defaultConfig: UniversalFormModalConfig = { size: "lg", closeOnOutsideClick: false, showCloseButton: true, - showSaveButton: true, - saveButtonText: "์ €์žฅ", - cancelButtonText: "์ทจ์†Œ", - showResetButton: false, - resetButtonText: "์ดˆ๊ธฐํ™”", }, sections: [ { diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index db4d5503..25d04ea8 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -786,13 +786,6 @@ export interface ModalConfig { size: "sm" | "md" | "lg" | "xl" | "full"; closeOnOutsideClick?: boolean; showCloseButton?: boolean; - - // ๋ฒ„ํŠผ ์„ค์ • - showSaveButton?: boolean; // ์ €์žฅ ๋ฒ„ํŠผ ํ‘œ์‹œ (๊ธฐ๋ณธ: true) - saveButtonText?: string; // ์ €์žฅ ๋ฒ„ํŠผ ํ…์ŠคํŠธ (๊ธฐ๋ณธ: "์ €์žฅ") - cancelButtonText?: string; // ์ทจ์†Œ ๋ฒ„ํŠผ ํ…์ŠคํŠธ (๊ธฐ๋ณธ: "์ทจ์†Œ") - showResetButton?: boolean; // ์ดˆ๊ธฐํ™” ๋ฒ„ํŠผ ํ‘œ์‹œ - resetButtonText?: string; // ์ดˆ๊ธฐํ™” ๋ฒ„ํŠผ ํ…์ŠคํŠธ } // ์ „์ฒด ์„ค์ • From 23ebae95d64faae7fc72d33d3c03c3cba4c7c1f8 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 10:39:48 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=EA=B2=80=EC=83=89=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=ED=8B=80=EA=B3=A0=EC=A0=95=EA=B8=B0=EB=8A=A5=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-options/ColumnVisibilityPanel.tsx | 8 ++- .../table-list/TableListComponent.tsx | 68 ++++++++++++++----- frontend/types/table-options.ts | 2 +- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx index 64acd942..67c11171 100644 --- a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx +++ b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx @@ -97,9 +97,13 @@ export const ColumnVisibilityPanel: React.FC = ({ table.onColumnOrderChange(newOrder); } - // ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ์ˆ˜ ๋ณ€๊ฒฝ ์ฝœ๋ฐฑ ํ˜ธ์ถœ + // ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ์ˆ˜ ๋ณ€๊ฒฝ ์ฝœ๋ฐฑ ํ˜ธ์ถœ (ํ˜„์žฌ ์ปฌ๋Ÿผ ์ƒํƒœ๋„ ํ•จ๊ป˜ ์ „๋‹ฌ) if (table?.onFrozenColumnCountChange) { - table.onFrozenColumnCountChange(frozenColumnCount); + const updatedColumns = localColumns.map((col) => ({ + columnName: col.columnName, + visible: col.visible, + })); + table.onFrozenColumnCountChange(frozenColumnCount, updatedColumns); } onClose(); diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 74cea859..09422164 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1039,14 +1039,16 @@ export const TableListComponent: React.FC = ({ onGroupSumChange: setGroupSumConfig, // ๊ทธ๋ฃน๋ณ„ ํ•ฉ์‚ฐ ์„ค์ • // ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ๊ด€๋ จ frozenColumnCount, // ํ˜„์žฌ ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ์ˆ˜ - onFrozenColumnCountChange: (count: number) => { + onFrozenColumnCountChange: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => { setFrozenColumnCount(count); // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ์€ ํ•ญ์ƒ ํ‹€๊ณ ์ •์— ํฌํ•จ const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; // ํ‘œ์‹œ ๊ฐ€๋Šฅํ•œ ์ปฌ๋Ÿผ ์ค‘ ์ฒ˜์Œ N๊ฐœ๋ฅผ ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ์œผ๋กœ ์„ค์ • - const visibleCols = columnsToRegister + // updatedColumns๊ฐ€ ์ „๋‹ฌ๋˜๋ฉด ๊ทธ๊ฒƒ์„ ์‚ฌ์šฉ, ์•„๋‹ˆ๋ฉด columnsToRegister ์‚ฌ์šฉ + const colsToUse = updatedColumns || columnsToRegister; + const visibleCols = colsToUse .filter((col) => col.visible !== false) - .map((col) => col.columnName || col.field); + .map((col) => col.columnName || (col as any).field); const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)]; setFrozenColumns(newFrozenColumns); }, @@ -4754,9 +4756,22 @@ export const TableListComponent: React.FC = ({ }); setColumnWidths(newWidths); - // ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ์—…๋ฐ์ดํŠธ - const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName); + // ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ์—…๋ฐ์ดํŠธ (๋ณด์ด๋Š” ์ปฌ๋Ÿผ ๊ธฐ์ค€์œผ๋กœ ์ฒ˜์Œ N๊ฐœ๋ฅผ ํ‹€๊ณ ์ •) + // ๊ธฐ์กด frozen ๊ฐœ์ˆ˜๋ฅผ ์œ ์ง€ํ•˜๋ฉด์„œ, ์ˆจ๊ฒจ์ง„ ์ปฌ๋Ÿผ์„ ์ œ์™ธํ•œ ๋ณด์ด๋Š” ์ปฌ๋Ÿผ ์ค‘ ์ฒ˜์Œ N๊ฐœ๋ฅผ ํ‹€๊ณ ์ • + const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; + const visibleCols = config.columns + .filter((col) => col.visible && col.columnName !== "__checkbox__") + .map((col) => col.columnName); + + // ํ˜„์žฌ ์„ค์ •๋œ frozen ์ปฌ๋Ÿผ ๊ฐœ์ˆ˜ (์ฒดํฌ๋ฐ•์Šค ์ œ์™ธ) + const currentFrozenCount = config.columns.filter( + (col) => col.frozen && col.columnName !== "__checkbox__" + ).length; + + // ๋ณด์ด๋Š” ์ปฌ๋Ÿผ ์ค‘ ์ฒ˜์Œ currentFrozenCount๊ฐœ๋ฅผ ํ‹€๊ณ ์ •์œผ๋กœ ์„ค์ • + const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, currentFrozenCount)]; setFrozenColumns(newFrozenColumns); + setFrozenColumnCount(currentFrozenCount); // ๊ทธ๋ฆฌ๋“œ์„  ํ‘œ์‹œ ์—…๋ฐ์ดํŠธ setShowGridLines(config.showGridLines); @@ -5819,13 +5834,18 @@ export const TableListComponent: React.FC = ({ {visibleColumns.map((column, columnIndex) => { const columnWidth = columnWidths[column.columnName]; const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ + + // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ (๋ณด์ด๋Š” ์ปฌ๋Ÿผ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ) + // ์ˆจ๊ฒจ์ง„ ์ปฌ๋Ÿผ์€ ์ œ์™ธํ•˜๊ณ  ๋ณด์ด๋Š” ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ๋งŒ ํฌํ•จ + const visibleFrozenColumns = visibleColumns + .filter(col => frozenColumns.includes(col.columnName)) + .map(col => col.columnName); + const frozenIndex = visibleFrozenColumns.indexOf(column.columnName); + let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; + const frozenCol = visibleFrozenColumns[i]; // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ์€ 48px ๊ณ ์ • const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; @@ -6131,13 +6151,17 @@ export const TableListComponent: React.FC = ({ const isNumeric = inputType === "number" || inputType === "decimal"; const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); + + // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ (๋ณด์ด๋Š” ์ปฌ๋Ÿผ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ) + const visibleFrozenColumns = visibleColumns + .filter(col => frozenColumns.includes(col.columnName)) + .map(col => col.columnName); + const frozenIndex = visibleFrozenColumns.indexOf(column.columnName); - // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; + const frozenCol = visibleFrozenColumns[i]; // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ์€ 48px ๊ณ ์ • const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; @@ -6284,7 +6308,12 @@ export const TableListComponent: React.FC = ({ const isNumeric = inputType === "number" || inputType === "decimal"; const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); + + // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ (๋ณด์ด๋Š” ์ปฌ๋Ÿผ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ) + const visibleFrozenColumns = visibleColumns + .filter(col => frozenColumns.includes(col.columnName)) + .map(col => col.columnName); + const frozenIndex = visibleFrozenColumns.indexOf(column.columnName); // ์…€ ํฌ์ปค์Šค ์ƒํƒœ const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; @@ -6298,11 +6327,10 @@ export const TableListComponent: React.FC = ({ // ๐Ÿ†• ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ์—ฌ๋ถ€ const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); - // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; + const frozenCol = visibleFrozenColumns[i]; // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ์€ 48px ๊ณ ์ • const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; @@ -6462,13 +6490,17 @@ export const TableListComponent: React.FC = ({ const summary = summaryData[column.columnName]; const columnWidth = columnWidths[column.columnName]; const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); + + // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ (๋ณด์ด๋Š” ์ปฌ๋Ÿผ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ) + const visibleFrozenColumns = visibleColumns + .filter(col => frozenColumns.includes(col.columnName)) + .map(col => col.columnName); + const frozenIndex = visibleFrozenColumns.indexOf(column.columnName); - // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; + const frozenCol = visibleFrozenColumns[i]; // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ์€ 48px ๊ณ ์ • const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; diff --git a/frontend/types/table-options.ts b/frontend/types/table-options.ts index bfcfccbc..6f9b2644 100644 --- a/frontend/types/table-options.ts +++ b/frontend/types/table-options.ts @@ -66,7 +66,7 @@ export interface TableRegistration { onGroupChange: (groups: string[]) => void; onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; onGroupSumChange?: (config: GroupSumConfig | null) => void; // ๊ทธ๋ฃน๋ณ„ ํ•ฉ์‚ฐ ์„ค์ • ๋ณ€๊ฒฝ - onFrozenColumnCountChange?: (count: number) => void; // ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ์ˆ˜ ๋ณ€๊ฒฝ + onFrozenColumnCountChange?: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => void; // ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ์ˆ˜ ๋ณ€๊ฒฝ // ํ˜„์žฌ ์„ค์ • ๊ฐ’ (์ฝ๊ธฐ ์ „์šฉ) frozenColumnCount?: number; // ํ˜„์žฌ ํ‹€๊ณ ์ • ์ปฌ๋Ÿผ ์ˆ˜ From d90a403ed9c2012fc297772ed8c5a6c4563241e8 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 11:09:40 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=9E=90=EB=8F=99=20=EA=B0=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/dynamicFormService.ts | 19 ++ .../src/services/tableCategoryValueService.ts | 214 ++++++++++++++++++ .../components/common/ExcelUploadModal.tsx | 15 +- 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 68c30252..8337ed74 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,6 +1,7 @@ import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; +import tableCategoryValueService from "./tableCategoryValueService"; export interface FormDataResult { id: number; @@ -427,6 +428,24 @@ export class DynamicFormService { dataToInsert, }); + // ์นดํ…Œ๊ณ ๋ฆฌ ํƒ€์ž… ์ปฌ๋Ÿผ์˜ ๋ผ๋ฒจ ๊ฐ’์„ ์ฝ”๋“œ ๊ฐ’์œผ๋กœ ๋ณ€ํ™˜ (์—‘์…€ ์—…๋กœ๋“œ ๋“ฑ ์ง€์›) + console.log("๐Ÿท๏ธ ์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋ณ€ํ™˜ ์‹œ์ž‘..."); + const companyCodeForCategory = company_code || "*"; + const { convertedData: categoryConvertedData, conversions } = + await tableCategoryValueService.convertCategoryLabelsToCodesForData( + tableName, + companyCodeForCategory, + dataToInsert + ); + + if (conversions.length > 0) { + console.log(`๐Ÿท๏ธ ์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋ณ€ํ™˜ ์™„๋ฃŒ: ${conversions.length}๊ฐœ`, conversions); + // ๋ณ€ํ™˜๋œ ๋ฐ์ดํ„ฐ๋กœ ๊ต์ฒด + Object.assign(dataToInsert, categoryConvertedData); + } else { + console.log("๐Ÿท๏ธ ์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋ณ€ํ™˜ ์—†์Œ (์นดํ…Œ๊ณ ๋ฆฌ ์ปฌ๋Ÿผ ์—†๊ฑฐ๋‚˜ ์ด๋ฏธ ์ฝ”๋“œ ๊ฐ’)"); + } + // ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์ •๋ณด ์กฐํšŒํ•˜์—ฌ ํƒ€์ž… ๋ณ€ํ™˜ ์ ์šฉ console.log("๐Ÿ” ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์ •๋ณด ์กฐํšŒ ์ค‘..."); const columnInfo = await this.getTableColumnInfo(tableName); diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 1638a417..edeb55b2 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1398,6 +1398,220 @@ class TableCategoryValueService { throw error; } } + + /** + * ํ…Œ์ด๋ธ”์˜ ์นดํ…Œ๊ณ ๋ฆฌ ํƒ€์ž… ์ปฌ๋Ÿผ๊ณผ ํ•ด๋‹น ๊ฐ’ ๋งคํ•‘ ์กฐํšŒ (๋ผ๋ฒจ โ†’ ์ฝ”๋“œ ๋ณ€ํ™˜์šฉ) + * + * ์—‘์…€ ์—…๋กœ๋“œ ๋“ฑ์—์„œ ๋ผ๋ฒจ ๊ฐ’์„ ์ฝ”๋“œ ๊ฐ’์œผ๋กœ ๋ณ€ํ™˜ํ•  ๋•Œ ์‚ฌ์šฉ + * + * @param tableName - ํ…Œ์ด๋ธ”๋ช… + * @param companyCode - ํšŒ์‚ฌ ์ฝ”๋“œ + * @returns { [columnName]: { [label]: code } } ํ˜•ํƒœ์˜ ๋งคํ•‘ ๊ฐ์ฒด + */ + async getCategoryLabelToCodeMapping( + tableName: string, + companyCode: string + ): Promise>> { + try { + logger.info("์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋งคํ•‘ ์กฐํšŒ", { tableName, companyCode }); + + const pool = getPool(); + + // 1. ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ์นดํ…Œ๊ณ ๋ฆฌ ํƒ€์ž… ์ปฌ๋Ÿผ ์กฐํšŒ + const categoryColumnsQuery = ` + SELECT column_name + FROM table_type_columns + WHERE table_name = $1 + AND input_type = 'category' + `; + const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]); + + if (categoryColumnsResult.rows.length === 0) { + logger.info("์นดํ…Œ๊ณ ๋ฆฌ ํƒ€์ž… ์ปฌ๋Ÿผ ์—†์Œ", { tableName }); + return {}; + } + + const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name); + logger.info(`์นดํ…Œ๊ณ ๋ฆฌ ์ปฌ๋Ÿผ ${categoryColumns.length}๊ฐœ ๋ฐœ๊ฒฌ`, { categoryColumns }); + + // 2. ๊ฐ ์นดํ…Œ๊ณ ๋ฆฌ ์ปฌ๋Ÿผ์˜ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋งคํ•‘ ์กฐํšŒ + const result: Record> = {}; + + for (const columnName of categoryColumns) { + let query: string; + let params: any[]; + + if (companyCode === "*") { + // ์ตœ๊ณ  ๊ด€๋ฆฌ์ž: ๋ชจ๋“  ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ์กฐํšŒ + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + params = [tableName, columnName]; + } else { + // ์ผ๋ฐ˜ ํšŒ์‚ฌ: ์ž์‹ ์˜ ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ + ๊ณตํ†ต ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ์กฐํšŒ + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + AND (company_code = $3 OR company_code = '*') + `; + params = [tableName, columnName, companyCode]; + } + + const valuesResult = await pool.query(query, params); + + // { [label]: code } ํ˜•ํƒœ๋กœ ๋ณ€ํ™˜ + const labelToCodeMap: Record = {}; + for (const row of valuesResult.rows) { + // ๋ผ๋ฒจ์„ ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋Œ€์†Œ๋ฌธ์ž ๊ตฌ๋ถ„ ์—†์ด ๋งคํ•‘ + labelToCodeMap[row.value_label] = row.value_code; + // ์†Œ๋ฌธ์ž ํ‚ค๋„ ์ถ”๊ฐ€ (๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œ ๊ฒ€์ƒ‰์šฉ) + labelToCodeMap[row.value_label.toLowerCase()] = row.value_code; + } + + if (Object.keys(labelToCodeMap).length > 0) { + result[columnName] = labelToCodeMap; + logger.info(`์ปฌ๋Ÿผ ${columnName}์˜ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋งคํ•‘ ${valuesResult.rows.length}๊ฐœ ์กฐํšŒ`); + } + } + + logger.info(`์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋งคํ•‘ ์กฐํšŒ ์™„๋ฃŒ`, { + tableName, + columnCount: Object.keys(result).length + }); + + return result; + } catch (error: any) { + logger.error(`์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋งคํ•‘ ์กฐํšŒ ์‹คํŒจ: ${error.message}`, { error }); + throw error; + } + } + + /** + * ๋ฐ์ดํ„ฐ์˜ ์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจ ๊ฐ’์„ ์ฝ”๋“œ ๊ฐ’์œผ๋กœ ๋ณ€ํ™˜ + * + * ์—‘์…€ ์—…๋กœ๋“œ ๋“ฑ์—์„œ ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ๋ผ๋ฒจ ๊ฐ’์„ DB ์ €์žฅ์šฉ ์ฝ”๋“œ ๊ฐ’์œผ๋กœ ๋ณ€ํ™˜ + * + * @param tableName - ํ…Œ์ด๋ธ”๋ช… + * @param companyCode - ํšŒ์‚ฌ ์ฝ”๋“œ + * @param data - ๋ณ€ํ™˜ํ•  ๋ฐ์ดํ„ฐ ๊ฐ์ฒด + * @returns ๋ผ๋ฒจ์ด ์ฝ”๋“œ๋กœ ๋ณ€ํ™˜๋œ ๋ฐ์ดํ„ฐ ๊ฐ์ฒด + */ + async convertCategoryLabelsToCodesForData( + tableName: string, + companyCode: string, + data: Record + ): Promise<{ convertedData: Record; conversions: Array<{ column: string; label: string; code: string }> }> { + try { + // ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋งคํ•‘ ์กฐํšŒ + const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode); + + if (Object.keys(labelToCodeMapping).length === 0) { + // ์นดํ…Œ๊ณ ๋ฆฌ ์ปฌ๋Ÿผ ์—†์Œ + return { convertedData: data, conversions: [] }; + } + + const convertedData = { ...data }; + const conversions: Array<{ column: string; label: string; code: string }> = []; + + for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) { + const value = data[columnName]; + + if (value !== undefined && value !== null && value !== "") { + const stringValue = String(value).trim(); + + // ๋‹ค์ค‘ ๊ฐ’ ํ™•์ธ (์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„๋œ ๊ฒฝ์šฐ) + if (stringValue.includes(",")) { + // ๋‹ค์ค‘ ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ์ฒ˜๋ฆฌ + const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== ""); + const convertedCodes: string[] = []; + let allConverted = true; + + for (const label of labels) { + // ์ •ํ™•ํ•œ ๋ผ๋ฒจ ๋งค์นญ ์‹œ๋„ + let matchedCode = labelCodeMap[label]; + + // ๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œ ๋งค์นญ + if (!matchedCode) { + matchedCode = labelCodeMap[label.toLowerCase()]; + } + + if (matchedCode) { + convertedCodes.push(matchedCode); + conversions.push({ + column: columnName, + label: label, + code: matchedCode, + }); + logger.info(`์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋ณ€ํ™˜ (๋‹ค์ค‘): ${columnName} "${label}" โ†’ "${matchedCode}"`); + } else { + // ์ด๋ฏธ ์ฝ”๋“œ๊ฐ’์ธ์ง€ ํ™•์ธ + const isAlreadyCode = Object.values(labelCodeMap).includes(label); + if (isAlreadyCode) { + // ์ด๋ฏธ ์ฝ”๋“œ๊ฐ’์ด๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ + convertedCodes.push(label); + } else { + // ๋ผ๋ฒจ๋„ ์ฝ”๋“œ๋„ ์•„๋‹ˆ๋ฉด ์›๋ž˜ ๊ฐ’ ์œ ์ง€ + convertedCodes.push(label); + allConverted = false; + logger.warn(`์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ๋งคํ•‘ ์—†์Œ (๋‹ค์ค‘): ${columnName} = "${label}" (๋ผ๋ฒจ๋„ ์ฝ”๋“œ๋„ ์•„๋‹˜)`); + } + } + } + + // ๋ณ€ํ™˜๋œ ์ฝ”๋“œ๋“ค์„ ์‰ผํ‘œ๋กœ ํ•ฉ์ณ์„œ ์ €์žฅ + convertedData[columnName] = convertedCodes.join(","); + logger.info(`๋‹ค์ค‘ ์นดํ…Œ๊ณ ๋ฆฌ ๋ณ€ํ™˜ ์™„๋ฃŒ: ${columnName} "${stringValue}" โ†’ "${convertedData[columnName]}"`); + } else { + // ๋‹จ์ผ ๊ฐ’ ์ฒ˜๋ฆฌ + // ์ •ํ™•ํ•œ ๋ผ๋ฒจ ๋งค์นญ ์‹œ๋„ + let matchedCode = labelCodeMap[stringValue]; + + // ๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œ ๋งค์นญ + if (!matchedCode) { + matchedCode = labelCodeMap[stringValue.toLowerCase()]; + } + + if (matchedCode) { + // ๋ผ๋ฒจ ๊ฐ’์„ ์ฝ”๋“œ ๊ฐ’์œผ๋กœ ๋ณ€ํ™˜ + convertedData[columnName] = matchedCode; + conversions.push({ + column: columnName, + label: stringValue, + code: matchedCode, + }); + logger.info(`์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋ณ€ํ™˜: ${columnName} "${stringValue}" โ†’ "${matchedCode}"`); + } else { + // ์ด๋ฏธ ์ฝ”๋“œ๊ฐ’์ธ์ง€ ํ™•์ธ (์—ญ๋ฐฉํ–ฅ ํ™•์ธ) + const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue); + if (!isAlreadyCode) { + logger.warn(`์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ๋งคํ•‘ ์—†์Œ: ${columnName} = "${stringValue}" (๋ผ๋ฒจ๋„ ์ฝ”๋“œ๋„ ์•„๋‹˜)`); + } + // ๋ณ€ํ™˜ ์—†์ด ์›๋ž˜ ๊ฐ’ ์œ ์ง€ + } + } + } + } + + logger.info(`์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋ณ€ํ™˜ ์™„๋ฃŒ`, { + tableName, + conversionCount: conversions.length, + conversions, + }); + + return { convertedData, conversions }; + } catch (error: any) { + logger.error(`์นดํ…Œ๊ณ ๋ฆฌ ๋ผ๋ฒจโ†’์ฝ”๋“œ ๋ณ€ํ™˜ ์‹คํŒจ: ${error.message}`, { error }); + // ์‹คํŒจ ์‹œ ์›๋ณธ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ + return { convertedData: data, conversions: [] }; + } + } } export default new TableCategoryValueService(); diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index a4a17274..28be5688 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -307,10 +307,23 @@ export const ExcelUploadModal: React.FC = ({ return mappedRow; }); + // ๋นˆ ํ–‰ ํ•„ํ„ฐ๋ง: ๋ชจ๋“  ๊ฐ’์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ undefined/null์ธ ํ–‰ ์ œ์™ธ + const filteredData = mappedData.filter((row) => { + const values = Object.values(row); + // ํ•˜๋‚˜๋ผ๋„ ์œ ํšจํ•œ ๊ฐ’์ด ์žˆ๋Š”์ง€ ํ™•์ธ + return values.some((value) => { + if (value === undefined || value === null) return false; + if (typeof value === "string" && value.trim() === "") return false; + return true; + }); + }); + + console.log(`๐Ÿ“Š ์—‘์…€ ์—…๋กœ๋“œ: ์ „์ฒด ${mappedData.length}ํ–‰ ์ค‘ ์œ ํšจํ•œ ${filteredData.length}ํ–‰`); + let successCount = 0; let failCount = 0; - for (const row of mappedData) { + for (const row of filteredData) { try { if (uploadMode === "insert") { const formData = { screenId: 0, tableName, data: row }; From 5321ea5b806cc027b2e682de9ca89d3c7bed3825 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 11:45:39 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/excelMappingController.ts | 208 +++++++++++++ backend-node/src/routes/excelMappingRoutes.ts | 25 ++ .../src/services/excelMappingService.ts | 283 ++++++++++++++++++ .../components/common/ExcelUploadModal.tsx | 274 +++++++++++------ frontend/lib/api/excelMapping.ts | 106 +++++++ 6 files changed, 809 insertions(+), 89 deletions(-) create mode 100644 backend-node/src/controllers/excelMappingController.ts create mode 100644 backend-node/src/routes/excelMappingRoutes.ts create mode 100644 backend-node/src/services/excelMappingService.ts create mode 100644 frontend/lib/api/excelMapping.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index e928f96c..80e406b9 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // ๋ฆฌ์Šคํฌ/์•Œ๋ฆผ ๊ด€ import todoRoutes from "./routes/todoRoutes"; // To-Do ๊ด€๋ฆฌ import bookingRoutes from "./routes/bookingRoutes"; // ์˜ˆ์•ฝ ์š”์ฒญ ๊ด€๋ฆฌ import mapDataRoutes from "./routes/mapDataRoutes"; // ์ง€๋„ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ +import excelMappingRoutes from "./routes/excelMappingRoutes"; // ์—‘์…€ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D ํ•„๋“œ //import materialRoutes from "./routes/materialRoutes"; // ์ž์žฌ ๊ด€๋ฆฌ import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // ๋””์ง€ํ„ธ ํŠธ์œˆ (์•ผ๋“œ ๊ด€์ œ) @@ -220,6 +221,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/screen-files", screenFileRoutes); app.use("/api/batch-configs", batchRoutes); +app.use("/api/excel-mapping", excelMappingRoutes); // ์—‘์…€ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes); // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // ํŒŒ์ผ์ด ์กด์žฌํ•˜์ง€ ์•Š์Œ diff --git a/backend-node/src/controllers/excelMappingController.ts b/backend-node/src/controllers/excelMappingController.ts new file mode 100644 index 00000000..e29d4fe2 --- /dev/null +++ b/backend-node/src/controllers/excelMappingController.ts @@ -0,0 +1,208 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import excelMappingService from "../services/excelMappingService"; +import { logger } from "../utils/logger"; + +/** + * ์—‘์…€ ์ปฌ๋Ÿผ ๊ตฌ์กฐ๋กœ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ + * POST /api/excel-mapping/find + */ +export async function findMappingByColumns( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, excelColumns } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName || !excelColumns || !Array.isArray(excelColumns)) { + res.status(400).json({ + success: false, + message: "tableName๊ณผ excelColumns(๋ฐฐ์—ด)๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + }); + return; + } + + logger.info("์—‘์…€ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ์š”์ฒญ", { + tableName, + excelColumns, + companyCode, + userId: req.user?.userId, + }); + + const template = await excelMappingService.findMappingByColumns( + tableName, + excelColumns, + companyCode + ); + + if (template) { + res.json({ + success: true, + data: template, + message: "๊ธฐ์กด ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์„ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค.", + }); + } else { + res.json({ + success: true, + data: null, + message: "์ผ์น˜ํ•˜๋Š” ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์ด ์—†์Šต๋‹ˆ๋‹ค.", + }); + } + } catch (error: any) { + logger.error("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ์‹คํŒจ", { error: error.message }); + res.status(500).json({ + success: false, + message: "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: error.message, + }); + } +} + +/** + * ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ (UPSERT) + * POST /api/excel-mapping/save + */ +export async function saveMappingTemplate( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, excelColumns, columnMappings } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + if (!tableName || !excelColumns || !columnMappings) { + res.status(400).json({ + success: false, + message: "tableName, excelColumns, columnMappings๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + }); + return; + } + + logger.info("์—‘์…€ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์š”์ฒญ", { + tableName, + excelColumns, + columnMappings, + companyCode, + userId, + }); + + const template = await excelMappingService.saveMappingTemplate( + tableName, + excelColumns, + columnMappings, + companyCode, + userId + ); + + res.json({ + success: true, + data: template, + message: "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }); + } catch (error: any) { + logger.error("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์‹คํŒจ", { error: error.message }); + res.status(500).json({ + success: false, + message: "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: error.message, + }); + } +} + +/** + * ํ…Œ์ด๋ธ”์˜ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ + * GET /api/excel-mapping/list/:tableName + */ +export async function getMappingTemplates( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName) { + res.status(400).json({ + success: false, + message: "tableName์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + }); + return; + } + + logger.info("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ ์š”์ฒญ", { + tableName, + companyCode, + }); + + const templates = await excelMappingService.getMappingTemplates( + tableName, + companyCode + ); + + res.json({ + success: true, + data: templates, + }); + } catch (error: any) { + logger.error("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", { error: error.message }); + res.status(500).json({ + success: false, + message: "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: error.message, + }); + } +} + +/** + * ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ + * DELETE /api/excel-mapping/:id + */ +export async function deleteMappingTemplate( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!id) { + res.status(400).json({ + success: false, + message: "id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + }); + return; + } + + logger.info("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ ์š”์ฒญ", { + id, + companyCode, + }); + + const deleted = await excelMappingService.deleteMappingTemplate( + parseInt(id), + companyCode + ); + + if (deleted) { + res.json({ + success: true, + message: "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }); + } else { + res.status(404).json({ + success: false, + message: "์‚ญ์ œํ•  ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + }); + } + } catch (error: any) { + logger.error("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ ์‹คํŒจ", { error: error.message }); + res.status(500).json({ + success: false, + message: "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: error.message, + }); + } +} + diff --git a/backend-node/src/routes/excelMappingRoutes.ts b/backend-node/src/routes/excelMappingRoutes.ts new file mode 100644 index 00000000..cbcecc15 --- /dev/null +++ b/backend-node/src/routes/excelMappingRoutes.ts @@ -0,0 +1,25 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + findMappingByColumns, + saveMappingTemplate, + getMappingTemplates, + deleteMappingTemplate, +} from "../controllers/excelMappingController"; + +const router = Router(); + +// ์—‘์…€ ์ปฌ๋Ÿผ ๊ตฌ์กฐ๋กœ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ +router.post("/find", authenticateToken, findMappingByColumns); + +// ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ (UPSERT) +router.post("/save", authenticateToken, saveMappingTemplate); + +// ํ…Œ์ด๋ธ”์˜ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ +router.get("/list/:tableName", authenticateToken, getMappingTemplates); + +// ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ +router.delete("/:id", authenticateToken, deleteMappingTemplate); + +export default router; + diff --git a/backend-node/src/services/excelMappingService.ts b/backend-node/src/services/excelMappingService.ts new file mode 100644 index 00000000..a63a027b --- /dev/null +++ b/backend-node/src/services/excelMappingService.ts @@ -0,0 +1,283 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import crypto from "crypto"; + +export interface ExcelMappingTemplate { + id?: number; + tableName: string; + excelColumns: string[]; + excelColumnsHash: string; + columnMappings: Record; // { "์—‘์…€์ปฌ๋Ÿผ": "์‹œ์Šคํ…œ์ปฌ๋Ÿผ" } + companyCode: string; + createdDate?: Date; + updatedDate?: Date; +} + +class ExcelMappingService { + /** + * ์—‘์…€ ์ปฌ๋Ÿผ ๋ชฉ๋ก์œผ๋กœ ํ•ด์‹œ ์ƒ์„ฑ + * ์ •๋ ฌ ํ›„ MD5 ํ•ด์‹œ ์ƒ์„ฑํ•˜์—ฌ ๋™์ผํ•œ ์ปฌ๋Ÿผ ๊ตฌ์กฐ ์‹๋ณ„ + */ + generateColumnsHash(columns: string[]): string { + // ์ปฌ๋Ÿผ ๋ชฉ๋ก์„ ์ •๋ ฌํ•˜์—ฌ ์ˆœ์„œ์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋™์ผํ•œ ํ•ด์‹œ ์ƒ์„ฑ + const sortedColumns = [...columns].sort(); + const columnsString = sortedColumns.join("|"); + return crypto.createHash("md5").update(columnsString).digest("hex"); + } + + /** + * ์—‘์…€ ์ปฌ๋Ÿผ ๊ตฌ์กฐ๋กœ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ + * ๋™์ผํ•œ ์ปฌ๋Ÿผ ๊ตฌ์กฐ๊ฐ€ ์žˆ์œผ๋ฉด ๊ธฐ์กด ๋งคํ•‘ ๋ฐ˜ํ™˜ + */ + async findMappingByColumns( + tableName: string, + excelColumns: string[], + companyCode: string + ): Promise { + try { + const hash = this.generateColumnsHash(excelColumns); + + logger.info("์—‘์…€ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ", { + tableName, + excelColumns, + hash, + companyCode, + }); + + const pool = getPool(); + + // ํšŒ์‚ฌ๋ณ„ ๋งคํ•‘ ๋จผ์ € ์กฐํšŒ, ์—†์œผ๋ฉด ๊ณตํ†ต(*) ๋งคํ•‘ ์กฐํšŒ + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND excel_columns_hash = $2 + ORDER BY updated_date DESC + LIMIT 1 + `; + params = [tableName, hash]; + } else { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND excel_columns_hash = $2 + AND (company_code = $3 OR company_code = '*') + ORDER BY + CASE WHEN company_code = $3 THEN 0 ELSE 1 END, + updated_date DESC + LIMIT 1 + `; + params = [tableName, hash, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rows.length > 0) { + logger.info("๊ธฐ์กด ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ฐœ๊ฒฌ", { + id: result.rows[0].id, + tableName, + }); + return result.rows[0]; + } + + logger.info("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์—†์Œ - ์ƒˆ ๊ตฌ์กฐ", { tableName, hash }); + return null; + } catch (error: any) { + logger.error(`๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ์‹คํŒจ: ${error.message}`, { error }); + throw error; + } + } + + /** + * ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ (UPSERT) + * ๋™์ผํ•œ ํ…Œ์ด๋ธ”+์ปฌ๋Ÿผ๊ตฌ์กฐ+ํšŒ์‚ฌ์ฝ”๋“œ๊ฐ€ ์žˆ์œผ๋ฉด ์—…๋ฐ์ดํŠธ, ์—†์œผ๋ฉด ์‚ฝ์ž… + */ + async saveMappingTemplate( + tableName: string, + excelColumns: string[], + columnMappings: Record, + companyCode: string, + userId?: string + ): Promise { + try { + const hash = this.generateColumnsHash(excelColumns); + + logger.info("์—‘์…€ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ (UPSERT)", { + tableName, + excelColumns, + hash, + columnMappings, + companyCode, + }); + + const pool = getPool(); + + const query = ` + INSERT INTO excel_mapping_template ( + table_name, + excel_columns, + excel_columns_hash, + column_mappings, + company_code, + created_date, + updated_date + ) VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (table_name, excel_columns_hash, company_code) + DO UPDATE SET + column_mappings = EXCLUDED.column_mappings, + updated_date = NOW() + RETURNING + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + `; + + const result = await pool.query(query, [ + tableName, + excelColumns, + hash, + JSON.stringify(columnMappings), + companyCode, + ]); + + logger.info("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์™„๋ฃŒ", { + id: result.rows[0].id, + tableName, + hash, + }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์‹คํŒจ: ${error.message}`, { error }); + throw error; + } + } + + /** + * ํ…Œ์ด๋ธ”์˜ ๋ชจ๋“  ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ + */ + async getMappingTemplates( + tableName: string, + companyCode: string + ): Promise { + try { + logger.info("ํ…Œ์ด๋ธ” ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ", { tableName, companyCode }); + + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + ORDER BY updated_date DESC + `; + params = [tableName]; + } else { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND (company_code = $2 OR company_code = '*') + ORDER BY updated_date DESC + `; + params = [tableName, companyCode]; + } + + const result = await pool.query(query, params); + + logger.info(`๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ${result.rows.length}๊ฐœ ์กฐํšŒ`, { tableName }); + + return result.rows; + } catch (error: any) { + logger.error(`๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ: ${error.message}`, { error }); + throw error; + } + } + + /** + * ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ + */ + async deleteMappingTemplate( + id: number, + companyCode: string + ): Promise { + try { + logger.info("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ", { id, companyCode }); + + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = `DELETE FROM excel_mapping_template WHERE id = $1`; + params = [id]; + } else { + query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rowCount && result.rowCount > 0) { + logger.info("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ ์™„๋ฃŒ", { id }); + return true; + } + + logger.warn("์‚ญ์ œํ•  ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์—†์Œ", { id, companyCode }); + return false; + } catch (error: any) { + logger.error(`๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ ์‹คํŒจ: ${error.message}`, { error }); + throw error; + } + } +} + +export default new ExcelMappingService(); + diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 28be5688..97214a2a 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -19,7 +19,6 @@ import { SelectValue, } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; import { toast } from "sonner"; import { Upload, @@ -35,6 +34,7 @@ import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { getTableSchema, TableColumn } from "@/lib/api/tableSchema"; import { cn } from "@/lib/utils"; +import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; export interface ExcelUploadModalProps { open: boolean; @@ -66,12 +66,14 @@ export const ExcelUploadModal: React.FC = ({ const [file, setFile] = useState(null); const [sheetNames, setSheetNames] = useState([]); const [selectedSheet, setSelectedSheet] = useState(""); + const [isDragOver, setIsDragOver] = useState(false); const fileInputRef = useRef(null); // 2๋‹จ๊ณ„: ๋ฒ”์œ„ ์ง€์ • - const [autoCreateColumn, setAutoCreateColumn] = useState(false); - const [selectedCompany, setSelectedCompany] = useState(""); - const [selectedDataType, setSelectedDataType] = useState(""); + // (๋” ์ด์ƒ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ์ƒํƒœ๋“ค - 3๋‹จ๊ณ„๋กœ ์ด๋™) + + // 3๋‹จ๊ณ„: ์ปฌ๋Ÿผ ๋งคํ•‘ + ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ž๋™ ์ ์šฉ + const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false); const [detectedRange, setDetectedRange] = useState(""); const [previewData, setPreviewData] = useState[]>([]); const [allData, setAllData] = useState[]>([]); @@ -89,7 +91,11 @@ export const ExcelUploadModal: React.FC = ({ const handleFileChange = async (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; if (!selectedFile) return; + await processFile(selectedFile); + }; + // ํŒŒ์ผ ์ฒ˜๋ฆฌ ๊ณตํ†ต ํ•จ์ˆ˜ (ํŒŒ์ผ ์„ ํƒ ๋ฐ ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ์—์„œ ๊ณต์œ ) + const processFile = async (selectedFile: File) => { const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase(); if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) { toast.error("์—‘์…€ ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. (.xlsx, .xls, .csv)"); @@ -105,7 +111,7 @@ export const ExcelUploadModal: React.FC = ({ const data = await importFromExcel(selectedFile, sheets[0]); setAllData(data); - setDisplayData(data); // ์ „์ฒด ๋ฐ์ดํ„ฐ๋ฅผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์— ํ‘œ์‹œ (์Šคํฌ๋กค ๊ฐ€๋Šฅ) + setDisplayData(data); if (data.length > 0) { const columns = Object.keys(data[0]); @@ -122,6 +128,30 @@ export const ExcelUploadModal: React.FC = ({ } }; + // ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ํ•ธ๋“ค๋Ÿฌ + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const droppedFile = e.dataTransfer.files?.[0]; + if (droppedFile) { + await processFile(droppedFile); + } + }; + // ์‹œํŠธ ๋ณ€๊ฒฝ ํ•ธ๋“ค๋Ÿฌ const handleSheetChange = async (sheetName: string) => { setSelectedSheet(sheetName); @@ -201,6 +231,15 @@ export const ExcelUploadModal: React.FC = ({ } }, [currentStep, tableName]); + // ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹œ ์ž๋™ ์ƒ์„ฑ๋˜๋Š” ์‹œ์Šคํ…œ ์ปฌ๋Ÿผ (๋งคํ•‘์—์„œ ์ œ์™ธ) + const AUTO_GENERATED_COLUMNS = [ + "id", // ID + "created_date", // ์ƒ์„ฑ์ผ์‹œ + "updated_date", // ์ˆ˜์ •์ผ์‹œ + "writer", // ์ž‘์„ฑ์ž + "company_code", // ํšŒ์‚ฌ์ฝ”๋“œ + ]; + const loadTableSchema = async () => { try { console.log("๐Ÿ” ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ๋กœ๋“œ ์‹œ์ž‘:", { tableName }); @@ -210,14 +249,41 @@ export const ExcelUploadModal: React.FC = ({ console.log("๐Ÿ“Š ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ์‘๋‹ต:", response); if (response.success && response.data) { - console.log("โœ… ์‹œ์Šคํ…œ ์ปฌ๋Ÿผ ๋กœ๋“œ ์™„๋ฃŒ:", response.data.columns); - setSystemColumns(response.data.columns); + // ์ž๋™ ์ƒ์„ฑ ์ปฌ๋Ÿผ ์ œ์™ธ + const filteredColumns = response.data.columns.filter( + (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) + ); + console.log("โœ… ์‹œ์Šคํ…œ ์ปฌ๋Ÿผ ๋กœ๋“œ ์™„๋ฃŒ (์ž๋™ ์ƒ์„ฑ ์ปฌ๋Ÿผ ์ œ์™ธ):", filteredColumns); + setSystemColumns(filteredColumns); - const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ - excelColumn: col, - systemColumn: null, - })); - setColumnMappings(initialMappings); + // ๊ธฐ์กด ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ + console.log("๐Ÿ” ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ์ค‘...", { tableName, excelColumns }); + const mappingResponse = await findMappingByColumns(tableName, excelColumns); + + if (mappingResponse.success && mappingResponse.data) { + // ์ €์žฅ๋œ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์ด ์žˆ์œผ๋ฉด ์ž๋™ ์ ์šฉ + console.log("โœ… ๊ธฐ์กด ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ฐœ๊ฒฌ:", mappingResponse.data); + const savedMappings = mappingResponse.data.columnMappings; + + const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({ + excelColumn: col, + systemColumn: savedMappings[col] || null, + })); + setColumnMappings(appliedMappings); + setIsAutoMappingLoaded(true); + + const matchedCount = appliedMappings.filter((m) => m.systemColumn).length; + toast.success(`์ด์ „ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์ด ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (${matchedCount}๊ฐœ ์ปฌ๋Ÿผ)`); + } else { + // ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์ด ์—†์œผ๋ฉด ์ดˆ๊ธฐ ์ƒํƒœ๋กœ ์„ค์ • + console.log("โ„น๏ธ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์—†์Œ - ์ƒˆ ์—‘์…€ ๊ตฌ์กฐ"); + const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ + excelColumn: col, + systemColumn: null, + })); + setColumnMappings(initialMappings); + setIsAutoMappingLoaded(false); + } } else { console.error("โŒ ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ๋กœ๋“œ ์‹คํŒจ:", response); } @@ -343,6 +409,27 @@ export const ExcelUploadModal: React.FC = ({ toast.success( `${successCount}๊ฐœ ํ–‰์ด ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.${failCount > 0 ? ` (์‹คํŒจ: ${failCount}๊ฐœ)` : ""}` ); + + // ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ (UPSERT - ์ž๋™ ์ €์žฅ) + try { + const mappingsToSave: Record = {}; + columnMappings.forEach((mapping) => { + mappingsToSave[mapping.excelColumn] = mapping.systemColumn; + }); + + console.log("๐Ÿ’พ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์ค‘...", { tableName, excelColumns, mappingsToSave }); + const saveResult = await saveMappingTemplate(tableName, excelColumns, mappingsToSave); + + if (saveResult.success) { + console.log("โœ… ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์™„๋ฃŒ:", saveResult.data); + } else { + console.warn("โš ๏ธ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์‹คํŒจ:", saveResult.error); + } + } catch (error) { + console.warn("โš ๏ธ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜:", error); + // ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์‹คํŒจํ•ด๋„ ์—…๋กœ๋“œ๋Š” ์„ฑ๊ณต์ด๋ฏ€๋กœ ์—๋Ÿฌ ํ‘œ์‹œ ์•ˆํ•จ + } + onSuccess?.(); } else { toast.error("์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); @@ -362,9 +449,7 @@ export const ExcelUploadModal: React.FC = ({ setFile(null); setSheetNames([]); setSelectedSheet(""); - setAutoCreateColumn(false); - setSelectedCompany(""); - setSelectedDataType(""); + setIsAutoMappingLoaded(false); setDetectedRange(""); setPreviewData([]); setAllData([]); @@ -456,16 +541,46 @@ export const ExcelUploadModal: React.FC = ({ -
- + {/* ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์˜์—ญ */} +
fileInputRef.current?.click()} + className={cn( + "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors", + isDragOver + ? "border-primary bg-primary/5" + : file + ? "border-green-500 bg-green-50" + : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50" + )} + > + {file ? ( + <> + +

{file.name}

+

+ ํด๋ฆญํ•˜์—ฌ ๋‹ค๋ฅธ ํŒŒ์ผ ์„ ํƒ +

+ + ) : ( + <> + +

+ {isDragOver ? "ํŒŒ์ผ์„ ๋†“์œผ์„ธ์š”" : "ํŒŒ์ผ์„ ๋“œ๋ž˜๊ทธํ•˜๊ฑฐ๋‚˜ ํด๋ฆญํ•˜์—ฌ ์„ ํƒ"} +

+

+ ์ง€์› ํ˜•์‹: .xlsx, .xls, .csv +

+ + )} = ({ className="hidden" />
-

- ์ง€์› ํ˜•์‹: .xlsx, .xls, .csv -

{sheetNames.length > 0 && ( @@ -510,67 +622,22 @@ export const ExcelUploadModal: React.FC = ({ {/* 2๋‹จ๊ณ„: ๋ฒ”์œ„ ์ง€์ • */} {currentStep === 2 && (
- {/* ์ƒ๋‹จ: 3๊ฐœ ๋“œ๋กญ๋‹ค์šด ๊ฐ€๋กœ ๋ฐฐ์น˜ */} -
- - - - - -
- - {/* ์ค‘๊ฐ„: ์ฒดํฌ๋ฐ•์Šค + ๋ฒ„ํŠผ๋“ค ํ•œ ์ค„ ๋ฐฐ์น˜ */} -
-
- setAutoCreateColumn(checked as boolean)} - /> - + {/* ์ƒ๋‹จ: ์‹œํŠธ ์„ ํƒ + ๋ฒ„ํŠผ๋“ค */} +
+
+ +
@@ -751,6 +818,35 @@ export const ExcelUploadModal: React.FC = ({ ))}
+ + {/* ๋งคํ•‘ ์ž๋™ ์ €์žฅ ์•ˆ๋‚ด */} + {isAutoMappingLoaded ? ( +
+
+ +
+

์ด์ „ ๋งคํ•‘์ด ์ž๋™ ์ ์šฉ๋จ

+

+ ๋™์ผํ•œ ์—‘์…€ ๊ตฌ์กฐ๊ฐ€ ๊ฐ์ง€๋˜์–ด ์ด์ „์— ์ €์žฅ๋œ ๋งคํ•‘์ด ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + ํ•„์š”์‹œ ์ˆ˜์ •ํ•˜๋ฉด ์—…๋กœ๋“œ ์‹œ ์ž๋™์œผ๋กœ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. +

+
+
+
+ ) : ( +
+
+ +
+

์ƒˆ๋กœ์šด ์—‘์…€ ๊ตฌ์กฐ

+

+ ์ด ์—‘์…€ ๊ตฌ์กฐ๋Š” ์ฒ˜์Œ์ž…๋‹ˆ๋‹ค. ์ปฌ๋Ÿผ ๋งคํ•‘์„ ์„ค์ •ํ•˜๋ฉด ์—…๋กœ๋“œ ์‹œ ์ž๋™์œผ๋กœ ์ €์žฅ๋˜์–ด + ๋‹ค์Œ์— ๊ฐ™์€ ๊ตฌ์กฐ์˜ ์—‘์…€์„ ์—…๋กœ๋“œํ•  ๋•Œ ์ž๋™ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. +

+
+
+
+ )}
)} diff --git a/frontend/lib/api/excelMapping.ts b/frontend/lib/api/excelMapping.ts new file mode 100644 index 00000000..50b046ed --- /dev/null +++ b/frontend/lib/api/excelMapping.ts @@ -0,0 +1,106 @@ +import { apiClient } from "./client"; + +export interface ExcelMappingTemplate { + id?: number; + tableName: string; + excelColumns: string[]; + excelColumnsHash: string; + columnMappings: Record; // { "์—‘์…€์ปฌ๋Ÿผ": "์‹œ์Šคํ…œ์ปฌ๋Ÿผ" } + companyCode: string; + createdDate?: string; + updatedDate?: string; +} + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +/** + * ์—‘์…€ ์ปฌ๋Ÿผ ๊ตฌ์กฐ๋กœ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ + * ๋™์ผํ•œ ์—‘์…€ ์ปฌ๋Ÿผ ๊ตฌ์กฐ๊ฐ€ ์žˆ์œผ๋ฉด ๊ธฐ์กด ๋งคํ•‘ ๋ฐ˜ํ™˜ + */ +export async function findMappingByColumns( + tableName: string, + excelColumns: string[] +): Promise> { + try { + const response = await apiClient.post("/excel-mapping/find", { + tableName, + excelColumns, + }); + return response.data; + } catch (error: any) { + console.error("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ์‹คํŒจ:", error); + return { + success: false, + error: error.message || "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ์‹คํŒจ", + }; + } +} + +/** + * ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ (UPSERT) + * ๋™์ผํ•œ ํ…Œ์ด๋ธ”+์ปฌ๋Ÿผ๊ตฌ์กฐ๊ฐ€ ์žˆ์œผ๋ฉด ์—…๋ฐ์ดํŠธ, ์—†์œผ๋ฉด ์‚ฝ์ž… + */ +export async function saveMappingTemplate( + tableName: string, + excelColumns: string[], + columnMappings: Record +): Promise> { + try { + const response = await apiClient.post("/excel-mapping/save", { + tableName, + excelColumns, + columnMappings, + }); + return response.data; + } catch (error: any) { + console.error("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์‹คํŒจ:", error); + return { + success: false, + error: error.message || "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์‹คํŒจ", + }; + } +} + +/** + * ํ…Œ์ด๋ธ”์˜ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ + */ +export async function getMappingTemplates( + tableName: string +): Promise> { + try { + const response = await apiClient.get( + `/excel-mapping/list/${encodeURIComponent(tableName)}` + ); + return response.data; + } catch (error: any) { + console.error("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ:", error); + return { + success: false, + error: error.message || "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", + }; + } +} + +/** + * ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ + */ +export async function deleteMappingTemplate( + id: number +): Promise> { + try { + const response = await apiClient.delete(`/excel-mapping/${id}`); + return response.data; + } catch (error: any) { + console.error("๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ ์‹คํŒจ:", error); + return { + success: false, + error: error.message || "๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ ์‹คํŒจ", + }; + } +} + From 83eb92cb277165ab3a5226651c00a4097ec75d16 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 11:51:02 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8B=A8=EA=B3=84=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/ExcelUploadModal.tsx | 468 +++++++++--------- 1 file changed, 235 insertions(+), 233 deletions(-) diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 97214a2a..01c39351 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -18,7 +18,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { Upload, @@ -62,29 +61,23 @@ export const ExcelUploadModal: React.FC = ({ }) => { const [currentStep, setCurrentStep] = useState(1); - // 1๋‹จ๊ณ„: ํŒŒ์ผ ์„ ํƒ + // 1๋‹จ๊ณ„: ํŒŒ์ผ ์„ ํƒ & ๋ฏธ๋ฆฌ๋ณด๊ธฐ const [file, setFile] = useState(null); const [sheetNames, setSheetNames] = useState([]); const [selectedSheet, setSelectedSheet] = useState(""); const [isDragOver, setIsDragOver] = useState(false); const fileInputRef = useRef(null); - - // 2๋‹จ๊ณ„: ๋ฒ”์œ„ ์ง€์ • - // (๋” ์ด์ƒ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ์ƒํƒœ๋“ค - 3๋‹จ๊ณ„๋กœ ์ด๋™) - - // 3๋‹จ๊ณ„: ์ปฌ๋Ÿผ ๋งคํ•‘ + ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ž๋™ ์ ์šฉ - const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false); const [detectedRange, setDetectedRange] = useState(""); - const [previewData, setPreviewData] = useState[]>([]); const [allData, setAllData] = useState[]>([]); const [displayData, setDisplayData] = useState[]>([]); - // 3๋‹จ๊ณ„: ์ปฌ๋Ÿผ ๋งคํ•‘ + // 2๋‹จ๊ณ„: ์ปฌ๋Ÿผ ๋งคํ•‘ + ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ž๋™ ์ ์šฉ + const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false); const [excelColumns, setExcelColumns] = useState([]); const [systemColumns, setSystemColumns] = useState([]); const [columnMappings, setColumnMappings] = useState([]); - // 4๋‹จ๊ณ„: ํ™•์ธ + // 3๋‹จ๊ณ„: ํ™•์ธ const [isUploading, setIsUploading] = useState(false); // ํŒŒ์ผ ์„ ํƒ ํ•ธ๋“ค๋Ÿฌ @@ -160,7 +153,7 @@ export const ExcelUploadModal: React.FC = ({ try { const data = await importFromExcel(file, sheetName); setAllData(data); - setDisplayData(data); // ์ „์ฒด ๋ฐ์ดํ„ฐ๋ฅผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์— ํ‘œ์‹œ (์Šคํฌ๋กค ๊ฐ€๋Šฅ) + setDisplayData(data); if (data.length > 0) { const columns = Object.keys(data[0]); @@ -224,30 +217,30 @@ export const ExcelUploadModal: React.FC = ({ } }; - // ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ๊ฐ€์ ธ์˜ค๊ธฐ + // ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ๊ฐ€์ ธ์˜ค๊ธฐ (2๋‹จ๊ณ„ ์ง„์ž… ์‹œ) useEffect(() => { - if (currentStep === 3 && tableName) { + if (currentStep === 2 && tableName) { loadTableSchema(); } }, [currentStep, tableName]); // ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹œ ์ž๋™ ์ƒ์„ฑ๋˜๋Š” ์‹œ์Šคํ…œ ์ปฌ๋Ÿผ (๋งคํ•‘์—์„œ ์ œ์™ธ) const AUTO_GENERATED_COLUMNS = [ - "id", // ID - "created_date", // ์ƒ์„ฑ์ผ์‹œ - "updated_date", // ์ˆ˜์ •์ผ์‹œ - "writer", // ์ž‘์„ฑ์ž - "company_code", // ํšŒ์‚ฌ์ฝ”๋“œ + "id", + "created_date", + "updated_date", + "writer", + "company_code", ]; const loadTableSchema = async () => { try { console.log("๐Ÿ” ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ๋กœ๋“œ ์‹œ์ž‘:", { tableName }); - + const response = await getTableSchema(tableName); - + console.log("๐Ÿ“Š ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ์‘๋‹ต:", response); - + if (response.success && response.data) { // ์ž๋™ ์ƒ์„ฑ ์ปฌ๋Ÿผ ์ œ์™ธ const filteredColumns = response.data.columns.filter( @@ -259,19 +252,19 @@ export const ExcelUploadModal: React.FC = ({ // ๊ธฐ์กด ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ console.log("๐Ÿ” ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ์ค‘...", { tableName, excelColumns }); const mappingResponse = await findMappingByColumns(tableName, excelColumns); - + if (mappingResponse.success && mappingResponse.data) { // ์ €์žฅ๋œ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์ด ์žˆ์œผ๋ฉด ์ž๋™ ์ ์šฉ console.log("โœ… ๊ธฐ์กด ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ๋ฐœ๊ฒฌ:", mappingResponse.data); const savedMappings = mappingResponse.data.columnMappings; - + const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({ excelColumn: col, systemColumn: savedMappings[col] || null, })); setColumnMappings(appliedMappings); setIsAutoMappingLoaded(true); - + const matchedCount = appliedMappings.filter((m) => m.systemColumn).length; toast.success(`์ด์ „ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ์ด ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (${matchedCount}๊ฐœ ์ปฌ๋Ÿผ)`); } else { @@ -297,10 +290,11 @@ export const ExcelUploadModal: React.FC = ({ const handleAutoMapping = () => { const newMappings = excelColumns.map((excelCol) => { const normalizedExcelCol = excelCol.toLowerCase().trim(); - + // 1. ๋จผ์ € ๋ผ๋ฒจ๋กœ ๋งค์นญ ์‹œ๋„ let matchedSystemCol = systemColumns.find( - (sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol + (sysCol) => + sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol ); // 2. ๋ผ๋ฒจ๋กœ ๋งค์นญ๋˜์ง€ ์•Š์œผ๋ฉด ์ปฌ๋Ÿผ๋ช…์œผ๋กœ ๋งค์นญ ์‹œ๋„ @@ -325,9 +319,7 @@ export const ExcelUploadModal: React.FC = ({ const handleMappingChange = (excelColumn: string, systemColumn: string | null) => { setColumnMappings((prev) => prev.map((mapping) => - mapping.excelColumn === excelColumn - ? { ...mapping, systemColumn } - : mapping + mapping.excelColumn === excelColumn ? { ...mapping, systemColumn } : mapping ) ); }; @@ -339,12 +331,12 @@ export const ExcelUploadModal: React.FC = ({ return; } - if (currentStep === 2 && displayData.length === 0) { + if (currentStep === 1 && displayData.length === 0) { toast.error("๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."); return; } - setCurrentStep((prev) => Math.min(prev + 1, 4)); + setCurrentStep((prev) => Math.min(prev + 1, 3)); }; // ์ด์ „ ๋‹จ๊ณ„ @@ -362,7 +354,7 @@ export const ExcelUploadModal: React.FC = ({ setIsUploading(true); try { - // allData๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ „์ฒด ๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ (displayData๋Š” ๋ฏธ๋ฆฌ๋ณด๊ธฐ์šฉ 10๊ฐœ๋งŒ) + // allData๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ „์ฒด ๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ const mappedData = allData.map((row) => { const mappedRow: Record = {}; columnMappings.forEach((mapping) => { @@ -376,7 +368,6 @@ export const ExcelUploadModal: React.FC = ({ // ๋นˆ ํ–‰ ํ•„ํ„ฐ๋ง: ๋ชจ๋“  ๊ฐ’์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ undefined/null์ธ ํ–‰ ์ œ์™ธ const filteredData = mappedData.filter((row) => { const values = Object.values(row); - // ํ•˜๋‚˜๋ผ๋„ ์œ ํšจํ•œ ๊ฐ’์ด ์žˆ๋Š”์ง€ ํ™•์ธ return values.some((value) => { if (value === undefined || value === null) return false; if (typeof value === "string" && value.trim() === "") return false; @@ -384,7 +375,9 @@ export const ExcelUploadModal: React.FC = ({ }); }); - console.log(`๐Ÿ“Š ์—‘์…€ ์—…๋กœ๋“œ: ์ „์ฒด ${mappedData.length}ํ–‰ ์ค‘ ์œ ํšจํ•œ ${filteredData.length}ํ–‰`); + console.log( + `๐Ÿ“Š ์—‘์…€ ์—…๋กœ๋“œ: ์ „์ฒด ${mappedData.length}ํ–‰ ์ค‘ ์œ ํšจํ•œ ${filteredData.length}ํ–‰` + ); let successCount = 0; let failCount = 0; @@ -416,10 +409,18 @@ export const ExcelUploadModal: React.FC = ({ columnMappings.forEach((mapping) => { mappingsToSave[mapping.excelColumn] = mapping.systemColumn; }); - - console.log("๐Ÿ’พ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์ค‘...", { tableName, excelColumns, mappingsToSave }); - const saveResult = await saveMappingTemplate(tableName, excelColumns, mappingsToSave); - + + console.log("๐Ÿ’พ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์ค‘...", { + tableName, + excelColumns, + mappingsToSave, + }); + const saveResult = await saveMappingTemplate( + tableName, + excelColumns, + mappingsToSave + ); + if (saveResult.success) { console.log("โœ… ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์™„๋ฃŒ:", saveResult.data); } else { @@ -427,7 +428,6 @@ export const ExcelUploadModal: React.FC = ({ } } catch (error) { console.warn("โš ๏ธ ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜:", error); - // ๋งคํ•‘ ํ…œํ”Œ๋ฆฟ ์ €์žฅ ์‹คํŒจํ•ด๋„ ์—…๋กœ๋“œ๋Š” ์„ฑ๊ณต์ด๋ฏ€๋กœ ์—๋Ÿฌ ํ‘œ์‹œ ์•ˆํ•จ } onSuccess?.(); @@ -451,7 +451,6 @@ export const ExcelUploadModal: React.FC = ({ setSelectedSheet(""); setIsAutoMappingLoaded(false); setDetectedRange(""); - setPreviewData([]); setAllData([]); setDisplayData([]); setExcelColumns([]); @@ -479,17 +478,16 @@ export const ExcelUploadModal: React.FC = ({ ์—‘์…€ ๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ - ์—‘์…€ ํŒŒ์ผ์„ ์„ ํƒํ•˜๊ณ  ์ปฌ๋Ÿผ์„ ๋งคํ•‘ํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฅผ ์—…๋กœ๋“œํ•˜์„ธ์š”. ๋ชจ๋‹ฌ ํ…Œ๋‘๋ฆฌ๋ฅผ ๋“œ๋ž˜๊ทธํ•˜์—ฌ ํฌ๊ธฐ๋ฅผ ์กฐ์ ˆํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + ์—‘์…€ ํŒŒ์ผ์„ ์„ ํƒํ•˜๊ณ  ์ปฌ๋Ÿผ์„ ๋งคํ•‘ํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฅผ ์—…๋กœ๋“œํ•˜์„ธ์š”. - {/* ์Šคํ… ์ธ๋””์ผ€์ดํ„ฐ */} + {/* ์Šคํ… ์ธ๋””์ผ€์ดํ„ฐ (3๋‹จ๊ณ„) */}
{[ { num: 1, label: "ํŒŒ์ผ ์„ ํƒ" }, - { num: 2, label: "๋ฒ”์œ„ ์ง€์ •" }, - { num: 3, label: "์ปฌ๋Ÿผ ๋งคํ•‘" }, - { num: 4, label: "ํ™•์ธ" }, + { num: 2, label: "์ปฌ๋Ÿผ ๋งคํ•‘" }, + { num: 3, label: "ํ™•์ธ" }, ].map((step, index) => (
@@ -512,15 +510,13 @@ export const ExcelUploadModal: React.FC = ({ {step.label}
- {index < 3 && ( + {index < 2 && (
= ({ {/* ์Šคํ…๋ณ„ ์ปจํ…์ธ  */}
- {/* 1๋‹จ๊ณ„: ํŒŒ์ผ ์„ ํƒ */} + {/* 1๋‹จ๊ณ„: ํŒŒ์ผ ์„ ํƒ & ๋ฏธ๋ฆฌ๋ณด๊ธฐ (ํ†ตํ•ฉ) */} {currentStep === 1 && (
+ {/* ํŒŒ์ผ ์„ ํƒ ์˜์—ญ */}
- {/* ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์˜์—ญ */}
fileInputRef.current?.click()} className={cn( - "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors", + "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors", isDragOver ? "border-primary bg-primary/5" : file @@ -557,24 +553,32 @@ export const ExcelUploadModal: React.FC = ({ )} > {file ? ( - <> - -

{file.name}

-

- ํด๋ฆญํ•˜์—ฌ ๋‹ค๋ฅธ ํŒŒ์ผ ์„ ํƒ -

- +
+ +
+

{file.name}

+

+ ํด๋ฆญํ•˜์—ฌ ๋‹ค๋ฅธ ํŒŒ์ผ ์„ ํƒ +

+
+
) : ( <> - -

- {isDragOver ? "ํŒŒ์ผ์„ ๋†“์œผ์„ธ์š”" : "ํŒŒ์ผ์„ ๋“œ๋ž˜๊ทธํ•˜๊ฑฐ๋‚˜ ํด๋ฆญํ•˜์—ฌ ์„ ํƒ"} + +

+ {isDragOver + ? "ํŒŒ์ผ์„ ๋†“์œผ์„ธ์š”" + : "ํŒŒ์ผ์„ ๋“œ๋ž˜๊ทธํ•˜๊ฑฐ๋‚˜ ํด๋ฆญํ•˜์—ฌ ์„ ํƒ"}

์ง€์› ํ˜•์‹: .xlsx, .xls, .csv @@ -592,163 +596,148 @@ export const ExcelUploadModal: React.FC = ({

- {sheetNames.length > 0 && ( -
- - -
- )} -
- )} + {/* ํŒŒ์ผ์ด ์„ ํƒ๋œ ๊ฒฝ์šฐ์—๋งŒ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ‘œ์‹œ */} + {file && displayData.length > 0 && ( + <> + {/* ์‹œํŠธ ์„ ํƒ + ํ–‰/์—ด ํŽธ์ง‘ ๋ฒ„ํŠผ */} +
+
+ + +
- {/* 2๋‹จ๊ณ„: ๋ฒ”์œ„ ์ง€์ • */} - {currentStep === 2 && ( -
- {/* ์ƒ๋‹จ: ์‹œํŠธ ์„ ํƒ + ๋ฒ„ํŠผ๋“ค */} -
-
- - -
+
+ + + + +
+
-
- - - - -
-
+ {/* ๊ฐ์ง€๋œ ๋ฒ”์œ„ */} +
+ ๊ฐ์ง€๋œ ๋ฒ”์œ„: {detectedRange} + ({displayData.length}๊ฐœ ํ–‰) +
- {/* ํ•˜๋‹จ: ๊ฐ์ง€๋œ ๋ฒ”์œ„ + ํ…Œ์ด๋ธ” */} -
- ๊ฐ์ง€๋œ ๋ฒ”์œ„: {detectedRange} - - ์ฒซ ํ–‰์ด ์ปฌ๋Ÿผ๋ช…, ๋ฐ์ดํ„ฐ๋Š” ์ž๋™ ๊ฐ์ง€๋ฉ๋‹ˆ๋‹ค - -
- - {displayData.length > 0 && ( -
- - - - - {excelColumns.map((col, index) => ( - - ))} - - - - - - {excelColumns.map((col) => ( - - ))} - - {displayData.map((row, rowIndex) => ( - - + + {excelColumns.map((col) => ( + + ))} + + ))} + {displayData.length > 10 && ( + + + + )} + +
- - - {String.fromCharCode(65 + index)} -
- 1 - - {col} -
- {rowIndex + 2} + {/* ๋ฐ์ดํ„ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ…Œ์ด๋ธ” */} +
+ + + + + {excelColumns.map((col, index) => ( + + ))} + + + + + {excelColumns.map((col) => ( ))} - ))} - -
+ {String.fromCharCode(65 + index)} +
+ 1 - {String(row[col] || "")} + {col}
-
+ {displayData.slice(0, 10).map((row, rowIndex) => ( +
+ {rowIndex + 2} + + {String(row[col] || "")} +
+ ... ์™ธ {displayData.length - 10}๊ฐœ ํ–‰ +
+
+ )}
)} - {/* 3๋‹จ๊ณ„: ์ปฌ๋Ÿผ ๋งคํ•‘ */} - {currentStep === 3 && ( + {/* 2๋‹จ๊ณ„: ์ปฌ๋Ÿผ ๋งคํ•‘ */} + {currentStep === 2 && (
{/* ์ƒ๋‹จ: ์ œ๋ชฉ + ์ž๋™ ๋งคํ•‘ ๋ฒ„ํŠผ */}
@@ -773,9 +762,12 @@ export const ExcelUploadModal: React.FC = ({
์‹œ์Šคํ…œ ์ปฌ๋Ÿผ
-
+
{columnMappings.map((mapping, index) => ( -
+
{mapping.excelColumn}
@@ -793,7 +785,9 @@ export const ExcelUploadModal: React.FC = ({ {mapping.systemColumn ? (() => { - const col = systemColumns.find(c => c.name === mapping.systemColumn); + const col = systemColumns.find( + (c) => c.name === mapping.systemColumn + ); return col?.label || mapping.systemColumn; })() : "๋งคํ•‘ ์•ˆํ•จ"} @@ -821,27 +815,27 @@ export const ExcelUploadModal: React.FC = ({ {/* ๋งคํ•‘ ์ž๋™ ์ €์žฅ ์•ˆ๋‚ด */} {isAutoMappingLoaded ? ( -
+

์ด์ „ ๋งคํ•‘์ด ์ž๋™ ์ ์šฉ๋จ

๋™์ผํ•œ ์—‘์…€ ๊ตฌ์กฐ๊ฐ€ ๊ฐ์ง€๋˜์–ด ์ด์ „์— ์ €์žฅ๋œ ๋งคํ•‘์ด ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. - ํ•„์š”์‹œ ์ˆ˜์ •ํ•˜๋ฉด ์—…๋กœ๋“œ ์‹œ ์ž๋™์œผ๋กœ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. + ์ˆ˜์ •ํ•˜๋ฉด ์—…๋กœ๋“œ ์‹œ ์ž๋™ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

) : ( -
+

์ƒˆ๋กœ์šด ์—‘์…€ ๊ตฌ์กฐ

- ์ด ์—‘์…€ ๊ตฌ์กฐ๋Š” ์ฒ˜์Œ์ž…๋‹ˆ๋‹ค. ์ปฌ๋Ÿผ ๋งคํ•‘์„ ์„ค์ •ํ•˜๋ฉด ์—…๋กœ๋“œ ์‹œ ์ž๋™์œผ๋กœ ์ €์žฅ๋˜์–ด - ๋‹ค์Œ์— ๊ฐ™์€ ๊ตฌ์กฐ์˜ ์—‘์…€์„ ์—…๋กœ๋“œํ•  ๋•Œ ์ž๋™ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. + ์ด ์—‘์…€ ๊ตฌ์กฐ๋Š” ์ฒ˜์Œ์ž…๋‹ˆ๋‹ค. ๋งคํ•‘์„ ์„ค์ •ํ•˜๋ฉด ๋‹ค์Œ์— ๊ฐ™์€ ๊ตฌ์กฐ์˜ + ์—‘์…€์— ์ž๋™ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.

@@ -850,8 +844,8 @@ export const ExcelUploadModal: React.FC = ({
)} - {/* 4๋‹จ๊ณ„: ํ™•์ธ */} - {currentStep === 4 && ( + {/* 3๋‹จ๊ณ„: ํ™•์ธ */} + {currentStep === 3 && (

์—…๋กœ๋“œ ์š”์•ฝ

@@ -871,7 +865,7 @@ export const ExcelUploadModal: React.FC = ({

๋ชจ๋“œ:{" "} {uploadMode === "insert" - ? "์‚ฝ์ž…" + ? "์‹ ๊ทœ ๋“ฑ๋ก" : uploadMode === "update" ? "์—…๋ฐ์ดํŠธ" : "Upsert"} @@ -884,12 +878,17 @@ export const ExcelUploadModal: React.FC = ({

{columnMappings .filter((m) => m.systemColumn) - .map((mapping, index) => ( -

- {mapping.excelColumn} โ†’{" "} - {mapping.systemColumn} -

- ))} + .map((mapping, index) => { + const col = systemColumns.find( + (c) => c.name === mapping.systemColumn + ); + return ( +

+ {mapping.excelColumn} โ†’{" "} + {col?.label || mapping.systemColumn} +

+ ); + })} {columnMappings.filter((m) => m.systemColumn).length === 0 && (

๋งคํ•‘๋œ ์ปฌ๋Ÿผ์ด ์—†์Šต๋‹ˆ๋‹ค.

)} @@ -902,7 +901,8 @@ export const ExcelUploadModal: React.FC = ({

์ฃผ์˜์‚ฌํ•ญ

- ์—…๋กœ๋“œ๋ฅผ ์ง„ํ–‰ํ•˜๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. ๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? + ์—…๋กœ๋“œ๋ฅผ ์ง„ํ–‰ํ•˜๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. + ๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?

@@ -920,10 +920,10 @@ export const ExcelUploadModal: React.FC = ({ > {currentStep === 1 ? "์ทจ์†Œ" : "์ด์ „"} - {currentStep < 4 ? ( + {currentStep < 3 ? ( )} From b61cb17aeafcd3d74a59752fe9ae8faa2dbcc7c4 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 12:04:31 +0900 Subject: [PATCH 07/12] =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B1=84=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=ED=95=B8=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/EditableSpreadsheet.tsx | 695 ++++++++++++++++++ .../components/common/ExcelUploadModal.tsx | 228 ++---- 2 files changed, 762 insertions(+), 161 deletions(-) create mode 100644 frontend/components/common/EditableSpreadsheet.tsx diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx new file mode 100644 index 00000000..de9c827d --- /dev/null +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -0,0 +1,695 @@ +"use client"; + +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; + +interface EditableSpreadsheetProps { + columns: string[]; + data: Record[]; + onColumnsChange: (columns: string[]) => void; + onDataChange: (data: Record[]) => void; + maxHeight?: string; +} + +/** + * ์—‘์…€์ฒ˜๋Ÿผ ํŽธ์ง‘ ๊ฐ€๋Šฅํ•œ ์Šคํ”„๋ ˆ๋“œ์‹œํŠธ ์ปดํฌ๋„ŒํŠธ + * - ์…€ ํด๋ฆญ์œผ๋กœ ํŽธ์ง‘ + * - Tab/Enter๋กœ ๋‹ค์Œ ์…€ ์ด๋™ + * - ๋งˆ์ง€๋ง‰ ํ–‰/์—ด์—์„œ ์ž๋™ ์ถ”๊ฐ€ + * - ํ—ค๋”(์ปฌ๋Ÿผ๋ช…)๋„ ํŽธ์ง‘ ๊ฐ€๋Šฅ + * - ์ž๋™ ์ฑ„์šฐ๊ธฐ (๋“œ๋ž˜๊ทธ ํ•ธ๋“ค) + */ +export const EditableSpreadsheet: React.FC = ({ + columns, + data, + onColumnsChange, + onDataChange, + maxHeight = "350px", +}) => { + // ํ˜„์žฌ ํŽธ์ง‘ ์ค‘์ธ ์…€ (row: -1์€ ํ—ค๋”) + const [editingCell, setEditingCell] = useState<{ + row: number; + col: number; + } | null>(null); + const [editValue, setEditValue] = useState(""); + + // ํ˜„์žฌ ์„ ํƒ๋œ ์…€ (ํŽธ์ง‘ ๋ชจ๋“œ ์•„๋‹ ๋•Œ๋„ ํ‘œ์‹œ) + const [selectedCell, setSelectedCell] = useState<{ + row: number; + col: number; + } | null>(null); + + // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์ƒํƒœ + const [isDraggingFill, setIsDraggingFill] = useState(false); + const [fillPreviewEnd, setFillPreviewEnd] = useState(null); + + const inputRef = useRef(null); + const tableRef = useRef(null); + + // ์…€ ์„ ํƒ (ํด๋ฆญ๋งŒ, ํŽธ์ง‘ ์•„๋‹˜) + const selectCell = useCallback((row: number, col: number) => { + setSelectedCell({ row, col }); + }, []); + + // ์…€ ํŽธ์ง‘ ์‹œ์ž‘ (๋”๋ธ”ํด๋ฆญ ๋˜๋Š” ํƒ€์ดํ•‘ ์‹œ์ž‘) + const startEditing = useCallback( + (row: number, col: number) => { + setEditingCell({ row, col }); + setSelectedCell({ row, col }); + if (row === -1) { + // ํ—ค๋” ํŽธ์ง‘ + setEditValue(columns[col] || ""); + } else { + // ๋ฐ์ดํ„ฐ ์…€ ํŽธ์ง‘ + const colName = columns[col]; + setEditValue(String(data[row]?.[colName] ?? "")); + } + }, + [columns, data] + ); + + // ํŽธ์ง‘ ์™„๋ฃŒ + const finishEditing = useCallback(() => { + if (!editingCell) return; + + const { row, col } = editingCell; + + if (row === -1) { + // ํ—ค๋”(์ปฌ๋Ÿผ๋ช…) ๋ณ€๊ฒฝ + const newColumns = [...columns]; + const oldColName = newColumns[col]; + const newColName = editValue.trim() || `Column${col + 1}`; + + if (oldColName !== newColName) { + newColumns[col] = newColName; + onColumnsChange(newColumns); + + // ๋ฐ์ดํ„ฐ์˜ ํ‚ค๋„ ํ•จ๊ป˜ ๋ณ€๊ฒฝ + const newData = data.map((rowData) => { + const newRowData: Record = {}; + Object.keys(rowData).forEach((key) => { + if (key === oldColName) { + newRowData[newColName] = rowData[key]; + } else { + newRowData[key] = rowData[key]; + } + }); + return newRowData; + }); + onDataChange(newData); + } + } else { + // ๋ฐ์ดํ„ฐ ์…€ ๋ณ€๊ฒฝ + const colName = columns[col]; + const newData = [...data]; + if (!newData[row]) { + newData[row] = {}; + } + newData[row] = { ...newData[row], [colName]: editValue }; + onDataChange(newData); + } + + setEditingCell(null); + setEditValue(""); + }, [editingCell, editValue, columns, data, onColumnsChange, onDataChange]); + + // ๋‹ค์Œ ์…€๋กœ ์ด๋™ + const moveToNextCell = useCallback( + (direction: "right" | "down" | "left" | "up") => { + if (!editingCell) return; + + finishEditing(); + + const { row, col } = editingCell; + let nextRow = row; + let nextCol = col; + + switch (direction) { + case "right": + if (col < columns.length - 1) { + nextCol = col + 1; + } else { + // ๋งˆ์ง€๋ง‰ ์—ด์—์„œ Tab โ†’ ์ƒˆ ์—ด ์ถ”๊ฐ€ (๋นˆ ํ—ค๋”๋กœ) + const tempColId = `__temp_${Date.now()}`; + const newColumns = [...columns, ""]; + onColumnsChange(newColumns); + + // ๋ชจ๋“  ํ–‰์— ์ƒˆ ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (์ž„์‹œ ํ‚ค ์‚ฌ์šฉ) + const newData = data.map((rowData) => ({ + ...rowData, + [tempColId]: "", + })); + onDataChange(newData); + + nextCol = columns.length; + } + break; + + case "down": + if (row === -1) { + nextRow = 0; + } else if (row < data.length - 1) { + nextRow = row + 1; + } else { + // ๋งˆ์ง€๋ง‰ ํ–‰์—์„œ Enter โ†’ ์ƒˆ ํ–‰ ์ถ”๊ฐ€ + const newRow: Record = {}; + columns.forEach((c) => { + newRow[c] = ""; + }); + onDataChange([...data, newRow]); + nextRow = data.length; + } + break; + + case "left": + if (col > 0) { + nextCol = col - 1; + } + break; + + case "up": + if (row > -1) { + nextRow = row - 1; + } + break; + } + + // ๋‹ค์Œ ์…€ ํŽธ์ง‘ ์‹œ์ž‘ + setTimeout(() => { + startEditing(nextRow, nextCol); + }, 0); + }, + [editingCell, columns, data, onColumnsChange, onDataChange, finishEditing, startEditing] + ); + + // ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "Tab": + e.preventDefault(); + moveToNextCell(e.shiftKey ? "left" : "right"); + break; + case "Enter": + e.preventDefault(); + moveToNextCell("down"); + break; + case "Escape": + setEditingCell(null); + setEditValue(""); + break; + case "ArrowUp": + if (!e.shiftKey) { + e.preventDefault(); + moveToNextCell("up"); + } + break; + case "ArrowDown": + if (!e.shiftKey) { + e.preventDefault(); + moveToNextCell("down"); + } + break; + case "ArrowLeft": + // ์ปค์„œ๊ฐ€ ๋งจ ์•ž์ด๋ฉด ์™ผ์ชฝ ์…€๋กœ + if (inputRef.current?.selectionStart === 0) { + e.preventDefault(); + moveToNextCell("left"); + } + break; + case "ArrowRight": + // ์ปค์„œ๊ฐ€ ๋งจ ๋’ค๋ฉด ์˜ค๋ฅธ์ชฝ ์…€๋กœ + if (inputRef.current?.selectionStart === editValue.length) { + e.preventDefault(); + moveToNextCell("right"); + } + break; + } + }, + [moveToNextCell, editValue] + ); + + // ํŽธ์ง‘ ๋ชจ๋“œ์ผ ๋•Œ input์— ํฌ์ปค์Šค + useEffect(() => { + if (editingCell && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editingCell]); + + // ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ํŽธ์ง‘ ์ข…๋ฃŒ + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (tableRef.current && !tableRef.current.contains(e.target as Node)) { + finishEditing(); + setSelectedCell(null); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [finishEditing]); + + // ํ–‰ ์‚ญ์ œ + const handleDeleteRow = (rowIndex: number) => { + const newData = data.filter((_, i) => i !== rowIndex); + onDataChange(newData); + }; + + // ์—ด ์‚ญ์ œ + const handleDeleteColumn = (colIndex: number) => { + if (columns.length <= 1) return; + + const colName = columns[colIndex]; + const newColumns = columns.filter((_, i) => i !== colIndex); + onColumnsChange(newColumns); + + const newData = data.map((row) => { + const { [colName]: removed, ...rest } = row; + return rest; + }); + onDataChange(newData); + }; + + // ์ปฌ๋Ÿผ ๋ฌธ์ž (A, B, C, ...) + const getColumnLetter = (index: number): string => { + let letter = ""; + let i = index; + while (i >= 0) { + letter = String.fromCharCode(65 + (i % 26)) + letter; + i = Math.floor(i / 26) - 1; + } + return letter; + }; + + // ============ ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋กœ์ง ============ + + // ๊ฐ’์—์„œ ๋งˆ์ง€๋ง‰ ์ˆซ์ž ํŒจํ„ด ์ถ”์ถœ (์˜ˆ: "26-item-0005" โ†’ prefix: "26-item-", number: 5, suffix: "", numLength: 4) + const extractNumberPattern = (value: string): { + prefix: string; + number: number; + suffix: string; + numLength: number; + isZeroPadded: boolean; + } | null => { + // ์ˆซ์ž๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ + if (/^-?\d+(\.\d+)?$/.test(value)) { + const isZeroPadded = value.startsWith("0") && value.length > 1 && !value.includes("."); + return { + prefix: "", + number: parseFloat(value), + suffix: "", + numLength: value.replace("-", "").split(".")[0].length, + isZeroPadded + }; + } + + // ๋งˆ์ง€๋ง‰ ์ˆซ์ž ์‹œํ€€์Šค๋ฅผ ์ฐพ๊ธฐ (greedyํ•˜๊ฒŒ prefix๋ฅผ ์ฐพ์Œ) + // ์˜ˆ: "26-item-0005" โ†’ prefix: "26-item-", number: "0005", suffix: "" + const match = value.match(/^(.*)(\d+)(\D*)$/); + if (match) { + const numStr = match[2]; + const isZeroPadded = numStr.startsWith("0") && numStr.length > 1; + return { + prefix: match[1], + number: parseInt(numStr, 10), + suffix: match[3], + numLength: numStr.length, + isZeroPadded + }; + } + + return null; + }; + + // ๋‚ ์งœ ํŒจํ„ด ์ธ์‹ + const extractDatePattern = (value: string): Date | null => { + // YYYY-MM-DD ํ˜•์‹ + const dateMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (dateMatch) { + const date = new Date(parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3])); + if (!isNaN(date.getTime())) { + return date; + } + } + return null; + }; + + // ๋‹ค์Œ ๊ฐ’ ์ƒ์„ฑ + const generateNextValue = (sourceValue: string, step: number): string => { + // ๋นˆ ๊ฐ’์ด๋ฉด ๊ทธ๋Œ€๋กœ + if (!sourceValue || sourceValue.trim() === "") { + return ""; + } + + // ๋‚ ์งœ ํŒจํ„ด ์ฒดํฌ + const datePattern = extractDatePattern(sourceValue); + if (datePattern) { + const newDate = new Date(datePattern); + newDate.setDate(newDate.getDate() + step); + const year = newDate.getFullYear(); + const month = String(newDate.getMonth() + 1).padStart(2, "0"); + const day = String(newDate.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + // ์ˆซ์ž ํŒจํ„ด ์ฒดํฌ + const numberPattern = extractNumberPattern(sourceValue); + if (numberPattern) { + const newNumber = numberPattern.number + step; + + // ์Œ์ˆ˜ ๋ฐฉ์ง€ (ํ•„์š”์‹œ) + const absNumber = Math.max(0, newNumber); + + let numStr: string; + if (numberPattern.isZeroPadded) { + // ์ œ๋กœํŒจ๋”ฉ ์œ ์ง€ (์˜ˆ: 0005 โ†’ 0006) + numStr = String(absNumber).padStart(numberPattern.numLength, "0"); + } else { + numStr = String(absNumber); + } + + return numberPattern.prefix + numStr + numberPattern.suffix; + } + + // ํŒจํ„ด ์—†์œผ๋ฉด ๋ณต์‚ฌ + return sourceValue; + }; + + // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ + const handleFillDragStart = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!selectedCell || selectedCell.row < 0) return; + + setIsDraggingFill(true); + setFillPreviewEnd(selectedCell.row); + }; + + // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์ค‘ + const handleFillDragMove = useCallback((e: MouseEvent) => { + if (!isDraggingFill || !selectedCell || !tableRef.current) return; + + const rows = tableRef.current.querySelectorAll("tbody tr"); + const mouseY = e.clientY; + + // ๋งˆ์šฐ์Šค ์œ„์น˜์— ํ•ด๋‹นํ•˜๋Š” ํ–‰ ์ฐพ๊ธฐ + for (let i = 0; i < rows.length - 1; i++) { // ๋งˆ์ง€๋ง‰ ํ–‰(์ถ”๊ฐ€ ์˜์—ญ) ์ œ์™ธ + const row = rows[i] as HTMLElement; + const rect = row.getBoundingClientRect(); + + if (mouseY >= rect.top && mouseY <= rect.bottom) { + setFillPreviewEnd(i); + break; + } else if (mouseY > rect.bottom && i === rows.length - 2) { + setFillPreviewEnd(i); + } + } + }, [isDraggingFill, selectedCell]); + + // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ + const handleFillDragEnd = useCallback(() => { + if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) { + setIsDraggingFill(false); + setFillPreviewEnd(null); + return; + } + + const { row: startRow, col } = selectedCell; + const endRow = fillPreviewEnd; + + if (startRow !== endRow && startRow >= 0) { + const colName = columns[col]; + const sourceValue = String(data[startRow]?.[colName] ?? ""); + const newData = [...data]; + + if (endRow > startRow) { + // ์•„๋ž˜๋กœ ์ฑ„์šฐ๊ธฐ + for (let i = startRow + 1; i <= endRow; i++) { + const step = i - startRow; + if (!newData[i]) { + newData[i] = {}; + columns.forEach((c) => { + newData[i][c] = ""; + }); + } + newData[i] = { + ...newData[i], + [colName]: generateNextValue(sourceValue, step), + }; + } + } else { + // ์œ„๋กœ ์ฑ„์šฐ๊ธฐ + for (let i = startRow - 1; i >= endRow; i--) { + const step = i - startRow; + if (!newData[i]) { + newData[i] = {}; + columns.forEach((c) => { + newData[i][c] = ""; + }); + } + newData[i] = { + ...newData[i], + [colName]: generateNextValue(sourceValue, step), + }; + } + } + + onDataChange(newData); + } + + setIsDraggingFill(false); + setFillPreviewEnd(null); + }, [isDraggingFill, selectedCell, fillPreviewEnd, columns, data, onDataChange]); + + // ๋“œ๋ž˜๊ทธ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + useEffect(() => { + if (isDraggingFill) { + document.addEventListener("mousemove", handleFillDragMove); + document.addEventListener("mouseup", handleFillDragEnd); + return () => { + document.removeEventListener("mousemove", handleFillDragMove); + document.removeEventListener("mouseup", handleFillDragEnd); + }; + } + }, [isDraggingFill, handleFillDragMove, handleFillDragEnd]); + + // ์…€์ด ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ฒ”์œ„์— ์žˆ๋Š”์ง€ ํ™•์ธ + const isInFillPreview = (rowIndex: number, colIndex: number): boolean => { + if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) return false; + if (colIndex !== selectedCell.col) return false; + + const startRow = selectedCell.row; + const endRow = fillPreviewEnd; + + if (endRow > startRow) { + return rowIndex > startRow && rowIndex <= endRow; + } else { + return rowIndex >= endRow && rowIndex < startRow; + } + }; + + return ( +
+ + {/* ์—ด ์ธ๋ฑ์Šค ํ—ค๋” (A, B, C, ...) */} + + + {/* ๋นˆ ์ฝ”๋„ˆ ์…€ */} + + {columns.map((_, colIndex) => ( + + ))} + {/* ์ƒˆ ์—ด ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} + + + + {/* ์ปฌ๋Ÿผ๋ช… ํ—ค๋” (ํŽธ์ง‘ ๊ฐ€๋Šฅ) */} + + + {columns.map((colName, colIndex) => ( + + ))} + + + + + + {data.map((row, rowIndex) => ( + + {/* ํ–‰ ๋ฒˆํ˜ธ */} + + + {/* ๋ฐ์ดํ„ฐ ์…€ */} + {columns.map((colName, colIndex) => { + const isSelected = selectedCell?.row === rowIndex && selectedCell?.col === colIndex; + const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex; + const inFillPreview = isInFillPreview(rowIndex, colIndex); + + return ( + + ); + })} + + + ))} + + {/* ์ƒˆ ํ–‰ ์ถ”๊ฐ€ ์˜์—ญ */} + + + + + +
+ + +
+ {getColumnLetter(colIndex)} + {columns.length > 1 && ( + + )} +
+
+ +
+ 1 + { + selectCell(-1, colIndex); + startEditing(-1, colIndex); + }} + > + {editingCell?.row === -1 && editingCell?.col === colIndex ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={finishEditing} + className="w-full bg-white px-2 py-1 text-xs font-medium text-primary outline-none" + /> + ) : ( +
{colName || ๋นˆ ํ—ค๋”}
+ )} +
+
+ {rowIndex + 2} + +
+
{ + selectCell(rowIndex, colIndex); + if (!isEditing) { + // ๋‹จ์ผ ํด๋ฆญ์€ ์„ ํƒ๋งŒ + } + }} + onDoubleClick={() => startEditing(rowIndex, colIndex)} + > + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={finishEditing} + className="w-full bg-white px-2 py-1 text-xs outline-none" + /> + ) : ( +
+ {String(row[colName] ?? "")} +
+ )} + + {/* ์ž๋™ ์ฑ„์šฐ๊ธฐ ํ•ธ๋“ค - ์„ ํƒ๋œ ์…€์—์„œ๋งŒ ํ‘œ์‹œ */} + {isSelected && !isEditing && ( +
+ )} +
+ + { + const newRow: Record = {}; + columns.forEach((c) => { + newRow[c] = ""; + }); + onDataChange([...data, newRow]); + // ์ƒˆ ํ–‰์˜ ์ฒซ ๋ฒˆ์งธ ์…€ ํŽธ์ง‘ ์‹œ์ž‘ + setTimeout(() => { + startEditing(data.length, 0); + }, 0); + }} + > + ํด๋ฆญํ•˜์—ฌ ์ƒˆ ํ–‰ ์ถ”๊ฐ€... +
+
+ ); +}; diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 01c39351..867b6f85 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -24,8 +24,6 @@ import { FileSpreadsheet, AlertCircle, CheckCircle2, - Plus, - Minus, ArrowRight, Zap, } from "lucide-react"; @@ -34,6 +32,7 @@ import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { getTableSchema, TableColumn } from "@/lib/api/tableSchema"; import { cn } from "@/lib/utils"; import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; +import { EditableSpreadsheet } from "./EditableSpreadsheet"; export interface ExcelUploadModalProps { open: boolean; @@ -167,56 +166,6 @@ export const ExcelUploadModal: React.FC = ({ } }; - // ํ–‰ ์ถ”๊ฐ€ - const handleAddRow = () => { - const newRow: Record = {}; - excelColumns.forEach((col) => { - newRow[col] = ""; - }); - setDisplayData([...displayData, newRow]); - toast.success("ํ–‰์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - }; - - // ํ–‰ ์‚ญ์ œ - const handleRemoveRow = () => { - if (displayData.length > 1) { - setDisplayData(displayData.slice(0, -1)); - toast.success("๋งˆ์ง€๋ง‰ ํ–‰์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - } else { - toast.error("์ตœ์†Œ 1๊ฐœ์˜ ํ–‰์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); - } - }; - - // ์—ด ์ถ”๊ฐ€ - const handleAddColumn = () => { - const newColName = `Column${excelColumns.length + 1}`; - setExcelColumns([...excelColumns, newColName]); - setDisplayData( - displayData.map((row) => ({ - ...row, - [newColName]: "", - })) - ); - toast.success("์—ด์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - }; - - // ์—ด ์‚ญ์ œ - const handleRemoveColumn = () => { - if (excelColumns.length > 1) { - const lastCol = excelColumns[excelColumns.length - 1]; - setExcelColumns(excelColumns.slice(0, -1)); - setDisplayData( - displayData.map((row) => { - const { [lastCol]: removed, ...rest } = row; - return rest; - }) - ); - toast.success("๋งˆ์ง€๋ง‰ ์—ด์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - } else { - toast.error("์ตœ์†Œ 1๊ฐœ์˜ ์—ด์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); - } - }; - // ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ๊ฐ€์ ธ์˜ค๊ธฐ (2๋‹จ๊ณ„ ์ง„์ž… ์‹œ) useEffect(() => { if (currentStep === 2 && tableName) { @@ -336,6 +285,42 @@ export const ExcelUploadModal: React.FC = ({ return; } + // 1๋‹จ๊ณ„ โ†’ 2๋‹จ๊ณ„ ์ „ํ™˜ ์‹œ: ๋นˆ ํ—ค๋” ์—ด ์ œ์™ธ + if (currentStep === 1) { + // ๋นˆ ํ—ค๋”๊ฐ€ ์•„๋‹Œ ์—ด๋งŒ ํ•„ํ„ฐ๋ง + const validColumnIndices: number[] = []; + const validColumns: string[] = []; + + excelColumns.forEach((col, index) => { + if (col && col.trim() !== "") { + validColumnIndices.push(index); + validColumns.push(col); + } + }); + + // ๋นˆ ํ—ค๋” ์—ด์ด ์žˆ์—ˆ๋‹ค๋ฉด ๋ฐ์ดํ„ฐ์—์„œ๋„ ํ•ด๋‹น ์—ด ์ œ๊ฑฐ + if (validColumns.length < excelColumns.length) { + const removedCount = excelColumns.length - validColumns.length; + + // ์ƒˆ๋กœ์šด ๋ฐ์ดํ„ฐ: ์œ ํšจํ•œ ์—ด๋งŒ ํฌํ•จ + const cleanedData = displayData.map((row) => { + const newRow: Record = {}; + validColumns.forEach((colName) => { + newRow[colName] = row[colName]; + }); + return newRow; + }); + + setExcelColumns(validColumns); + setDisplayData(cleanedData); + setAllData(cleanedData); + + if (removedCount > 0) { + toast.info(`๋นˆ ํ—ค๋” ${removedCount}๊ฐœ ์—ด์ด ์ œ์™ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`); + } + } + } + setCurrentStep((prev) => Math.min(prev + 1, 3)); }; @@ -599,8 +584,8 @@ export const ExcelUploadModal: React.FC = ({ {/* ํŒŒ์ผ์ด ์„ ํƒ๋œ ๊ฒฝ์šฐ์—๋งŒ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ‘œ์‹œ */} {file && displayData.length > 0 && ( <> - {/* ์‹œํŠธ ์„ ํƒ + ํ–‰/์—ด ํŽธ์ง‘ ๋ฒ„ํŠผ */} -
+ {/* ์‹œํŠธ ์„ ํƒ */} +
- -
- - - - -
+ + {displayData.length}๊ฐœ ํ–‰ ยท ์…€์„ ํด๋ฆญํ•˜์—ฌ ํŽธ์ง‘, Tab/Enter๋กœ ์ด๋™ +
- {/* ๊ฐ์ง€๋œ ๋ฒ”์œ„ */} -
- ๊ฐ์ง€๋œ ๋ฒ”์œ„: {detectedRange} - ({displayData.length}๊ฐœ ํ–‰) -
- - {/* ๋ฐ์ดํ„ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ…Œ์ด๋ธ” */} -
- - - - - {excelColumns.map((col, index) => ( - - ))} - - - - - - {excelColumns.map((col) => ( - - ))} - - {displayData.slice(0, 10).map((row, rowIndex) => ( - - - {excelColumns.map((col) => ( - - ))} - - ))} - {displayData.length > 10 && ( - - - - )} - -
- {String.fromCharCode(65 + index)} -
- 1 - - {col} -
- {rowIndex + 2} - - {String(row[col] || "")} -
- ... ์™ธ {displayData.length - 10}๊ฐœ ํ–‰ -
-
+ {/* ์—‘์…€์ฒ˜๋Ÿผ ํŽธ์ง‘ ๊ฐ€๋Šฅํ•œ ์Šคํ”„๋ ˆ๋“œ์‹œํŠธ */} + { + setExcelColumns(newColumns); + // ๋ฒ”์œ„ ์žฌ๊ณ„์‚ฐ + const lastCol = + newColumns.length > 0 + ? String.fromCharCode(64 + newColumns.length) + : "A"; + setDetectedRange(`A1:${lastCol}${displayData.length + 1}`); + }} + onDataChange={(newData) => { + setDisplayData(newData); + setAllData(newData); + // ๋ฒ”์œ„ ์žฌ๊ณ„์‚ฐ + const lastCol = + excelColumns.length > 0 + ? String.fromCharCode(64 + excelColumns.length) + : "A"; + setDetectedRange(`A1:${lastCol}${newData.length + 1}`); + }} + maxHeight="320px" + /> )}
From 6a1343b847c6e1d2bef58609150f60018d7c59de Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 12:14:04 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=EB=84=A3=EA=B8=B0=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/globals.css | 43 ++ .../components/common/EditableSpreadsheet.tsx | 492 ++++++++++++++---- 2 files changed, 447 insertions(+), 88 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b332f5a0..2fbbe7c5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -567,4 +567,47 @@ select { scrollbar-width: none; } +/* ===== Marching Ants Animation (Excel Copy Border) ===== */ +@keyframes marching-ants-h { + 0% { + background-position: 0 0; + } + 100% { + background-position: 16px 0; + } +} + +@keyframes marching-ants-v { + 0% { + background-position: 0 0; + } + 100% { + background-position: 0 16px; + } +} + +.animate-marching-ants-h { + background: repeating-linear-gradient( + 90deg, + hsl(var(--primary)) 0, + hsl(var(--primary)) 4px, + transparent 4px, + transparent 8px + ); + background-size: 16px 2px; + animation: marching-ants-h 0.4s linear infinite; +} + +.animate-marching-ants-v { + background: repeating-linear-gradient( + 180deg, + hsl(var(--primary)) 0, + hsl(var(--primary)) 4px, + transparent 4px, + transparent 8px + ); + background-size: 2px 16px; + animation: marching-ants-v 0.4s linear infinite; +} + /* ===== End of Global Styles ===== */ diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx index de9c827d..94b080e5 100644 --- a/frontend/components/common/EditableSpreadsheet.tsx +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -11,13 +11,22 @@ interface EditableSpreadsheetProps { maxHeight?: string; } +// ์…€ ๋ฒ”์œ„ ์ •์˜ +interface CellRange { + startRow: number; + startCol: number; + endRow: number; + endCol: number; +} + /** * ์—‘์…€์ฒ˜๋Ÿผ ํŽธ์ง‘ ๊ฐ€๋Šฅํ•œ ์Šคํ”„๋ ˆ๋“œ์‹œํŠธ ์ปดํฌ๋„ŒํŠธ * - ์…€ ํด๋ฆญ์œผ๋กœ ํŽธ์ง‘ * - Tab/Enter๋กœ ๋‹ค์Œ ์…€ ์ด๋™ * - ๋งˆ์ง€๋ง‰ ํ–‰/์—ด์—์„œ ์ž๋™ ์ถ”๊ฐ€ * - ํ—ค๋”(์ปฌ๋Ÿผ๋ช…)๋„ ํŽธ์ง‘ ๊ฐ€๋Šฅ - * - ์ž๋™ ์ฑ„์šฐ๊ธฐ (๋“œ๋ž˜๊ทธ ํ•ธ๋“ค) + * - ๋‹ค์ค‘ ์…€ ์„ ํƒ (๋“œ๋ž˜๊ทธ) + * - ์ž๋™ ์ฑ„์šฐ๊ธฐ (๋“œ๋ž˜๊ทธ ํ•ธ๋“ค) - ๋‹ค์ค‘ ์…€ ์ง€์› */ export const EditableSpreadsheet: React.FC = ({ columns, @@ -33,29 +42,108 @@ export const EditableSpreadsheet: React.FC = ({ } | null>(null); const [editValue, setEditValue] = useState(""); - // ํ˜„์žฌ ์„ ํƒ๋œ ์…€ (ํŽธ์ง‘ ๋ชจ๋“œ ์•„๋‹ ๋•Œ๋„ ํ‘œ์‹œ) - const [selectedCell, setSelectedCell] = useState<{ - row: number; - col: number; - } | null>(null); + // ์„ ํƒ ๋ฒ”์œ„ (๋‹ค์ค‘ ์…€ ์„ ํƒ) + const [selection, setSelection] = useState(null); + + // ์…€ ์„ ํƒ ๋“œ๋ž˜๊ทธ ์ค‘ + const [isDraggingSelection, setIsDraggingSelection] = useState(false); // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์ƒํƒœ const [isDraggingFill, setIsDraggingFill] = useState(false); const [fillPreviewEnd, setFillPreviewEnd] = useState(null); + // ๋ณต์‚ฌ๋œ ๋ฒ”์œ„ (์ ์„  ์• ๋‹ˆ๋ฉ”์ด์…˜ ํ‘œ์‹œ์šฉ) + const [copiedRange, setCopiedRange] = useState(null); + const inputRef = useRef(null); const tableRef = useRef(null); - // ์…€ ์„ ํƒ (ํด๋ฆญ๋งŒ, ํŽธ์ง‘ ์•„๋‹˜) - const selectCell = useCallback((row: number, col: number) => { - setSelectedCell({ row, col }); - }, []); + // ๋ฒ”์œ„ ์ •๊ทœํ™” (์‹œ์ž‘์ด ๋๋ณด๋‹ค ํฌ๋ฉด ๊ตํ™˜) + const normalizeRange = (range: CellRange): CellRange => { + return { + startRow: Math.min(range.startRow, range.endRow), + startCol: Math.min(range.startCol, range.endCol), + endRow: Math.max(range.startRow, range.endRow), + endCol: Math.max(range.startCol, range.endCol), + }; + }; - // ์…€ ํŽธ์ง‘ ์‹œ์ž‘ (๋”๋ธ”ํด๋ฆญ ๋˜๋Š” ํƒ€์ดํ•‘ ์‹œ์ž‘) + // ์…€์ด ์„ ํƒ ๋ฒ”์œ„ ๋‚ด์— ์žˆ๋Š”์ง€ ํ™•์ธ + const isCellInSelection = (row: number, col: number): boolean => { + if (!selection) return false; + const norm = normalizeRange(selection); + return ( + row >= norm.startRow && + row <= norm.endRow && + col >= norm.startCol && + col <= norm.endCol + ); + }; + + // ์…€์ด ์„ ํƒ ๋ฒ”์œ„์˜ ๋(์šฐํ•˜๋‹จ)์ธ์ง€ ํ™•์ธ + const isCellSelectionEnd = (row: number, col: number): boolean => { + if (!selection) return false; + const norm = normalizeRange(selection); + return row === norm.endRow && col === norm.endCol; + }; + + // ์…€ ์„ ํƒ ์‹œ์ž‘ (ํด๋ฆญ) + const handleCellMouseDown = useCallback((row: number, col: number, e: React.MouseEvent) => { + // ํŽธ์ง‘ ์ค‘์ด๋ฉด ์ข…๋ฃŒ + if (editingCell) { + setEditingCell(null); + setEditValue(""); + } + + // ์ƒˆ ์„ ํƒ ์‹œ์ž‘ + setSelection({ + startRow: row, + startCol: col, + endRow: row, + endCol: col, + }); + setIsDraggingSelection(true); + + // ๋ณต์‚ฌ ๋ฒ”์œ„ ์ดˆ๊ธฐํ™” (์ƒˆ๋กœ์šด ์„ ํƒ ์‹œ์ž‘ํ•˜๋ฉด ์ด์ „ ๋ณต์‚ฌ ํ‘œ์‹œ ์ œ๊ฑฐ) + setCopiedRange(null); + + // ํ…Œ์ด๋ธ”์— ํฌ์ปค์Šค (ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ ์ˆ˜์‹ ์šฉ) + tableRef.current?.focus(); + }, [editingCell]); + + // ์…€ ์„ ํƒ ๋“œ๋ž˜๊ทธ ์ค‘ + const handleCellMouseEnter = useCallback((row: number, col: number) => { + if (isDraggingSelection && selection) { + setSelection((prev) => prev ? { + ...prev, + endRow: row, + endCol: col, + } : null); + } + }, [isDraggingSelection, selection]); + + // ์…€ ์„ ํƒ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ + useEffect(() => { + const handleMouseUp = () => { + if (isDraggingSelection) { + setIsDraggingSelection(false); + } + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isDraggingSelection]); + + // ์…€ ํŽธ์ง‘ ์‹œ์ž‘ (๋”๋ธ”ํด๋ฆญ) const startEditing = useCallback( (row: number, col: number) => { setEditingCell({ row, col }); - setSelectedCell({ row, col }); + setSelection({ + startRow: row, + startCol: col, + endRow: row, + endCol: col, + }); if (row === -1) { // ํ—ค๋” ํŽธ์ง‘ setEditValue(columns[col] || ""); @@ -242,7 +330,7 @@ export const EditableSpreadsheet: React.FC = ({ const handleClickOutside = (e: MouseEvent) => { if (tableRef.current && !tableRef.current.contains(e.target as Node)) { finishEditing(); - setSelectedCell(null); + setSelection(null); } }; @@ -250,6 +338,207 @@ export const EditableSpreadsheet: React.FC = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [finishEditing]); + // ============ ๋ณต์‚ฌ/๋ถ™์—ฌ๋„ฃ๊ธฐ ============ + + // ์…€์ด ๋ณต์‚ฌ ๋ฒ”์œ„ ๋‚ด์— ์žˆ๋Š”์ง€ ํ™•์ธ + const isCellInCopiedRange = (row: number, col: number): boolean => { + if (!copiedRange) return false; + const norm = normalizeRange(copiedRange); + return ( + row >= norm.startRow && + row <= norm.endRow && + col >= norm.startCol && + col <= norm.endCol + ); + }; + + // ๋ณต์‚ฌ ๋ฒ”์œ„์˜ ํ…Œ๋‘๋ฆฌ ์œ„์น˜ ํ™•์ธ + const getCopiedBorderPosition = (row: number, col: number): { top: boolean; right: boolean; bottom: boolean; left: boolean } => { + if (!copiedRange) return { top: false, right: false, bottom: false, left: false }; + const norm = normalizeRange(copiedRange); + + if (!isCellInCopiedRange(row, col)) { + return { top: false, right: false, bottom: false, left: false }; + } + + return { + top: row === norm.startRow, + right: col === norm.endCol, + bottom: row === norm.endRow, + left: col === norm.startCol, + }; + }; + + // ์„ ํƒ ๋ฒ”์œ„ ๋ณต์‚ฌ (Ctrl+C) + const handleCopy = useCallback(async () => { + if (!selection || editingCell) return; + + const norm = normalizeRange(selection); + const rows: string[] = []; + + for (let r = norm.startRow; r <= norm.endRow; r++) { + const rowValues: string[] = []; + for (let c = norm.startCol; c <= norm.endCol; c++) { + if (r === -1) { + // ํ—ค๋” ๋ณต์‚ฌ + rowValues.push(columns[c] || ""); + } else { + // ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ + const colName = columns[c]; + rowValues.push(String(data[r]?.[colName] ?? "")); + } + } + rows.push(rowValues.join("\t")); + } + + const text = rows.join("\n"); + + try { + await navigator.clipboard.writeText(text); + // ๋ณต์‚ฌ ๋ฒ”์œ„ ์ €์žฅ (์ ์„  ์• ๋‹ˆ๋ฉ”์ด์…˜ ํ‘œ์‹œ) + setCopiedRange({ ...norm }); + } catch (err) { + console.warn("ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ ์‹คํŒจ:", err); + } + }, [selection, editingCell, columns, data]); + + // ๋ถ™์—ฌ๋„ฃ๊ธฐ (Ctrl+V) + const handlePaste = useCallback(async () => { + if (!selection || editingCell) return; + + try { + const text = await navigator.clipboard.readText(); + if (!text) return; + + const norm = normalizeRange(selection); + const pasteRows = text.split(/\r?\n/).map((row) => row.split("\t")); + + // ๋นˆ ํ–‰ ์ œ๊ฑฐ + const filteredRows = pasteRows.filter((row) => row.some((cell) => cell.trim() !== "")); + if (filteredRows.length === 0) return; + + const newData = [...data]; + const newColumns = [...columns]; + let columnsChanged = false; + + for (let ri = 0; ri < filteredRows.length; ri++) { + const pasteRow = filteredRows[ri]; + const targetRow = norm.startRow + ri; + + for (let ci = 0; ci < pasteRow.length; ci++) { + const targetCol = norm.startCol + ci; + const value = pasteRow[ci]; + + if (targetRow === -1) { + // ํ—ค๋”์— ๋ถ™์—ฌ๋„ฃ๊ธฐ + if (targetCol < newColumns.length) { + newColumns[targetCol] = value; + columnsChanged = true; + } + } else { + // ๋ฐ์ดํ„ฐ์— ๋ถ™์—ฌ๋„ฃ๊ธฐ + if (targetCol < columns.length) { + // ํ•„์š”์‹œ ํ–‰ ์ถ”๊ฐ€ + while (newData.length <= targetRow) { + const emptyRow: Record = {}; + columns.forEach((c) => { + emptyRow[c] = ""; + }); + newData.push(emptyRow); + } + + const colName = columns[targetCol]; + newData[targetRow] = { + ...newData[targetRow], + [colName]: value, + }; + } + } + } + } + + if (columnsChanged) { + onColumnsChange(newColumns); + } + onDataChange(newData); + + // ๋ถ™์—ฌ๋„ฃ๊ธฐ ๋ฒ”์œ„๋กœ ์„ ํƒ ํ™•์žฅ + setSelection({ + startRow: norm.startRow, + startCol: norm.startCol, + endRow: Math.min(norm.startRow + filteredRows.length - 1, data.length - 1), + endCol: Math.min(norm.startCol + (filteredRows[0]?.length || 1) - 1, columns.length - 1), + }); + + // ๋ถ™์—ฌ๋„ฃ๊ธฐ ํ›„ ๋ณต์‚ฌ ๋ฒ”์œ„ ์ดˆ๊ธฐํ™” + setCopiedRange(null); + } catch (err) { + console.warn("ํด๋ฆฝ๋ณด๋“œ ๋ถ™์—ฌ๋„ฃ๊ธฐ ์‹คํŒจ:", err); + } + }, [selection, editingCell, columns, data, onColumnsChange, onDataChange]); + + // Delete ํ‚ค๋กœ ์„ ํƒ ๋ฒ”์œ„ ์‚ญ์ œ + const handleDelete = useCallback(() => { + if (!selection || editingCell) return; + + const norm = normalizeRange(selection); + const newData = [...data]; + + for (let r = norm.startRow; r <= norm.endRow; r++) { + if (r >= 0 && r < newData.length) { + for (let c = norm.startCol; c <= norm.endCol; c++) { + if (c < columns.length) { + const colName = columns[c]; + newData[r] = { + ...newData[r], + [colName]: "", + }; + } + } + } + } + + onDataChange(newData); + }, [selection, editingCell, columns, data, onDataChange]); + + // ์ „์—ญ ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ (๋ณต์‚ฌ/๋ถ™์—ฌ๋„ฃ๊ธฐ/์‚ญ์ œ) + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + // ํŽธ์ง‘ ์ค‘์ด๋ฉด ๋ฌด์‹œ (input์—์„œ ์ž์ฒด ์ฒ˜๋ฆฌ) + if (editingCell) return; + + // ์„ ํƒ์ด ์—†์œผ๋ฉด ๋ฌด์‹œ + if (!selection) return; + + // ๋‹ค๋ฅธ ์ž…๋ ฅ ํ•„๋“œ์— ํฌ์ปค์Šค๊ฐ€ ์žˆ์œผ๋ฉด ๋ฌด์‹œ + const activeElement = document.activeElement; + const isInputFocused = activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement instanceof HTMLSelectElement; + + // ํ…Œ์ด๋ธ” ๋‚ด๋ถ€์˜ input์ด ์•„๋‹Œ ๋‹ค๋ฅธ input์— ํฌ์ปค์Šค๊ฐ€ ์žˆ์œผ๋ฉด ๋ฌด์‹œ + if (isInputFocused && !tableRef.current?.contains(activeElement)) { + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key === "c") { + e.preventDefault(); + handleCopy(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "v") { + e.preventDefault(); + handlePaste(); + } else if (e.key === "Delete" || e.key === "Backspace") { + // ๋‹ค๋ฅธ ๊ณณ์— ํฌ์ปค์Šค๊ฐ€ ์žˆ์œผ๋ฉด Delete ๋ฌด์‹œ + if (isInputFocused) return; + e.preventDefault(); + handleDelete(); + } + }; + + document.addEventListener("keydown", handleGlobalKeyDown); + return () => document.removeEventListener("keydown", handleGlobalKeyDown); + }, [editingCell, selection, handleCopy, handlePaste, handleDelete]); + // ํ–‰ ์‚ญ์ œ const handleDeleteRow = (rowIndex: number) => { const newData = data.filter((_, i) => i !== rowIndex); @@ -284,7 +573,7 @@ export const EditableSpreadsheet: React.FC = ({ // ============ ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋กœ์ง ============ - // ๊ฐ’์—์„œ ๋งˆ์ง€๋ง‰ ์ˆซ์ž ํŒจํ„ด ์ถ”์ถœ (์˜ˆ: "26-item-0005" โ†’ prefix: "26-item-", number: 5, suffix: "", numLength: 4) + // ๊ฐ’์—์„œ ๋งˆ์ง€๋ง‰ ์ˆซ์ž ํŒจํ„ด ์ถ”์ถœ const extractNumberPattern = (value: string): { prefix: string; number: number; @@ -292,7 +581,6 @@ export const EditableSpreadsheet: React.FC = ({ numLength: number; isZeroPadded: boolean; } | null => { - // ์ˆซ์ž๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ if (/^-?\d+(\.\d+)?$/.test(value)) { const isZeroPadded = value.startsWith("0") && value.length > 1 && !value.includes("."); return { @@ -304,8 +592,6 @@ export const EditableSpreadsheet: React.FC = ({ }; } - // ๋งˆ์ง€๋ง‰ ์ˆซ์ž ์‹œํ€€์Šค๋ฅผ ์ฐพ๊ธฐ (greedyํ•˜๊ฒŒ prefix๋ฅผ ์ฐพ์Œ) - // ์˜ˆ: "26-item-0005" โ†’ prefix: "26-item-", number: "0005", suffix: "" const match = value.match(/^(.*)(\d+)(\D*)$/); if (match) { const numStr = match[2]; @@ -324,7 +610,6 @@ export const EditableSpreadsheet: React.FC = ({ // ๋‚ ์งœ ํŒจํ„ด ์ธ์‹ const extractDatePattern = (value: string): Date | null => { - // YYYY-MM-DD ํ˜•์‹ const dateMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (dateMatch) { const date = new Date(parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3])); @@ -337,12 +622,10 @@ export const EditableSpreadsheet: React.FC = ({ // ๋‹ค์Œ ๊ฐ’ ์ƒ์„ฑ const generateNextValue = (sourceValue: string, step: number): string => { - // ๋นˆ ๊ฐ’์ด๋ฉด ๊ทธ๋Œ€๋กœ if (!sourceValue || sourceValue.trim() === "") { return ""; } - // ๋‚ ์งœ ํŒจํ„ด ์ฒดํฌ const datePattern = extractDatePattern(sourceValue); if (datePattern) { const newDate = new Date(datePattern); @@ -353,17 +636,13 @@ export const EditableSpreadsheet: React.FC = ({ return `${year}-${month}-${day}`; } - // ์ˆซ์ž ํŒจํ„ด ์ฒดํฌ const numberPattern = extractNumberPattern(sourceValue); if (numberPattern) { const newNumber = numberPattern.number + step; - - // ์Œ์ˆ˜ ๋ฐฉ์ง€ (ํ•„์š”์‹œ) const absNumber = Math.max(0, newNumber); let numStr: string; if (numberPattern.isZeroPadded) { - // ์ œ๋กœํŒจ๋”ฉ ์œ ์ง€ (์˜ˆ: 0005 โ†’ 0006) numStr = String(absNumber).padStart(numberPattern.numLength, "0"); } else { numStr = String(absNumber); @@ -372,7 +651,6 @@ export const EditableSpreadsheet: React.FC = ({ return numberPattern.prefix + numStr + numberPattern.suffix; } - // ํŒจํ„ด ์—†์œผ๋ฉด ๋ณต์‚ฌ return sourceValue; }; @@ -381,21 +659,22 @@ export const EditableSpreadsheet: React.FC = ({ e.preventDefault(); e.stopPropagation(); - if (!selectedCell || selectedCell.row < 0) return; + if (!selection) return; + const norm = normalizeRange(selection); + if (norm.startRow < 0) return; // ํ—ค๋”๋Š” ์ œ์™ธ setIsDraggingFill(true); - setFillPreviewEnd(selectedCell.row); + setFillPreviewEnd(norm.endRow); }; // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์ค‘ const handleFillDragMove = useCallback((e: MouseEvent) => { - if (!isDraggingFill || !selectedCell || !tableRef.current) return; + if (!isDraggingFill || !selection || !tableRef.current) return; const rows = tableRef.current.querySelectorAll("tbody tr"); const mouseY = e.clientY; - // ๋งˆ์šฐ์Šค ์œ„์น˜์— ํ•ด๋‹นํ•˜๋Š” ํ–‰ ์ฐพ๊ธฐ - for (let i = 0; i < rows.length - 1; i++) { // ๋งˆ์ง€๋ง‰ ํ–‰(์ถ”๊ฐ€ ์˜์—ญ) ์ œ์™ธ + for (let i = 0; i < rows.length - 1; i++) { const row = rows[i] as HTMLElement; const rect = row.getBoundingClientRect(); @@ -406,53 +685,71 @@ export const EditableSpreadsheet: React.FC = ({ setFillPreviewEnd(i); } } - }, [isDraggingFill, selectedCell]); + }, [isDraggingFill, selection]); - // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ + // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ (๋‹ค์ค‘ ์…€ ์ง€์›) const handleFillDragEnd = useCallback(() => { - if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) { + if (!isDraggingFill || !selection || fillPreviewEnd === null) { setIsDraggingFill(false); setFillPreviewEnd(null); return; } - const { row: startRow, col } = selectedCell; + const norm = normalizeRange(selection); const endRow = fillPreviewEnd; + const selectionHeight = norm.endRow - norm.startRow + 1; - if (startRow !== endRow && startRow >= 0) { - const colName = columns[col]; - const sourceValue = String(data[startRow]?.[colName] ?? ""); + if (endRow !== norm.endRow && norm.startRow >= 0) { const newData = [...data]; - if (endRow > startRow) { + if (endRow > norm.endRow) { // ์•„๋ž˜๋กœ ์ฑ„์šฐ๊ธฐ - for (let i = startRow + 1; i <= endRow; i++) { - const step = i - startRow; - if (!newData[i]) { - newData[i] = {}; + for (let targetRow = norm.endRow + 1; targetRow <= endRow; targetRow++) { + // ์„ ํƒ ๋ฒ”์œ„ ๋‚ด ํ–‰ ์ˆœํ™˜ + const sourceRowOffset = (targetRow - norm.startRow) % selectionHeight; + const sourceRow = norm.startRow + sourceRowOffset; + const stepMultiplier = Math.floor((targetRow - norm.startRow) / selectionHeight); + + if (!newData[targetRow]) { + newData[targetRow] = {}; columns.forEach((c) => { - newData[i][c] = ""; + newData[targetRow][c] = ""; }); } - newData[i] = { - ...newData[i], - [colName]: generateNextValue(sourceValue, step), - }; + + // ์„ ํƒ๋œ ๋ชจ๋“  ์—ด์— ๋Œ€ํ•ด ์ฑ„์šฐ๊ธฐ + for (let col = norm.startCol; col <= norm.endCol; col++) { + const colName = columns[col]; + const sourceValue = String(data[sourceRow]?.[colName] ?? ""); + const step = targetRow - sourceRow; + newData[targetRow] = { + ...newData[targetRow], + [colName]: generateNextValue(sourceValue, step), + }; + } } - } else { + } else if (endRow < norm.startRow) { // ์œ„๋กœ ์ฑ„์šฐ๊ธฐ - for (let i = startRow - 1; i >= endRow; i--) { - const step = i - startRow; - if (!newData[i]) { - newData[i] = {}; + for (let targetRow = norm.startRow - 1; targetRow >= endRow; targetRow--) { + const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight; + const sourceRow = norm.endRow - sourceRowOffset; + + if (!newData[targetRow]) { + newData[targetRow] = {}; columns.forEach((c) => { - newData[i][c] = ""; + newData[targetRow][c] = ""; }); } - newData[i] = { - ...newData[i], - [colName]: generateNextValue(sourceValue, step), - }; + + for (let col = norm.startCol; col <= norm.endCol; col++) { + const colName = columns[col]; + const sourceValue = String(data[sourceRow]?.[colName] ?? ""); + const step = targetRow - sourceRow; + newData[targetRow] = { + ...newData[targetRow], + [colName]: generateNextValue(sourceValue, step), + }; + } } } @@ -461,7 +758,7 @@ export const EditableSpreadsheet: React.FC = ({ setIsDraggingFill(false); setFillPreviewEnd(null); - }, [isDraggingFill, selectedCell, fillPreviewEnd, columns, data, onDataChange]); + }, [isDraggingFill, selection, fillPreviewEnd, columns, data, onDataChange]); // ๋“œ๋ž˜๊ทธ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ useEffect(() => { @@ -477,23 +774,27 @@ export const EditableSpreadsheet: React.FC = ({ // ์…€์ด ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ฒ”์œ„์— ์žˆ๋Š”์ง€ ํ™•์ธ const isInFillPreview = (rowIndex: number, colIndex: number): boolean => { - if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) return false; - if (colIndex !== selectedCell.col) return false; + if (!isDraggingFill || !selection || fillPreviewEnd === null) return false; + + const norm = normalizeRange(selection); + + // ์—ด์ด ์„ ํƒ ๋ฒ”์œ„ ๋‚ด์— ์žˆ์–ด์•ผ ํ•จ + if (colIndex < norm.startCol || colIndex > norm.endCol) return false; - const startRow = selectedCell.row; - const endRow = fillPreviewEnd; - - if (endRow > startRow) { - return rowIndex > startRow && rowIndex <= endRow; - } else { - return rowIndex >= endRow && rowIndex < startRow; + if (fillPreviewEnd > norm.endRow) { + return rowIndex > norm.endRow && rowIndex <= fillPreviewEnd; + } else if (fillPreviewEnd < norm.startRow) { + return rowIndex >= fillPreviewEnd && rowIndex < norm.startRow; } + + return false; }; return (
@@ -527,7 +828,6 @@ export const EditableSpreadsheet: React.FC = ({
{ - selectCell(rowIndex, colIndex); - if (!isEditing) { - // ๋‹จ์ผ ํด๋ฆญ์€ ์„ ํƒ๋งŒ - } - }} + onMouseDown={(e) => handleCellMouseDown(rowIndex, colIndex, e)} + onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)} onDoubleClick={() => startEditing(rowIndex, colIndex)} > {isEditing ? ( @@ -638,10 +937,28 @@ export const EditableSpreadsheet: React.FC = ({ )} - {/* ์ž๋™ ์ฑ„์šฐ๊ธฐ ํ•ธ๋“ค - ์„ ํƒ๋œ ์…€์—์„œ๋งŒ ํ‘œ์‹œ */} - {isSelected && !isEditing && ( + {/* ๋ณต์‚ฌ ๋ฒ”์œ„ ์ ์„  ํ…Œ๋‘๋ฆฌ (Marching Ants) */} + {isCopied && ( + <> + {copiedBorder.top && ( +
+ )} + {copiedBorder.right && ( +
+ )} + {copiedBorder.bottom && ( +
+ )} + {copiedBorder.left && ( +
+ )} + + )} + + {/* ์ž๋™ ์ฑ„์šฐ๊ธฐ ํ•ธ๋“ค - ์„ ํƒ ๋ฒ”์œ„์˜ ์šฐํ•˜๋‹จ์—์„œ๋งŒ ํ‘œ์‹œ */} + {isSelectionEnd && !isEditing && selection && normalizeRange(selection).startRow >= 0 && (
@@ -679,7 +996,6 @@ export const EditableSpreadsheet: React.FC = ({ newRow[c] = ""; }); onDataChange([...data, newRow]); - // ์ƒˆ ํ–‰์˜ ์ฒซ ๋ฒˆ์งธ ์…€ ํŽธ์ง‘ ์‹œ์ž‘ setTimeout(() => { startEditing(data.length, 0); }, 0); From f33d989202eda4846ea36b1b68b2e2d04fe4a566 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 12:23:00 +0900 Subject: [PATCH 09/12] =?UTF-8?q?=EB=B3=B5=EC=82=AC=EB=90=9C=20=EC=85=80?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/EditableSpreadsheet.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx index 94b080e5..cf05bbe2 100644 --- a/frontend/components/common/EditableSpreadsheet.tsx +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -104,9 +104,6 @@ export const EditableSpreadsheet: React.FC = ({ }); setIsDraggingSelection(true); - // ๋ณต์‚ฌ ๋ฒ”์œ„ ์ดˆ๊ธฐํ™” (์ƒˆ๋กœ์šด ์„ ํƒ ์‹œ์ž‘ํ•˜๋ฉด ์ด์ „ ๋ณต์‚ฌ ํ‘œ์‹œ ์ œ๊ฑฐ) - setCopiedRange(null); - // ํ…Œ์ด๋ธ”์— ํฌ์ปค์Šค (ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ ์ˆ˜์‹ ์šฉ) tableRef.current?.focus(); }, [editingCell]); @@ -532,6 +529,9 @@ export const EditableSpreadsheet: React.FC = ({ if (isInputFocused) return; e.preventDefault(); handleDelete(); + } else if (e.key === "Escape") { + // Esc๋กœ ๋ณต์‚ฌ ๋ฒ”์œ„ ํ‘œ์‹œ ์ทจ์†Œ + setCopiedRange(null); } }; From a1466676152a56d011a912789ef18bc1f4f1b0a5 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 12:25:52 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/EditableSpreadsheet.tsx | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx index cf05bbe2..0d4ad3d3 100644 --- a/frontend/components/common/EditableSpreadsheet.tsx +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -532,6 +532,24 @@ export const EditableSpreadsheet: React.FC = ({ } else if (e.key === "Escape") { // Esc๋กœ ๋ณต์‚ฌ ๋ฒ”์œ„ ํ‘œ์‹œ ์ทจ์†Œ setCopiedRange(null); + } else if (e.key === "F2") { + // F2๋กœ ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… (๊ธฐ์กด ๊ฐ’ ์œ ์ง€) + const norm = normalizeRange(selection); + if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) { + e.preventDefault(); + const colName = columns[norm.startCol]; + setEditingCell({ row: norm.startRow, col: norm.startCol }); + setEditValue(String(data[norm.startRow]?.[colName] ?? "")); + } + } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + // ์ผ๋ฐ˜ ๋ฌธ์ž ํ‚ค ์ž…๋ ฅ ์‹œ ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… (์—‘์…€์ฒ˜๋Ÿผ) + const norm = normalizeRange(selection); + if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) { + // ๋‹จ์ผ ์…€ ์„ ํƒ ์‹œ์—๋งŒ + e.preventDefault(); + setEditingCell({ row: norm.startRow, col: norm.startCol }); + setEditValue(e.key); // ์ž…๋ ฅํ•œ ๋ฌธ์ž๋กœ ์‹œ์ž‘ + } } }; @@ -659,6 +677,32 @@ export const EditableSpreadsheet: React.FC = ({ e.preventDefault(); e.stopPropagation(); + // ํŽธ์ง‘ ์ค‘์ด๋ฉด ๋จผ์ € ํ˜„์žฌ ํŽธ์ง‘ ๊ฐ’์„ ์ €์žฅ + if (editingCell) { + const { row, col } = editingCell; + if (row === -1) { + // ํ—ค๋” ๋ณ€๊ฒฝ + const newColumns = [...columns]; + const oldColName = newColumns[col]; + const newColName = editValue.trim() || `Column${col + 1}`; + if (oldColName !== newColName) { + newColumns[col] = newColName; + onColumnsChange(newColumns); + } + } else { + // ๋ฐ์ดํ„ฐ ์…€ ๋ณ€๊ฒฝ + const colName = columns[col]; + const newData = [...data]; + if (!newData[row]) { + newData[row] = {}; + } + newData[row] = { ...newData[row], [colName]: editValue }; + onDataChange(newData); + } + setEditingCell(null); + setEditValue(""); + } + if (!selection) return; const norm = normalizeRange(selection); if (norm.startRow < 0) return; // ํ—ค๋”๋Š” ์ œ์™ธ @@ -955,8 +999,8 @@ export const EditableSpreadsheet: React.FC = ({ )} - {/* ์ž๋™ ์ฑ„์šฐ๊ธฐ ํ•ธ๋“ค - ์„ ํƒ ๋ฒ”์œ„์˜ ์šฐํ•˜๋‹จ์—์„œ๋งŒ ํ‘œ์‹œ */} - {isSelectionEnd && !isEditing && selection && normalizeRange(selection).startRow >= 0 && ( + {/* ์ž๋™ ์ฑ„์šฐ๊ธฐ ํ•ธ๋“ค - ์„ ํƒ ๋ฒ”์œ„์˜ ์šฐํ•˜๋‹จ์—์„œ๋งŒ ํ‘œ์‹œ (ํŽธ์ง‘ ์ค‘์—๋„ ํ‘œ์‹œ) */} + {isSelectionEnd && selection && normalizeRange(selection).startRow >= 0 && (
Date: Thu, 8 Jan 2026 12:28:48 +0900 Subject: [PATCH 11/12] =?UTF-8?q?=EB=A6=AC=EB=8F=84,=EC=96=B8=EB=8F=84?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/EditableSpreadsheet.tsx | 95 ++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx index 0d4ad3d3..17c8ce94 100644 --- a/frontend/components/common/EditableSpreadsheet.tsx +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -55,8 +55,91 @@ export const EditableSpreadsheet: React.FC = ({ // ๋ณต์‚ฌ๋œ ๋ฒ”์œ„ (์ ์„  ์• ๋‹ˆ๋ฉ”์ด์…˜ ํ‘œ์‹œ์šฉ) const [copiedRange, setCopiedRange] = useState(null); + // Undo/Redo ํžˆ์Šคํ† ๋ฆฌ + interface HistoryState { + columns: string[]; + data: Record[]; + } + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [isUndoRedo, setIsUndoRedo] = useState(false); + const inputRef = useRef(null); const tableRef = useRef(null); + + // ํžˆ์Šคํ† ๋ฆฌ์— ํ˜„์žฌ ์ƒํƒœ ์ €์žฅ + const saveToHistory = useCallback(() => { + if (isUndoRedo) return; + + const newState: HistoryState = { + columns: [...columns], + data: data.map(row => ({ ...row })), + }; + + setHistory(prev => { + // ํ˜„์žฌ ์ธ๋ฑ์Šค ์ดํ›„์˜ ํžˆ์Šคํ† ๋ฆฌ๋Š” ์‚ญ์ œ (์ƒˆ๋กœ์šด ๋ถ„๊ธฐ) + const newHistory = prev.slice(0, historyIndex + 1); + newHistory.push(newState); + // ์ตœ๋Œ€ 50๊ฐœ๊นŒ์ง€๋งŒ ์œ ์ง€ + if (newHistory.length > 50) { + newHistory.shift(); + return newHistory; + } + return newHistory; + }); + setHistoryIndex(prev => Math.min(prev + 1, 49)); + }, [columns, data, historyIndex, isUndoRedo]); + + // ์ดˆ๊ธฐ ์ƒํƒœ ์ €์žฅ + useEffect(() => { + if (history.length === 0 && (columns.length > 0 || data.length > 0)) { + setHistory([{ columns: [...columns], data: data.map(row => ({ ...row })) }]); + setHistoryIndex(0); + } + }, []); + + // ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์‹œ ํžˆ์Šคํ† ๋ฆฌ ์ €์žฅ (Undo/Redo๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ) + useEffect(() => { + if (!isUndoRedo && historyIndex >= 0) { + const currentState = history[historyIndex]; + if (currentState) { + const columnsChanged = JSON.stringify(columns) !== JSON.stringify(currentState.columns); + const dataChanged = JSON.stringify(data) !== JSON.stringify(currentState.data); + if (columnsChanged || dataChanged) { + saveToHistory(); + } + } + } + setIsUndoRedo(false); + }, [columns, data]); + + // Undo ์‹คํ–‰ + const handleUndo = useCallback(() => { + if (historyIndex <= 0) return; + + const prevIndex = historyIndex - 1; + const prevState = history[prevIndex]; + if (prevState) { + setIsUndoRedo(true); + setHistoryIndex(prevIndex); + onColumnsChange([...prevState.columns]); + onDataChange(prevState.data.map(row => ({ ...row }))); + } + }, [history, historyIndex, onColumnsChange, onDataChange]); + + // Redo ์‹คํ–‰ + const handleRedo = useCallback(() => { + if (historyIndex >= history.length - 1) return; + + const nextIndex = historyIndex + 1; + const nextState = history[nextIndex]; + if (nextState) { + setIsUndoRedo(true); + setHistoryIndex(nextIndex); + onColumnsChange([...nextState.columns]); + onDataChange(nextState.data.map(row => ({ ...row }))); + } + }, [history, historyIndex, onColumnsChange, onDataChange]); // ๋ฒ”์œ„ ์ •๊ทœํ™” (์‹œ์ž‘์ด ๋๋ณด๋‹ค ํฌ๋ฉด ๊ตํ™˜) const normalizeRange = (range: CellRange): CellRange => { @@ -518,7 +601,15 @@ export const EditableSpreadsheet: React.FC = ({ return; } - if ((e.ctrlKey || e.metaKey) && e.key === "c") { + if ((e.ctrlKey || e.metaKey) && e.key === "z") { + // Ctrl+Z: Undo + e.preventDefault(); + handleUndo(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "y") { + // Ctrl+Y: Redo + e.preventDefault(); + handleRedo(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "c") { e.preventDefault(); handleCopy(); } else if ((e.ctrlKey || e.metaKey) && e.key === "v") { @@ -555,7 +646,7 @@ export const EditableSpreadsheet: React.FC = ({ document.addEventListener("keydown", handleGlobalKeyDown); return () => document.removeEventListener("keydown", handleGlobalKeyDown); - }, [editingCell, selection, handleCopy, handlePaste, handleDelete]); + }, [editingCell, selection, handleCopy, handlePaste, handleDelete, handleUndo, handleRedo]); // ํ–‰ ์‚ญ์ œ const handleDeleteRow = (rowIndex: number) => { From a3c83c834e5bd59e7641c3aea065b5ebe2b800d7 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 12:32:50 +0900 Subject: [PATCH 12/12] =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=ED=95=B8?= =?UTF-8?q?=EB=93=A4=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/EditableSpreadsheet.tsx | 103 ++++++++++++++---- 1 file changed, 83 insertions(+), 20 deletions(-) diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx index 17c8ce94..67e7f80b 100644 --- a/frontend/components/common/EditableSpreadsheet.tsx +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -822,7 +822,43 @@ export const EditableSpreadsheet: React.FC = ({ } }, [isDraggingFill, selection]); + // ์—ด์˜ ์ˆซ์ž ํŒจํ„ด ๊ฐ„๊ฒฉ ๊ณ„์‚ฐ (์˜ˆ: 201, 202 โ†’ ๊ฐ„๊ฒฉ 1) + const calculateColumnIncrement = (colIndex: number, startRow: number, endRow: number): number | null => { + if (startRow === endRow) return 1; // ๋‹จ์ผ ํ–‰์ด๋ฉด ๊ธฐ๋ณธ ์ฆ๊ฐ€ 1 + + const colName = columns[colIndex]; + const increments: number[] = []; + + for (let row = startRow; row < endRow; row++) { + const currentValue = String(data[row]?.[colName] ?? ""); + const nextValue = String(data[row + 1]?.[colName] ?? ""); + + const currentPattern = extractNumberPattern(currentValue); + const nextPattern = extractNumberPattern(nextValue); + + if (currentPattern && nextPattern) { + // ์ ‘๋‘์‚ฌ์™€ ์ ‘๋ฏธ์‚ฌ๊ฐ€ ๊ฐ™์€์ง€ ํ™•์ธ + if (currentPattern.prefix === nextPattern.prefix && currentPattern.suffix === nextPattern.suffix) { + increments.push(nextPattern.number - currentPattern.number); + } else { + return null; // ํŒจํ„ด์ด ๋‹ค๋ฅด๋ฉด ๋ณต์‚ฌ ๋ชจ๋“œ + } + } else { + return null; // ์ˆซ์ž ํŒจํ„ด์ด ์—†์œผ๋ฉด ๋ณต์‚ฌ ๋ชจ๋“œ + } + } + + // ๋ชจ๋“  ๊ฐ„๊ฒฉ์ด ๊ฐ™์€์ง€ ํ™•์ธ + if (increments.length > 0 && increments.every(inc => inc === increments[0])) { + return increments[0]; + } + + return null; + }; + // ์ž๋™ ์ฑ„์šฐ๊ธฐ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ (๋‹ค์ค‘ ์…€ ์ง€์›) + // - ์ˆซ์ž ํŒจํ„ด์ด ์žˆ์œผ๋ฉด: ํŒจํ„ด ๊ฐ„๊ฒฉ์„ ์ธ์‹ํ•˜์—ฌ ์ฆ๊ฐ€ (201, 202 โ†’ 203, 204) + // - ์ˆซ์ž ํŒจํ„ด์ด ์—†์œผ๋ฉด: ์„ ํƒ๋œ ํŒจํ„ด ๊ทธ๋Œ€๋กœ ๋ฐ˜๋ณต (๋ณต์‚ฌ) const handleFillDragEnd = useCallback(() => { if (!isDraggingFill || !selection || fillPreviewEnd === null) { setIsDraggingFill(false); @@ -836,15 +872,16 @@ export const EditableSpreadsheet: React.FC = ({ if (endRow !== norm.endRow && norm.startRow >= 0) { const newData = [...data]; + + // ๊ฐ ์—ด๋ณ„๋กœ ์ฆ๊ฐ€ ํŒจํ„ด ๊ณ„์‚ฐ + const columnIncrements: Map = new Map(); + for (let col = norm.startCol; col <= norm.endCol; col++) { + columnIncrements.set(col, calculateColumnIncrement(col, norm.startRow, norm.endRow)); + } if (endRow > norm.endRow) { // ์•„๋ž˜๋กœ ์ฑ„์šฐ๊ธฐ for (let targetRow = norm.endRow + 1; targetRow <= endRow; targetRow++) { - // ์„ ํƒ ๋ฒ”์œ„ ๋‚ด ํ–‰ ์ˆœํ™˜ - const sourceRowOffset = (targetRow - norm.startRow) % selectionHeight; - const sourceRow = norm.startRow + sourceRowOffset; - const stepMultiplier = Math.floor((targetRow - norm.startRow) / selectionHeight); - if (!newData[targetRow]) { newData[targetRow] = {}; columns.forEach((c) => { @@ -855,20 +892,32 @@ export const EditableSpreadsheet: React.FC = ({ // ์„ ํƒ๋œ ๋ชจ๋“  ์—ด์— ๋Œ€ํ•ด ์ฑ„์šฐ๊ธฐ for (let col = norm.startCol; col <= norm.endCol; col++) { const colName = columns[col]; - const sourceValue = String(data[sourceRow]?.[colName] ?? ""); - const step = targetRow - sourceRow; - newData[targetRow] = { - ...newData[targetRow], - [colName]: generateNextValue(sourceValue, step), - }; + const increment = columnIncrements.get(col); + + if (increment !== null) { + // ์ˆซ์ž ํŒจํ„ด ์ฆ๊ฐ€ ๋ชจ๋“œ + // ๋งˆ์ง€๋ง‰ ์„ ํƒ ํ–‰์˜ ๊ฐ’์„ ๊ธฐ์ค€์œผ๋กœ ์ฆ๊ฐ€ + const lastValue = String(data[norm.endRow]?.[colName] ?? ""); + const step = (targetRow - norm.endRow) * increment; + newData[targetRow] = { + ...newData[targetRow], + [colName]: generateNextValue(lastValue, step), + }; + } else { + // ๋ณต์‚ฌ ๋ชจ๋“œ (ํŒจํ„ด ๋ฐ˜๋ณต) + const sourceRowOffset = (targetRow - norm.endRow - 1) % selectionHeight; + const sourceRow = norm.startRow + sourceRowOffset; + const sourceValue = String(data[sourceRow]?.[colName] ?? ""); + newData[targetRow] = { + ...newData[targetRow], + [colName]: sourceValue, + }; + } } } } else if (endRow < norm.startRow) { // ์œ„๋กœ ์ฑ„์šฐ๊ธฐ for (let targetRow = norm.startRow - 1; targetRow >= endRow; targetRow--) { - const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight; - const sourceRow = norm.endRow - sourceRowOffset; - if (!newData[targetRow]) { newData[targetRow] = {}; columns.forEach((c) => { @@ -878,12 +927,26 @@ export const EditableSpreadsheet: React.FC = ({ for (let col = norm.startCol; col <= norm.endCol; col++) { const colName = columns[col]; - const sourceValue = String(data[sourceRow]?.[colName] ?? ""); - const step = targetRow - sourceRow; - newData[targetRow] = { - ...newData[targetRow], - [colName]: generateNextValue(sourceValue, step), - }; + const increment = columnIncrements.get(col); + + if (increment !== null) { + // ์ˆซ์ž ํŒจํ„ด ๊ฐ์†Œ ๋ชจ๋“œ + const firstValue = String(data[norm.startRow]?.[colName] ?? ""); + const step = (targetRow - norm.startRow) * increment; + newData[targetRow] = { + ...newData[targetRow], + [colName]: generateNextValue(firstValue, step), + }; + } else { + // ๋ณต์‚ฌ ๋ชจ๋“œ (ํŒจํ„ด ๋ฐ˜๋ณต) + const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight; + const sourceRow = norm.endRow - sourceRowOffset; + const sourceValue = String(data[sourceRow]?.[colName] ?? ""); + newData[targetRow] = { + ...newData[targetRow], + [colName]: sourceValue, + }; + } } } }