From 1b0a2d0cf0594f7bda97c6c4801e0d84b485dff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 20 Mar 2026 09:48:39 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20claudedocs/=20git=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?=20=EC=A0=9C=EC=99=B8=20(.gitignore=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + claudedocs/.DS_Store | Bin 10244 -> 0 bytes ...3-11] qms-checklist-template-management.md | 96 - ...-02-26] windows-compatibility-checklist.md | 109 - ...IX-2026-03-10] eslint-cleanup-checklist.md | 54 - ...] account-subject-unification-checklist.md | 123 - ...L-2026-03-08] frontend-weekly-0302-0308.md | 250 -- ...03-10] notice-popup-display-integration.md | 103 - ...2026-03-06] account-subject-unification.md | 498 ---- ...A-2026-03-16] approval-module-qa-report.md | 285 -- ...SK-2026-03-03] daily-report-usd-section.md | 172 -- claudedocs/_index.md | 99 - .../[IMPL-2025-12-18] bill-management.md | 65 - ...-2025-12-18] expected-expense-checklist.md | 38 - .../[IMPL-2025-12-18] purchase-management.md | 111 - .../[IMPL-2025-12-18] receivables-status.md | 73 - .../[IMPL-2025-12-18] vendor-ledger.md | 129 - ...2025-12-18] vendor-management-checklist.md | 287 -- ...-12-18] withdrawal-management-checklist.md | 142 - ...5-12-19] bad-debt-collection-management.md | 230 -- ...PL-2025-12-19] card-transaction-inquiry.md | 89 - .../[PLAN-2025-12-18] sales-management.md | 270 -- ...12-19] bank-account-transaction-inquiry.md | 204 -- ...026-01-23] vendor-credit-analysis-modal.md | 103 - ...I-2026-03-10] calendar-bill-integration.md | 45 - ...d-expenses-dashboard-detail-date-filter.md | 52 - ...C-2026-03-03] ceo-dashboard-backend-api.md | 821 ------ ...GELOG-2026-03-09] sam-api-daily-changes.md | 122 - ...2026-03-10] calendar-new-schedule-types.md | 77 - .../[IMPL-2025-11-07] api-key-management.md | 320 -- ...[IMPL-2025-11-11] api-route-type-safety.md | 334 --- ...MPL-2025-12-30] fetch-wrapper-migration.md | 262 -- ...2026-03-18] expense-accounts-receipt-no.md | 70 - claudedocs/api/[REF] api-analysis.md | 342 --- claudedocs/api/[REF] api-requirements.md | 436 --- ...2025-12-17] approval-document-checklist.md | 134 - ...26-03-07] approval-box-linked-documents.md | 59 - claudedocs/architecture/.DS_Store | Bin 6148 -> 0 bytes .../[ANALYSIS-2026-01-20] 공통화-현황-분석.md | 213 -- ...SIS-2026-02-05] SAM-ERP-MES-정체성-분석.md | 256 -- ...6-02-05] list-page-commonization-status.md | 505 ---- ...26-02-19] frontend-comprehensive-review.md | 165 -- ...3] deep-analysis-util-component-zustand.md | 396 --- ...YSIS-2026-02-27] ceo-dashboard-analysis.md | 176 -- ...-2026-03-06] account-subject-comparison.md | 281 -- ...026-03-13] mes-data-integrity-report-v2.md | 498 ---- ...S-2026-03-13] mes-data-integrity-report.md | 421 --- ...nant-module-separation-dependency-audit.md | 437 --- ...-12-20] item-master-zustand-refactoring.md | 538 ---- ...026-02-11] dynamic-field-type-extension.md | 1299 --------- ...1-29] masterdata-cache-tenant-isolation.md | 145 - .../[GUIDE] component-tier-definition.md | 153 - ...IMPL-2025-11-13] browser-support-policy.md | 516 ---- .../[IMPL-2025-11-18] ssr-hydration-fix.md | 106 - ...2026-01-21] input-form-componentization.md | 349 --- ...6-01-21] phase4-input-migration-rollout.md | 372 --- ...2026-02-05] detail-hooks-migration-plan.md | 304 -- ...026-02-05] formatter-commonization-plan.md | 88 - ...PL-2026-02-11] dynamic-field-components.md | 343 --- ...L-2026-02-23] phase1-item4-error-format.md | 112 - ...6-02-23] phase1-item5-zustand-selectors.md | 229 -- .../[IMPL-2026-03-06] lazy-snapshot-system.md | 103 - ...MPL] IntegratedDetailTemplate-checklist.md | 254 -- ...025-02-10] frontend-improvement-roadmap.md | 74 - .../[PLAN-2025-12-29] dynamic-menu-refresh.md | 346 --- .../[PLAN-2026-01-16] layout-restructure.md | 96 - ...AN-2026-01-22] ui-component-abstraction.md | 546 ---- ...-06] multi-tenancy-optimization-roadmap.md | 666 ----- .../[PLAN-2026-02-06] refactoring-roadmap.md | 446 --- ...03-11] dynamic-multi-tenant-page-system.md | 950 ------ ...26-03-17] tenant-module-separation-plan.md | 1844 ------------ ...025-11-19] multi-tenancy-implementation.md | 1045 ------- .../[REF] architecture-integration-risks.md | 867 ------ .../architecture/[REF] technical-decisions.md | 316 -- .../[REF] template-migration-status.md | 260 -- ...] ERP-admin-panel-architecture-patterns.md | 606 ---- ...ST-2025-11-19] multi-tenancy-test-guide.md | 495 ---- ...26-03-10] user-preferences-db-migration.md | 166 -- ...19] dynamic-rendering-platform-strategy.md | 224 -- .../architecture/module-separation-guide.md | 315 -- ...5-11-07] seo-bot-blocking-configuration.md | 364 --- .../[IMPL-2025-11-11] chart-warning-fix.md | 113 - ...L-2025-11-11] error-pages-configuration.md | 572 ---- ...25-11-12] modal-select-layout-shift-fix.md | 1183 -------- .../[IMPL-2025-11-17] item-list-css-sync.md | 280 -- .../archive/[INDEX] DOCUMENTATION-MAP.md | 260 -- claudedocs/archive/[LEGACY] 00_INDEX.md | 532 ---- .../archive/[LEGACY] authentication-design.md | 931 ------ .../[PLAN-2025-11-18] refactoring-plan.md | 268 -- .../[PLAN-2025-11-21] component-separation.md | 703 ----- .../[REF-2025-11-18] cleanup-summary.md | 243 -- .../[REF-2025-11-18] unused-files-report.md | 248 -- ...EF-2025-11-21] type-error-fix-checklist.md | 356 --- .../archive/[REF] code-quality-report.md | 354 --- .../[REF] communication_improvement_guide.md | 292 -- .../archive/[REF] component-usage-analysis.md | 444 --- .../[REF] production-deployment-checklist.md | 233 -- claudedocs/archive/[REF] project-context.md | 428 --- ...-11-18] localStorage-ssr-fix-checkpoint.md | 169 -- claudedocs/archive/qa-inbox-modal-test.png | Bin 751695 -> 0 bytes .../archive/qa-reference-modal-test.png | Bin 729528 -> 0 bytes ...25-11-26] item-master-api-pending-tasks.md | 227 -- ...-11-26] item-master-pending-integration.md | 106 - ...T-2025-12-06] item-crud-session-context.md | 80 - ...NEXT-2025-12-09] client-session-context.md | 143 - ...T-2025-12-09] item-crud-session-context.md | 120 - ...T-2025-12-10] item-crud-session-context.md | 119 - ...T-2025-12-12] item-crud-session-context.md | 205 -- ...12-13] item-file-upload-session-context.md | 96 - ...20] zustand-refactoring-session-context.md | 344 --- ...-2025-12-22] production-session-context.md | 97 - ...-12-24] item-master-refactoring-session.md | 134 - ...25-12-30] fetch-wrapper-session-context.md | 78 - ...-30] partner-management-session-context.md | 101 - ...25] httponly-cookie-security-validation.md | 370 --- .../[IMPL-2025-11-07] auth-guard-usage.md | 335 --- ...07] authentication-implementation-guide.md | 328 --- ...-11-07] jwt-cookie-authentication-final.md | 508 ---- ...2025-11-07] middleware-issue-resolution.md | 178 -- ...25-11-07] route-protection-architecture.md | 513 ---- ...IMPL-2025-11-10] token-management-guide.md | 467 --- ...2025-11-13] safari-cookie-compatibility.md | 504 ---- .../[IMPL-2025-12-04] signup-page-blocking.md | 74 - ...[IMPL-2025-12-30] token-refresh-caching.md | 512 ---- ...IMPL-2026-01-15] middleware-pre-refresh.md | 424 --- .../[PLAN] httponly-cookie-implementation.md | 391 --- ...js15-middleware-authentication-research.md | 478 --- .../auth/[REF] session-migration-backend.md | 615 ---- .../auth/[REF] session-migration-frontend.md | 580 ---- .../auth/[REF] session-migration-summary.md | 366 --- .../[REF] token-security-nextjs15-research.md | 1614 ---------- claudedocs/backend/2026-03-02_구현내역.md | 38 - claudedocs/backend/2026-03-03_구현내역.md | 197 -- claudedocs/backend/2026-03-04_구현내역.md | 336 --- claudedocs/backend/2026-03-05_구현내역.md | 386 --- claudedocs/backend/2026-03-06_구현내역.md | 287 -- claudedocs/backend/2026-03-07_구현내역.md | 40 - claudedocs/backend/2026-03-08_구현내역.md | 47 - claudedocs/backend/_index.md | 72 - ...IMPL-2025-12-30] dynamic-board-creation.md | 120 - ...-12-19] board-management-implementation.md | 313 -- ...20250108_order_frontend_api_integration.md | 92 - ...20250108_order_phase3_advanced_features.md | 113 - claudedocs/components/_registry.md | 691 ----- ...26-01-05] category-management-checklist.md | 98 - ...L-2026-01-05] item-management-checklist.md | 209 -- ...026-01-05] pricing-management-checklist.md | 119 - ...-09] partner-management-api-integration.md | 117 - ...-01-09] site-management-api-integration.md | 90 - ...PL-2026-01-12] project-detail-checklist.md | 52 - ...01-02] estimate-detail-form-refactoring.md | 231 -- ...026-01-05] order-detail-form-separation.md | 292 -- ...-01-05] order-management-implementation.md | 323 -- .../[REF] construction-project-flow.md | 82 - .../[REF] juil-project-structure.md | 89 - .../[IMPL-2025-12-19] inquiry-management.md | 89 - ...[FIX-2026-03-09] ceo-dashboard-fix-plan.md | 213 -- ...5-11-10] dashboard-integration-complete.md | 212 -- ...L-2025-11-11] dashboard-cleanup-summary.md | 197 -- ...PL-2025-11-11] sidebar-active-menu-sync.md | 596 ---- ...2025-11-13] sidebar-scroll-improvements.md | 416 --- ...MPL-2026-01-07] ceo-dashboard-checklist.md | 435 --- ...-08] dashboard-settings-popup-checklist.md | 130 - .../[IMPL-2026-02-11] favorites-feature.md | 122 - ...26-01-08] ceo-dashboard-session-context.md | 125 - .../[PLAN] ceo-dashboard-refactoring.md | 331 --- ...26-03-09] ceo-dashboard-ui-verification.md | 252 -- .../[REF] dashboard-migration-summary.md | 170 -- ...6] ceo-dashboard-data-flow-verification.md | 432 --- ...-2026-01-29] typecheck-errors-checklist.md | 150 - .../[FIX-2026-02-09] PDF-이미지-누락-해결.md | 180 -- ...OTFIX-2026-01-27] E2E-테스트-수정계획서.md | 286 -- ...025-12-29] quality-inspection-checklist.md | 84 - .../[IMPL-2026-01-23] full-page-inspection.md | 362 --- ...-2026-02-03] claude-config-optimization.md | 163 -- ...LAN] detail-page-pattern-classification.md | 155 - .../[REF-2026-02-19] todo-issue-tracker.md | 342 --- claudedocs/dev/[REF] all-pages-test-urls.md | 632 ---- .../[REF] chrome-devtools-mcp-emoji-issue.md | 118 - .../dev/[REF] construction-pages-test-urls.md | 52 - .../dev/[REF] page-builder-implementation.md | 298 -- ...-2026-02-23] E2E-remaining-bugs-handoff.md | 129 - ...EW] MD-to-PPTX 자동화 파이프라인 검토서.md | 313 -- ...-01-02] document-modal-common-component.md | 276 -- ...05] radix-ui-select-controlled-mode-bug.md | 139 - ...7] popover-outside-click-dialog-cascade.md | 81 - ...E-2025-12-16] options-vs-flattened-data.md | 222 -- .../[GUIDE-2025-12-29] vercel-deployment.md | 204 -- .../guides/[GUIDE] CSS-MIGRATION-WORKFLOW.md | 426 --- .../guides/[GUIDE] LARGE-FILE-WORKFLOW.md | 564 ---- .../[GUIDE] ZOD-VALIDATION-TROUBLESHOOTING.md | 667 ----- .../[GUIDE] collaboration-with-claude.md | 129 - .../guides/[GUIDE] common-page-patterns.md | 902 ------ .../[GUIDE] large-file-handling-strategy.md | 458 --- .../guides/[GUIDE] print-area-utility.md | 194 -- .../[IMPL-2025-11-06] i18n-usage-guide.md | 907 ------ ...[IMPL-2025-11-07] form-validation-guide.md | 1041 ------- ...IMPL-2026-01-05] stat-cards-grid-layout.md | 60 - ...2025-01-21] document-system-integration.md | 476 --- ...2025-12-19] page-layout-standardization.md | 169 -- ...-2025-12-19] project-health-improvement.md | 417 --- ...-2025-01-21] document-system-inspection.md | 204 -- ...xtjs-security-update-and-migration-plan.md | 127 - .../[REF] nextjs-error-handling-guide.md | 729 ----- .../guides/badge-commonization-guide.md | 276 -- ... common-component-extraction-candidates.md | 216 -- .../[ANALYSIS] common-component-patterns.md | 158 - ... list-page-ui-standardization-checklist.md | 180 -- ...1-21] utility-input-migration-checklist.md | 122 - ...2026-01-23] button-navigation-checklist.md | 143 - ...PL-2026-01-23] mode-migration-checklist.md | 191 -- ...6-01-23] mode-navigation-full-checklist.md | 299 -- ...6-02-06] datepicker-migration-checklist.md | 106 - ...UniversalListPage-pilot-session-context.md | 91 - ...12-23] common-component-extraction-plan.md | 435 --- ...to-client-component-migration-checklist.md | 146 - .../[FIX-2026-02-04] mobile-zoom-panning.md | 67 - .../[GUIDE] foldable-device-layout-fix.md | 154 - .../[GUIDE] mobile-responsive-patterns.md | 538 ---- ...1-13] mobile-filter-migration-checklist.md | 181 -- ...2026-01-20] mobile-card-infinity-scroll.md | 368 --- .../mobile/[PLAN] mobile-overflow-testing.md | 386 --- ...1-21] mobile-infinity-scroll-inspection.md | 225 -- .../mobile/[REF] mobile-zoom-fix-guide.md | 165 -- .../[REF] mobile-zoom-prevention-guide.md | 101 - .../UniversalListPage-검색기능-수정내역.md | 80 - ...iversalListPage-검색리렌더링-해결가이드.md | 192 -- ...GN-2026-01-14] universal-list-component.md | 515 ---- ...-14] universal-list-component-checklist.md | 818 ------ .../[PLAN] universal-detail-component.md | 495 ---- ...6-01-15] universal-list-page-inspection.md | 200 -- .../[REF] UniversalListPage-QA-patterns.md | 313 -- ...-12-05] department-management-checklist.md | 319 -- ...25-12-05] employee-management-checklist.md | 143 - ...25-12-06] vacation-management-checklist.md | 196 -- .../hr/[IMPL-2025-12-16] mobile-attendance.md | 206 -- .../hr/[IMPL-2025-12-19] card-management.md | 86 - ...[ANALYSIS-2025-11-21] item-master-notes.md | 1060 ------- ...[ANALYSIS-2025-11-26] item-master-notes.md | 1371 --------- ...2025-12-06] item-data-mapping-checklist.md | 451 --- .../[ANALYSIS] item-master-data-management.md | 2588 ----------------- ...I-2025-11-20] item-master-specification.md | 1297 --------- ...11-23] item-master-backend-requirements.md | 286 -- ...11-24] item-management-dynamic-api-spec.md | 1608 ---------- ...item-master-data-management-api-request.md | 1034 ------- ...-2025-12-06] item-crud-backend-requests.md | 546 ---- ...2025-11-25] section-template-fields-api.md | 604 ---- ...-2025-11-28] dynamic-page-rendering-api.md | 1114 ------- ...-02-12] dynamic-field-type-backend-spec.md | 390 --- ...11-24] item-management-dynamic-frontend.md | 1137 -------- ...12-12] item-master-form-builder-roadmap.md | 421 --- ...25-12-16] options-details-duplicate-bug.md | 179 -- .../[GUIDE] ITEM-MANAGEMENT-MIGRATION.md | 1128 ------- ...] item-master-api-integration-checklist.md | 1671 ----------- ...PL-2025-11-27] item-master-api-refactor.md | 307 -- .../[IMPL-2025-11-27] realtime-sync-fixes.md | 265 -- ...MPL-2025-12-02] dynamic-item-form-fixes.md | 110 - ...L-2025-12-02] dynamic-item-form-rebuild.md | 223 -- ...L-2025-12-02] item-code-auto-generation.md | 81 - ...-2025-12-05] item-file-upload-checklist.md | 162 -- ...5-12-06] assembly-part-issues-checklist.md | 154 - ...-2025-12-15] backend-item-api-migration.md | 166 -- ...025-12-24] item-master-test-and-zustand.md | 132 - ...-01-09] item-management-api-integration.md | 154 - ...5-11-27] item-form-component-separation.md | 326 --- ...11-28] dynamic-item-form-implementation.md | 351 --- ...N-2025-12-01] service-layer-refactoring.md | 443 --- ...025-12-08] dynamic-form-separation-plan.md | 305 -- ...-12-16] dynamicitemform-hook-extraction.md | 301 -- .../[PLAN-2025-12-24] hook-extraction-plan.md | 270 -- ...25-11-26] item-master-hooks-refactoring.md | 446 --- .../[REF-2025-12-01] state-sync-solutions.md | 225 -- .../[REF] api-requirements-items.md | 937 ------ .../item-master/[REF] item-code-hardcoding.md | 557 ---- .../[REF] items-route-consolidation.md | 145 - .../[IMPL-2025-12-23] stock-status.md | 97 - ...3-04] shipment-dispatch-api-integration.md | 70 - .../[DESIGN-2026-01-29] worker-screen-spec.md | 288 -- ...5-12-22] production-dashboard-checklist.md | 391 --- ...3-05] production-orders-api-integration.md | 105 - ...26-03-13] bending-module-implementation.md | 532 ---- ...-12-23] inspection-management-checklist.md | 159 - ...[IMPL-2026-03-07] quality-api-migration.md | 124 - ...026-02-02] document-viewer-architecture.md | 295 -- ...2-04] quality-audit-document-management.md | 27 - ...26-02-09] phase1-common-hooks-checklist.md | 290 -- ...6-02-19] frontend-improvement-checklist.md | 465 --- ...25-02-06] useListHandlers-commonization.md | 97 - ...-19] code-dedup-commonization-checklist.md | 480 --- .../[API-2025-12-04] client-api-analysis.md | 390 --- .../[API-2025-12-04] quote-api-request.md | 675 ----- ...-12-08] pricing-api-enhancement-request.md | 358 --- ...2-04] client-management-api-integration.md | 170 -- ...025-12-05] pricing-management-migration.md | 328 --- ...2-09] pricing-api-integration-checklist.md | 139 - ...IMPL-2025-12-22] order-management-sales.md | 624 ---- ...26-01-12] quote-v2-test-pages-checklist.md | 133 - ...-04] price-distribution-session-context.md | 33 - ...[PLAN-2025-12-02] sales-pages-migration.md | 455 --- ...-12-04] quote-management-implementation.md | 346 --- ...T-2026-02-09] third-pass-security-audit.md | 952 ------ ...01-20] permission-system-implementation.md | 248 -- ...2] tenant-data-isolation-implementation.md | 324 --- ...2-03] permission-verification-checklist.md | 943 ------ ...2025-12-12] tenant-data-isolation-audit.md | 958 ------ ...IS-2026-01-07] permission-system-status.md | 306 -- .../[IMPL-2025-12-19] account-info.md | 76 - ...025-12-19] account-management-checklist.md | 125 - .../[IMPL-2025-12-19] company-info.md | 83 - .../[IMPL-2025-12-19] popup-management.md | 71 - ...MPL-2025-12-19] subscription-management.md | 71 - ...26-01-12] permission-frontend-checklist.md | 153 - ...8] vehicle-forklift-menu-implementation.md | 322 -- claudedocs/vercel/vercel-deployment-setup.md | 423 --- claudedocs/vercel/vercel-env-setup-guide.md | 81 - 315 files changed, 3 insertions(+), 105278 deletions(-) delete mode 100644 claudedocs/.DS_Store delete mode 100644 claudedocs/[FEAT-2026-03-11] qms-checklist-template-management.md delete mode 100644 claudedocs/[FIX-2026-02-26] windows-compatibility-checklist.md delete mode 100644 claudedocs/[FIX-2026-03-10] eslint-cleanup-checklist.md delete mode 100644 claudedocs/[IMPL-2026-03-06] account-subject-unification-checklist.md delete mode 100644 claudedocs/[IMPL-2026-03-08] frontend-weekly-0302-0308.md delete mode 100644 claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md delete mode 100644 claudedocs/[PLAN-2026-03-06] account-subject-unification.md delete mode 100644 claudedocs/[QA-2026-03-16] approval-module-qa-report.md delete mode 100644 claudedocs/[TASK-2026-03-03] daily-report-usd-section.md delete mode 100644 claudedocs/_index.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-18] bill-management.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-18] expected-expense-checklist.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-18] purchase-management.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-18] receivables-status.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-18] vendor-ledger.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-18] vendor-management-checklist.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-18] withdrawal-management-checklist.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-19] bad-debt-collection-management.md delete mode 100644 claudedocs/accounting/[IMPL-2025-12-19] card-transaction-inquiry.md delete mode 100644 claudedocs/accounting/[PLAN-2025-12-18] sales-management.md delete mode 100644 claudedocs/accounting/[PLAN-2025-12-19] bank-account-transaction-inquiry.md delete mode 100644 claudedocs/accounting/[PLAN-2026-01-23] vendor-credit-analysis-modal.md delete mode 100644 claudedocs/api/[API-2026-03-10] calendar-bill-integration.md delete mode 100644 claudedocs/api/[API-REQ-2026-03-03] expected-expenses-dashboard-detail-date-filter.md delete mode 100644 claudedocs/api/[API-SPEC-2026-03-03] ceo-dashboard-backend-api.md delete mode 100644 claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md delete mode 100644 claudedocs/api/[FEAT-2026-03-10] calendar-new-schedule-types.md delete mode 100644 claudedocs/api/[IMPL-2025-11-07] api-key-management.md delete mode 100644 claudedocs/api/[IMPL-2025-11-11] api-route-type-safety.md delete mode 100644 claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md delete mode 100644 claudedocs/api/[IMPL-2026-03-18] expense-accounts-receipt-no.md delete mode 100644 claudedocs/api/[REF] api-analysis.md delete mode 100644 claudedocs/api/[REF] api-requirements.md delete mode 100644 claudedocs/approval/[IMPL-2025-12-17] approval-document-checklist.md delete mode 100644 claudedocs/approval/[IMPL-2026-03-07] approval-box-linked-documents.md delete mode 100644 claudedocs/architecture/.DS_Store delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-01-20] 공통화-현황-분석.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-02-05] SAM-ERP-MES-정체성-분석.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-02-05] list-page-commonization-status.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-02-23] deep-analysis-util-component-zustand.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-02-27] ceo-dashboard-analysis.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report-v2.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report.md delete mode 100644 claudedocs/architecture/[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md delete mode 100644 claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md delete mode 100644 claudedocs/architecture/[DESIGN-2026-02-11] dynamic-field-type-extension.md delete mode 100644 claudedocs/architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md delete mode 100644 claudedocs/architecture/[GUIDE] component-tier-definition.md delete mode 100644 claudedocs/architecture/[IMPL-2025-11-13] browser-support-policy.md delete mode 100644 claudedocs/architecture/[IMPL-2025-11-18] ssr-hydration-fix.md delete mode 100644 claudedocs/architecture/[IMPL-2026-01-21] input-form-componentization.md delete mode 100644 claudedocs/architecture/[IMPL-2026-01-21] phase4-input-migration-rollout.md delete mode 100644 claudedocs/architecture/[IMPL-2026-02-05] detail-hooks-migration-plan.md delete mode 100644 claudedocs/architecture/[IMPL-2026-02-05] formatter-commonization-plan.md delete mode 100644 claudedocs/architecture/[IMPL-2026-02-11] dynamic-field-components.md delete mode 100644 claudedocs/architecture/[IMPL-2026-02-23] phase1-item4-error-format.md delete mode 100644 claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md delete mode 100644 claudedocs/architecture/[IMPL-2026-03-06] lazy-snapshot-system.md delete mode 100644 claudedocs/architecture/[IMPL] IntegratedDetailTemplate-checklist.md delete mode 100644 claudedocs/architecture/[PLAN-2025-02-10] frontend-improvement-roadmap.md delete mode 100644 claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md delete mode 100644 claudedocs/architecture/[PLAN-2026-01-16] layout-restructure.md delete mode 100644 claudedocs/architecture/[PLAN-2026-01-22] ui-component-abstraction.md delete mode 100644 claudedocs/architecture/[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md delete mode 100644 claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md delete mode 100644 claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md delete mode 100644 claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md delete mode 100644 claudedocs/architecture/[REF-2025-11-19] multi-tenancy-implementation.md delete mode 100644 claudedocs/architecture/[REF] architecture-integration-risks.md delete mode 100644 claudedocs/architecture/[REF] technical-decisions.md delete mode 100644 claudedocs/architecture/[REF] template-migration-status.md delete mode 100644 claudedocs/architecture/[RESEARCH-2026-02-11] ERP-admin-panel-architecture-patterns.md delete mode 100644 claudedocs/architecture/[TEST-2025-11-19] multi-tenancy-test-guide.md delete mode 100644 claudedocs/architecture/[TODO-2026-03-10] user-preferences-db-migration.md delete mode 100644 claudedocs/architecture/[VISION-2026-02-19] dynamic-rendering-platform-strategy.md delete mode 100644 claudedocs/architecture/module-separation-guide.md delete mode 100644 claudedocs/archive/[IMPL-2025-11-07] seo-bot-blocking-configuration.md delete mode 100644 claudedocs/archive/[IMPL-2025-11-11] chart-warning-fix.md delete mode 100644 claudedocs/archive/[IMPL-2025-11-11] error-pages-configuration.md delete mode 100644 claudedocs/archive/[IMPL-2025-11-12] modal-select-layout-shift-fix.md delete mode 100644 claudedocs/archive/[IMPL-2025-11-17] item-list-css-sync.md delete mode 100644 claudedocs/archive/[INDEX] DOCUMENTATION-MAP.md delete mode 100644 claudedocs/archive/[LEGACY] 00_INDEX.md delete mode 100644 claudedocs/archive/[LEGACY] authentication-design.md delete mode 100644 claudedocs/archive/[PLAN-2025-11-18] refactoring-plan.md delete mode 100644 claudedocs/archive/[PLAN-2025-11-21] component-separation.md delete mode 100644 claudedocs/archive/[REF-2025-11-18] cleanup-summary.md delete mode 100644 claudedocs/archive/[REF-2025-11-18] unused-files-report.md delete mode 100644 claudedocs/archive/[REF-2025-11-21] type-error-fix-checklist.md delete mode 100644 claudedocs/archive/[REF] code-quality-report.md delete mode 100644 claudedocs/archive/[REF] communication_improvement_guide.md delete mode 100644 claudedocs/archive/[REF] component-usage-analysis.md delete mode 100644 claudedocs/archive/[REF] production-deployment-checklist.md delete mode 100644 claudedocs/archive/[REF] project-context.md delete mode 100644 claudedocs/archive/[SESSION-2025-11-18] localStorage-ssr-fix-checkpoint.md delete mode 100644 claudedocs/archive/qa-inbox-modal-test.png delete mode 100644 claudedocs/archive/qa-reference-modal-test.png delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-api-pending-tasks.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-pending-integration.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-06] item-crud-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-09] client-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-09] item-crud-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-10] item-crud-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-12] item-crud-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-13] item-file-upload-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-20] zustand-refactoring-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-22] production-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-24] item-master-refactoring-session.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-30] fetch-wrapper-session-context.md delete mode 100644 claudedocs/archive/sessions/[NEXT-2025-12-30] partner-management-session-context.md delete mode 100644 claudedocs/auth/[CASE-2025-11-25] httponly-cookie-security-validation.md delete mode 100644 claudedocs/auth/[IMPL-2025-11-07] auth-guard-usage.md delete mode 100644 claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md delete mode 100644 claudedocs/auth/[IMPL-2025-11-07] jwt-cookie-authentication-final.md delete mode 100644 claudedocs/auth/[IMPL-2025-11-07] middleware-issue-resolution.md delete mode 100644 claudedocs/auth/[IMPL-2025-11-07] route-protection-architecture.md delete mode 100644 claudedocs/auth/[IMPL-2025-11-10] token-management-guide.md delete mode 100644 claudedocs/auth/[IMPL-2025-11-13] safari-cookie-compatibility.md delete mode 100644 claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md delete mode 100644 claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md delete mode 100644 claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md delete mode 100644 claudedocs/auth/[PLAN] httponly-cookie-implementation.md delete mode 100644 claudedocs/auth/[REF] nextjs15-middleware-authentication-research.md delete mode 100644 claudedocs/auth/[REF] session-migration-backend.md delete mode 100644 claudedocs/auth/[REF] session-migration-frontend.md delete mode 100644 claudedocs/auth/[REF] session-migration-summary.md delete mode 100644 claudedocs/auth/[REF] token-security-nextjs15-research.md delete mode 100644 claudedocs/backend/2026-03-02_구현내역.md delete mode 100644 claudedocs/backend/2026-03-03_구현내역.md delete mode 100644 claudedocs/backend/2026-03-04_구현내역.md delete mode 100644 claudedocs/backend/2026-03-05_구현내역.md delete mode 100644 claudedocs/backend/2026-03-06_구현내역.md delete mode 100644 claudedocs/backend/2026-03-07_구현내역.md delete mode 100644 claudedocs/backend/2026-03-08_구현내역.md delete mode 100644 claudedocs/backend/_index.md delete mode 100644 claudedocs/board/[IMPL-2025-12-30] dynamic-board-creation.md delete mode 100644 claudedocs/board/[PLAN-2025-12-19] board-management-implementation.md delete mode 100644 claudedocs/changes/20250108_order_frontend_api_integration.md delete mode 100644 claudedocs/changes/20250108_order_phase3_advanced_features.md delete mode 100644 claudedocs/components/_registry.md delete mode 100644 claudedocs/construction/[IMPL-2026-01-05] category-management-checklist.md delete mode 100644 claudedocs/construction/[IMPL-2026-01-05] item-management-checklist.md delete mode 100644 claudedocs/construction/[IMPL-2026-01-05] pricing-management-checklist.md delete mode 100644 claudedocs/construction/[IMPL-2026-01-09] partner-management-api-integration.md delete mode 100644 claudedocs/construction/[IMPL-2026-01-09] site-management-api-integration.md delete mode 100644 claudedocs/construction/[IMPL-2026-01-12] project-detail-checklist.md delete mode 100644 claudedocs/construction/[PLAN-2026-01-02] estimate-detail-form-refactoring.md delete mode 100644 claudedocs/construction/[PLAN-2026-01-05] order-detail-form-separation.md delete mode 100644 claudedocs/construction/[PLAN-2026-01-05] order-management-implementation.md delete mode 100644 claudedocs/construction/[REF] construction-project-flow.md delete mode 100644 claudedocs/construction/[REF] juil-project-structure.md delete mode 100644 claudedocs/customer-center/[IMPL-2025-12-19] inquiry-management.md delete mode 100644 claudedocs/dashboard/[FIX-2026-03-09] ceo-dashboard-fix-plan.md delete mode 100644 claudedocs/dashboard/[IMPL-2025-11-10] dashboard-integration-complete.md delete mode 100644 claudedocs/dashboard/[IMPL-2025-11-11] dashboard-cleanup-summary.md delete mode 100644 claudedocs/dashboard/[IMPL-2025-11-11] sidebar-active-menu-sync.md delete mode 100644 claudedocs/dashboard/[IMPL-2025-11-13] sidebar-scroll-improvements.md delete mode 100644 claudedocs/dashboard/[IMPL-2026-01-07] ceo-dashboard-checklist.md delete mode 100644 claudedocs/dashboard/[IMPL-2026-01-08] dashboard-settings-popup-checklist.md delete mode 100644 claudedocs/dashboard/[IMPL-2026-02-11] favorites-feature.md delete mode 100644 claudedocs/dashboard/[NEXT-2026-01-08] ceo-dashboard-session-context.md delete mode 100644 claudedocs/dashboard/[PLAN] ceo-dashboard-refactoring.md delete mode 100644 claudedocs/dashboard/[QA-2026-03-09] ceo-dashboard-ui-verification.md delete mode 100644 claudedocs/dashboard/[REF] dashboard-migration-summary.md delete mode 100644 claudedocs/dashboard/[VERIFY-2026-03-06] ceo-dashboard-data-flow-verification.md delete mode 100644 claudedocs/dev/[FIX-2026-01-29] typecheck-errors-checklist.md delete mode 100644 claudedocs/dev/[FIX-2026-02-09] PDF-이미지-누락-해결.md delete mode 100644 claudedocs/dev/[HOTFIX-2026-01-27] E2E-테스트-수정계획서.md delete mode 100644 claudedocs/dev/[IMPL-2025-12-29] quality-inspection-checklist.md delete mode 100644 claudedocs/dev/[IMPL-2026-01-23] full-page-inspection.md delete mode 100644 claudedocs/dev/[PLAN-2026-02-03] claude-config-optimization.md delete mode 100644 claudedocs/dev/[PLAN] detail-page-pattern-classification.md delete mode 100644 claudedocs/dev/[REF-2026-02-19] todo-issue-tracker.md delete mode 100644 claudedocs/dev/[REF] all-pages-test-urls.md delete mode 100644 claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md delete mode 100644 claudedocs/dev/[REF] construction-pages-test-urls.md delete mode 100644 claudedocs/dev/[REF] page-builder-implementation.md delete mode 100644 claudedocs/dev/[REPORT-2026-02-23] E2E-remaining-bugs-handoff.md delete mode 100644 claudedocs/dev/[REVIEW] MD-to-PPTX 자동화 파이프라인 검토서.md delete mode 100644 claudedocs/guides/[DESIGN-2026-01-02] document-modal-common-component.md delete mode 100644 claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md delete mode 100644 claudedocs/guides/[FIX-2026-03-17] popover-outside-click-dialog-cascade.md delete mode 100644 claudedocs/guides/[GUIDE-2025-12-16] options-vs-flattened-data.md delete mode 100644 claudedocs/guides/[GUIDE-2025-12-29] vercel-deployment.md delete mode 100644 claudedocs/guides/[GUIDE] CSS-MIGRATION-WORKFLOW.md delete mode 100644 claudedocs/guides/[GUIDE] LARGE-FILE-WORKFLOW.md delete mode 100644 claudedocs/guides/[GUIDE] ZOD-VALIDATION-TROUBLESHOOTING.md delete mode 100644 claudedocs/guides/[GUIDE] collaboration-with-claude.md delete mode 100644 claudedocs/guides/[GUIDE] common-page-patterns.md delete mode 100644 claudedocs/guides/[GUIDE] large-file-handling-strategy.md delete mode 100644 claudedocs/guides/[GUIDE] print-area-utility.md delete mode 100644 claudedocs/guides/[IMPL-2025-11-06] i18n-usage-guide.md delete mode 100644 claudedocs/guides/[IMPL-2025-11-07] form-validation-guide.md delete mode 100644 claudedocs/guides/[IMPL-2026-01-05] stat-cards-grid-layout.md delete mode 100644 claudedocs/guides/[PLAN-2025-01-21] document-system-integration.md delete mode 100644 claudedocs/guides/[PLAN-2025-12-19] page-layout-standardization.md delete mode 100644 claudedocs/guides/[PLAN-2025-12-19] project-health-improvement.md delete mode 100644 claudedocs/guides/[QA-2025-01-21] document-system-inspection.md delete mode 100644 claudedocs/guides/[REF-2026-01-07] nextjs-security-update-and-migration-plan.md delete mode 100644 claudedocs/guides/[REF] nextjs-error-handling-guide.md delete mode 100644 claudedocs/guides/badge-commonization-guide.md delete mode 100644 claudedocs/guides/migration/[ANALYSIS-2025-12-23] common-component-extraction-candidates.md delete mode 100644 claudedocs/guides/migration/[ANALYSIS] common-component-patterns.md delete mode 100644 claudedocs/guides/migration/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md delete mode 100644 claudedocs/guides/migration/[IMPL-2026-01-21] utility-input-migration-checklist.md delete mode 100644 claudedocs/guides/migration/[IMPL-2026-01-23] button-navigation-checklist.md delete mode 100644 claudedocs/guides/migration/[IMPL-2026-01-23] mode-migration-checklist.md delete mode 100644 claudedocs/guides/migration/[IMPL-2026-01-23] mode-navigation-full-checklist.md delete mode 100644 claudedocs/guides/migration/[IMPL-2026-02-06] datepicker-migration-checklist.md delete mode 100644 claudedocs/guides/migration/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md delete mode 100644 claudedocs/guides/migration/[PLAN-2025-12-23] common-component-extraction-plan.md delete mode 100644 claudedocs/guides/migration/[REF-2026-01-09] server-to-client-component-migration-checklist.md delete mode 100644 claudedocs/guides/mobile/[FIX-2026-02-04] mobile-zoom-panning.md delete mode 100644 claudedocs/guides/mobile/[GUIDE] foldable-device-layout-fix.md delete mode 100644 claudedocs/guides/mobile/[GUIDE] mobile-responsive-patterns.md delete mode 100644 claudedocs/guides/mobile/[IMPL-2026-01-13] mobile-filter-migration-checklist.md delete mode 100644 claudedocs/guides/mobile/[PLAN-2026-01-20] mobile-card-infinity-scroll.md delete mode 100644 claudedocs/guides/mobile/[PLAN] mobile-overflow-testing.md delete mode 100644 claudedocs/guides/mobile/[QA-2026-01-21] mobile-infinity-scroll-inspection.md delete mode 100644 claudedocs/guides/mobile/[REF] mobile-zoom-fix-guide.md delete mode 100644 claudedocs/guides/mobile/[REF] mobile-zoom-prevention-guide.md delete mode 100644 claudedocs/guides/universal-list/UniversalListPage-검색기능-수정내역.md delete mode 100644 claudedocs/guides/universal-list/UniversalListPage-검색리렌더링-해결가이드.md delete mode 100644 claudedocs/guides/universal-list/[DESIGN-2026-01-14] universal-list-component.md delete mode 100644 claudedocs/guides/universal-list/[IMPL-2026-01-14] universal-list-component-checklist.md delete mode 100644 claudedocs/guides/universal-list/[PLAN] universal-detail-component.md delete mode 100644 claudedocs/guides/universal-list/[QA-2026-01-15] universal-list-page-inspection.md delete mode 100644 claudedocs/guides/universal-list/[REF] UniversalListPage-QA-patterns.md delete mode 100644 claudedocs/hr/[IMPL-2025-12-05] department-management-checklist.md delete mode 100644 claudedocs/hr/[IMPL-2025-12-05] employee-management-checklist.md delete mode 100644 claudedocs/hr/[IMPL-2025-12-06] vacation-management-checklist.md delete mode 100644 claudedocs/hr/[IMPL-2025-12-16] mobile-attendance.md delete mode 100644 claudedocs/hr/[IMPL-2025-12-19] card-management.md delete mode 100644 claudedocs/item-master/[ANALYSIS-2025-11-21] item-master-notes.md delete mode 100644 claudedocs/item-master/[ANALYSIS-2025-11-26] item-master-notes.md delete mode 100644 claudedocs/item-master/[ANALYSIS-2025-12-06] item-data-mapping-checklist.md delete mode 100644 claudedocs/item-master/[ANALYSIS] item-master-data-management.md delete mode 100644 claudedocs/item-master/[API-2025-11-20] item-master-specification.md delete mode 100644 claudedocs/item-master/[API-2025-11-23] item-master-backend-requirements.md delete mode 100644 claudedocs/item-master/[API-2025-11-24] item-management-dynamic-api-spec.md delete mode 100644 claudedocs/item-master/[API-2025-11-25] item-master-data-management-api-request.md delete mode 100644 claudedocs/item-master/[API-2025-12-06] item-crud-backend-requests.md delete mode 100644 claudedocs/item-master/[API-REQUEST-2025-11-25] section-template-fields-api.md delete mode 100644 claudedocs/item-master/[API-REQUEST-2025-11-28] dynamic-page-rendering-api.md delete mode 100644 claudedocs/item-master/[API-REQUEST-2026-02-12] dynamic-field-type-backend-spec.md delete mode 100644 claudedocs/item-master/[DESIGN-2025-11-24] item-management-dynamic-frontend.md delete mode 100644 claudedocs/item-master/[DESIGN-2025-12-12] item-master-form-builder-roadmap.md delete mode 100644 claudedocs/item-master/[FIX-2025-12-16] options-details-duplicate-bug.md delete mode 100644 claudedocs/item-master/[GUIDE] ITEM-MANAGEMENT-MIGRATION.md delete mode 100644 claudedocs/item-master/[IMPL-2025-11-20] item-master-api-integration-checklist.md delete mode 100644 claudedocs/item-master/[IMPL-2025-11-27] item-master-api-refactor.md delete mode 100644 claudedocs/item-master/[IMPL-2025-11-27] realtime-sync-fixes.md delete mode 100644 claudedocs/item-master/[IMPL-2025-12-02] dynamic-item-form-fixes.md delete mode 100644 claudedocs/item-master/[IMPL-2025-12-02] dynamic-item-form-rebuild.md delete mode 100644 claudedocs/item-master/[IMPL-2025-12-02] item-code-auto-generation.md delete mode 100644 claudedocs/item-master/[IMPL-2025-12-05] item-file-upload-checklist.md delete mode 100644 claudedocs/item-master/[IMPL-2025-12-06] assembly-part-issues-checklist.md delete mode 100644 claudedocs/item-master/[IMPL-2025-12-15] backend-item-api-migration.md delete mode 100644 claudedocs/item-master/[IMPL-2025-12-24] item-master-test-and-zustand.md delete mode 100644 claudedocs/item-master/[IMPL-2026-01-09] item-management-api-integration.md delete mode 100644 claudedocs/item-master/[PLAN-2025-11-27] item-form-component-separation.md delete mode 100644 claudedocs/item-master/[PLAN-2025-11-28] dynamic-item-form-implementation.md delete mode 100644 claudedocs/item-master/[PLAN-2025-12-01] service-layer-refactoring.md delete mode 100644 claudedocs/item-master/[PLAN-2025-12-08] dynamic-form-separation-plan.md delete mode 100644 claudedocs/item-master/[PLAN-2025-12-16] dynamicitemform-hook-extraction.md delete mode 100644 claudedocs/item-master/[PLAN-2025-12-24] hook-extraction-plan.md delete mode 100644 claudedocs/item-master/[REF-2025-11-26] item-master-hooks-refactoring.md delete mode 100644 claudedocs/item-master/[REF-2025-12-01] state-sync-solutions.md delete mode 100644 claudedocs/item-master/[REF] api-requirements-items.md delete mode 100644 claudedocs/item-master/[REF] item-code-hardcoding.md delete mode 100644 claudedocs/item-master/[REF] items-route-consolidation.md delete mode 100644 claudedocs/material/[IMPL-2025-12-23] stock-status.md delete mode 100644 claudedocs/outbound/[IMPL-2026-03-04] shipment-dispatch-api-integration.md delete mode 100644 claudedocs/production/[DESIGN-2026-01-29] worker-screen-spec.md delete mode 100644 claudedocs/production/[IMPL-2025-12-22] production-dashboard-checklist.md delete mode 100644 claudedocs/production/[IMPL-2026-03-05] production-orders-api-integration.md delete mode 100644 claudedocs/production/[PLAN-2026-03-13] bending-module-implementation.md delete mode 100644 claudedocs/quality/[IMPL-2025-12-23] inspection-management-checklist.md delete mode 100644 claudedocs/quality/[IMPL-2026-03-07] quality-api-migration.md delete mode 100644 claudedocs/quality/[PLAN-2026-02-02] document-viewer-architecture.md delete mode 100644 claudedocs/quality/[PLAN-2026-02-04] quality-audit-document-management.md delete mode 100644 claudedocs/refactoring/[IMPL-2026-02-09] phase1-common-hooks-checklist.md delete mode 100644 claudedocs/refactoring/[IMPL-2026-02-19] frontend-improvement-checklist.md delete mode 100644 claudedocs/refactoring/[REF-2025-02-06] useListHandlers-commonization.md delete mode 100644 claudedocs/refactoring/[REF-2026-02-19] code-dedup-commonization-checklist.md delete mode 100644 claudedocs/sales/[API-2025-12-04] client-api-analysis.md delete mode 100644 claudedocs/sales/[API-2025-12-04] quote-api-request.md delete mode 100644 claudedocs/sales/[API-2025-12-08] pricing-api-enhancement-request.md delete mode 100644 claudedocs/sales/[IMPL-2025-12-04] client-management-api-integration.md delete mode 100644 claudedocs/sales/[IMPL-2025-12-05] pricing-management-migration.md delete mode 100644 claudedocs/sales/[IMPL-2025-12-09] pricing-api-integration-checklist.md delete mode 100644 claudedocs/sales/[IMPL-2025-12-22] order-management-sales.md delete mode 100644 claudedocs/sales/[IMPL-2026-01-12] quote-v2-test-pages-checklist.md delete mode 100644 claudedocs/sales/[NEXT-2026-02-04] price-distribution-session-context.md delete mode 100644 claudedocs/sales/[PLAN-2025-12-02] sales-pages-migration.md delete mode 100644 claudedocs/sales/[PLAN-2025-12-04] quote-management-implementation.md delete mode 100644 claudedocs/security/[AUDIT-2026-02-09] third-pass-security-audit.md delete mode 100644 claudedocs/security/[PLAN-2025-01-20] permission-system-implementation.md delete mode 100644 claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md delete mode 100644 claudedocs/security/[QA-2026-02-03] permission-verification-checklist.md delete mode 100644 claudedocs/security/[SECURITY-2025-12-12] tenant-data-isolation-audit.md delete mode 100644 claudedocs/settings/[ANALYSIS-2026-01-07] permission-system-status.md delete mode 100644 claudedocs/settings/[IMPL-2025-12-19] account-info.md delete mode 100644 claudedocs/settings/[IMPL-2025-12-19] account-management-checklist.md delete mode 100644 claudedocs/settings/[IMPL-2025-12-19] company-info.md delete mode 100644 claudedocs/settings/[IMPL-2025-12-19] popup-management.md delete mode 100644 claudedocs/settings/[IMPL-2025-12-19] subscription-management.md delete mode 100644 claudedocs/settings/[IMPL-2026-01-12] permission-frontend-checklist.md delete mode 100644 claudedocs/vehicle/[PLAN-2025-01-28] vehicle-forklift-menu-implementation.md delete mode 100644 claudedocs/vercel/vercel-deployment-setup.md delete mode 100644 claudedocs/vercel/vercel-env-setup-guide.md diff --git a/.gitignore b/.gitignore index b3f3f04c..9cdb6e9b 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ src/app/**/dev/dashboard/ # ---> Deploy script (로컬 전용) deploy.sh + +# Claude 작업 문서 +claudedocs/ diff --git a/claudedocs/.DS_Store b/claudedocs/.DS_Store deleted file mode 100644 index 15466cfd56057886628dcc060d54a2d5ea733ae4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMJ!lj`6n>ja%$WoTb< zL2LchE8rFI3d9O<{}AG2TXD8yt?4>&F-rj0UKE=H_gDv*n9$jZvmI+qDC)GUhropf zZix}taQH)p!&aQ_SZl*6u;CQA%z;~>1SUJ;LIbCuVy(Y=1-t@T1$gZ~Kxb$if010j z*C+R$9FH1J8}jBy5v~zct7G+O4F2-c$NuZvF1$EcjIFI1+nf^l?N?j^&n8W4W_R1j z<~)*1^l9Pphf9V_>`k&~y(%BQLZ#@DIA4=mP-Y(Yb)9+mMjl3T`0+K?BU4u+O4xM6 zXy~`if>*qIOt0K8 zc+aQUmg@JxHieKJ;T?Y;8IBoLqGT~j@hN=+5F^hS=+>qwrCVt`4>7S%a_IXKw<+OI zvgi4A-rJnFkH*lV_#M_%W;(!Mk?_jgJFeY2wSK`ozd|J0^ARdP9q0TT&|>Cj7Ec~g z1MR0pyHPS>_U`bX<}|nahF=d)jNn_o35;lB1L{XC{!t*08)oW=K0pz9(}7IILM zHxMiBD;Jq??T*y^Ogk%q-vrHTT?Zr7<2;hb!=+m2$9 zj-FLndzKF%tMbd-M;^reKnjyQeGx|SC^eA=GrXyEYr4t~Q!eu!-BMl|H=HlYm-eYV zber=Gx_+>McHA&6fqTVPZFamq7DaXboC_dIoPqD$ObU>a?&70Zrb2Y`0IQ?bBMt50 zN9v^+^9oZ#&hugElbp7_jyc7#vf_fy^DDq699P3?p2v|M1K2q* zgl?uup4SXFUC$yUw>vLlZmD|Yn=D3YJ|XkzL$}Cm&=IVB4yj^AF?e0kL6Y9vmoYt2 zt)!NZP_%kN&R+&Cp11I{6kVI-FwC*mF>Q#4R*OQGqrN}6=yrvnsCIK ca9F#W|JQ#EaJw&Z<_11~{qNWR|IPaUH@6sQ`Tzg` diff --git a/claudedocs/[FEAT-2026-03-11] qms-checklist-template-management.md b/claudedocs/[FEAT-2026-03-11] qms-checklist-template-management.md deleted file mode 100644 index 8d43b78a..00000000 --- a/claudedocs/[FEAT-2026-03-11] qms-checklist-template-management.md +++ /dev/null @@ -1,96 +0,0 @@ -# QMS 점검표 항목 관리 기능 - -## 개요 -품질인정심사 시스템(QMS)의 "화면 설정" 패널에 **점검표 항목 관리** 섹션을 추가하여, -카테고리/항목의 CRUD + 순서 변경 + 버전 관리를 지원한다. - -## 현재 구조 -- 점검표 데이터: `MOCK_DAY1_CATEGORIES` (mockData.ts) — Mock 상태 -- 타입: `ChecklistCategory` → `ChecklistSubItem[]` -- 설정 패널: `AuditSettingsPanel.tsx` — 레이아웃/점검표 옵션 토글만 존재 -- 데이터 훅: `useDay1Audit.ts` — `USE_MOCK = true` - -## 구현 범위 - -### 1. 점검표 템플릿 관리 UI (화면 설정 패널 내) -**위치**: AuditSettingsPanel → 새 섹션 "점검표 항목 관리" - -**기능**: -- 현재 버전 표시 + 버전 이력 드롭다운 -- 카테고리 CRUD (추가/수정/삭제) -- 하위 항목 CRUD (추가/수정/삭제) -- 순서 변경 (위/아래 버튼 — 드래그앤드롭 라이브러리 미사용) -- "저장 (새 버전 생성)" 버튼 → API 호출 -- "초기화" 버튼 → 마지막 저장 상태로 복원 - -### 2. 데이터 구조 (프론트) - -```typescript -// 점검표 템플릿 버전 -interface ChecklistTemplateVersion { - id: string; - version: number; - createdAt: string; - createdBy: string; - description?: string; // 변경 사유 -} - -// 점검표 템플릿 (API 응답) -interface ChecklistTemplate { - id: string; - currentVersion: number; - categories: ChecklistCategory[]; // 기존 타입 재사용 - versions: ChecklistTemplateVersion[]; -} -``` - -### 3. API 엔드포인트 (Mock → 추후 연동) - -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | `/api/v1/qms/checklist-templates/current` | 현재 템플릿 조회 | -| POST | `/api/v1/qms/checklist-templates` | 새 버전 저장 | -| GET | `/api/v1/qms/checklist-templates/versions` | 버전 이력 조회 | -| GET | `/api/v1/qms/checklist-templates/versions/:id` | 특정 버전 조회 | -| POST | `/api/v1/qms/checklist-templates/versions/:id/restore` | 버전 복원 | - -### 4. UI 구성 (설정 패널 내) - -``` -━━ 점검표 항목 관리 ━━ - -[v3 (2026-03-10) ▾] ← 버전 셀렉트 (이력 조회/복원) - -── 카테고리 ── -┌─────────────────────────────────────┐ -│ [⬆][⬇] 1. 원재료 품질관리 기준 [✏️][🗑] │ -│ [⬆][⬇] 수입검사 기준 확인 [✏️][🗑] │ -│ [⬆][⬇] 불합격품 처리 기준 확인 [✏️][🗑] │ -│ [⬆][⬇] 자재 보관 기준 확인 [✏️][🗑] │ -│ [+ 항목 추가] │ -├─────────────────────────────────────┤ -│ [⬆][⬇] 2. 제조공정 관리 기준 [✏️][🗑] │ -│ ... │ -└─────────────────────────────────────┘ -[+ 카테고리 추가] - -━━━━━━━━━━━━━━━━━━━━━━━━ -[초기화] [저장 (새 버전)] -``` - -### 5. 작업 목록 - -- [ ] types.ts에 템플릿 관련 타입 추가 -- [ ] ChecklistTemplateEditor 컴포넌트 생성 (편집 UI) -- [ ] AuditSettingsPanel에 탭/섹션 추가 ("화면 설정" / "점검표 관리") -- [ ] useChecklistTemplate 훅 생성 (상태 관리 + Mock 데이터) -- [ ] page.tsx 연동 (훅 → 설정 패널 props) -- [ ] 버전 이력 UI (Select 드롭다운 + 복원 확인) - -### 6. 설계 결정 - -- **드래그앤드롭 미사용**: 패키지 추가 없이 ⬆⬇ 버튼으로 순서 변경 -- **설정 패널 분리**: 기존 "화면 설정"과 "점검표 관리"를 탭으로 분리 -- **Mock 우선**: `USE_MOCK = true`로 시작, API 연동 시 교체 -- **인라인 편집**: 항목명 클릭 시 input으로 전환 (별도 모달 없음) -- **낙관적 업데이트**: 로컬 편집 → 저장 버튼 클릭 시 한번에 API 호출 diff --git a/claudedocs/[FIX-2026-02-26] windows-compatibility-checklist.md b/claudedocs/[FIX-2026-02-26] windows-compatibility-checklist.md deleted file mode 100644 index 9cab31c1..00000000 --- a/claudedocs/[FIX-2026-02-26] windows-compatibility-checklist.md +++ /dev/null @@ -1,109 +0,0 @@ -# Windows 호환성 개선 계획서 - -> 작성일: 2026-02-26 -> 배경: macOS 개발환경 → Windows 공장 PC 사용자 환경 차이로 인한 이슈 -> 상태: 계획 단계 - ---- - -## 완료된 작업 - -- [x] Popover 계열 컴포넌트 Windows 포커스 이슈 수정 (6개 파일) - - `ui/date-picker.tsx` — onPointerDownOutside/onInteractOutside 방어 - - `ui/time-picker.tsx` — 동일 - - `ui/date-range-picker.tsx` — 동일 - - `ui/multi-select-combobox.tsx` — 동일 - - `ui/searchable-select.tsx` — 동일 - - `molecules/ColumnSettingsPopover.tsx` — 동일 -- [x] 삭제 버튼 아이콘 X → Trash2 통일 (23개 파일) - ---- - -## Phase 1: IMPORTANT — 사용자 체감 영향 큰 항목 - -### 1-1. backdrop-filter 성능 최적화 -- **파일**: `src/app/globals.css` (line 269-280) -- **문제**: `backdrop-filter: blur()` 가 Windows GPU에서 비효율적 → 공장 PC에서 스크롤 버벅거림 -- **영향 범위**: `.clean-glass` 클래스 사용하는 전체 레이아웃 (Sidebar, Login 등) -- **수정 방향**: - - `prefers-reduced-motion` / `prefers-reduced-transparency` 미디어쿼리로 분기 - - 성능 낮은 환경에서는 `backdrop-filter: none` + 불투명 배경 대체 -- [ ] globals.css `.clean-glass` 수정 -- [ ] 적용된 컴포넌트에서 시각적 변화 확인 - -### 1-2. 폰트 굵기 렌더링 차이 보정 -- **파일**: `src/app/globals.css` (line 219-235), `src/app/[locale]/layout.tsx` (line 14-19) -- **문제**: Windows 폰트 렌더링이 macOS보다 ~0.5-1.5 weight 더 굵게 표시 (Pretendard 변수 폰트) -- **영향 범위**: 전체 UI 텍스트 -- **수정 방향**: - - `-webkit-font-smoothing: antialiased` 확인 (이미 적용됨) - - `font-weight: 400` 명시적 지정 - - 필요 시 Windows에서 `font-weight: 350` 적용 검토 (변수 폰트이므로 가능) -- [ ] 현재 폰트 설정 확인 -- [ ] Windows에서 시각 비교 테스트 후 보정값 결정 -- [ ] globals.css 수정 - -### 1-3. 스크롤바 동작 차이 -- **파일**: `src/app/globals.css` (line 332-412), `src/layouts/AuthenticatedLayout.tsx` (line 173-197) -- **문제**: macOS는 스크롤바 자동 숨김, Windows는 항상 표시 → 투명→페이드인 방식이 어색 -- **영향 범위**: 모든 스크롤 가능한 영역 -- **수정 방향**: - - 스크롤바 thumb을 기본 살짝 보이게 (`rgba(0,0,0,0.1)`) - - `.is-scrolling` 시 진하게 (`rgba(0,0,0,0.2)`) 유지 - - 너비 8px → 10-12px 로 Windows 기대치에 맞게 조정 검토 -- [ ] globals.css 스크롤바 스타일 수정 -- [ ] Windows에서 시각 확인 - -### 1-4. 숫자 포맷 locale 명시 -- **파일**: `src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx` (line 65-68) -- **문제**: `toLocaleString(undefined, ...)` → Windows 지역 설정에 따라 포맷 달라짐 -- **영향 범위**: 해당 페이지 숫자 표시 (다른 곳에도 동일 패턴 있는지 추가 검색 필요) -- **수정 방향**: - - `undefined` → `'ko-KR'` 명시적 locale 지정 - - 프로젝트 전체에서 동일 패턴 일괄 검색 후 수정 -- [ ] `toLocaleString(undefined` 패턴 전체 검색 -- [ ] locale을 `'ko-KR'`로 일괄 변경 - ---- - -## Phase 2: MINOR — 안정성/효율성 개선 - -### 2-1. number input 스피너 버튼 숨김 -- **파일**: 품질관리 문서 등 `input[type="number"]` 사용처 -- **문제**: Windows에서 ↑↓ 스피너 버튼 표시 → 터치스크린에서 실수 클릭 -- **수정 방향**: 전역 CSS로 스피너 숨김 처리 -- [ ] globals.css에 `input[type="number"]` 스피너 숨김 CSS 추가 - -### 2-2. 클립보드 API 에러 핸들링 -- **파일**: `src/app/[locale]/(protected)/dev/component-registry/ComponentRegistryClient.tsx` (line 44-48) -- **문제**: `navigator.clipboard.writeText()` 가 Windows 보안 정책으로 실패 가능 -- **수정 방향**: try-catch + `document.execCommand('copy')` fallback -- [ ] 클립보드 사용 코드 에러 핸들링 추가 - -### 2-3. 출퇴근 시계 갱신 주기 최적화 -- **파일**: `src/app/[locale]/(protected)/hr/attendance/page.tsx` (line ~170-180) -- **문제**: `setInterval(1000)` 매초 갱신 → 공장 PC CPU 부하 -- **수정 방향**: 날짜만 표시하므로 60초 간격으로 변경 -- [ ] setInterval 주기 1초 → 60초로 변경 - ---- - -## 테스트 체크리스트 - -모든 수정 후 Windows 환경에서 확인: - -- [ ] DatePicker: Dialog 안에서 날짜 선택 → 값 정상 입력 -- [ ] DatePicker: 이전/다음달 날짜 클릭 → 팝업 유지, 월 이동 -- [ ] TimePicker: Dialog 안에서 시간 선택 → 정상 동작 -- [ ] 스크롤: 메인 레이아웃 + 테이블 스크롤 부드러움 확인 -- [ ] 폰트: 텍스트 두께가 macOS와 비슷한 수준인지 확인 -- [ ] 숫자 포맷: 천단위 구분자, 소수점 정상 표시 -- [ ] number input: 스피너 버튼 안 보이는지 확인 - ---- - -## 참고 - -- Windows 공장 PC 사양: 보통 중저사양 (Intel i3-i5, 8GB RAM, 내장 GPU) -- 브라우저: Chrome 또는 Edge (Chromium 기반) -- 터치스크린 사용 가능성 있음 diff --git a/claudedocs/[FIX-2026-03-10] eslint-cleanup-checklist.md b/claudedocs/[FIX-2026-03-10] eslint-cleanup-checklist.md deleted file mode 100644 index b647ecaa..00000000 --- a/claudedocs/[FIX-2026-03-10] eslint-cleanup-checklist.md +++ /dev/null @@ -1,54 +0,0 @@ -# ESLint 코드 정리 체크리스트 - -## 점검 결과 요약 -- **TypeScript**: 0건 (완벽) -- **ESLint**: 923 errors + 220 warnings (1,529개 파일 중 399개) - -## 수정 대상 (exhaustive-deps 제외 - 동작 변경 위험) - -### ✅ 완료 - -| 룰 | 건수 | 상태 | 수정 내용 | -|---|---|---|---| -| `no-unreachable` | 7 | ✅ 완료 | 도달 불가 catch 블록 제거 (construction actions 3파일) | -| `no-constant-binary-expression` | 6 | ✅ 완료 | `false && ...` 조건 제거 (MasterFieldTab, SectionsTab) | -| `no-useless-escape` | 6 | ✅ 완료 | 불필요한 `\` 제거 (CurrencyField, currency-input, number-input, locale.ts) | -| `no-case-declarations` | 21 | ✅ 완료 | switch case에 `{}` 블록 추가 (5파일) | - -### ⏳ 미완료 - -| 룰 | 건수 | 상태 | 수정 방법 | -|---|---|---|---| -| `no-unused-vars` | 707 | ⏳ 대기 | `eslint-plugin-unused-imports` 자동 수정 예정 | - -## unused-vars 수정 계획 - -### 준비 상태 -- `eslint-plugin-unused-imports` 이미 설치됨 (npm install -D 완료) -- eslint.config.mjs 아직 미수정 - -### 실행 순서 -```bash -# 1. eslint.config.mjs에 플러그인 임시 추가 -# 2. npx eslint --fix src/ (unused-imports 룰만) -# 3. eslint.config.mjs 원복 -# 4. npx eslint src/ 로 결과 확인 -# 5. eslint-plugin-unused-imports 패키지 제거 -``` - -### unused-vars 파일 분포 (284개 파일) -- src/app/: 44파일 -- src/components/business/: 33파일 -- src/components/accounting+hr/: 42파일 -- src/components/items+orders+quotes+production/: 55파일 -- src/components/ 기타: 95파일 -- src/lib+stores+types/: 15파일 - -## 수정하지 않는 항목 - -| 룰 | 건수 | 사유 | -|---|---|---| -| `no-explicit-any` | 155 | warning 수준, 타입 정의 필요 (별도 작업) | -| `exhaustive-deps` | 24 | useEffect 재실행 빈도 변경 위험 | -| `no-img-element` | 39 | next/image 전환은 별도 작업 | -| `no-undef` | 168 | globals 설정 추가 필요 (sessionStorage 등) | diff --git a/claudedocs/[IMPL-2026-03-06] account-subject-unification-checklist.md b/claudedocs/[IMPL-2026-03-06] account-subject-unification-checklist.md deleted file mode 100644 index c5274bb5..00000000 --- a/claudedocs/[IMPL-2026-03-06] account-subject-unification-checklist.md +++ /dev/null @@ -1,123 +0,0 @@ -# 계정과목 통합 프로젝트 체크리스트 - -> 시작: 2026-03-06 -> 목표: 계정과목 마스터 통합 → 분개 흐름 통합 → 대시보드 연동 - ---- - -## Phase 1: 계정과목 마스터 강화 (백엔드) - -### 1-1. account_codes 테이블 확장 -- [x] 마이그레이션: sub_category(중분류), depth(계층), parent_code(상위계정), department_type(부문) 추가 -- [x] AccountCode 모델 업데이트 (fillable, casts, 관계) -- [x] AccountCodeService 확장 (계층 조회, 부문 필터 지원) -- [x] AccountSubjectController 확장 (새 필드 지원 API) -- [x] UpdateAccountSubjectRequest 생성 -- [x] 라우트 추가 (PUT /{id}, POST /seed-defaults) - -### 1-2. 표준 계정과목표 시드 데이터 (더존 Smart A 기준) -- [x] 시드 데이터 정의 (대분류 5개 + 중분류 12개 + 소분류 111개 = 128건) -- [x] seedDefaults() API 엔드포인트 (별도 Seeder 대신 API로 제공) -- [x] 기존 데이터와 충돌 방지 로직 (tenant_id+code 중복 시 skip) - ---- - -## Phase 2: 프론트 공용 컴포넌트 - -### 2-1. 공용 계정과목 설정 모달 (리스트 페이지용 - CRUD) -- [x] AccountSubjectSettingModal 공용 컴포넌트 생성 (src/components/accounting/common/) -- [x] 기존 GeneralJournalEntry/AccountSubjectSettingModal 코드 이관 + 확장 -- [x] 계층 표시 (depth별 들여쓰기: 대→중→소) -- [x] 부문 컬럼 추가 -- [x] "기본 계정과목 생성" 버튼 (seedDefaults API 연동) - -### 2-2. 공용 계정과목 Select (세부 페이지/모달용 - 조회/선택) -- [x] AccountSubjectSelect 공용 컴포넌트 생성 -- [x] DB 마스터 API 호출로 옵션 로드 (selectable=true, isActive=true) -- [x] 활성 계정과목만 표시 -- [x] "[코드] 계정과목명" 형태 표시 (예: [51100] 복리후생비(제조)) -- [x] 분류별 필터 지원 (props: category, subCategory, departmentType) - -### 2-3. 공용 타입/API 함수 -- [x] 공용 타입 정의 (src/components/accounting/common/types.ts) -- [x] 공용 actions.ts (계정과목 CRUD + seedDefaults + update API) -- [x] index.ts 배럴 파일 생성 - ---- - -## Phase 3: 7개 모듈 전환 (프론트) - -### 3-1. 일반전표입력 -- [x] 전용 AccountSubjectSettingModal → 공용 컴포넌트로 교체 -- [x] 전용 타입/API → 공용으로 교체 (actions.ts, types.ts 정리) -- [x] ManualJournalEntryModal: getAccountSubjects → 공용 actions -- [x] JournalEditModal: getAccountSubjects → 공용 actions -- [x] 전용 AccountSubjectSettingModal.tsx 삭제 - -### 3-2. 세금계산서관리 -- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect - -### 3-3. 카드사용내역 -- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect -- [x] ManualInputModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect -- [x] index.tsx 인라인 Select → AccountSubjectSelect -- 참고: ACCOUNT_SUBJECT_OPTIONS 상수는 엑셀 변환에서 기존 데이터 호환용으로 유지 - -### 3-4. 입금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님) -### 3-5. 출금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님) - -### 3-6. 미지급비용 -- [x] ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect (category="expense" 필터) - -### 3-7. 매출관리 — 보류 (매출유형 분류이며 계정과목 코드가 아님) - ---- - -## Phase 4: 분개 흐름 통합 (백엔드) - -### 4-1. source_type 확장 -- [x] JournalEntry 모델에 SOURCE_CARD_TRANSACTION, SOURCE_TAX_INVOICE 상수 추가 -- [x] source_type은 string(30)이므로 enum 마이그레이션 불필요 (상수 추가만으로 완료) - -### 4-2. 세금계산서 분개 통합 -- [x] JournalSyncService 생성 (공용 분개 CRUD + expense 동기화) -- [x] TaxInvoiceController에 journal CRUD 메서드 추가 (get/store/delete) -- [x] 라우트 추가: GET/POST/PUT/DELETE /api/v1/tax-invoices/{id}/journal-entries -- [x] source_type = 'tax_invoice', source_key = 'tax_invoice_{id}' - -### 4-3. 카드사용내역 분개 통합 -- [x] CardTransactionController에 journal CRUD 메서드 추가 (get/store) -- [x] 라우트 추가: GET/POST /api/v1/card-transactions/{id}/journal-entries -- [x] 카드 items → 차변(비용계정) + 대변(미지급금) 자동 변환 -- [x] source_type = 'card_transaction', source_key = 'card_{id}' - ---- - -## Phase 5: 대시보드 연동 - -### 5-1. expense_accounts 동기화 확장 -- [x] SyncsExpenseAccounts 트레이트 생성 (app/Traits/) -- [x] GeneralJournalEntryService → 트레이트 사용으로 전환 -- [x] JournalSyncService에서 트레이트 사용 (세금계산서/카드 분개 저장 시 자동 동기화) -- [x] source_type별 payment_method 자동 결정 (card_transaction → PAYMENT_CARD) -- [x] 모든 source_type에서 복리후생비/접대비 감지 - -### 5-2. 대시보드 집계 검증 -- [x] expense_accounts에 journal_entry_id/journal_entry_line_id 연결 (기존 마이그레이션 활용) -- [x] CEO 대시보드는 expense_accounts 테이블 기준 집계 → 모든 source_type 반영됨 - ---- - -## 작업 순서 및 의존성 - -``` -Phase 1 (백엔드 마스터 강화) - ↓ -Phase 2 (프론트 공용 컴포넌트) - ↓ -Phase 3 (7개 모듈 전환) — 모듈별 독립, 병렬 가능 - ↓ -Phase 4 (분개 흐름 통합) — Phase 3과 병렬 가능 - ↓ -Phase 5 (대시보드 연동) -``` diff --git a/claudedocs/[IMPL-2026-03-08] frontend-weekly-0302-0308.md b/claudedocs/[IMPL-2026-03-08] frontend-weekly-0302-0308.md deleted file mode 100644 index 3511af63..00000000 --- a/claudedocs/[IMPL-2026-03-08] frontend-weekly-0302-0308.md +++ /dev/null @@ -1,250 +0,0 @@ -# 프론트엔드 주간 구현내역 (2026-03-02 ~ 2026-03-08) - -> 총 커밋 59개 (feat 30 / fix 17 / refactor 3 / chore 3 / merge 1 / 기타 5) - ---- - -## 1. 품질관리 — Mock→API 전환 + 검사 모달/문서 대폭 개선 - -**커밋**: 50e4c72c, 563b240f, 899493a7, fe930b58, e7263fee, 4ea03922, 295585d8, c150d807, e75d8f9b (9개) -**변경 규모**: +2,210 / -566 라인 - -### 1-1. API 전환 -- `USE_MOCK_FALLBACK = false` 설정, Mock 데이터 제거 -- 엔드포인트: `/api/v1/quality/documents`, `/api/v1/quality/performance-reports` -- snake_case → camelCase 변환 함수 구현 -- InspectionFormData에 `clientId`, `inspectorId`, `receptionDate` 필드 추가 - -### 1-2. 검사 모달 개선 (InspectionInputModal) -- 일괄 합격/초기화 토글 버튼 추가 -- 시공 치수 필드 (너비/높이) 추가 -- 변경사유 입력 필드 추가 -- 사진 첨부 (최대 2장, base64) -- 이전/다음 개소 네비게이션 + 자동저장 -- 레거시 검사 데이터 통합 (합격/불합격/진행중/미완) - -### 1-3. 수주선택 모달 (OrderSelectModal) -- 발주처(clientName) 컬럼 추가 -- 동일 발주처 + 동일 모델 필터링 제약 -- `SearchableSelectionModal`에 `isItemDisabled` 콜백 추가 (공통 컴포넌트 확장) -- 비활성 항목 스타일링 + 전체선택 시 비활성 항목 제외 - -### 1-4. 제품검사 성적서 (FqcDocumentContent) -- 8컬럼 동적 렌더링: No / 검사항목 / 세부항목 / 검사기준 / 검사방법 / 검사주기 / 측정값 / 판정 -- rowSpan 병합: 카테고리 단일 + method+frequency 복합 병합 -- measurement_type별 처리: checkbox → 양호/불량, numeric → 숫자입력, none → 비활성 -- FQC 모드 우선 + legacy fallback 패턴 - -### 1-5. 제품검사 요청서 (FqcRequestDocumentContent) — 신규 -- 양식 기반 동적 렌더링 (template_id: 66) -- 결재라인 + 기본정보(7개) + 입력섹션(4개) + 사전통보 테이블 -- EAV 데이터 구조: section_id, column_id, row_index, field_key, field_value -- EAV 문서 없을 때 legacy fallback 적용 - -### 1-6. 수주 연결 동기화 -- order_ids 배열 매핑 (다중 수주 지원) -- 개소별 inspectionData 서버 저장 - -### 주요 파일 -- `src/components/quality/InspectionManagement/actions.ts` -- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx` -- `src/components/quality/InspectionManagement/OrderSelectModal.tsx` -- `src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx` (신규) -- `src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx` (신규) -- `src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx` - ---- - -## 2. 문서스냅샷 시스템 (Lazy Snapshot) — 신규 기능 - -**커밋**: 31f523c8, a1fb0d4f, 8250eaf2, 72a2a3e9, 04f2a8a7 (5개) -**변경 규모**: +300 라인 - -### 개요 -문서 저장/조회 시 `rendered_html` 스냅샷을 자동 캡처하여 백엔드에 전송하는 시스템. - -### 2-1. 수동 캡처 (저장 시) -- 검사성적서(InspectionReportModal): `contentWrapperRef.innerHTML` 캡처 → 저장 시 `rendered_html` 파라미터 포함 -- 작업일지(WorkLogModal): 동일 패턴 -- 수입검사(ImportInspectionInputModal): 오프스크린 렌더링 방식 - -### 2-2. Lazy Snapshot (조회 시 자동 캡처) -- 조건: `rendered_html === NULL`인 문서 조회 시 -- 동작: 500ms 지연 → innerHTML 캡처 → 백그라운드 PATCH -- 비차단(non-blocking): UI에 영향 없이 백그라운드 처리 -- `patchDocumentSnapshot()` 서버 액션으로 전송 - -### 2-3. 오프스크린 렌더링 유틸리티 -- `src/lib/utils/capture-rendered-html.tsx` (신규) -- 폼 HTML이 아닌 실제 문서 렌더링 결과를 캡처 -- readOnly 모드 자동 캡처 useEffect 제거 (불필요한 PUT 요청 방지) - -### 적용 범위 -| 문서 | 수동 캡처 | Lazy Snapshot | -|------|-----------|---------------| -| 검사성적서 | ✅ | ✅ | -| 작업일지 | ✅ | ✅ | -| 수입검사 | ✅ (오프스크린) | - | -| 제품검사 요청서 | ✅ | ✅ | - -### 주요 파일 -- `src/lib/utils/capture-rendered-html.tsx` (신규) -- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx` -- `src/components/production/WorkerScreen/WorkLogModal.tsx` -- `src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx` -- `src/components/production/WorkOrders/actions.ts` - ---- - -## 3. 생산지시 — API 연동 + 작업자 화면 + 중간검사 - -**커밋**: fa7efb7b, 8b6da749, 4331b84a, 0166601b, 8bcabafd, 2a2a356f, b45c35a5, 0b81e9c1 (8개) -**변경 규모**: +2,000 라인 - -### 3-1. 생산지시 목록/상세 API 연동 -- Mock → API 전환 (`executePaginatedAction` + `buildApiUrl` 패턴) -- 서버사이드 페이지네이션 + 동적 탭 카운트 (stats API) -- WorkOrder 상태 배지 6단계: 미배정→배정→작업중→검사→완료→출하 -- BOM null 상태 처리 - -### 3-2. 절곡 중간검사 입력 모달 (InspectionInputModal) -- 7개 제품 항목 통합 폼 -- 제품 ID 자동 매칭: 정규화 → 키워드 → 인덱스 fallback (3단계) -- cellValues 구조: `{bending_state, length, width, spacing}` -- PO 단위 데이터 공유 모델: 동일 PO 내 모든 아이템이 inspection_data 공유 - -### 3-3. 자재투입 모달 (MaterialInputModal) -- 동일 자재 다중 BOM 그룹 LOT 독립 관리 -- `bomGroupKey = ${item_id}-${category}-${partType}` 그룹핑 -- 카테고리 정렬: 가이드레일(1) → 하단마감재(2) → 셔터박스(3) → 연기차단재(4) -- FIFO 자동충전 시 그룹 간 물리적 LOT 가용량 추적 -- 번호 배지(①②③) + partType 배지 - -### 3-4. 공정 단계 검사범위 설정 (InspectionScope) — 신규 -- 전수검사 / 샘플링 / 그룹 3가지 타입 -- 샘플링 시 샘플 수(n) 입력 지원 -- StepForm 컴포넌트에 UI 추가, options JSON으로 API 저장 - -### 주요 파일 -- `src/components/production/ProductionOrders/actions.ts`, `types.ts` -- `src/components/production/WorkerScreen/InspectionInputModal.tsx` -- `src/components/production/WorkerScreen/MaterialInputModal.tsx` -- `src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx` (신규) -- `src/components/process-management/StepForm.tsx` -- `src/types/process.ts` - ---- - -## 4. 출하/배차 — 배차 다중행 + 차량관리 API + 출고관리 - -**커밋**: 83a23701, 1d380578, 5ff5093d, f653960a, a4f99ae3, 03d129c3 (6개) -**변경 규모**: +2,400 / -1,100 라인 - -### 4-1. 배차정보 다중 행 API 연동 -- `vehicle_dispatches` 배열 지원 (기존 단일 배차 → 다중 배차) -- transform 함수: `transformApiToDetail`, `transformCreateFormToApi`, `transformEditFormToApi` 갱신 -- 레거시 단일 배차 필드 하위호환 유지 - -### 4-2. 배차차량관리 Mock→API 전환 -- `executePaginatedAction` + `buildApiUrl` 패턴 적용 -- `transformToListItem()` / `transformToDetail()` snake_case → camelCase 변환 -- 쿼리 파라미터: `search`, `status`, `start_date`, `end_date`, `page`, `per_page` - -### 4-3. 출고관리 목록 필드 매핑 -- `writer_name`, `writer_id`, `delivery_date` 등 5개 필드 API 매핑 추가 -- `OrderInfoApiData` 타입으로 주문 연결 정보 처리 - -### 4-4. 배차 상세/수정 레이아웃 개선 -- 기본정보 그리드: 1열 → 2×4열 레이아웃 - -### 4-5. 출하관리 캘린더 -- 기본 뷰: day → week-time 변경 - -### 주요 파일 -- `src/components/outbound/ShipmentManagement/actions.ts` -- `src/components/outbound/VehicleDispatchManagement/actions.ts` -- `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx`, `ShipmentEdit.tsx` - ---- - -## 5. 전자결재 — 결재함 확장 + 연결문서 - -**커밋**: 181352d7, 72cf5d86 (2개) -**변경 규모**: +458 / -127 라인 - -### 5-1. 결재함 기능 확장 -- 결재함 API 연동: - - `GET /api/v1/approvals/inbox` — 결재함 목록 - - `GET /api/v1/approvals/inbox/summary` — 통계 - - `POST /api/v1/approvals/{id}/approve` — 승인 - - `POST /api/v1/approvals/{id}/reject` — 반려 -- 문서 상태: DRAFT → PENDING → APPROVED / REJECTED / CANCELLED - -### 5-2. 연결문서 기능 (LinkedDocumentContent) — 신규 -- 검사성적서, 작업일지 등을 결재 문서에 연결하여 렌더링 -- DocumentHeader 컴포넌트 활용, 결재라인/상태배지/메타 정보 표시 - -### 5-3. 모바일 반응형 -- AuthenticatedLayout: 사이드바/메인 콘텐츠 모바일 대응 -- HeaderFavoritesBar 전면 재설계 -- SearchableSelectionModal HTML 유효성 수정 - -### 주요 파일 -- `src/components/approval/ApprovalBox/actions.ts`, `index.tsx`, `types.ts` -- `src/components/approval/DocumentDetail/LinkedDocumentContent.tsx` (신규) -- `src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx` -- `src/layouts/AuthenticatedLayout.tsx` -- `src/components/layout/HeaderFavoritesBar.tsx` - ---- - -## 6. CEO 대시보드 — API 연동 + 섹션 확장 + 리팩토링 - -**커밋**: 9ad4c8ee, 23fa9c0e, cde93336, 4e179d2e, db84d679, 1bccaffe, bec933b3, 1675f3ed (8개) -**별도 문서**: `claudedocs/dashboard/[VERIFY-2026-03-06] ceo-dashboard-data-flow-verification.md` - -### 주요 변경 -- SummaryNavBar 추가 (상단 요약 데이터 네비게이션) -- 접대비/복리후생비/매출채권/캘린더 섹션 개선 -- 컴포넌트 분리 및 모달/섹션 리팩토링 -- mockData/modalConfigs 정리 -- API 연동 강화 (회계/결재/HR 섹션) -- `invalidateDashboard()` 시스템 추가 (5개 도메인 연동) - ---- - -## 7. 회계 — 계정과목 공통화 + 어음 리팩토링 - -**커밋**: 7d369d14, 1691337f, a4f99ae3(일부) (3개) -**별도 문서**: `claudedocs/[IMPL-2026-03-06] account-subject-unification-checklist.md` - -### 주요 변경 -- AccountSubjectSelect 공통 컴포넌트: 7개 페이지에 일괄 적용 -- 매출/매입/부실채권/일일보고 UI 개선 -- BillManagement 섹션 분리: 11개 섹션 컴포넌트 + 커스텀 훅(`useBillForm`, `useBillConditions`) - ---- - -## 8. 기타 - -### E2E 테스트 -- `f5bdc5ba`: 11개 FAIL 시나리오 수정 후 전체 PASS - -### 인프라 -- `f9eea0c9`, `c18c68b6`: Slack 알림 채널 분리 (product_infra → deploy_react) -- `888fae11`: next dev에서 --turbo 플래그 제거 - ---- - -## 문서 현황 - -| 도메인 | 문서 상태 | -|--------|----------| -| 품질관리 Mock→API | ✅ 본 문서 §1 | -| 문서스냅샷 (Lazy Snapshot) | ✅ 본 문서 §2 | -| 생산지시 API 연동 | ✅ 본 문서 §3 | -| 출하/배차 API 연동 | ✅ 본 문서 §4 | -| 전자결재 확장 | ✅ 본 문서 §5 | -| CEO 대시보드 | ✅ 별도 문서 존재 | -| 계정과목 공통화 | ✅ 별도 문서 존재 | -| 백엔드 구현내역 | ✅ 일별 문서 존재 (03-02 ~ 03-08) | diff --git a/claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md b/claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md deleted file mode 100644 index 8b85b596..00000000 --- a/claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md +++ /dev/null @@ -1,103 +0,0 @@ -# [IMPL] 공지 팝업 사용자 표시 연동 - -> 관리자가 등록한 팝업을 사용자에게 자동 표시하는 기능 구현 - -## 현황 - -| 구분 | 상태 | -|------|------| -| 관리자 팝업 관리 UI (CRUD) | ✅ 완성 | -| 백엔드 API (`/api/v1/popups`) | ✅ 완성 | -| `NoticePopupModal` 표시 컴포넌트 | ✅ 완성 | -| 활성 팝업 조회 서버 액션 | ✅ 완성 | -| 레이아웃 자동 표시 연동 | ✅ 완성 | -| 부서별 팝업 필터링 (백엔드) | ✅ 완성 (2026-03-10) | -| 부서별 팝업 필터링 (프론트) | ✅ 완성 (2026-03-10) | -| 부서 선택 UI (관리자 폼) | ✅ 완성 (2026-03-10) | - -## 구현 범위 (프론트만) - -### 1. `getActivePopups()` 서버 액션 -- 위치: `src/components/common/NoticePopupModal/actions.ts` -- `GET /api/v1/popups?status=active` 호출 -- 기존 `PopupApiData` → `NoticePopupData` 변환 - -### 2. `NoticePopupContainer` 컴포넌트 -- 위치: `src/components/common/NoticePopupModal/NoticePopupContainer.tsx` -- 로그인 후 활성 팝업 fetch -- `isPopupDismissedForToday()` 필터링 -- 여러 개 팝업 순차 표시 (하나 닫으면 다음 팝업) - -### 3. `AuthenticatedLayout` 연동 -- `NoticePopupContainer` 렌더링 추가 - -## 기존 파일 활용 - -``` -src/components/common/NoticePopupModal/ -├── NoticePopupModal.tsx ← 기존 (수정 없음) -├── NoticePopupContainer.tsx ← 신규 -└── actions.ts ← 신규 - -src/components/settings/PopupManagement/ -├── utils.ts ← transformApiToFrontend 재사용 -└── types.ts ← PopupApiData 타입 재사용 - -src/layouts/AuthenticatedLayout.tsx ← NoticePopupContainer 추가 -``` - -## 동작 흐름 - -``` -로그인 → AuthenticatedLayout 마운트 - → NoticePopupContainer useEffect - → localStorage에서 user.department_id 조회 - → getActivePopups(departmentId) API 호출 - → 백엔드 scopeForUser(departmentId) 적용 - → target_type='all' 팝업 + 해당 부서 팝업 반환 - → 날짜 범위(startDate~endDate) 필터 - → isPopupDismissedForToday() 필터 - → 표시할 팝업 있으면 첫 번째 팝업 모달 표시 - → 닫기 클릭 → "오늘 하루 안 보기" 체크 시 localStorage 저장 - → 다음 팝업 표시 (없으면 종료) -``` - ---- - -## [2026-03-10] 부서별 팝업 필터링 + 부서 선택 UI - -### 배경 -팝업 대상이 "부서별"일 때 어떤 부서인지 선택할 수 없었고, 사용자에게도 부서 기반 필터링이 적용되지 않았음. - -### 변경사항 - -#### 백엔드 (sam-api) -- `MemberService::getUserInfoForLogin()` — 로그인 응답에 `department_id` 추가 -- `PopupService` — `scopeForUser(?int $departmentId)` 스코프로 부서별 필터링 - -#### 프론트엔드 -| 파일 | 변경 | -|------|------| -| `LoginPage.tsx` | localStorage user에 `department_id` 저장 | -| `NoticePopupContainer.tsx` | `user.department_id`를 `getActivePopups()`에 전달 | -| `popupDetailConfig.ts` | `target` 필드를 custom 렌더로 변경, `TargetSelectorField` 컴포넌트 추가 | -| `PopupDetailClientV2.tsx` | `handleSubmit`에서 `decodeTargetValue()`로 `targetDepartmentId` 추출 | -| `types.ts` | `Popup.targetId`, `Popup.targetName` 필드 추가 | -| `utils.ts` | `transformApiToFrontend`에 `targetId`, `targetName` 매핑 추가 | -| `actions.ts` | `getDepartmentList()` 서버 액션 추가 | - -### 핵심 구현: 대상 필드 값 인코딩 -```typescript -// 단일 form field에 target_type + department_id를 함께 저장 -encodeTargetValue('department', 13) → 'department:13' -decodeTargetValue('department:13') → { targetType: 'department', departmentId: 13 } -encodeTargetValue('all') → 'all' -``` - -### TargetSelectorField 동작 -``` -대상 Select: [전사 | 부서별] - → "부서별" 선택 시 → getDepartmentList() API 호출 - → 부서 Select 추가 표시: [개발팀 | 영업팀 | ...] - → 부서 선택 시 form value = 'department:13' -``` diff --git a/claudedocs/[PLAN-2026-03-06] account-subject-unification.md b/claudedocs/[PLAN-2026-03-06] account-subject-unification.md deleted file mode 100644 index 35b87d3f..00000000 --- a/claudedocs/[PLAN-2026-03-06] account-subject-unification.md +++ /dev/null @@ -1,498 +0,0 @@ -# 계정과목 통합 기획서 - -> 작성일: 2026-03-06 -> 상태: 진행중 -> 관련: `claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md` - ---- - -## 1. 배경 및 목표 - -### 문제점 -현재 계정과목이 **7개 모듈에서 각자 하드코딩**으로 관리되고 있음. -- 일반전표만 DB 마스터(account_codes) 사용, 나머지는 프론트 상수 배열 -- 계정과목 등록은 일반전표 설정에서만 가능 -- 분개 데이터가 3개 테이블에 분산 (journal_entries, hometax_invoice_journals, barobill_card_transactions) -- CEO 대시보드 비용 집계가 일반전표 분개에서만 작동 - -### 목표 -1. **계정과목 마스터 통합**: 하나의 DB 테이블, 전 모듈 공유 -2. **공용 컴포넌트**: 설정 모달(CRUD) + Select(조회) 2개로 전 모듈 대응 -3. **분개 흐름 통합**: 모든 분개 → journal_entries 한 곳에 저장 -4. **대시보드 정확도**: 어디서 분개하든 비용 집계 정상 작동 - -### 회계담당자 요구사항 -- 계정과목을 번호 + 명칭으로 구분 (예: 5201 급여) -- 제조/회계 동일 명칭이지만 번호로 구분 가능해야 함 -- 등록하면 전체 공유, 개별 등록도 가능 - ---- - -## 2. 현재 상태 (AS-IS) - -### 2.1 모듈별 계정과목 관리 - -| 모듈 | 소스 | 옵션 수 | 필드명 | API 필드 | -|------|------|---------|--------|----------| -| 일반전표입력 | DB 마스터 | 동적 | accountSubjectId | account_subject_id | -| 세금계산서관리 | 프론트 상수 | 11개 | accountSubject | account_subject | -| 카드사용내역 | 프론트 상수 | 16개 | accountSubject | account_code | -| 입금관리 | 프론트 상수 | ~11개 | depositType | account_code | -| 출금관리 | 프론트 상수 | ~11개 | withdrawalType | account_code | -| 미지급비용 | 프론트 상수 | 9개 | accountSubject | account_code | -| 매출관리 | 프론트 상수 | 8개 | accountSubject | account_code | - -### 2.2 분개 저장 위치 - -| 소스 | 저장 테이블 | expense_accounts 동기화 | -|------|-----------|----------------------| -| 일반전표 (수기) | journal_entries + journal_entry_lines | O | -| 일반전표 (입출금 연동) | journal_entries + journal_entry_lines | O | -| 세금계산서 분개 | hometax_invoice_journals (별도) | X | -| 카드 계정과목 태그 | barobill_card_transactions.account_code | X | - -### 2.3 백엔드 현재 테이블 - -```sql --- account_codes (계정과목 마스터 - 일반전표만 사용) -id, tenant_id, code(10), name(100), category(enum), sort_order, is_active - --- journal_entries (분개 헤더) -id, tenant_id, entry_no, entry_date, entry_type, description, -total_debit, total_credit, status, source_type, source_key - --- journal_entry_lines (분개 상세) -id, journal_entry_id, tenant_id, line_no, account_code, account_name, -side(debit/credit), amount, trading_partner_id, trading_partner_name, description - --- hometax_invoice_journals (세금계산서 분개 - 별도) -id, tenant_id, hometax_invoice_id, nts_confirm_num, -dc_type, account_code, account_name, debit_amount, credit_amount, ... - --- barobill_card_transactions (카드 거래) -..., account_code, ... -``` - ---- - -## 3. 목표 상태 (TO-BE) - -### 3.1 통합 구조 - -``` -[계정과목 마스터] - account_codes 테이블 (확장) - ├── code: "5201" - ├── name: "급여" - ├── category: "expense" - ├── sub_category: "selling_admin" (판관비) - ├── parent_code: "52" (상위 그룹) - ├── depth: 3 (대=1, 중=2, 소=3) - └── department_type: "common" (공통/제조/관리) - -[분개 통합] - journal_entries (source_type으로 출처 구분) - ├── source_type: 'manual' ← 수기 전표 - ├── source_type: 'bank_transaction' ← 입출금 연동 - ├── source_type: 'tax_invoice' ← 세금계산서 (신규) - └── source_type: 'card_transaction' ← 카드사용내역 (신규) - -[프론트 공용 컴포넌트] - AccountSubjectSettingModal → 리스트 페이지에서 CRUD - AccountSubjectSelect → 세부 페이지/모달에서 선택 -``` - -### 3.2 데이터 흐름 (TO-BE) - -``` -계정과목 등록 (어느 페이지에서든) - → account_codes 테이블에 저장 - → 전 모듈에서 즉시 사용 가능 - -분개 입력 (어느 모듈에서든) - → journal_entries + journal_entry_lines에 저장 - → account_code는 account_codes 마스터 참조 - → expense_accounts 자동 동기화 (복리후생비/접대비) - → CEO 대시보드에 자동 반영 -``` - ---- - -## 4. Phase별 세부 구현 계획 - -### Phase 1: 백엔드 마스터 강화 - -#### 1-1. account_codes 테이블 확장 마이그레이션 - -```php -// database/migrations/2026_03_06_100000_enhance_account_codes_table.php -Schema::table('account_codes', function (Blueprint $table) { - $table->string('sub_category', 50)->nullable()->after('category') - ->comment('중분류 (current_asset, fixed_asset, selling_admin, cogs 등)'); - $table->string('parent_code', 10)->nullable()->after('sub_category') - ->comment('상위 계정과목 코드 (계층 구조)'); - $table->tinyInteger('depth')->default(3)->after('parent_code') - ->comment('계층 깊이 (1=대분류, 2=중분류, 3=소분류)'); - $table->string('department_type', 20)->default('common')->after('depth') - ->comment('부문 (common=공통, manufacturing=제조, admin=관리)'); - $table->string('description', 500)->nullable()->after('department_type') - ->comment('계정과목 설명'); -}); -``` - -**sub_category 값 목록:** - -| category | sub_category | 한글 | -|----------|-------------|------| -| asset | current_asset | 유동자산 | -| asset | fixed_asset | 비유동자산 | -| liability | current_liability | 유동부채 | -| liability | long_term_liability | 비유동부채 | -| capital | - | 자본 | -| revenue | sales_revenue | 매출 | -| revenue | other_revenue | 영업외수익 | -| expense | cogs | 매출원가 | -| expense | selling_admin | 판매비와관리비 | -| expense | other_expense | 영업외비용 | - -**department_type 값:** -- `common`: 공통 (모든 부문에서 사용) -- `manufacturing`: 제조 (매출원가 계정) -- `admin`: 관리 (판관비 계정) - -#### 1-2. AccountCode 모델 업데이트 - -```php -// app/Models/Tenants/AccountCode.php -protected $fillable = [ - 'tenant_id', 'code', 'name', 'category', - 'sub_category', 'parent_code', 'depth', 'department_type', - 'description', 'sort_order', 'is_active', -]; - -// 상수 -const DEPT_COMMON = 'common'; -const DEPT_MANUFACTURING = 'manufacturing'; -const DEPT_ADMIN = 'admin'; - -const DEPTH_MAJOR = 1; // 대분류 -const DEPTH_MIDDLE = 2; // 중분류 -const DEPTH_MINOR = 3; // 소분류 -``` - -#### 1-3. AccountCodeService 확장 - -기존 CRUD에 추가: -- `getHierarchical()`: 계층 구조 조회 (대-중-소 트리) -- `getByCategory(category, sub_category?)`: 분류별 조회 -- `getByDepartment(department_type)`: 부문별 조회 -- 필터: category, sub_category, department_type, depth, search, is_active - -#### 1-4. AccountSubjectController 확장 - -기존 엔드포인트 유지 + 확장: -``` -GET /api/v1/account-subjects ← 기존 (필터 파라미터 확장) - ?category=expense - &sub_category=selling_admin - &department_type=common - &depth=3 - &search=급여 - &is_active=true - &hierarchical=true ← 계층 구조 응답 옵션 - -POST /api/v1/account-subjects ← 기존 (새 필드 추가) -PATCH /api/v1/account-subjects/{id} ← 신규 (수정) -PATCH /api/v1/account-subjects/{id}/status ← 기존 -DELETE /api/v1/account-subjects/{id} ← 기존 - -POST /api/v1/account-subjects/seed-defaults ← 신규 (기본 계정과목표 일괄 생성) -``` - -#### 1-5. 표준 계정과목표 시드 데이터 - -``` -1xxx 자산 - 11xx 유동자산 - 1101 현금 - 1102 보통예금 - 1103 당좌예금 - 1110 매출채권(외상매출금) - 1120 선급금 - 1130 미수금 - 1140 가지급금 - 12xx 비유동자산 - 1201 토지 - 1202 건물 - 1210 기계장치 - 1220 차량운반구 - 1230 비품 - 1240 보증금 - -2xxx 부채 - 21xx 유동부채 - 2101 매입채무(외상매입금) - 2102 미지급금 - 2103 선수금 - 2104 예수금 - 2110 부가세예수금 - 2120 부가세대급금 - 22xx 비유동부채 - 2201 장기차입금 - -3xxx 자본 - 31xx 자본금 - 3101 자본금 - 32xx 잉여금 - 3201 이익잉여금 - -4xxx 수익 - 41xx 매출 - 4101 제품매출 - 4102 상품매출 - 4103 부품매출 - 4104 용역매출 - 4105 공사매출 - 4106 임대수익 - 42xx 영업외수익 - 4201 이자수익 - 4202 외환차익 - -5xxx 비용 - 51xx 매출원가 (제조) - 5101 재료비 ← department: manufacturing - 5102 노무비 ← department: manufacturing - 5103 외주가공비 ← department: manufacturing - 52xx 판매비와관리비 (관리) - 5201 급여 ← department: admin - 5202 복리후생비 ← department: admin - 5203 접대비 ← department: admin - 5204 세금과공과 ← department: admin - 5205 감가상각비 ← department: admin - 5206 임차료 ← department: admin - 5207 보험료(4대보험) ← department: admin - 5208 통신비 ← department: admin - 5209 수도광열비 ← department: admin - 5210 소모품비 ← department: admin - 5211 여비교통비 ← department: admin - 5212 차량유지비 ← department: admin - 5213 운반비 ← department: admin - 5214 재료비 ← department: admin (관리부문) - 5220 경비 ← department: admin - 53xx 영업외비용 - 5301 이자비용 - 5302 외환차손 - 5310 배당금지급 -``` - -기존 하드코딩 옵션과의 매핑: - -| 기존 하드코딩 (영문 키워드) | 매핑될 계정코드 | -|---------------------------|---------------| -| purchasePayment (매입대금) | 2101 매입채무 | -| advance (선급금) | 1120 선급금 | -| suspense (가지급금) | 1140 가지급금 | -| rent (임차료) | 5206 임차료 | -| salary (급여) | 5201 급여 | -| insurance (4대보험) | 5207 보험료 | -| tax (세금) | 5204 세금과공과 | -| utilities (공과금) | 5209 수도광열비 | -| expenses (경비) | 5220 경비 | -| salesRevenue (매출수금) | 4101~4106 매출 | -| accountsReceivable (외상매출금) | 1110 매출채권 | -| accountsPayable (외상매입금) | 2101 매입채무 | -| salesVat (부가세예수금) | 2110 부가세예수금 | -| purchaseVat (부가세대급금) | 2120 부가세대급금 | -| cashAndDeposits (현금및예금) | 1101~1103 현금/예금 | -| advanceReceived (선수금) | 2103 선수금 | - ---- - -### Phase 2: 프론트 공용 컴포넌트 - -#### 2-1. 파일 구조 - -``` -src/components/accounting/common/ -├── types.ts # 공용 타입 정의 -├── actions.ts # 공용 계정과목 API 함수 -├── AccountSubjectSettingModal.tsx # 설정 모달 (CRUD) -└── AccountSubjectSelect.tsx # Select 컴포넌트 (조회/선택) -``` - -#### 2-2. 공용 타입 (types.ts) - -```typescript -export interface AccountSubject { - id: string; - code: string; // "5201" - name: string; // "급여" - category: AccountCategory; // 'asset' | 'liability' | 'capital' | 'revenue' | 'expense' - subCategory: string | null; - parentCode: string | null; - depth: number; // 1=대, 2=중, 3=소 - departmentType: string; // 'common' | 'manufacturing' | 'admin' - description: string | null; - isActive: boolean; -} - -// Select에서 표시할 때: `[${code}] ${name}` → "[5201] 급여" -``` - -#### 2-3. 공용 actions.ts - -```typescript -'use server'; -// 계정과목 조회 (Select용 - 활성만) -export async function getAccountSubjects(params?) - -// 계정과목 CRUD (설정 모달용) -export async function createAccountSubject(data) -export async function updateAccountSubject(id, data) -export async function updateAccountSubjectStatus(id, isActive) -export async function deleteAccountSubject(id) - -// 기본 계정과목표 일괄 생성 -export async function seedDefaultAccountSubjects() -``` - -#### 2-4. AccountSubjectSettingModal (설정 모달) - -기존 GeneralJournalEntry/AccountSubjectSettingModal 기반 확장: -- 계층 구조 표시 (번호대별 그룹핑 또는 들여쓰기) -- 대분류/중분류/부문 필터 -- 등록: 코드 + 명칭 + 분류 + 중분류 + 부문 -- 수정: 명칭, 분류, 상태 -- 삭제: 미사용 계정만 -- "기본 계정과목표 불러오기" 버튼 (초기 세팅용) - -#### 2-5. AccountSubjectSelect (Select 컴포넌트) - -```typescript -interface AccountSubjectSelectProps { - value: string; // 선택된 계정과목 code - onValueChange: (code: string) => void; - category?: AccountCategory; // 특정 분류만 표시 - subCategory?: string; // 특정 중분류만 표시 - departmentType?: string; // 특정 부문만 표시 - placeholder?: string; - disabled?: boolean; - className?: string; - size?: 'default' | 'sm'; -} -``` - -사용 예시: -```tsx -// 세금계산서 분개 - 전체 계정과목 - - -// 카드내역 - 비용 계정만 - - -// 입금관리 - 수익 + 자산 계정 - -``` - ---- - -### Phase 3: 7개 모듈 전환 - -각 모듈에서: -1. 하드코딩 ACCOUNT_SUBJECT_OPTIONS 상수 **제거** -2. Radix Select → **AccountSubjectSelect** 교체 -3. 리스트 페이지에 **설정 모달 버튼** 추가 (필요한 곳만) -4. API 저장 시 영문 키워드 → **계정코드(숫자)** 로 변경 - -#### 데이터 마이그레이션 고려 - -기존 데이터의 영문 키워드를 숫자 코드로 변환하는 마이그레이션 필요: -```php -// 예: barobill_card_transactions.account_code -// 'salary' → '5201' -// 'rent' → '5206' -``` - ---- - -### Phase 4: 분개 흐름 통합 - -#### 4-1. JournalEntry source_type 확장 - -```php -// JournalEntry 모델 -const SOURCE_MANUAL = 'manual'; -const SOURCE_BANK_TRANSACTION = 'bank_transaction'; -const SOURCE_TAX_INVOICE = 'tax_invoice'; // 신규 -const SOURCE_CARD_TRANSACTION = 'card_transaction'; // 신규 -``` - -#### 4-2. 세금계산서 분개 통합 - -현재: `/api/v1/tax-invoices/{id}/journal-entries` → hometax_invoice_journals 저장 -변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장 - -- source_type = 'tax_invoice' -- source_key = 'tax_invoice_{id}' -- hometax_invoice_journals는 레거시 호환으로 유지 (향후 제거) - -#### 4-3. 카드사용내역 분개 통합 - -현재: `/api/v1/card-transactions/{id}/journal-entries` → barobill_card_transaction_splits -변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장 - -- source_type = 'card_transaction' -- source_key = 'card_{id}' - ---- - -### Phase 5: 대시보드 연동 - -#### 5-1. expense_accounts 동기화 공용화 - -현재 GeneralJournalEntryService에만 있는 syncExpenseAccounts를: -- **JournalEntryService (공용)** 로 분리 -- 모든 분개 저장/수정/삭제 시 자동 호출 -- account_name에 '복리후생비' 또는 '접대비' 포함 → expense_accounts 동기화 - -#### 5-2. 검증 - -- 일반전표에서 복리후생비 분개 → 대시보드 반영 확인 -- 세금계산서에서 복리후생비 분개 → 대시보드 반영 확인 -- 카드내역에서 복리후생비 분개 → 대시보드 반영 확인 - ---- - -## 5. 작업 순서 및 의존성 - -``` -Phase 1: 백엔드 마스터 강화 - ├── 1-1. 마이그레이션 + 모델 - ├── 1-2. 서비스 + 컨트롤러 - └── 1-3. 시드 데이터 - ↓ -Phase 2: 프론트 공용 컴포넌트 - ├── 2-1. 공용 타입 + actions - ├── 2-2. AccountSubjectSettingModal - └── 2-3. AccountSubjectSelect - ↓ -Phase 3: 7개 모듈 전환 ──────────── Phase 4: 분개 흐름 통합 - ├── 3-1. 일반전표 ├── 4-1. source_type 확장 - ├── 3-2. 세금계산서 ├── 4-2. 세금계산서 분개 - ├── 3-3. 카드사용내역 └── 4-3. 카드 분개 - ├── 3-4. 입금관리 ↓ - ├── 3-5. 출금관리 Phase 5: 대시보드 연동 - ├── 3-6. 미지급비용 ├── 5-1. 동기화 공용화 - └── 3-7. 매출관리 └── 5-2. 검증 -``` - ---- - -## 6. 리스크 및 주의사항 - -| 리스크 | 대응 | -|--------|------| -| 기존 데이터 마이그레이션 | 영문 키워드 → 숫자 코드 변환 마이그레이션 작성 | -| 하드코딩 의존 코드 | 엑셀 다운로드 등에서 label 변환 로직 확인 | -| API 하위호환 | 기존 엔드포인트 유지, 새 필드는 optional | -| 시드 데이터 중복 | tenant별 기존 데이터 확인 후 없는 것만 추가 | diff --git a/claudedocs/[QA-2026-03-16] approval-module-qa-report.md b/claudedocs/[QA-2026-03-16] approval-module-qa-report.md deleted file mode 100644 index 38571e94..00000000 --- a/claudedocs/[QA-2026-03-16] approval-module-qa-report.md +++ /dev/null @@ -1,285 +0,0 @@ -# 결재 모듈 QA 검증 보고서 및 수정 계획서 - -**작성일**: 2026-03-16 -**검증 대상**: 결재관리 모듈 전체 (기안함, 결재함, 참조함, 완료함) -**검증 범위**: 문서 분류/양식 선택, 등록/수정/삭제, 벨리데이션, 파일업로드 -**상태**: Phase 0~3 완료, 버그 수정 5건 완료 및 재검수 통과, Phase 2-B 미완료 - ---- - -## Phase 0: 문서 분류 / 양식 선택 검증 ✅ 완료 - -### 7개 카테고리, 17개 양식 전체 목록 확인 - -| 카테고리 | 양식 수 | 양식 목록 | 상태 | -|---------|--------|----------|------| -| 일반 (3) | 3 | 근태신청, 사유서, 품의서 | ✅ | -| 경비 (2) | 2 | 지출결의서, 비용견적서 | ✅ | -| 인사 (2) | 2 | 연차사용촉진 통지서 (1차), 연차사용촉진 통지서 (2차) | ✅ | -| 총무 (2) | 2 | 공문서, 이사회의사록 | ✅ | -| 재무 (1) | 1 | 견적서 | ✅ | -| 총무/기타 (2) | 2 | 위임장, 사용인감계 | ✅ | -| 증명서 (5) | 5 | 사직서, 위촉증명서, 경력증명서, 재직증명서, 사용인감계 | ✅ | - -**결론**: 2단계 Select (카테고리 → 양식)이 정상 동작하며 모든 양식이 노출됨 - ---- - -## Phase 1: 등록/수정/삭제 검증 ✅ 완료 - -### Phase 1-A: 일반 카테고리 ✅ - -#### 품의서 (proposal) — 전용 폼 ✅ - -| 테스트 항목 | 결과 | 비고 | -|-----------|------|------| -| 양식 선택 → 폼 렌더링 | ✅ | 제목, 거래처, 내용, 사유, 예상비용, 첨부파일 | -| 미리보기 | ✅ | DocumentDetailModal에 정상 렌더링 | -| 벨리데이션 (결재선 미지정) | ✅ | "결재선을 지정해주세요" toast | -| 임시저장 | ✅ | AP-20260316-0001 발급 | -| 상신 | ✅ | AP-20260316-0002 발급, 결재대기 전환 | -| 수정 (기안함에서 클릭) | ✅ | 모든 필드 복원, 제목 변경 후 저장 성공 | -| 삭제 | ✅ | 확인 다이얼로그 후 삭제 성공 | - -#### 근태신청 (attendance_request) — 동적 폼 ✅ - -| 테스트 항목 | 결과 | 비고 | -|-----------|------|------| -| 양식 선택 → 폼 렌더링 | ✅ | DynamicFormRenderer 5필드 정상 | -| 미리보기 | ✅ | 동적 폼 미리보기 정상 | -| 임시저장 | ✅ | 부분 입력 시 성공 (빈 폼은 실패 — BUG #13) | -| 상신 | ✅ | 부분 입력으로도 상신 성공 | - -#### 사유서 (reason_report) — 동적 폼 ✅ - -| 테스트 항목 | 결과 | 비고 | -|-----------|------|------| -| 양식 선택 → 폼 렌더링 | ✅ | DynamicFormRenderer 정상 | -| 미리보기 | ✅ | 정상 | - -### Phase 1-B: 경비 카테고리 ✅ - -#### 지출결의서 (expenseReport) — 전용 폼 ✅ - -| 테스트 항목 | 결과 | 비고 | -|-----------|------|------| -| 양식 선택 → 폼 렌더링 | ✅ | 항목 추가/삭제 테이블, 카드 정보 | -| 미리보기 | ✅ | 정상 | -| 임시저장 → 수정 → 상신 | ✅ | 전체 CRUD 정상 | - -#### 비용견적서 (expenseEstimate) — 전용 폼 ✅ - -| 테스트 항목 | 결과 | 비고 | -|-----------|------|------| -| 양식 선택 → 폼 렌더링 | ✅ | 항목 테이블, 지출합계/계좌잔액/최종차액 자동계산 | -| 미리보기 | ✅ | 정상 | -| 임시저장 → 수정 → 상신 | ✅ | 전체 CRUD 정상 | - -### Phase 1-C: 나머지 카테고리 ✅ - -| 카테고리 | 양식 | 렌더링 | 미리보기 | 비고 | -|---------|------|--------|---------|------| -| 인사 | 연차촉진 1차 | ✅ | ✅ | 전체 CRUD 테스트 완료 | -| 인사 | 연차촉진 2차 | ✅ | ✅ | | -| 총무 | 공문서 | ✅ | ✅ | | -| 재무 | 견적서 | ✅ | ✅ | | -| 총무/기타 | 이사회의사록 | ✅ | ✅ | | -| 총무/기타 | 위임장 | ✅ | ✅ | 경미 a11y 이슈 (BUG #12) | -| 증명서 | 사용인감계 | ✅ | ✅ | 경미 a11y 이슈 (BUG #12) | -| 증명서 | 사직서 | ✅ | ✅ | | -| 증명서 | 위촉증명서 | ✅ | ✅ | | -| 증명서 | 경력증명서 | ✅ | ✅ | | -| 증명서 | 재직증명서 | ✅ | ✅ | | - ---- - -## Phase 2: 벨리데이션 체크 및 파일업로드 ✅ 완료 - -### 벨리데이션 테스트 결과 - -| 테스트 시나리오 | 결과 | 동작 | -|--------------|------|------| -| 결재자 미지정 → 상신 | ✅ | "결재선을 지정해주세요." toast (프론트엔드) | -| 결재자 미지정 + 빈 폼 → 상신 | ✅ | 결재선 검증이 먼저 작동 | -| 결재자 지정 + 빈 폼 → 상신 | ✅ | "내용은(는) 필수 항목입니다." toast (백엔드 API) | -| 빈 폼 → 임시저장 | ❌ BUG #13 | 백엔드가 임시저장에도 content 필수 검증 적용 | -| 부분 입력 → 임시저장 | ✅ | AP-20260316-0009 발급, 성공 | -| 부분 입력 → 상신 | ⚠️ | 성공하지만 필드별 검증 부재 (BUG #14) | -| 임시저장 반복 클릭 | ❌ BUG #11 | 매번 새 문서 생성 (중복) | - -### 파일 업로드 테스트 결과 - -| 테스트 항목 | 결과 | 비고 | -|-----------|------|------| -| FileDropzone 렌더링 (품의서) | ✅ | "클릭하거나 파일을 드래그하세요" | -| 이미지 파일 업로드 | ✅ | test-upload.png 정상 첨부 | -| 첨부 파일 표시 | ✅ | "test-upload.png (새 파일) 73 B" | -| 첨부 파일 삭제 | ✅ | 삭제 후 "첨부된 파일이 없습니다" 복원 | - ---- - -## Phase 2-B: 대시보드 연동 검증 ⏳ 미완료 - ---- - -## 발견된 버그 목록 (전체) - -### 🔴 CRITICAL - -#### BUG #11: 임시저장 후 URL 미갱신 → 중복 문서 생성 + 삭제 불가 - -**증상**: -1. 새 문서 작성(`?mode=new`)에서 임시저장 성공 후 URL이 `?mode=new`로 유지 -2. `isEditMode`가 false인 채로 유지됨 -3. 임시저장을 다시 클릭하면 `createApproval()` 재호출 → **매번 새 문서 생성** (AP-0009, AP-0010...) -4. 삭제 버튼 클릭 시 `isEditMode`가 false이므로 API 호출 없이 `router.back()` 실행 - -**재현**: 새 문서 → 내용 입력 → 임시저장 → 임시저장 반복 → 기안함에서 중복 문서 확인 - -**파일**: `src/components/approval/DocumentCreate/index.tsx` lines 526-569 - -**수정 방안**: -```typescript -// handleSaveDraft 성공 후 URL 갱신 추가 -if (result.success && result.data?.id) { - // URL을 edit 모드로 전환하여 이후 저장이 updateApproval을 호출하도록 - router.replace(`/approval/draft/new?id=${result.data.id}&mode=edit`, { scroll: false }); - // 또는 state로 관리 - setDocumentId(String(result.data.id)); -} -``` - -**우선순위**: 🔴 CRITICAL — 데이터 중복 생성, 삭제 불가 - ---- - -### 🟡 MEDIUM - -#### BUG #1: 상신 후 기안함 리다이렉트 시 목록 데이터 미로드 - -**증상**: 문서 상신 후 기안함으로 리다이렉트되지만 목록이 0건으로 표시. 새로고침 후 정상. - -**파일**: `src/components/approval/DocumentCreate/index.tsx` (handleSubmit → router.push) - -**수정 방안**: DraftBox의 데이터 로딩에 pathname 의존성 추가 또는 invalidate 후 딜레이 - -**우선순위**: 🟡 MEDIUM — 새로고침으로 해결 가능 - ---- - -#### BUG #13: 빈 폼 임시저장 시 백엔드 검증 에러 - -**증상**: 폼 필드를 하나도 입력하지 않은 상태에서 임시저장 클릭 시 "내용은(는) 필수 항목입니다." 에러 - -**원인**: 동적 폼의 `dynamicFormData`가 `{}`일 때 백엔드가 content 필수 검증 적용 - -**수정 방안**: -- 프론트엔드: 빈 폼일 때 프론트엔드에서 "최소 1개 필드를 입력해주세요" 안내 -- 또는 백엔드: 임시저장(`is_submitted=false`) 시 content 필수 검증 제외 - -**우선순위**: 🟡 MEDIUM — 임시저장 UX 개선 - ---- - -#### BUG #14: 부분 입력 폼 상신 시 필드별 벨리데이션 미비 - -**증상**: 근태신청에서 신청자와 사유만 입력하고 신청유형/기간/일수 미입력 상태로 상신 성공 - -**원인**: 백엔드에서 `content` JSON 내부 필드별 필수값 검증을 하지 않음 - -**수정 방안**: 백엔드에서 양식별 required 필드 검증 추가 필요 - -**우선순위**: 🟡 MEDIUM — 불완전한 문서가 상신될 수 있음 - ---- - -### 🟢 LOW - -#### BUG #12: 폼 헤더에 로딩 텍스트 a11y 이슈 - -**증상**: PowerOfAttorneyForm, SealUsageForm에서 `

` 안에 로딩 `` 포함 -- 로딩 중: "위임인 불러오는 중..." / "회사 정보 불러오는 중..."이 h3의 일부로 읽힘 -- 로딩 완료 후: 정상 - -**파일**: -- `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` line 47 -- `src/components/approval/DocumentCreate/SealUsageForm.tsx` line 101 - -**수정 방안**: 로딩 텍스트를 `

` 외부로 이동하거나 `aria-hidden` 추가 - -**우선순위**: 🟢 LOW — 일시적 상태, 기능 영향 없음 - ---- - -### ✅ 수정 완료 (이전 세션에서 해결) - -| 버그 | 증상 | 수정 내용 | -|------|------|----------| -| BUG #2 (서버 hang) | startTransition + 서버 액션 deadlock | startTransition 제거, try/catch 패턴 적용 | -| BUG #3 (Select 경고) | controlled/uncontrolled 전환 | value에 undefined 사용 + key prop | -| BUG #7 (content empty) | 전용 폼 content가 빈 객체 | getDocumentContent()에 구조화된 데이터 추가 | -| BUG #8 (명칭 불일치) | "지출 예상 내역서" → "비용견적서" | 11개 파일 명칭 통일 | -| BUG #9 (key 중복 에러) | 저장된 항목 복원 시 id 누락 | transformApiToFormData()에 fallback ID 생성 | -| BUG #10 (null Input) | Input value에 null 전달 | `?? ''` null guard 추가 | - ---- - -## 수정 우선순위 정리 - -| 순위 | 버그 | 심각도 | 수정 난이도 | 파일 | -|------|------|--------|-----------|------| -| 1 | BUG #11 (중복 문서 생성) | 🔴 CRITICAL | 낮음 | `DocumentCreate/index.tsx` | -| 2 | BUG #1 (리다이렉트 미로드) | 🟡 MEDIUM | 중간 | `DocumentCreate/index.tsx`, `DraftBox/index.tsx` | -| 3 | BUG #13 (빈 폼 임시저장) | 🟡 MEDIUM | 낮음 | `DocumentCreate/index.tsx` (프론트) 또는 백엔드 | -| 4 | BUG #14 (필드별 검증 미비) | 🟡 MEDIUM | 높음 | 백엔드 API | -| 5 | BUG #12 (a11y 로딩 텍스트) | 🟢 LOW | 낮음 | `PowerOfAttorneyForm.tsx`, `SealUsageForm.tsx` | - ---- - -## 테스트 데이터 정리 필요 - -QA 과정에서 생성된 테스트 문서: -- AP-20260316-0009 (근태신청, 임시저장) — 중복 1 -- AP-20260316-0010 (근태신청, 임시저장) — 중복 2 -- AP-20260316-0011 (근태신청, 결재대기) — 부분 입력 상신 -- AP-20260316-0008 (연차촉진1차, 임시저장) - ---- - -## 버그 수정 및 재검수 결과 ✅ 완료 - -### 수정 완료 (2026-03-16 14:00) - -| BUG | 수정 내용 | 재검수 결과 | 검증 방법 | -|-----|----------|-----------|----------| -| **#11** (중복 생성) | `savedDocId` state 추가, 첫 저장 후 `isEditMode` 전환 | ✅ PASS | 1차 저장 `createApproval()` → 2차 저장 `updateApproval(55, ...)` 확인 | -| **#1** (리다이렉트 미로드) | `router.back()` → `router.push('/approval/draft')` 변경 | ✅ PASS | 상신 후 기안함 9건 정상 로드, 토스트 표시 | -| **#13** (빈 폼 임시저장) | 프론트엔드 사전 검증 추가 (동적/전용 폼 내용 체크) | ✅ PASS | "문서 내용을 최소 1개 이상 입력해주세요" 토스트 표시 | -| **#14** (필수 필드 검증) | 프론트엔드 동적 폼 required 필드 검증 추가 | ✅ PASS | "필수 항목을 입력해주세요: 신청유형, 기간, 일수" 토스트 표시 | -| **#12** (a11y 로딩) | `

` 내부 로딩 span → `
` wrapper로 sibling 분리 | ✅ PASS | heading에 로딩 텍스트 미포함 확인 | - -### 수정 파일 목록 - -| 파일 | 수정 내용 | -|------|----------| -| `src/components/approval/DocumentCreate/index.tsx` | BUG #11, #1, #13, #14 | -| `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` | BUG #12 | -| `src/components/approval/DocumentCreate/SealUsageForm.tsx` | BUG #12 | - ---- - -## 전체 QA 진행 상태 - -| Phase | 상태 | 비고 | -|-------|------|------| -| Phase 0: 문서 분류/양식 선택 | ✅ 완료 | 7카테고리 17양식 전체 확인 | -| Phase 1-A: 일반 카테고리 CRUD | ✅ 완료 | 품의서 전체 CRUD, 근태신청/사유서 렌더링+미리보기 | -| Phase 1-B: 경비 카테고리 CRUD | ✅ 완료 | 지출결의서, 비용견적서 전체 CRUD | -| Phase 1-C: 나머지 카테고리 | ✅ 완료 | 11개 양식 렌더링+미리보기 전체 통과 | -| Phase 2: 벨리데이션/파일업로드 | ✅ 완료 | 7개 벨리데이션 시나리오, 파일 업로드/삭제 테스트 | -| Phase 2-B: 대시보드 연동 | ⏳ 미완료 | | -| Phase 3: 버그 정리/수정 계획 | ✅ 완료 | 본 문서 | -| **버그 수정 + 재검수** | **✅ 완료** | **5건 수정, 5건 화면 재검수 통과** | - -### QA 중 생성된 테스트 데이터 -- AP-20260316-0012 (근태신청, 결재대기) — BUG #11 재검수용 diff --git a/claudedocs/[TASK-2026-03-03] daily-report-usd-section.md b/claudedocs/[TASK-2026-03-03] daily-report-usd-section.md deleted file mode 100644 index 6df8f060..00000000 --- a/claudedocs/[TASK-2026-03-03] daily-report-usd-section.md +++ /dev/null @@ -1,172 +0,0 @@ -# 일일일보 — USD(외국환) 섹션 누락 - -**유형**: 프론트엔드 UI 누락 -**파일**: `src/components/accounting/DailyReport/index.tsx` -**날짜**: 2026-03-03 - ---- - -## 현상 - -일일일보 페이지에 KRW(원화) 계좌만 표시되고, USD(외국환) 계좌 섹션이 없음. -summary에 `usd_totals`(이월/입금/출금/잔액)이 내려오고, daily-accounts에 `currency: 'USD'` 항목도 내려오지만 UI에서 렌더링하지 않음. - ---- - -## 원인 - -모든 테이블에서 `currency === 'KRW'` 필터만 적용 중: - -```tsx -// line 391 — 계좌별 상세 -filteredDailyAccounts.filter(item => item.currency === 'KRW') - -// line 448 — 입금 테이블 -filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0) - -// line 497 — 출금 테이블 -filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0) -``` - ---- - -## 요구사항 - -기존 KRW 섹션과 동일한 구조로 USD 섹션 추가: - -### 1. 일자별 상세 테이블에 USD 행 추가 -- 기존 KRW 계좌 목록 아래에 USD 계좌 목록 표시 -- 또는 KRW/USD 구분 소계 행으로 분리 -- 합계: `accountTotals.usd` 사용 (이미 계산 로직 있음, line 134-144) - -### 2. 예금 입출금 내역에 USD 입금/출금 테이블 추가 -- 기존 KRW 입금/출금 아래에 USD 입금/출금 테이블 추가 -- 필터: `currency === 'USD' && item.income > 0` / `currency === 'USD' && item.expense > 0` -- 금액 표시: USD 포맷 ($ 또는 달러 표기) - ---- - -## 참고: 이미 준비된 데이터 - -### summary에서 내려오는 USD 데이터 (line 53-58) -```typescript -summary: { - krwTotals: { carryover, income, expense, balance }, // ← 현재 사용 중 - usdTotals: { carryover, income, expense, balance }, // ← 미사용 (여기 추가) -} -``` - -### accountTotals 계산 로직 (line 134-144) -```typescript -// 이미 USD 합계 계산이 있음 — 사용만 하면 됨 -const usdAccounts = dailyAccounts.filter(item => item.currency === 'USD'); -const usdTotal = usdAccounts.reduce( - (acc, item) => ({ - carryover: acc.carryover + item.carryover, - income: acc.income + item.income, - expense: acc.expense + item.expense, - balance: acc.balance + item.balance, - }), - { carryover: 0, income: 0, expense: 0, balance: 0 } -); -// accountTotals.usd 로 접근 가능 -``` - ---- - -## 작업 범위 - -| 작업 | 설명 | -|------|------| -| 일자별 상세 테이블 | USD 계좌 행 추가 + USD 소계 행 | -| 입금 테이블 | USD 입금 내역 추가 | -| 출금 테이블 | USD 출금 내역 추가 | -| 금액 포맷 | USD 표시 (달러 기호 또는 통화 표기) | - -**수정 파일**: `src/components/accounting/DailyReport/index.tsx` (이 파일만) -**새 코드 불필요**: API 데이터, 타입, 계산 로직 모두 이미 있음. 렌더링만 추가. - -**상태**: ✅ 완료 (프론트엔드 렌더링 추가됨) - ---- - -# CEO 대시보드 — 자금현황 데이터 정합성 이슈 - -**유형**: 백엔드 데이터 불일치 -**관련 API**: `GET /api/proxy/daily-report/summary` -**관련 파일**: `sam-api/app/Services/DailyReportService.php` -**날짜**: 2026-03-03 - ---- - -## 현상 - -CEO 대시보드 자금현황 섹션의 **입금 합계**가 입금 관리 페이지(`/accounting/deposits`)의 실제 데이터와 불일치. - -| 항목 | 대시보드 summary API | 입금 관리 페이지 API | 차이 | -|------|---------------------|---------------------|------| -| 3월 입금 합계 | **200,000원** | **50,000원** (1건) | **150,000원 차이** | -| 3월 출금 합계 | 50,000원 | 50,000원 (1건) | 일치 | - ---- - -## 자금현황 각 수치의 의미 (현재 구조) - -``` -현금성 자산 합계 (cash_asset_total) -= KRW 활성 계좌들의 누적 잔액 합계 (당월만이 아닌 전체 잔고) -├── 전월이월(carryover): 49,872,638원 ← 3월 이전 누적 (입금총액 - 출금총액) -├── 당월입금(income): 200,000원 ← 3월 1일~오늘 입금 -├── 당월출금(expense): 50,000원 ← 3월 1일~오늘 출금 -└── 잔액(balance): 50,022,638원 = 이월+입금-출금 - -외국환(USD) 합계 (foreign_currency_total) = USD 계좌 잔액 합계 -입금 합계 = krw_totals.income (당월 KRW 입금만) -출금 합계 = krw_totals.expense (당월 KRW 출금만) -``` - ---- - -## 원인 분석 - -### 대시보드 summary API 쿼리 (DailyReportService.php line 77-80) -```php -$income = Deposit::where('tenant_id', $tenantId) - ->where('bank_account_id', $account->id) - ->whereBetween('deposit_date', [$startOfMonth, $endOfDay]) - ->sum('amount'); -``` - -### 입금 관리 페이지 API 쿼리 -- 별도 컨트롤러/서비스에서 조회 -- 동일한 `deposits` 테이블을 읽지만, 조회 조건이 다를 수 있음 - -### 불일치 가능 원인 -1. **soft delete 차이**: summary는 soft-deleted 레코드 포함, 목록 API는 제외 -2. **tenant_id 조건 차이**: 두 API의 tenant 필터링이 다를 수 있음 -3. **E2E 테스트 데이터**: 테스트가 DB에 직접 삽입한 레코드가 목록 API에서는 필터됨 -4. **status 필터**: 입금 관리 목록에 status 조건이 추가되어 일부 제외 - ---- - -## 확인 필요 사항 (백엔드) - -### 1. deposits 테이블 직접 조회 -```sql -SELECT id, deposit_date, amount, bank_account_id, deleted_at, status -FROM deposits -WHERE tenant_id = [현재테넌트] - AND bank_account_id = 1 - AND deposit_date BETWEEN '2026-03-01' AND '2026-03-03' -ORDER BY id; -``` -→ 실제 레코드 수와 합계 확인 (soft delete, status 포함) - -### 2. 두 API의 쿼리 조건 비교 -- `DailyReportService::dailyAccounts()` — Deposit 모델 조건 -- 입금 관리 컨트롤러/서비스 — Deposit 모델 조건 -- 차이점 확인 (withTrashed, status 등) - -### 3. 해결 방향 -- 두 API가 동일한 데이터 소스를 보도록 통일 -- 또는 대시보드에서 기존 입금/출금 관리 API를 재사용하여 데이터 일관성 확보 diff --git a/claudedocs/_index.md b/claudedocs/_index.md deleted file mode 100644 index 7dce9e7c..00000000 --- a/claudedocs/_index.md +++ /dev/null @@ -1,99 +0,0 @@ -# claudedocs 문서 맵 - -> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-03-09) - -## 빠른 참조 - -| 문서 | 설명 | -|------|------| -| **[`[REF] all-pages-test-urls.md`](./dev/[REF]%20all-pages-test-urls.md)** | 전체 페이지 테스트 URL 목록 | -| **[`[REF] technical-decisions.md`](./architecture/[REF]%20technical-decisions.md)** | 프로젝트 기술 결정 사항 (13개 항목) | -| **[`[GUIDE] common-page-patterns.md`](./guides/[GUIDE]%20common-page-patterns.md)** | 공통 페이지 패턴 가이드 | - -## 주간 구현내역 - -| 기간 | 문서 | -|------|------| -| 2026-03-02 ~ 03-08 | **[`[IMPL-2026-03-08] frontend-weekly-0302-0308.md`](./%5BIMPL-2026-03-08%5D%20frontend-weekly-0302-0308.md)** | -| (백엔드 일별) | `backend/2026-03-02_구현내역.md` ~ `2026-03-08_구현내역.md` | - ---- - -## 폴더 구조 - -``` -claudedocs/ -├── _index.md # 이 파일 - 문서 맵 -├── auth/ # 인증 & 토큰 관리 -├── hr/ # 인사관리 (부서/사원) -├── item-master/ # 품목기준관리 -├── production/ # 생산관리 (생산현황판/작업자화면) -├── quality/ # 품질관리 (검사관리) -├── sales/ # 판매관리 (견적/거래처/단가) -├── accounting/ # 회계관리 (매입/매출/출금) -├── construction/ # 주일 공사 MES -├── board/ # 게시판 관리 -├── settings/ # 설정 관리 -├── dashboard/ # 대시보드 & 사이드바 -├── security/ # 보안 & 권한 -├── api/ # API 통합 -├── dev/ # 개발도구 & 테스트 -├── guides/ # 범용 가이드 -│ ├── mobile/ # 모바일 반응형 -│ ├── universal-list/ # UniversalListPage 관련 -│ └── migration/ # 마이그레이션 체크리스트 -├── architecture/ # 아키텍처 & 시스템 & 기술 결정 -├── changes/ # 변경이력 -├── refactoring/ # 리팩토링 체크리스트 -├── outbound/ # 출하/배차관리 -├── vehicle/ # 차량관리 -├── material/ # 자재관리 -├── approval/ # 결재관리 -├── backend/ # 백엔드 일별 구현내역 -├── customer-center/ # 고객센터 -├── components/ # 컴포넌트 문서 -├── vercel/ # Vercel 배포 -└── archive/ # 레거시/완료된 문서 - └── sessions/ # 만료된 세션 체크포인트 -``` - ---- - -## 문서 작성 규칙 - -### 파일명 컨벤션 -``` -[TYPE-YYYY-MM-DD] description.md -``` - -**TYPE 종류**: -- `IMPL` - 구현 문서 -- `API` - API 명세/요청 -- `GUIDE` - 사용 가이드 -- `REF` - 참조 문서 -- `ANALYSIS` - 분석 노트 -- `PLAN` - 계획 문서 -- `DESIGN` - 설계 문서 -- `TEST` - 테스트 가이드 -- `NEXT` - 다음 작업 목록 (세션 체크포인트) -- `FIX` - 버그 해결 문서 -- `QA` - 품질 검사 문서 -- `HOTFIX` - 긴급 수정 문서 -- `REPORT` - 보고서/전달 문서 - -### 폴더 배치 기준 -1. **기능/도메인 우선**: 문서 주제에 맞는 폴더에 배치 -2. **범용 가이드**: 여러 기능에 적용되면 `guides/`에 배치 -3. **시스템 전체**: 아키텍처/리팩토링/기술결정은 `architecture/`에 배치 -4. **개발도구**: 테스트 URL, 빌드, E2E, 설정은 `dev/`에 배치 -5. **완료된 작업**: 더 이상 활성화되지 않으면 `archive/`로 이동 -6. **만료 세션**: 2개월 이상 경과한 NEXT-* 파일은 `archive/sessions/`로 이동 - -### 파일 목록 확인 -```bash -# 특정 도메인 파일 확인 -ls claudedocs// - -# 전체 파일 검색 -find claudedocs/ -name "*.md" | sort -``` diff --git a/claudedocs/accounting/[IMPL-2025-12-18] bill-management.md b/claudedocs/accounting/[IMPL-2025-12-18] bill-management.md deleted file mode 100644 index 1f6bad99..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-18] bill-management.md +++ /dev/null @@ -1,65 +0,0 @@ -# 어음관리 (Bill Management) 구현 - -## 스크린샷 분석 - -### 리스트 화면 -- 상단: 일괄등록 버튼, 날짜범위 선택, 상태 탭 (전체, 전일, 오늘, 미결, 수취, 우등록) -- 통계 카드: 4개 (건수 표시) -- 테이블 컬럼: No, 어음번호, 구분, 거래처, 금액, 만기일, 이유, 추수, 메모, 상태 -- 필터: 거래처, 상태 - -### 상세/수정 화면 -- 타이틀: "어음 상세" -- 버튼: view 모드 [목록, 삭제, 수정] / edit 모드 [취소, 저장] -- 기본 정보: - - 어음번호 (Input) - - 구분 (Select: 수취/발행) - - 거래처 (Select) - - 금액 (Input) - - 발행일 (Date) - - 만기일 (Date) - - 상태 (Select - 구분에 따라 옵션 변경) - - 비고 (Input) -- 차수 관리 섹션: - - 테이블: 일자, 금액, 비고 - - [+ 추가] 버튼 - -### 타입 정의 -- **구분**: 수취, 발행 -- **상태 (수취)**: 보관중, 만기입금(7일전), 만기결과, 결제완료, 부도 -- **상태 (발행)**: 보관중, 만기입금(7일전), 추심의뢰, 추심완료, 추소중, 부도 - ---- - -## 체크리스트 - -### Phase 1: 기본 구조 -- [x] types.ts 생성 (타입, 상수 정의) -- [x] 폴더 구조 생성 (BillManagement/) - -### Phase 2: 리스트 화면 -- [x] index.tsx 생성 (리스트 컴포넌트) -- [x] 통계 카드 구현 -- [x] 테이블 렌더링 구현 -- [x] 필터 및 정렬 구현 - -### Phase 3: 상세/수정 화면 -- [x] BillDetail.tsx 생성 -- [x] 기본 정보 폼 구현 -- [x] 차수 관리 섹션 구현 -- [x] view/edit 모드 분기 처리 - -### Phase 4: 라우팅 -- [x] page.tsx 파일 생성 (리스트, 상세, 등록) -- [x] 네비게이션 패턴 적용 (?mode=edit) - -### Phase 5: 검증 -- [x] 빌드 테스트 ✅ -- [ ] 기능 확인 (사용자 확인 필요) - ---- - -## 참고 패턴 -- 입금관리 (DepositManagement) 구조 참고 -- IntegratedListTemplateV2 사용 -- PageLayout + PageHeader 패턴 \ No newline at end of file diff --git a/claudedocs/accounting/[IMPL-2025-12-18] expected-expense-checklist.md b/claudedocs/accounting/[IMPL-2025-12-18] expected-expense-checklist.md deleted file mode 100644 index d6ea836d..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-18] expected-expense-checklist.md +++ /dev/null @@ -1,38 +0,0 @@ -# 지출 예상 내역서 구현 체크리스트 - -## 현재 세션 작업 (2025-12-18) - -### 1. 테이블 필터 수정 -- [x] 1.1 첫번째 필터: 전체 → 거래처 목록으로 변경 ✅ - - 현재: 거래유형 필터 (매입, 선급금, 가지급금 등) - - 변경: 거래처 필터 (전체, 거래처1, 거래처2...) -- [x] 1.2 두번째 필터: 정렬 옵션 축소 ✅ - - 현재: 최신순, 등록순, 지급일 빠른순, 지급일 느린순, 금액 높은순, 금액 낮은순 - - 변경: 최신순, 등록순 (2개만) - -### 2. 예상 지급일 변경 팝업 -- [x] 2.1 팝업 다이얼로그 생성 ✅ - - 헤더: "예상 지급일 변경" + X 닫기 버튼 -- [x] 2.2 선택 항목 요약 영역 ✅ - - 항목명 외 N (선택된 항목 수) - - 총 금액 표시 (합계) -- [x] 2.3 예상 지급일 선택 ✅ - - 라벨: "예상 지급일" - - 공용 달력 컴포넌트 사용 (Calendar + Popover) -- [x] 2.4 버튼 영역 ✅ - - 취소 버튼 - - 저장 버튼 (날짜 미선택 시 비활성화) - -### 3. 전자결재 버튼 기능 -- [x] 3.1 버튼 클릭 시 페이지 이동 ✅ - - 이동 경로: `/ko/approval/document-write/expected-expense` (문서 작성_지출 예상 내역서) - - 항목 선택 필수 (미선택 시 버튼 비활성화) - ---- - -## 참고 스크린샷 -- 예상 지급일 변경 팝업: `스크린샷 2025-12-18 오후 4.39.35.png` -- 필터 위치 참고: `스크린샷 2025-12-18 오후 4.19.33.png`, `4.20.20.png` - -## 테스트 URL -- http://localhost:3000/ko/accounting/expected-expenses \ No newline at end of file diff --git a/claudedocs/accounting/[IMPL-2025-12-18] purchase-management.md b/claudedocs/accounting/[IMPL-2025-12-18] purchase-management.md deleted file mode 100644 index a38ac185..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-18] purchase-management.md +++ /dev/null @@ -1,111 +0,0 @@ -# [IMPL-2025-12-18] 매입관리 페이지 구현 - -## 개요 -회계관리 > 매입관리 페이지 구현 (리스트 + 상세) - -## 참고자료 -- 기안함 리스트 페이지: `src/components/approval/DraftBox/index.tsx` -- 공통 템플릿: `IntegratedListTemplateV2` -- 공통 컴포넌트: `DateRangeSelector`, `ListMobileCard` - ---- - -## Phase 1: 리스트 페이지 - -### 1.1 페이지 구조 -- [ ] 라우트 생성: `/accounting/purchase` -- [ ] 컴포넌트 생성: `src/components/accounting/PurchaseManagement/index.tsx` -- [ ] 타입 정의: `src/components/accounting/PurchaseManagement/types.ts` - -### 1.2 상단 영역 -- [ ] 날짜 범위 선택 (DateRangeSelector) -- [ ] 탭 버튼: 담대조건, 진행중, 당일, 이달, 이번, 미결 - -### 1.3 통계 카드 (4개) -- [ ] 매입금액 합계 (원) -- [ ] 출금액 합계 (원) -- [ ] 매입 건수 -- [ ] 미결 건수 - -### 1.4 필터 셀렉트 박스 -- [ ] 부가세여부 필터 (다중 선택): 부가세여부, 상품매입, 오주경비, 소모품비, 수선비, 원재료비, 사무용품비 등 -- [ ] 거래처 필터 (검색 + 다중 선택) -- [ ] 증빙 필터 (다중 선택): 증빙유형 목록 - -### 1.5 테이블 컬럼 -| 컬럼 | 설명 | -|------|------| -| No | 순번 | -| 매입일자 | 매입 등록일 | -| 매입금액 | 금액 | -| 거래처 | 거래처명 | -| 출금액 | 실제 출금액 | -| 부가세 | 부가세 금액 | -| 매입유형 | 유형 분류 | -| 증빙유형 | 세금계산서 등 | - -### 1.6 기능 -- [ ] 매입 자동 등록: 지출예상내역서 승인 완료 시 자동 등록 -- [ ] 매입/매출등록 Alert 표시 (API 연동 예정) -- [ ] 일람표/거래처원장 연계 출력 - ---- - -## Phase 2: 상세 페이지 (모달) - -### 2.1 기본 정보 섹션 -- [ ] 근거 문서명: 품의서 또는 지출결의서 표시 -- [ ] 결재 버튼: 클릭 시 매입 문서 상세 팝업 -- [ ] 예상 비용 표시: 품의서/지출결의서 예상/총 비용 - -### 2.2 매입 정보 섹션 -- [ ] 매입일자: 문서번호 + 상세조회 아이콘 -- [ ] 출금계좌 셀렉트 박스: 등록된 계좌 목록 (계좌번호 마지막 4자리 + 별명) -- [ ] 거래처 셀렉트 박스: 검색 기능 -- [ ] 매입 유형 셀렉트 박스: 부자재매입, 상품매입, 오주경비, 소모품비, 수선비, 원재료비, 사무용품비, 임차료, 수도광열비, 통신비, 차량유지비, 잡비, 보험료, 기타경비, 미상담 - -### 2.3 품목 정보 섹션 -- [ ] 테이블: 품목명, 수량, 단가, 공급가액, 부가세, 적요 -- [ ] 행 추가/삭제 기능 -- [ ] 합계 표시 - -### 2.4 세금계산서 섹션 -- [ ] 세금계산서 수취 토글 버튼 -- [ ] 토글 시 미수취/수취완료 상태 변경 -- [ ] 수취 완료 후 완료 상태로 변경 - ---- - -## Phase 3: 연동 및 마무리 -- [ ] 전자결재 시스템 연동 (지출예상내역서 승인 → 매입 자동 등록) -- [ ] API 연동 준비 (비포템 API 자동 등록 예정) -- [ ] 일람표/거래처원장 출력 기능 - ---- - -## 파일 구조 -``` -src/ -├── app/[locale]/(protected)/accounting/ -│ └── purchase/ -│ └── page.tsx -├── components/accounting/ -│ └── PurchaseManagement/ -│ ├── index.tsx # 리스트 페이지 -│ ├── types.ts # 타입 정의 -│ └── PurchaseDetailModal.tsx # 상세 모달 -``` - ---- - -## 진행 상태 -- [x] 요구사항 분석 완료 -- [x] 계획서 작성 완료 -- [x] Phase 1 완료 (리스트 페이지) -- [x] Phase 2 완료 (상세 모달) -- [ ] Phase 3 대기 (API 연동) - -## 테스트 URL -``` -http://localhost:3000/ko/accounting/purchase -``` \ No newline at end of file diff --git a/claudedocs/accounting/[IMPL-2025-12-18] receivables-status.md b/claudedocs/accounting/[IMPL-2025-12-18] receivables-status.md deleted file mode 100644 index 427657dd..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-18] receivables-status.md +++ /dev/null @@ -1,73 +0,0 @@ -# 미수금 현황 페이지 구현 체크리스트 - -## 기본 정보 -- **생성일**: 2025-12-18 -- **경로**: `/ko/accounting/receivables-status` -- **참고 페이지**: 매출관리, 거래처원장 - ---- - -## Phase 1: 기본 구조 설정 - -- [x] 페이지 라우트 생성 (`src/app/[locale]/(protected)/accounting/receivables-status/page.tsx`) -- [x] 컴포넌트 디렉토리 생성 (`src/components/accounting/ReceivablesStatus/`) -- [x] 메인 컴포넌트 생성 (`index.tsx`) -- [x] 타입 정의 파일 생성 (`types.ts`) - ---- - -## Phase 2: 레이아웃 구현 - -- [x] DateRangeSelector 공통 달력 적용 -- [x] 프리셋 버튼 (당해년도, 전전월, 전월, 당월, 어제, 오늘) -- [x] 엑셀 다운로드 버튼 -- [x] 저장 버튼 (엑셀 다운로드 아래) -- [x] 검색창 (거래처 검색) - ---- - -## Phase 3: 테이블 구현 (특수 구조) - -테이블 구조 (스크린샷 기준): -- 컬럼: 거래처/연체, 구분, 1월~12월, 합계 -- 그룹핑: 거래처별로 묶이고 각 거래처 아래 구분 (5개: 매출, 입금, 어음, 미수금, 메모) - -- [x] 거래처별 그룹핑 테이블 구조 (rowSpan=5 사용) -- [x] 월별 컬럼 (1월~12월 + 합계) -- [x] 구분 행 (매출, 입금, 어음, 미수금, 메모) - 5개 카테고리 -- [x] 연체 토글 (거래처/연체 컬럼 내) -- [x] 연체 영역 전체 하이라이트 (토글 ON 시 해당 월 전체 빨간 배경) - ---- - -## Phase 4: 토글 기능 - -스크린샷 Description 기준: -- ON: 연체 상태로 표시, 연체일수 시작 -- OFF: 정상 상태 -- 거래처 상세에서 연체 설정과 연동 - -- [x] Switch 컴포넌트로 연체 토글 구현 -- [x] 토글 상태에 따른 UI 변화 -- [x] 연체 상태 표시 로직 - ---- - -## Phase 5: 추가 기능 - -- [x] Mock 데이터 생성 -- [x] 합계 행 계산 -- [ ] 모바일 카드 뷰 (추후 필요시) -- [x] 반응형 레이아웃 (overflow-x-auto) - ---- - -## 진행 상태 -- **현재 단계**: 완료 -- **마지막 업데이트**: 2025-12-18 - ---- - -## 참고 사항 -- Description 영역 (수취 어음 등록 시 표시, 메모 입력박스)은 현재 스코프에서 제외 -- 기본 기능 먼저 구현 후 추가 기능 논의 \ No newline at end of file diff --git a/claudedocs/accounting/[IMPL-2025-12-18] vendor-ledger.md b/claudedocs/accounting/[IMPL-2025-12-18] vendor-ledger.md deleted file mode 100644 index 60eaa4af..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-18] vendor-ledger.md +++ /dev/null @@ -1,129 +0,0 @@ -# [IMPL-2025-12-18] 거래처원장 (Vendor Ledger) 구현 - -## 개요 -- **화면명**: 거래처원장 -- **경로**: 회계관리 > 거래처원장 -- **구성**: 리스트 페이지 + 상세 페이지 - ---- - -## Phase 1: 리스트 페이지 (VendorLedger/index.tsx) - -### 1.1 헤더 영역 -- [ ] 제목: "거래처원장" -- [ ] 설명: "거래처별 기간 내역을 조회합니다." -- [ ] 기간 선택기: 2025-09-01 ~ 2025-09-03 형식 -- [ ] 기간 버튼: 당해년도, 전년월, 전월, 당월, 어제, 오늘 -- [ ] 엑셀 다운로드 버튼 - -### 1.2 요약 카드 (4개) -- [ ] 전기 이월: 3,123,000원 -- [ ] 매출: 3,123,000원 -- [ ] 수금: 3,123,000원 -- [ ] 잔액: 3,123,000원 - -### 1.3 테이블 영역 -- [ ] 검색 필드 -- [ ] 총 N건 표시 -- [ ] 테이블 컬럼: - - No. - - 거래처명 - - 이월잔액 - - 매출 - - 수금 - - 잔액 - - 결제일 -- [ ] 합계 행 (테이블 하단) -- [ ] 행 클릭 시 상세 페이지 이동 - ---- - -## Phase 2: 상세 페이지 (VendorLedger/VendorLedgerDetail.tsx) - -### 2.1 헤더 영역 -- [ ] 제목: "거래처원장 상세 (거래명세서별)" -- [ ] 설명: "거래처 상세 내역을 조회합니다." -- [ ] 기간 선택기: 2025-09-01 ~ 2025-09-03 -- [ ] 기간 버튼: 당해년도, 전년월, 전월, 당월, 어제, 오늘 -- [ ] PDF 다운로드 버튼 - -### 2.2 거래처 정보 섹션 (2열 레이아웃) -- [ ] 좌측 열: - - 회사명: [값] - - 사업자등록번호: 123-12-12345 - - 전화번호: 02-1234-1234 - - 팩스: 02-1234-1236 - - 주소: 주소영 -- [ ] 우측 열: - - 기간: 2025-01-01 ~ 2025-12-31 - - 대표자: 홍길동 - - 모바일: 02-1234-1235 - - 이메일: abc@email.com - -### 2.3 판매/수금 내역 테이블 -- [ ] 컬럼: 일자, 적요, 판매, 수금, 잔액, 작업 -- [ ] 이월잔액 행 (첫 행, 적요에 "이월잔액") -- [ ] 거래 행 (◆ 아이콘 + 날짜) - - 클릭 시 어음 상세 화면 이동 -- [ ] 거래명세서 행 (클릭 시 문서 상세 팝업) -- [ ] 품목명 행 (세금계산서 미발행 시 노란색 하이라이트) -- [ ] 누계 행 (※ 123건 누계 (VAT 포함) 형식) -- [ ] 월별 계 행 (회색 배경, 예: "2025/01 계", "2025/09 계") -- [ ] 작업 컬럼: 수정 아이콘 (✏️) - ---- - -## Phase 3: 타입 및 라우트 - -### 3.1 types.ts -- [ ] VendorLedgerItem 인터페이스 -- [ ] VendorLedgerDetail 인터페이스 -- [ ] TransactionEntry 인터페이스 - -### 3.2 라우트 설정 -- [ ] `/ko/accounting/vendor-ledger` - 리스트 페이지 -- [ ] `/ko/accounting/vendor-ledger/[id]` - 상세 페이지 - ---- - -## 스크린샷 상세 분석 - -### 리스트 페이지 테이블 데이터 예시 -| No. | 거래처명 | 이월잔액 | 매출 | 수금 | 잔액 | 결제일 | -|-----|---------|---------|------|------|------|--------| -| 7 | 회사명 | -100,000 | | | | | -| 6 | 회사명 | 10,000,000 | 10,000,000 | 10,000,000 | | | -| 5 | 회사명 | 10,000,000 | | | | | -| ... | | | | | | | -| **합계** | | 10,000,000 | 10,000,000 | 10,000,000 | 10,000,000 | | - -### 상세 페이지 판매/수금 내역 예시 -| 일자 | 적요 | 판매 | 수금 | 잔액 | 작업 | -|------|------|------|------|------|------| -| | 이월잔액 | 10,000,000 | | | | -| ◆ 2025-01-01 | 수취 어음 (만기 1/5) | | 3,000,000 | 10,000,000 | ✏️ | -| ◆ 2025-01-05 | 어음 회수 | | 3,000,000 | | | -| **2025/01 계** | | | | | | -| ◆ 2025-09-25 | 매출 입력 | 1,000,000 | | | | -| | 품목명 | **🟡 500,000** | | | | -| | ※ 123건 누계 (VAT 포함) | 1,000,000 | | 1,000,000 | | -| **2025/09 계** | | 1,000,000 | 8,000,000 | | | - ---- - -## 작업 진행 상황 -- [x] Phase 1: 리스트 페이지 구현 -- [x] Phase 2: 상세 페이지 구현 -- [x] Phase 3: 타입 및 라우트 설정 -- [x] Phase 4: 테스트 및 검증 - -## 생성된 파일 -1. `src/components/accounting/VendorLedger/types.ts` - 타입 정의 -2. `src/components/accounting/VendorLedger/index.tsx` - 리스트 페이지 -3. `src/components/accounting/VendorLedger/VendorLedgerDetail.tsx` - 상세 페이지 -4. `src/app/[locale]/(protected)/accounting/vendor-ledger/page.tsx` - 리스트 라우트 -5. `src/app/[locale]/(protected)/accounting/vendor-ledger/[id]/page.tsx` - 상세 라우트 - -## 접속 URL -- 리스트: `/ko/accounting/vendor-ledger` -- 상세: `/ko/accounting/vendor-ledger/[id]` \ No newline at end of file diff --git a/claudedocs/accounting/[IMPL-2025-12-18] vendor-management-checklist.md b/claudedocs/accounting/[IMPL-2025-12-18] vendor-management-checklist.md deleted file mode 100644 index 5ab96a44..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-18] vendor-management-checklist.md +++ /dev/null @@ -1,287 +0,0 @@ -# 거래처 관리 (Vendor Management) 구현 체크리스트 - -> **상태**: ✅ 완료 (2025-12-18) -> **리스트 수정**: ✅ 완료 (2025-12-18) - 필터/액션버튼/신규등록 수정 - -## 개요 -- **경로**: `/accounting/vendors` (리스트), `/accounting/vendors/[id]` (상세), `/accounting/vendors/new` (신규등록) -- **기능**: 거래처 등록, 조회, 수정, 삭제 -- **참고**: 스크린샷 4장 (리스트 1장, 상세 3장) - ---- - -## Phase 1: 타입 및 상수 정의 ✅ - -### 1.1 types.ts 생성 -- [x] 거래처 구분 타입 (VendorCategory): `sales` | `purchase` | `both` -- [x] 신용등급 타입 (CreditRating): `AAA` | `AA` | `A` | `BBB` | `BB` | `B` | `CCC` | `CC` | `C` | `D` -- [x] 거래등급 타입 (TransactionGrade): `A` | `B` | `C` | `D` | `E` -- [x] 악성채권 상태 타입 (BadDebtStatus): `none` | `normal` | `warning` -- [x] 정렬 옵션 타입 (SortOption) -- [x] 거래처 인터페이스 (Vendor) - - id, vendorCode, businessNumber - - vendorName, representativeName - - category (sales/purchase/both) - - businessType, businessCategory - - address (zipCode, address1, address2) - - phone, mobile, fax, email - - managerName, managerPhone, systemManager - - logoUrl - - purchasePaymentDay, salesPaymentDay - - creditRating, transactionGrade - - taxInvoiceEmail, bankName, accountNumber, accountHolder - - outstandingAmount, overdueAmount, overdueDays - - unpaidAmount, badDebtStatus - - overdueToggle, badDebtToggle - - memos: Memo[] - - createdAt, updatedAt - -### 1.2 상수 정의 -- [x] VENDOR_CATEGORY_OPTIONS (구분 필터) -- [x] CREDIT_RATING_OPTIONS (신용등급 필터) -- [x] TRANSACTION_GRADE_OPTIONS (거래등급 필터) -- [x] BAD_DEBT_STATUS_OPTIONS (악성채권 필터) -- [x] SORT_OPTIONS (정렬 옵션) -- [x] PAYMENT_DAY_OPTIONS (결제일 1~31일) -- [x] BANK_OPTIONS (은행 목록) - ---- - -## Phase 2: 리스트 페이지 구현 ✅ - -### 2.1 페이지 라우트 생성 -- [x] `/src/app/[locale]/(protected)/accounting/vendors/page.tsx` 생성 - -### 2.2 VendorManagement 컴포넌트 -- [x] `/src/components/accounting/VendorManagement/index.tsx` 생성 - -#### 2.2.1 상단 통계 카드 (3개) -| 카드 | 값 | 아이콘 색상 | -|------|-----|------------| -| 전체 거래처 | {count}개 | blue | -| 매출 거래처 | {count}개 | green | -| 매입 거래처 | {count}개 | orange | - -#### 2.2.2 필터 조건 (5개 셀렉트박스) -| 필터 | 옵션 | 기본값 | -|------|------|--------| -| 구분 | 전체, 매출, 매입, 매입매출 | 전체 | -| 신용등급 | 전체, AAA, AA, A, BBB, BB, B, CCC, CC, C, D | 전체 | -| 거래등급 | 전체, A(우수), B(양호), C(보통), D(주의), E(위험) | 전체 | -| 악성채권 | 전체, 미설정, 정상 | 전체 | -| 정렬 | 최신순, 등록순, 거래처명 오름차순, 거래처명 내림차순, 미수금 높은순, 미수금 낮은순 | 최신순 | - -#### 2.2.3 테이블 컬럼 (체크박스 + 9개) -| 순서 | 컬럼명 | 정렬 | 비고 | -|------|--------|------|------| -| 0 | 체크박스 | center | - | -| 1 | 번호 | center | globalIndex + 1 | -| 2 | 구분 | center | Badge (매출/매입/매입매출) | -| 3 | 거래처명 | left | - | -| 4 | 매입 결제일 | center | {n}일 | -| 5 | 매출 결제일 | center | {n}일 | -| 6 | 신용등급 | center | Badge | -| 7 | 거래등급 | center | Badge | -| 8 | 미수금 | right | 금액 포맷 | -| 9 | 악성채권 | center | Badge or - | - -#### 2.2.4 행 선택 시 버튼 -- [x] 상세 버튼 → `/accounting/vendors/{id}` 이동 -- [x] 수정 버튼 → `/accounting/vendors/{id}?mode=edit` 이동 -- [x] 삭제 버튼 → 삭제 확인 AlertDialog -- [x] 취소 버튼 → 선택 해제 - -#### 2.2.5 헤더 액션 -- [x] 신규등록 버튼 → `/accounting/vendors/new` 이동 - ---- - -## Phase 3: 상세/수정/등록 페이지 구현 ✅ - -### 3.1 페이지 라우트 생성 -- [x] `/src/app/[locale]/(protected)/accounting/vendors/[id]/page.tsx` (상세/수정) -- [x] `/src/app/[locale]/(protected)/accounting/vendors/new/page.tsx` (신규등록) - -### 3.2 VendorDetail 컴포넌트 -- [x] `/src/components/accounting/VendorManagement/VendorDetail.tsx` 생성 -- [x] mode prop: `view` | `edit` | `new` - -#### 3.2.1 상단 버튼 -| 모드 | 버튼 | -|------|------| -| view | 삭제, 수정 | -| edit | 취소, 저장 | -| new | 취소, 등록 | - -#### 3.2.2 기본 정보 섹션 -| 필드명 | 타입 | 필수 | 비고 | -|--------|------|------|------| -| 사업자등록번호 | text (마스크: 000-00-00000) | * | - | -| 거래처코드 | text | - | 자동생성 또는 수동입력 | -| 거래처명 | text | * | - | -| 대표자명 | text | - | - | -| 거래처 유형 | select | * | 매출매입, 매출, 매입 | -| 업태 | text | - | - | -| 업종 | text | - | - | - -#### 3.2.3 연락처 정보 섹션 -| 필드명 | 타입 | 필수 | 비고 | -|--------|------|------|------| -| 주소 | address | - | 우편번호 찾기 + 기본주소 + 상세주소 | -| 전화번호 | tel | - | 02-0000-0000 | -| 모바일 | tel | - | 010-0000-0000 | -| 팩스 | tel | - | 02-0000-0000 | -| 이메일 | email | - | - | - -#### 3.2.4 담당자 정보 섹션 -| 필드명 | 타입 | 필수 | 비고 | -|--------|------|------|------| -| 담당자명 | text | - | - | -| 담당자 전화 | tel | - | - | -| 시스템 관리자 | text | - | - | - -#### 3.2.5 회사 정보 섹션 -| 필드명 | 타입 | 필수 | 비고 | -|--------|------|------|------| -| 회사 로고 | file | - | 750x250px, 10MB 이하, PNG/JPEG/GIF | -| 매입 결제일 | select | - | 1~31일 | -| 매출 결제일 | select | - | 1~31일 | - -#### 3.2.6 신용/거래 정보 섹션 -| 필드명 | 타입 | 필수 | 비고 | -|--------|------|------|------| -| 신용등급 | select | - | AAA~D | -| 거래등급 | select | - | A(우수)~E(위험) | -| 세금계산서 이메일 | email | - | - | -| 입금계좌 은행 | select | - | 은행 목록 | -| 계좌 | text | - | 숫자만 | -| 예금주 | text | - | - | - -#### 3.2.7 추가 정보 섹션 -| 필드명 | 타입 | 필수 | 비고 | -|--------|------|------|------| -| 미수금 | number | - | 금액 입력 | -| 연체 | number + toggle | - | 일수 + ON/OFF | -| 미지급 | number | - | 금액 입력 | -| 악성채권 | select + toggle | - | 선택 + ON/OFF (악성채권 추심관리 연동) | - -#### 3.2.8 메모 섹션 -| 필드명 | 타입 | 비고 | -|--------|------|------| -| 메모 | textarea + list | 추가 버튼으로 메모 리스트에 추가 | -| 메모 리스트 | table | 날짜, 내용, 삭제버튼 | - ---- - -## Phase 4: 다이얼로그 ✅ - -### 4.1 삭제 확인 다이얼로그 -- [x] AlertDialog 사용 -- [x] 제목: "거래처(명)을 삭제하시겠습니까?" -- [x] 설명: 확인 클릭 시 거래처관리 목록으로 이동 -- [x] 버튼: 취소, 삭제 - -### 4.2 수정 확인 다이얼로그 -- [x] AlertDialog 사용 -- [x] 제목: "정말 수정하시겠습니까?" -- [x] 설명: 확인 클릭 시 "수정이 완료되었습니다." 알림 -- [x] 버튼: 취소, 확인 - ---- - -## Phase 5: 파일 구조 ✅ - -``` -src/ -├── app/[locale]/(protected)/accounting/vendors/ -│ ├── page.tsx # 리스트 페이지 ✅ -│ ├── [id]/ -│ │ └── page.tsx # 상세/수정 페이지 ✅ -│ └── new/ -│ └── page.tsx # 신규등록 페이지 ✅ -└── components/accounting/VendorManagement/ - ├── index.tsx # 리스트 컴포넌트 ✅ - ├── VendorDetail.tsx # 상세/수정/등록 컴포넌트 ✅ - └── types.ts # 타입 및 상수 정의 ✅ -``` - ---- - -## 구현 완료 요약 - -### Step 1: 기본 구조 ✅ -- [x] types.ts 생성 및 타입/상수 정의 -- [x] 페이지 라우트 파일 생성 - -### Step 2: 리스트 페이지 ✅ -- [x] index.tsx 생성 -- [x] IntegratedListTemplateV2 활용 -- [x] 통계 카드 (3개) -- [x] 필터 (5개 셀렉트박스) -- [x] 테이블 (체크박스 + 번호 + 9개 컬럼) -- [x] 행 선택 시 버튼 (상세/수정/삭제/취소) -- [x] 삭제 확인 다이얼로그 - -### Step 3: 상세 페이지 ✅ -- [x] VendorDetail.tsx 생성 -- [x] 기본 정보 섹션 -- [x] 연락처 정보 섹션 -- [x] 담당자 정보 섹션 -- [x] 회사 정보 섹션 (로고 업로드 영역 포함) -- [x] 신용/거래 정보 섹션 -- [x] 추가 정보 섹션 (토글 포함) -- [x] 메모 섹션 - -### Step 4: 수정/등록 기능 ✅ -- [x] mode별 버튼 및 동작 구현 -- [x] 수정/삭제 확인 다이얼로그 - ---- - -## 테스트 URL - -| 페이지 | URL | -|--------|-----| -| 리스트 | `/ko/accounting/vendors` | -| 상세 | `/ko/accounting/vendors/vendor-1` | -| 수정 | `/ko/accounting/vendors/vendor-1?mode=edit` | -| 신규등록 | `/ko/accounting/vendors/new` | - ---- - -## 참고 사항 - -### 공통 컴포넌트 사용 -- IntegratedListTemplateV2 (리스트 템플릿) -- AlertDialog (삭제/수정 확인) -- Card, CardHeader, CardContent (섹션 구분) -- Select, Input, Textarea (폼 요소) -- Switch (토글) -- Badge (상태 표시) - -### 스타일 가이드 -- 주요 색상: orange (강조), blue/green (통계 카드) -- Badge 색상: - - 구분: 매출(green), 매입(orange), 매입매출(blue) - - 신용등급: AAA~A(green), BBB~B(yellow), CCC~D(red) - - 거래등급: A(green), B(blue), C(yellow), D(orange), E(red) - - 악성채권: 정상(green), 미설정(-), 경고(red) - -### 주의사항 -- 상세 페이지는 **페이지**로 구현 (모달 X) ✅ -- 테이블에 번호 컬럼 필수 (체크박스 바로 뒤) ✅ -- 금액은 toLocaleString() 포맷 사용 ✅ -- 행 클릭 시 상세 페이지로 이동 ✅ - ---- - -## 리스트 페이지 수정 (2025-12-18) - -### 수정 사항 -- [x] 악성채권 필터 옵션: "미설정" → "악성채권" 변경 -- [x] BadDebtStatus 타입: 'warning' → 'badDebt' 변경 -- [x] 작업 버튼: 상단 액션바 → 테이블 맨 끝 컬럼으로 이동 -- [x] 체크박스 선택 시에만 수정/삭제 버튼 표시 -- [x] headerActions (신규등록 버튼) 제거 -- [x] beforeTableContent (상단 액션 바) 제거 -- [x] 미사용 핸들러 정리 (handleNewVendor, handleCancelSelection) \ No newline at end of file diff --git a/claudedocs/accounting/[IMPL-2025-12-18] withdrawal-management-checklist.md b/claudedocs/accounting/[IMPL-2025-12-18] withdrawal-management-checklist.md deleted file mode 100644 index a6c39020..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-18] withdrawal-management-checklist.md +++ /dev/null @@ -1,142 +0,0 @@ -# 출금관리 (Withdrawal Management) 구현 체크리스트 - -> **상태**: ✅ 완료 (2025-12-18) -> **참고**: 입금관리(DepositManagement)와 동일한 구조 - -## 개요 -- **경로**: `/accounting/withdrawals` (리스트), `/accounting/withdrawals/[id]` (상세) -- **기능**: 출금 내역 조회, 수정 (계좌 관리에 등록된 계좌의 자동 출금 내역 수집) -- **참고**: 스크린샷 3장 - ---- - -## Phase 1: 타입 및 상수 정의 - -### 1.1 types.ts 생성 -- [ ] 출금 유형 타입 (WithdrawalType) - - `unset` (미설정) - - `purchasePayment` (매입대금) - - `advance` (선급금) - - `suspense` (가지급금) - - `rent` (임대료) - - `interestExpense` (이자비용) - - `depositPayment` (보증금 지급) - - `loanRepayment` (차입금 상환) - - `dividendPayment` (배당금 지급) - - `vatPayment` (부가세 납부) - - `salary` (급여) - - `insurance` (4대보험) - - `tax` (세금) - - `utilities` (공과금) - - `expenses` (경비) - - `other` (기타) - -- [ ] 정렬 옵션 (SortOption): 최신순, 등록순, 금액 높은순, 금액 낮은순 -- [ ] 출금 레코드 인터페이스 (WithdrawalRecord) - - id, withdrawalDate, withdrawalAmount - - accountName, recipientName (수취인명) - - note (적요), withdrawalType - - vendorId, vendorName - - createdAt, updatedAt - ---- - -## Phase 2: 리스트 페이지 구현 - -### 2.1 페이지 라우트 -- [ ] `/src/app/[locale]/(protected)/accounting/withdrawals/page.tsx` - -### 2.2 WithdrawalManagement 컴포넌트 -- [ ] `/src/components/accounting/WithdrawalManagement/index.tsx` - -#### 2.2.1 상단 통계 카드 (4개) -| 카드 | 값 | 아이콘 색상 | -|------|-----|------------| -| 출금 총액 | {amount}원 | blue | -| 당월 출금 | {amount}원 | green | -| 거래처 미설정 | {count}건 | orange | -| 출금유형 미설정 | {count}건 | red | - -#### 2.2.2 헤더 액션 -- [ ] DateRangeSelector (날짜 범위) -- [ ] 빠른 필터: 당해연도, 전년도, 전월, 당월, 어제, 오늘, 새로고침 - -#### 2.2.3 계정과목명 셀렉트 + 저장 버튼 -- [ ] 계정과목명 Select (출금 유형 옵션) -- [ ] 저장 버튼 → "N개의 출금 유형을 {계정과목명}으로 모두 변경하시겠습니까?" Alert - -#### 2.2.4 필터 (3개) -| 필터 | 옵션 | -|------|------| -| 거래처 | 전체, 거래처 목록 | -| 출금유형 | 전체, 매입대금, 선급금, 가지급금, 임대료, 이자비용, 보증금 지급, 차입금 상환, 배당금 지급, 부가세 납부, 급여, 4대보험, 세금, 공과금, 경비, 기타, 미설정 | -| 정렬 | 최신순, 등록순, 금액 높은순, 금액 낮은순 | - -#### 2.2.5 테이블 컬럼 (체크박스 + 7개 + 작업) -| 순서 | 컬럼명 | 정렬 | 비고 | -|------|--------|------|------| -| 0 | 체크박스 | center | - | -| 1 | 출금일 | center | - | -| 2 | 출금계좌 | left | - | -| 3 | 수취인명 | left | - | -| 4 | 출금금액 | right | 금액 포맷 | -| 5 | 거래처 | left | 미설정시 빨간색 | -| 6 | 출금유형 | center | Badge | -| 7 | 작업 | center | 선택 시 수정/삭제 버튼 | - -#### 2.2.6 테이블 합계 행 -- [ ] 출금금액 합계 - ---- - -## Phase 3: 상세 페이지 구현 - -### 3.1 페이지 라우트 -- [ ] `/src/app/[locale]/(protected)/accounting/withdrawals/[id]/page.tsx` - -### 3.2 WithdrawalDetail 컴포넌트 -- [ ] `/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx` -- [ ] mode prop: `view` | `edit` - -#### 3.2.1 상단 버튼 -| 모드 | 버튼 | -|------|------| -| view | 목록, 삭제, 수정 | -| edit | 취소, 저장 | - -#### 3.2.2 기본 정보 섹션 -| 필드명 | 타입 | 편집 가능 | 비고 | -|--------|------|----------|------| -| 출금일 | date | ❌ | 읽기 전용 | -| 출금계좌 | text | ❌ | 읽기 전용 | -| 수취인명 | text | ❌ | 읽기 전용 | -| 출금금액 | number | ❌ | 읽기 전용 | -| 적요 | select | ✅ | 선택 가능 | -| 거래처 | select | ✅ | 필수 | -| 출금 유형 | select | ✅ | 필수 | - ---- - -## Phase 4: 파일 구조 - -``` -src/ -├── app/[locale]/(protected)/accounting/withdrawals/ -│ ├── page.tsx # 리스트 페이지 -│ └── [id]/ -│ └── page.tsx # 상세/수정 페이지 -└── components/accounting/WithdrawalManagement/ - ├── index.tsx # 리스트 컴포넌트 - ├── WithdrawalDetail.tsx # 상세/수정 컴포넌트 - └── types.ts # 타입 및 상수 정의 -``` - ---- - -## 테스트 URL - -| 페이지 | URL | -|--------|-----| -| 리스트 | `/ko/accounting/withdrawals` | -| 상세 | `/ko/accounting/withdrawals/withdrawal-1` | -| 수정 | `/ko/accounting/withdrawals/withdrawal-1?mode=edit` | \ No newline at end of file diff --git a/claudedocs/accounting/[IMPL-2025-12-19] bad-debt-collection-management.md b/claudedocs/accounting/[IMPL-2025-12-19] bad-debt-collection-management.md deleted file mode 100644 index 4f3222a0..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-19] bad-debt-collection-management.md +++ /dev/null @@ -1,230 +0,0 @@ -# 악성채권 추심관리 구현 계획서 - -> 작성일: 2025-12-19 -> URL: `/ko/accounting/bad-debt-collection` - ---- - -## 📋 스크린샷 분석 요약 - -### 리스트 화면 -- **제목**: 악성채권 추심관리 -- **통계 카드 4개**: 총 악성채권, 추심중, 법적조치, 회수완료 -- **필터 3개**: - 1. 거래처 필터 (검색 + 다중선택, 디폴트: 전체) - 2. 상태 필터 (전체, 추심중, 법적조치, 회수완료, 대손처리) - 3. 정렬 (최신순, 등록순, 디폴트: 최신순) -- **테이블 컬럼**: No., 거래처, 채권금액, 발생일, 연체일수, 담당자, 상태, 설정, 작업 -- **설정 컬럼**: ON/OFF 토글 -- **작업 컬럼**: 수정/삭제 아이콘 - -### 상세 화면 (view/edit/new 모드) -1. **기본 정보**: 사업자등록번호, 거래처코드, 거래처명, 대표자명, 거래처유형(토글), 악성채권등록(업태/업종) -2. **연락처 정보**: 주소(우편번호 찾기), 전화번호, 모바일, 팩스, 이메일 -3. **담당자 정보**: 담당자명, 담당자전화, 시스템관리자 -4. **필요 서류**: 사업자등록증, 세금계산서, 추가서류 (파일 첨부) -5. **악성 채권 정보**: - - 미수금 + 상태 셀렉트 (추심중, 법적조치, 회수완료, 대손처리) - - 연체일수 + 본사 담당자 셀렉트 (부서명 이름 직급명 연락처) - - 악성채권 발생일 / 종료일 - - **수취 어음 현황 버튼** → 어음관리 화면 (해당 거래처 필터) - - **거래처 미수금 현황 버튼** → 미수금 현황 화면 (해당 거래처 하이라이트) -6. **메모**: 타임스탬프 형식 메모 목록 - ---- - -## ✅ 구현 체크리스트 - -### Phase 1: 파일 구조 및 타입 정의 -- [x] 1.1 `src/components/accounting/BadDebtCollection/types.ts` 생성 - - BadDebtRecord 인터페이스 - - CollectionStatus 타입 (추심중, 법적조치, 회수완료, 대손처리) - - SortOption 타입 - - 상태 라벨/컬러 상수 -- [x] 1.2 `src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx` 생성 -- [x] 1.3 `src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx` 생성 -- [x] 1.4 `src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx` 생성 -- [x] 1.5 `src/app/[locale]/(protected)/accounting/bad-debt-collection/new/page.tsx` 생성 - -### Phase 2: 리스트 컴포넌트 구현 -- [x] 2.1 `src/components/accounting/BadDebtCollection/index.tsx` 생성 - - IntegratedListTemplateV2 사용 -- [x] 2.2 통계 카드 4개 구현 (총 악성채권, 추심중, 법적조치, 회수완료) -- [x] 2.3 필터 3개 구현 - - 거래처 필터 (검색 + 다중선택) - - 상태 필터 (전체, 추심중, 법적조치, 회수완료, 대손처리) - - 정렬 (최신순, 등록순) -- [x] 2.4 테이블 컬럼 구현 (체크박스 + No. + 거래처 + 채권금액 + 발생일 + 연체일수 + 담당자 + 상태 + 설정 + 작업) - - No.: 순번 (1부터) - - 거래처: 거래처명 - - 채권금액: 금액 (원) - - 발생일: YYYY-MM-DD - - 연체일수: 숫자 + "일" - - 담당자: 담당자명 - - 상태: Badge (추심중/법적조치/회수완료/대손처리) - - 설정: ON/OFF 토글 (Switch) - - 작업: 수정/삭제 아이콘 (row 선택 시 표시) -- [x] 2.5 Mock 데이터 생성 함수 -- [x] 2.6 행 클릭 → 상세 페이지 이동 기능 -- [x] 2.7 모바일 카드 렌더링 - -### Phase 3: 상세/수정/등록 컴포넌트 구현 -- [x] 3.1 `src/components/accounting/BadDebtCollection/BadDebtDetail.tsx` 생성 - - mode prop (view/edit/new) -- [x] 3.2 기본 정보 섹션 - - 사업자등록번호 (Input, readonly) - - 거래처 코드 (Input, readonly) - - 거래처명 (Input) - - 대표자명 (Input) - - 거래처 유형 (Switch - 매출매입) - - 악성채권 등록 - 업태 (Input) - - 악성채권 등록 - 업종 (Input) -- [x] 3.3 연락처 정보 섹션 - - 주소 (우편번호 찾기 버튼 + Input 3개) - - 전화번호 (Input) - - 모바일 (Input) - - 팩스 (Input) - - 이메일 (Input) -- [x] 3.4 담당자 정보 섹션 - - 담당자명 (Input) - - 담당자 전화 (Input) - - 시스템 관리자 (Input, readonly) -- [x] 3.5 필요 서류 섹션 - - 사업자등록증 (파일 찾기) - - 세금계산서 (파일 찾기) - - 추가 서류 (파일 찾기 + 추가 버튼 + 삭제 X) -- [x] 3.6 악성 채권 정보 섹션 - - 미수금 (Input, readonly) - - 상태 (Select: 추심중, 법적조치, 회수완료, 대손처리) - - 연체일수 (Switch + Input) - - 본사 담당자 (Select: 부서명 이름 직급명 연락처) - - 악성채권 발생일 (DatePicker) - - 악성채권 종료일 (DatePicker) - - **수취 어음 현황 버튼** → `/ko/accounting/bills?vendorId={id}&type=received` - - **거래처 미수금 현황 버튼** → `/ko/accounting/receivables-status?highlight={id}` -- [x] 3.7 메모 섹션 - - 메모 목록 (Textarea, readonly) - - 메모 추가 기능 (타임스탬프 자동 생성) -- [x] 3.8 삭제/수정 버튼 및 다이얼로그 -- [x] 3.9 저장 기능 (mock) - -### Phase 4: 연동 기능 구현 -- [x] 4.1 어음관리 페이지 수정 - vendorId 쿼리 파라미터 지원 -- [x] 4.2 미수금 현황 페이지 수정 - highlight 쿼리 파라미터 지원 (해당 거래처 행 하이라이트) - -### Phase 5: 최종 검증 및 문서화 -- [x] 5.1 리스트 페이지 테스트 -- [x] 5.2 상세/수정/등록 페이지 테스트 -- [x] 5.3 어음관리 연동 테스트 -- [x] 5.4 미수금 현황 연동 테스트 -- [x] 5.5 모바일 반응형 테스트 -- [x] 5.6 `[REF] all-pages-test-urls.md` 업데이트 - ---- - -## 📁 파일 구조 - -``` -src/ -├── app/[locale]/(protected)/accounting/ -│ └── bad-debt-collection/ -│ ├── page.tsx # 리스트 페이지 -│ ├── new/ -│ │ └── page.tsx # 등록 페이지 -│ └── [id]/ -│ ├── page.tsx # 상세 페이지 -│ └── edit/ -│ └── page.tsx # 수정 페이지 -└── components/accounting/ - └── BadDebtCollection/ - ├── index.tsx # 리스트 컴포넌트 - ├── BadDebtDetail.tsx # 상세/수정/등록 컴포넌트 - └── types.ts # 타입 정의 -``` - ---- - -## 🔗 URL 구조 - -| 페이지 | URL | 비고 | -|--------|-----|------| -| 리스트 | `/ko/accounting/bad-debt-collection` | 메인 | -| 등록 | `/ko/accounting/bad-debt-collection/new` | | -| 상세 | `/ko/accounting/bad-debt-collection/[id]` | | -| 수정 | `/ko/accounting/bad-debt-collection/[id]/edit` | | - ---- - -## 📊 테이블 컬럼 상세 - -| 컬럼 | key | label | 정렬 | 비고 | -|------|-----|-------|------|------| -| 체크박스 | checkbox | - | center | Checkbox | -| 번호 | no | No. | center | 1부터 시작 | -| 거래처 | vendorName | 거래처 | left | | -| 채권금액 | debtAmount | 채권금액 | right | 1,000,000원 | -| 발생일 | occurrenceDate | 발생일 | center | YYYY-MM-DD | -| 연체일수 | overdueDays | 연체일수 | center | 100일 | -| 담당자 | managerName | 담당자 | left | | -| 상태 | status | 상태 | center | Badge | -| 설정 | settingToggle | 설정 | center | Switch | -| 작업 | actions | 작업 | center | 수정/삭제 | - ---- - -## 🎨 상태 Badge 스타일 - -| 상태 | 라벨 | 스타일 | -|------|------|--------| -| collecting | 추심중 | `border-orange-300 text-orange-600 bg-orange-50` | -| legalAction | 법적조치 | `border-red-300 text-red-600 bg-red-50` | -| recovered | 회수완료 | `border-green-300 text-green-600 bg-green-50` | -| badDebt | 대손처리 | `border-gray-300 text-gray-600 bg-gray-50` | - ---- - -## 🔍 필터 상세 - -### 1. 거래처 필터 -- **타입**: 검색 + 다중선택 (Combobox/MultiSelect) -- **옵션**: 전체, 거래처 목록 (API) -- **디폴트**: 전체 - -### 2. 상태 필터 -- **타입**: Select -- **옵션**: 전체, 추심중, 법적조치, 회수완료, 대손처리 -- **디폴트**: 악성채권 설정 시 디폴트 상태 - -### 3. 정렬 -- **타입**: Select -- **옵션**: 최신순, 등록순 -- **디폴트**: 최신순 - ---- - -## 🔗 연동 기능 - -### 수취 어음 현황 버튼 -- **대상**: 어음관리 페이지 (`/ko/accounting/bills`) -- **쿼리**: `?vendorId={id}&type=received` -- **동작**: 해당 거래처의 수취 어음만 필터링하여 표시 - -### 거래처 미수금 현황 버튼 -- **대상**: 미수금 현황 페이지 (`/ko/accounting/receivables-status`) -- **쿼리**: `?highlight={id}` -- **동작**: 해당 거래처 행을 하이라이트 표시 - ---- - -## 📝 작업 완료 후 체크 - -- [x] `claudedocs/[REF] all-pages-test-urls.md` 업데이트 -- [x] 빌드 오류 없음 확인 -- [x] 리스트/상세/수정/등록 페이지 정상 작동 -- [x] 연동 페이지 정상 작동 - ---- - -## ✅ 구현 완료 - -> 완료일: 2025-12-19 \ No newline at end of file diff --git a/claudedocs/accounting/[IMPL-2025-12-19] card-transaction-inquiry.md b/claudedocs/accounting/[IMPL-2025-12-19] card-transaction-inquiry.md deleted file mode 100644 index 572f81b5..00000000 --- a/claudedocs/accounting/[IMPL-2025-12-19] card-transaction-inquiry.md +++ /dev/null @@ -1,89 +0,0 @@ -# 카드 내역 조회 구현 체크리스트 - -## 개요 -- **페이지 경로**: `/ko/accounting/card-transactions` -- **참고**: 입출금 계좌조회 페이지와 유사한 구조 - -## 화면 구성 - -### 1. 상단 영역 -- 제목: "카드 내역 조회" -- 부제: "법인카드 사용 내역을 조회합니다" - -### 2. 날짜 선택 + 빠른 버튼 -- 날짜 범위 선택 -- 빠른 버튼: 당해년도, 전전월, 전월, 당월, 어제, 오늘 -- 새로고침 버튼 (출금관리 스타일, 오른쪽 위치) - -### 3. 요약 카드 (2개) -- 전월 사용액: 금액 표시 -- 당월 사용액: 금액 표시 - -### 4. 필터 영역 -- 검색 입력창 (좌측) -- 총 N건 표시 -- 필터 2개: - - 카드명 필터 (전체, 카드명 목록) - 디폴트: 전체 - - 정렬 필터 (최신순, 등록순, 금액 높은순, 금액 낮은순) - 디폴트: 최신순 - -### 5. 테이블 -- **체크박스 없음** -- **번호 컬럼 없음** -- 컬럼 순서: - 1. 카드 - 2. 카드명 - 3. 사용자 - 4. 사용일시 - 5. 가맹점명 - 6. 사용금액 - -### 6. 합계 행 -- 테이블 마지막에 합계 행 -- 사용금액 열에만 합계 표시 - ---- - -## 구현 체크리스트 - -### Phase 1: 파일 구조 생성 -- [x] types.ts 생성 (타입 정의) -- [x] index.tsx 생성 (메인 컴포넌트) -- [x] page.tsx 생성 (라우트) - -### Phase 2: 타입 정의 -- [x] CardTransaction 인터페이스 -- [x] SortOption 타입 - -### Phase 3: 컴포넌트 구현 -- [x] 헤더 영역 (제목, 부제) -- [x] 날짜 선택 + 빠른 버튼 -- [x] 새로고침 버튼 -- [x] 요약 카드 2개 (전월/당월 사용액) -- [x] 검색 + 필터 영역 (카드명, 정렬) -- [x] 테이블 (체크박스/번호 없음) -- [x] 합계 행 - -### Phase 4: 테스트 및 문서화 -- [x] all-pages-test-urls.md 업데이트 - -### Phase 5: 템플릿 기능 추가 -- [x] IntegratedListTemplateV2에 showCheckbox props 추가 -- [x] 조건부 체크박스 렌더링 구현 - ---- - -## Mock 데이터 예시 -```typescript -{ - card: '신한 1234', - cardName: '법인카드1', - user: '홍길동', - usedAt: '2025-12-12 12:12', - merchantName: '가맹점명', - amount: 100000 -} -``` - -## 참고 파일 -- `src/components/accounting/BankTransactionInquiry/index.tsx` -- `src/components/accounting/BankTransactionInquiry/types.ts` \ No newline at end of file diff --git a/claudedocs/accounting/[PLAN-2025-12-18] sales-management.md b/claudedocs/accounting/[PLAN-2025-12-18] sales-management.md deleted file mode 100644 index 86032a66..00000000 --- a/claudedocs/accounting/[PLAN-2025-12-18] sales-management.md +++ /dev/null @@ -1,270 +0,0 @@ -# [PLAN-2025-12-18] 매출관리 페이지 구현 계획서 - -## 개요 -- **목표**: 회계관리 > 매출관리 페이지 구현 -- **참조**: 기안함(DraftBox) 구조 기반 -- **페이지 수**: 2개 (리스트 + 상세/등록) - ---- - -## 1. 리스트 페이지 (매출관리) - -### 1.1 페이지 구조 -- [ ] 경로: `/accounting/sales` -- [ ] 컴포넌트: `src/components/accounting/SalesManagement/index.tsx` -- [ ] IntegratedListTemplateV2 사용 - -### 1.2 헤더 영역 -- [ ] DateRangeSelector (공통 달력) -- [ ] 상태 필터 버튼들 - - [ ] 당월마감 - - [ ] 전월 - - [ ] 합의 - - [ ] 미수 - - [ ] 전체 -- [ ] 매출 등록 버튼 (클릭 시 등록 페이지로 이동) - -### 1.3 통계 카드 (4개) -- [ ] 매출금액 합계 (예: 3,123,000원) -- [ ] 입금금액 합계 (예: 3,123,000원) -- [ ] 미수건수 (예: 3건) -- [ ] 전체건수 (예: 4건) - -### 1.4 필터 셀렉트 박스들 -| 필터명 | 설명 | 옵션 | 기본값 | -|--------|------|------|--------| -| 계정과목별 | 계정과목명으로 분류 | 전체, 외상 매출, 상품 매출, 부품 매출, 공사 매출, 임대 수익, 기타 매출 | 제품 매출 | -| 거래처 필터 | 거래처별 검색/필터 | 거래처 검색 (검색 가능) | - | -| 매출유형 필터 | 다중 선택 가능 | 외상 매출, 상품 매출, 부품 매출, 공사 매출, 임대 수익, 기타 매출 | 전체 | -| 정렬 | 단독 선택 | 최신순, 금액 높은 순, 금액 낮은 순 | 최신순 | - -### 1.5 테이블 컬럼 -| 컬럼 | 설명 | -|------|------| -| 체크박스 | 다중 선택 | -| 번호 | 순번 | -| 매출번호 | 자동 채번 (포맷: 조합+자동생성) | -| 매출일 | 날짜 | -| 거래처명 | 거래처 | -| 결제대면(?) | 결제 방식 | -| 매출금액 | 금액 | -| 미수금액 | 미수 금액 | -| 비고 | 적요 | -| 작업 | 수정/삭제 아이콘 | - -### 1.6 기능 -- [ ] 검색 기능 (매출번호, 거래처명, 적요) -- [ ] 페이지네이션 (20건씩) -- [ ] 체크박스 전체/개별 선택 -- [ ] 행 클릭 → 상세 페이지 이동 - ---- - -## 2. 상세/등록 페이지 (매출 상세) - -### 2.1 페이지 구조 -- [ ] 경로: `/accounting/sales/[id]` (상세/수정) -- [ ] 경로: `/accounting/sales/new` (신규 등록) -- [ ] 컴포넌트: `src/components/accounting/SalesManagement/SalesDetail.tsx` - -### 2.2 헤더 영역 -- [ ] 페이지 제목: "매출 상세" 또는 "매출 상세_직접 등록" -- [ ] 버튼 - - [ ] 삭제 버튼 (신규 등록 시) - - [ ] 수정 버튼 - -### 2.3 기본 정보 섹션 -| 필드 | 타입 | 설명 | -|------|------|------| -| 매출번호 | 텍스트 (읽기전용/자동채번) | 수정 시 표시, 신규 시 자동 채번 | -| 매출일 | DatePicker | 날짜 선택 | -| 거래처명 | Select (검색 가능) | 거래처 선택 | -| 매출 유형 | Select | 외상 매출, 상품 매출, 부품 매출, 공사 매출, 임대 수익, 기타 매출 (기본: 제품 매출) | - -### 2.4 품목 정보 섹션 -- [ ] 테이블 형태의 품목 입력 -| 컬럼 | 타입 | 설명 | -|------|------|------| -| 번호 | 자동 | 순번 | -| 품목명 | Select/Input | 품목 선택 또는 입력 | -| 수량 | Number | 수량 입력 | -| 단가 | Number | 단가 입력 | -| 공급가액 | Number (계산) | 수량 × 단가 자동 계산 | -| 부가세 | Number (계산) | 공급가액 × 10% 자동 계산 | -| 적요 | Text | 메모 | -| 삭제 | Button | 행 삭제 (X 버튼) | - -- [ ] 합계 행: 공급가액 합계, 부가세 합계 -- [ ] 추가 버튼: 품목 행 추가 - -### 2.5 세금계산서 섹션 -- [ ] 세금계산서 발행 토글 (Switch) - - 클릭 시: 미발행 ↔ 발행완료 토글 - - 세금계산서 수동 발행 후 발행 상태로 변경 - -### 2.6 거래명세서 섹션 -- [ ] 거래명세서 발행 토글 (Switch) - - 클릭 시: 미발행 ↔ 발행완료 토글 -- [ ] 거래명세서 발행하기 버튼 - - 클릭 시: 거래처 이메일로 자동 발송 - - Alert: "거래명세서가 'abc@email.com'으로 발송되었습니다" - - 발행 후 자동으로 발행 상태 변경 - ---- - -## 3. 파일 구조 - -``` -src/ -├── app/[locale]/(protected)/accounting/ -│ └── sales/ -│ ├── page.tsx # 리스트 페이지 -│ ├── [id]/ -│ │ └── page.tsx # 상세/수정 페이지 -│ └── new/ -│ └── page.tsx # 신규 등록 페이지 -│ -└── components/accounting/ - └── SalesManagement/ - ├── index.tsx # 리스트 컴포넌트 - ├── SalesDetail.tsx # 상세/등록 컴포넌트 - ├── SalesItemTable.tsx # 품목 테이블 컴포넌트 - ├── TaxInvoiceSection.tsx # 세금계산서 섹션 - ├── TransactionStatementSection.tsx # 거래명세서 섹션 - └── types.ts # 타입 정의 -``` - ---- - -## 4. 타입 정의 (types.ts) - -```typescript -// 매출 레코드 -interface SalesRecord { - id: string; - salesNo: string; // 매출번호 - salesDate: string; // 매출일 - vendorId: string; // 거래처 ID - vendorName: string; // 거래처명 - salesType: SalesType; // 매출 유형 - items: SalesItem[]; // 품목 목록 - totalSupplyAmount: number; // 공급가액 합계 - totalVat: number; // 부가세 합계 - totalAmount: number; // 총 금액 - receivedAmount: number; // 입금액 - outstandingAmount: number; // 미수금액 - taxInvoiceIssued: boolean; // 세금계산서 발행 여부 - transactionStatementIssued: boolean; // 거래명세서 발행 여부 - note: string; // 비고 - status: SalesStatus; // 상태 - createdAt: string; - updatedAt: string; -} - -// 매출 유형 -type SalesType = - | 'credit' // 외상 매출 - | 'product' // 제품 매출 - | 'goods' // 상품 매출 - | 'parts' // 부품 매출 - | 'construction'// 공사 매출 - | 'rental' // 임대 수익 - | 'other'; // 기타 매출 - -// 매출 품목 -interface SalesItem { - id: string; - itemName: string; // 품목명 - quantity: number; // 수량 - unitPrice: number; // 단가 - supplyAmount: number; // 공급가액 - vat: number; // 부가세 - note: string; // 적요 -} - -// 매출 상태 -type SalesStatus = - | 'monthlyClose' // 당월마감 - | 'lastMonth' // 전월 - | 'agreed' // 합의 - | 'outstanding' // 미수 - | 'all'; // 전체 - -// 필터 옵션 -type FilterOption = 'all' | 'monthlyClose' | 'lastMonth' | 'agreed' | 'outstanding'; - -// 정렬 옵션 -type SortOption = 'latest' | 'amountHigh' | 'amountLow'; -``` - ---- - -## 5. 구현 체크리스트 - -### Phase 1: 기본 구조 설정 -- [ ] 폴더 구조 생성 -- [ ] 라우트 페이지 생성 (page.tsx들) -- [ ] types.ts 작성 - -### Phase 2: 리스트 페이지 구현 -- [ ] SalesManagement/index.tsx 생성 -- [ ] IntegratedListTemplateV2 연동 -- [ ] DateRangeSelector 연동 -- [ ] 상태 필터 버튼 구현 -- [ ] 통계 카드 구현 -- [ ] 필터 셀렉트 박스들 구현 -- [ ] 테이블 렌더링 구현 -- [ ] 모바일 카드 렌더링 구현 -- [ ] 페이지네이션 구현 -- [ ] 검색 기능 구현 -- [ ] Mock 데이터 생성 - -### Phase 3: 상세/등록 페이지 구현 -- [ ] SalesDetail.tsx 생성 -- [ ] 기본 정보 섹션 구현 -- [ ] SalesItemTable.tsx 구현 (품목 테이블) - - [ ] 품목 행 추가/삭제 - - [ ] 자동 계산 (공급가액, 부가세) - - [ ] 합계 행 -- [ ] TaxInvoiceSection.tsx 구현 -- [ ] TransactionStatementSection.tsx 구현 -- [ ] 폼 유효성 검사 -- [ ] 저장/수정 로직 - -### Phase 4: 연동 및 테스트 -- [ ] 리스트 ↔ 상세 페이지 연결 -- [ ] 신규 등록 플로우 확인 -- [ ] 수정 플로우 확인 -- [ ] 반응형 레이아웃 확인 - ---- - -## 6. 참고 사항 - -### 기안함(DraftBox)에서 참고할 패턴 -- IntegratedListTemplateV2 사용법 -- DateRangeSelector 연동 -- 필터/정렬 셀렉트 박스 패턴 -- 테이블/모바일 카드 렌더링 -- 체크박스 선택 관리 -- 페이지네이션 - -### 주의 사항 -1. 매출번호 자동 채번 로직 확인 필요 (API 연동 시) -2. 거래처 목록 API 연동 필요 -3. 품목 목록 API 연동 필요 -4. 세금계산서/거래명세서 발행 API 연동 필요 - ---- - -## 7. 예상 작업 시간 -- Phase 1: 기본 구조 설정 -- Phase 2: 리스트 페이지 구현 -- Phase 3: 상세/등록 페이지 구현 -- Phase 4: 연동 및 테스트 - ---- - -**작성일**: 2025-12-18 -**작성자**: Claude -**상태**: 계획 검토 대기 \ No newline at end of file diff --git a/claudedocs/accounting/[PLAN-2025-12-19] bank-account-transaction-inquiry.md b/claudedocs/accounting/[PLAN-2025-12-19] bank-account-transaction-inquiry.md deleted file mode 100644 index b76a92ac..00000000 --- a/claudedocs/accounting/[PLAN-2025-12-19] bank-account-transaction-inquiry.md +++ /dev/null @@ -1,204 +0,0 @@ -# [PLAN-2025-12-19] 입출금 계좌조회 페이지 구현 계획서 - -> **작성일**: 2025-12-19 -> **상태**: 📋 대기 (사용자 확인 필요) -> **참조**: DepositManagement, IntegratedListTemplateV2 - ---- - -## 1. 페이지 개요 - -| 항목 | 내용 | -|------|------| -| **페이지명** | 입출금 계좌조회 | -| **설명** | 은행 계좌 정보와 입출금 내역을 조회할 수 있습니다 | -| **URL** | `/ko/accounting/bank-transactions` | -| **아이콘** | `Building2` (은행) 또는 `Wallet` | - ---- - -## 2. UI 구성 분석 (스크린샷 기준) - -### 2.1 헤더 영역 -- [x] 페이지 타이틀: "입출금 계좌조회" -- [x] 설명: "은행 계좌 정보와 입출금 내역을 조회할 수 있습니다" - -### 2.2 필터 영역 (DateRangeSelector 확장) -- [ ] 기간 선택: 시작일 ~ 종료일 (DateRangeSelector 컴포넌트) -- [ ] 탭 버튼 그룹: - - `전체(선택)` - 전체 입출금 내역 - - `입금/수입` - 입금만 필터 - - `출금` - 출금만 필터 - - `입금` - (중복인지 확인 필요, 스크린샷 확인) - - `어제` - 어제 날짜 빠른 선택 - - `오늘` - 오늘 날짜 빠른 선택 - - `새로고침` - 데이터 새로고침 버튼 - -### 2.3 통계 카드 (4개) -| 순서 | 라벨 | 값 예시 | 아이콘 색상 | -|------|-----------|---------|-------------| -| 1 | 입금 | 3,123,000원 | 🔵 blue | -| 2 | 출금 | 3,123,000원 | 🔴 red | -| 3 | 입금 유형 미설정 | 4건 | 🟢 green | -| 4 | 출금 유형 미설정 | 4건 | 🟠 orange | - -### 2.4 검색 영역 -- [ ] 검색창 (은행명, 계좌번호, 거래처, 비고 검색) - -### 2.5 테이블 컬럼 (14개) -| 순서 | 컬럼명 | key | 정렬 | 비고 | -|------|--------|-----|------|------| -| 1 | 체크박스 | checkbox | center | 선택용 | -| 2 | 번호 | no | center | globalIndex | -| 3 | 은행명 | bankName | left | | -| 4 | 계좌명 | accountName | left | | -| 5 | 거래일시 | transactionDate | center | | -| 6 | 구분 | type | center | 입금/출금 Badge | -| 7 | 적요 | note | left | | -| 8 | 거래처 | vendorName | left | | -| 9 | 입금자/수취인 | depositorName | left | | -| 10 | 입금 | depositAmount | right | 숫자 포맷 | -| 11 | 출금 | withdrawalAmount | right | 숫자 포맷 | -| 12 | 잔액 | balance | right | 숫자 포맷 | -| 13 | 입출금 유형 | transactionType | center | Badge | -| 14 | 작업 | actions | center | 수정 버튼 (체크 시 표시) | - -### 2.6 테이블 합계 행 -- [ ] 합계 행 표시 (입금액 합계, 출금액 합계) - -### 2.7 수정 버튼 동작 -- [ ] 수정 버튼 클릭 시: - - 입금 데이터 → `/ko/accounting/deposits/{id}?mode=edit` 이동 - - 출금 데이터 → `/ko/accounting/withdrawals/{id}?mode=edit` 이동 - ---- - -## 3. 구현 체크리스트 - -### Phase 1: 기본 구조 설정 -- [ ] 1.1 페이지 라우트 생성 (`/accounting/bank-transactions/page.tsx`) -- [ ] 1.2 컴포넌트 폴더 생성 (`/components/accounting/BankTransactionInquiry/`) -- [ ] 1.3 types.ts 작성 (타입 정의) -- [ ] 1.4 index.tsx 기본 구조 작성 - -### Phase 2: 타입 정의 -- [ ] 2.1 `BankTransaction` 인터페이스 정의 - ```typescript - interface BankTransaction { - id: string; - bankName: string; // 은행명 - accountName: string; // 계좌명 - transactionDate: string; // 거래일시 - type: 'deposit' | 'withdrawal'; // 구분 (입금/출금) - note?: string; // 적요 - vendorId?: string; // 거래처 ID - vendorName?: string; // 거래처명 - depositorName?: string; // 입금자/수취인 - depositAmount: number; // 입금 - withdrawalAmount: number; // 출금 - balance: number; // 잔액 - transactionType?: string; // 입출금 유형 - sourceId: string; // 원본 입금/출금 ID (상세 이동용) - } - ``` -- [ ] 2.2 필터 타입 정의 -- [ ] 2.3 정렬 옵션 정의 - -### Phase 3: 통계 카드 구현 -- [ ] 3.1 총 입금 카드 -- [ ] 3.2 총 출금 카드 -- [ ] 3.3 입금 건 카드 -- [ ] 3.4 출금 건 카드 - -### Phase 4: 필터 영역 구현 -- [ ] 4.1 DateRangeSelector 연동 -- [ ] 4.2 탭 버튼 그룹 구현 (전체/입금/수입/출금/어제/오늘) -- [ ] 4.3 새로고침 버튼 -- [ ] 4.4 빠른 날짜 선택 (어제/오늘) 로직 - -### Phase 5: 테이블 구현 -- [ ] 5.1 IntegratedListTemplateV2 연동 -- [ ] 5.2 테이블 컬럼 정의 (14개 컬럼) -- [ ] 5.3 체크박스 기능 -- [ ] 5.4 번호 컬럼 (globalIndex 사용) -- [ ] 5.5 구분 컬럼 Badge (입금: 파랑, 출금: 빨강) -- [ ] 5.6 금액 컬럼 숫자 포맷팅 -- [ ] 5.7 합계 행 구현 (tableFooter) -- [ ] 5.8 작업 컬럼 (체크 시 수정 버튼 표시) - -### Phase 6: 상세 이동 로직 -- [ ] 6.1 수정 버튼 클릭 핸들러 -- [ ] 6.2 type에 따른 분기 처리 - - deposit → `/ko/accounting/deposits/{sourceId}?mode=edit` - - withdrawal → `/ko/accounting/withdrawals/{sourceId}?mode=edit` - -### Phase 7: Mock 데이터 및 테스트 -- [ ] 7.1 Mock 데이터 생성 함수 -- [ ] 7.2 필터링 로직 테스트 -- [ ] 7.3 정렬 로직 테스트 -- [ ] 7.4 페이지네이션 테스트 - -### Phase 8: 마무리 -- [ ] 8.1 모바일 카드 뷰 구현 -- [ ] 8.2 코드 정리 및 최적화 -- [ ] 8.3 테스트 URL 문서 업데이트 (`[REF] all-pages-test-urls.md`) - ---- - -## 4. 파일 구조 - -``` -src/ -├── app/[locale]/(protected)/accounting/ -│ └── bank-transactions/ -│ └── page.tsx # 페이지 라우트 -└── components/accounting/ - └── BankTransactionInquiry/ - ├── index.tsx # 메인 컴포넌트 - └── types.ts # 타입 정의 -``` - ---- - -## 5. 참고 사항 - -### 5.1 기존 컴포넌트 재사용 -- `IntegratedListTemplateV2` - 리스트 템플릿 -- `DateRangeSelector` - 날짜 범위 선택 -- `StatCard` - 통계 카드 -- `ListMobileCard` - 모바일 카드 - -### 5.2 스크린샷 Description 정보 참고 -- 필터 탭: 전체(선택), 입금/수입, 출금, 입금, 어제, 오늘 -- 수정 버튼 클릭 시 입금/출금 상세 화면으로 이동 -- 종류(정렬): 취입선, 등록순, 입력순 - -### 5.3 레이아웃 일치 확인 사항 -- DepositManagement와 동일한 레이아웃 구조 사용 -- 카드 4개 가로 배치 -- 테이블 합계 행 스타일 일치 - ---- - -## 6. 완료 후 작업 - -- [ ] `claudedocs/[REF] all-pages-test-urls.md` 업데이트 - ```markdown - | 입출금 계좌조회 | `/ko/accounting/bank-transactions` | 🆕 NEW | - ``` - ---- - -## 7. 예상 소요 시간 - -| Phase | 작업 | 예상 | -|-------|------|------| -| 1-2 | 기본 구조 + 타입 | - | -| 3-4 | 카드 + 필터 | - | -| 5 | 테이블 | - | -| 6-7 | 상세 이동 + Mock | - | -| 8 | 마무리 | - | - ---- - -**확인 후 작업 시작하겠습니다!** \ No newline at end of file diff --git a/claudedocs/accounting/[PLAN-2026-01-23] vendor-credit-analysis-modal.md b/claudedocs/accounting/[PLAN-2026-01-23] vendor-credit-analysis-modal.md deleted file mode 100644 index 18e65216..00000000 --- a/claudedocs/accounting/[PLAN-2026-01-23] vendor-credit-analysis-modal.md +++ /dev/null @@ -1,103 +0,0 @@ -# 신규 거래처 신용분석 모달 - -## 개요 -- **목적**: 신규 거래처 등록 시 국가관리 API를 통해 받아온 기업 신용정보를 표시 -- **위치**: 거래처 등록 완료 후 모달로 표시 -- **현재 단계**: 목업 데이터로 UI 구현 (추후 API 연동) - -## 화면 구성 - -### 1. 헤더 -- 로고 + "SAM 기업 신용분석 리포트" -- 조회일시 표시 - -### 2. 기업 정보 -- "신규거래 신용정보 조회" 뱃지 -- "기업 신용 분석" 제목 -- 사업자번호, 법인명 (대표자명), 평가기준일 정보 - -### 3. 자료 효력기간 안내 -- 노란 배경의 알림 박스 -- 데이터 유효기간 및 면책 안내 - -### 4. 종합 신용 신호등 -- 5단계 신호등 표시 (Level 1~5) -- 현재 레벨 강조 (예: 양호 Level 4) -- 신용 등급 설명 텍스트 -- "유료 상세 분석 제공받기" 버튼 - -### 5. 신용 리스크 프로필 -- 오각형 레이더 차트 - - 한국신용평가등급 - - 금융 종합 위험도 - - 매입 결제 - - 매출 결제 - - 저당권설정 - -### 6. 신용 상세 정보 -- 신용채무정보 버튼 -- 신용등급추이정보 버튼 -- 정보 없음 안내 텍스트 - -### 7. 하단 거래 승인 판정 -- 안전/위험 배지 -- 신용등급 (Level 1~5) -- 거래 유형 (계속사업자/신규거래 등) -- 외상 가능 여부 -- "거래 승인 완료" 버튼 - -## 데이터 구조 - -```typescript -interface CreditAnalysisData { - // 기업 정보 - businessNumber: string; // 사업자번호 - companyName: string; // 법인명 - representativeName: string; // 대표자명 - evaluationDate: string; // 평가기준일 - - // 신용 등급 - creditLevel: 1 | 2 | 3 | 4 | 5; // 1: 위험, 5: 최우량 - creditStatus: '위험' | '주의' | '보통' | '양호' | '우량'; - - // 리스크 프로필 (0~100) - riskProfile: { - koreaCreditRating: number; // 한국신용평가등급 - financialRisk: number; // 금융 종합 위험도 - purchasePayment: number; // 매입 결제 - salesPayment: number; // 매출 결제 - mortgageSetting: number; // 저당권설정 - }; - - // 거래 승인 판정 - approval: { - safety: '안전' | '주의' | '위험'; - level: number; - businessType: string; // 계속사업자, 신규거래 등 - creditAvailable: boolean; // 외상 가능 여부 - }; -} -``` - -## 파일 구조 - -``` -src/components/accounting/VendorManagement/ -├── CreditAnalysisModal.tsx # 신용분석 모달 컴포넌트 -└── CreditAnalysisModal/ - ├── index.tsx # 메인 모달 - ├── CreditSignal.tsx # 신용 신호등 컴포넌트 - ├── RiskRadarChart.tsx # 레이더 차트 컴포넌트 - └── types.ts # 타입 정의 - -src/app/[locale]/(protected)/dev/ -└── credit-analysis-test/ - └── page.tsx # 테스트 페이지 -``` - -## 구현 순서 - -1. [x] 계획 md 파일 작성 -2. [ ] CreditAnalysisModal 컴포넌트 생성 -3. [ ] 테스트 페이지 생성 -4. [ ] dev/test-urls에 URL 추가 diff --git a/claudedocs/api/[API-2026-03-10] calendar-bill-integration.md b/claudedocs/api/[API-2026-03-10] calendar-bill-integration.md deleted file mode 100644 index 74e235b1..00000000 --- a/claudedocs/api/[API-2026-03-10] calendar-bill-integration.md +++ /dev/null @@ -1,45 +0,0 @@ -# 어음 만기일 캘린더 연동 - -**날짜**: 2026-03-10 -**범위**: Backend (CalendarService) + Frontend (CalendarSection) - -## 변경 요약 - -대시보드 캘린더에 어음(Bill) 만기일을 5번째 데이터 소스로 추가. -기존 4개 소스(작업지시, 계약, 휴가, 범용일정)와 동일한 패턴. - -## Backend 변경 - -### `app/Services/CalendarService.php` - -- `use App\Models\Tenants\Bill` import 추가 -- `getSchedules()`: `$type === 'bill'` 필터 조건 및 merge 추가 -- `getBillSchedules()` 메서드 신규: - - `maturity_date` 기준 날짜 범위 필터 - - `paymentComplete`, `dishonored` 상태 제외 - - 아이템 형식: `bill_{id}`, `[만기] {거래처명} {금액}원` - - `type: 'bill'`, `isAllDay: true` - -## Frontend 변경 - -### `src/lib/api/dashboard/types.ts` -- `CalendarScheduleType`에 `'bill'` 추가 - -### `src/components/business/CEODashboard/types.ts` -- `CalendarScheduleItem.type`에 `'bill'` 추가 -- `CalendarTaskFilterType`에 `'bill'` 추가 - -### `src/components/business/CEODashboard/sections/CalendarSection.tsx` -- `SCHEDULE_TYPE_COLORS`: `bill: 'amber'` -- `SCHEDULE_TYPE_LABELS`: `bill: '어음'` -- `SCHEDULE_TYPE_BADGE_COLORS`: `bill: amber 배지 스타일` -- `TASK_FILTER_OPTIONS`: `{ value: 'bill', label: '어음' }` -- `ExtendedTaskFilterType`: `'bill'` 추가 -- 모바일 리스트뷰 `colorMap`: `bill: 'bg-amber-500'` - -## 검증 방법 - -1. 대시보드 캘린더에서 어음 만기일이 amber 색상 점으로 표시되는지 확인 -2. 캘린더 필터에서 "어음" 선택 시 어음 일정만 필터링되는지 확인 -3. 어음 만기일 클릭 시 `[만기] 거래처명 금액원` 형식으로 표시되는지 확인 -4. 기존 일정(일정/발주/시공/기타) 정상 동작 확인 diff --git a/claudedocs/api/[API-REQ-2026-03-03] expected-expenses-dashboard-detail-date-filter.md b/claudedocs/api/[API-REQ-2026-03-03] expected-expenses-dashboard-detail-date-filter.md deleted file mode 100644 index 1aeab278..00000000 --- a/claudedocs/api/[API-REQ-2026-03-03] expected-expenses-dashboard-detail-date-filter.md +++ /dev/null @@ -1,52 +0,0 @@ -# 백엔드 API 수정 요청: 당월 예상 지출 상세 - 날짜 범위 필터링 - -## 엔드포인트 -`GET /api/v1/expected-expenses/dashboard-detail` - -## 현재 상태 -- `transaction_type` 파라미터만 지원 (purchase, card, bill) -- `start_date`, `end_date` 파라미터를 **무시**함 -- `items` 배열이 항상 **당월(현재 월)** 기준으로만 반환됨 -- `summary`도 당월 기준 고정 (total_amount, change_rate 등) -- `monthly_trend`만 여러 월 데이터 포함 (최근 7개월) - -## 요청 내용 - -### 1. 날짜 범위 필터 지원 추가 -``` -GET /api/v1/expected-expenses/dashboard-detail?transaction_type=purchase&start_date=2026-01-01&end_date=2026-01-31 -``` - -| 파라미터 | 타입 | 설명 | 기본값 | -|---------|------|------|--------| -| `start_date` | string (yyyy-MM-dd) | 조회 시작일 | 당월 1일 | -| `end_date` | string (yyyy-MM-dd) | 조회 종료일 | 당월 말일 | -| `search` | string | 거래처/항목 검색 | (없음) | - -### 2. 기대 동작 -- `items`: `start_date` ~ `end_date` 범위의 거래 내역만 반환 -- `summary.total_amount`: 해당 기간의 합계 -- `summary.change_rate`: 해당 기간 vs 직전 동일 기간 비교 -- `vendor_distribution`: 해당 기간 기준 분포 -- `footer_summary`: 해당 기간 기준 합계 -- `monthly_trend`: 변경 불필요 (기존처럼 최근 7개월 유지) - -### 3. 검색 필터 (선택) -- `search` 파라미터로 거래처명/항목명 부분 검색 - -## 검증 데이터 -현재 `monthly_trend` 기준 데이터가 있는 월: -- 11월: 14,101,865원 -- 12월: 35,241,935원 -- 1월: 3,000,000원 -- 2월: 1,650,000원 - -`start_date=2026-01-01&end_date=2026-01-31` 조회 시: -- `items`: 1월 거래 내역 (현재 빈 배열) -- `summary.total_amount`: 3,000,000 (현재 0) - -## 프론트엔드 준비 상태 -- 프록시: 쿼리 파라미터 정상 전달 확인 -- 훅: `fetchData(cardId, { startDate, endDate, search })` 지원 -- 모달: 조회 버튼 + 날짜 필터 UI 완료 -- 백엔드 수정만 되면 즉시 동작 diff --git a/claudedocs/api/[API-SPEC-2026-03-03] ceo-dashboard-backend-api.md b/claudedocs/api/[API-SPEC-2026-03-03] ceo-dashboard-backend-api.md deleted file mode 100644 index 44b2d5de..00000000 --- a/claudedocs/api/[API-SPEC-2026-03-03] ceo-dashboard-backend-api.md +++ /dev/null @@ -1,821 +0,0 @@ -# CEO Dashboard 백엔드 API 명세서 - -**작성일**: 2026-03-03 -**기획서**: SAM_ERP_Storyboard_D1.7_260227.pdf p33~60 -**프론트엔드 타입**: `src/lib/api/dashboard/types.ts` -**대상**: 백엔드 팀 (Laravel sam-api) - ---- - -## 공통 규칙 - -### 응답 형식 -```json -{ - "success": true, - "message": "조회 성공", - "data": { ... } -} -``` - -### 인증 -- 모든 API는 `Authorization: Bearer {access_token}` 필수 -- Next.js API route 프록시(`/api/proxy/...`) 경유 - -### 캐싱 -- `sam_stat` 테이블 5분 캐시 (기존 구현 유지) -- 대시보드 API는 실시간성보다 성능 우선 - -### 날짜/기간 파라미터 규칙 -- 날짜: `YYYY-MM-DD` (예: `2026-03-03`) -- 월: `YYYY-MM` (예: `2026-03`) -- 분기: `year=2026&quarter=1` -- 기본값: 파라미터 미지정 시 **당월/당분기** 기준 - ---- - -## 검수 중 발견된 누락 API - -### N1. 오늘의 이슈 — 과거 이력 저장 및 조회 -**우선순위**: 상 -**페이지**: p34 -**현상**: `GET /api/v1/today-issues/summary?date=2026-02-17` 호출 시 항상 `{"items":[], "total_count":0}` 반환. 과거 이슈를 저장하는 구조가 없어서 이전 이슈 탭이 항상 빈 목록. - -**요구사항**: -1. **이슈 이력 테이블** 필요 (예: `dashboard_issue_history`) - - 매일 자정(또는 배치) 시점에 당일 이슈 스냅샷 저장 - - 또는 이슈 발생 시점에 이력 테이블에 INSERT -2. **기존 API 수정**: `GET /api/v1/today-issues/summary` - - `date` 파라미터가 있을 때 해당 날짜의 이력 데이터 반환 - - `date` 파라미터가 없으면 기존대로 실시간 집계 - -**Response** (기존 `TodayIssueApiResponse`와 동일): -```json -{ - "items": [ - { - "id": "issue-20260302-001", - "badge": "수주", - "notification_type": "sales_order", - "content": "대한건설 수주 3건 접수", - "time": "14:30", - "date": "2026-03-02", - "path": "/ko/sales/order-management", - "needs_approval": false - } - ], - "total_count": 5 -} -``` - -**Laravel 힌트**: -- 배치 저장 방식: `App\Console\Commands\SnapshotDailyIssues` (Schedule::daily) -- 또는 이벤트 기반: 수주/채권/재고 변동 시 `dashboard_issue_history` INSERT - -### N2. 자금현황 — 전일 대비 변동률 (daily_change) -**우선순위**: 중 -**페이지**: p33 -**현상**: `GET /api/v1/daily-report/summary` 응답에 `daily_change` 필드가 없음. 프론트엔드에서 하드코딩 fallback 값(+5.2%, +2.1%, +12.0%, -8.0%)을 사용 중. - -**요구사항**: -1. **기존 API 수정**: `GET /api/v1/daily-report/summary` -2. 응답에 `daily_change` 객체 추가 -3. 각 항목의 전일 대비 변동률(%) 계산 로직: - - `cash_asset_change_rate`: (오늘 현금성자산 - 어제 현금성자산) / 어제 현금성자산 × 100 - - `foreign_currency_change_rate`: (오늘 외국환 - 어제 외국환) / 어제 외국환 × 100 - - `income_change_rate`: (오늘 입금 - 어제 입금) / 어제 입금 × 100 - - `expense_change_rate`: (오늘 지출 - 어제 지출) / 어제 지출 × 100 -4. 어제 데이터 없을 시 해당 필드 `null` (프론트에서 fallback 처리) - -**Response** (기존 응답에 `daily_change` 추가): -```json -{ - "date": "2026-03-03", - "day_of_week": "화", - "cash_asset_total": 1250000000, - "foreign_currency_total": 85000, - "krw_totals": { "income": 45000000, "expense": 32000000, "balance": 1250000000 }, - "daily_change": { - "cash_asset_change_rate": 5.2, - "foreign_currency_change_rate": 2.1, - "income_change_rate": 12.0, - "expense_change_rate": -8.0 - } -} -``` - -**Laravel 힌트**: -- `DailyReportService`에서 전일 데이터 조회 추가 -- `sam_stat` 캐시 테이블에 전일 스냅샷 있으면 활용 -- 프론트 타입: `DailyChangeRate` (`src/lib/api/dashboard/types.ts:23`) - -### N3. 일일일보 — daily-accounts에 입출금관리 데이터 미반영 -**우선순위**: 상 -**페이지**: 일일일보 페이지 (`/ko/accounting/daily-report`) -**현상**: 입금관리/출금관리에서 당일 거래를 등록하면 대시보드 자금현황(`daily-report/summary`)의 합계에는 즉시 반영되지만, 일일일보 페이지의 계좌별 상세 테이블(`daily-report/daily-accounts`)에는 표시되지 않음. (출금 테스트로 확인됨, 입금도 동일 구조로 미반영 추정) - -**영향 범위**: -| 데이터 | 관리 테이블 | summary (합계) | daily-accounts (상세) | -|--------|-----------|:-:|:-:| -| 입금 | `deposits` (`/api/v1/deposits`) | ✅ 반영 추정 | ❌ 미반영 추정 | -| 출금 | `withdrawals` (`/api/v1/withdrawals`) | ✅ 반영 확인 | ❌ 미반영 확인 | -| 외국환 (USD) | 별도 관리 페이지 미확인 | ✅ 반영 | ❓ 확인 필요 | - -**원인 분석**: -- `GET /api/v1/daily-report/summary` → `krw_totals`에 `deposits`/`withdrawals` 테이블 데이터 포함 ✅ -- `GET /api/v1/daily-report/daily-accounts` → `bank_accounts` 단위 집계만 반환, `deposits`/`withdrawals` 테이블 미포함 ❌ - -**데이터 흐름**: -``` -입금관리 등록 → deposits 테이블 INSERT (bank_account_id 포함) -출금관리 등록 → withdrawals 테이블 INSERT (bank_account_id 포함) - ├─ summary API → krw_totals.income/expense에 합산 → 대시보드 ✅ - └─ daily-accounts API → bank_accounts 기준만 조회 → 일일일보 상세 ❌ -``` - -**요구사항**: -1. `GET /api/v1/daily-report/daily-accounts` 수정 -2. 각 계좌별로 `deposits` 테이블의 당일 income과 `withdrawals` 테이블의 당일 expense를 합산 -3. 또는 입금/출금 등록 시 해당 계좌의 거래 내역(`bank_account_transactions`)에도 자동 반영 - -**해결 방안 (택 1)**: -- **방안 A** (daily-accounts 쿼리 수정): `bank_accounts` LEFT JOIN `deposits`/`withdrawals` WHERE date = 당일 → 계좌별 income/expense에 합산 -- **방안 B** (트랜잭션 연동): 입금/출금 등록 시 `bank_account_transactions`에도 INSERT → daily-accounts가 자연스럽게 포함 - -**Response** (기존 `DailyAccountItemApi[]`와 동일, 데이터만 보완): -```json -[ - { - "id": "acc_1", - "category": "우리은행 123-456", - "match_status": "matched", - "carryover": 50000000, - "income": 1000000, - "expense": 50000, - "balance": 50950000, - "currency": "KRW" - } -] -``` - -**Laravel 힌트**: -- `DailyReportService`의 `getDailyAccounts()` 메서드 확인 -- `deposits` 테이블: `deposit.bank_account_id`로 해당 계좌 income 합산 -- `withdrawals` 테이블: `withdrawal.bank_account_id`로 해당 계좌 expense 합산 -- USD 계좌도 동일 패턴 적용 필요 - -### N4. 현황판 `purchases`(발주) — path 오류 + 데이터 정합성 이슈 -**우선순위**: 중 -**페이지**: p34 (현황판) - -#### 이슈 A: path 하드코딩 오류 -**현상**: `purchases` 항목의 실제 데이터는 `purchases` 테이블(매입, 공통)에서 조회하면서, path는 건설 모듈 경로로 하드코딩되어 있음. - -**문제 코드** (`StatusBoardService.php` — `getPurchaseStatus()`): -```php -$count = Purchase::query() - ->where('tenant_id', $tenantId) - ->where('status', 'draft') - ->count(); - -return [ - 'id' => 'purchases', - 'label' => '발주', - 'path' => '/construction/order/order-management', // ← 매입 데이터인데 건설 경로 -]; -``` - -- 데이터 출처: `purchases` 테이블 (모든 테넌트 공통 매입 테이블) -- path: `/construction/order/order-management` (건설 전용 페이지) -- **데이터와 path가 불일치** — 매입 draft 건수를 보여주면서 건설 발주 페이지로 링크 - -**현재 프론트 임시 대응**: `status-issue.ts`에서 `/accounting/purchase`(매입관리)로 오버라이드 중 - -**요구사항**: -1. path를 `/accounting/purchase`로 변경 (데이터 출처와 일치시키기) -2. 또는 테넌트 업종에 따라 path 동적 분기 (건설: `/construction/order/order-management`, 기타: `/accounting/purchase`) -3. 라벨도 재검토: "발주"가 맞는지, "매입(임시저장)"이 더 정확한지 - -#### 이슈 B: 데이터 정합성 의심 -**현상**: StatusBoard API에서 `purchases` count=**9건** 반환, 하지만 매입관리 페이지(`/accounting/purchase`)에서 전체 조회 시 **1건**만 표시. - -**확인 사항** (DB 직접 확인 필요): -```sql --- 현재 테넌트의 purchases 테이블 전체 건수 -SELECT COUNT(*), status FROM purchases WHERE tenant_id = {현재 테넌트 ID} GROUP BY status; - --- draft 상태 건수 (StatusBoard가 조회하는 조건) -SELECT COUNT(*) FROM purchases WHERE tenant_id = {현재 테넌트 ID} AND status = 'draft'; -``` - -**가능한 원인**: -1. StatusBoard와 매입관리 페이지가 다른 tenant_id 스코프로 조회 -2. DummyDataSeeder가 다른 tenant_id로 데이터 생성 -3. 매입관리 API에 추가 필터 조건이 있어서 draft 건이 제외됨 -4. StatusBoard가 실제와 다른 데이터를 집계 - -**기대 결과**: StatusBoard 9건 클릭 → 매입관리 페이지에서 9건 확인 가능해야 함 - ---- - -## 신규 API (10개) - -### 1. 매출 현황 Summary -**우선순위**: 중 -**페이지**: p39 - -``` -GET /api/v1/dashboard/sales/summary -``` - -**Query Params**: -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| year | int | N | 조회 연도 (기본: 당해) | -| month | int | N | 조회 월 (기본: 당월) | - -**Response** (`SalesStatusApiResponse`): -```json -{ - "cumulative_sales": 312300000, - "achievement_rate": 94.5, - "yoy_change": 12.5, - "monthly_sales": 312300000, - "monthly_trend": [ - { "month": "2026-08", "label": "8월", "amount": 250000000 }, - { "month": "2026-09", "label": "9월", "amount": 280000000 } - ], - "client_sales": [ - { "name": "대한건설", "amount": 95000000 }, - { "name": "삼성테크", "amount": 78000000 } - ], - "daily_items": [ - { - "date": "2026-02-01", - "client": "대한건설", - "item": "스크린 외", - "amount": 25000000, - "status": "deposited" - } - ], - "daily_total": 312300000 -} -``` - -**Laravel 힌트**: -- 매출: `sales_orders` 합계 (confirmed 상태) -- 달성률: 매출 목표 대비 (`sales_targets` 테이블) -- YoY: 전년 동월 대비 변화율 -- 거래처별: GROUP BY vendor_id → TOP 5 -- status 코드: `deposited` (입금완료), `unpaid` (미입금), `partial` (부분입금) - ---- - -### 2. 매입 현황 Summary -**우선순위**: 중 -**페이지**: p40 - -``` -GET /api/v1/dashboard/purchases/summary -``` - -**Query Params**: -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| year | int | N | 조회 연도 (기본: 당해) | -| month | int | N | 조회 월 (기본: 당월) | - -**Response** (`PurchaseStatusApiResponse`): -```json -{ - "cumulative_purchase": 312300000, - "unpaid_amount": 312300000, - "yoy_change": -12.5, - "monthly_trend": [ - { "month": "2026-08", "label": "8월", "amount": 180000000 } - ], - "material_ratio": [ - { "name": "원자재", "value": 55, "percentage": 55, "color": "#3b82f6" }, - { "name": "부자재", "value": 35, "percentage": 35, "color": "#10b981" }, - { "name": "소모품", "value": 10, "percentage": 10, "color": "#f59e0b" } - ], - "daily_items": [ - { - "date": "2026-02-01", - "supplier": "한국철강", - "item": "철판 외", - "amount": 45000000, - "status": "paid" - } - ], - "daily_total": 312300000 -} -``` - -**Laravel 힌트**: -- 매입: `purchase_orders` 합계 -- 미결제: 결제 미완료 건 합계 -- 원자재/부자재/소모품: `item_categories` 기준 분류 -- status 코드: `paid` (결제완료), `unpaid` (미결제), `partial` (부분결제) - ---- - -### 3. 생산 현황 Summary -**우선순위**: 상 -**페이지**: p41 - -``` -GET /api/v1/dashboard/production/summary -``` - -**Query Params**: -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) | - -**Response** (`DailyProductionApiResponse`): -```json -{ - "date": "2026-02-23", - "day_of_week": "월요일", - "processes": [ - { - "process_name": "스크린", - "total_work": 10, - "todo": 3, - "in_progress": 4, - "completed": 3, - "urgent": 2, - "sub_line": 1, - "regular": 5, - "worker_count": 8, - "work_items": [ - { - "id": "wo_1", - "order_no": "SO-2026-001", - "client": "대한건설", - "product": "스크린 A형", - "quantity": 50, - "status": "in_progress" - } - ], - "workers": [ - { - "name": "김철수", - "assigned": 5, - "completed": 3, - "rate": 60 - } - ] - } - ], - "shipment": { - "expected_amount": 150000000, - "expected_count": 12, - "actual_amount": 120000000, - "actual_count": 9 - } -} -``` - -**Laravel 힌트**: -- 공정: `work_processes` 테이블 (스크린, 슬랫, 절곡 등) -- 작업: `work_orders` JOIN `work_process_id` -- status: `pending` → todo, `in_progress`, `completed` -- urgent: 납기 3일 이내 -- 출고: `shipments` 테이블 (당일 예상 vs 실적) - ---- - -### 4. 출고 현황 (생산 현황에 포함) -**우선순위**: 하 -**페이지**: p41 - -생산 현황 API의 `shipment` 필드로 포함됨. 별도 API 불필요. - ---- - -### 5. 미출고 내역 -**우선순위**: 하 -**페이지**: p42 - -``` -GET /api/v1/dashboard/unshipped/summary -``` - -**Query Params**: -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| days | int | N | 납기 N일 이내 (기본: 30) | - -**Response** (`UnshippedApiResponse`): -```json -{ - "items": [ - { - "id": "us_1", - "port_no": "P-2026-001", - "site_name": "강남 현장", - "order_client": "대한건설", - "due_date": "2026-02-25", - "days_left": 2 - } - ], - "total_count": 7 -} -``` - -**Laravel 힌트**: -- `shipment_items` WHERE shipped_at IS NULL AND due_date >= NOW() -- days_left: DATEDIFF(due_date, NOW()) -- ORDER BY due_date ASC (납기 임박 순) - ---- - -### 6. 시공 현황 -**우선순위**: 중 -**페이지**: p42 - -``` -GET /api/v1/dashboard/construction/summary -``` - -**Query Params**: -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| month | int | N | 조회 월 (기본: 당월) | - -**Response** (`ConstructionApiResponse`): -```json -{ - "this_month": 15, - "completed": 5, - "items": [ - { - "id": "cs_1", - "site_name": "강남 현장", - "client": "대한건설", - "start_date": "2026-02-01", - "end_date": "2026-02-28", - "progress": 85, - "status": "in_progress" - } - ] -} -``` - -**Laravel 힌트**: -- `constructions` 테이블 -- status: `in_progress`, `scheduled`, `completed` -- completed: 최근 7일 이내 완료 건 - ---- - -### 7. 근태 현황 -**우선순위**: 중 -**페이지**: p43 - -``` -GET /api/v1/dashboard/attendance/summary -``` - -**Query Params**: -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) | - -**Response** (`DailyAttendanceApiResponse`): -```json -{ - "present": 42, - "on_leave": 3, - "late": 1, - "absent": 0, - "employees": [ - { - "id": "emp_1", - "department": "생산부", - "position": "과장", - "name": "김철수", - "status": "present" - } - ] -} -``` - -**Laravel 힌트**: -- `attendances` WHERE date = :date -- status: `present`, `on_leave`, `late`, `absent` -- employees: 이상 상태(late, absent, on_leave) 위주 표시 - ---- - -### 8. 일별 매출 내역 -**우선순위**: 하 -**페이지**: p47 (설정 팝업에서 별도 ON/OFF) - -매출 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시: - -``` -GET /api/v1/dashboard/sales/daily -``` - -**Query Params**: -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| start_date | string | N | 시작일 (기본: 당월 1일) | -| end_date | string | N | 종료일 (기본: 오늘) | -| page | int | N | 페이지 (기본: 1) | -| per_page | int | N | 건수 (기본: 20) | - ---- - -### 9. 일별 매입 내역 -**우선순위**: 하 - -매입 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시: - -``` -GET /api/v1/dashboard/purchases/daily -``` - -(매출 일별과 동일 구조) - ---- - -### 10. 접대비 상세 -**우선순위**: 상 -**페이지**: p53-54 - -``` -GET /api/v1/dashboard/entertainment/detail -``` - -**Query Params**: -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| year | int | N | 연도 | -| quarter | int | N | 분기 (1-4) | -| limit_type | string | N | annual/quarterly | -| company_type | string | N | large/medium/small | - -**Response**: -```json -{ - "summary": { - "total_used": 10000000, - "annual_limit": 40120000, - "remaining": 30120000, - "usage_rate": 24.9 - }, - "limit_calculation": { - "base_limit": 36000000, - "revenue_additional": 4120000, - "total_limit": 40120000, - "revenue": 2060000000, - "company_type": "medium" - }, - "quarterly_status": [ - { - "quarter": 1, - "label": "1분기", - "limit": 10030000, - "used": 3500000, - "remaining": 6530000, - "exceeded": 0 - } - ], - "transactions": [ - { - "id": 1, - "date": "2026-01-15", - "user_name": "홍길동", - "merchant_name": "강남식당", - "amount": 350000, - "counterpart": "대한건설", - "receipt_type": "법인카드", - "risk_flags": ["high_amount"] - } - ] -} -``` - ---- - -## 수정 API (6개) - -### 1. 가지급금 Summary (수정) -**현재**: 카드/가지급금/법인세/종합세 -**변경**: 카드/경조사/상품권/접대비/총합계 (5카드) - -``` -GET /api/proxy/card-transactions/summary -``` - -**Response 변경**: -```json -{ - "cards": [ - { "id": "cm1", "label": "카드", "amount": 3123000, "sub_label": "미정리 5건", "count": 5 }, - { "id": "cm2", "label": "경조사", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 }, - { "id": "cm3", "label": "상품권", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 }, - { "id": "cm4", "label": "접대비", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 }, - { "id": "cm_total", "label": "총 가지급금 합계", "amount": 350000000 } - ], - "check_points": [ - { - "id": "cm-cp1", - "type": "warning", - "message": "법인카드 사용 총 850만원이 가지급금으로 전환되었습니다.", - "highlights": [{ "text": "850만원", "color": "red" }] - } - ], - "warning_banner": "가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의" -} -``` - -**Laravel 힌트**: -- 분류: `card_transactions.category` 기준 (card/congratulation/gift_card/entertainment) -- 미정리/미증빙: `evidence_status = 'pending'` COUNT - ---- - -### 2. 접대비 Summary (수정) -**현재**: 매출/한도/잔여한도/사용금액 -**변경**: 주말심야/기피업종/고액결제/증빙미비 (리스크 4종) - -``` -GET /api/proxy/entertainment/summary -``` - -**Response 변경**: -```json -{ - "cards": [ - { "id": "et1", "label": "주말/심야", "amount": 3123000, "sub_label": "5건", "count": 5 }, - { "id": "et2", "label": "기피업종 (유흥, 귀금속 등)", "amount": 3123000, "sub_label": "불인정 5건", "count": 5 }, - { "id": "et3", "label": "고액 결제", "amount": 3123000, "sub_label": "5건", "count": 5 }, - { "id": "et4", "label": "증빙 미비", "amount": 3123000, "sub_label": "5건", "count": 5 } - ], - "check_points": [...] -} -``` - -**리스크 감지 로직** (p60 참조): -- 주말/심야: 토~일, 22:00~06:00 거래 -- 기피업종: MCC 코드 기반 (유흥업소 7273, 귀금속 5944, 골프장 7941 등) -- 고액 결제: 설정 금액(기본 50만원) 초과 -- 증빙 미비: 적격증빙(세금계산서/카드매출전표) 없는 건 - ---- - -### 3. 복리후생비 Summary (수정) -**현재**: 한도/잔여한도/사용금액 -**변경**: 비과세한도초과/사적사용의심/특정인편중/항목별한도초과 (리스크 4종) - -``` -GET /api/proxy/welfare/summary -``` - -**Response 변경**: -```json -{ - "cards": [ - { "id": "wf1", "label": "비과세 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 }, - { "id": "wf2", "label": "사적 사용 의심", "amount": 3123000, "sub_label": "5건", "count": 5 }, - { "id": "wf3", "label": "특정인 편중", "amount": 3123000, "sub_label": "5건", "count": 5 }, - { "id": "wf4", "label": "항목별 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 } - ], - "check_points": [...] -} -``` - -**리스크 감지 로직**: -- 비과세 한도 초과: 항목별 비과세 기준 초과 (식대 20만원, 교통비 10만원 등) -- 사적 사용 의심: 주말/야간 + 비업무 업종 조합 -- 특정인 편중: 직원별 사용액 편차 > 평균의 200% -- 항목별 한도 초과: 설정 금액 초과 - ---- - -### 4. 가지급금 Detail (수정) - -기존 `LoanDashboardApiResponse`에 AI분류 컬럼 추가. - -``` -GET /api/v1/loans/dashboard -``` - -**Response 추가 필드**: -```json -{ - "items": [ - { - "...기존 필드...", - "ai_category": "카드", - "evidence_status": "미증빙" - } - ] -} -``` - ---- - -### 5. 복리후생비 Detail (수정) - -기존 `WelfareDetailApiResponse`에 계산방식 파라미터 추가. - -``` -GET /api/proxy/welfare/detail?calculation_type=fixed&fixed_amount_per_month=200000 -``` - -(기존 구현 유지, 계산 파라미터만 반영 확인) - ---- - -### 6. 부가세 Detail (수정) - -기존 `VatApiResponse`에 신고기간 파라미터 반영. - -``` -GET /api/proxy/vat/summary?period_type=quarter&year=2026&period=1 -``` - -(기존 구현 유지, 기간별 필터링 확인) - ---- - -## 리스크 감지 로직 참고 (p58-60) - -### MCC 코드 기피업종 -| MCC | 업종 | 분류 | -|-----|------|------| -| 7273 | 유흥업소 | 기피업종 | -| 5944 | 귀금속 | 기피업종 | -| 7941 | 골프장 | 기피업종 | -| 5813 | 주점 | 기피업종 | -| 7011 | 호텔/리조트 | 주의업종 | - -### 리스크 판별 규칙 -``` -규칙1: 시간대 이상 → 22:00~06:00 또는 토~일 -규칙2: 업종 이상 → MCC 기피업종 해당 -규칙3: 금액 이상 → 설정 금액 초과 (기본 50만원) -규칙4: 빈도 이상 → 월 10회 이상 동일 업종 -규칙5: 증빙 미비 → 적격증빙 없음 - -리스크 등급: -- 2개 이상 해당 → 🔴 고위험 -- 1개 해당 → 🟡 주의 -- 0개 → 🟢 정상 -``` - ---- - -## 계산 공식 참고 - -### 가지급금 인정이자 (p58) -``` -인정이자 = 가지급금잔액 × (4.6% / 365) × 경과일수 -법인세 추가 = 인정이자 × 19% -대표자 소득세 = 인정이자 × 35% -``` - -### 접대비 손금한도 (p59) -``` -기본한도: - 일반법인: 1,200만원/년 - 중소기업: 3,600만원/년 - -수입금액별 추가: - 100억 이하: 수입금액 × 0.2% - 100~500억: 2,000만원 + (수입금액-100억) × 0.1% - 500억 초과: 6,000만원 + (수입금액-500억) × 0.03% -``` - -### 복리후생비 (p60) -``` -방식1 (정액): 직원수 × 월정액 × 12 -방식2 (비율): 연봉총액 × 비율% - -비과세 한도: - 식대: 20만원/월 - 교통비: 10만원/월 - 경조사: 5만원/건 - 건강검진: 연간 총액/12 환산 - 교육훈련: 8만원/월 - 복지포인트: 10만원/월 -``` - ---- - -## 우선순위 정리 - -| 우선순위 | API | 이유 | -|---------|-----|------| -| 🔴 상 | 접대비 summary 수정, 복리후생비 summary 수정 | D1.7 카드 구조 변경 | -| 🔴 상 | 가지급금 summary 수정 | D1.7 카드 구조 변경 | -| 🔴 상 | 접대비 detail 신규 | 모달 확장 | -| 🟡 중 | 매출 현황, 매입 현황, 시공 현황, 근태 현황 | 신규 섹션 | -| 🟡 중 | 생산 현황 | 복잡한 공정 집계 | -| 🟢 하 | 미출고 내역, 일별 매출/매입 | 단순 조회 | diff --git a/claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md b/claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md deleted file mode 100644 index 8e236a5a..00000000 --- a/claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md +++ /dev/null @@ -1,122 +0,0 @@ -# sam-api 변경 내역 (2026-03-09) - -총 **13개 커밋** (중복 1건 제외 실질 12건) - ---- - -## feat: 신규 기능 (6건) - -### 1. [database] codebridge 이관 완료 테이블 58개 삭제 -- **커밋**: `28ae481` / `74e3c21` (동일 커밋 2건) -- **작업자**: 권혁성 -- **변경 파일**: 마이그레이션 1개 -- **내용**: - - sam DB → codebridge DB 이관 완료된 58개 테이블 DROP - - FK 체크 비활성화 후 일괄 삭제 - - 복원 경로: `~/backups/sam_codebridge_tables_20260309.sql` - -### 2. [결재] 테넌트 부트스트랩에 기본 결재 양식 자동 시딩 -- **커밋**: `45a207d` -- **작업자**: 권혁성 -- **변경 파일**: `RecipeRegistry.php`, `ApprovalFormsStep.php` (신규) -- **내용**: - - ApprovalFormsStep 신규 생성 (proposal, expenseReport, expenseEstimate, attendance_request, reason_report) - - RecipeRegistry STANDARD 레시피에 등록 - - 테넌트 생성 시 자동 실행, 기존 테넌트는 `php artisan tenants:bootstrap --all` - -### 3. [quality] 검사 상태 자동 재계산 + 수주처 선택 연동 -- **커밋**: `3fc5f51` -- **작업자**: 권혁성 -- **변경 파일**: `QualityDocumentLocation.php`, `QualityDocumentService.php` -- **내용**: - - 개소별 inspection_status를 검사 데이터 기반 자동 판정 (15개 판정필드 + 사진 유무 → pending/in_progress/completed) - - 문서 status를 개소 상태 집계로 자동 재계산 - - transformToFrontend에 client_id 매핑 추가 - -### 4. [현황판/악성채권] 카드별 sub_label 추가 -- **커밋**: `56c60ec` -- **작업자**: 유병철 -- **변경 파일**: `BadDebtService.php`, `StatusBoardService.php` -- **내용**: - - BadDebtService: 카드별(전체/추심중/법적조치/회수완료) sub_labels 추가 - - StatusBoardService: 악성채권(최다 금액 거래처명), 신규거래처(최근 등록 업체명), 결재(최근 결재 제목) sub_label 추가 - -### 5. [복리후생] 상세 조회 커스텀 날짜 범위 필터 -- **커밋**: `60c4256` -- **작업자**: 유병철 -- **변경 파일**: `WelfareController.php`, `WelfareService.php` -- **내용**: - - start_date, end_date 쿼리 파라미터 추가 - - 커스텀 날짜 범위 지정 시 해당 범위로 일별 사용 내역 조회 - - 미지정 시 기존 분기 기준 유지 - -### 6. [finance] 더존 Smart A 표준 계정과목 추가 시딩 -- **커밋**: `1d5d161` -- **작업자**: 유병철 -- **변경 파일**: 마이그레이션 1개 (467줄) -- **내용**: - - 기획서 14장 기준 누락분 보완 - - tenant_id + code 중복 시 skip (기존 데이터 보호) - ---- - -## fix: 버그 수정 (4건) - -### 7. [현황판] 결재 카드 조회에 approvalOnly 스코프 추가 -- **커밋**: `ee9f4d0` -- **작업자**: 유병철 -- **변경 파일**: `StatusBoardService.php` -- **내용**: ApprovalStep 쿼리에 approvalOnly() 스코프 적용, 결재 유형만 필터링 - -### 8. [악성채권] tenant_id ambiguous 에러 + JOIN 컬럼 prefix 보완 -- **커밋**: `3929c5f`, `ca259cc` -- **작업자**: 유병철 -- **변경 파일**: `BadDebtService.php`, `StatusBoardService.php` -- **내용**: - - JOIN 쿼리에서 `bad_debts.tenant_id`로 테이블 명시 - - is_active, status 컬럼에도 `bad_debts.` prefix 추가 - -### 9. [세금계산서] NOT NULL 컬럼 null 방어 처리 -- **커밋**: `1861f4d` -- **작업자**: 유병철 -- **변경 파일**: `TaxInvoiceService.php` -- **내용**: supplier/buyer corp_num, corp_name null→빈문자열 보정 (ConvertEmptyStringsToNull 미들웨어 대응) - -### 10. [세금계산서] 매입/매출 방향별 필수값 조건 분리 -- **커밋**: `c62e59a` -- **작업자**: 유병철 -- **변경 파일**: `CreateTaxInvoiceRequest.php` -- **내용**: 매입(supplier 필수), 매출(buyer 필수) — `required → required_if:direction` 조건부 검증 - ---- - -## refactor: 리팩토링 (1건) - -### 11. [세금계산서/바로빌] ApiResponse::handle() 클로저 패턴 통일 -- **커밋**: `e6f13e3` -- **작업자**: 유병철 -- **변경 파일**: `BarobillSettingController.php`, `TaxInvoiceController.php` -- **내용**: - - 전체 액션 클로저 방식 전환 (show/save/testConnection, index/show/store/update/destroy/issue/bulkIssue/cancel/checkStatus/summary) - - 중간 변수 할당 제거, 일관된 응답 패턴 적용 - - **-38줄** (91→40+27 구조 정리) - ---- - -## 영향받는 주요 서비스 파일 - -| 파일 | 변경 횟수 | 도메인 | -|------|----------|--------| -| `StatusBoardService.php` | 4회 | 현황판/대시보드 | -| `BadDebtService.php` | 3회 | 악성채권 | -| `TaxInvoiceService.php` | 1회 | 세금계산서 | -| `TaxInvoiceController.php` | 1회 | 세금계산서 | -| `QualityDocumentService.php` | 1회 | 품질검사 | -| `WelfareService.php` | 1회 | 복리후생 | - -## 작업자별 커밋 수 - -| 작업자 | 커밋 수 | 주요 도메인 | -|--------|---------|-------------| -| 유병철 | 9건 | 현황판, 악성채권, 세금계산서, 복리후생, 계정과목 | -| 권혁성 | 4건 | DB 이관, 결재 시딩, 품질검사 | diff --git a/claudedocs/api/[FEAT-2026-03-10] calendar-new-schedule-types.md b/claudedocs/api/[FEAT-2026-03-10] calendar-new-schedule-types.md deleted file mode 100644 index de15e44e..00000000 --- a/claudedocs/api/[FEAT-2026-03-10] calendar-new-schedule-types.md +++ /dev/null @@ -1,77 +0,0 @@ -# 캘린더 신규 일정 타입 추가 (결제예정/납기/출고) - -**작업일**: 2026-03-10 -**목적**: CEO 대시보드 캘린더에서 자금/물류/납기 일정을 한눈에 파악 - ---- - -## 추가된 타입 - -| 타입 | 라벨 | 색상 | ID 형식 | 제목 형식 | -|------|------|------|---------|----------| -| `expected_expense` | 결제예정 | rose (분홍) | `expense_{id}` | `[결제] {거래처명} {금액}원` | -| `delivery` | 납기 | cyan (청록) | `delivery_{id}` | `[납기] {거래처명} {현장명 or 수주번호}` | -| `shipment` | 출고 | teal (틸) | `shipment_{id}` | `[출고] {거래처명} {현장명 or 출하번호}` | - -## 제외 항목 - -| 항목 | 사유 | -|------|------| -| 미수금 입금 예정일 | `Deposit` 모델에 expected_date 필드 없음 → Phase 2 | -| 세금 납부 예정일 | 이미 CalendarScheduleStore + 상수로 orange 색상 표시 중 | - ---- - -## 변경 파일 - -### Backend (1파일) - -**`app/Services/CalendarService.php`** -- import 추가: `Order`, `ExpectedExpense`, `Shipment` -- `getSchedules()`: 3개 merge 블록 추가 (`expected_expense`, `delivery`, `shipment`) -- 신규 private 메서드 3개: - - `getExpectedExpenseSchedules()` — `ExpectedExpense` 모델, `expected_payment_date`, `payment_status != 'paid'` - - `getDeliverySchedules()` — `Order` 모델, `delivery_date`, 활성 status_code 5개 - - `getShipmentSchedules()` — `Shipment` 모델, `scheduled_date`, status in ('scheduled', 'ready') - -### Frontend (3파일) - -**`src/components/business/CEODashboard/types.ts`** -- `CalendarScheduleItem.type` union에 3개 타입 추가 -- `CalendarTaskFilterType` union에 3개 타입 추가 - -**`src/lib/api/dashboard/types.ts`** -- `CalendarScheduleType` union에 3개 타입 추가 - -**`src/components/business/CEODashboard/sections/CalendarSection.tsx`** -- `SCHEDULE_TYPE_COLORS`: rose/cyan/teal 추가 -- `SCHEDULE_TYPE_ROUTES`: 3개 라우트 추가 -- `SCHEDULE_TYPE_LABELS`: 결제예정/납기/출고 추가 -- `SCHEDULE_TYPE_BADGE_COLORS`: rose/cyan/teal 뱃지 스타일 추가 -- `TASK_FILTER_OPTIONS`: 필터 드롭다운 옵션 3개 추가 -- `ExtendedTaskFilterType`: `'bill'` 제거 (CalendarTaskFilterType에 이미 포함) -- `getScheduleLink()`: `expected_expense`는 목록 페이지만 이동 (상세 없음) -- 모바일 `colorMap`: 3개 dot 색상 추가 - ---- - -## 라우트 매핑 - -| 타입 | 상세보기 클릭 시 이동 경로 | 비고 | -|------|--------------------------|------| -| `expected_expense` | `/ko/accounting/expected-expenses` | 목록 페이지 (상세 없음) | -| `delivery` | `/ko/sales/order-management-sales/{id}` | 수주 상세 | -| `shipment` | `/ko/outbound/shipments/{id}` | 출고 상세 | - ---- - -## 검수 결과 (2026-03-10) - -- [x] 캘린더 '전체' 필터에서 결제예정 항목 표시 -- [x] 필터 드롭다운에 결제예정/납기/출고 옵션 추가 -- [x] 결제예정 필터 선택 시 해당 타입만 표시 -- [x] 결제예정 상세보기 링크 동작 -- [x] 결제예정 뱃지 rose 색상 표시 -- [x] 기존 5개 타입 정상 동작 -- [x] TypeScript 빌드 에러 없음 -- [ ] 납기/출고 데이터 표시 (테스트 DB에 해당 날짜 데이터 없어 미확인 — 기능은 정상) diff --git a/claudedocs/api/[IMPL-2025-11-07] api-key-management.md b/claudedocs/api/[IMPL-2025-11-07] api-key-management.md deleted file mode 100644 index c71e1969..00000000 --- a/claudedocs/api/[IMPL-2025-11-07] api-key-management.md +++ /dev/null @@ -1,320 +0,0 @@ -# API Key 관리 가이드 - -## 📋 개요 - -PHP 백엔드에서 발급하는 API Key의 안전한 관리 및 주기적 갱신 대응 방법 - ---- - -## 🔑 현재 API Key 정보 - -```yaml -개발용 API Key: - 키 값: 42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a - 발급일: 2025-11-07 - 용도: 개발 환경 고정 키 - 갱신: 주기적으로 변동 가능 -``` - ---- - -## 🔐 보안 원칙 - -### ✅ DO (반드시 해야 할 것) -- `.env.local`에만 실제 키 저장 -- 서버 사이드 코드에서만 사용 -- Git에 절대 커밋 금지 -- 팀 공유 문서로 키 관리 - -### ❌ DON'T (절대 하지 말 것) -- 하드코딩 금지 -- `NEXT_PUBLIC_` 접두사 사용 금지 -- 브라우저 코드에서 사용 금지 -- 공개 저장소에 업로드 금지 - ---- - -## 📁 파일 구성 - -### .env.local (실제 키 - Git 제외) -```env -# API Key (서버 사이드 전용 - 절대 공개 금지!) -# 개발용 고정 키 (주기적 갱신 예정) -# 발급일: 2025-11-07 -# 갱신 필요 시: PHP 백엔드 팀에 새 키 요청 -API_KEY=42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a -``` - -### .env.example (템플릿 - Git 커밋 OK) -```env -# API Key (⚠️ 서버 사이드 전용 - 절대 공개 금지!) -# 개발팀 공유: 팀 내부 문서에서 키 값 확인 -# 주기적 갱신: PHP 백엔드 팀에서 새 키 발급 시 업데이트 필요 -API_KEY=your-secret-api-key-here -``` - -### .gitignore 확인 -```bash -# 라인 100-101에 이미 포함됨 -.env.local -.env*.local -``` - ---- - -## 🔄 API Key 갱신 프로세스 - -### 1️⃣ PHP 팀에서 새 키 발급 -``` -PHP 백엔드 팀 → 새 API Key 발급 - ↓ - 팀 공유 문서 업데이트 -``` - -### 2️⃣ 로컬 개발 환경 업데이트 -```bash -# .env.local 파일 열기 -vi .env.local - -# 또는 -code .env.local - -# API_KEY 값만 변경 -API_KEY=새로운키값여기에입력 - -# 개발 서버 재시작 -npm run dev -``` - -### 3️⃣ 프로덕션 환경 업데이트 - -#### Vercel 배포 -```bash -# CLI로 업데이트 -vercel env add API_KEY production - -# 또는 대시보드에서 -# Settings → Environment Variables → API_KEY 편집 -``` - -#### AWS/기타 환경 -```bash -# 환경 변수 업데이트 -export API_KEY=새로운키값 - -# 또는 배포 설정에서 환경 변수 수정 -``` - -### 4️⃣ 검증 -```bash -# 개발 서버 시작 시 자동으로 검증됨 -npm run dev - -# 콘솔 출력 확인: -# 🔐 API Key Configuration: -# ├─ Configured: ✅ -# ├─ Valid Format: ✅ -# ├─ Masked Key: 42Jf********************dk1a -# └─ Length: 48 chars -``` - ---- - -## 🛠️ API Key 검증 유틸리티 - -### 자동 검증 기능 -```typescript -// lib/api/auth/api-key-validator.ts -import { apiKeyValidator } from '@/lib/api/auth/api-key-validator'; - -// 개발 서버 시작 시 자동 실행 -console.log(apiKeyValidator.getDebugInfo()); - -// 출력 예시: -// API Key Status: -// ├─ Configured: ✅ -// ├─ Valid Format: ✅ -// ├─ Masked Key: 42Jf********************dk1a -// └─ Length: 48 chars -``` - -### 수동 검증 -```typescript -import { apiKeyValidator } from '@/lib/api/auth/api-key-validator'; - -// API Key 존재 확인 -if (!apiKeyValidator.isConfigured()) { - console.error('API Key not configured!'); -} - -// 형식 검증 -if (!apiKeyValidator.isValid()) { - console.error('Invalid API Key format!'); -} - -// 디버그 정보 출력 -console.log(apiKeyValidator.getDebugInfo()); -``` - ---- - -## 📊 사용 예시 - -### 서버 사이드 (Next.js API Route) -```typescript -// app/api/sync/route.ts -import { createApiKeyClient } from '@/lib/api/auth/api-key-client'; - -export async function GET() { - try { - // 환경 변수에서 자동으로 키를 가져옴 - const client = createApiKeyClient(); - - const data = await client.fetchData('/api/external-data'); - - return Response.json({ success: true, data }); - } catch (error) { - console.error('API request failed:', error); - return Response.json( - { error: 'Failed to fetch data' }, - { status: 500 } - ); - } -} -``` - -### 백그라운드 스크립트 -```typescript -// scripts/sync-data.ts -import { createApiKeyClient } from '@/lib/api/auth/api-key-client'; -import { apiKeyValidator } from '@/lib/api/auth/api-key-validator'; - -async function syncData() { - // 1. 환경 변수 확인 - console.log(apiKeyValidator.getDebugInfo()); - - if (!apiKeyValidator.isValid()) { - throw new Error('Invalid API Key configuration'); - } - - // 2. API 요청 - const client = createApiKeyClient(); - const data = await client.fetchData('/api/sync-endpoint'); - - console.log('Sync completed:', data); -} - -syncData().catch(console.error); -``` - ---- - -## ⚠️ 에러 처리 - -### API Key 미설정 -``` -❌ API_KEY is not configured! -📝 Please check: - 1. .env.local file exists - 2. API_KEY is set correctly - 3. Restart development server (npm run dev) - -💡 Contact backend team if you need a new API key. -``` - -**해결 방법:** -1. `.env.local` 파일 생성 확인 -2. `API_KEY=실제키값` 입력 -3. `npm run dev` 재시작 - -### API Key 형식 오류 -``` -❌ Invalid API Key format! - - Minimum 32 characters required - - Only alphanumeric characters allowed -``` - -**해결 방법:** -1. PHP 팀에서 발급받은 키 확인 -2. 복사 시 공백/줄바꿈 없는지 확인 -3. 정확한 키 값 재입력 - ---- - -## 🔍 만료 경고 (선택사항) - -### 만료 체크 기능 -```typescript -// lib/api/auth/key-expiry-check.ts -import { apiKeyValidator } from './api-key-validator'; - -// API Key 발급일 -const issuedDate = new Date('2025-11-07'); - -// 90일 유효기간으로 체크 -const status = apiKeyValidator.checkExpiry(issuedDate, 90); - -console.log(status.message); -// ✅ API Key valid (75 days left) -// ⚠️ API Key expiring in 10 days -// 🔴 API Key expired! Contact backend team. - -if (status.isExpiring) { - console.warn('⚠️ Please contact backend team for new API key!'); -} -``` - ---- - -## 📚 체크리스트 - -### 초기 설정 -- [ ] `.env.local` 파일 생성 -- [ ] `API_KEY` 값 입력 -- [ ] `.gitignore`에 `.env.local` 포함 확인 -- [ ] 개발 서버 시작 후 검증 확인 - -### 키 갱신 시 -- [ ] PHP 팀에서 새 키 수령 -- [ ] `.env.local` 업데이트 -- [ ] 로컬 개발 서버 재시작 -- [ ] 검증 로그 확인 -- [ ] 프로덕션 환경 변수 업데이트 - -### 보안 점검 -- [ ] Git에 `.env.local` 커밋 안됨 -- [ ] 브라우저 코드에서 사용 안함 -- [ ] `NEXT_PUBLIC_` 접두사 없음 -- [ ] 팀 공유 문서에 키 기록 - ---- - -## 🚀 다음 단계 - -API Key 설정 완료 후: -1. `createApiKeyClient()` 사용하여 API 요청 -2. 서버 사이드 코드에서만 호출 -3. 에러 발생 시 검증 로그 확인 -4. 주기적으로 만료 시간 체크 (선택) - ---- - -## 📞 문의 - -- **API Key 발급**: PHP 백엔드 팀 -- **기술 지원**: 프론트엔드 팀 -- **보안 문제**: DevOps/보안 팀 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/lib/api/auth/api-key-client.ts` - API Key 클라이언트 -- `src/lib/api/auth/api-key-validator.ts` - API Key 검증 유틸리티 -- `src/app/api/sync/route.ts` - 서버 사이드 API Route 예시 - -### 설정 파일 -- `.env.local` - 환경 변수 (API_KEY 저장) -- `.env.example` - 환경 변수 템플릿 -- `.gitignore` - Git 제외 설정 \ No newline at end of file diff --git a/claudedocs/api/[IMPL-2025-11-11] api-route-type-safety.md b/claudedocs/api/[IMPL-2025-11-11] api-route-type-safety.md deleted file mode 100644 index 74e6ce73..00000000 --- a/claudedocs/api/[IMPL-2025-11-11] api-route-type-safety.md +++ /dev/null @@ -1,334 +0,0 @@ -# API Route 타입 안전성 가이드 - -## 📋 개요 - -Next.js API Route에서 백엔드 API 응답 데이터를 프론트엔드로 전달할 때, TypeScript 타입 정의를 통해 데이터 누락을 방지하는 방법 - ---- - -## 🎯 문제 사례 - -### 발생한 이슈 -로그인 API를 테스트할 때, API 테스트 도구에서는 `roles` 데이터가 정상적으로 나오지만, 프론트엔드에서는 빈 배열로 나오는 현상 발생 - -### 원인 분석 -```typescript -// ❌ 타입 정의 없이 데이터 전달 (문제 코드) -const responseData = { - message: data.message, - user: data.user, - tenant: data.tenant, - menus: data.menus, - // roles: data.roles, ← 누락됨! - token_type: data.token_type, - expires_in: data.expires_in, - expires_at: data.expires_at, -}; -``` - -**문제점:** -- 백엔드에서 `roles` 데이터를 반환했지만 -- Next.js API Route에서 프론트로 전달할 때 `roles` 필드를 포함하지 않음 -- 타입 정의가 없어서 컴파일 타임에 감지 불가 - ---- - -## ✅ 해결 방법 - -### 1. 백엔드 응답 타입 정의 - -```typescript -/** - * 백엔드 API 로그인 응답 타입 - */ -interface BackendLoginResponse { - message: string; - access_token: string; - refresh_token: string; - token_type: string; - expires_in: number; - expires_at: string; - user: { - id: number; - user_id: string; - name: string; - email: string; - phone: string; - }; - tenant: { - id: number; - company_name: string; - business_num: string; - tenant_st_code: string; - other_tenants: any[]; - }; - menus: Array<{ - id: number; - parent_id: number | null; - name: string; - url: string; - icon: string; - sort_order: number; - is_external: number; - external_url: string | null; - }>; - roles: Array<{ - id: number; - name: string; - description: string; - }>; -} -``` - -### 2. 프론트엔드 응답 타입 정의 - -```typescript -/** - * 프론트엔드로 전달할 응답 타입 (토큰 제외) - */ -interface FrontendLoginResponse { - message: string; - user: BackendLoginResponse['user']; - tenant: BackendLoginResponse['tenant']; - menus: BackendLoginResponse['menus']; - roles: BackendLoginResponse['roles']; // ✅ 명시적으로 포함 - token_type: string; - expires_in: number; - expires_at: string; -} -``` - -### 3. 타입 적용 - -```typescript -export async function POST(request: NextRequest) { - try { - // ... 백엔드 API 호출 - - // ✅ 타입 지정 - const data: BackendLoginResponse = await backendResponse.json(); - - // ✅ 타입 지정 + 모든 필드 포함 - const responseData: FrontendLoginResponse = { - message: data.message, - user: data.user, - tenant: data.tenant, - menus: data.menus, - roles: data.roles, // ✅ 누락 방지 - token_type: data.token_type, - expires_in: data.expires_in, - expires_at: data.expires_at, - }; - - return NextResponse.json(responseData, { status: 200 }); - } catch (error) { - // ... 에러 처리 - } -} -``` - ---- - -## 🎁 타입 정의의 장점 - -### 1. 컴파일 타임 에러 감지 -```typescript -// ❌ roles 누락 시 TypeScript 에러 발생 -const responseData: FrontendLoginResponse = { - message: data.message, - user: data.user, - // ... roles 필드 빠짐 - // ⚠️ Type Error: Property 'roles' is missing in type -}; -``` - -### 2. 자동 완성 지원 -- IDE에서 필드명 자동 완성 -- 오타 방지 -- 개발 생산성 향상 - -### 3. API 문서 역할 -- 백엔드 API 스펙이 코드에 명시됨 -- 별도 문서 없이도 데이터 구조 파악 가능 -- 팀원 간 커뮤니케이션 비용 절감 - -### 4. 리팩토링 안정성 -- 백엔드 API 변경 시 즉시 감지 -- 영향 범위 파악 용이 -- 안전한 코드 수정 - ---- - -## 📝 적용 체크리스트 - -### API Route 작성 시 필수 사항 - -- [ ] 백엔드 응답 타입 인터페이스 정의 -- [ ] 프론트엔드 응답 타입 인터페이스 정의 -- [ ] `await response.json()` 시 타입 지정 -- [ ] 프론트 응답 객체에 타입 지정 -- [ ] 모든 필수 필드 포함 확인 - -### 타입 정의 원칙 - -```typescript -// ✅ Good: 명시적 타입 지정 -const data: BackendResponse = await response.json(); -const result: FrontendResponse = { - // ... 모든 필드 포함 -}; - -// ❌ Bad: 타입 없이 작성 -const data = await response.json(); -const result = { - // ... 필드 누락 가능성 -}; -``` - ---- - -## 🔍 실제 적용 예시 - -### 파일 위치 -``` -src/app/api/auth/login/route.ts -``` - -### Before (문제 코드) -```typescript -export async function POST(request: NextRequest) { - // ... - const data = await backendResponse.json(); // 타입 없음 - - const responseData = { - message: data.message, - user: data.user, - menus: data.menus, - // roles 누락! - }; - - return NextResponse.json(responseData); -} -``` - -### After (개선 코드) -```typescript -interface BackendLoginResponse { - // ... 전체 타입 정의 - roles: Array<{ id: number; name: string; description: string }>; -} - -interface FrontendLoginResponse { - // ... 전체 타입 정의 - roles: BackendLoginResponse['roles']; -} - -export async function POST(request: NextRequest) { - // ... - const data: BackendLoginResponse = await backendResponse.json(); - - const responseData: FrontendLoginResponse = { - message: data.message, - user: data.user, - menus: data.menus, - roles: data.roles, // ✅ 명시적 포함 - // ... 기타 필드 - }; - - return NextResponse.json(responseData); -} -``` - ---- - -## 🚨 주의사항 - -### 1. 타입과 실제 데이터 불일치 -```typescript -// ⚠️ 백엔드 API 스펙 변경 시 -interface BackendResponse { - // 타입 정의는 그대로인데 - user_name: string; -} - -// 실제 응답은 변경됨 -{ - "username": "홍길동" // 필드명 변경됨 -} -``` - -**대응 방안:** -- 백엔드 API 스펙 변경 시 타입 정의도 함께 업데이트 -- API 응답 검증 로직 추가 (런타임 체크) -- 백엔드 팀과 스펙 변경 사전 공유 - -### 2. Optional vs Required -```typescript -// 명확한 옵셔널 표시 -interface Response { - required_field: string; // 필수 - optional_field?: string; // 선택 - nullable_field: string | null; // null 가능 -} -``` - -### 3. any 타입 남용 금지 -```typescript -// ❌ Bad -interface Response { - data: any; // 타입 안전성 상실 -} - -// ✅ Good -interface Response { - data: { - id: number; - name: string; - }; -} -``` - ---- - -## 📚 관련 문서 - -- [Authentication Implementation Guide](./[IMPL-2025-11-07]%20authentication-implementation-guide.md) -- [Token Management Guide](./[IMPL-2025-11-10]%20token-management-guide.md) -- [API Requirements](./[REF]%20api-requirements.md) - ---- - -## 📌 핵심 요약 - -1. **API Route는 백엔드와 프론트 사이의 중간 레이어** - - 데이터 변환/필터링 역할 수행 - - 타입 정의로 누락 방지 - -2. **타입 정의의 3가지 핵심 가치** - - 컴파일 타임 에러 감지 - - 개발 생산성 향상 (자동완성) - - 리팩토링 안정성 보장 - -3. **실무 적용 원칙** - - 백엔드 응답 타입 → 프론트 응답 타입 순서로 정의 - - 모든 API Route에 타입 적용 - - 백엔드 스펙 변경 시 타입도 함께 업데이트 - ---- - -**작성일:** 2025-11-11 -**작성자:** Claude Code -**마지막 수정:** 2025-11-11 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/app/api/auth/login/route.ts` - 로그인 API Route -- `src/types/auth.ts` - 인증 타입 정의 -- `src/lib/api/auth/types.ts` - API 인증 타입 - -### 참조 문서 -- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` -- `claudedocs/auth/[IMPL-2025-11-10] token-management-guide.md` diff --git a/claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md b/claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md deleted file mode 100644 index a06d1c56..00000000 --- a/claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md +++ /dev/null @@ -1,262 +0,0 @@ -# Fetch Wrapper Migration Checklist - -**생성일**: 2025-12-30 -**목적**: 모든 Server Actions의 API 통신을 `serverFetch`로 중앙화 - -## 목적 및 배경 - -### 왜 fetch-wrapper를 도입했는가? - -1. **중앙화된 인증 처리** - - 401 에러(세션 만료) 발생 시 → 로그인 페이지 리다이렉트 - - 모든 API 호출에서 **일관된 인증 검증** - -2. **개발 규칙 표준화** - - 새 작업자도 `serverFetch` 사용하면 자동으로 인증 검증 적용 - - 개별 파일마다 인증 로직 구현 불필요 - -3. **유지보수성 향상** - - 인증 로직 변경 시 **`fetch-wrapper.ts` 한 파일만** 수정 - - 403, 네트워크 에러 등 공통 에러 처리도 중앙화 - ---- - -## 마이그레이션 패턴 - -### Before (기존 패턴) -```typescript -import { cookies } from 'next/headers'; - -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - return { - 'Authorization': token ? `Bearer ${token}` : '', - // ... - }; -} - -export async function getSomething() { - const headers = await getApiHeaders(); - const response = await fetch(url, { headers }); - // 401 처리 없음! -} -``` - -### After (새 패턴) -```typescript -import { serverFetch } from '@/lib/api/fetch-wrapper'; - -export async function getSomething() { - const { response, error } = await serverFetch(url, { method: 'GET' }); - - if (error) { - // 401/403/네트워크 에러 자동 처리됨 - return { success: false, error: error.message }; - } - - const data = await response.json(); - // ... -} -``` - ---- - -## 마이그레이션 체크리스트 - -### Accounting 도메인 (12 files) ✅ 완료 -- [x] `SalesManagement/actions.ts` -- [x] `VendorManagement/actions.ts` -- [x] `PurchaseManagement/actions.ts` -- [x] `DepositManagement/actions.ts` -- [x] `WithdrawalManagement/actions.ts` -- [x] `VendorLedger/actions.ts` -- [x] `ReceivablesStatus/actions.ts` -- [x] `ExpectedExpenseManagement/actions.ts` -- [x] `CardTransactionInquiry/actions.ts` -- [x] `DailyReport/actions.ts` -- [x] `BadDebtCollection/actions.ts` -- [x] `BankTransactionInquiry/actions.ts` - -### HR 도메인 (6 files) ✅ 완료 -- [x] `EmployeeManagement/actions.ts` ✅ (이미 마이그레이션됨) -- [x] `VacationManagement/actions.ts` ✅ -- [x] `SalaryManagement/actions.ts` ✅ -- [x] `CardManagement/actions.ts` ✅ -- [x] `DepartmentManagement/actions.ts` ✅ -- [x] `AttendanceManagement/actions.ts` ✅ - -### Approval 도메인 (4 files) ✅ 완료 -- [x] `ApprovalBox/actions.ts` -- [x] `DraftBox/actions.ts` -- [x] `ReferenceBox/actions.ts` -- [x] `DocumentCreate/actions.ts` (파일 업로드는 직접 fetch 유지) - -### Production 도메인 (4 files) ✅ 완료 -- [x] `WorkerScreen/actions.ts` -- [x] `WorkOrders/actions.ts` -- [x] `WorkResults/actions.ts` -- [x] `ProductionDashboard/actions.ts` - -### Settings 도메인 (10 files) ✅ 완료 -- [x] `WorkScheduleManagement/actions.ts` -- [x] `SubscriptionManagement/actions.ts` -- [x] `PopupManagement/actions.ts` -- [x] `PaymentHistoryManagement/actions.ts` -- [x] `LeavePolicyManagement/actions.ts` -- [x] `NotificationSettings/actions.ts` -- [x] `AttendanceSettingsManagement/actions.ts` -- [x] `CompanyInfoManagement/actions.ts` -- [x] `AccountInfoManagement/actions.ts` -- [x] `AccountManagement/actions.ts` - -### 기타 도메인 (12 files) ✅ 완료 -- [x] `process-management/actions.ts` -- [x] `outbound/ShipmentManagement/actions.ts` -- [x] `material/StockStatus/actions.ts` -- [x] `material/ReceivingManagement/actions.ts` -- [x] `customer-center/shared/actions.ts` -- [x] `board/actions.ts` -- [x] `reports/actions.ts` -- [x] `quotes/actions.ts` -- [x] `board/BoardManagement/actions.ts` -- [x] `attendance/actions.ts` -- [x] `pricing/actions.ts` -- [x] `quality/InspectionManagement/actions.ts` - ---- - -## 진행 상황 - -| 도메인 | 파일 수 | 완료 | 상태 | -|--------|---------|------|------| -| Accounting | 12 | 12 | ✅ 완료 | -| HR | 6 | 6 | ✅ 완료 | -| Approval | 4 | 4 | ✅ 완료 | -| Production | 4 | 4 | ✅ 완료 | -| Settings | 10 | 10 | ✅ 완료 | -| 기타 | 12 | 12 | ✅ 완료 | -| **총계** | **48** | **48** | **100%** ✅ | - -### 완료된 파일 (완전 마이그레이션) - -**Accounting 도메인 (12/12)** -- [x] `SalesManagement/actions.ts` -- [x] `VendorManagement/actions.ts` -- [x] `PurchaseManagement/actions.ts` -- [x] `DepositManagement/actions.ts` -- [x] `WithdrawalManagement/actions.ts` -- [x] `VendorLedger/actions.ts` -- [x] `ReceivablesStatus/actions.ts` -- [x] `ExpectedExpenseManagement/actions.ts` -- [x] `CardTransactionInquiry/actions.ts` -- [x] `DailyReport/actions.ts` -- [x] `BadDebtCollection/actions.ts` -- [x] `BankTransactionInquiry/actions.ts` - -**HR 도메인 (6/6)** -- [x] `EmployeeManagement/actions.ts` (이미 마이그레이션됨) -- [x] `VacationManagement/actions.ts` -- [x] `SalaryManagement/actions.ts` -- [x] `CardManagement/actions.ts` -- [x] `DepartmentManagement/actions.ts` -- [x] `AttendanceManagement/actions.ts` - -**Approval 도메인 (4/4)** -- [x] `ApprovalBox/actions.ts` -- [x] `DraftBox/actions.ts` -- [x] `ReferenceBox/actions.ts` -- [x] `DocumentCreate/actions.ts` (파일 업로드는 직접 fetch 유지) - -**Production 도메인 (4/4)** -- [x] `WorkerScreen/actions.ts` -- [x] `WorkOrders/actions.ts` -- [x] `WorkResults/actions.ts` -- [x] `ProductionDashboard/actions.ts` - -**Settings 도메인 (10/10)** -- [x] `WorkScheduleManagement/actions.ts` -- [x] `SubscriptionManagement/actions.ts` -- [x] `PopupManagement/actions.ts` -- [x] `PaymentHistoryManagement/actions.ts` -- [x] `LeavePolicyManagement/actions.ts` -- [x] `NotificationSettings/actions.ts` -- [x] `AttendanceSettingsManagement/actions.ts` -- [x] `CompanyInfoManagement/actions.ts` -- [x] `AccountInfoManagement/actions.ts` -- [x] `AccountManagement/actions.ts` - -**기타 도메인 (12/12)** ✅ 완료 -- [x] `process-management/actions.ts` -- [x] `outbound/ShipmentManagement/actions.ts` -- [x] `material/StockStatus/actions.ts` -- [x] `material/ReceivingManagement/actions.ts` -- [x] `customer-center/shared/actions.ts` -- [x] `board/actions.ts` -- [x] `reports/actions.ts` -- [x] `quotes/actions.ts` -- [x] `board/BoardManagement/actions.ts` -- [x] `attendance/actions.ts` -- [x] `pricing/actions.ts` -- [x] `quality/InspectionManagement/actions.ts` - ---- - -## 참조 파일 - -- **fetch-wrapper**: `src/lib/api/fetch-wrapper.ts` -- **errors**: `src/lib/api/errors.ts` -- **완료된 예시**: `src/components/accounting/BillManagement/actions.ts` (참고용) - ---- - -## 주의사항 - -1. **기존 `getApiHeaders()` 함수 제거** - `serverFetch`가 헤더 자동 생성 -2. **`import { cookies } from 'next/headers'` 제거** - wrapper에서 처리 -3. **에러 응답 구조 맞추기** - `{ success: false, error: string }` 형태 유지 -4. **빌드 테스트 필수** - 마이그레이션 후 `npm run build` 확인 - ---- - -## 🔜 추가 작업 (마이그레이션 완료 후) - -### Phase 2: 리프레시 토큰 자동 갱신 적용 - -**현재 문제:** -- access_token 만료 시 (약 2시간) 바로 로그인 리다이렉트됨 -- refresh_token (7일)을 사용한 자동 갱신 로직이 호출되지 않음 -- 결과: 40분~2시간 후 세션 만료 → 재로그인 필요 - -**목표:** -- 401 발생 시 → 리프레시 토큰으로 갱신 시도 → 성공 시 재시도 -- 7일간 세션 유지 (refresh_token 만료 시에만 재로그인) - -**적용 범위:** - -| 영역 | 적용 위치 | 작업 | -|------|----------|------| -| Server Actions | `fetch-wrapper.ts` | 401 시 리프레시 후 재시도 로직 추가 | -| 품목관리 | `ItemListClient.tsx` 등 | 클라이언트 fetch에 리프레시 로직 추가 | -| 품목기준관리 | 관련 컴포넌트들 | 클라이언트 fetch에 리프레시 로직 추가 | - -**관련 파일:** -- `src/lib/auth/token-refresh.ts` - 리프레시 함수 (이미 존재) -- `src/app/api/auth/refresh/route.ts` - 리프레시 API (이미 존재) - -**예상 구현:** -```typescript -// fetch-wrapper.ts 401 처리 부분 -if (response.status === 401 && !options?.skipAuthCheck) { - // 1. 리프레시 토큰으로 갱신 시도 - const refreshResult = await refreshTokenServer(refreshToken); - - if (refreshResult.success) { - // 2. 새 토큰으로 원래 요청 재시도 - return serverFetch(url, { ...options, skipAuthCheck: true }); - } - - // 3. 리프레시도 실패하면 로그인 리다이렉트 - redirect('/login'); -} -``` diff --git a/claudedocs/api/[IMPL-2026-03-18] expense-accounts-receipt-no.md b/claudedocs/api/[IMPL-2026-03-18] expense-accounts-receipt-no.md deleted file mode 100644 index ff98b49c..00000000 --- a/claudedocs/api/[IMPL-2026-03-18] expense-accounts-receipt-no.md +++ /dev/null @@ -1,70 +0,0 @@ -# 접대비 증빙번호(receipt_no) 자동 매핑 및 수기 입력 지원 - -## 날짜: 2026-03-18 - -## 배경 -CEO 대시보드 접대비 현황에서 "증빙 미비"로 표시되는 항목의 근본 원인: -- `expense_accounts.receipt_no`가 항상 `null`로 고정 저장됨 -- 카드 거래(바로빌) 승인번호가 전달되지 않음 -- 수기 전표 입력 시 증빙번호 입력 필드 부재 - -## 수정 파일 - -### 백엔드 (sam-api) - -| 파일 | 변경 내용 | -|------|----------| -| `app/Traits/SyncsExpenseAccounts.php` | `syncExpenseAccounts()`에 `$receiptNo` 파라미터 추가 + `resolveReceiptNo()` 메서드 신규 | -| `app/Services/JournalSyncService.php` | `saveForSource()`에 `$receiptNo` 파라미터 추가 → `syncExpenseAccounts()`에 전달 | -| `app/Services/GeneralJournalEntryService.php` | `store()`, `updateJournal()`에서 `$data['receipt_no']` → `syncExpenseAccounts()`에 전달 | -| `app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php` | `receipt_no` validation 규칙 추가 (`nullable\|string\|max:100`) | -| `app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php` | `receipt_no` validation 규칙 추가 (`nullable\|string\|max:100`) | - -### 프론트엔드 (sam-react-prod) - -| 파일 | 변경 내용 | -|------|----------| -| `src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx` | 증빙번호 입력 필드 추가 (FormField) | -| `src/components/accounting/GeneralJournalEntry/actions.ts` | `createManualJournal()`에 `receiptNo` 파라미터 추가 → `receipt_no` body 전달 | - -## 증빙번호 결정 로직 (우선순위) - -``` -1순위: 명시적 전달 ($receiptNo 파라미터) — 수기 전표에서 사용자가 직접 입력 -2순위: 바로빌 카드 승인번호 자동 조회 — source_type=barobill_card일 때 approval_num -3순위: null — 기본값 (증빙 미비로 판정됨) -``` - -## SyncsExpenseAccounts 변경 상세 - -### Before -```php -ExpenseAccount::create([ - 'receipt_no' => null, // 항상 null -]); -``` - -### After -```php -// 증빙번호 결정: 명시 전달 > 바로빌 승인번호 > null -$resolvedReceiptNo = $receiptNo ?? $this->resolveReceiptNo($entry); - -ExpenseAccount::create([ - 'receipt_no' => $resolvedReceiptNo, -]); -``` - -### resolveReceiptNo() 신규 메서드 -- `SOURCE_BAROBILL_CARD` → `source_key`에서 ID 추출 → `BarobillCardTransaction.approval_num` 조회 -- 그 외 → `null` - -## 영향 범위 -- CEO 대시보드 접대비 현황: 증빙 미비 건수 정확도 향상 -- CEO 대시보드 복리후생비 현황: 동일 트레이트 사용으로 함께 개선 -- 일반전표입력: 증빙번호 필드 추가 (UI) -- 카드사용내역 분개: 바로빌 승인번호 자동 매핑 (추가 UI 변경 없음) - -## 테스트 결과 -- 수기 전표에 증빙번호 입력 → expense_accounts.receipt_no에 저장 확인 -- 기존 미증빙 전표에 증빙번호 PUT → 증빙 미비 해소 확인 -- CEO 대시보드 접대비 현황: 증빙 미비 0건 / 고액 결제 0건 확인 diff --git a/claudedocs/api/[REF] api-analysis.md b/claudedocs/api/[REF] api-analysis.md deleted file mode 100644 index 0175f239..00000000 --- a/claudedocs/api/[REF] api-analysis.md +++ /dev/null @@ -1,342 +0,0 @@ -# SAM API 분석 결과 - -API 문서: https://api.5130.co.kr/docs?api-docs-v1.json - -## 🔍 핵심 발견사항 - -### 1. 인증 방식 - -**현재 API 문서에서 확인된 인증 방식:** -``` -❌ 세션 쿠키 기반 (Sanctum SPA 모드) - 없음 -✅ Bearer Token (JWT) 방식 -✅ API Key 방식 -``` - -### 2. 보안 스킴 - -```yaml -securitySchemes: - ApiKeyAuth: - type: apiKey - in: header - name: X-API-KEY (추정) - - BearerAuth: - type: http - scheme: bearer - bearerFormat: JWT -``` - -**사용 패턴:** -- 대부분의 엔드포인트: `ApiKeyAuth` OR `BearerAuth` -- 두 방식 중 선택 가능 - -### 3. User 관련 엔드포인트 (Admin) - -**POST /api/v1/admin/users** (사용자 생성) -```json -{ - "name": "string", // 필수 - "email": "string", // 필수 - "password": "string", // 필수 - "user_id": "string", // 선택 - "phone": "string", // 선택 - "roles": ["string"] // 선택 -} -``` - -**성공 응답 (201):** -```json -{ - "id": 1, - "name": "John Doe", - "email": "user@example.com", - "created_at": "2024-01-01T00:00:00Z" -} -``` - -**에러 응답:** -- 409: 이메일 중복 -- 400: 필수 파라미터 누락 - -## ⚠️ 중요한 발견 - -### 인증 엔드포인트가 문서에 없음 - -**현재 문서에서 찾을 수 없는 엔드포인트:** -``` -❌ POST /api/auth/login -❌ POST /api/auth/register -❌ POST /api/auth/logout -❌ GET /api/auth/user -❌ POST /api/auth/refresh -❌ GET /sanctum/csrf-cookie -``` - -**이유:** -1. 아직 구성 중이라 문서화 안됨 -2. 별도 인증 서버 존재 가능성 -3. 다른 경로에 존재 (예: /api/v1/auth/*) - -## 🎯 설계 조정 필요 - -### 원래 설계 (Sanctum SPA 모드) -``` -인증: HTTP-only 쿠키 -저장: 서버 세션 -CSRF: 필요 -Middleware: 쿠키 확인 -``` - -### 새로운 설계 (Bearer Token 모드) -``` -인증: JWT Bearer Token -저장: localStorage 또는 쿠키 -CSRF: 불필요 -Middleware: Token 확인 (클라이언트 사이드) -``` - -## 📋 두 가지 시나리오 - -### 시나리오 A: Bearer Token (JWT) 방식 - -**장점:** -- 현재 API 구조와 일치 -- Stateless (서버 세션 불필요) -- 모바일 앱 지원 용이 -- API Key 또는 Token 선택 가능 - -**단점:** -- XSS 취약 (localStorage 사용 시) -- Token 관리 복잡 (refresh token 등) -- CORS 이슈 가능성 - -**구현 방식:** -```typescript -// 1. 로그인 → JWT 토큰 받기 -const { token } = await login(email, password); -localStorage.setItem('token', token); - -// 2. API 요청 시 토큰 포함 -fetch('/api/endpoint', { - headers: { - 'Authorization': `Bearer ${token}` - } -}); - -// 3. Middleware는 클라이언트에서 체크 -// (서버 Middleware에서는 체크 불가) -``` - -**Middleware 제약:** -- Next.js Middleware는 서버사이드 실행 -- localStorage 접근 불가 -- Token 검증 어려움 -- **→ 클라이언트 가드 컴포넌트 필요** - ---- - -### 시나리오 B: 세션 쿠키 방식 (권장) - -**장점:** -- 서버 Middleware에서 인증 체크 가능 -- XSS 방어 (HTTP-only 쿠키) -- CSRF 토큰으로 보안 강화 -- 기존 설계 그대로 사용 - -**단점:** -- Laravel API 수정 필요 -- 세션 관리 필요 - -**필요한 Laravel 변경:** -```php -// config/sanctum.php -'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost:3000')), - -// API Routes -Route::post('/login', [AuthController::class, 'login']); // 세션 생성 -Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); -Route::get('/user', [AuthController::class, 'user'])->middleware('auth:sanctum'); -``` - -**프론트엔드는 기존 설계 그대로:** -```typescript -// Middleware에서 쿠키 확인 -const sessionCookie = request.cookies.get('laravel_session'); -if (!sessionCookie) redirect('/login'); -``` - ---- - -## 🤔 권장사항 - -### 1차 선택: **백엔드 개발자와 협의 필요** - -**질문할 사항:** -``` -Q1. 인증 방식이 정해졌나요? - A. Bearer Token (JWT) - B. 세션 쿠키 (Sanctum SPA) - C. 둘 다 지원 - -Q2. 로그인/회원가입 API 경로는? - 예: POST /api/v1/auth/login? - -Q3. 로그인 응답 형식은? - A. { token: "xxx" } // JWT - B. { user: {...} } // 세션 + 쿠키 - -Q4. Token refresh 로직 있나요? (JWT인 경우) - -Q5. CORS 설정 완료? - - Allow Origin: http://localhost:3000 - - Allow Credentials: true (쿠키 사용 시) -``` - -### 2차 선택: **시나리오별 구현 방식** - -#### Option A: Bearer Token으로 진행 -```typescript -// 장점: 현재 API 구조 그대로 사용 -// 단점: Middleware 인증 체크 불가, 클라이언트 가드 필요 - -// lib/auth/token-client.ts -class TokenClient { - async login(email: string, password: string) { - const { token } = await fetch('/api/v1/auth/login', { - method: 'POST', - body: JSON.stringify({ email, password }) - }).then(r => r.json()); - - localStorage.setItem('auth_token', token); - } - - getToken() { - return localStorage.getItem('auth_token'); - } -} - -// components/ProtectedRoute.tsx (클라이언트 가드) -function ProtectedRoute({ children }) { - const token = localStorage.getItem('auth_token'); - - if (!token) { - redirect('/login'); - } - - return children; -} -``` - -#### Option B: 세션 쿠키로 진행 (권장) -```typescript -// 장점: Middleware 인증, 보안 강화 -// 단점: Laravel API 수정 필요 - -// 기존 설계 문서 그대로 구현 -// claudedocs/authentication-design.md 참고 -``` - ---- - -## 📝 다음 단계 - -### 1. 백엔드 개발자와 협의 ✅ 최우선 - -**확인 사항:** -- [ ] 인증 방식 확정 (JWT vs 세션) -- [ ] 로그인/회원가입 API 경로 -- [ ] 응답 형식 -- [ ] CORS 설정 - -### 2. 협의 결과에 따라 - -**A. Bearer Token 방식:** -- [ ] Token 클라이언트 구현 -- [ ] AuthContext (Token 저장/관리) -- [ ] 클라이언트 가드 컴포넌트 -- [ ] API 인터셉터 (Token 자동 추가) - -**B. 세션 쿠키 방식:** -- [ ] 기존 설계 그대로 구현 -- [ ] Sanctum 클라이언트 -- [ ] Middleware 인증 로직 -- [ ] 로그인/회원가입 페이지 - -### 3. API 테스트 - -**Bearer Token 테스트:** -```bash -# 로그인 -curl -X POST https://api.5130.co.kr/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"test@test.com","password":"password"}' - -# 응답 예상 -{"token": "eyJhbGciOiJIUzI1NiIs..."} - -# 인증 요청 -curl -X GET https://api.5130.co.kr/api/v1/user \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." -``` - -**세션 쿠키 테스트:** -```bash -# CSRF 토큰 -curl -X GET https://api.5130.co.kr/sanctum/csrf-cookie -c cookies.txt - -# 로그인 -curl -X POST https://api.5130.co.kr/api/login \ - -b cookies.txt -c cookies.txt \ - -d '{"email":"test@test.com","password":"password"}' - -# 사용자 정보 -curl -X GET https://api.5130.co.kr/api/user \ - -b cookies.txt -``` - ---- - -## 🎯 현재 상태 - -**대기 사항:** -1. ✅ API 문서 분석 완료 -2. ⏳ 인증 방식 확정 대기 -3. ⏳ 실제 로그인 API 경로 확인 대기 -4. ⏳ 응답 형식 확인 대기 - -**다음 액션:** -- 백엔드 개발자와 인증 방식 협의 -- 결정되면 즉시 구현 시작 - ---- - -## 💡 개인적 권장 - -**세션 쿠키 방식 (Sanctum SPA) 추천 이유:** - -1. **보안**: HTTP-only 쿠키로 XSS 방어 -2. **Middleware 활용**: 서버사이드 인증 체크 -3. **간단함**: CSRF 토큰만 관리하면 됨 -4. **Laravel 친화적**: Sanctum이 기본 제공 -5. **우리 설계와 완벽히 일치**: 기존 문서 그대로 사용 - -하지만 최종 결정은 백엔드 아키텍처와 요구사항에 따라야 합니다! - -**백엔드 개발자에게 이 문서 공유 후 협의 추천** 👍 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/lib/api/client.ts` - 통합 HTTP 클라이언트 -- `src/lib/api/auth/token-storage.ts` - Token 저장 관리 -- `src/lib/api/auth/auth-config.ts` - 인증 설정 -- `src/middleware.ts` - 인증 미들웨어 -- `src/contexts/AuthContext.tsx` - 인증 상태 관리 - -### 설정 파일 -- `.env.local` - 환경 변수 -- `next.config.ts` - Next.js 설정 \ No newline at end of file diff --git a/claudedocs/api/[REF] api-requirements.md b/claudedocs/api/[REF] api-requirements.md deleted file mode 100644 index 3d0eb057..00000000 --- a/claudedocs/api/[REF] api-requirements.md +++ /dev/null @@ -1,436 +0,0 @@ -# Laravel API 요구사항 체크리스트 - -프론트엔드 인증 구현을 위해 백엔드에서 준비해야 할 API 목록입니다. - -## 📋 필수 API 엔드포인트 - -### 1. CSRF 토큰 발급 -```http -GET /sanctum/csrf-cookie -``` - -**응답:** -``` -Set-Cookie: XSRF-TOKEN=xxx; Path=/; HttpOnly -Status: 204 No Content -``` - -**용도:** 로그인/회원가입 전에 CSRF 토큰 획득 - ---- - -### 2. 로그인 -```http -POST /api/login -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "password123" -} -``` - -**성공 응답 (200):** -```json -{ - "user": { - "id": 1, - "name": "John Doe", - "email": "user@example.com", - "created_at": "2024-01-01T00:00:00.000000Z" - }, - "message": "로그인 성공" -} - -Set-Cookie: laravel_session=xxx; Path=/; HttpOnly; SameSite=Lax -``` - -**실패 응답 (422):** -```json -{ - "message": "The provided credentials are incorrect.", - "errors": { - "email": ["The provided credentials are incorrect."] - } -} -``` - -**필요 정보:** -- ✅ 응답에 user 객체 포함 여부? -- ✅ user 객체 구조 (어떤 필드들 포함?) -- ✅ 세션 쿠키 이름 (laravel_session?) - ---- - -### 3. 회원가입 -```http -POST /api/register -Content-Type: application/json - -{ - "name": "John Doe", - "email": "user@example.com", - "password": "password123", - "password_confirmation": "password123" -} -``` - -**성공 응답 (201):** -```json -{ - "user": { - "id": 1, - "name": "John Doe", - "email": "user@example.com", - "created_at": "2024-01-01T00:00:00.000000Z" - }, - "message": "회원가입 성공" -} - -Set-Cookie: laravel_session=xxx; Path=/; HttpOnly; SameSite=Lax -``` - -**Validation 실패 (422):** -```json -{ - "message": "The email has already been taken.", - "errors": { - "email": ["The email has already been taken."], - "password": ["The password must be at least 8 characters."] - } -} -``` - -**필요 정보:** -- ✅ 회원가입 필수 필드? (name, email, password만?) -- ✅ 추가 필드 필요? (phone, company, etc.) -- ✅ 비밀번호 규칙? (최소 8자? 특수문자 필수?) -- ✅ 이메일 인증 필요? (즉시 로그인 vs 이메일 확인 후) - ---- - -### 4. 현재 사용자 정보 -```http -GET /api/user -Cookie: laravel_session=xxx -``` - -**성공 응답 (200):** -```json -{ - "id": 1, - "name": "John Doe", - "email": "user@example.com", - "role": "user", - "permissions": ["read", "write"], - "created_at": "2024-01-01T00:00:00.000000Z" -} -``` - -**인증 실패 (401):** -```json -{ - "message": "Unauthenticated." -} -``` - -**필요 정보:** -- ✅ user 객체 전체 구조 -- ✅ role/permission 시스템 사용 여부? -- ✅ 추가 사용자 정보 (profile, settings 등) - ---- - -### 5. 로그아웃 -```http -POST /api/logout -Cookie: laravel_session=xxx -``` - -**성공 응답 (200):** -```json -{ - "message": "로그아웃 성공" -} - -Set-Cookie: laravel_session=; expires=Thu, 01 Jan 1970 00:00:00 GMT -``` - ---- - -### 6. 비밀번호 재설정 (선택적) -```http -POST /api/forgot-password -Content-Type: application/json - -{ - "email": "user@example.com" -} -``` - -**성공 응답 (200):** -```json -{ - "message": "비밀번호 재설정 링크가 이메일로 전송되었습니다." -} -``` - ---- - -## 🔧 Laravel 설정 확인 사항 - -### 1. Sanctum 설정 (config/sanctum.php) -```php -'stateful' => explode(',', env( - 'SANCTUM_STATEFUL_DOMAINS', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:3000,::1' -)), -``` - -**확인 필요:** -- ✅ Next.js 개발 서버 도메인 포함? (localhost:3000) -- ✅ 프로덕션 도메인 설정? - ---- - -### 2. CORS 설정 (config/cors.php) -```php -'paths' => ['api/*', 'sanctum/csrf-cookie'], -'supports_credentials' => true, -'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')], -'allowed_methods' => ['*'], -'allowed_headers' => ['*'], -'exposed_headers' => [], -'max_age' => 0, -``` - -**확인 필요:** -- ✅ `supports_credentials` = true? -- ✅ `allowed_origins`에 Next.js URL 포함? - ---- - -### 3. 세션 설정 (config/session.php) -```php -'driver' => env('SESSION_DRIVER', 'file'), -'lifetime' => 120, -'expire_on_close' => false, -'encrypt' => false, -'http_only' => true, -'same_site' => 'lax', -'secure' => env('SESSION_SECURE_COOKIE', false), -'domain' => env('SESSION_DOMAIN'), -``` - -**확인 필요:** -- ✅ `http_only` = true? -- ✅ `same_site` = 'lax'? -- ✅ `domain` 설정 (개발: null, 프로덕션: .yourdomain.com) -- ✅ 세션 쿠키 이름? (기본: laravel_session) - ---- - -### 4. 환경 변수 (.env) -```env -# Frontend URL -FRONTEND_URL=http://localhost:3000 - -# Sanctum -SANCTUM_STATEFUL_DOMAINS=localhost:3000 - -# Session -SESSION_DOMAIN=localhost -SESSION_SECURE_COOKIE=false # 개발: false, 프로덕션: true - -# CORS -``` - -**확인 필요:** -- ✅ FRONTEND_URL 설정? -- ✅ SANCTUM_STATEFUL_DOMAINS 설정? - ---- - -## 📝 API 테스트 시나리오 - -### 테스트 1: CSRF + 로그인 플로우 -```bash -# 1. CSRF 토큰 획득 -curl -X GET http://localhost:8000/sanctum/csrf-cookie \ - -H "Accept: application/json" \ - -c cookies.txt - -# 2. 로그인 -curl -X POST http://localhost:8000/api/login \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -b cookies.txt \ - -c cookies.txt \ - -d '{"email":"test@test.com","password":"password123"}' - -# 3. 사용자 정보 확인 -curl -X GET http://localhost:8000/api/user \ - -H "Accept: application/json" \ - -b cookies.txt -``` - -### 테스트 2: 회원가입 플로우 -```bash -# 1. CSRF 토큰 -curl -X GET http://localhost:8000/sanctum/csrf-cookie \ - -c cookies.txt - -# 2. 회원가입 -curl -X POST http://localhost:8000/api/register \ - -H "Content-Type: application/json" \ - -b cookies.txt \ - -c cookies.txt \ - -d '{ - "name":"New User", - "email":"new@test.com", - "password":"password123", - "password_confirmation":"password123" - }' -``` - ---- - -## 🎯 프론트엔드에서 필요한 정보 - -### 1. API Base URL -``` -개발: http://localhost:8000 -프로덕션: https://api.yourdomain.com -``` - -### 2. 세션 쿠키 이름 -``` -기본: laravel_session -커스텀: ___? -``` - -### 3. User 객체 구조 -```typescript -interface User { - id: number; - name: string; - email: string; - // 추가 필드? - role?: string; - permissions?: string[]; - avatar?: string; - created_at: string; - updated_at: string; -} -``` - -### 4. 에러 응답 형식 -```typescript -interface ApiError { - message: string; - errors?: Record; // Validation errors -} -``` - -### 5. 회원가입 필수 필드 -```typescript -interface RegisterData { - name: string; - email: string; - password: string; - password_confirmation: string; - // 추가 필드? - phone?: string; - company?: string; -} -``` - ---- - -## ✅ 체크리스트 - -### Laravel 백엔드 준비 사항 - -- [ ] Sanctum 패키지 설치 및 설정 -- [ ] CORS 설정 완료 -- [ ] 세션 설정 확인 (http_only, same_site) -- [ ] API 엔드포인트 구현 - - [ ] GET /sanctum/csrf-cookie - - [ ] POST /api/login - - [ ] POST /api/register - - [ ] GET /api/user - - [ ] POST /api/logout -- [ ] Validation 규칙 정의 -- [ ] 에러 응답 형식 통일 -- [ ] 로컬 테스트 (curl 또는 Postman) - -### Next.js 프론트엔드 대기 항목 - -- [x] 인증 설계 완료 -- [ ] API 구조 확인 후 구현 시작 - - [ ] lib/auth/sanctum.ts - - [ ] lib/auth/auth-config.ts - - [ ] middleware.ts 업데이트 - - [ ] 로그인 페이지 - - [ ] 회원가입 페이지 - - [ ] 인증 테스트 - ---- - -## 📞 다음 단계 - -**백엔드 개발자에게 전달:** -1. 이 문서의 API 엔드포인트 구현 -2. 위의 curl 테스트로 동작 확인 -3. 다음 정보 공유: - - API Base URL - - User 객체 구조 - - 회원가입 필수 필드 - - 세션 쿠키 이름 (변경한 경우) - -**정보 받으면 즉시 시작:** -1. Sanctum 클라이언트 구현 -2. 로그인/회원가입 페이지 -3. Middleware 인증 로직 추가 -4. 통합 테스트 - ---- - -## 🔍 테스트 계획 - -### Phase 1: API 연동 테스트 -1. CSRF 토큰 획득 확인 -2. 로그인 성공/실패 케이스 -3. 회원가입 Validation -4. 세션 쿠키 저장 확인 - -### Phase 2: Middleware 테스트 -1. 비로그인 상태 → /dashboard 접근 → /login 리다이렉트 -2. 로그인 상태 → /dashboard 접근 → 페이지 표시 -3. 로그인 상태 → /login 접근 → /dashboard 리다이렉트 -4. 로그아웃 → 쿠키 삭제 확인 - -### Phase 3: 통합 테스트 -1. 회원가입 → 자동 로그인 → 대시보드 -2. 로그인 → 페이지 새로고침 → 세션 유지 -3. 로그아웃 → 보호된 페이지 접근 → 차단 - ---- - -**API 준비되면 바로 알려주세요! 🚀** - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/lib/api/client.ts` - 통합 HTTP 클라이언트 -- `src/lib/api/auth/sanctum-client.ts` - Sanctum 클라이언트 -- `src/lib/api/auth/auth-config.ts` - 인증 설정 (라우트, URL) -- `src/middleware.ts` - 인증 미들웨어 -- `src/app/[locale]/(auth)/login/page.tsx` - 로그인 페이지 -- `src/app/[locale]/(auth)/signup/page.tsx` - 회원가입 페이지 - -### 설정 파일 -- `.env.local` - 환경 변수 (API URL, API Key) -- `next.config.ts` - Next.js 설정 \ No newline at end of file diff --git a/claudedocs/approval/[IMPL-2025-12-17] approval-document-checklist.md b/claudedocs/approval/[IMPL-2025-12-17] approval-document-checklist.md deleted file mode 100644 index 4fa1bcf2..00000000 --- a/claudedocs/approval/[IMPL-2025-12-17] approval-document-checklist.md +++ /dev/null @@ -1,134 +0,0 @@ -# 전자결재 문서 작성/상세 기능 구현 체크리스트 - -## 개요 -- **작업일**: 2025-12-17 -- **목표**: 전자결재 기안함 문서 작성 및 상세 모달 구현 - ---- - -## 1. 기안함 목록 페이지 (완료) - -- [x] 기안함 컴포넌트 구현 (`src/components/approval/DraftBox/`) -- [x] 타입 정의 (`types.ts`) -- [x] 메인 컴포넌트 (`index.tsx`) -- [x] 라우트 페이지 (`src/app/[locale]/(protected)/approval/draft/page.tsx`) -- [x] 통계 카드 수정 (진행, 완료, 반려, 임시 저장) -- [x] 체크박스 선택 시에만 작업 버튼 표시 -- [x] 헤더 버튼 순서 조정 (상신/삭제 → 문서 작성) - -**접속 URL**: `http://localhost:3000/ko/approval/draft` - ---- - -## 2. 문서 작성 페이지 (완료) - -### 2.1 공통 컴포넌트 -- [x] 타입 정의 (`src/components/approval/DocumentCreate/types.ts`) -- [x] 기본 정보 섹션 (`BasicInfoSection.tsx`) -- [x] 결재선 섹션 (`ApprovalLineSection.tsx`) -- [x] 참조 섹션 (`ReferenceSection.tsx`) - -### 2.2 문서 유형별 폼 -- [x] 품의서 폼 (`ProposalForm.tsx`) -- [x] 지출결의서 폼 (`ExpenseReportForm.tsx`) -- [x] 지출 예상 내역서 폼 (`ExpenseEstimateForm.tsx`) - - [x] Fragment key 에러 수정 - -### 2.3 메인 컴포넌트 및 라우트 -- [x] 메인 컴포넌트 (`index.tsx`) -- [x] 라우트 페이지 (`src/app/[locale]/(protected)/approval/draft/new/page.tsx`) -- [x] 기안함에서 문서 작성 버튼 클릭 시 페이지 이동 연결 - -**접속 URL**: `http://localhost:3000/ko/approval/draft/new` - ---- - -## 3. 문서 상세 모달 (완료) - -### 3.1 디자인 참고 -- [x] sam-design 프로젝트 `QuoteDetailView.tsx` 산출내역서 모달 구조 분석 - -### 3.2 공통 컴포넌트 (`src/components/approval/DocumentDetail/`) -- [x] 타입 정의 (`types.ts`) -- [x] 결재선 박스 (`ApprovalLineBox.tsx`) - -### 3.3 문서 유형별 컴포넌트 -- [x] 품의서 문서 (`ProposalDocument.tsx`) -- [x] 지출결의서 문서 (`ExpenseReportDocument.tsx`) -- [x] 지출 예상 내역서 문서 (`ExpenseEstimateDocument.tsx`) - -### 3.4 메인 모달 컴포넌트 -- [x] 메인 모달 (`index.tsx` - DocumentDetailModal) - - [x] 상단 버튼: 복제, 수정, 반려, 승인, 인쇄, 공유, 닫기 - - [x] 공유 드롭다운: PDF, 이메일, 팩스, 카카오톡 - - [x] 스크롤 가능한 문서 영역 (A4 형식) - -### 3.5 기안함 연결 -- [x] 기안함 목록에서 문서 클릭 시 조건부 처리 - - 임시저장 상태 → 문서 작성 페이지 (수정 모드) - - 그 외 상태 → 문서 상세 모달 -- [x] 문서 작성 화면에서 상세 버튼 클릭 시 미리보기 모달 - ---- - -## 4. 추가 작업 (완료) - -- [x] 빌드 테스트 (2025-12-17 완료) - - ✓ Compiled successfully in 7.0s - - ✓ Generating static pages (108/108) -- [ ] 근태관리 작업 버튼 수정 확인 (별도 작업) -- [ ] 문서 URL 목록 업데이트 (`claudedocs/[REF] all-pages-test-urls.md`) (별도 작업) - ---- - -## 파일 구조 - -``` -src/components/approval/ -├── DraftBox/ -│ ├── types.ts -│ └── index.tsx -├── DocumentCreate/ -│ ├── types.ts -│ ├── BasicInfoSection.tsx -│ ├── ApprovalLineSection.tsx -│ ├── ReferenceSection.tsx -│ ├── ProposalForm.tsx -│ ├── ExpenseReportForm.tsx -│ ├── ExpenseEstimateForm.tsx -│ └── index.tsx -└── DocumentDetail/ - ├── types.ts - ├── ApprovalLineBox.tsx - ├── ProposalDocument.tsx - ├── ExpenseReportDocument.tsx - ├── ExpenseEstimateDocument.tsx ✅ - └── index.tsx ✅ - -src/app/[locale]/(protected)/approval/ -├── draft/ -│ ├── page.tsx -│ └── new/ -│ └── page.tsx -``` - ---- - -## 참고 사항 - -### 문서 유형 -1. **품의서** (`proposal`) - - 구매처 정보, 제목, 품의 내역, 품의 사유, 예상 비용, 첨부파일 - -2. **지출결의서** (`expenseReport`) - - 지출 요청일/결제일, 내역 테이블, 법인카드, 총 비용, 첨부파일 - -3. **지출 예상 내역서** (`expenseEstimate`) - - 월별 테이블, 소계, 지출 합계, 계좌 잔액, 최종 차액 - -### 모달 디자인 구조 (sam-design 참고) -- Dialog: `max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh]` -- 헤더: 고정 (`flex-shrink-0`) -- 버튼 영역: 고정 (`flex-shrink-0 bg-muted/30`) -- 문서 영역: 스크롤 (`flex-1 overflow-y-auto bg-gray-100`) -- A4 크기: `max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8` \ No newline at end of file diff --git a/claudedocs/approval/[IMPL-2026-03-07] approval-box-linked-documents.md b/claudedocs/approval/[IMPL-2026-03-07] approval-box-linked-documents.md deleted file mode 100644 index fd183b32..00000000 --- a/claudedocs/approval/[IMPL-2026-03-07] approval-box-linked-documents.md +++ /dev/null @@ -1,59 +0,0 @@ -# 전자결재 결재함 확장 및 연결문서 기능 - -> **작업일**: 2026-03-01 ~ 03-07 -> **상태**: ✅ 완료 -> **커밋**: 181352d7, 72cf5d86 - ---- - -## 개요 - -결재함(ApprovalBox) API 연동, 연결문서(LinkedDocumentContent) 렌더링, -모바일 반응형 레이아웃 개선. - ---- - -## 1. 결재함 API 연동 - -- [x] 결재함 목록: `GET /api/v1/approvals/inbox` -- [x] 결재함 통계: `GET /api/v1/approvals/inbox/summary` -- [x] 승인 처리: `POST /api/v1/approvals/{id}/approve` -- [x] 반려 처리: `POST /api/v1/approvals/{id}/reject` -- [x] 문서 상태 매핑: DRAFT → PENDING → APPROVED / REJECTED / CANCELLED -- [x] 결재함 상태 헬퍼 함수 추가 - -### 주요 파일 -- `src/components/approval/ApprovalBox/actions.ts` (+123/-7) -- `src/components/approval/ApprovalBox/index.tsx` (+47/-1) -- `src/components/approval/ApprovalBox/types.ts` (+9/-1) - ---- - -## 2. 연결문서 기능 (LinkedDocumentContent) — 신규 - -검사성적서, 작업일지 등 문서관리 시스템의 문서를 결재 문서에 연결하여 렌더링. - -- [x] `LinkedDocumentContent` 컴포넌트 신규 생성 -- [x] `DocumentHeader` 컴포넌트 활용 (일관된 스타일) -- [x] 결재라인 / 상태배지 / 문서 메타정보 표시 -- [x] `DocumentDetailModalV2`에 연결문서 렌더링 통합 - -### 주요 파일 -- `src/components/approval/DocumentDetail/LinkedDocumentContent.tsx` (신규, +133) -- `src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx` -- `src/components/approval/DocumentDetail/types.ts` (+27/-1) - ---- - -## 3. 모바일 반응형 개선 - -- [x] `AuthenticatedLayout`: 사이드바/메인 콘텐츠 모바일 대응 -- [x] `HeaderFavoritesBar`: 전면 재설계 (+315/-127) -- [x] `Sidebar`: 반응형 숨김/표시 -- [x] `SearchableSelectionModal`: HTML 유효성 에러 수정 - -### 주요 파일 -- `src/layouts/AuthenticatedLayout.tsx` (+12/-1) -- `src/components/layout/HeaderFavoritesBar.tsx` (+315/-127) -- `src/components/layout/Sidebar.tsx` (+8/-1) -- `src/components/organisms/SearchableSelectionModal.tsx` (+79/-2) diff --git a/claudedocs/architecture/.DS_Store b/claudedocs/architecture/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 } -/> -``` -**효과**: -- 폼 레이아웃 일관성 -- 버튼 영역 통합 (저장/취소/삭제) -- 유효성 검사 패턴 통합 - -#### 📝 레거시 페이지 마이그레이션 -**현황**: ~40-50개 파일이 PageLayout/PageHeader 직접 사용 -**대상 파일** (샘플): -- `SubscriptionClient.tsx` -- `SubscriptionManagement.tsx` -- `ComprehensiveAnalysis/index.tsx` -- `DailyReport/index.tsx` -- `ReceivablesStatus/index.tsx` -- `FAQManagement/FAQList.tsx` -- `DepartmentManagement/index.tsx` -- 등등 - ---- - -### 4.2 우선순위 중간 (Medium Priority) - -#### 🗑️ 삭제 확인 다이얼로그 통합 -**현황**: 각 컴포넌트에서 AlertDialog 반복 구현 -**제안**: -```typescript -// 제안: useDeleteConfirm hook -const { openDeleteConfirm, DeleteConfirmDialog } = useDeleteConfirm({ - title: '삭제 확인', - description: '정말 삭제하시겠습니까?', - onConfirm: handleDelete, -}); - -// 또는 공통 컴포넌트 - setIsOpen(false)} -/> -``` - -#### 📁 파일 업로드/다운로드 패턴 통합 -**현황**: 여러 컴포넌트에서 파일 처리 로직 중복 -**제안**: -```typescript -// 제안: useFileUpload hook -const { uploadFile, downloadFile, FileDropzone } = useFileUpload({ - accept: ['image/*', '.pdf'], - maxSize: 10 * 1024 * 1024, -}); -``` - -#### 🔄 로딩 상태 표시 통합 -**현황**: 43개 파일에서 다양한 로딩 패턴 사용 -**제안**: -- `LoadingOverlay` 컴포넌트 확대 적용 -- `Skeleton` 패턴 표준화 - ---- - -### 4.3 우선순위 낮음 (Low Priority) - -#### 📊 대시보드 카드 컴포넌트 -**현황**: CEO 대시보드, 생산 대시보드 등에서 유사 패턴 -**제안**: `DashboardCard`, `StatCard` 공통 컴포넌트 - -#### 🔍 검색/필터 패턴 -**현황**: IntegratedListTemplateV2에 이미 통합됨 -**추가**: 독립 검색 컴포넌트 표준화 - ---- - -## 5. 레거시 파일 정리 대상 - -### 5.1 _legacy 폴더 (삭제 검토) -``` -src/components/hr/CardManagement/_legacy/ - - CardDetail.tsx - - CardForm.tsx - -src/components/settings/AccountManagement/_legacy/ - - AccountDetail.tsx -``` - -### 5.2 V1/V2 중복 파일 (통합 검토) -- `LaborDetailClient.tsx` vs `LaborDetailClientV2.tsx` -- `PricingDetailClient.tsx` vs `PricingDetailClientV2.tsx` -- `DepositDetail.tsx` vs `DepositDetailClientV2.tsx` -- `WithdrawalDetail.tsx` vs `WithdrawalDetailClientV2.tsx` - ---- - -## 6. 권장 액션 플랜 - -### Phase 7: 레거시 페이지 마이그레이션 -| 순서 | 대상 | 예상 작업량 | -|------|------|------------| -| 1 | 설정 관리 페이지 (8개) | 중간 | -| 2 | 회계 관리 페이지 (5개) | 중간 | -| 3 | 인사 관리 페이지 (5개) | 중간 | -| 4 | 보고서/분석 페이지 (3개) | 낮음 | - -### Phase 8: Form 템플릿 개발 -1. IntegratedFormTemplate 설계 -2. 파일럿 적용 (2-3개 Form) -3. 점진적 마이그레이션 - -### Phase 9: 유틸리티 Hook 개발 -1. useDeleteConfirm -2. useFileUpload -3. useFormState (공통 폼 상태 관리) - -### Phase 10: 레거시 정리 -1. _legacy 폴더 삭제 -2. V1/V2 중복 파일 통합 -3. 미사용 컴포넌트 정리 - ---- - -## 7. 결론 - -### 공통화 성과 -- **상세 페이지**: 62% 공통화 달성 (Phase 6 완료) -- **목록 페이지**: 82% 공통화 달성 -- **UI 컴포넌트**: Radix UI 기반 일관성 확보 -- **토스트/알림**: Sonner로 완전 통합 - -### 남은 과제 -- **Form 템플릿**: 72개 폼 컴포넌트 공통화 필요 -- **레거시 페이지**: ~40-50개 마이그레이션 필요 -- **코드 정리**: _legacy, V1/V2 중복 파일 정리 - -### 예상 효과 (추가 공통화 시) -- 코드 중복 30% 추가 감소 -- 신규 페이지 개발 시간 50% 단축 -- 유지보수성 대폭 향상 diff --git a/claudedocs/architecture/[ANALYSIS-2026-02-05] SAM-ERP-MES-정체성-분석.md b/claudedocs/architecture/[ANALYSIS-2026-02-05] SAM-ERP-MES-정체성-분석.md deleted file mode 100644 index 840d9e02..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-02-05] SAM-ERP-MES-정체성-분석.md +++ /dev/null @@ -1,256 +0,0 @@ -# SAM 프로젝트 정체성 및 현장 효용성 분석 - -> 작성일: 2026-02-05 -> 목적: ERP/MES 관점에서 SAM 시스템의 포지션, 강점/약점, 현장 효용성 분석 - ---- - -## 1. SAM의 정체성: "제조+설치 통합형 ERP/MES" - -### 포지셔닝 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ SAM 시스템 포지션 │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ Pure MES ◄────────── SAM ──────────► Pure ERP │ -│ (공장 실행) │ (경영 관리) │ -│ │ │ -│ ┌────────┴────────┐ │ -│ │ 70% ERP │ │ -│ │ 30% MES │ │ -│ │ + 건설 프로젝트 │ │ -│ └─────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -SAM은 **순수 MES도 아니고 순수 ERP도 아닌**, 제조업체가 실제로 필요로 하는 기능들을 통합한 시스템이다. - -### 타겟 산업: 블라인드/셔터 제조 + 설치 - -| 특징 | SAM의 대응 | -|------|-----------| -| 주문생산(Make-to-Order) | 수주 → 생산지시 → 작업실적 흐름 | -| 다품종 소량생산 | 동적 품목 마스터 (빌더 시스템) | -| 설치 서비스 병행 | 건설/시공 프로젝트 모듈 | -| 품질 인증 필요 | QMS 검사성적서 시스템 | -| 중소기업 규모 | SaaS 멀티테넌트 구조 | - ---- - -## 2. ERP 관점 분석 - -### 커버리지 - -| ERP 영역 | SAM 구현 수준 | 비고 | -|----------|-------------|------| -| **재무회계** | ⭐⭐⭐⭐ (80%) | 매입/매출/입출금/어음/카드/채권 | -| **영업관리** | ⭐⭐⭐⭐ (85%) | 견적→수주→생산지시 연동 | -| **구매관리** | ⭐⭐⭐ (70%) | 입고 중심, 발주 모듈 약함 | -| **재고관리** | ⭐⭐⭐ (65%) | 재고현황 중심, 창고이동 미흡 | -| **인사관리** | ⭐⭐⭐⭐ (80%) | 근태/급여/휴가/문서 | -| **전자결재** | ⭐⭐⭐ (70%) | 기안/결재/참조 기본 구조 | -| **프로젝트** | ⭐⭐⭐⭐⭐ (90%) | 건설 모듈이 매우 정교함 | - -### ERP로서의 강점 - -1. **영업-생산 연동**: 수주가 바로 생산지시로 연결되는 구조 -2. **프로젝트 관리**: 입찰→계약→시공→정산까지 풀 사이클 -3. **회계 통합**: 매출/매입이 거래처원장과 연동 -4. **멀티테넌트**: 신규 고객사 온보딩이 빠름 - -### ERP로서의 약점 - -1. **구매/발주**: 입고 위주, 구매요청→발주→입고 흐름 미흡 -2. **원가계산**: 제조원가 계산 로직이 명시적이지 않음 -3. **창고관리**: 다창고, 로케이션 관리 부재 -4. **BI/분석**: 대시보드는 있으나 심층 분석 약함 - ---- - -## 3. MES 관점 분석 - -### 커버리지 - -| MES 영역 | SAM 구현 수준 | 비고 | -|----------|-------------|------| -| **작업지시** | ⭐⭐⭐⭐ (80%) | 생산지시 생성/관리 | -| **작업실적** | ⭐⭐⭐⭐ (80%) | 실적 입력/조회 | -| **품질관리** | ⭐⭐⭐⭐⭐ (90%) | 다양한 검사성적서 | -| **설비관리** | ⭐ (20%) | 거의 없음 | -| **실시간 모니터링** | ⭐⭐⭐ (60%) | 대시보드 있음, PLC 연동 없음 | -| **작업자 화면** | ⭐⭐⭐⭐ (75%) | 현장 터치 인터페이스 | -| **추적성(Traceability)** | ⭐⭐⭐ (65%) | 로트 추적 기본 구조 | - -### MES로서의 강점 - -1. **품질 시스템**: QMS가 상당히 정교함 (6종 검사성적서) -2. **작업자 친화적**: 현장용 작업자 화면 별도 존재 -3. **생산-영업 연결**: 수주 기반 생산이라 주문 추적 용이 - -### MES로서의 약점 - -1. **설비 연동 없음**: PLC, 바코드 스캐너 등 현장 장비 연동 부재 -2. **실시간성 부족**: 폴링 기반, 실시간 푸시 아님 -3. **공정 스케줄링**: 단순 작업지시, APS(고급계획) 없음 -4. **설비 모니터링**: OEE, 설비 가동률 등 없음 - ---- - -## 4. 현장 효용성 평가 - -### 실제로 잘 맞는 업종 - -``` -✅ 주문생산 제조업 (블라인드, 가구, 인테리어 자재) -✅ 설치 서비스 병행 업체 -✅ 다품종 소량생산 -✅ 품질 인증 필요 업종 (ISO, KS 등) -✅ 직원 50명 이하 중소기업 -✅ IT 인력이 부족한 회사 (SaaS로 운영부담 최소화) -``` - -### 맞지 않는 업종 - -``` -❌ 대량생산 (자동차, 반도체) - MES 깊이 부족 -❌ 연속공정 (화학, 식품) - 배치/레시피 관리 없음 -❌ 설비 집약 산업 - 설비 연동/모니터링 없음 -❌ 복잡한 원가계산 필요 업종 - 원가 모듈 약함 -❌ 대기업 (100명+) - 워크플로우 복잡도 한계 -``` - -### 현장에서의 실제 가치 - -| 관점 | 효용 | -|------|------| -| **경영진** | 수주~매출까지 한눈에, 프로젝트별 손익 파악 | -| **영업팀** | 견적→수주→생산현황 실시간 확인 | -| **생산팀** | 작업지시 받고 실적 입력, 품질 기록 | -| **품질팀** | 검사성적서 발행, 인증심사 대응 | -| **경리팀** | 매입/매출/입출금 통합 관리 | -| **현장 작업자** | 터치 화면으로 작업 확인/실적 입력 | - ---- - -## 5. 경쟁 포지션 - -### vs 범용 ERP (더존, 영림원) - -| 항목 | SAM | 범용 ERP | -|------|-----|---------| -| 제조 특화 | ⭐⭐⭐⭐ | ⭐⭐ | -| 건설/시공 | ⭐⭐⭐⭐⭐ | ⭐ | -| 회계 깊이 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | -| 커스터마이징 | ⭐⭐⭐⭐ (빌더) | ⭐⭐ | -| 도입 비용 | 낮음 (SaaS) | 높음 | - -### vs 전문 MES (포스코ICT, 미라콤) - -| 항목 | SAM | 전문 MES | -|------|-----|---------| -| 설비 연동 | ❌ | ⭐⭐⭐⭐⭐ | -| 실시간성 | ⭐⭐ | ⭐⭐⭐⭐⭐ | -| 품질 관리 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | -| ERP 통합 | ⭐⭐⭐⭐⭐ | ⭐⭐ (별도 연동) | -| 도입 기간 | 짧음 | 길음 | - -### SAM의 틈새 (Niche) - -``` -"전문 MES는 과하고, 범용 ERP는 제조 기능이 부족한" -중소 제조+설치 업체를 위한 통합 솔루션 -``` - ---- - -## 6. 빌더 확장 시 기대효과 - -현재 품목기준관리 빌더를 다른 영역으로 확장하면: - -| 확장 영역 | 예상 효과 | -|----------|----------| -| **폼 빌더** (등록/수정) | 신규 업종 대응 시 개발 50% 절감 | -| **리스트 빌더** (조회) | 화면 추가/변경 무코딩 가능 | -| **문서 빌더** (성적서) | 업종별 양식 빠른 대응 | -| **워크플로우 빌더** | 결재/승인 프로세스 설정화 | - -**신규 업체 온보딩 시나리오**: -``` -현재: 요구분석 → 개발 → 테스트 → 배포 (4-8주) -목표: 요구분석 → 빌더 설정 → 배포 (1-2주) -``` - ---- - -## 7. 종합 평가 - -### SAM의 정체성 한 문장 - -> **"주문생산 중소 제조업을 위한 ERP+MES 통합 SaaS로, 생산-품질-영업-회계를 하나로 연결하고, 설치 프로젝트까지 관리하는 올인원 솔루션"** - -### 핵심 차별점 - -1. **제조+설치 통합** - 대부분의 시스템이 둘 중 하나만 함 -2. **품질 시스템 내장** - QMS가 기본 탑재 -3. **빌더 기반 확장성** - 업종별 커스터마이징 용이 -4. **SaaS 멀티테넌트** - 도입 부담 최소화 - -### 발전 방향 제안 - -| 단기 | 중기 | 장기 | -|------|------|------| -| 빌더 → 리스트까지 확장 | 바코드/QR 스캐닝 | 설비 연동 (IoT) | -| 발주 모듈 보강 | 모바일 앱 강화 | AI 수요 예측 | -| 원가계산 기본 기능 | 실시간 알림 (WebSocket) | APS 스케줄링 | - ---- - -## 8. 시스템 규모 현황 - -### 프로젝트 스케일 - -- **24개** 주요 기능 모듈 -- **250+** 페이지 -- **900+** 컴포넌트 파일 -- 멀티테넌트 아키텍처 -- 다국어 지원 (한국어, 영어, 일본어) - -### 모듈별 복잡도 - -| 모듈 | 복잡도 | 페이지 수 | 핵심 기능 | -|------|--------|----------|----------| -| Construction | ⭐⭐⭐⭐⭐ | 57 | 프로젝트 풀 라이프사이클 | -| Accounting | ⭐⭐⭐⭐ | 31 | 재무 관리 전체 | -| Production | ⭐⭐⭐⭐ | 12 | 실시간 MES 코어 | -| Quality | ⭐⭐⭐⭐ | 24 | 다중 검사 QMS | -| Master Data | ⭐⭐⭐⭐⭐ | 12 | 동적 폼 템플릿 | -| Sales | ⭐⭐⭐ | 20 | 견적→수주 흐름 | -| HR | ⭐⭐⭐ | 17 | 직원 라이프사이클 | -| Material | ⭐⭐ | 6 | 재고 & 입고 | -| Outbound | ⭐⭐ | 7 | 출고 & 배차 | - ---- - -## 부록: 기술 스택 - -**Frontend:** -- Next.js 15 (App Router) -- React 18 -- TypeScript -- Tailwind CSS -- Radix UI -- Zustand - -**Backend:** -- PHP Laravel API (별도 코드베이스) -- MySQL/MariaDB -- JWT 인증 -- 멀티테넌트 아키텍처 - -**인프라:** -- HttpOnly 쿠키 보안 -- 멀티테넌트 데이터 격리 -- RESTful API 설계 diff --git a/claudedocs/architecture/[ANALYSIS-2026-02-05] list-page-commonization-status.md b/claudedocs/architecture/[ANALYSIS-2026-02-05] list-page-commonization-status.md deleted file mode 100644 index a94830a4..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-02-05] list-page-commonization-status.md +++ /dev/null @@ -1,505 +0,0 @@ -# 리스트 페이지 공통화 현황 분석 - -> 작성일: 2026-02-05 -> 목적: 리스트 페이지 반복 패턴 식별 및 공통화 가능성 분석 - ---- - -## 📊 전체 현황 - -| 구분 | 수량 | -|------|------| -| 총 리스트 페이지 | 37개 | -| UniversalListPage 사용 | 15개+ | -| IntegratedListTemplateV2 직접 사용 | 5개+ | -| 레거시 패턴 | 10개+ | - ---- - -## 🏗️ 템플릿 계층 구조 - -``` -UniversalListPage (최상위 - config 기반) - └── IntegratedListTemplateV2 (하위 - props 기반) - └── 공통 UI 컴포넌트 - ├── PageLayout - ├── PageHeader - ├── StatCards - ├── DateRangeSelector - ├── MobileFilter - ├── ListMobileCard - └── Table, Pagination 등 -``` - ---- - -## 📁 리스트 페이지 목록 및 사용 템플릿 - -### UniversalListPage 사용 (최신 패턴) - -| 파일 | 도메인 | 특징 | -|------|--------|------| -| `items/ItemListClient.tsx` | 품목관리 | 외부 훅(useItemList) 사용, 엑셀 업로드/다운로드 | -| `pricing/PricingListClient.tsx` | 가격관리 | 외부 훅 사용 | -| `production/WorkOrders/WorkOrderList.tsx` | 생산 | 공정 기반 탭, 외부 통계 API | -| `outbound/ShipmentManagement/ShipmentList.tsx` | 출고 | 캘린더 통합, 날짜범위 필터 | -| `outbound/VehicleDispatchManagement/VehicleDispatchList.tsx` | 배차 | - | -| `material/ReceivingManagement/ReceivingList.tsx` | 입고 | - | -| `material/StockStatus/StockStatusList.tsx` | 재고 | - | -| `customer-center/NoticeManagement/NoticeList.tsx` | 공지사항 | 클라이언트 사이드 필터링 | -| `customer-center/EventManagement/EventList.tsx` | 이벤트 | 클라이언트 사이드 필터링 | -| `customer-center/InquiryManagement/InquiryList.tsx` | 문의 | - | -| `customer-center/FAQManagement/FAQList.tsx` | FAQ | - | -| `quality/InspectionManagement/InspectionList.tsx` | 품질검사 | - | -| `process-management/ProcessListClient.tsx` | 공정관리 | - | -| `pricing-table-management/PricingTableListClient.tsx` | 단가표 | - | -| `pricing-distribution/PriceDistributionList.tsx` | 가격배포 | - | - -### 건설 도메인 (UniversalListPage 사용) - -| 파일 | 기능 | -|------|------| -| `construction/management/ProjectListClient.tsx` | 프로젝트 목록 | -| `construction/management/ConstructionManagementListClient.tsx` | 공사관리 목록 | -| `construction/contract/ContractListClient.tsx` | 계약 목록 | -| `construction/estimates/EstimateListClient.tsx` | 견적 목록 | -| `construction/bidding/BiddingListClient.tsx` | 입찰 목록 | -| `construction/pricing-management/PricingListClient.tsx` | 단가관리 목록 | -| `construction/partners/PartnerListClient.tsx` | 협력사 목록 | -| `construction/order-management/OrderManagementListClient.tsx` | 발주관리 목록 | -| `construction/site-management/SiteManagementListClient.tsx` | 현장관리 목록 | -| `construction/site-briefings/SiteBriefingListClient.tsx` | 현장브리핑 목록 | -| `construction/handover-report/HandoverReportListClient.tsx` | 인수인계 목록 | -| `construction/issue-management/IssueManagementListClient.tsx` | 이슈관리 목록 | -| `construction/structure-review/StructureReviewListClient.tsx` | 구조검토 목록 | -| `construction/utility-management/UtilityManagementListClient.tsx` | 유틸리티 목록 | -| `construction/worker-status/WorkerStatusListClient.tsx` | 작업자 현황 | -| `construction/progress-billing/ProgressBillingManagementListClient.tsx` | 기성관리 목록 | - -### 기타/레거시 - -| 파일 | 비고 | -|------|------| -| `settings/PopupManagement/PopupList.tsx` | 팝업관리 | -| `production/WorkResults/WorkResultList.tsx` | 작업실적 | -| `quality/PerformanceReportManagement/PerformanceReportList.tsx` | 성과보고서 | -| `board/BoardList/BoardListUnified.tsx` | 통합 게시판 | - ---- - -## 🔄 반복 패턴 분석 - -### 1. Badge 색상 매핑 (매우 반복적) - -각 페이지마다 개별 정의되어 있는 패턴: - -```typescript -// ItemListClient.tsx -const badges: Record = { - FG: { variant: 'default', className: 'bg-purple-100 text-purple-700 border-purple-200' }, - PT: { variant: 'default', className: 'bg-orange-100 text-orange-700 border-orange-200' }, - // ... -}; - -// WorkOrderList.tsx -const PRIORITY_COLORS: Record = { - '긴급': 'bg-red-100 text-red-700', - '우선': 'bg-orange-100 text-orange-700', - '일반': 'bg-gray-100 text-gray-700', -}; - -// ShipmentList.tsx - types.ts에서 import -export const SHIPMENT_STATUS_STYLES: Record = { ... }; -``` - -**현황**: -- `src/lib/utils/status-config.ts`에 `createStatusConfig` 유틸 존재 -- 일부 페이지만 사용 중 (대부분 개별 정의) - -### 2. 상태 라벨 정의 (반복적) - -```typescript -// WorkOrderList.tsx - types.ts에서 import -export const WORK_ORDER_STATUS_LABELS: Record = { - pending: '대기', - in_progress: '진행중', - completed: '완료', -}; - -// ShipmentList.tsx - types.ts에서 import -export const SHIPMENT_STATUS_LABELS: Record = { ... }; -``` - -**현황**: 각 도메인 types.ts에서 개별 정의 - -### 3. 필터 설정 (filterConfig) - -```typescript -// WorkOrderList.tsx -const filterConfig: FilterFieldConfig[] = [ - { - key: 'status', - label: '상태', - type: 'single', - options: [ - { value: 'waiting', label: '작업대기' }, - { value: 'in_progress', label: '진행중' }, - { value: 'completed', label: '작업완료' }, - ], - }, - { - key: 'priority', - label: '우선순위', - type: 'single', - options: [ - { value: 'urgent', label: '긴급' }, - { value: 'priority', label: '우선' }, - { value: 'normal', label: '일반' }, - ], - }, -]; -``` - -**공통 필터 패턴**: -- 상태 필터 (대기/진행/완료) -- 우선순위 필터 (긴급/우선/일반) -- 유형 필터 (전체/유형1/유형2...) - -### 4. 행 클릭 핸들러 패턴 - -```typescript -// 모든 페이지에서 동일한 패턴 -const handleRowClick = useCallback( - (item: SomeType) => { - router.push(`/ko/${basePath}/${item.id}?mode=view`); - }, - [router] -); -``` - -### 5. 테이블 행 렌더링 (renderTableRow) - -```typescript -// 공통 구조 - handleRowClick(item)}> - e.stopPropagation()}> - - - {globalIndex} - {/* 데이터 컬럼들 */} - - - {getStatusLabel(item.status)} - - - -``` - ---- - -## ✅ 이미 공통화된 것 - -| 유틸/컴포넌트 | 위치 | 사용률 | -|--------------|------|--------| -| `UniversalListPage` | templates/ | 높음 (15개+) | -| `IntegratedListTemplateV2` | templates/ | 높음 | -| `ListMobileCard`, `InfoField` | organisms/ | 높음 | -| `MobileFilter` | molecules/ | 높음 | -| `DateRangeSelector` | molecules/ | 높음 | -| `StatCards` | organisms/ | 높음 | -| `createStatusConfig` | lib/utils/ | **낮음** (일부만 사용) | - ---- - -## ❌ 공통화 필요한 것 - -### 높은 우선순위 (ROI 높음) - -| 패턴 | 현황 | 공통화 방안 | -|------|------|-------------| -| **Badge 색상 매핑** | 각 페이지 개별 정의 | `src/lib/utils/badge-styles.ts` 생성 | -| **공통 필터 프리셋** | 각 페이지 개별 정의 | `src/lib/constants/filter-presets.ts` 생성 | -| **우선순위 색상** | 각 페이지 개별 정의 | 공통 상수로 추출 | - -### 중간 우선순위 - -| 패턴 | 현황 | 공통화 방안 | -|------|------|-------------| -| 상태 라벨 | 도메인별 types.ts | 도메인별 유지 (비즈니스 로직) | -| 행 클릭 핸들러 | 각 페이지 개별 | UniversalListPage에서 처리 중 | - ---- - -## 📋 공통화 대상 상세 - -### 1. Badge 스타일 공통화 - -**현재 분산된 위치**: -- `items/ItemListClient.tsx` - getItemTypeBadge() -- `production/WorkOrders/types.ts` - WORK_ORDER_STATUS_COLORS -- `outbound/ShipmentManagement/types.ts` - SHIPMENT_STATUS_STYLES -- 기타 각 도메인별 개별 정의 - -**이미 존재하는 공통 유틸** (`src/lib/utils/status-config.ts`): -```typescript -export const BADGE_STYLE_PRESETS: Record = { - default: 'bg-gray-100 text-gray-800', - success: 'bg-green-100 text-green-800', - warning: 'bg-yellow-100 text-yellow-800', - destructive: 'bg-red-100 text-red-800', - info: 'bg-blue-100 text-blue-800', - muted: 'bg-gray-100 text-gray-500', - orange: 'bg-orange-100 text-orange-800', - purple: 'bg-purple-100 text-purple-800', -}; -``` - -**문제**: 존재하지만 대부분의 페이지에서 사용하지 않음 - -### 2. 공통 필터 프리셋 - -**추출 가능한 공통 필터**: -```typescript -// 상태 필터 (거의 모든 페이지) -export const COMMON_STATUS_FILTER: FilterFieldConfig = { - key: 'status', - label: '상태', - type: 'single', - options: [ - { value: 'pending', label: '대기' }, - { value: 'in_progress', label: '진행중' }, - { value: 'completed', label: '완료' }, - ], -}; - -// 우선순위 필터 (생산, 출고 등) -export const COMMON_PRIORITY_FILTER: FilterFieldConfig = { - key: 'priority', - label: '우선순위', - type: 'single', - options: [ - { value: 'urgent', label: '긴급' }, - { value: 'priority', label: '우선' }, - { value: 'normal', label: '일반' }, - ], -}; -``` - -### 3. 우선순위 색상 통합 - -**현재 상태**: 여러 파일에서 동일한 색상 반복 -```typescript -// 긴급: bg-red-100 text-red-700 -// 우선: bg-orange-100 text-orange-700 -// 일반: bg-gray-100 text-gray-700 -``` - ---- - -## 🎯 권장 액션 - -### Phase 1: 즉시 실행 가능 - -1. **`createStatusConfig` 사용률 높이기** - - 기존 유틸 활용도 확인 - - 새 페이지 작성 시 필수 사용 권장 - -2. **공통 필터 프리셋 파일 생성** - - 위치: `src/lib/constants/filter-presets.ts` - - 상태/우선순위/유형 필터 템플릿 - -3. **우선순위 색상 상수 통합** - - 위치: `src/lib/utils/status-config.ts`에 추가 - -### Phase 2: 점진적 적용 - -1. 신규 페이지는 공통 유틸 필수 사용 -2. 기존 페이지는 수정 시 점진적 마이그레이션 -3. 기능 변경 없이 import만 변경 - ---- - -## 📊 공통화 효과 예측 - -| 항목 | Before | After | -|------|--------|-------| -| Badge 정의 위치 | 37개 파일에 분산 | 1개 파일 (+ import) | -| 필터 프리셋 | 각 페이지 개별 | 공통 상수 재사용 | -| 색상 변경 시 수정 범위 | 37개 파일 | 1개 파일 | -| 신규 페이지 개발 시간 | 기존 페이지 참고 필요 | 공통 유틸 import만 | - ---- - -## 📝 결론 - -1. **UniversalListPage는 이미 잘 구축됨** - 대부분 리스트가 사용 중 -2. **Badge/필터 공통화가 주요 개선점** - 반복 코드 제거 가능 -3. **기존 유틸(`createStatusConfig`) 활용도 낮음** - 홍보/가이드 필요 -4. **기능 변경 없이 공통화 가능** - 리팩토링 리스크 낮음 - ---- - -## ✅ 공통화 작업 완료 현황 (2026-02-05) - -### 생성된 파일 - -| 파일 | 설명 | -|------|------| -| `src/lib/constants/filter-presets.ts` | 공통 필터 프리셋 (상태/우선순위/품목유형 등) | -| `claudedocs/guides/badge-commonization-guide.md` | Badge 공통화 사용 가이드 | - -### 수정된 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `src/lib/utils/status-config.ts` | 우선순위/품목유형 설정 추가, 한글 라벨 지원 | -| `src/components/production/WorkOrders/WorkOrderList.tsx` | 공통 유틸 적용 (샘플 마이그레이션) | - -### 추가된 공통 유틸 - -**filter-presets.ts**: -- `COMMON_STATUS_FILTER` - 대기/진행/완료 -- `WORK_STATUS_FILTER` - 작업대기/진행중/작업완료 -- `COMMON_PRIORITY_FILTER` - 긴급/우선/일반 -- `ITEM_TYPE_FILTER` - 품목유형 -- `createSingleFilter()`, `createMultiFilter()` - 커스텀 필터 생성 - -**status-config.ts**: -- `getPriorityLabel()`, `getPriorityStyle()` - 우선순위 (한글/영문 모두 지원) -- `getItemTypeLabel()`, `getItemTypeStyle()` - 품목유형 -- `COMMON_STATUS_CONFIG`, `WORK_STATUS_CONFIG` 등 - 미리 정의된 상태 설정 - -### 샘플 마이그레이션 결과 (WorkOrderList.tsx) - -**Before**: -```tsx -// 개별 정의 -const PRIORITY_COLORS: Record = { - '긴급': 'bg-red-100 text-red-700', - '우선': 'bg-orange-100 text-orange-700', - '일반': 'bg-gray-100 text-gray-700', -}; - -const filterConfig: FilterFieldConfig[] = [ - { key: 'status', label: '상태', type: 'single', options: [...] }, - { key: 'priority', label: '우선순위', type: 'single', options: [...] }, -]; - - -``` - -**After**: -```tsx -// 공통 유틸 사용 -import { WORK_STATUS_FILTER, COMMON_PRIORITY_FILTER } from '@/lib/constants/filter-presets'; -import { getPriorityStyle } from '@/lib/utils/status-config'; - -const filterConfig = [WORK_STATUS_FILTER, COMMON_PRIORITY_FILTER]; - - -``` - -**효과**: -- 코드 라인 20줄 → 3줄 -- 필터 옵션 중복 정의 제거 -- 색상 일관성 보장 - ---- - -## 🔄 추가 마이그레이션 (2026-02-05 업데이트) - -### 완료된 마이그레이션 - -| 파일 | 적용 내용 | 효과 | -|------|----------|------| -| `WorkOrderList.tsx` | WORK_STATUS_FILTER + COMMON_PRIORITY_FILTER + getPriorityStyle | 20줄 → 3줄 | -| `ItemListClient.tsx` | getItemTypeStyle (품목유형 Badge) | 17줄 → 4줄 | -| `ItemDetailClient.tsx` | getItemTypeStyle (품목유형 Badge) | 17줄 → 4줄 | - -### 마이그레이션 제외 대상 (도메인 특화 설정) - -| 파일 | 제외 사유 | -|------|----------| -| `PricingListClient.tsx` | 다른 색상 체계 (SM=cyan, BENDING 추가 타입) | -| `StockStatus/types.ts` | 레거시 타입 지원 (raw_material, bent_part 등) | -| `ShipmentManagement/types.ts` | 다른 우선순위 라벨 (보통/낮음) | -| `issue-management/types.ts` | 2단계 우선순위 (긴급/일반만) | -| `WipProductionModal.tsx` | 버튼 스타일 우선순위 (Badge 아님) | -| `ReceivingList.tsx` | 도메인 특화 상태 (입고대기/입고완료/검사완료) | -| HR 페이지들 | 도메인 특화 상태 설정 | -| 건설 도메인 페이지들 | 도메인 특화 상태 설정 | - -### 분석 결과 요약 - -1. **공통 유틸 적용 완료 페이지**: 3개 (WorkOrderList, ItemListClient, ItemDetailClient) -2. **도메인 특화 설정 페이지**: 34개 (개별 유지가 적절) -3. **결론**: 대부분의 페이지는 도메인별 특화된 상태/라벨/색상을 사용하며, 이는 비즈니스 로직을 명확히 반영하기 위해 의도된 설계 - -### 공통 유틸 권장 사용 시나리오 - -1. **신규 리스트 페이지 생성 시**: 표준 패턴(대기/진행/완료, 긴급/우선/일반) 사용 -2. **품목유형 Badge**: 일관된 색상 적용 필요 시 `getItemTypeStyle` 사용 -3. **우선순위 Badge**: 표준 3단계(긴급/우선/일반) 사용 시 `getPriorityStyle` 사용 - ---- - -## 🎨 getPresetStyle 마이그레이션 완료 (2026-02-05 최종) - -### 마이그레이션 완료 파일 (22개) - -| 파일 | 적용 내용 | -|------|----------| -| `orders/OrderRegistration.tsx` | success, info preset | -| `pricing-distribution/PriceDistributionDetail.tsx` | success preset | -| `pricing/PricingFormClient.tsx` | purple, info, success preset | -| `quality/InspectionManagement/InspectionList.tsx` | success, destructive preset | -| `quality/InspectionManagement/InspectionCreate.tsx` | success, destructive preset | -| `quality/InspectionManagement/InspectionDetail.tsx` | success, destructive preset | -| `accounting/PurchaseManagement/index.tsx` | info preset | -| `accounting/PurchaseManagement/PurchaseDetail.tsx` | orange preset (기존) | -| `accounting/PurchaseManagement/PurchaseDetailModal.tsx` | orange preset (기존) | -| `accounting/VendorManagement/CreditAnalysisModal/CreditAnalysisDocument.tsx` | info preset | -| `quotes/QuoteRegistration.tsx` | success preset | -| `pricing/PricingHistoryDialog.tsx` | info preset | -| `business/construction/management/KanbanColumn.tsx` | info preset | -| `business/construction/management/DetailCard.tsx` | warning preset | -| `business/construction/management/StageCard.tsx` | warning preset | -| `business/construction/management/ProjectCard.tsx` | info preset | -| `production/WorkerScreen/WorkCard.tsx` | success, destructive preset | -| `production/WorkerScreen/ProcessDetailSection.tsx` | warning preset | -| `production/ProductionDashboard/index.tsx` | orange, success preset (기존) | -| `items/ItemForm/BOMSection.tsx` | info preset (기존) | -| `items/DynamicItemForm/sections/DynamicBOMSection.tsx` | info preset (기존) | -| `items/ItemMasterDataManagement/tabs/MasterFieldTab/index.tsx` | info preset | -| `customer-center/InquiryManagement/InquiryList.tsx` | warning, success preset (기존) | -| `hr/EmployeeManagement/CSVUploadDialog.tsx` | success, destructive preset (기존) | - -### 마이그레이션 제외 파일 (유지) - -| 파일 | 제외 사유 | -|------|----------| -| `business/MainDashboard.tsx` | CEO 대시보드 - 다양한 데이터 시각화용 고유 색상 (achievement %, overdue days 등) | -| `pricing/PricingListClient.tsx` | 도메인 특화 색상 체계 (SM=cyan, BENDING type 등) | -| `business/CEODashboard/sections/TodayIssueSection.tsx` | 알림 유형별 고유 색상+아이콘 (notification_type 기반) | -| `dev/DevToolbar.tsx` | 개발 도구 (운영 무관) | -| `ui/status-badge.tsx` | 이미 status-config.ts 사용 중 | -| `items/ItemDetailClient.tsx` | getItemTypeStyle 사용 (도메인 특화) | -| `items/ItemListClient.tsx` | getItemTypeStyle 사용 (도메인 특화) | - -### 사용된 Preset 유형 통계 - -| Preset | 사용 횟수 | 용도 | -|--------|----------|------| -| `success` | 15+ | 완료, 일치, 활성, 긍정적 상태 | -| `info` | 10+ | 정보성 라벨, 진행 상태, 문서 타입 | -| `warning` | 6+ | 진행중, 주의 필요, 선행 생산 | -| `destructive` | 5+ | 오류, 불일치, 긴급 | -| `orange` | 3+ | 품의서/지출결의서, 지연 | -| `purple` | 2+ | 최종 확정, 특수 상태 | - -### 마이그레이션 효과 - -1. **코드 일관성**: 22개 파일에서 동일한 유틸리티 함수 사용 -2. **유지보수성**: 색상 변경 시 `status-config.ts` 한 곳만 수정 -3. **가독성 향상**: `getPresetStyle('success')` vs `bg-green-100 text-green-700 border-green-200` -4. **타입 안전성**: TypeScript로 프리셋 이름 자동완성 \ No newline at end of file diff --git a/claudedocs/architecture/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md b/claudedocs/architecture/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md deleted file mode 100644 index 27178ed8..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md +++ /dev/null @@ -1,165 +0,0 @@ -# SAM ERP 프론트엔드 종합 검수 보고서 - -> 작성일: 2026-02-19 -> 분석 범위: src/ 전체 (1,438개 TS/TSX 파일, ~314K줄) -> 분석 방법: 5개 에이전트 병렬 분석 (코드품질, 번들/성능, 에러/UX, 아키텍처, 모바일/보안) - ---- - -## 종합 스코어카드 - -| 영역 | 점수 | 등급 | 핵심 이슈 | -|------|------|------|-----------| -| **코드 품질** | 7.5/10 | 🟢 양호 | TS 규율 우수, any 133건/TODO 121건 잔존 | -| **번들/성능** | 8.5/10 | 🟢 우수 | 동적 로드 적용, tree-shaking 양호 | -| **에러/UX 일관성** | 5.5/10 | 🟡 보통 | 에러바운더리 우수, 로딩UI/접근성 미흡 | -| **아키텍처** | 6.5/10 | 🟡 보통 | 순환의존 없음, 상태관리 중복 | -| **모바일 대응** | 6/10 | 🟡 보통 | 57% 반응형, 터치영역 미달 | -| **보안** | 7/10 | 🟢 양호 | 인증 강함, CSP unsafe 허용 | - -**전체: 6.8/10** — 기능적으로 안정적이나, UX 일관성과 아키텍처 정리에 개선 여지 - ---- - -## 우선순위별 개선 항목 - -### P0: 보안 이슈 (즉시 조치) - -| # | 항목 | 심각도 | 현황 | 조치 | -|---|------|--------|------|------| -| S-1 | CSP `unsafe-inline`/`unsafe-eval` | 🔴 높음 | middleware.ts에서 허용 중 | nonce 기반으로 전환 | -| S-2 | `new Function()` 코드 주입 | 🔴 높음 | ComputedField.tsx에서 사용 | 사용자 입력 검증 추가 또는 safe-eval 대체 | -| S-3 | sanitizeHTML 함수 강도 | 🟡 중간 | 5개 파일에서 사용 중 | DOMPurify 사용 여부 확인 | - -### P1: 아키텍처 정리 (1~2주) - -| # | 항목 | 현황 | 개선안 | -|---|------|------|--------| -| A-1 | **상태관리 중복** | ItemMasterContext + itemStore + useItemMasterStore 3중 | Zustand 하나로 통합 | -| A-2 | **테마 중복** | ThemeContext + themeStore 병존 | Zustand로 완전 마이그레이션 | -| A-3 | **utils 폴더 중복** | `src/utils/` (2개) + `src/lib/utils/` (11개) 병존 | `src/utils/` → `src/lib/utils/`로 통합 | -| A-4 | **상수 산재** | constants/ 1개 파일만, 나머지 각 컴포넌트 내부 하드코딩 | 도메인별 `constants/` 정리 | - -### P2: 코드 품질 (2~3주) - -| # | 항목 | 건수 | 현황 | 조치 | -|---|------|------|------|------| -| Q-1 | `as any` 타입 캐스트 | 64건 | 주로 form errors 처리 | 제네릭 타입 정의 | -| Q-2 | `: any` 타입 선언 | 48건 | API 응답/props 타입 | 인터페이스 정의 | -| Q-3 | TODO/FIXME 누적 | 121건 (68파일) | useItemMasterStore 15건 등 | 이슈화 → 점진적 해소 | -| Q-4 | God 컴포넌트 | 5개 | ItemMasterContext 2,200줄, MainDashboard 1,400줄 | 단계적 분리 | -| Q-5 | 거대 훅 | 1개 | useCEODashboard 37.9KB | stats/charts/timeline 분리 | -| Q-6 | `alert()`/`confirm()` 잔존 | 32건 | 15개 alert + 17개 confirm | ConfirmDialog/toast로 교체 | - -### P3: UX 일관성 (3~4주) - -| # | 항목 | 현황 | 목표 | -|---|------|------|------| -| U-1 | **로딩 UI** | 40+ 페이지에서 `"로딩 중..."` 텍스트만 사용, Skeleton 2개만 | Skeleton 기반 로딩으로 통일 | -| U-2 | **접근성 (a11y)** | aria-label 3건, role 9건 | 주요 폼/테이블에 ARIA 추가 | -| U-3 | **i18n 사용률** | 인프라 완성(ko/en/ja), 실제 사용 ~5% | 점진적 적용 확대 | -| U-4 | **Zod 검증** | 2개 폼만 적용 | 신규 폼 필수, 기존은 유지 | -| U-5 | **EmptyState 활용** | 컴포넌트 존재하나 하드코딩 "데이터 없음" 다수 | EmptyState 컴포넌트 통일 | - -### P4: 모바일/성능 (선택) - -| # | 항목 | 현황 | 조치 | -|---|------|------|------| -| M-1 | **반응형 커버리지** | 57% 페이지 적용 | HR/대시보드 등 미적용 페이지 보강 | -| M-2 | **터치 영역** | Checkbox 20x20px (권장 44x44px) | 모바일 터치 타겟 확대 | -| M-3 | **html2canvas + dom-to-image** 중복 | 2개 라이브러리 공존 | 하나로 통합 (~50-80KB 절감) | -| M-4 | **Tiptap 동적 로딩** | 보드/팝업에서만 사용하나 번들 포함 | next/dynamic 적용 (~80-100KB 절감) | -| M-5 | **도메인별 actions.ts 표준화** | accounting만 page-level actions, 나머지는 컴포넌트 내부 | accounting 패턴으로 통일 | - ---- - -## 잘 되어있는 점 (유지 사항) - -### 코드 품질 -- ✅ **TypeScript 규율**: @ts-ignore 0건, @ts-nocheck 1건(레거시) -- ✅ **console.log 관리**: 23건만 (16건은 logger 유틸리티) -- ✅ **에러 바운더리**: 글로벌 + Protected 레벨 4개, Slack 연동 -- ✅ **Toast 시스템**: sonner 기반 1,277개 인스턴스 일관 사용 - -### 번들/성능 -- ✅ **XLSX 동적 로드**: 버튼 클릭 시에만 ~400KB 로드 -- ✅ **대시보드 코드 스플리팅**: ~850KB 초기 번들에서 제외 -- ✅ **tree-shaking**: `import *` 0건, lodash/moment 미사용 -- ✅ **Zustand 정규화**: 체계적 상태 + Immer + selector hooks -- ✅ **Tailwind v4**: 최신 버전, 효율적 트리셰이킹 - -### 아키텍처 -- ✅ **순환 의존성 없음**: pages→components→ui 단방향 -- ✅ **API 계층**: buildApiUrl 43개 actions.ts 전면 적용 -- ✅ **executePaginatedAction**: 14개 파일 표준화 - -### 보안 -- ✅ **Bot 차단**: 25개 패턴 필터링 -- ✅ **다층 인증**: Bearer Token + Authorization 헤더 + Sanctum + API Key -- ✅ **Open Redirect 방지**: 내부 경로 검증 -- ✅ **환경변수 분리**: NEXT_PUBLIC_ 적절히 사용 -- ✅ **민감 정보 노출 없음**: console.log에 토큰/비밀번호 출력 0건 - ---- - -## 주요 파일 참조 - -### God 컴포넌트 (분리 대상) -- `src/contexts/ItemMasterContext.tsx` (2,200줄) -- `src/components/business/MainDashboard.tsx` (1,400줄) -- `src/hooks/useCEODashboard.ts` (37.9KB) - -### any 타입 집중 지역 -- `src/components/items/ItemForm/forms/parts/` (22건) -- `src/components/items/ItemMasterDataManagement/` (18건) -- `src/components/quotes/LocationDetailPanel.tsx` (10건) - -### 보안 확인 대상 -- `src/middleware.ts` (CSP 설정) -- `src/components/**/ComputedField.tsx` (new Function) -- sanitizeHTML 사용 파일 5개 (게시판, 팝업, 고객센터) - -### 상태관리 중복 -- `src/contexts/ItemMasterContext.tsx` vs `src/stores/itemStore.ts` vs `src/stores/item-master/useItemMasterStore.ts` -- `src/contexts/ThemeContext.tsx` vs `src/stores/themeStore.ts` - ---- - -## 기존 로드맵과의 관계 - -| 기존 항목 | 상태 | 이번 분석 결과 | -|-----------|------|---------------| -| D-1 God 컴포넌트 분리 | ⏳ 대기 | → P2-Q4로 재확인, 여전히 필요 | -| D-2 `as` 타입 캐스트 | 보류 | → P2-Q1/Q2로 133건 확인 (기존 ~200건에서 감소) | -| D-6 TODO 102건 | ⏳ 대기 | → P2-Q3으로 121건 확인 (소폭 증가) | -| A-2 DataTable 최적화 | ⏳ 대기 | → 에이전트 분석 결과 re-render 위험 낮음 (우선순위 하향) | - -### 신규 발견 항목 (기존 로드맵에 없었던 것) -- **S-1~S-3**: 보안 이슈 (CSP, code injection, sanitization) -- **A-1~A-2**: 상태관리 3중 중복 -- **U-1~U-5**: UX 일관성 전반 (로딩/접근성/i18n/빈상태) -- **M-3~M-4**: 라이브러리 중복/동적 로딩 기회 - ---- - -## 실행 로드맵 요약 - -``` -Week 1-2: P0 보안 + P1 아키텍처 정리 - ├── CSP nonce 전환 - ├── ComputedField 보안 패치 - ├── 상태관리 중복 정리 (Context → Zustand) - └── utils 폴더 통합 - -Week 3-4: P2 코드 품질 - ├── any 타입 정리 (form errors 제네릭) - ├── alert/confirm → ConfirmDialog 교체 - └── TODO/FIXME 이슈 정리 - -Week 5-6: P3 UX 일관성 (선택) - ├── Skeleton 로딩 UI 통일 - ├── EmptyState 활용 확대 - └── 접근성 기본 적용 - -이후: P4 모바일/성능 (필요 시) -``` \ No newline at end of file diff --git a/claudedocs/architecture/[ANALYSIS-2026-02-23] deep-analysis-util-component-zustand.md b/claudedocs/architecture/[ANALYSIS-2026-02-23] deep-analysis-util-component-zustand.md deleted file mode 100644 index dc10c79e..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-02-23] deep-analysis-util-component-zustand.md +++ /dev/null @@ -1,396 +0,0 @@ -# SAM ERP 프로젝트 심층분석 종합 보고서 -> 분석일: 2026-02-23 | 분석 영역: Util 분리 / 컴포넌트 공통화 / Zustand 통합 - ---- - -## 목차 -1. [Executive Summary](#1-executive-summary) -2. [Util 함수 분리 분석](#2-util-함수-분리-분석) -3. [컴포넌트 공통화 분석](#3-컴포넌트-공통화-분석) -4. [Zustand 스토어 통합 분석](#4-zustand-스토어-통합-분석) -5. [통합 리팩토링 로드맵](#5-통합-리팩토링-로드맵) - ---- - -## 1. Executive Summary - -### 전체 현황 스코어카드 - -| 영역 | 현재 수준 | 주요 이슈 | 예상 절감 | -|------|----------|----------|----------| -| **Util 분리** | 🟡 보통 | 중복 함수 6건, 과대 파일 4개, 인라인 유틸 6패턴 | ~800줄 | -| **컴포넌트 공통화** | 🟡 보통 | 중복 다이얼로그 5건, Detail 버전 혼재, 패턴 비일관 | ~1,500줄 | -| **Zustand 통합** | 🟢 양호 | Context→Zustand 미전환 3건, 셀렉터 훅 미비 | 리렌더 최적화 | - -### Top 5 우선 조치 항목 - -1. 🔴 **AuthContext → Zustand 마이그레이션** (전역 리렌더 제거) -2. 🔴 **GenericCRUDDialog 추출** (5개 중복 다이얼로그 통합) -3. 🔴 **파일 다운로드 로직 통합** (3곳 중복 → 1곳) -4. 🟡 **dashboard/transformers.ts 분할** (1,700줄 → 도메인별 분리) -5. 🟡 **Detail/DetailClient/DetailClientV2 정리** (버전 혼재 제거) - ---- - -## 2. Util 함수 분리 분석 - -### 2.1 현재 유틸 파일 인벤토리 - -``` -src/lib/ -├── utils.ts (cn, safeJsonParse - 최소) -├── formatters.ts (phone, businessNumber, card, account 포맷터) -├── print-utils.ts (인쇄 유틸) -├── sanitize.ts (데이터 정제) -├── error-reporting.ts (에러 리포팅) -├── utils/ (13개 파일, ~82KB) -│ ├── amount.ts (금액 포맷: 원/만원) -│ ├── date.ts (날짜 유틸) -│ ├── validation.ts (Zod 스키마 - 725줄 ⚠️) -│ ├── excel-download.ts (엑셀 다운로드 - 528줄 ⚠️) -│ ├── fileDownload.ts (파일 다운로드) -│ ├── export.ts (엑셀 내보내기 - 중복 ⚠️) -│ ├── search.ts (검색/필터 파이프라인) -│ ├── materialTransform.ts (자재 데이터 변환) -│ ├── menuTransform.ts (메뉴 구조 변환) -│ ├── menuRefresh.ts (메뉴 새로고침) -│ ├── status-config.ts (상태 스타일 설정) -│ ├── redirect-error.ts (Next.js 리다이렉트 에러) -│ └── locale.ts (로케일 유틸) -├── api/ (25개 파일) -│ ├── error-handler.ts (API 에러 처리) -│ ├── toast-utils.ts (토스트 유틸 - 중복 ⚠️) -│ ├── transformers.ts (변환기 - 454줄 ⚠️) -│ ├── dashboard/transformers.ts (대시보드 변환 - 1,700줄 🔴) -│ ├── execute-server-action.ts -│ ├── execute-paginated-action.ts -│ └── query-params.ts (buildApiUrl - 표준화 완료) -├── permissions/ (3개 파일) -├── auth/ (2개 파일) -└── cache/ (2개 파일) -``` - -### 2.2 중복 로직 탐지 (6건) - -#### 🔴 HIGH PRIORITY - -| # | 중복 항목 | 위치 | 상세 | -|---|----------|------|------| -| 1 | **Blob 다운로드** | `export.ts`, `excel-download.ts`, `fileDownload.ts` | 동일한 `URL.createObjectURL → link.click → revokeObjectURL` 패턴이 3곳에 존재 | -| 2 | **날짜 문자열 생성** | `export.ts:58`, `excel-download.ts:78` | `toISOString().slice(0,10).replace(/-/g,'')` 동일 패턴, 시간 정밀도만 다름(초 vs 분) | -| 3 | **에러 메시지 포맷** | `error-handler.ts:122`, `toast-utils.ts:106` | `getErrorMessage()` vs `formatApiError()` - 동일 로직 | -| 4 | **숫자 포맷팅** | `amount.ts:15`, `formatters.ts:178` | `Intl.NumberFormat` vs regex 기반 - 3가지 접근법 혼재 | - -#### 🟡 MEDIUM PRIORITY - -| # | 중복 항목 | 위치 | -|---|----------|------| -| 5 | 엑셀 파일명 생성 | `export.ts:54` vs `excel-download.ts:78` | -| 6 | 쿼리 파라미터 빌드 | 레거시 `URLSearchParams` 패턴 (마이그레이션 완료 상태) | - -### 2.3 인라인 유틸 추출 후보 (6패턴) - -컴포넌트 내부에 반복적으로 등장하지만 util로 분리되지 않은 패턴: - -| 패턴 | 발견 위치 | 영향 파일 | 추천 위치 | -|------|----------|----------|----------| -| 월/분기 날짜 범위 계산 | TaxInvoice, HR 페이지들 | 5+ | `lib/utils/dateRange.ts` | -| 시간 문자열 포맷팅 | TransactionFormModal, time-picker | 4+ | `lib/utils/timeFormatter.ts` | -| 포맷된 숫자 파싱 | VendorManagement, Withdrawal 등 | 8+ | `lib/formatters.ts` 확장 | -| 에러 객체→메시지 변환 | attendance/page, employee/page | 3+ | `lib/utils/errorFormatter.ts` | -| 배열 합계/카운트 reduce | 대시보드, 주문관리 등 | 6+ | `lib/utils/aggregation.ts` | -| 파일 크기 포맷팅 | file-input.tsx | 2 | `lib/utils/fileSizeFormatter.ts` | - -### 2.4 과대 파일 (분할 필요) - -| 파일 | 줄 수 | 문제 | 분할 방안 | -|------|-------|------|----------| -| 🔴 `api/dashboard/transformers.ts` | **1,700+** | 10+ 도메인 변환 혼재 | `dashboard/transformers/{sales,production,quality,accounting,hr,common}.ts` | -| 🟡 `utils/validation.ts` | 725 | 5개 아이템 타입 스키마 혼재 | `validations/{item-master-base,product,part,material,filters}.ts` | -| 🟡 `utils/excel-download.ts` | 528 | 다운로드/내보내기/템플릿 혼재 | `{blob-download,excel-export,excel-template}.ts` | -| 🟡 `api/transformers.ts` | 454 | 27개 export 함수 | `transformers/{pages,sections,fields,bom,templates,options}.ts` | - -### 2.5 미사용 유틸 (후보) - -| 함수 | 파일 | 상태 | -|------|------|------| -| `parsePhoneNumber()` | `formatters.ts:36` | import 0건 | -| `extractNumbers()` | `formatters.ts:220` | import 0건 | -| `formatPersonalNumber()` | `formatters.ts:84` | 실제 사용은 `formatPersonalNumberMasked` | - ---- - -## 3. 컴포넌트 공통화 분석 - -### 3.1 현재 컴포넌트 계층 구조 - -``` -src/components/ -├── ui/ (49개 - Radix UI 래퍼) -├── atoms/ (3개 - 최소 단위) -├── molecules/ (9개 - 복합 폼/표시) -├── organisms/ (11개 - 비즈니스 컴포넌트) -├── templates/ (2+1개 - UniversalListPage, IntegratedDetailTemplate, IntegratedListTemplateV2) -├── accounting/ (18개 도메인 폴더, 100+ 컴포넌트) -├── settings/ (12개 도메인 폴더) -└── [기타 도메인] (15+ 폴더) -``` - -### 3.2 중복 컴포넌트 패턴 (핵심 발견) - -#### 🔴 CRITICAL: 단순 CRUD 다이얼로그 중복 (5건) - -거의 동일한 구조: Dialog 래퍼 → 폼 필드 → 유효성 검증 → 제출/취소 버튼 - -| 컴포넌트 | 줄 수 | 차이점 | -|----------|-------|--------| -| `settings/RankManagement/RankDialog.tsx` | 89 | 라벨명만 다름 | -| `settings/TitleManagement/TitleDialog.tsx` | 90 | 라벨명만 다름 | -| `settings/PermissionManagement/PermissionDialog.tsx` | ~90 | 라벨명만 다름 | -| `settings/NotificationSettings/ItemSettingsDialog.tsx` | ~90 | 라벨명만 다름 | -| `accounting/VendorManagement/CreditAnalysisModal/` | ~100 | 약간 복잡 | - -**해결안**: `GenericCRUDDialog` 제네릭 컴포넌트 생성 -```typescript -// src/components/molecules/GenericCRUDDialog.tsx -interface GenericCRUDDialogProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - mode: 'add' | 'edit'; - title: string; - fields: FormFieldDefinition[]; - data?: T; - onSubmit: (data: T) => Promise; -} -``` -→ **~400줄 절감** - -#### 🔴 CRITICAL: Detail 파일 버전 혼재 - -한 엔티티에 대해 여러 버전의 Detail 파일이 공존: - -| 엔티티 | 파일들 | 문제 | -|--------|--------|------| -| BadDebt | `BadDebtDetail.tsx`, `BadDebtDetailClientV2.tsx` | V2 마이그레이션 미완 | -| Withdrawal | `WithdrawalDetailClientV2.tsx` | ClientV2 접미사 | -| Deposit | `DepositDetailClientV2.tsx` | ClientV2 접미사 | -| Vendor | `VendorDetail.tsx`, `VendorDetailClient.tsx` | 두 파일 공존 | - -→ **단일 소스로 통합 필요, ~300줄 절감** - -#### 🟡 HIGH: 리스트 페이지 설정 중복 - -`UniversalListPage`로 통합은 잘 되어있으나, 설정(config) 코드가 각 페이지에 반복: - -| 반복 요소 | 발견 위치 | 해결안 | -|-----------|----------|--------| -| 상태 관리 (data, filters, pagination) | Sales, Purchase, Vendor 등 | 설정 파일 분리 | -| DateRange 선택기 | 8+ 회계 페이지 | `useDateRange()` 훅 표준화 | -| Stats 계산 useMemo | 대부분의 리스트 페이지 | `DataStatsCard` 추출 | - -→ **~500줄 절감** - -### 3.3 재사용률 분석 - -#### 높은 재사용 (Good) -- **UniversalListPage**: 40+ 페이지 (우수) -- **IntegratedDetailTemplate**: 20+ 상세 페이지 -- **FormField**: 50+ 폼 - -#### 활용 부족 (Should Use More) -- **SearchableSelectionModal**: 실제 3곳만 사용 → 더 광범위 적용 가능 -- **StandardDialog**: 존재하지만 단순 다이얼로그들이 미사용 -- **MobileCard**: 정의되었지만 비일관적 사용 - -### 3.4 패턴 비일관성 - -| 패턴 | 현재 상태 | 표준화 방향 | -|------|----------|------------| -| 날짜 범위 선택 | 3가지 방식 혼재 (컴포넌트/훅/인라인) | `useDateRange()` + `` | -| 검색/필터 | 3가지 경쟁 패턴 (A: UniversalListPage, B: 커스텀 useState, C: IntegratedListTemplateV2) | Pattern A로 통일 | -| 모달 vs 페이지 | VendorDetail→풀페이지, PurchaseDetail→모달 혼재 | 도메인별 기준 확립 | - -### 3.5 추출 필요 공유 컴포넌트 - -| 컴포넌트 | 사용처 | 설명 | -|----------|--------|------| -| `LineItemsTable` | SalesDetail, PurchaseDetail | 품목 추가/삭제/계산 테이블 (~150줄×2 절감) | -| `DataStatsCard` | 회계 리스트 페이지들 | 유연한 통계 표시 카드 | -| `DocumentTemplate` | CreditAnalysis, InspectionReport | 인쇄용 문서 래퍼 (헤더/푸터/워터마크) | -| `DataTableWithActions` | 대부분의 리스트 | 페이지네이션+선택+액션 통합 | - ---- - -## 4. Zustand 스토어 통합 분석 - -### 4.1 현재 스토어 인벤토리 (7개) - -| 스토어 | 파일 | 줄 수 | 미들웨어 | 용도 | -|--------|------|-------|---------|------| -| `useItemMasterStore` | `stores/item-master/useItemMasterStore.ts` | 1,150 | devtools, immer | 품목기준관리 정규화 상태 | -| `useMasterDataStore` | `stores/masterDataStore.ts` | 450 | devtools | 동적 폼 설정 캐싱 | -| `useMenuStore` | `stores/menuStore.ts` | ~100 | persist | 사이드바/메뉴 상태 | -| `useFavoritesStore` | `stores/favoritesStore.ts` | ~100 | persist + custom storage | 즐겨찾기 (최대 10개) | -| `useThemeStore` | `stores/themeStore.ts` | ~50 | persist | 테마 (light/dark/senior) | -| `useTableColumnStore` | `stores/useTableColumnStore.ts` | ~100 | persist + custom storage | 테이블 컬럼 가시성/너비 | -| `useCalendarScheduleStore` | `stores/useCalendarScheduleStore.ts` | ~100 | devtools | 캘린더 일정 연도별 캐싱 | - -### 4.2 핵심 발견: Context → Zustand 미전환 (3건) - -#### 🔴 #1: AuthContext (최우선) - -| 항목 | 현재 | 문제 | -|------|------|------| -| **위치** | `/src/contexts/AuthContext.tsx` (278줄) | React Context + useState | -| **상태** | users[], currentUser, roles, tenants | Provider 리렌더 전파 | -| **localStorage** | 수동 동기화 (line 162-190) | Zustand persist가 자동 처리 가능 | -| **영향** | 사이드바, 대시보드, 모든 인증 페이지 | 상태 변경 시 전체 앱 리렌더 | - -**전환 방안**: -```typescript -// /src/stores/authStore.ts -export const useAuthStore = create()( - persist( - devtools((set) => ({ - currentUser: null, - setCurrentUser: (user) => set({ currentUser: user }), - // ... 기타 액션 - })), - { name: 'mes-currentUser' } - ) -); -``` - -#### 🟡 #2: ItemMasterContext (중복 제거) - -| 항목 | 현재 | 문제 | -|------|------|------| -| **Context** | `contexts/ItemMasterContext.tsx` (27,922 토큰) | useState 13개+ 상태 | -| **Zustand** | `stores/item-master/useItemMasterStore.ts` (1,150줄) | 유사 데이터 관리 | -| **중복** | 양쪽에서 품목 마스터 데이터 관리 | 캐싱/API 레이어 분리 | - -→ **Context를 Zustand 스토어로 통합, Context는 얇은 래퍼로만 유지** - -#### 🟡 #3: PermissionContext - -| 항목 | 현재 | 문제 | -|------|------|------| -| **위치** | `contexts/PermissionContext.tsx` | 순수 데이터/셀렉터 패턴 | -| **적합도** | Zustand 셀렉터 패턴에 완벽 부합 | Provider 불필요 | - -### 4.3 셀렉터 훅 미비 (성능 이슈) - -| 스토어 | 셀렉터 훅 | 문제 | -|--------|----------|------| -| ✅ `masterDataStore` | `usePageConfig()`, `usePageConfigLoading()` 등 | 양호 | -| ❌ `useTableColumnStore` | 없음 - 전체 스토어 구독 | 불필요한 리렌더 | -| ❌ `useMenuStore` | 없음 - 전체 스토어 구독 | 사이드바 토글이 모든 구독자 리렌더 | -| ❌ `useThemeStore` | 없음 | 경미 | - -**해결 패턴**: -```typescript -// ✅ 추가 필요 -export const useTableSettings = (pageId: string) => - useTableColumnStore((state) => state.pageSettings[pageId]); - -export const useMenuActiveId = () => - useMenuStore((state) => state.activeMenu); - -export const useSidebarCollapsed = () => - useMenuStore((state) => state.sidebarCollapsed); -``` - -### 4.4 Custom Storage 중복 - -`favoritesStore`와 `tableColumnStore`에서 동일한 사용자별 localStorage 래퍼가 반복: - -```typescript -// 두 파일 모두 동일 패턴 반복: -const customStorage = { - getItem: (name) => { /* userId 기반 키 생성 */ }, - setItem: (name, value) => { /* userId 기반 키로 저장 */ }, - removeItem: (name) => { /* userId 기반 키로 삭제 */ }, -}; -``` - -**해결안**: `/src/lib/storage/user-scoped-storage.ts` 추출 -```typescript -export function createUserScopedStorage(prefix: string): StateStorage { - return { getItem, setItem, removeItem }; -} -``` - -### 4.5 누락된 스토어 기회 - -| 스토어 | 용도 | 현재 상태 | -|--------|------|----------| -| 🔴 `useUIStore` | 전역 모달/노티/로딩 | 각 컴포넌트에서 로컬 관리 | -| 🟡 글로벌 필터 상태 | 리스트 페이지 공통 필터 | useState로 산재 | - ---- - -## 5. 통합 리팩토링 로드맵 - -### Phase 1: 즉시 (1주) - -| 작업 | 영역 | 영향도 | 난이도 | -|------|------|--------|--------| -| AuthContext → Zustand 마이그레이션 | Zustand | 🔴 전역 리렌더 제거 | 중 | -| GenericCRUDDialog 추출 (5개 다이얼로그 통합) | 컴포넌트 | 🔴 ~400줄 절감 | 저 | -| Blob 다운로드 로직 통합 (3곳→1곳) | Util | 🔴 중복 제거 | 저 | -| 에러 메시지 포맷 통합 (`formatApiError` 제거) | Util | 🟡 API 레이어 정리 | 저 | -| Zustand 셀렉터 훅 추가 (3개 스토어) | Zustand | 🟡 리렌더 최적화 | 저 | - -### Phase 2: 단기 (2~3주) - -| 작업 | 영역 | 영향도 | 난이도 | -|------|------|--------|--------| -| `dashboard/transformers.ts` 분할 (1,700줄) | Util | 🟡 유지보수성 | 중 | -| Detail 파일 버전 정리 (V2 통합) | 컴포넌트 | 🟡 ~300줄 절감 | 중 | -| `LineItemsTable` organism 추출 | 컴포넌트 | 🟡 Sales/Purchase 공통화 | 중 | -| Custom Storage 유틸 추출 | Zustand | 🟡 DRY | 저 | -| 날짜 범위 선택 표준화 | 컴포넌트 | 🟡 패턴 통일 | 중 | - -### Phase 3: 중기 (3~4주) - -| 작업 | 영역 | 영향도 | 난이도 | -|------|------|--------|--------| -| `validation.ts` 분할 (725줄) | Util | 🟢 유지보수성 | 저 | -| ItemMasterContext → Zustand 통합 | Zustand | 🟡 중복 제거 | 고 | -| IntegratedListTemplateV2 폐기 | 컴포넌트 | 🟢 레거시 제거 | 중 | -| 인라인 유틸 추출 (6패턴) | Util | 🟢 코드 품질 | 저 | -| 미사용 유틸 함수 정리 | Util | 🟢 코드 청결 | 저 | - -### Phase 4: 장기 (4주+) - -| 작업 | 영역 | 영향도 | 난이도 | -|------|------|--------|--------| -| PermissionContext → Zustand | Zustand | 🟢 아키텍처 통일 | 중 | -| DocumentTemplate organism 추출 | 컴포넌트 | 🟢 인쇄 공통화 | 중 | -| useUIStore 생성 (전역 UI 상태) | Zustand | 🟢 모달/노티 통합 | 중 | -| 숫자 포맷팅 API 표준화 | Util | 🟢 일관성 | 저 | - ---- - -## 부록: 핵심 파일 참조 - -### 리팩토링 대상 (Util) -- `/src/lib/utils/export.ts` - 중복 제거 대상 -- `/src/lib/utils/excel-download.ts` - 분할 대상 (528줄) -- `/src/lib/utils/validation.ts` - 분할 대상 (725줄) -- `/src/lib/api/dashboard/transformers.ts` - 분할 대상 (1,700줄) -- `/src/lib/api/toast-utils.ts` - `formatApiError` 제거 대상 - -### 리팩토링 대상 (컴포넌트) -- `/src/components/settings/RankManagement/RankDialog.tsx` - GenericCRUDDialog로 대체 -- `/src/components/settings/TitleManagement/TitleDialog.tsx` - GenericCRUDDialog로 대체 -- `/src/components/accounting/BadDebtCollection/BadDebtDetailClientV2.tsx` - 버전 통합 -- `/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx` - 버전 통합 -- `/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx` - 버전 통합 - -### 리팩토링 대상 (Zustand) -- `/src/contexts/AuthContext.tsx` → `/src/stores/authStore.ts` -- `/src/contexts/ItemMasterContext.tsx` → `/src/stores/item-master/` 통합 -- `/src/stores/useTableColumnStore.ts` - 셀렉터 훅 추가 -- `/src/stores/menuStore.ts` - 셀렉터 훅 추가 -- `/src/stores/favoritesStore.ts` - custom storage 유틸 추출 diff --git a/claudedocs/architecture/[ANALYSIS-2026-02-27] ceo-dashboard-analysis.md b/claudedocs/architecture/[ANALYSIS-2026-02-27] ceo-dashboard-analysis.md deleted file mode 100644 index 8ec2c525..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-02-27] ceo-dashboard-analysis.md +++ /dev/null @@ -1,176 +0,0 @@ -# CEO Dashboard 분석 (기획서 D1.7 기준) - -**기획서**: `SAM_ERP_Storyboard_D1.7_260227.pdf` p33~60 -**분석일**: 2026-02-27 -**상태**: 기획서 분석 완료, 구현 대기 - ---- - -## 1. 전체 구성 - -| 구분 | 페이지 | 수량 | -|------|--------|------| -| 메인 대시보드 섹션 | p33~43 | 20개 | -| 상세 모달 | p44~57 | 10개 | -| 참고 자료 (계산공식) | p58~60 | 3페이지 | - ---- - -## 2. 섹션별 현황 (20개) - -### API 연동 완료 (11개) - -| # | 섹션 | 페이지 | hook | API endpoint | -|---|------|--------|------|-------------| -| 1 | 오늘의 이슈 | p33 | useTodayIssue | today-issues/summary | -| 2 | 자금 현황 | p33-34 | useCEODashboard | daily-report/summary | -| 3 | 현황판 | p34 | useStatusBoard | status-board/summary | -| 4 | 당월 예상 지출 | p34-35 | useMonthlyExpense | expected-expenses/summary | -| 5 | 가지급금 현황 | p35 | useCardManagement | card-transactions/summary + 2개 | -| 6 | 접대비 현황 | p35-36 | useEntertainment | entertainment/summary | -| 7 | 복리후생비 현황 | p36 | useWelfare | welfare/summary | -| 8 | 미수금 현황 | p36 | useReceivable | receivables/summary | -| 9 | 채권추심 현황 | p37 | useDebtCollection | bad-debts/summary | -| 10 | 부가세 현황 | p37-38 | useVat | vat/summary | -| 11 | 캘린더 | p38 | useCalendar | calendar/schedules | - -### Mock 데이터만 (9개) - API 신규 필요 - -| # | 섹션 | 페이지 | 필요 데이터 | -|---|------|--------|-----------| -| 12 | 매출 현황 | p39 | 누적매출, 달성률, YoY, 당월매출 + 차트2 + 테이블 | -| 13 | 일별 매출 내역 | p47(설정) | 매출일, 거래처, 매출금액 (🆕 신규 섹션) | -| 14 | 매입 현황 | p40 | 누적매입, 미결제, YoY + 차트2 + 테이블 | -| 15 | 일별 매입 내역 | p47(설정) | 매입일, 거래처, 매입금액 (🆕 신규 섹션) | -| 16 | 생산 현황 | p41 | 공정별(스크린/슬랫/절곡) 집계 + 작업자현황 | -| 17 | 출고 현황 | p41 | 예상출고 7일/30일 금액+건수 | -| 18 | 미출고 내역 | p42 | 로트번호, 현장명, 수주처, 잔량, 납기일 | -| 19 | 시공 현황 | p42 | 진행/완료(7일이내) + 현장카드 | -| 20 | 근태 현황 | p43 | 출근/휴가/지각/결근 + 직원테이블 | - ---- - -## 3. 🔴 D1.7 핵심 변경사항 - -### 카드 구조 변경 (한도관리형 → 리스크감지형) - -| 섹션 | 기존 구현 | D1.7 기획서 | -|------|---------|-----------| -| **가지급금** | 카드, 가지급금, 법인세예상, 종합세예상 | 카드, 경조사, 상품권, 접대비, 총합계 (5카드) | -| **접대비** | 매출, 분기한도, 잔여한도, 사용금액 | **주말/심야, 기피업종, 고액결제, 증빙미비** | -| **복리후생비** | 당해한도, 분기한도, 잔여한도, 사용금액 | **비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과** | - -### 신규 섹션 (2개) -- 일별 매출 내역: 항목 설정(p47)에서 별도 ON/OFF -- 일별 매입 내역: 항목 설정(p47)에서 별도 ON/OFF - -### 설정 팝업 확장 (p45-47) -- 접대비: 한도관리(연간/반기/분기/월), 기업구분(일반법인/중소기업), 고액결제기준금액 -- 복리후생비: 한도관리, 계산방식(직원당정액 or 연봉총액×비율), 조건부입력필드, 1회결제기준금액 - ---- - -## 4. 상세 모달 (10개) - -| # | 모달 | 페이지 | 프론트 config | API 상태 | -|---|------|--------|-------------|---------| -| 1 | 일정 상세 | p44 | ✅ ScheduleDetailModal | ✅ 연동 | -| 2 | 항목 설정 | p45-47 | ✅ DashboardSettingsDialog | localStorage | -| 3 | 당월 매입 상세 | p48 | ✅ me1 config | ⚠️ 부분연동 | -| 4 | 당월 카드 상세 | p49 | ✅ me2 config | ⚠️ 부분연동 | -| 5 | 당월 발행어음 상세 | p50 | ✅ me3 config | ⚠️ 부분연동 | -| 6 | 당월 지출 예상 상세 | p51 | ✅ me4 config | ⚠️ 부분연동 | -| 7 | 가지급금 상세 | p52 | ✅ cm2 config | ⚠️ 구조변경 필요 | -| 8 | 접대비 상세 | p53-54 | ✅ et config | ⚠️ 대폭확장 | -| 9 | 복리후생비 상세 | p55-56 | ✅ wf config | ⚠️ 대폭확장 | -| 10 | 예상 납부세액 상세 | p57 | ✅ vat config | ⚠️ 확장필요 | - ---- - -## 5. 필요 API 작업 (16개) - -### 백엔드 API 수정 (6개) - -| # | API | 변경 내용 | -|---|-----|---------| -| 1 | 가지급금 summary | 카드/경조사/상품권/접대비 분류 집계 | -| 2 | 접대비 summary | 리스크 4종 (주말심야/기피업종/고액/증빙미비) - MCC코드 판별 | -| 3 | 복리후생비 summary | 리스크 4종 (비과세초과/사적사용/편중/한도초과) | -| 4 | 가지급금 detail | 분류별 상세 + AI분류 컬럼 | -| 5 | 복리후생비 detail | 계산방식별 + 분기별현황 | -| 6 | 부가세 detail | 신고기간별 + 부가세요약 + 미발행/미수취 | - -### 백엔드 API 신규 (10개) - -| # | API | 용도 | 난이도 | -|---|-----|------|--------| -| 1 | 접대비 detail | 한도계산 + 분기별현황 + 내역테이블 | 상 | -| 2 | 매출 현황 summary | 누적/달성률/YoY/당월 + 차트 | 중 | -| 3 | 일별 매출 내역 | 매출일, 거래처, 매출금액 | 하 | -| 4 | 매입 현황 summary | 누적/미결제/YoY + 차트 | 중 | -| 5 | 일별 매입 내역 | 매입일, 거래처, 매입금액 | 하 | -| 6 | 생산 현황 | 공정별 집계 + 작업자실적 | 상 | -| 7 | 출고 현황 | 7일/30일 예상출고 | 하 | -| 8 | 미출고 내역 | 납기기준 미출고 조회 | 하 | -| 9 | 시공 현황 | 진행/완료(7일이내) + 카드 | 중 | -| 10 | 근태 현황 | 출근/휴가/지각/결근 집계 | 중 | - ---- - -## 6. 프론트엔드 작업 (8개) - -| # | 작업 | 대상 | -|---|------|------| -| 1 | 가지급금 카드 구조 변경 | CardManagementSection | -| 2 | 접대비 카드 → 리스크형 | EntertainmentSection | -| 3 | 복리후생비 카드 → 리스크형 | WelfareSection | -| 4 | 일별 매출 내역 섹션 신규 | 새 컴포넌트 | -| 5 | 일별 매입 내역 섹션 신규 | 새 컴포넌트 | -| 6 | 항목 설정 팝업 업데이트 | DashboardSettingsDialog | -| 7 | 모달 config API 연동 | 각 modalConfigs | -| 8 | Mock 섹션 API 연동 | 매출~근태 hook 생성 | - ---- - -## 7. 데이터 아키텍처 - -대시보드 전용 테이블 없음. 모든 데이터는 각 도메인 페이지 입력 데이터의 실시간 집계. - -### 자금 현황 데이터 조합 -| 카드 | 출처 | -|------|------| -| 일일일보 | bank_accounts 잔액 합계 | -| 미수금 잔액 | sales 합계 - deposits 합계 | -| 미지급금 잔액 | purchases 합계 - payments 합계 | -| 당월 예상 지출 | 매입예정 + 카드결제 + 어음만기 합산 | - -### 리스크 감지 로직 (접대비/복리후생비) -- MCC 코드 기반 업종 판별 (p60: 유흥업소, 귀금속, 골프장 등) -- 체크 규칙: 시간대이상(22~06시), 업종이상, 금액이상(50만원), 빈도이상(월10회) -- 사적사용 의심: 토요일 23시 + 유흥주점 + 25만원 → 2개 규칙 해당 - -### 캐싱 -- sam_stat 테이블 5분 캐시 (백엔드 기존 구현) - ---- - -## 8. 참고 계산 공식 (p58-60) - -### 가지급금 인정이자 -- 인정이자율: 4.6% (당좌대출이자율 기준, 매년 고시) -- 인정이자 = 가지급금 × 일이자율(연이자율/365) × 경과일수 -- 법인세 추가: 인정이자 × 0.19 -- 대표자 소득세 추가: 인정이자 × 0.35 - -### 접대비 손금한도 -- 기본한도: 일반법인 1,200만원/년, 중소기업 3,600만원/년 -- 수입금액별 추가한도: - - 100억 이하: 수입금액 × 0.2% - - 100억~500억: 2,000만원 + (수입금액-100억) × 0.1% - - 500억 초과: 6,000만원 + (수입금액-500억) × 0.03% - -### 복리후생비 계산 -- 방식1 (직원당 정액): 직원수 × 월정액 × 12 -- 방식2 (연봉총액 비율): 연봉총액 × 비율% -- 법정 복리후생비: 4대보험 회사부담분 -- 비과세 항목별 기준: 식대 20만원, 교통비 10만원, 경조사 5만원, 건강검진 월환산, 교육훈련 8만원, 복지포인트 10만원 \ No newline at end of file diff --git a/claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md b/claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md deleted file mode 100644 index abf55a1b..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md +++ /dev/null @@ -1,281 +0,0 @@ -# 계정과목(Chart of Accounts) 현황 분석 및 일반 ERP 비교 - -> 작성일: 2026-03-06 -> 목적: 회계담당자 피드백 기반, 현재 시스템 vs 일반 ERP 계정과목 체계 비교 - ---- - -## 1. 회계담당자 요구사항 요약 - -| # | 요구사항 | 핵심 | -|---|---------|------| -| 1 | 계정과목을 통일해서 관리 | 하나의 마스터에서 전사적 관리 | -| 2 | 번호와 명칭으로 구분 | 코드 체계 필수 (예: 401-매출, 501-급여) | -| 3 | 제조/회계 동일 명칭이지만 번호가 다른 경우 존재 | 부문별 세분화 필요 | -| 4 | 등록하면 전체가 공유 + 개별등록도 가능 | 공통 + 부문별 계정 | - ---- - -## 2. 현재 시스템 계정과목 사용 현황 - -### 2.1 모듈별 계정과목 관리 방식 - -| 모듈 | 계정과목 소스 | 옵션 수 | 관리 방식 | API 필드명 | -|------|-------------|---------|----------|-----------| -| **일반전표입력** | DB 마스터 (account_codes) | 동적 | API CRUD | `account_subject_id` | -| **카드사용내역** | 프론트 하드코딩 | 16개 | 상수 배열 | `account_code` | -| **미지급비용** | 프론트 하드코딩 | 9개 | 상수 배열 | `account_code` | -| **매출관리** | 프론트 하드코딩 | 8개 | 상수 배열 | `account_code` | -| **입금관리** | 프론트 하드코딩 | ~11개 | depositType 상수 | `account_code` | -| **출금관리** | 프론트 하드코딩 | ~11개 | withdrawalType 상수 | `account_code` | -| **세금계산서관리** | 프론트 하드코딩 | 11개 | 상수 배열 (분개 모달) | `account_subject` | -| **CEO 대시보드** | 표시만 | - | account_title 표시 | `account_title` | - -### 2.2 핵심 문제점 - -``` -[문제 1] 계정과목 이원화 - 일반전표: DB 마스터 (code + name + category) ← 유일하게 정상 - 나머지: 프론트엔드 하드코딩 상수 배열 ← 각자 따로 관리 - -[문제 2] 코드 체계 불일치 - 일반전표: { code: "101", name: "현금", category: "asset" } - 카드내역: { value: "purchasePayment", label: "매입대금" } ← 영문 키워드 - 입금관리: { value: "salesRevenue", label: "매출수금" } ← 또 다른 영문 키워드 - -[문제 3] 옵션 중복 + 불일치 - "급여"가 카드내역(salary), 미지급비용(salary), 입출금(salary)에 각각 존재 - 세금계산서(분개)는 또 다른 옵션 세트 (매출, 부가세예수금 등) - 하지만 서로 독립적이라 추가/수정 시 각 파일 개별 수정 필요 - -[문제 4] 번호 체계 없음 - 카드내역의 "매입대금" = 코드 없이 "purchasePayment"라는 문자열만 존재 - 제조에서 쓰는 "재료비"와 회계에서 쓰는 "재료비"를 구분할 방법 없음 -``` - -### 2.3 백엔드 DB 구조 (현재) - -``` -account_codes 테이블 (일반전표 전용 마스터) -├── id (PK) -├── tenant_id (테넌트 격리) -├── code (varchar 10) ← 계정번호 -├── name (varchar 100) ← 계정명 -├── category (enum: asset/liability/capital/revenue/expense) -├── sort_order -├── is_active -├── created_at / updated_at -└── unique(tenant_id, code) - -journal_entry_lines (분개 상세) -├── account_code (varchar) ← 코드 저장 -├── account_name (varchar) ← 명칭 스냅샷 저장 -└── ... (side, amount 등) - -barobill_card_transactions (카드거래) -├── account_code (varchar) ← 문자열 직접 저장 ("purchasePayment" 등) -└── ... - -barobill_card_transaction_splits (카드 분개) -├── account_code (varchar) ← 문자열 직접 저장 -└── ... -``` - ---- - -## 3. 일반적인 ERP의 계정과목(Chart of Accounts) 체계 - -### 3.1 표준 구조 - -``` -[계정과목표 = Chart of Accounts] - -계정분류(대분류) -├── 1xxx: 자산 (Assets) -│ ├── 11xx: 유동자산 -│ │ ├── 1101: 현금 -│ │ ├── 1102: 보통예금 -│ │ ├── 1103: 당좌예금 -│ │ ├── 1110: 매출채권 -│ │ └── 1120: 선급금 -│ └── 12xx: 비유동자산 -│ ├── 1201: 토지 -│ ├── 1202: 건물 -│ └── 1210: 기계장치 -│ -├── 2xxx: 부채 (Liabilities) -│ ├── 21xx: 유동부채 -│ │ ├── 2101: 매입채무 -│ │ ├── 2102: 미지급금 -│ │ └── 2110: 예수금 -│ └── 22xx: 비유동부채 -│ -├── 3xxx: 자본 (Equity) -│ ├── 3101: 자본금 -│ └── 3201: 이익잉여금 -│ -├── 4xxx: 수익 (Revenue) -│ ├── 4101: 제품매출 -│ ├── 4102: 상품매출 -│ └── 4201: 임대수익 -│ -└── 5xxx: 비용 (Expenses) - ├── 51xx: 매출원가 - │ ├── 5101: 재료비 (제조) ← 코드로 구분! - │ └── 5102: 노무비 - ├── 52xx: 판매비와관리비 - │ ├── 5201: 급여 - │ ├── 5202: 복리후생비 - │ ├── 5203: 접대비 - │ ├── 5210: 재료비 (관리) ← 같은 명칭, 다른 코드! - │ └── 5220: 임차료 - └── 53xx: 영업외비용 - ├── 5301: 이자비용 - └── 5302: 외환차손 -``` - -### 3.2 일반 ERP 계정과목 마스터 구조 - -``` -account_subjects (계정과목 마스터) -├── id (PK) -├── code (varchar 10) ← "5101" 같은 번호 (4~6자리) -├── name (varchar 100) ← "재료비" -├── category (대분류) ← 자산/부채/자본/수익/비용 -├── sub_category (중분류) ← 유동자산/비유동자산/매출원가/판관비 등 -├── parent_code (상위 계정) ← 계층 구조용 -├── depth (계층 깊이) ← 1=대, 2=중, 3=소 -├── department_type (부문) ← 제조/관리/공통 등 -├── is_control (통제계정) ← 하위 세부계정 존재 여부 -├── is_active (사용여부) -├── sort_order -├── description (설명) -└── tenant_id -``` - -### 3.3 일반 ERP vs 현재 SAM ERP 비교 - -| 항목 | 일반 ERP | SAM ERP (현재) | 차이 | -|------|---------|---------------|------| -| **마스터 테이블** | 1개 (전사 공유) | 1개 있지만 일반전표만 사용 | 다른 모듈 미연동 | -| **코드 체계** | 4~6자리 숫자 (1101, 5201) | 일반전표만 code 있음, 나머지 영문 키워드 | 번호 체계 불통일 | -| **계층 구조** | 대-중-소 분류 (parent_code) | 대분류(5개)만 존재 | 중/소분류 없음 | -| **부문 구분** | department_type으로 제조/관리 분리 | 없음 | 제조vs회계 구분 불가 | -| **공유 범위** | 전 모듈이 같은 마스터 참조 | 각 모듈 독자 관리 | 핵심 문제 | -| **등록 방식** | 계정과목 설정 화면 1곳 | 일반전표 설정에서만 등록 | 접근성 제한 | -| **사용처 추적** | 어떤 전표에서 사용되는지 추적 | 없음 | 감사 추적 불가 | -| **잠금/보호** | 사용 중인 계정 삭제 방지 | 없음 | 데이터 무결성 위험 | - ---- - -## 4. 담당자 요구사항 vs 현재 시스템 GAP 분석 - -### 요구 1: "계정과목을 통일해서 관리" - -``` -현재 상태: - 일반전표 → account_codes 테이블 (DB) - 세금계산서 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 11개) - 분개 모달 - 카드내역 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 16개) - 미지급비용 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 9개) - 매출관리 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 8개) - 입금관리 → depositType 상수 - 출금관리 → withdrawalType 상수 - -필요한 것: - 모든 모듈 → account_codes 테이블 (DB) 하나만 참조 - -GAP: 크다 (프론트 하드코딩 → DB 마스터 참조로 전환 필요) -``` - -### 요구 2: "번호와 명칭으로 구분" - -``` -현재 상태: - 일반전표: code="101", name="현금" ← 있음 - 카드내역: value="salary", label="급여" ← 영문 키워드, 번호 없음 - -필요한 것: - 모든 곳에서: code="5201", name="급여" 형태로 표시 - UI에서: "5201 - 급여" 또는 "[5201] 급여" 식으로 코드+명칭 동시 표시 - -GAP: 중간 (코드 체계는 DB에 이미 있으나, 다른 모듈이 참조하지 않음) -``` - -### 요구 3: "제조/회계 동일 명칭, 번호로 구분" - -``` -현재 상태: - 구분 불가. "재료비"가 제조인지 관리인지 알 방법 없음 - -필요한 것: - 5101: 재료비 (제조 - 매출원가) - 5210: 재료비 (판관비 - 관리비용) - → 코드가 다르므로 자동 구분 - -GAP: 크다 (중분류 + 부문 구분 필드 추가 필요) -``` - -### 요구 4: "전체 공유 + 개별 등록 가능" - -``` -현재 상태: - 일반전표 설정에서만 등록 가능. 다른 모듈은 하드코딩이라 등록 개념 없음. - -필요한 것: - - 기본 계정과목표 (회사 설정 시 일괄 생성) - - 추가 등록 (필요에 따라 개별 계정과목 추가) - - 전 모듈 공유 (등록 즉시 카드, 입출금, 세금계산서 등에서 사용 가능) - -GAP: 중간 (DB 마스터는 있으니, 다른 모듈이 참조하도록 연결만 하면 됨) -``` - ---- - -## 5. 결론 및 권장사항 - -### 5.1 담당자 말씀이 맞는가? - -**맞습니다.** 일반적인 ERP에서 계정과목은 반드시: -- 하나의 마스터(Chart of Accounts)로 전사 통합 관리 -- 숫자 코드 + 명칭으로 식별 (코드가 PK 역할) -- 코드 번호로 계정 분류/부문 구분 (제조 5101 vs 관리 5210) -- 한 번 등록하면 모든 회계 모듈에서 공유 - -현재 SAM ERP는 일반전표에만 정상적인 마스터가 있고, 나머지는 각자 하드코딩이므로 -**회계적으로 올바르지 않은 상태**입니다. - -### 5.2 개선 방향 (단계별) - -``` -[Phase 1] 계정과목 마스터 강화 (백엔드) - - account_codes 테이블에 sub_category, parent_code, depth, department_type 추가 - - 표준 계정과목표 시드 데이터 준비 (대/중/소 분류) - - 코드 체계 확정 (4자리 vs 6자리) - -[Phase 2] 계정과목 설정 화면 독립 (프론트) - - 일반전표 내부 모달 → 독립 메뉴로 분리 (회계 > 계정과목 설정) - - 계층 구조 표시 (트리뷰 또는 들여쓰기 목록) - - 대량 등록 (Excel import), 기본 계정과목표 초기 세팅 - -[Phase 3] 전 모듈 통합 (프론트 + 백엔드) - - 세금계산서관리: ACCOUNT_SUBJECT_OPTIONS 상수 (11개) → DB 마스터 API 호출로 전환 - - 카드사용내역: ACCOUNT_SUBJECT_OPTIONS 상수 (16개) → DB 마스터 API 호출로 전환 - - 입금/출금관리: depositType/withdrawalType → DB 마스터 참조로 전환 - - 미지급비용, 매출관리: 동일하게 전환 - - Select UI에 "코드 - 명칭" 형태로 표시 (예: "[5201] 급여") - -[Phase 4] 고급 기능 - - 사용중 계정 삭제 방지 (참조 무결성) - - 계정과목별 거래 내역 조회 - - 기간별 잔액 집계 -``` - -### 5.3 작업 규모 예상 - -| Phase | 범위 | 핵심 변경 | -|-------|------|----------| -| 1 | 백엔드 마이그레이션 + 시드 | account_codes 테이블 확장, 시드 데이터 | -| 2 | 프론트 1개 페이지 신규 | 계정과목 설정 독립 페이지 | -| 3 | 프론트 6~7개 모듈 수정, 백엔드 API 조정 | 하드코딩 → API 참조 전환 | -| 4 | 양쪽 추가 개발 | 무결성, 집계, 조회 | diff --git a/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report-v2.md b/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report-v2.md deleted file mode 100644 index f6c04dca..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report-v2.md +++ /dev/null @@ -1,498 +0,0 @@ -# MES 데이터 정합성 심층 분석 보고서 v2 - -**분석일**: 2026-03-13 (v2 - 코드 업데이트 반영) -**범위**: 수주(Order) → 생산(Production) → 품질(Quality) → 출고(Shipment) 전체 파이프라인 -**방법**: 프론트엔드(sam-react-prod) + 백엔드(sam-api) 코드 레벨 재분석 - ---- - -## v1 대비 변경사항 요약 - -| 항목 | v1 (초기 분석) | v2 (코드 업데이트 반영) | -|------|---------------|----------------------| -| StockLot.work_order_id FK | 확인 안됨 | ✅ 2026-02-21 추가 확인 (생산→재고 연결 기반 마련) | -| QualityDocument 시스템 | 존재 인지 | ✅ 2026-03-05~10 활발히 개선 중 (inspection_data, options JSON 추가) | -| 출하 자동생성 | 언급 | ✅ 상세 분석 완료: createShipmentFromOrder() 중복방지 + ensureShipmentExists() | -| 3월 MES FK 추가 | 미확인 | ❌ 3월 마이그레이션에 MES FK 추가 없음 확인 | -| 나머지 4개 이슈 | 발견 | 🔴 여전히 미해결 (can_ship, LOT, ShipmentItem FK) | - ---- - -## Executive Summary - -| # | 이슈 | 심각도 | v1 판정 | v2 판정 | 변경 | -|---|------|--------|---------|---------|------| -| 1 | 생산완료→수주 PRODUCED 자동전환 | 🟡→🟢 | 조건부 동작 | **정상 동작 + 자동출하 생성** | ⬆ 개선 | -| 2 | 품질검사 이중 시스템 | 🔴 | 구조적 문제 | 🔴 **구조적 문제 지속** (QualityDocument 활발 개발 중이나 출고 연동 미완) | 유지 | -| 3 | 출고 시 can_ship 검증 | 🔴 | 누락 | 🔴 **여전히 누락** (canProceedToShip 호출 0회) | 유지 | -| 4 | 출고 시 재고 차감 | ✅ | 구현됨 | ✅ **구현됨**, ⚠️ soft fail 리스크 유지 | 유지 | -| 5 | LOT 추적 체계 | 🔴 | 단절 | 🟡 **부분 개선** (StockLot.work_order_id FK 추가, 그러나 LOT 전달 로직 미구현) | ⬆ 부분 | -| 6 | 출고품목↔수주품목 FK | 🔴 | 없음 | 🔴 **여전히 없음** (3월 마이그레이션에도 미추가) | 유지 | - ---- - -## 이슈 1: 생산완료 → 수주 상태 자동전환 + 출하 자동생성 - -### v2 판정: 🟢 정상 동작 (v1 대비 상향) - -v2 분석에서 자동 출하 생성 로직까지 상세 확인 완료. **정상 동작 확인**. - -### 전체 흐름 - -``` -WorkOrder 상태 변경 (updateStatus) - ↓ -syncOrderStatus() 자동 호출 (L971-1059) - ↓ -메인 WO 필터링: is_auxiliary=false AND process_id≠null - ↓ -전체 완료 시 → Order.status = PRODUCED - ↓ -createShipmentFromOrder() 자동 호출 (L719-809) - ↓ -Shipment 생성: status='scheduled', can_ship=true(자동) - ↓ -기존 Shipment 있으면 → 중복 생성 방지 (L721-728) -``` - -### 코드 근거 - -**syncOrderStatus**: `WorkOrderService.php:971-1059` - -```php -// L989-995: 메인 WO 필터 (보조공정 + process_id=null 제외) -$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => - !$this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null -); - -// L1001-1019: 상태 결정 -if ($shippedCount === $totalCount) { - $newOrderStatus = Order::STATUS_SHIPPED; -} elseif (($completedCount + $shippedCount) === $totalCount) { - $newOrderStatus = Order::STATUS_PRODUCED; -} -``` - -**createShipmentFromOrder**: `WorkOrderService.php:719-809` - -```php -// L721-728: 중복 방지 -$existingShipment = Shipment::where('order_id', $order->id)->first(); -if ($existingShipment) return $existingShipment; - -// L732-744: 출하 자동 생성 -$shipment = Shipment::create([ - 'order_id' => $order->id, - 'work_order_id' => null, // 수주 레벨 (WO 레벨 아님) - 'status' => 'scheduled', - 'can_ship' => true, // ← 자동으로 true 설정 -]); - -// L746-790: WO 아이템 → ShipmentItem 복사 -``` - -**ensureShipmentExists**: 이미 PRODUCED인데 출하가 없는 경우 보완 (L1027-1033) - -### 잔존 리스크 (낮음) - -| 조건 | 원인 | 발생 가능성 | -|------|------|------------| -| `process_id = NULL`인 WO | 공정 매핑 실패 | 낮음 (생성 시 검증됨) | -| `is_auxiliary` 오설정 | options JSON 수동 수정 | 매우 낮음 | - -### 회의 논의 포인트 -- ✅ 이 부분은 정상 동작 확인됨. 추가 조치 불필요 -- (선택) process_id=null WO가 실데이터에 존재하는지 한번 쿼리 확인 - ---- - -## 이슈 2: 품질검사 이중 시스템 - -### v2 판정: 🔴 구조적 문제 지속 (QualityDocument 활발 개발 중이나 출고 연동은 미완) - -### v1 대비 변화 - -| 변경 사항 | 시기 | 내용 | -|-----------|------|------| -| `quality_document_locations.inspection_data` JSON 추가 | 2026-03-06 | 개소별 검사 데이터 저장 | -| `quality_document_locations.options` JSON 추가 | 2026-03-10 | 검사 옵션 확장 | -| QualityDocumentService 개선 | 2026-03 | inspectLocation() 등 기능 확장 | - -### 여전히 해결 안 된 핵심 문제 - -``` -QualityDocument.complete() 호출 시: - → inspection_status = 'completed' (QualityDocument 내부만 업데이트) - → ❌ Shipment.can_ship 업데이트 없음 - → ❌ Inspection 테이블 동기화 없음 -``` - -**두 시스템 현재 상태**: - -| 항목 | 경로A: Inspection | 경로B: QualityDocument | -|------|-------------------|----------------------| -| **테이블** | `inspections` | `quality_documents` + `quality_document_locations` | -| **FK 연결** | `work_order_id` (작업지시) | `order_id` (수주) + `order_item_id` (개소) | -| **3월 업데이트** | 변경 없음 | ✅ 활발히 개선 중 | -| **출고 참조** | ❌ 안됨 | ❌ 안됨 | - -### 회의 논의 포인트 -- QualityDocument가 활발히 개발 중 → **경로B를 표준으로 확정하는 것이 합리적** -- 품질 완료 시 Shipment.can_ship 자동 업데이트 연동 필요 -- 경로A(Inspection)는 IQC/PQC 전용으로 역할 한정, FQC는 경로B로 통일 - ---- - -## 이슈 3: 출고 시 can_ship 검증 누락 - -### v2 판정: 🔴 여전히 미해결 (canProceedToShip() 호출 0회 확인) - -### 코드 현황 (변경 없음) - -**canProceedToShip()**: `Shipment.php:220-223` — 정의만 존재 - -```php -public function canProceedToShip(): bool { - return $this->can_ship && $this->deposit_confirmed; -} -// grep 결과: 모델 정의 외 호출 0회 -``` - -**updateStatus()**: `ShipmentService.php:305-356` — can_ship 검증 없이 바로 업데이트 - -```php -public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment -{ - $shipment = Shipment::findOrFail($id); - // 🔴 can_ship 검증 없음 - $shipment->update(['status' => $status, ...]); -} -``` - -**프론트엔드**: `ShipmentDetail.tsx:304-314` — can_ship 무시하고 버튼 표시 - -```typescript -const STATUS_TRANSITIONS: Record = { - scheduled: 'ready', ready: 'shipping', shipping: 'completed', completed: null, -}; -// can_ship=false여도 상태 변경 버튼 표시됨 -``` - -### v2 신규 발견: 자동 출하에서 can_ship=true 자동 설정 - -```php -// createShipmentFromOrder (L732-744) -'can_ship' => true, // 자동 생성 시 무조건 true -``` - -→ 자동 생성된 출하는 can_ship=true이므로 문제 경감 -→ **그러나** 수동 생성 출하에서는 여전히 검증 없음 - -### 위험 시나리오 - -``` -수동 출하 생성 (can_ship=false) - → 사용자가 "출하대기" 클릭 → 검증 없이 ready - → "배송중" → "배송완료" → 재고 차감 시도 - → 재고 부족 시 soft fail (로그만, 상태는 completed) ❌ -``` - -### 수정안 (최소 변경) - -**백엔드** (1곳 수정): -```php -// ShipmentService::updateStatus() 시작부에 추가 -if (in_array($status, ['ready', 'shipping', 'completed']) && !$shipment->can_ship) { - throw new \Exception('출하 불가 상태입니다. 품질 검수를 완료해주세요.'); -} -``` - -**프론트엔드** (1곳 수정): -```typescript -// ShipmentDetail.tsx 버튼 표시 조건 -{STATUS_TRANSITIONS[detail.status] && detail.canShip && ( - -)} -``` - ---- - -## 이슈 4: 출고 시 재고 차감 - -### v2 판정: ✅ 구현됨, ⚠️ Soft Fail 리스크 유지 (변경 없음) - -**코드**: `ShipmentService.php:361-401` - -```php -private function decreaseStockForShipment(Shipment $shipment): void -{ - foreach ($items as $item) { - try { - $stockService->decreaseForShipment(...); - } catch (\Exception $e) { - // 🟡 SOFT FAIL: 로그만 기록, 출하 상태는 completed 유지 - Log::warning('Failed to decrease stock', [...]); - // throw 없음 → 다음 아이템으로 계속 - } - } -} -``` - -### 회의 논의 포인트 -- **Hard Fail 전환 여부**: `throw`로 변경하면 하나라도 실패 시 출하 전체 롤백 -- **현재 방식 장점**: 일부 품목 재고 부족해도 출하는 진행 가능 -- **권장**: 최소한 재고 차감 실패 건수를 프론트에 표시 + 관리자 알림 - ---- - -## 이슈 5: LOT 추적 체계 - -### v2 판정: 🟡 부분 개선 (v1 🔴 → v2 🟡) - -### v1 대비 개선 사항 - -| 개선 | 시기 | 코드 근거 | -|------|------|-----------| -| `stock_lots.work_order_id` FK 추가 | 2026-02-21 | 마이그레이션 확인 | -| `inspections.work_order_id` FK 추가 | 2026-02-27 | 마이그레이션 확인 | - -→ 재고↔생산, 검사↔생산 연결 **기반은 마련됨** - -### 여전히 해결 안 된 핵심 문제 - -**1. 프론트에서 LOT 생성 → 백엔드 전송 안 됨** - -```typescript -// WorkerScreen/actions.ts:246 — 프론트에서만 LOT 생성 -const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`; -// ← 이 값이 API 요청 body에 포함되지 않음 -``` - -**2. 백엔드 LOT 저장 로직 없음** - -```php -// WorkOrderService.php:578-583 -case WorkOrder::STATUS_COMPLETED: - $workOrder->completed_at = now(); - $this->saveItemResults($workOrder, $resultData, $userId); - // ❌ LOT 자동 채번/저장 로직 없음 - break; -``` - -**3. 생산입고 시 LOT 전달 실패** - -```php -// WorkOrderService.php:620-637 -private function stockInFromProduction(WorkOrder $workOrder): void { - foreach ($workOrder->items as $woItem) { - $lotNo = $woItem->options['result']['lot_no'] ?? ''; // ← 항상 빈값 - if ($goodQty > 0 && $lotNo) { // ← 조건 불충족 → 실행 안됨 - $this->stockService->increaseFromProduction(...); - } - } -} -``` - -→ **StockLot.work_order_id FK는 추가됐지만, 실제 LOT를 생성/저장하는 코드가 없어서 FK가 활용되지 않음** - -### LOT 추적 현황 (업데이트) - -``` -수주 KD-TS-260313-01 - → 생산 완료 (LOT 미생성 ❌ — 프론트에서만 생성, 백엔드 저장 안됨) - → 재고 입고 (LOT 전달 실패 ❌ — stockInFromProduction 조건 불충족) - → [신규] StockLot.work_order_id FK 존재 (✅ 기반 마련) - → 품질검사 (별도 LOT 입력 ⚠️) - → 출고 (자재 LOT만 선택 가능 ❌, 생산 LOT 없음) -``` - -### 수정 방향 (StockLot.work_order_id 활용) - -```php -// 1. 백엔드에서 LOT 자동 채번 (WorkOrderService) -$lotNo = $this->numberingService->generate('production-lot', $tenantId); - -// 2. saveItemResults()에서 lot_no 저장 -$woItem->options = array_merge($woItem->options, ['result' => ['lot_no' => $lotNo]]); - -// 3. stockInFromProduction()에서 정상 동작 → StockLot 생성 시 work_order_id 연결 -$this->stockService->increaseFromProduction( - lotNo: $lotNo, - workOrderId: $workOrder->id // ← 이미 FK 존재 -); -``` - ---- - -## 이슈 6: 출고품목 ↔ 수주품목 FK 부재 - -### v2 판정: 🔴 여전히 미해결 (3월 마이그레이션에도 미추가 확인) - -### ShipmentItem 실제 컬럼 (변경 없음) - -``` -id, tenant_id, shipment_id(FK), seq, -item_code, item_name, floor_unit, specification, -quantity, unit, lot_no, stock_lot_id(index only), remarks -``` - -- ❌ `order_item_id` → 없음 -- ❌ `work_order_item_id` → 없음 - -### 3월 마이그레이션 확인 결과 - -3월에 추가된 마이그레이션 중 `shipment_items` 관련 변경 **0건**. -주요 3월 마이그레이션은 QualityDocument 관련 (`inspection_data`, `options` JSON 추가)에 집중. - -### 추적 불가 질문들 (여전히) - -| 질문 | 답변 가능 여부 | -|------|--------------| -| "출고 #1234에서 어떤 수주 품목이 출고됐나?" | ❌ 불가 | -| "수주 품목 #999는 어느 출고에서 출고됐나?" | ❌ 역추적 불가 | -| "수주 10개 품목 중 미출고 품목은?" | ❌ 집계 불가 | -| "부분 출고 진행률은?" | ❌ 계산 불가 | - -### 자동 출하 생성 시 연결 기회 놓침 - -```php -// createShipmentFromOrder (L746-790) -// WO 아이템을 ShipmentItem으로 복사하면서 source 정보 저장 안 함 -ShipmentItem::create([ - 'shipment_id' => $shipment->id, - 'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null, - 'quantity' => $result['good_qty'] ?? $woItem->quantity, - // ❌ 'order_item_id' => $woItem->source_order_item_id ← 이것만 추가하면 됨 - // ❌ 'work_order_item_id' => $woItem->id ← 이것만 추가하면 됨 -]); -``` - -### 수정안 - -**마이그레이션** (새 파일): -```php -Schema::table('shipment_items', function (Blueprint $table) { - $table->unsignedBigInteger('order_item_id')->nullable()->after('stock_lot_id'); - $table->unsignedBigInteger('work_order_item_id')->nullable()->after('order_item_id'); - $table->foreign('order_item_id')->references('id')->on('order_items')->nullOnDelete(); - $table->foreign('work_order_item_id')->references('id')->on('work_order_items')->nullOnDelete(); -}); -``` - -**createShipmentFromOrder** (2줄 추가): -```php -ShipmentItem::create([ - ...기존 필드, - 'order_item_id' => $woItem->source_order_item_id, // 추가 - 'work_order_item_id' => $woItem->id, // 추가 -]); -``` - ---- - -## 전체 FK 연결 현황도 (v2 업데이트) - -``` -orders ──────────────────── order_items ──────── order_nodes - │ (order_id FK) │ (order_node_id FK) │ - │ │ │ - ├─── work_orders │ │ - │ │ (sales_order_id FK) │ │ - │ │ │ │ - │ └─── work_order_items │ │ - │ │ │ │ - │ │ source_order_item_id ──→ ❌ FK 없음 (인덱스만) - │ │ │ - │ inspections │ - │ │ (work_order_id FK ✅) [2026-02-27 추가] │ - │ │ (lot_no ← 연결 안됨 ❌) │ - │ │ - │ stock_lots │ - │ │ (work_order_id FK ✅) [2026-02-21 추가] ← 🆕 v1에서 미확인 - │ │ - ├─── quality_document_orders ──→ quality_documents │ - │ │ (order_id FK ✅) │ - │ │ │ - │ └─── quality_document_locations │ - │ │ (order_item_id FK ✅) │ - │ │ (inspection_data JSON 🆕 2026-03-06) │ - │ │ (options JSON 🆕 2026-03-10) │ - │ │ - └─── shipments │ - │ (order_id FK ✅, work_order_id FK ✅) │ - │ │ - └─── shipment_items │ - │ (shipment_id FK ✅) │ - │ (stock_lot_id → 인덱스만, FK 없음) │ - │ (order_item_id ❌ 컬럼 없음) │ - │ (work_order_item_id ❌ 컬럼 없음) │ -``` - ---- - -## 개선 우선순위 로드맵 (v2 업데이트) - -### P0 (즉시 - 운영 리스크) — 변경 없음 - -| # | 작업 | 수정 범위 | 난이도 | -|---|------|---------|--------| -| 1 | **can_ship 검증 추가** | ShipmentService::updateStatus() 1곳 + ShipmentDetail.tsx 1곳 | 하 (수정 3줄) | -| 2 | **재고 차감 실패 알림** | ShipmentService::decreaseStockForShipment() → 최소 결과 반환 | 하 | - -### P1 (단기 - 데이터 정합성) — 🆕 StockLot.work_order_id 활용 추가 - -| # | 작업 | 수정 범위 | 난이도 | -|---|------|---------|--------| -| 3 | **생산 LOT 백엔드 자동 채번** | WorkOrderService::saveItemResults() + NumberingService | 중 | -| 4 | **생산입고 LOT 연결** | WorkOrderService::stockInFromProduction() → StockLot.work_order_id 활용 | 중 | -| 5 | **shipment_items에 order_item_id 추가** | 마이그레이션 + createShipmentFromOrder() 2줄 추가 | 중 | - -### P2 (중기 - 구조 개선) — 🆕 QualityDocument 기반 통합 명시 - -| # | 작업 | 수정 범위 | 난이도 | -|---|------|---------|--------| -| 6 | **품질검사 정본 = QualityDocument** | Inspection은 IQC/PQC 전용, FQC는 QualityDocument로 통일 | 상 | -| 7 | **품질완료 → can_ship 자동 연동** | QualityDocumentService::complete() → Shipment.can_ship 업데이트 | 중 | -| 8 | **work_order_items.source_order_item_id FK** | 마이그레이션 1줄 | 하 | -| 9 | **stock_lot_id FK constraint 추가** | shipment_items 마이그레이션 | 하 | - ---- - -## 정상 동작 확인 항목 (v2) - -- ✅ 수주 → 생산지시 생성 (공정별 자동 분류) -- ✅ 작업지시 상태 관리 (유효 상태 전환 + auxiliary 필터링) -- ✅ **syncOrderStatus()**: 메인 WO 완료 → Order PRODUCED 자동 전환 -- ✅ **createShipmentFromOrder()**: PRODUCED 전환 시 출하 자동 생성 (중복 방지 포함) -- ✅ **ensureShipmentExists()**: 이미 PRODUCED인데 출하 없는 경우 보완 -- ✅ 자재 투입 재고 차감 (WorkOrderMaterialInput + StockService) -- ✅ 출고 완료 시 재고 차감 (FIFO + lockForUpdate + stock_transactions) -- ✅ 출고 완료 → 수주 상태 SHIPPED 자동 전환 -- ✅ 매출 자동 생성 (sales_recognition 조건부) -- ✅ 수주 상태별 수정/삭제 제한 -- ✅ 생산지시 되돌리기 (WorkOrder/Item/Result 삭제) -- 🆕 ✅ StockLot.work_order_id FK (생산→재고 연결 기반) -- 🆕 ✅ Inspection.work_order_id FK (검사→생산 연결) - ---- - -## 회의 토론 안건 정리 - -### 즉시 결정 필요 (P0) - -1. **can_ship 검증**: 백엔드 1줄 + 프론트 1줄 수정으로 해결 가능. 즉시 적용? -2. **재고 차감 실패 처리**: Hard fail(롤백) vs Soft fail(현행) + 알림 추가? - -### 설계 방향 결정 필요 (P1) - -3. **LOT 채번 규칙**: 생산 LOT 형식 결정 (현재 프론트: `KD-SA-YYMMDD-NN`) -4. **생산 LOT 생성 시점**: WO 완료 시? WO 생성 시? 첫 작업 보고 시? -5. **ShipmentItem FK**: 마이그레이션 타이밍 (기존 데이터 소급 매칭 필요?) - -### 방향성 논의 (P2) - -6. **품질 시스템 정본**: QualityDocument를 표준으로 확정하는 것에 이견 있는지? -7. **품질→출하 자동 연동**: 어떤 조건에서 can_ship=true로 전환할 것인지? - - 전체 개소(location) 검사 완료 시? - - 합격률 기준? - - 수동 최종 승인 필요? diff --git a/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report.md b/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report.md deleted file mode 100644 index 51a3a764..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report.md +++ /dev/null @@ -1,421 +0,0 @@ -# MES 데이터 정합성 심층 분석 보고서 - -**분석일**: 2026-03-13 -**범위**: 수주(Order) → 생산(Production) → 품질(Quality) → 출고(Shipment) 전체 파이프라인 -**방법**: 프론트엔드(sam-react-prod) + 백엔드(sam-api) 코드 레벨 분석 - ---- - -## Executive Summary - -| # | 이슈 | 심각도 | 현황 | 코드 근거 | -|---|------|--------|------|-----------| -| 1 | 생산완료→수주 PRODUCED 자동전환 | 🟡 조건부 동작 | 로직 있으나 edge case에서 실패 가능 | `WorkOrderService.php:974-1062` | -| 2 | 품질검사 이중 시스템 | 🔴 구조적 문제 | Inspection vs QualityDocument 분리, 출고 연동 없음 | 양쪽 모두 Shipment 참조 안함 | -| 3 | 출고 시 can_ship 검증 | 🔴 누락 | canProceedToShip() 정의만 있고 호출 0회 | `ShipmentService.php:305-356` | -| 4 | 출고 시 재고 차감 | ✅ 구현됨 | completed 전환 시 FIFO 자동 차감 | `ShipmentService.php:361-401` | -| 5 | LOT 추적 체계 | 🔴 단절 | 프론트에서만 LOT 생성, 백엔드 저장 안됨 | `WorkerScreen/actions.ts:246` | -| 6 | 출고품목↔수주품목 FK | 🔴 없음 | ShipmentItem에 order_item_id 컬럼 자체 부재 | `shipment_items 마이그레이션` | - ---- - -## 이슈 1: 생산완료 → 수주 상태 자동전환 - -### 결론: ✅ 로직 있음, 🟡 조건부 실패 가능 - -### 동작 원리 - -``` -WorkOrder 상태 변경 (updateStatus) - ↓ (라인 603) -syncOrderStatus() 자동 호출 - ↓ (라인 1004-1022) -메인 작업지시 집계 → 조건 충족 시 Order.status = PRODUCED - ↓ (라인 1059-1061) -PRODUCED 전환 시 → 출고(Shipment) 자동 생성 -``` - -**코드**: `sam-api/app/Services/WorkOrderService.php` - -```php -// 라인 998: 메인 작업지시 필터 -$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => - !$this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null -); - -// 라인 1011-1022: 상태 결정 -if ($shippedCount === $totalCount) { - $newOrderStatus = Order::STATUS_SHIPPED; -} elseif (($completedCount + $shippedCount) === $totalCount) { - $newOrderStatus = Order::STATUS_PRODUCED; // ← 핵심 조건 -} elseif ($inProgressCount > 0 || $completedCount > 0 || $shippedCount > 0) { - $newOrderStatus = Order::STATUS_IN_PRODUCTION; -} -``` - -### 실패 가능 조건 - -| 조건 | 원인 | 영향 | -|------|------|------| -| `process_id = NULL`인 WO 존재 | 공정 매핑 실패로 생성된 작업지시 | 메인 WO 카운트에서 제외 → 조건식 계산 오류 | -| `is_auxiliary = true` 오설정 | options JSON에 잘못 저장 | 메인 WO로 인식 안 됨 | - -### 검증 SQL - -```sql --- 해당 수주의 작업지시 현황 확인 -SELECT id, work_order_no, status, process_id, - JSON_EXTRACT(options, '$.is_auxiliary') as is_auxiliary -FROM work_orders -WHERE sales_order_id = {order_id} AND status != 'cancelled'; -``` - -### 회의 논의 포인트 -- process_id=null인 작업지시가 실제로 존재하는지 DB 확인 필요 -- 존재한다면 → 생산지시 생성 시 process_id null 방지 로직 추가 - ---- - -## 이슈 2: 품질검사 이중 시스템 - -### 결론: 🔴 두 시스템이 독립 운영, 출고와 연동 없음 - -### 두 시스템 비교 - -| 항목 | 경로A: Inspection | 경로B: QualityDocument | -|------|-------------------|----------------------| -| **테이블** | `inspections` | `quality_documents` + `quality_document_locations` | -| **생성일** | 2025-12-29 | 2026-03-05 (최근 추가) | -| **연결 키** | `work_order_id` (작업지시) | `order_id` (수주) + `order_item_id` (개소) | -| **판정 필드** | `result: pass/fail` | `inspection_status: pending/completed` | -| **검사 단위** | 전체 건 | 개소(location)별 | -| **프론트 진입점** | 검사 메뉴 | 제품검사 메뉴 | -| **FQC 문서** | JSON items 배열 | Document 시스템 (EAV) | -| **출고 참조** | ❌ 안됨 | ❌ 안됨 | - -### 핵심 문제: 출고에서 둘 다 참조 안함 - -**코드**: `sam-api/app/Services/ShipmentService.php` - -```php -// 라인 207: 출고 생성 시 -'can_ship' => $data['can_ship'] ?? false, // ← 수동 입력만, 품질 검사 결과 참조 없음 - -// 라인 220-223: 출고 가능 여부 메서드 -public function canProceedToShip(): bool { - return $this->can_ship && $this->deposit_confirmed; - // ❌ Inspection.result 참조 없음 - // ❌ QualityDocumentLocation.inspection_status 참조 없음 -} -``` - -### 프론트엔드 판정 우선순위 - -**코드**: `src/components/quality/InspectionManagement/InspectionDetail.tsx` - -``` -경로B (QualityDocument/FQC 문서) 우선 → 경로A (Inspection) fallback -``` - -### 회의 논의 포인트 -- **정본 결정 필요**: 경로A(Inspection) vs 경로B(QualityDocument) 중 하나를 표준으로 -- 경로B가 최근(3월) 추가된 것 → 경로B를 표준으로 하고 경로A는 호환 레이어? -- 출고 시 품질 판정 자동 참조 로직 추가 필수 - ---- - -## 이슈 3: 출고 시 can_ship 검증 누락 - -### 결론: 🔴 canProceedToShip() 메서드 정의만 있고, 실제 호출 0회 - -### 현재 상태 변경 코드 - -**코드**: `sam-api/app/Services/ShipmentService.php:305-356` - -```php -public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment -{ - $shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id); - - // 🔴 can_ship 검증 로직 전혀 없음 - - $shipment->update(['status' => $status, ...]); // ← 바로 업데이트 - - // completed 시 재고 차감 (이것은 동작함) - if ($status === 'completed' && $previousStatus !== 'completed') { - $this->decreaseStockForShipment($shipment); - } -} -``` - -### 프론트엔드도 미검증 - -**코드**: `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx:304-314` - -```typescript -// 상태 전이 맵만 확인, canShip 체크 없음 -const STATUS_TRANSITIONS: Record = { - scheduled: 'ready', - ready: 'shipping', - shipping: 'completed', - completed: null, -}; - -// can_ship=false여도 버튼이 표시됨 ❌ -{STATUS_TRANSITIONS[detail.status] && ( - -)} -``` - -### 위험 시나리오 - -``` -can_ship=false (품질 미통과) + status=scheduled - → 사용자가 "출하대기로 변경" 클릭 - → 백엔드 검증 없음 → status='ready' ❌ - → "배송중" → "배송완료" → 재고 차감 시도 - → 재고 부족 시 soft fail (로그만 기록, 상태는 변경됨) ❌ -``` - -### 회의 논의 포인트 -- 백엔드: `updateStatus()`에 `can_ship` 검증 추가 (1줄 수정) -- 프론트: 버튼 표시 조건에 `detail.canShip` 추가 -- 재고 차감 실패 시 hard fail로 변경할지 논의 필요 - ---- - -## 이슈 4: 출고 시 재고 차감 - -### 결론: ✅ 완전 구현됨 - -**코드**: `sam-api/app/Services/ShipmentService.php:347-350` - -```php -if ($status === 'completed' && $previousStatus !== 'completed') { - $this->decreaseStockForShipment($shipment); -} -``` - -**StockService FIFO 차감**: `StockService.php:1236-1354` -- Stock 행 잠금 (lockForUpdate) -- LOT별 FIFO 순서 차감 -- stock_transactions 거래 기록 (reason: SHIPMENT) -- 감사 로그 기록 - -**⚠️ 주의**: 개별 품목 차감 실패 시 soft fail (로그만 기록, 트랜잭션 미롤백) - ---- - -## 이슈 5: LOT 추적 체계 단절 - -### 결론: 🔴 4개 모듈이 완전 독립적 LOT 관리, 추적 불가 - -### LOT 생성/관리 현황 - -| 모듈 | LOT 형식 | 생성 위치 | 저장 위치 | 상태 | -|------|----------|-----------|-----------|------| -| **수주** | - | - | Order에 lot_no 필드 없음 | ❌ 필드 없음 | -| **생산** | `KD-SA-YYMMDD-NN` | 프론트 `WorkerScreen/actions.ts:246` | ❌ 백엔드 전송 안됨 | ❌ 저장 안됨 | -| **자재** | 입고 시 생성 | `StockService` | `stock_lots.lot_no` | ✅ 동작 | -| **품질** | 검사팀 별도 입력 | `InspectionService` | `inspections.lot_no` | ⚠️ 연결 없음 | -| **출고** | StockLot에서 선택 | `ShipmentService:getLotOptions()` | `shipments.lot_no` | ⚠️ 자재 LOT만 | - -### 핵심 단절 코드 - -**프론트에서 LOT 생성하지만 전송 안 함**: - -```typescript -// WorkerScreen/actions.ts:246 -const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`; -// ← 이 값이 API 요청에 포함되지 않음 -``` - -**백엔드에서 LOT 저장 안 함**: - -```php -// WorkOrderService.php:578-583 -case WorkOrder::STATUS_COMPLETED: - $workOrder->completed_at = now(); - $this->saveItemResults($workOrder, $resultData, $userId); - // ❌ LOT 생성/저장 로직 없음 - break; -``` - -**생산입고 시 LOT 전달 실패**: - -```php -// WorkOrderService.php:620-637 -private function stockInFromProduction(WorkOrder $workOrder): void { - foreach ($workOrder->items as $woItem) { - $lotNo = $woItem->options['result']['lot_no'] ?? ''; // ← 항상 빈값 - if ($goodQty > 0 && $lotNo) { // ← 조건 불충족으로 실행 안됨 - $this->stockService->increaseFromProduction(...); - } - } -} -``` - -**출고 LOT 옵션에서 생산 LOT 제외**: - -```php -// ShipmentService.php:525-550 -public function getLotOptions(): array { - return StockLot::where(...) // ← 구매입고 LOT만 조회 - ->whereIn('status', ['available', 'reserved']) - ->get(); - // ❌ 생산 완료 LOT(KD-SA-*) 미포함 -} -``` - -### 추적 불가 시나리오 - -``` -수주 KD-TS-260313-01 - → 생산 완료 (LOT 미생성) - → 재고 입고 (LOT 전달 실패 → 입고 안됨?) - → 품질검사 (별도 LOT 입력) - → 출고 (자재 LOT만 선택 가능, 생산품 LOT 없음) - -결과: "이 출고 건이 어느 생산 LOT인지" → 답 불가 -``` - -### 회의 논의 포인트 -- **최우선**: 백엔드에서 생산 LOT 자동 채번/저장 로직 구현 -- WorkResult.lot_no에 실제 저장 -- StockLot.work_order_id (이미 2026-02-21 추가됨) 활용하여 연결 -- getLotOptions()에 생산 LOT 포함 - ---- - -## 이슈 6: 출고품목 ↔ 수주품목 FK 부재 - -### 결론: 🔴 ShipmentItem에 order_item_id, work_order_item_id 컬럼 자체가 없음 - -### ShipmentItem 실제 컬럼 - -**마이그레이션**: `2025_12_26_150605_create_shipment_items_table.php` - -``` -id, tenant_id, shipment_id(FK), seq, -item_code, item_name, floor_unit, specification, -quantity, unit, lot_no, stock_lot_id(FK), remarks -``` - -- ❌ `order_item_id` → 없음 -- ❌ `work_order_item_id` → 없음 -- 품목 데이터는 **텍스트 복사**만 (품명, 규격, 수량) - -### ShipmentItem 생성 코드 - -**코드**: `sam-api/app/Services/ShipmentService.php:468-493` - -```php -ShipmentItem::create([ - 'item_code' => $item['item_code'] ?? null, - 'item_name' => $item['item_name'], - 'quantity' => $item['quantity'] ?? 0, - // ❌ order_item_id 없음 - // ❌ work_order_item_id 없음 -]); -``` - -### 추적 불가 질문들 - -| 질문 | 답변 가능 여부 | -|------|--------------| -| "출고 #1234에서 어떤 수주 품목이 출고됐나?" | ❌ 불가 | -| "수주 품목 #999는 어느 출고에서 출고됐나?" | ❌ 역추적 불가 | -| "수주 10개 품목 중 미출고 품목은?" | ❌ 집계 불가 | -| "부분 출고 진행률은?" | ❌ 계산 불가 | - -### 관련 FK도 불완전 - -**WorkOrderItem.source_order_item_id**: 인덱스만 있고 FK constraint 없음 - -```php -// 마이그레이션 2026_01_16 -$table->unsignedBigInteger('source_order_item_id')->nullable(); -$table->index('source_order_item_id'); // ← 인덱스만 -// ❌ $table->foreign('source_order_item_id')->references('id')->on('order_items') 없음 -``` - -### 회의 논의 포인트 -- shipment_items에 `order_item_id`, `work_order_item_id` 컬럼 추가 마이그레이션 -- 기존 데이터 마이그레이션 방안 (품명+규격으로 매칭?) -- work_order_items.source_order_item_id에 FK constraint 추가 - ---- - -## 전체 FK 연결 현황도 - -``` -orders ──────────────────── order_items ──────── order_nodes - │ (order_id FK) │ (order_node_id FK) │ - │ │ │ - ├─── work_orders │ │ - │ │ (sales_order_id FK) │ │ - │ │ │ │ - │ └─── work_order_items │ │ - │ │ │ │ - │ │ source_order_item_id ──→ ❌ FK 없음 (인덱스만) - │ │ │ - │ inspections │ - │ │ (work_order_id FK ✅) │ - │ │ (lot_no ← 연결 안됨 ❌) │ - │ │ - ├─── quality_document_orders ──→ quality_documents │ - │ │ (order_id FK ✅) │ - │ │ │ - │ └─── quality_document_locations │ - │ │ (order_item_id FK ✅) │ - │ │ - └─── shipments │ - │ (order_id FK ✅, work_order_id FK ✅) │ - │ │ - └─── shipment_items │ - │ (shipment_id FK ✅) │ - │ (stock_lot_id FK ✅) │ - │ (order_item_id ❌ 없음) │ - │ (work_order_item_id ❌ 없음) │ -``` - ---- - -## 개선 우선순위 로드맵 - -### P0 (즉시 - 운영 리스크) - -| # | 작업 | 영향범위 | 예상 난이도 | -|---|------|---------|------------| -| 1 | **can_ship 검증 추가** (백엔드 updateStatus + 프론트 버튼 조건) | ShipmentService 1곳 + ShipmentDetail 1곳 | 하 | -| 2 | **재고 차감 실패 시 hard fail** (try-catch에서 throw로 변경) | ShipmentService 1곳 | 하 | - -### P1 (단기 - 데이터 정합성) - -| # | 작업 | 영향범위 | 예상 난이도 | -|---|------|---------|------------| -| 3 | **생산 LOT 백엔드 자동 채번/저장** | WorkOrderService + NumberingService | 중 | -| 4 | **생산입고 LOT 연결 수정** (stockInFromProduction) | WorkOrderService + StockService | 중 | -| 5 | **shipment_items에 order_item_id 추가** (마이그레이션 + 서비스) | 마이그레이션 + ShipmentService | 중 | - -### P2 (중기 - 구조 개선) - -| # | 작업 | 영향범위 | 예상 난이도 | -|---|------|---------|------------| -| 6 | **품질검사 정본 결정** (Inspection vs QualityDocument 통합) | 양쪽 서비스 + 프론트 | 상 | -| 7 | **출고 시 품질 판정 자동 참조** (can_ship 자동 설정) | ShipmentService + 품질 연동 | 상 | -| 8 | **work_order_items.source_order_item_id FK 추가** | 마이그레이션 | 하 | -| 9 | **process_id=null 작업지시 생성 방지** | OrderService.createProductionOrder | 하 | - ---- - -## 참고: 정상 동작하는 부분 - -- ✅ 수주 → 생산지시 생성 (공정별 자동 분류) -- ✅ 작업지시 상태 관리 (유효한 상태 전환 규칙) -- ✅ 자재 투입 재고 차감 (WorkOrderMaterialInput + StockService) -- ✅ 출고 완료 시 재고 차감 (FIFO + 거래 기록) -- ✅ 출고 완료 → 수주 상태 SHIPPED 자동 전환 -- ✅ 매출 자동 생성 (sales_recognition 조건부) -- ✅ 수주 상태별 수정/삭제 제한 -- ✅ 생산지시 되돌리기 (WorkOrder/Item/Result 삭제) diff --git a/claudedocs/architecture/[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md b/claudedocs/architecture/[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md deleted file mode 100644 index af4f9a18..00000000 --- a/claudedocs/architecture/[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md +++ /dev/null @@ -1,437 +0,0 @@ -# Tenant-Based Module Separation: Cross-Module Dependency Audit - -**Date**: 2026-03-17 -**Scope**: Full dependency analysis for tenant-based module separation -**Status**: COMPLETE - ---- - -## Module Separation Plan - -| Tenant Package | Modules | File Count | -|---|---|---| -| Common ERP | dashboard, accounting, sales, HR, approval, board, customer-center, settings, master-data, material, outbound | ~majority | -| Kyungdong (Shutter MES) | production (56 files), quality (35 files) | 91 files | -| Juil (Construction) | construction (161 files) | 161 files | -| Optional | vehicle-management (13 files), vehicle (10 files) | 23 files | - ---- - -## CRITICAL RISK (Will break immediately on separation) - -### C1. Approval Module -> Production Component Import -**Files affected**: 1 file, blocks entire approval workflow -``` -src/components/approval/ApprovalBox/index.tsx:76 - import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal'; -``` -**Impact**: The approval inbox (Common ERP) directly imports a production document modal. If production module is removed, the approval box crashes entirely. -**Fix**: Extract `InspectionReportModal` to a shared `@/components/document-system/` or a shared document viewer module. Alternatively, use dynamic import with fallback. - ---- - -### C2. Sales Module -> Production Component Imports (3 page files) -**Files affected**: 3 files under `src/app/[locale]/(protected)/sales/` - -``` -sales/order-management-sales/production-orders/page.tsx - import { getProductionOrders, getProductionOrderStats } from "@/components/production/ProductionOrders/actions"; - import { ProductionOrder, ProductionOrderStats } from "@/components/production/ProductionOrders/types"; - -sales/order-management-sales/production-orders/[id]/page.tsx - import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions"; - import { ProductionOrderDetail, ProductionOrderStats } from "@/components/production/ProductionOrders/types"; - -sales/order-management-sales/[id]/production-order/page.tsx - import { AssigneeSelectModal } from "@/components/production/WorkOrders/AssigneeSelectModal"; - import { getProcessList } from "@/components/process-management/actions"; - import { createProductionOrder } from "@/components/orders/actions"; -``` -**Impact**: The sales module has a "production orders" sub-page that directly imports production actions, types, and UI components. This entire sub-section becomes non-functional without the production module. -**Fix strategy**: -1. The `production-orders` sub-pages under sales should be conditionally loaded (tenant feature flag) -2. `ProductionOrders/actions.ts` and `types.ts` should be extracted to a shared interface package -3. `AssigneeSelectModal` should be moved to a shared component or lazy-loaded with fallback - ---- - -### C3. Sales -> Production Route Navigation -**File**: `sales/order-management-sales/production-orders/[id]/page.tsx:247` -``` -router.push("/production/work-orders"); -``` -**Impact**: Hard navigation to production route from sales. Will 404 if production routes don't exist. -**Fix**: Conditional navigation wrapped in tenant feature check. - ---- - -### C4. QMS Page (Quality) -> Production + Outbound + Orders Imports -**File**: `src/app/[locale]/(protected)/quality/qms/page.tsx` -``` -import { InspectionReportModal } from '@/components/production/WorkOrders/documents'; -import { WorkLogModal } from '@/components/production/WorkOrders/documents'; -import { ProductInspectionViewModal } from '@/components/quality/InspectionManagement/ProductInspectionViewModal'; -``` -**File**: `src/app/[locale]/(protected)/quality/qms/mockData.ts` -``` -import type { WorkOrder } from '@/components/production/ProductionDashboard/types'; -import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types'; -``` -**File**: `src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx` -``` -import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation'; -import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip'; -import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument'; -import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types'; -``` -**Impact**: QMS (assigned to Kyungdong tenant with quality) imports from production (same tenant -- OK), but also from outbound and orders (Common ERP). If QMS is extracted WITH quality, it will still need access to outbound/orders document components from Common ERP. -**Fix**: This is actually a reverse dependency -- quality module needs Common ERP's outbound/orders docs. This direction is acceptable (tenant module depends on common). However, the production type imports need a shared types interface. - ---- - -### C5. CEO Dashboard -> Production + Construction Data Sections -**Files affected**: Multiple files in `src/components/business/CEODashboard/` -``` -CEODashboard.tsx: 'production' and 'construction' section rendering -sections/DailyProductionSection.tsx: production data display -sections/ConstructionSection.tsx: construction data display -sections/CalendarSection.tsx: '/production/work-orders' and '/construction/project/contract' route references -types.ts: DailyProductionData, ConstructionData, production/construction settings flags -useSectionSummary.ts: production and construction summary logic -``` -**File**: `src/lib/api/dashboard/transformers/production-logistics.ts` -``` -import type { DailyProductionData, UnshippedData, ConstructionData } from '@/components/business/CEODashboard/types'; -``` -**File**: `src/hooks/useCEODashboard.ts` -``` -useDashboardFetch('dashboard/production/summary', ...) -useDashboardFetch('dashboard/construction/summary', ...) -``` -**Impact**: The CEO Dashboard (Common ERP) renders production and construction sections. If modules are removed, these sections will fail. The dashboard types contain production/construction data structures hardcoded. -**Fix**: -1. Dashboard sections must be conditionally rendered based on tenant configuration -2. Dashboard types need module-awareness (optional types, feature flags) -3. Dashboard API hooks should gracefully handle missing endpoints -4. Route references in CalendarSection need tenant-aware navigation - ---- - -### C6. Dashboard Invalidation System -**File**: `src/lib/dashboard-invalidation.ts` -```typescript -type DomainKey = '...' | 'production' | 'shipment' | 'construction'; -const DOMAIN_SECTION_MAP = { - production: ['statusBoard', 'dailyProduction'], - construction: ['statusBoard', 'construction'], -}; -``` -**Impact**: The dashboard invalidation system in `@/lib/` (Common ERP) has hardcoded production and construction domains. If production/construction modules call `invalidateDashboard('production')`, this works cross-module. If they are removed, stale keys remain. -**Fix**: Make domain-section mapping configurable/dynamic. Register domains at module initialization. - ---- - -## HIGH RISK (Will cause issues during development/testing) - -### H1. Construction -> HR Module Import -**File**: `src/components/business/construction/site-briefings/SiteBriefingForm.tsx:61-64` -``` -import { getEmployees } from '@/components/hr/EmployeeManagement/actions'; -import type { Employee } from '@/components/hr/EmployeeManagement/types'; -``` -**Impact**: Construction module (Juil tenant) depends on HR module (Common ERP). This direction (tenant -> common) is acceptable for single-binary, but if construction is extracted to a separate package, it needs access to HR's interface. -**Fix**: Extract employee lookup to a shared API interface. Or accept that tenant modules depend on Common ERP (allowed direction). - ---- - -### H2. Production -> Process-Management Import -**File**: `src/components/production/WorkerScreen/index.tsx:47` -``` -import { getProcessList } from '@/components/process-management/actions'; -``` -**Impact**: Process management is under `master-data` (Common ERP). Production depends on it. -**Fix**: This is allowed (tenant -> common), but if extracting to separate package, needs clear interface. - ---- - -### H3. Shared Type: `@/types/process.ts` -**Files**: 8 production files import from this shared type file -``` -src/components/production/WorkerScreen/index.tsx -src/components/production/WorkOrders/documents/BendingInspectionContent.tsx -src/components/production/WorkOrders/documents/BendingWipInspectionContent.tsx -src/components/production/WorkOrders/documents/InspectionReportModal.tsx -src/components/production/WorkOrders/documents/ScreenInspectionContent.tsx -src/components/production/WorkOrders/documents/SlatInspectionContent.tsx -src/components/production/WorkOrders/documents/SlatJointBarInspectionContent.tsx -src/components/production/WorkOrders/documents/inspection-shared.tsx -``` -**Impact**: `@/types/process.ts` (296 lines) contains `InspectionSetting`, `InspectionScope`, `Process`, `ProcessStep` types. These are used heavily by production but defined at the project root level. -**Fix**: This file should remain in Common ERP (it's process master-data definition). Production depends on it -- that direction is acceptable. - ---- - -### H4. Dev Generator -> Production Type Import -**File**: `src/components/dev/generators/workOrderData.ts:13` -``` -import type { ProcessOption } from '@/components/production/WorkOrders/actions'; -``` -**Impact**: Dev tooling imports a type from production. Low runtime risk (dev only) but will cause TS errors if production module is absent. -**Fix**: Move `ProcessOption` type to a shared types location, or make dev generators tenant-aware. - ---- - -### H5. Production -> Dashboard Invalidation (Bidirectional) -**Files**: -``` -src/components/production/WorkOrders/WorkOrderCreate.tsx:10 -> import { invalidateDashboard } from '@/lib/dashboard-invalidation'; -src/components/production/WorkOrders/WorkOrderDetail.tsx:10 -> same -src/components/production/WorkOrders/WorkOrderEdit.tsx:11 -> same -src/components/business/construction/management/ConstructionDetailClient.tsx:4 -> same -``` -**Impact**: Production and construction call `invalidateDashboard()` from `@/lib/` (Common ERP). This is allowed direction (tenant -> common). But the function's domain keys include `'production'` and `'construction'` -- if those modules are absent, orphan event listeners remain. -**Fix**: Register domain keys dynamically. Modules register their dashboard sections at init. - ---- - -### H6. CEO Dashboard CalendarSection Route References -**File**: `src/components/business/CEODashboard/sections/CalendarSection.tsx` -``` -order: '/production/work-orders', -construction: '/construction/project/contract', -``` -**Impact**: Clicking calendar items navigates to production/construction routes. Will 404 if those tenant routes don't exist. -**Fix**: Tenant-aware route resolution with fallback or hidden navigation for unavailable modules. - ---- - -### H7. Menu Transform Production Icon Mapping -**File**: `src/lib/utils/menuTransform.ts:89` -``` -production: Factory, -``` -**Impact**: Menu icon mapping contains production key. Low risk (backend controls menu visibility), but vestigial code remains. -**Fix**: Menu rendering is already dynamic from backend. Icon map can safely retain unused entries. - ---- - -## MEDIUM RISK (Requires attention but not immediately breaking) - -### M1. Shared Component Dependencies (template/document-system) - -All three target modules (production, quality, construction) heavily depend on these shared components: -- `@/components/templates/UniversalListPage` -- used by all list pages -- `@/components/templates/IntegratedDetailTemplate` -- used by all detail pages -- `@/components/document-system/` -- DocumentViewer, SectionHeader, ConstructionApprovalTable -- `@/components/common/ServerErrorPage` -- `@/components/common/ScheduleCalendar` -- `@/components/organisms/` -- PageLayout, PageHeader, MobileCard - -**Impact**: These are all Common ERP components. The dependency direction (tenant -> common) is allowed. No breakage on separation. -**Risk**: If extracting to separate npm packages, these become peer dependencies. - ---- - -### M2. Zustand Store Dependencies - -Target modules use these stores (all Common ERP): -``` -production/WorkerScreen -> menuStore (useSidebarCollapsed) -quality/EquipmentRepair -> menuStore (useMenuStore) -quality/EquipmentManagement -> menuStore (useMenuStore) -quality/EquipmentForm -> menuStore (useMenuStore) -construction/estimates -> authStore (useAuthStore) -``` -**Impact**: All stores are in Common ERP. Direction is allowed. No breakage on separation. -**Risk**: If separate packages, stores become shared singletons requiring careful provider setup. - ---- - -### M3. Shared Hook Dependencies - -Target modules import from `@/hooks/`: -``` -production: usePermission -quality: useStatsLoader -construction: useStatsLoader, useListHandlers, useDateRange, useDaumPostcode, useCurrentTime -``` -**Impact**: All hooks are Common ERP. Allowed direction. - ---- - -### M4. `@/lib/` Utility Dependencies - -All modules depend on standard utilities: -``` -@/lib/utils (cn) -@/lib/utils/amount (formatNumber, formatAmount) -@/lib/utils/date (formatDate) -@/lib/utils/excel-download -@/lib/utils/redirect-error (isNextRedirectError) -@/lib/utils/status-config (getPresetStyle, getPriorityStyle) -@/lib/api/* (executeServerAction, executePaginatedAction, buildApiUrl, apiClient, serverFetch) -@/lib/formatters -@/lib/constants/filter-presets -``` -**Impact**: All in Common ERP. Direction allowed. - ---- - -### M5. document-system `ConstructionApprovalTable` Name Confusion -**File**: `src/components/document-system/components/ConstructionApprovalTable.tsx` -**Impact**: Despite the name, this is a generic 4-column approval table component in the shared `document-system`. It is imported by both production and construction modules. The name is misleading but the component is tenant-agnostic. -**Fix**: Consider renaming to `FourColumnApprovalTable` or similar during separation to avoid confusion. - ---- - -### M6. Production -> Bending Image API References -**File**: `src/components/production/WorkOrders/documents/bending/utils.ts` -``` -return `${API_BASE}/images/bending/guiderail/...` -return `${API_BASE}/images/bending/bottombar/...` -return `${API_BASE}/images/bending/box/...` -``` -**Impact**: Production references backend image API paths specific to the shutter MES domain. These endpoints exist on the backend and are module-specific. -**Fix**: These stay with the production module. No cross-dependency issue. - ---- - -### M7. Two Vehicle Modules -There are TWO vehicle-related component directories: -- `src/components/vehicle/` (10 files) -- older, simpler -- `src/components/vehicle-management/` (13 files) -- newer, IntegratedDetailTemplate-based - -Both have separate app routes: -- `src/app/[locale]/(protected)/vehicle/` (old) -- `src/app/[locale]/(protected)/vehicle-management/` (new) - -**Impact**: No cross-references found between them or from other modules. Both are fully self-contained. -**Fix**: Clean separation. May want to consolidate before extracting. - ---- - -## LOW RISK (Informational / No action needed) - -### L1. Module-Internal Dynamic Imports -Quality and production use `next/dynamic` for their own internal components (modals, heavy components). All dynamic imports are within their own module boundaries. No cross-module dynamic imports found. - -### L2. No Shared CSS/Style Modules -All modules use Tailwind utility classes. No module-specific CSS modules or shared stylesheets exist. No breakage risk. - -### L3. No React Context Providers in Modules -No module-specific React Context providers were found in production/quality/construction. All context comes from the shared `(protected)/layout.tsx` (RootProvider, ApiErrorProvider, FCMProvider, DevFillProvider, PermissionGate). - -### L4. No Module-Specific Layout Files -No nested `layout.tsx` files exist under production/quality/construction app routes. All pages use the shared `(protected)/layout.tsx`. - -### L5. No Test Files -No test files (`*.test.*`, `*.spec.*`) exist in the codebase. No test dependency issues. - -### L6. No Cross-Module API Endpoint Calls -Each module calls only its own backend API endpoints: -- Production: `/api/v1/work-orders/*`, `/api/v1/work-results/*`, `/api/v1/production-orders/*` -- Quality: `/api/v1/quality/*`, `/api/v1/equipment/*` -- Construction: `/construction/*` (via apiClient) - -No module calls another module's backend API directly. Clean backend separation. - -### L7. CustomEvent System -The `dashboard:invalidate` CustomEvent in `@/lib/dashboard-invalidation.ts` is the only cross-module event system. Production and construction dispatch events; the CEO Dashboard listens. This is a pub/sub pattern and tolerant of missing publishers. - -### L8. Tenant-Aware Cache Already Exists -`src/lib/cache/TenantAwareCache.ts` already implements tenant-id-based cache key isolation. This utility supports the separation strategy. - -### L9. Middleware Has No Module-Specific Logic -`src/middleware.ts` handles i18n and bot detection. No module-specific routing or tenant-based path filtering exists in middleware. Clean. - ---- - -## Dependency Flow Diagram (ASCII) - -``` - +-----------------+ - | Common ERP | - | (dashboard, | - | accounting, | - | sales, HR, | - | approval, | - | settings, | - | master-data, | - | outbound, | - | templates, | - | document-sys) | - +--------+--------+ - | - +--------------+---------------+ - | | | - +--------v------+ +----v-------+ +-----v----------+ - | Kyungdong | | Juil | | Vehicle | - | (production | | (construc | | (vehicle-mgmt) | - | + quality) | | tion) | | | - +------+--------+ +-----+------+ +----------------+ - | | | - v v | - depends on depends on | - Common ERP Common ERP v - self-contained - -FORBIDDEN ARROWS (must be broken): - Common ERP --X--> Production (approval, sales, dashboard) - Common ERP --X--> Construction (dashboard) -``` - ---- - -## Action Items Summary - -### Must Fix Before Separation (6 items) - -| # | Severity | Issue | Effort | -|---|----------|-------|--------| -| C1 | CRITICAL | ApprovalBox imports InspectionReportModal from production | Medium | -| C2 | CRITICAL | Sales production-orders pages import from production | High | -| C3 | CRITICAL | Sales page hard-navigates to /production/work-orders | Low | -| C4 | CRITICAL | QMS page imports production document modals | Medium | -| C5 | CRITICAL | CEO Dashboard hardcodes production/construction sections | High | -| C6 | CRITICAL | Dashboard invalidation hardcodes production/construction domains | Medium | - -### Recommended Actions (6 items) - -| # | Severity | Issue | Effort | -|---|----------|-------|--------| -| H1 | HIGH | Construction SiteBriefing imports HR actions | Low | -| H2 | HIGH | Production WorkerScreen imports process-management | Low | -| H4 | HIGH | Dev generator imports production type | Low | -| H5 | HIGH | Bidirectional dashboard invalidation coupling | Medium | -| H6 | HIGH | CEO Calendar hardcoded module routes | Low | -| H7 | HIGH | Menu transform production icon mapping | Low | - -### Total Estimated Effort - -- **CRITICAL fixes**: ~3-5 days of focused refactoring -- **HIGH fixes**: ~1-2 days -- **MEDIUM/LOW**: informational, no code changes needed - ---- - -## Recommended Separation Strategy - -### Phase 1: Shared Interface Layer (1-2 days) -1. Create `@/interfaces/production.ts` with shared types (ProcessOption, WorkOrder summary types) -2. Create `@/interfaces/quality.ts` with shared types (InspectionReport view props) -3. Move InspectionReportModal and WorkLogModal to `@/components/document-system/modals/` - -### Phase 2: Feature Flags (1-2 days) -1. Add tenant feature flags: `hasProduction`, `hasQuality`, `hasConstruction`, `hasVehicle` -2. Conditionally render CEO Dashboard sections based on flags -3. Conditionally render sales production-order sub-pages based on flags -4. Make dashboard invalidation domain registry dynamic - -### Phase 3: Route Guards (0.5 day) -1. Replace hardcoded route strings with a tenant-aware route resolver -2. Add fallback/redirect for unavailable module routes - -### Phase 4: Clean Separation (1 day) -1. Move production + quality components to a Kyungdong-specific directory or package -2. Move construction components to a Juil-specific directory or package -3. Verify all builds pass with each module removed independently diff --git a/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md b/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md deleted file mode 100644 index 2639c3f2..00000000 --- a/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md +++ /dev/null @@ -1,538 +0,0 @@ -# 품목기준관리 Zustand 리팩토링 설계서 - -> **핵심 목표**: 모든 기능을 100% 동일하게 유지하면서, 수정 절차를 간단화 - -## 📌 핵심 원칙 - -``` -⚠️ 중요: 모든 품목기준관리 기능을 그대로 가져와야 함 -⚠️ 중요: 수정 절차 간단화가 핵심 (3방향 동기화 → 1곳 수정) -⚠️ 중요: 모든 기능이 정확히 동일하게 작동해야 함 -``` - -## 🔴 최종 검증 기준 (가장 중요!) - -### 페이지 관계도 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ [DB / API] │ -│ (단일 진실 공급원) │ -└─────────────────────────────────────────────────────────────┘ - ↑ ↑ ↓ - │ │ │ -┌───────┴───────┐ ┌────────┴────────┐ ┌────────┴────────┐ -│ 품목기준관리 │ │ 품목기준관리 │ │ 품목관리 │ -│ 테스트 페이지 │ │ 페이지 (기존) │ │ 페이지 │ -│ (Zustand) │ │ (Context) │ │ (동적 폼 렌더링) │ -└───────────────┘ └──────────────────┘ └──────────────────┘ - [신규] [기존] [최종 사용처] -``` - -### 검증 시나리오 - -``` -1. 테스트 페이지에서 섹션/필드 수정 - ↓ -2. API 호출 → DB 저장 - ↓ -3. 품목기준관리 페이지 (기존)에서 동일하게 표시되어야 함 - ↓ -4. 품목관리 페이지에서 동적 폼이 변경된 구조로 렌더링되어야 함 -``` - -### 필수 검증 항목 - -| # | 검증 항목 | 설명 | -|---|----------|------| -| 1 | **API 동일성** | 테스트 페이지가 기존 페이지와 동일한 API 엔드포인트 사용 | -| 2 | **데이터 동일성** | API 응답/요청 데이터 형식 100% 동일 | -| 3 | **기존 페이지 반영** | 테스트 페이지에서 수정 → 기존 품목기준관리 페이지에 반영 | -| 4 | **품목관리 반영** | 테스트 페이지에서 수정 → 품목관리 동적 폼에 반영 | - -### 왜 이게 중요한가? - -``` -테스트 페이지 (Zustand) ──┐ - ├──→ 같은 API ──→ 같은 DB ──→ 품목관리 페이지 -기존 페이지 (Context) ────┘ - -→ 상태 관리 방식만 다르고, API/DB는 공유 -→ 테스트 페이지에서 수정한 내용이 품목관리 페이지에 그대로 적용되어야 함 -→ 이것이 성공하면 Zustand 리팩토링이 완전히 검증된 것 -``` - ---- - -## 1. 현재 문제점 분석 - -### 1.1 중복 상태 관리 (3방향 동기화) - -현재 `ItemMasterContext.tsx`에서 섹션 수정 시: - -```typescript -// updateSection() 함수 내부 (Line 1464-1486) -setItemPages(...) // 1. 계층구조 탭 -setSectionTemplates(...) // 2. 섹션 탭 -setIndependentSections(...) // 3. 독립 섹션 -``` - -**문제점**: -- 같은 데이터를 3곳에서 중복 관리 -- 한 곳 업데이트 누락 시 데이터 불일치 -- 모든 CRUD 함수에 동일 패턴 반복 -- 새 기능 추가 시 3곳 모두 수정 필요 - -### 1.2 현재 상태 변수 목록 (16개) - -| # | 상태 변수 | 설명 | 중복 여부 | -|---|----------|------|----------| -| 1 | `itemMasters` | 품목 마스터 | - | -| 2 | `specificationMasters` | 규격 마스터 | - | -| 3 | `materialItemNames` | 자재 품목명 | - | -| 4 | `itemCategories` | 품목 분류 | - | -| 5 | `itemUnits` | 단위 | - | -| 6 | `itemMaterials` | 재질 | - | -| 7 | `surfaceTreatments` | 표면처리 | - | -| 8 | `partTypeOptions` | 부품유형 옵션 | - | -| 9 | `partUsageOptions` | 부품용도 옵션 | - | -| 10 | `guideRailOptions` | 가이드레일 옵션 | - | -| 11 | `sectionTemplates` | 섹션 템플릿 | ⚠️ 중복 | -| 12 | `itemMasterFields` | 필드 마스터 | ⚠️ 중복 | -| 13 | `itemPages` | 페이지 (섹션/필드 포함) | ⚠️ 중복 | -| 14 | `independentSections` | 독립 섹션 | ⚠️ 중복 | -| 15 | `independentFields` | 독립 필드 | ⚠️ 중복 | -| 16 | `independentBomItems` | 독립 BOM | ⚠️ 중복 | - -**중복 문제가 있는 엔티티**: -- **섹션**: `sectionTemplates`, `itemPages.sections`, `independentSections` -- **필드**: `itemMasterFields`, `itemPages.sections.fields`, `independentFields` -- **BOM**: `itemPages.sections.bom_items`, `independentBomItems` - ---- - -## 2. 리팩토링 설계 - -### 2.1 정규화된 상태 구조 (Normalized State) - -```typescript -// stores/useItemMasterStore.ts -interface ItemMasterState { - // ===== 정규화된 엔티티 (ID 기반 딕셔너리) ===== - entities: { - pages: Record; - sections: Record; - fields: Record; - bomItems: Record; - }; - - // ===== ID 목록 (순서 관리) ===== - ids: { - pages: number[]; - independentSections: number[]; // page_id가 null인 섹션 - independentFields: number[]; // section_id가 null인 필드 - independentBomItems: number[]; // section_id가 null인 BOM - }; - - // ===== 참조 데이터 (중복 없음) ===== - references: { - itemMasters: ItemMaster[]; - specificationMasters: SpecificationMaster[]; - materialItemNames: MaterialItemName[]; - itemCategories: ItemCategory[]; - itemUnits: ItemUnit[]; - itemMaterials: ItemMaterial[]; - surfaceTreatments: SurfaceTreatment[]; - partTypeOptions: PartTypeOption[]; - partUsageOptions: PartUsageOption[]; - guideRailOptions: GuideRailOption[]; - }; - - // ===== UI 상태 ===== - ui: { - isLoading: boolean; - error: string | null; - selectedPageId: number | null; - selectedSectionId: number | null; - }; -} -``` - -### 2.2 엔티티 구조 - -```typescript -// 페이지 엔티티 (섹션 ID만 참조) -interface PageEntity { - id: number; - page_name: string; - item_type: string; - description?: string; - order_no: number; - is_active: boolean; - sectionIds: number[]; // 섹션 객체 대신 ID만 저장 - created_at?: string; - updated_at?: string; -} - -// 섹션 엔티티 (필드/BOM ID만 참조) -interface SectionEntity { - id: number; - title: string; - page_id: number | null; // null이면 독립 섹션 - order_no: number; - is_collapsible: boolean; - default_open: boolean; - fieldIds: number[]; // 필드 ID 목록 - bomItemIds: number[]; // BOM ID 목록 - created_at?: string; - updated_at?: string; -} - -// 필드 엔티티 -interface FieldEntity { - id: number; - field_key: string; - field_name: string; - field_type: string; - section_id: number | null; // null이면 독립 필드 - order_no: number; - is_required: boolean; - options?: any; - default_value?: any; - created_at?: string; - updated_at?: string; -} - -// BOM 엔티티 -interface BOMItemEntity { - id: number; - section_id: number | null; // null이면 독립 BOM - child_item_code: string; - child_item_name: string; - quantity: number; - unit: string; - order_no: number; - created_at?: string; - updated_at?: string; -} -``` - -### 2.3 수정 절차 비교 - -#### Before (현재): 3방향 동기화 - -```typescript -const updateSection = async (sectionId, updates) => { - const response = await api.update(sectionId, updates); - - // 1. itemPages 업데이트 - setItemPages(prev => prev.map(page => ({ - ...page, - sections: page.sections.map(s => s.id === sectionId ? {...s, ...updates} : s) - }))); - - // 2. sectionTemplates 업데이트 - setSectionTemplates(prev => prev.map(t => - t.id === sectionId ? {...t, ...updates} : t - )); - - // 3. independentSections 업데이트 - setIndependentSections(prev => prev.map(s => - s.id === sectionId ? {...s, ...updates} : s - )); -}; -``` - -#### After (Zustand): 1곳만 수정 - -```typescript -const updateSection = async (sectionId, updates) => { - const response = await api.update(sectionId, updates); - - // 딱 1곳만 수정하면 끝! - set((state) => ({ - entities: { - ...state.entities, - sections: { - ...state.entities.sections, - [sectionId]: { ...state.entities.sections[sectionId], ...updates } - } - } - })); -}; -``` - -### 2.4 파생 상태 (Selectors) - -```typescript -// 계층구조 탭용: 페이지 + 섹션 + 필드 조합 -const usePageWithDetails = (pageId: number) => { - return useItemMasterStore((state) => { - const page = state.entities.pages[pageId]; - if (!page) return null; - - return { - ...page, - sections: page.sectionIds.map(sId => { - const section = state.entities.sections[sId]; - return { - ...section, - fields: section.fieldIds.map(fId => state.entities.fields[fId]), - bom_items: section.bomItemIds.map(bId => state.entities.bomItems[bId]), - }; - }), - }; - }); -}; - -// 섹션 탭용: 모든 섹션 (페이지 연결 여부 무관) -const useAllSections = () => { - return useItemMasterStore((state) => - Object.values(state.entities.sections) - ); -}; - -// 독립 섹션만 -const useIndependentSections = () => { - return useItemMasterStore((state) => - state.ids.independentSections.map(id => state.entities.sections[id]) - ); -}; -``` - ---- - -## 3. 기능 매핑 체크리스트 - -### 3.1 페이지 관리 - -| 기존 함수 | 새 함수 | 상태 | -|----------|--------|------| -| `loadItemPages` | `loadPages` | ⬜ | -| `addItemPage` | `createPage` | ⬜ | -| `updateItemPage` | `updatePage` | ⬜ | -| `deleteItemPage` | `deletePage` | ⬜ | - -### 3.2 섹션 관리 - -| 기존 함수 | 새 함수 | 상태 | -|----------|--------|------| -| `loadSectionTemplates` | `loadSections` | ⬜ | -| `loadIndependentSections` | (loadSections에 통합) | ⬜ | -| `addSectionTemplate` | `createSection` | ⬜ | -| `addSectionToPage` | `createSectionInPage` | ⬜ | -| `createIndependentSection` | `createSection` (page_id: null) | ⬜ | -| `updateSectionTemplate` | `updateSection` | ⬜ | -| `updateSection` | `updateSection` | ⬜ | -| `deleteSectionTemplate` | `deleteSection` | ⬜ | -| `deleteSection` | `deleteSection` | ⬜ | -| `linkSectionToPage` | `linkSectionToPage` | ⬜ | -| `unlinkSectionFromPage` | `unlinkSectionFromPage` | ⬜ | -| `getSectionUsage` | `getSectionUsage` | ⬜ | - -### 3.3 필드 관리 - -| 기존 함수 | 새 함수 | 상태 | -|----------|--------|------| -| `loadItemMasterFields` | `loadFields` | ⬜ | -| `loadIndependentFields` | (loadFields에 통합) | ⬜ | -| `addItemMasterField` | `createField` | ⬜ | -| `addFieldToSection` | `createFieldInSection` | ⬜ | -| `createIndependentField` | `createField` (section_id: null) | ⬜ | -| `updateItemMasterField` | `updateField` | ⬜ | -| `updateField` | `updateField` | ⬜ | -| `deleteItemMasterField` | `deleteField` | ⬜ | -| `deleteField` | `deleteField` | ⬜ | -| `linkFieldToSection` | `linkFieldToSection` | ⬜ | -| `unlinkFieldFromSection` | `unlinkFieldFromSection` | ⬜ | -| `getFieldUsage` | `getFieldUsage` | ⬜ | - -### 3.4 BOM 관리 - -| 기존 함수 | 새 함수 | 상태 | -|----------|--------|------| -| `loadIndependentBomItems` | `loadBomItems` | ⬜ | -| `addBOMItem` | `createBomItem` | ⬜ | -| `createIndependentBomItem` | `createBomItem` (section_id: null) | ⬜ | -| `updateBOMItem` | `updateBomItem` | ⬜ | -| `deleteBOMItem` | `deleteBomItem` | ⬜ | - -### 3.5 참조 데이터 관리 - -| 기존 함수 | 새 함수 | 상태 | -|----------|--------|------| -| `addItemMaster` / `updateItemMaster` / `deleteItemMaster` | `itemMasterActions` | ⬜ | -| `addSpecificationMaster` / `updateSpecificationMaster` / `deleteSpecificationMaster` | `specificationActions` | ⬜ | -| `addMaterialItemName` / `updateMaterialItemName` / `deleteMaterialItemName` | `materialItemNameActions` | ⬜ | -| `addItemCategory` / `updateItemCategory` / `deleteItemCategory` | `categoryActions` | ⬜ | -| `addItemUnit` / `updateItemUnit` / `deleteItemUnit` | `unitActions` | ⬜ | -| `addItemMaterial` / `updateItemMaterial` / `deleteItemMaterial` | `materialActions` | ⬜ | -| `addSurfaceTreatment` / `updateSurfaceTreatment` / `deleteSurfaceTreatment` | `surfaceTreatmentActions` | ⬜ | -| `addPartTypeOption` / `updatePartTypeOption` / `deletePartTypeOption` | `partTypeActions` | ⬜ | -| `addPartUsageOption` / `updatePartUsageOption` / `deletePartUsageOption` | `partUsageActions` | ⬜ | -| `addGuideRailOption` / `updateGuideRailOption` / `deleteGuideRailOption` | `guideRailActions` | ⬜ | - ---- - -## 4. 구현 계획 - -### Phase 1: 기반 구축 ✅ 완료 (2025-12-20) - -- [x] Zustand, Immer 설치 -- [x] 테스트 페이지 라우트 생성 (`/items-management-test`) -- [x] 기본 스토어 구조 생성 (`useItemMasterStore.ts`) -- [x] 타입 정의 (`types.ts`) - -### Phase 2: API 연동 ✅ 완료 (2025-12-20) - -- [x] 기존 API 구조 분석 (`item-master.ts`) -- [x] API 응답 → 정규화 상태 변환 함수 (`normalizers.ts`) -- [x] 스토어에 `initFromApi()` 함수 구현 -- [x] 테스트 페이지에서 실제 API 데이터 로드 기능 추가 - -**생성된 파일**: -- `src/stores/item-master/normalizers.ts` - API 응답 정규화 함수 - -**테스트 페이지 기능**: -- "실제 API 로드" 버튼 - 백엔드 API에서 실제 데이터 로드 -- "테스트 데이터 로드" 버튼 - 하드코딩된 테스트 데이터 로드 -- 데이터 소스 표시 (API/테스트/없음) - -### Phase 3: 핵심 엔티티 구현 - -- [x] 페이지 CRUD 구현 (로컬 상태) -- [x] 섹션 CRUD 구현 (로컬 상태) -- [x] 필드 CRUD 구현 (로컬 상태) -- [x] BOM CRUD 구현 (로컬 상태) -- [x] link/unlink 기능 구현 (로컬 상태) -- [ ] API 연동 CRUD (DB 저장) - **다음 단계** - -### Phase 3: 참조 데이터 구현 - -- [ ] 품목 마스터 관리 -- [ ] 규격 마스터 관리 -- [ ] 분류/단위/재질 등 옵션 관리 - -### Phase 4: 파생 상태 & 셀렉터 - -- [ ] 계층구조 뷰용 셀렉터 -- [ ] 섹션 탭용 셀렉터 -- [ ] 필드 탭용 셀렉터 -- [ ] 독립 항목 셀렉터 - -### Phase 5: UI 연동 - -- [ ] 테스트 페이지 컴포넌트 생성 -- [ ] 기존 컴포넌트 재사용 (스토어만 교체) -- [ ] 동작 검증 - -### Phase 6: 검증 & 마이그레이션 - -- [ ] 기존 페이지와 1:1 동작 비교 -- [ ] 엣지 케이스 테스트 -- [ ] 성능 비교 -- [ ] 기존 페이지 마이그레이션 결정 - ---- - -## 5. 파일 구조 - -``` -src/ -├── stores/ -│ └── item-master/ -│ ├── useItemMasterStore.ts # 메인 스토어 -│ ├── slices/ -│ │ ├── pageSlice.ts # 페이지 액션 -│ │ ├── sectionSlice.ts # 섹션 액션 -│ │ ├── fieldSlice.ts # 필드 액션 -│ │ ├── bomSlice.ts # BOM 액션 -│ │ └── referenceSlice.ts # 참조 데이터 액션 -│ ├── selectors/ -│ │ ├── pageSelectors.ts # 페이지 파생 상태 -│ │ ├── sectionSelectors.ts # 섹션 파생 상태 -│ │ └── fieldSelectors.ts # 필드 파생 상태 -│ └── types.ts # 타입 정의 -│ -├── app/[locale]/(protected)/ -│ └── items-management-test/ -│ └── page.tsx # 테스트 페이지 -``` - ---- - -## 6. 테스트 시나리오 - -### 6.1 섹션 수정 동기화 테스트 - -``` -시나리오: 섹션 이름 수정 -1. 계층구조 탭에서 섹션 선택 -2. 섹션 이름 "기본정보" → "기본 정보" 수정 -3. 검증: - - [ ] 계층구조 탭에 반영 - - [ ] 섹션 탭에 반영 - - [ ] 독립 섹션(연결 해제 시) 반영 - - [ ] API 호출 1회만 발생 -``` - -### 6.2 필드 이동 테스트 - -``` -시나리오: 필드를 다른 섹션으로 이동 -1. 섹션 A에서 필드 선택 -2. 섹션 B로 이동 (unlink → link) -3. 검증: - - [ ] 섹션 A에서 필드 제거 - - [ ] 섹션 B에 필드 추가 - - [ ] 계층구조 탭 반영 - - [ ] 필드 탭에서 section_id 변경 -``` - -### 6.3 독립 → 연결 테스트 - -``` -시나리오: 독립 섹션을 페이지에 연결 -1. 독립 섹션 선택 -2. 페이지에 연결 (linkSectionToPage) -3. 검증: - - [ ] 독립 섹션 목록에서 제거 - - [ ] 페이지의 섹션 목록에 추가 - - [ ] 섹션 탭에서 page_id 변경 -``` - ---- - -## 7. 롤백 계획 - -문제 발생 시: -1. 테스트 페이지 라우트 제거 -2. 스토어 코드 삭제 -3. 기존 `ItemMasterContext` 그대로 사용 - -**리스크 최소화**: -- 기존 코드 수정 없음 -- 새 코드만 추가 -- 언제든 롤백 가능 - ---- - -## 8. 성공 기준 - -| 항목 | 기준 | -|-----|------| -| **기능 동등성** | 기존 모든 기능 100% 동작 | -| **동기화** | 1곳 수정으로 모든 뷰 업데이트 | -| **코드량** | CRUD 함수 코드 50% 이상 감소 | -| **버그** | 데이터 불일치 버그 0건 | -| **성능** | 기존 대비 동등 또는 향상 | - ---- - -## 변경 이력 - -| 날짜 | 작성자 | 내용 | -|-----|--------|------| -| 2025-12-20 | Claude | 초안 작성 | -| 2025-12-20 | Claude | Phase 1 완료 - 기반 구축 | -| 2025-12-20 | Claude | Phase 2 완료 - API 연동 (normalizers.ts, initFromApi) | \ No newline at end of file diff --git a/claudedocs/architecture/[DESIGN-2026-02-11] dynamic-field-type-extension.md b/claudedocs/architecture/[DESIGN-2026-02-11] dynamic-field-type-extension.md deleted file mode 100644 index 4f311bb0..00000000 --- a/claudedocs/architecture/[DESIGN-2026-02-11] dynamic-field-type-extension.md +++ /dev/null @@ -1,1299 +0,0 @@ -# 품목기준관리 동적 필드 타입 확장 설계 - -> 작성일: 2026-02-11 -> 목적: 멀티테넌시 품목기준관리의 필드 타입 확장 및 범용 테이블 섹션 설계 -> 범위: 프론트엔드 컴포넌트 설계 + 백엔드 API 계약 + 산업별 확장 구조 - ---- - -## 목차 - -1. [배경 및 목표](#1-배경-및-목표) -2. [현재 시스템 분석](#2-현재-시스템-분석) -3. [확장 필드 타입 레지스트리](#3-확장-필드-타입-레지스트리) -4. [범용 테이블 섹션 설계](#4-범용-테이블-섹션-설계) -5. [섹션 템플릿 라이브러리](#5-섹션-템플릿-라이브러리) -6. [산업별 확장 구조](#6-산업별-확장-구조) -7. [백엔드 API 계약](#7-백엔드-api-계약) -8. [프론트엔드 컴포넌트 아키텍처](#8-프론트엔드-컴포넌트-아키텍처) -9. [조건부 표시 확장](#9-조건부-표시-확장) -10. [검증 프레임워크](#10-검증-프레임워크) -11. [구현 로드맵](#11-구현-로드맵) - ---- - -## 1. 배경 및 목표 - -### 1.1 현재 문제 - -품목기준관리(`/master-data/item-master-data-management`)는 **동적 폼 구성 시스템**이 이미 존재하지만, 필드 타입이 6가지로 제한되어 제조 ERP의 다양한 요구를 충족하지 못함. - -| 현재 있는 것 | 없어서 부족한 것 | -|-------------|-----------------| -| textbox | 다른 테이블 참조/검색 선택 (거래처, 품목, 고객) | -| number | 복수 선택 (태그형) | -| dropdown | 파일/이미지 업로드 | -| checkbox | 통화/금액 (단위 포함) | -| date | 값+단위 조합 (100mm, 50kg) | -| textarea | 범용 테이블/그리드 (BOM 외) | - -또한 BOM이 유일한 "테이블형 섹션"이지만, 제조 ERP에서는 **공정, 품질검사, 구매처, 단가이력** 등도 테이블 구조가 필요함. - -### 1.2 설계 목표 - -``` -핵심 원칙: "항목을 미리 정의"하지 않고 "필드 타입과 config 조합"을 확장한다. -``` - -1. **필드 타입 확장**: 6종 → 14종으로 확장 (입력 원자 단위) -2. **범용 테이블 섹션**: BOM 전용 → config 기반 범용 테이블로 일반화 -3. **섹션 템플릿 라이브러리**: 산업별 표준 섹션 프리셋 제공 -4. **백엔드 key + type + config 체계**: 백엔드가 스키마만 정의하면 프론트가 자동 렌더링 -5. **멀티테넌시 확장성**: 테넌트마다 다른 항목 구성 가능 -6. **산업 불문 확장**: 제조/공사/유통/물류 전방위 커버 - -### 1.3 설계 원칙 - -- **하위 호환**: 기존 6가지 필드 타입은 그대로 유지, 기존 코드 변경 없음 -- **점진적 확장**: 새 필드 타입 추가 = 새 컴포넌트 1개 추가 + DynamicFieldRenderer switch 1줄 추가 -- **config 기반**: 필드의 동작은 `field_type` + `properties` 조합으로 결정 -- **백엔드 독립**: 프론트 컴포넌트는 미리 만들고, 백엔드는 나중에 key-config 매핑만 추가 - ---- - -## 2. 현재 시스템 분석 - -### 2.1 아키텍처 - -``` -품목기준관리 (Admin) 품목 등록/수정 (User) -ItemMasterDataManagement.tsx DynamicItemForm/index.tsx - ↓ 구조 정의 ↓ 구조 기반 렌더링 - Pages → Sections → Fields GET /pages/{id}/structure - → BOM Items ↓ - DynamicFieldRenderer (switch) - → TextField - → NumberField - → DropdownField - → CheckboxField - → DateField - → TextareaField -``` - -### 2.2 핵심 파일 - -| 파일 | 줄 수 | 역할 | -|------|-------|------| -| `DynamicItemForm/index.tsx` | 1,048 | 메인 폼 컴포넌트 | -| `DynamicItemForm/types.ts` | 261 | 타입 정의 | -| `DynamicItemForm/fields/DynamicFieldRenderer.tsx` | 44 | 필드 타입 라우터 | -| `DynamicItemForm/sections/DynamicBOMSection.tsx` | 515 | BOM 테이블 섹션 | -| `DynamicItemForm/hooks/` | 7개 훅 | 상태/검증/조건부 표시 | -| `types/item-master-api.ts` | 745 | API 타입 정의 | -| `ItemMasterDataManagement.tsx` | 1,005 | Admin 관리 페이지 | - -### 2.3 현재 필드 응답 구조 (ItemFieldResponse) - -```typescript -{ - id: number, - field_name: string, // "품목명" - field_key: string | null, // "98_item_name" - field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea', - is_required: boolean, - placeholder: string | null, - default_value: string | null, - options: [{label, value}] | null, // dropdown 옵션 - properties: Record | null, // 추가 메타데이터 - validation_rules: Record | null, - display_condition: Record | null, -} -``` - -### 2.4 현재 섹션 타입 - -```typescript -section.type: 'fields' | 'bom' -// 'fields' → DynamicFieldRenderer로 각 필드 렌더링 -// 'bom' → DynamicBOMSection (하드코딩된 BOM 전용 UI) -``` - ---- - -## 3. 확장 필드 타입 레지스트리 - -### 3.1 전체 필드 타입 목록 - -#### 기존 유지 (6종) - -| field_type | 컴포넌트 | 설명 | -|-----------|---------|------| -| `textbox` | TextField | 단일 행 텍스트 | -| `number` | NumberField | 숫자 입력 | -| `dropdown` | DropdownField | 단일 선택 | -| `checkbox` | CheckboxField | 체크박스 | -| `date` | DateField | 날짜 선택 | -| `textarea` | TextareaField | 여러 행 텍스트 | - -#### 신규 추가 (8종) - -| field_type | 컴포넌트 | 설명 | 우선순위 | -|-----------|---------|------|---------| -| `reference` | ReferenceField | 다른 테이블 참조 검색/선택 | 🔴 Phase 1 | -| `multi-select` | MultiSelectField | 복수 선택 (태그형) | 🔴 Phase 1 | -| `file` | FileField | 파일/이미지 업로드 | 🔴 Phase 1 | -| `currency` | CurrencyField | 통화 금액 (포맷 + 단위) | 🟡 Phase 2 | -| `unit-value` | UnitValueField | 값 + 단위 조합 | 🟡 Phase 2 | -| `radio` | RadioField | 라디오 버튼 그룹 | 🟡 Phase 2 | -| `toggle` | ToggleField | On/Off 토글 스위치 | 🟢 Phase 3 | -| `computed` | ComputedField | 읽기 전용 계산 필드 | 🟢 Phase 3 | - -### 3.2 각 필드 타입별 상세 스펙 - ---- - -#### 3.2.1 `reference` — 참조 룩업 필드 - -**용도**: 다른 테이블의 데이터를 검색하여 선택 (거래처, 품목, 고객, 공정, 현장, 차량 등) - -**UI**: 검색 입력 + 드롭다운 팝업 (SearchableSelectionModal 기반) - -**properties 스키마**: -```jsonc -{ - "source": "vendors", // 참조할 데이터 소스 (필수) — 프리셋 또는 "custom" - "displayField": "name", // 선택 후 표시할 필드 (기본: "name") - "valueField": "id", // 저장할 값 필드 (기본: "id") - "searchFields": ["name", "code"], // 검색 대상 필드 (기본: ["name"]) - "searchApiUrl": "/api/proxy/vendors", // 검색 API URL ("custom" source일 때 필수) - "minSearchLength": 1, // 최소 검색 글자 수 (기본: 1) - "modalTitle": "거래처 선택", // 모달 제목 (선택, 없으면 field_name + " 선택") - "columns": [ // 검색 결과 표시 컬럼 (선택) - { "key": "code", "label": "코드", "width": "120px" }, - { "key": "name", "label": "이름" }, - { "key": "contact", "label": "연락처", "width": "150px" } - ], - "displayFormat": "{code} - {name}", // 선택 후 표시 포맷 (선택) - "returnFields": ["id", "code", "name"] // 선택 시 폼에 저장할 필드들 (선택) -} -``` - -**저장되는 값**: -```jsonc -// 단일 값: valueField 기준 -{ "vendor_id": "123" } - -// returnFields 설정 시: 여러 필드 동시 저장 -{ "vendor_id": "123", "vendor_code": "V-001", "vendor_name": "삼성전자" } -``` - -**소스 프리셋** (프론트에서 미리 정의, 산업별 확장 가능): - -| source | 산업 | API | displayField | -|--------|------|-----|--------------| -| `vendors` | 공통 | `/api/proxy/vendors` | name | -| `items` | 공통 | `/api/proxy/items` | name | -| `customers` | 공통 | `/api/proxy/customers` | company_name | -| `employees` | 공통 | `/api/proxy/employees` | name | -| `processes` | 제조 | `/api/proxy/processes` | process_name | -| `warehouses` | 공통 | `/api/proxy/warehouses` | name | -| `materials` | 제조 | `/api/proxy/item-master/materials` | material_name | -| `surface_treatments` | 제조 | `/api/proxy/item-master/surface-treatments` | treatment_name | -| `sites` | 공사 | `/api/proxy/sites` | site_name | -| `vehicles` | 물류 | `/api/proxy/vehicles` | plate_number | -| `routes` | 물류 | `/api/proxy/routes` | route_name | -| `stores` | 유통 | `/api/proxy/stores` | store_name | -| `custom` | — | properties.searchApiUrl | properties.displayField | - -> `custom` source를 사용하면 백엔드에 새 API만 추가하면 프론트 코드 수정 없이 어떤 참조든 연결 가능. - ---- - -#### 3.2.2 `multi-select` — 복수 선택 필드 - -**용도**: 여러 항목을 동시에 선택 (태그/칩 형태) - -**UI**: Combobox + 태그 칩 - -**properties 스키마**: -```jsonc -{ - "maxSelections": 5, // 최대 선택 수 (선택, 기본: 무제한) - "allowCustom": false, // 사용자 직접 입력 허용 여부 (기본: false) - "layout": "chips" // "chips" | "list" (기본: "chips") -} -``` - -**options 사용**: 기존 dropdown과 동일한 `[{label, value}]` 형태 - -**저장되는 값**: -```jsonc -{ "applicable_processes": ["CUT", "BEND", "WELD", "PAINT"] } -``` - ---- - -#### 3.2.3 `file` — 파일/이미지 업로드 필드 - -**용도**: 문서, 이미지, 도면 첨부 - -**UI**: 파일 선택 버튼 + 미리보기 (이미지일 경우) - -**properties 스키마**: -```jsonc -{ - "accept": ".pdf,.doc,.docx", // 허용 파일 타입 (기본: "*") - "maxSize": 10485760, // 최대 파일 크기 bytes (기본: 10MB) - "maxFiles": 5, // 최대 파일 수 (기본: 1) - "preview": true, // 미리보기 표시 여부 (기본: true, 이미지 파일만) - "uploadApiUrl": "/api/proxy/files/upload", // 업로드 API (선택) - "category": "drawing" // 파일 카테고리 태그 (선택) -} -``` - -**저장되는 값**: -```jsonc -// 단일 파일 -{ "drawing_file": { "fileId": "uuid-123", "fileName": "도면_v2.pdf", "fileUrl": "/files/uuid-123" } } - -// 복수 파일 -{ "attachments": [ - { "fileId": "uuid-123", "fileName": "도면.pdf", "fileUrl": "/files/uuid-123" }, - { "fileId": "uuid-456", "fileName": "사진.jpg", "fileUrl": "/files/uuid-456" } - ] -} -``` - ---- - -#### 3.2.4 `currency` — 통화/금액 필드 - -**용도**: 단가, 총액 등 금액 입력 (천 단위 콤마 + 통화 기호) - -**UI**: 숫자 입력 + 통화 기호 + 천단위 포맷 - -**properties 스키마**: -```jsonc -{ - "currency": "KRW", // 통화 코드 (기본: "KRW") - "precision": 0, // 소수점 자릿수 (기본: KRW=0, USD=2) - "showSymbol": true, // 통화 기호 표시 (기본: true) - "allowNegative": false // 음수 허용 (기본: false) -} -``` - -**저장되는 값**: 숫자 (`{ "unit_price": 15000 }`) - ---- - -#### 3.2.5 `unit-value` — 값+단위 조합 필드 - -**용도**: 치수, 무게, 용량, 거리 등 (숫자 + 단위 동시 입력) - -**UI**: 숫자 입력 + 단위 선택 드롭다운 (inline) - -**properties 스키마**: -```jsonc -{ - "units": ["mm", "cm", "m", "inch"], // 선택 가능 단위 목록 (필수) - "defaultUnit": "mm", // 기본 단위 (선택) - "precision": 1, // 소수점 자릿수 (기본: 0) - "showConversion": false // 단위 변환 표시 (기본: false) -} -``` - -**저장되는 값**: `{ "thickness": { "value": 2.5, "unit": "mm" } }` - ---- - -#### 3.2.6 `radio` — 라디오 버튼 그룹 - -**용도**: 상호 배타적 선택 (3~5개 이내 옵션에 적합) - -**UI**: 라디오 버튼 그룹 (수평/수직) - -**properties 스키마**: -```jsonc -{ - "layout": "horizontal" // "horizontal" | "vertical" (기본: "horizontal") -} -``` - -**options 사용**: dropdown과 동일한 `[{label, value}]` - ---- - -#### 3.2.7 `toggle` — 토글 스위치 - -**용도**: On/Off 상태 전환 - -**UI**: Switch 컴포넌트 - -**properties 스키마**: -```jsonc -{ - "onLabel": "활성", // On 상태 라벨 (선택) - "offLabel": "비활성", // Off 상태 라벨 (선택) - "onValue": "active", // On 상태 저장값 (기본: "true") - "offValue": "inactive" // Off 상태 저장값 (기본: "false") -} -``` - ---- - -#### 3.2.8 `computed` — 읽기 전용 계산 필드 - -**용도**: 다른 필드 값을 기반으로 자동 계산되는 필드 (표시 전용) - -**UI**: 읽기 전용 표시 (배경색 구분) - -**properties 스키마**: -```jsonc -{ - "formula": "{quantity} * {unit_price}", // 계산식 (필수) - "dependsOn": ["quantity", "unit_price"], // 의존 필드 키 목록 (필수) - "format": "currency", // 표시 포맷: "number" | "currency" | "percent" (기본: "number") - "precision": 0 // 소수점 자릿수 (기본: 0) -} -``` - ---- - -### 3.3 field_type 확장 타입 정의 (TypeScript) - -```typescript -// 기존 -type FieldType = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; - -// 확장 -type ExtendedFieldType = FieldType - | 'reference' // Phase 1 - | 'multi-select' // Phase 1 - | 'file' // Phase 1 - | 'currency' // Phase 2 - | 'unit-value' // Phase 2 - | 'radio' // Phase 2 - | 'toggle' // Phase 3 - | 'computed'; // Phase 3 -``` - ---- - -## 4. 범용 테이블 섹션 설계 - -### 4.1 현재 문제 - -``` -현재: section.type === 'bom' → DynamicBOMSection (515줄, BOM 전용 하드코딩) -필요: 공정, 품질검사, 구매처, 단가이력 등도 테이블 필요 -``` - -### 4.2 설계: section.type 확장 - -```typescript -// 기존 -section.type: 'fields' | 'bom' - -// 확장 -section.type: 'fields' | 'bom' | 'table' -// ↑ 신규: 범용 테이블 -``` - -### 4.3 범용 테이블 섹션 config - -`section.properties`에 테이블 설정을 담음: - -```jsonc -{ - "table_config": { - // 컬럼 정의 (핵심) - "columns": [ - { - "key": "process_code", // 컬럼 키 (데이터 저장 키) - "label": "공정코드", // 컬럼 헤더 - "type": "reference", // 셀 입력 타입 (필드 타입과 동일한 14종) - "width": "150px", // 컬럼 너비 (선택) - "required": true, // 필수 여부 - "config": { // 타입별 추가 설정 (properties와 동일 구조) - "source": "processes", - "displayField": "process_name", - "searchFields": ["process_name", "process_code"] - } - }, - { - "key": "process_name", - "label": "공정명", - "type": "textbox", - "width": "200px", - "readOnly": true, // 참조 필드에서 자동 채움 - "autoFillFrom": "process_code.process_name" // 자동 채움 소스 - }, - { - "key": "quantity", - "label": "수량", - "type": "number", - "width": "100px", - "config": { "min": 0, "precision": 2 } - }, - { - "key": "unit", - "label": "단위", - "type": "dropdown", - "width": "100px", - "config": { "source": "unitOptions" } - }, - { - "key": "start_date", - "label": "시작일", - "type": "date", - "width": "140px" - }, - { - "key": "note", - "label": "비고", - "type": "textbox" // width 미지정 = 나머지 공간 - } - ], - - // 행 동작 - "addable": true, // 행 추가 가능 (기본: true) - "deletable": true, // 행 삭제 가능 (기본: true) - "reorderable": true, // 행 순서 변경 가능 (기본: false) - "maxRows": 100, // 최대 행 수 (선택, 기본: 무제한) - "minRows": 0, // 최소 행 수 (선택, 기본: 0) - - // 표시 - "showRowNumber": true, // 행 번호 표시 (기본: true) - "showCheckbox": false, // 행 선택 체크박스 (기본: false) - "emptyMessage": "데이터가 없습니다.", // 빈 상태 메시지 - - // 요약행 (선택) - "summaryRow": { - "enabled": true, - "columns": { - "quantity": { "type": "sum", "label": "합계" }, - "amount": { "type": "sum", "format": "currency" } - } - }, - - // 데이터 소스 (기존 데이터 로드용, 선택) - "dataApiUrl": "/api/proxy/items/{itemId}/routings", - "saveApiUrl": "/api/proxy/items/{itemId}/routings" - } -} -``` - -### 4.4 기존 BOM과의 관계 - -``` -DynamicBOMSection (기존) → 유지 (하위 호환) -DynamicTableSection (신규) → 범용 테이블 - -section.type === 'bom' → DynamicBOMSection (기존 그대로) -section.type === 'table' → DynamicTableSection (신규) -``` - -**점진적 마이그레이션**: BOM도 나중에 `type: 'table'`로 전환 가능하지만, 당장은 불필요. - -### 4.5 테이블 셀 = 필드 컴포넌트 재사용 - -테이블 각 셀의 입력은 **DynamicFieldRenderer와 동일한 컴포넌트**를 사용: - -``` -table column.type === "reference" → ReferenceField (인라인 축소판) -table column.type === "number" → NumberField -table column.type === "dropdown" → DropdownField -table column.type === "date" → DateField -table column.type === "currency" → CurrencyField -... (14종 모두 사용 가능) -``` - -즉, **필드 타입 컴포넌트 1개 = 폼 필드에서도, 테이블 셀에서도 동일하게 사용**. - -### 4.6 테이블 섹션 저장 데이터 구조 - -```jsonc -{ - "table_section_123": [ - { - "_rowId": "uuid-1", - "process_code": "CUT-001", - "process_name": "절단", - "quantity": 10, - "unit": "EA", - "start_date": "2026-03-01", - "note": "레이저 절단" - } - ] -} -``` - ---- - -## 5. 섹션 템플릿 라이브러리 - -### 5.1 전체 프리셋 목록 (산업 공통 + 산업별) - -#### 공통 프리셋 - -| 프리셋 ID | 이름 | type | 설명 | -|----------|------|------|------| -| `basic-info` | 기본정보 | fields | 코드, 이름, 유형, 상태, 비고 | -| `drawing` | 도면/문서 | fields | 파일 업로드 + 버전 관리 | -| `custom` | 사용자 정의 | fields/table | 빈 섹션 (직접 구성) | - -#### 제조 프리셋 - -| 프리셋 ID | 이름 | type | 설명 | -|----------|------|------|------| -| `specifications` | 규격/치수 | fields | 두께, 너비, 높이, 무게, 공차 | -| `bom` | BOM (자재명세서) | bom | 기존 BOM 구조 유지 | -| `routing` | 공정/라우팅 | table | 공정코드, 작업시간, 작업장 | -| `quality-spec` | 품질검사 항목 | table | 검사항목, 규격, 허용치, 검사방법 | -| `procurement` | 구매정보 | table | 공급업체, 단가, 리드타임, MOQ | -| `cost-breakdown` | 원가 구성 | table | 원가항목, 금액, 비율 | -| `inventory` | 재고 정보 | fields | 창고, 안전재고, 발주점 | - -#### 공사 프리셋 - -| 프리셋 ID | 이름 | type | 설명 | -|----------|------|------|------| -| `work-schedule` | 공정표 | table | 공종, 수량, 단가, 착수일, 완료일, 진행률 | -| `material-plan` | 자재투입계획 | table | 자재, 수량, 단위, 투입시기, 발주여부 | -| `labor-plan` | 인력투입계획 | table | 직종, 인원, 기간, 일단가, 금액 | -| `equipment-plan` | 장비투입계획 | table | 장비명, 규격, 수량, 기간, 단가 | -| `safety-checklist` | 안전점검 항목 | table | 점검항목, 점검주기, 담당자, 결과 | -| `site-info` | 현장정보 | fields | 현장명, 주소, 발주처, 감리사, 공사기간 | - -#### 유통 프리셋 - -| 프리셋 ID | 이름 | type | 설명 | -|----------|------|------|------| -| `pricing` | 가격정보 | table | 거래처유형, 단가, 할인율, 적용기간 | -| `packaging` | 포장정보 | fields | 포장단위, 박스수량, 바코드, 중량 | -| `store-allocation` | 매장배분 | table | 매장, 배분수량, 배분일, 상태 | -| `promotion` | 프로모션 | table | 프로모션명, 할인율, 시작일, 종료일, 조건 | - -#### 물류 프리셋 - -| 프리셋 ID | 이름 | type | 설명 | -|----------|------|------|------| -| `transport-spec` | 운송규격 | fields | 중량, 부피, 위험물등급, 보관온도, 적재방법 | -| `route-plan` | 배차/경로 | table | 출발지, 도착지, 거리, 소요시간, 운임 | -| `loading-plan` | 적재계획 | table | 품목, 수량, 중량, 적재순서, 위치 | -| `tracking` | 추적정보 | table | 일시, 위치, 상태, 온도, 비고 | - -### 5.2 프리셋 상세 예시 - -#### `work-schedule` (공사 — 공정표) - -```jsonc -{ - "preset_id": "work-schedule", - "type": "table", - "table_config": { - "columns": [ - { "key": "work_type", "label": "공종", "type": "reference", "width": "180px", - "config": { "source": "custom", "searchApiUrl": "/api/proxy/work-types", - "displayField": "name" }, "required": true }, - { "key": "quantity", "label": "수량", "type": "number", "width": "100px", - "config": { "min": 0, "precision": 2 } }, - { "key": "unit", "label": "단위", "type": "dropdown", "width": "80px", - "config": { "options": [ - {"label":"m²","value":"m2"}, {"label":"m³","value":"m3"}, - {"label":"m","value":"m"}, {"label":"EA","value":"EA"}, - {"label":"TON","value":"TON"}, {"label":"식","value":"SET"} - ]}}, - { "key": "unit_price", "label": "단가", "type": "currency", "width": "130px", - "config": { "currency": "KRW" } }, - { "key": "amount", "label": "금액", "type": "computed", "width": "140px", - "config": { "formula": "{quantity} * {unit_price}", "format": "currency" } }, - { "key": "start_date", "label": "착수일", "type": "date", "width": "130px" }, - { "key": "end_date", "label": "완료일", "type": "date", "width": "130px" }, - { "key": "progress", "label": "진행률(%)", "type": "number", "width": "100px", - "config": { "min": 0, "max": 100 } }, - { "key": "note", "label": "비고", "type": "textbox" } - ], - "addable": true, - "deletable": true, - "reorderable": true, - "summaryRow": { - "enabled": true, - "columns": { "amount": { "type": "sum", "format": "currency" } } - }, - "emptyMessage": "공정 항목을 추가하세요." - } -} -``` - -#### `route-plan` (물류 — 배차/경로) - -```jsonc -{ - "preset_id": "route-plan", - "type": "table", - "table_config": { - "columns": [ - { "key": "seq", "label": "순번", "type": "number", "width": "70px" }, - { "key": "origin", "label": "출발지", "type": "reference", "width": "180px", - "config": { "source": "custom", "searchApiUrl": "/api/proxy/locations", - "displayField": "name" }, "required": true }, - { "key": "destination", "label": "도착지", "type": "reference", "width": "180px", - "config": { "source": "custom", "searchApiUrl": "/api/proxy/locations", - "displayField": "name" }, "required": true }, - { "key": "distance", "label": "거리", "type": "unit-value", "width": "120px", - "config": { "units": ["km", "m"], "defaultUnit": "km", "precision": 1 } }, - { "key": "duration", "label": "소요시간(분)", "type": "number", "width": "110px" }, - { "key": "vehicle", "label": "차량", "type": "reference", "width": "150px", - "config": { "source": "vehicles", "displayField": "plate_number" } }, - { "key": "freight", "label": "운임", "type": "currency", "width": "130px", - "config": { "currency": "KRW" } }, - { "key": "note", "label": "비고", "type": "textbox" } - ], - "addable": true, - "deletable": true, - "reorderable": true, - "emptyMessage": "경로를 추가하세요." - } -} -``` - -#### `pricing` (유통 — 가격정보) - -```jsonc -{ - "preset_id": "pricing", - "type": "table", - "table_config": { - "columns": [ - { "key": "customer_type", "label": "거래처유형", "type": "dropdown", "width": "140px", - "config": { "options": [ - {"label":"도매","value":"WHOLESALE"}, {"label":"소매","value":"RETAIL"}, - {"label":"온라인","value":"ONLINE"}, {"label":"특판","value":"SPECIAL"} - ]}, "required": true }, - { "key": "unit_price", "label": "단가", "type": "currency", "width": "130px", - "config": { "currency": "KRW" }, "required": true }, - { "key": "discount_rate", "label": "할인율(%)", "type": "number", "width": "100px", - "config": { "min": 0, "max": 100, "precision": 1 } }, - { "key": "final_price", "label": "최종가", "type": "computed", "width": "130px", - "config": { "formula": "{unit_price} * (1 - {discount_rate}/100)", "format": "currency" } }, - { "key": "valid_from", "label": "적용시작", "type": "date", "width": "130px" }, - { "key": "valid_to", "label": "적용종료", "type": "date", "width": "130px" }, - { "key": "note", "label": "비고", "type": "textbox" } - ], - "addable": true, - "deletable": true, - "emptyMessage": "가격 정보를 추가하세요." - } -} -``` - ---- - -## 6. 산업별 확장 구조 - -### 6.1 핵심 개념: 4-Level 아키텍처 - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Level 1: 필드 타입 컴포넌트 (14종) │ -│ ─────────────────────────────────────────────────────────── │ -│ 코드 레벨. 거의 안 바뀜. │ -│ textbox | number | dropdown | checkbox | date | textarea │ -│ reference | multi-select | file | currency | unit-value │ -│ radio | toggle | computed │ -│ │ -│ → UI 입력의 "원자(atom)" 단위. 모든 산업의 입력 형태를 커버. │ -│ → 새 컴포넌트 추가 = 완전히 새로운 입력 패러다임이 등장할 때만. │ -└──────────────────────────────────────────────────────────────┘ - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ Level 2: properties config (JSON) │ -│ ─────────────────────────────────────────────────────────── │ -│ 설정 레벨. 필드 타입의 동작을 결정. 코드 변경 없음. │ -│ │ -│ 같은 "reference" 타입이지만: │ -│ 제조: { "source": "processes" } → 공정 선택 │ -│ 공사: { "source": "custom", │ -│ "searchApiUrl": "/api/proxy/work-types" } → 공종선택 │ -│ 물류: { "source": "vehicles" } → 차량 선택 │ -│ 유통: { "source": "stores" } → 매장 선택 │ -│ │ -│ 같은 "unit-value" 타입이지만: │ -│ 제조: { "units": ["mm","cm","m","inch"] } → 치수 │ -│ 물류: { "units": ["km","m"] } → 거리 │ -│ 유통: { "units": ["g","kg","ton"] } → 중량 │ -└──────────────────────────────────────────────────────────────┘ - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ Level 3: 섹션 프리셋 (JSON) │ -│ ─────────────────────────────────────────────────────────── │ -│ 템플릿 레벨. 산업별 표준 섹션 구성. 코드 변경 없음. │ -│ │ -│ 제조: basic-info + specifications + bom + routing + quality │ -│ 공사: basic-info + site-info + work-schedule + material-plan │ -│ 유통: basic-info + packaging + pricing + store-allocation │ -│ 물류: basic-info + transport-spec + route-plan + loading-plan │ -│ │ -│ → 관리자가 "섹션 추가" → 프리셋 선택 → 자동 구성 │ -│ → 새 프리셋 = JSON 추가만, 컴포넌트 수정 없음 │ -└──────────────────────────────────────────────────────────────┘ - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ Level 4: reference sources (API URL) │ -│ ─────────────────────────────────────────────────────────── │ -│ 연결 레벨. 새 데이터 소스를 reference 필드에 연결. 코드 변경 없음. │ -│ │ -│ 새 산업/도메인 추가 시: │ -│ 1. 백엔드에 API 추가 (예: /api/proxy/work-types) │ -│ 2. reference 필드에 source: "custom" + searchApiUrl 설정 │ -│ 3. 끝. 프론트 코드 수정 없음. │ -│ │ -│ 자주 사용되는 source는 프리셋으로 등록: │ -│ reference-sources.ts에 추가 → source: "work_types"로 단축 │ -└──────────────────────────────────────────────────────────────┘ -``` - -### 6.2 산업별 변경 범위 매트릭스 - -| 변경 항목 | 코드 변경 | DB 변경 | config 변경 | -|----------|----------|---------|------------| -| 새 필드 타입 추가 | ✅ 컴포넌트 1개 | ✅ field_type 값 추가 | — | -| 새 reference 소스 추가 | ❌ | ✅ API 엔드포인트 | ✅ source config | -| 새 테이블 섹션 구성 | ❌ | ✅ section + properties | ✅ table_config JSON | -| 새 섹션 프리셋 추가 | ❌ (또는 프리셋 JSON 1건) | ❌ | ✅ 프리셋 JSON | -| 새 산업 진출 | ❌ | ✅ API들 | ✅ 프리셋 + source | -| 테넌트별 커스터마이징 | ❌ | ✅ 테넌트 config | ❌ | - -> **핵심**: "새 산업 진출" 시에도 프론트엔드 코드 변경 = 0줄. -> 백엔드 API + config JSON만 추가. - -### 6.3 산업별 구성 예시 - -#### 제조업 테넌트 (금속 가공) - -``` -페이지: 제품(FG) 등록 -├── 기본정보 (fields) -│ ├── 품목코드 [textbox, required] -│ ├── 품목명 [textbox, required] -│ ├── 품목유형 [dropdown: FG/PT/SM/RM/CS] -│ ├── 단위 [dropdown: EA/SET/M] -│ └── 상태 [toggle: 활성/비활성] -├── 규격/치수 (fields) -│ ├── 두께 [unit-value: mm/cm/inch] -│ ├── 너비 [unit-value: mm/cm/m] -│ ├── 높이 [unit-value: mm/cm/m] -│ ├── 무게 [unit-value: g/kg/ton] -│ ├── 재질 [reference → materials] -│ └── 표면처리 [reference → surface_treatments] -├── BOM (bom) — 기존 유지 -├── 공정/라우팅 (table) -│ └── [공정, 작업장, 셋업시간, 사이클타임, 비고] -├── 품질검사 (table) -│ └── [검사항목, 규격, 상한, 하한, 검사방법, 측정장비] -└── 도면 (fields) - ├── 도면파일 [file: .pdf/.dwg/.dxf] - ├── 도면번호 [textbox] - └── 도면버전 [textbox] -``` - -#### 공사관리 테넌트 (건설) - -``` -페이지: 공사 항목 등록 -├── 기본정보 (fields) -│ ├── 항목코드 [textbox, required] -│ ├── 항목명 [textbox, required] -│ ├── 공사구분 [dropdown: 토목/건축/설비/전기] -│ └── 상태 [toggle] -├── 현장정보 (fields) -│ ├── 현장 [reference → sites] -│ ├── 발주처 [reference → customers] -│ ├── 감리사 [reference → vendors] -│ ├── 착공일 [date] -│ ├── 준공예정일 [date] -│ └── 공사금액 [currency: KRW] -├── 공정표 (table) -│ └── [공종, 수량, 단위, 단가, 금액(computed), 착수일, 완료일, 진행률] -├── 자재투입계획 (table) -│ └── [자재(reference→items), 수량, 단위, 투입시기, 발주여부(checkbox)] -├── 인력투입계획 (table) -│ └── [직종, 인원, 기간(일), 일단가(currency), 금액(computed)] -└── 안전점검 (table) - └── [점검항목, 점검주기(dropdown), 담당자(reference→employees), 최근점검일(date)] -``` - -#### 유통업 테넌트 (도소매) - -``` -페이지: 상품 등록 -├── 기본정보 (fields) -│ ├── 상품코드 [textbox, required] -│ ├── 상품명 [textbox, required] -│ ├── 카테고리 [reference → categories] -│ ├── 브랜드 [reference → brands] -│ └── 상태 [toggle] -├── 포장정보 (fields) -│ ├── 포장단위 [dropdown: 낱개/박스/팔레트] -│ ├── 입수량 [number] -│ ├── 바코드 [textbox] -│ ├── 중량 [unit-value: g/kg] -│ └── 상품이미지 [file: .jpg/.png, maxFiles:5] -├── 가격정보 (table) -│ └── [거래처유형, 단가, 할인율, 최종가(computed), 적용기간] -├── 매장배분 (table) -│ └── [매장(reference→stores), 배분수량, 배분일, 상태(dropdown)] -└── 프로모션 (table) - └── [프로모션명, 할인율, 시작일, 종료일, 적용조건] -``` - -#### 물류업 테넌트 (운송) - -``` -페이지: 화물 등록 -├── 기본정보 (fields) -│ ├── 화물코드 [textbox, required] -│ ├── 화물명 [textbox, required] -│ ├── 화물유형 [dropdown: 일반/냉장/냉동/위험물] -│ └── 상태 [toggle] -├── 운송규격 (fields) -│ ├── 총중량 [unit-value: kg/ton] -│ ├── 부피 [unit-value: m³/CBM] -│ ├── 위험물등급 [dropdown: 1~9등급/해당없음] -│ ├── 보관온도 [unit-value: ℃] -│ ├── 적재방법 [radio: 팔레트/산적/컨테이너] -│ └── 특수요구사항 [textarea] -├── 배차/경로 (table) -│ └── [출발지, 도착지, 거리, 소요시간, 차량(reference), 운임(currency)] -├── 적재계획 (table) -│ └── [품목(reference→items), 수량, 중량, 적재순서, 위치] -└── 추적정보 (table) - └── [일시(date), 위치, 상태(dropdown), 온도(number), 비고] -``` - -### 6.4 코드 변경이 필요한 예외 케이스 - -14종 필드 타입으로 커버 불가능한 **완전히 새로운 입력 패러다임**: - -| 새 입력 패러다임 | 필요한 field_type | 작업량 | -|----------------|-----------------|-------| -| 지도 위치 선택 (GPS 좌표) | `map-picker` | 컴포넌트 1개 + switch 1줄 | -| 간트차트 편집 | `gantt` (섹션 타입) | 섹션 컴포넌트 1개 | -| 전자서명/도장 | `signature` | 컴포넌트 1개 + switch 1줄 | -| 바코드/QR 스캔 | `barcode-scan` | 컴포넌트 1개 + switch 1줄 | -| 조직도 선택 | `org-chart-picker` | 컴포넌트 1개 + switch 1줄 | -| 색상 선택 (컬러피커) | `color-picker` | 컴포넌트 1개 + switch 1줄 | - -> 이런 경우에도 **컴포넌트 1개 파일 추가 + switch문 1줄 추가**로 끝. -> 기존 코드 수정 없음. 다른 필드 타입에 영향 없음. - -### 6.5 확장 가능성 요약 - -``` -Q: 새 산업(예: 의료)을 추가하려면? -A: 프론트 코드 변경 0줄. - 1. 백엔드에 의료 도메인 API 추가 (환자, 의약품, 의료기기 등) - 2. reference source config 추가 (JSON) - 3. 의료 섹션 프리셋 추가 (JSON) - 4. 테넌트 생성 시 의료 프리셋 자동 적용 - -Q: 새 필드 타입(예: 지도)이 필요하면? -A: MapPickerField.tsx 1개 생성 + switch 1줄 추가. - 기존 14종 컴포넌트/기존 config/기존 프리셋 전부 영향 없음. - -Q: 기존 테넌트가 산업을 추가하면? (제조 + 물류 겸업) -A: 해당 테넌트의 페이지에 물류 프리셋 섹션만 추가. - 코드 변경 없음. Admin UI에서 클릭으로 완료. -``` - ---- - -## 7. 백엔드 API 계약 - -### 7.1 field_type 확장 — DB 변경 - -```sql --- item_fields 테이블의 field_type 컬럼 확장 --- 기존: ENUM('textbox','number','dropdown','checkbox','date','textarea') --- 변경: VARCHAR(30) 또는 ENUM 확장 - -ALTER TABLE item_fields -MODIFY COLUMN field_type VARCHAR(30) NOT NULL DEFAULT 'textbox'; - --- 허용 값: textbox, number, dropdown, checkbox, date, textarea, --- reference, multi-select, file, currency, unit-value, radio, toggle, computed --- 향후 추가 가능: map-picker, signature, barcode-scan 등 -``` - -### 7.2 section type 확장 - -```sql -ALTER TABLE item_sections -MODIFY COLUMN type VARCHAR(20) NOT NULL DEFAULT 'fields'; - --- 허용 값: fields, bom, table --- 향후 추가 가능: gantt, calendar 등 -``` - -### 7.3 properties 필드 활용 - -**기존 `properties` 컬럼** (`JSON` 타입)이 이미 `item_fields`와 `item_sections` 테이블에 존재함. -신규 필드 타입의 config는 이 컬럼에 저장. - -```sql --- reference 타입 필드 -UPDATE item_fields SET - field_type = 'reference', - properties = '{"source":"vendors","displayField":"name","searchFields":["name","code"]}' -WHERE id = 100; - --- table 타입 섹션 -UPDATE item_sections SET - type = 'table', - properties = '{"table_config":{"columns":[...],"addable":true}}' -WHERE id = 50; -``` - -### 7.4 Init API / Page Structure API — 변경 없음 - -기존 응답 구조 그대로. `field_type`과 `properties`에 새로운 값이 들어올 뿐. - -```jsonc -// GET /v1/item-master/pages/{id}/structure — 응답 구조 동일 -{ - "page": { ... }, - "sections": [ - { - "section": { "type": "fields", ... }, - "fields": [ - { "field": { "field_type": "reference", "properties": { "source": "vendors" } } } - ] - }, - { - "section": { - "type": "table", - "properties": { "table_config": { "columns": [...] } } - }, - "fields": [], - "bom_items": [] - } - ] -} -``` - -### 7.5 테이블 섹션 데이터 CRUD API (신규) - -``` -GET /v1/items/{itemId}/section-data/{sectionId} -→ { "data": [{ "process": "CUT-001", "cycle_time": 5.0, ... }, ...] } - -PUT /v1/items/{itemId}/section-data/{sectionId} -← { "rows": [{ "process": "CUT-001", "cycle_time": 5.0, ... }, ...] } - -POST /v1/items/{itemId}/section-data/{sectionId} -← { "process": "CUT-001", "cycle_time": 5.0, ... } - -DELETE /v1/items/{itemId}/section-data/{sectionId}/{rowId} -``` - -### 7.6 Reference 필드 검색 API - -기존 API 활용 + custom source: - -| source | API | 비고 | -|--------|-----|------| -| vendors | `GET /v1/vendors?search={q}&size=20` | 기존 | -| items | `GET /v1/items?search={q}&size=20` | 기존 | -| customers | `GET /v1/customers?search={q}&size=20` | 기존 | -| employees | `GET /v1/employees?search={q}&size=20` | 기존 | -| processes | `GET /v1/processes?search={q}&size=20` | 기존 | -| warehouses | `GET /v1/warehouses?search={q}&size=20` | 기존 | -| materials | `GET /v1/item-master/materials?search={q}` | 기존 | -| custom | `properties.searchApiUrl?search={q}&size=20` | **신규 산업별 API** | - -> 새 산업 추가 시: 해당 도메인 API 생성 → source: "custom" + searchApiUrl 설정 - -### 7.7 파일 업로드 API - -``` -POST /v1/files/upload ← multipart/form-data -GET /v1/files/{fileId} → binary -DELETE /v1/files/{fileId} -``` - ---- - -## 8. 프론트엔드 컴포넌트 아키텍처 - -### 8.1 파일 구조 (신규 추가분) - -``` -DynamicItemForm/ -├── fields/ -│ ├── DynamicFieldRenderer.tsx # 수정: switch문 확장 -│ ├── TextField.tsx # 기존 유지 -│ ├── NumberField.tsx # 기존 유지 -│ ├── DropdownField.tsx # 기존 유지 -│ ├── CheckboxField.tsx # 기존 유지 -│ ├── DateField.tsx # 기존 유지 -│ ├── TextareaField.tsx # 기존 유지 -│ ├── ReferenceField.tsx # ★ 신규 Phase 1 -│ ├── MultiSelectField.tsx # ★ 신규 Phase 1 -│ ├── FileField.tsx # ★ 신규 Phase 1 -│ ├── CurrencyField.tsx # ★ 신규 Phase 2 -│ ├── UnitValueField.tsx # ★ 신규 Phase 2 -│ ├── RadioField.tsx # ★ 신규 Phase 2 -│ ├── ToggleField.tsx # ★ 신규 Phase 3 -│ └── ComputedField.tsx # ★ 신규 Phase 3 -├── sections/ -│ ├── DynamicBOMSection.tsx # 기존 유지 -│ ├── DynamicTableSection.tsx # ★ 신규 Phase 1 -│ └── TableCellRenderer.tsx # ★ 신규 Phase 1 -├── presets/ -│ ├── index.ts # ★ 신규: 프리셋 레지스트리 -│ └── section-presets.ts # ★ 신규: 전 산업 프리셋 정의 -└── config/ - └── reference-sources.ts # ★ 신규: 참조 소스 프리셋 -``` - -### 8.2 DynamicFieldRenderer 확장 - -```typescript -export function DynamicFieldRenderer(props: DynamicFieldRendererProps) { - switch (props.field.field_type) { - // 기존 6종 (변경 없음) - case 'textbox': return ; - case 'number': return ; - case 'dropdown': return ; - case 'checkbox': return ; - case 'date': return ; - case 'textarea': return ; - // Phase 1 - case 'reference': return ; - case 'multi-select': return ; - case 'file': return ; - // Phase 2 - case 'currency': return ; - case 'unit-value': return ; - case 'radio': return ; - // Phase 3 - case 'toggle': return ; - case 'computed': return ; - default: - return ; - } -} -``` - -### 8.3 테이블 셀 = 필드 컴포넌트 재사용 - -```typescript -// sections/TableCellRenderer.tsx -// DynamicFieldRenderer를 테이블 셀용으로 래핑 (축소 UI) -export function TableCellRenderer({ column, value, onChange }: TableCellProps) { - // column config → ItemFieldResponse 호환 객체로 변환 - const fieldLike: ItemFieldResponse = { - field_type: column.type, - field_name: column.label, - properties: column.config, - options: column.config?.options, - is_required: column.required || false, - // ... 최소 필수 필드 - }; - - return ( - - ); -} -``` - -### 8.4 참조 소스 프리셋 - -```typescript -// config/reference-sources.ts -export const REFERENCE_SOURCES: Record = { - // 공통 - vendors: { apiUrl: '/api/proxy/vendors', displayField: 'name', ... }, - items: { apiUrl: '/api/proxy/items', displayField: 'name', ... }, - customers: { apiUrl: '/api/proxy/customers', displayField: 'company_name', ... }, - employees: { apiUrl: '/api/proxy/employees', displayField: 'name', ... }, - warehouses: { apiUrl: '/api/proxy/warehouses', displayField: 'name', ... }, - // 제조 - processes: { apiUrl: '/api/proxy/processes', displayField: 'process_name', ... }, - materials: { apiUrl: '/api/proxy/item-master/materials', displayField: 'material_name', ... }, - surface_treatments: { apiUrl: '/api/proxy/item-master/surface-treatments', ... }, - // 공사 - sites: { apiUrl: '/api/proxy/sites', displayField: 'site_name', ... }, - // 물류 - vehicles: { apiUrl: '/api/proxy/vehicles', displayField: 'plate_number', ... }, - routes: { apiUrl: '/api/proxy/routes', displayField: 'route_name', ... }, - // 유통 - stores: { apiUrl: '/api/proxy/stores', displayField: 'store_name', ... }, -}; -// "custom" source → properties.searchApiUrl 직접 사용 -``` - ---- - -## 9. 조건부 표시 확장 - -### 9.1 현재 (유지) - -```jsonc -{ "fieldKey": "item_type", "expectedValue": "FG", "targetFieldIds": ["150"] } -``` - -### 9.2 확장: 비교 연산자 지원 - -```jsonc -{ "fieldKey": "item_type", "operator": "in", "expectedValue": ["FG","PT"], "targetFieldIds": ["150"] } -``` - -| operator | 설명 | 하위 호환 | -|----------|------|----------| -| `equals` | 같음 (기본) | ✅ operator 없으면 equals | -| `not_equals` | 다름 | | -| `in` | 배열 포함 | | -| `not_in` | 배열 미포함 | | -| `is_empty` | 비어있음 | | -| `is_not_empty` | 비어있지 않음 | | -| `greater_than` | 초과 | | -| `less_than` | 미만 | | - ---- - -## 10. 검증 프레임워크 - -### 10.1 필드 타입별 추가 검증 - -| field_type | 추가 검증 | -|-----------|----------| -| `reference` | 선택 항목 존재 여부 | -| `multi-select` | maxSelections 초과 | -| `file` | maxSize, accept 타입, maxFiles | -| `currency` | allowNegative, precision | -| `unit-value` | 값이 숫자, 단위가 유효 | -| `computed` | 검증 없음 (자동 계산) | - -### 10.2 테이블 섹션 검증 - -```typescript -function validateTableRows(rows, columns): string[] { - const errors = []; - rows.forEach((row, idx) => { - columns.forEach(col => { - if (col.required && !row[col.key]) { - errors.push(`${idx + 1}행: ${col.label}은(는) 필수입니다.`); - } - }); - }); - return errors; -} -``` - ---- - -## 11. 구현 로드맵 - -### Phase 1: 핵심 확장 (🔴) - -| 작업 | 예상 줄 수 | -|------|-----------| -| ReferenceField | ~200 | -| MultiSelectField | ~120 | -| FileField | ~180 | -| DynamicTableSection + TableCellRenderer | ~450 | -| reference-sources.ts | ~120 | -| 타입 정의 확장 | +50 | -| DynamicFieldRenderer switch 확장 | +10 | -| **총** | **~1,130줄 신규, ~30줄 수정** | - -### Phase 2: 편의 필드 (🟡) - -| 작업 | 예상 줄 수 | -|------|-----------| -| CurrencyField | ~80 | -| UnitValueField | ~100 | -| RadioField | ~60 | -| 섹션 프리셋 라이브러리 (전 산업) | ~400 | -| 프리셋 선택 UI | +100 | -| **총** | **~740줄 신규** | - -### Phase 3: 고급 필드 (🟢) - -| 작업 | 예상 줄 수 | -|------|-----------| -| ToggleField | ~50 | -| ComputedField | ~120 | -| 조건부 표시 연산자 확장 | +40 | -| 테이블 검증 강화 | +60 | -| **총** | **~270줄 신규** | - -### 백엔드 작업 (프론트와 병렬) - -| 작업 | 설명 | -|------|------| -| field_type 컬럼 확장 | VARCHAR(30) | -| section type 확장 | 'table' 추가 | -| 테이블 데이터 API | section-data CRUD | -| 산업별 도메인 API | 해당 산업 진출 시 추가 | -| 프리셋 시딩 | 테넌트 생성 시 산업별 프리셋 자동 적용 | - ---- - -## 부록 - -### A. 기존 코드 영향 분석 - -| 기존 파일 | 변경 | 내용 | -|----------|------|------| -| `DynamicFieldRenderer.tsx` | switch 추가 | +8 case문 | -| `DynamicItemForm/index.tsx` | 섹션 렌더링 | +10줄 (table case) | -| `types.ts` | 타입 확장 | field_type union + 신규 인터페이스 | -| `item-master-api.ts` | field_type 확장 | union 값 추가 | -| **기존 필드 컴포넌트 6개** | **변경 없음** | | -| **DynamicBOMSection** | **변경 없음** | | -| **hooks 7개** | **변경 없음** | | - -### B. 전체 아키텍처 다이어그램 - -``` -┌───────────────────────────────────────────────────────────┐ -│ Admin (품목기준관리) │ -│ → 산업 선택 → 프리셋 선택 → 필드 config 설정 │ -└────────────────────┬──────────────────────────────────────┘ - │ 저장 (field_type + properties JSON) - ▼ -┌───────────────────────────────────────────────────────────┐ -│ 백엔드 DB │ -│ item_pages → item_sections → item_fields │ -│ type: fields/bom/table │ -│ properties: { table_config / field config } │ -│ │ -│ field_type (14종): 모든 산업의 입력 원자 단위 │ -│ properties (JSON): 산업/테넌트별 동작 결정 │ -└────────────────────┬──────────────────────────────────────┘ - │ 조회 (기존 API 구조 그대로) - ▼ -┌───────────────────────────────────────────────────────────┐ -│ User (품목 등록/수정) │ -│ DynamicItemForm │ -│ ├─ DynamicFieldRenderer (14종 switch) │ -│ │ └─ 각 컴포넌트가 properties를 읽어 동작 결정 │ -│ ├─ DynamicBOMSection (기존 유지) │ -│ └─ DynamicTableSection (columns config 기반 렌더링) │ -│ └─ TableCellRenderer → DynamicFieldRenderer 재사용 │ -└───────────────────────────────────────────────────────────┘ -``` - ---- - -**문서 버전**: 1.1 (산업별 확장 구조 추가) -**마지막 업데이트**: 2026-02-11 -**다음 단계**: 백엔드 검토 → Phase 1 구현 착수 diff --git a/claudedocs/architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md b/claudedocs/architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md deleted file mode 100644 index 2438f58a..00000000 --- a/claudedocs/architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md +++ /dev/null @@ -1,145 +0,0 @@ -# masterDataStore 캐시 테넌트 격리 수정 - -**작성일**: 2026-01-29 -**타입**: 버그 수정 (캐시 격리 누락) -**관련 문서**: `[REF-2025-11-19] multi-tenancy-implementation.md` - ---- - -## 배경 - -멀티테넌시 검토 결과, `TenantAwareCache`(`mes-{tenantId}-{key}`)는 테넌트별로 캐시가 격리되어 있지만, `masterDataStore`의 sessionStorage 캐시는 테넌트 구분 없이 `page_config_{pageType}` 키를 사용하고 있었음. - -추가로 `setCurrentTenantId` 액션이 인터페이스에만 선언되어 있고 **구현도, 호출도 없는** dead code 상태였음. - ---- - -## 문제 - -### 1. 캐시 키에 tenantId 미포함 - -``` -TenantAwareCache: mes-282-itemMasters ← 테넌트 격리됨 -masterDataStore: page_config_item-master ← 테넌트 격리 안됨 -``` - -### 2. 발생 가능한 시나리오 - -``` -1. 테넌트 282 사용자가 품목관리 접속 - → sessionStorage: page_config_item-master = {테넌트282 설정} - -2. 세션 내 테넌트 500으로 전환 (로그아웃 없이) - → clearTenantCache()는 mes-282-* 만 삭제 - → page_config_item-master 는 삭제되지 않음 - -3. 테넌트 500 사용자에게 테넌트 282의 페이지 설정이 노출 -``` - -### 3. setCurrentTenantId 미구현 - -```typescript -// 인터페이스에 선언만 있고 구현 없음 -interface MasterDataStore { - currentTenantId: number | null; // ← initialState에도 누락 - setCurrentTenantId: (tenantId: number | null) => void; // ← 구현 없음 -} -``` - ---- - -## 수정 내역 - -### masterDataStore.ts - -| 영역 | Before | After | -|------|--------|-------| -| initialState | `currentTenantId` 누락 | `currentTenantId: null` 추가 | -| 캐시 키 포맷 | `page_config_{pageType}` | `page_config_{tenantId}_{pageType}` | -| setCurrentTenantId | 인터페이스만 선언 | 구현 추가 | -| fetchPageConfig | tenantId 미사용 | `currentTenantId`를 캐시 함수에 전달 | -| invalidateConfig | tenantId 미사용 | `currentTenantId` 기반 삭제 | -| invalidateAllConfigs | tenantId 미사용 | `currentTenantId` 기반 삭제 | -| reset() | pageType 목록 순회 삭제 | `page_config_` 프리픽스 기반 전체 삭제 | - -#### 핵심 변경: 캐시 키 생성 함수 추가 - -```typescript -function getStorageKey(tenantId: number | null, pageType: PageType): string { - return tenantId != null - ? `${STORAGE_PREFIX}${tenantId}_${pageType}` // page_config_282_item-master - : `${STORAGE_PREFIX}${pageType}`; // page_config_item-master (하위 호환) -} -``` - -#### 핵심 변경: reset()을 프리픽스 기반으로 변경 - -```typescript -// Before: 고정된 pageType 목록으로 삭제 (tenantId 포함 키를 찾지 못함) -pageTypes.forEach((pt) => removeConfigFromSessionStorage(pt)); - -// After: page_config_ 프리픽스로 모든 테넌트 캐시 일괄 삭제 -Object.keys(window.sessionStorage).forEach(key => { - if (key.startsWith(STORAGE_PREFIX)) { - window.sessionStorage.removeItem(key); - } -}); -``` - -### AuthContext.tsx - -| 영역 | Before | After | -|------|--------|-------| -| import | - | `useMasterDataStore` 추가 | -| tenantId 동기화 | 없음 | `currentUser.tenant.id` 변경 시 `setCurrentTenantId()` 호출 | -| clearTenantCache | `mes-{tenantId}-*` 만 삭제 | `mes-{tenantId}-*` + `page_config_{tenantId}_*` 삭제 | - -#### 핵심 변경: tenantId 동기화 useEffect - -```typescript -useEffect(() => { - const tenantId = currentUser?.tenant?.id ?? null; - useMasterDataStore.getState().setCurrentTenantId(tenantId); -}, [currentUser?.tenant?.id]); -``` - -#### 핵심 변경: clearTenantCache 범위 확장 - -```typescript -const tenantAwarePrefix = `mes-${tenantId}-`; -const pageConfigPrefix = `page_config_${tenantId}_`; - -Object.keys(sessionStorage).forEach(key => { - if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) { - sessionStorage.removeItem(key); - } -}); -``` - ---- - -## 하위 호환 - -| 항목 | 영향 | -|------|------| -| 기존 캐시 키 | `page_config_item-master` → 키 불일치로 miss → API 재요청 → 새 포맷으로 자동 전환 | -| logout.ts | `page_config_` 프리픽스 매칭이 새 키 포맷(`page_config_282_item-master`)도 커버 | -| sessionStorage TTL | 10분 만료이므로 기존 키는 자연 소멸 | -| tenantId가 null인 경우 | 기존 포맷(`page_config_{pageType}`) 유지하여 동작 보장 | - ---- - -## 효과 - -1. **세션 내 테넌트 전환 시 캐시 누수 차단**: `clearTenantCache`가 `page_config_{tenantId}_*`까지 삭제 -2. **캐시 패턴 일관성**: TenantAwareCache(`mes-{tenantId}-`)와 masterDataStore(`page_config_{tenantId}_`) 모두 테넌트 격리 -3. **dead code 해소**: `currentTenantId` 필드와 `setCurrentTenantId` 액션이 실제로 동작 - ---- - -## 관련 파일 - -- `src/stores/masterDataStore.ts` - 캐시 키 변경, setCurrentTenantId 구현 -- `src/contexts/AuthContext.tsx` - tenantId 동기화, clearTenantCache 범위 확장 -- `src/lib/auth/logout.ts` - 기존 `page_config_` 프리픽스 매칭 (변경 없음, 호환 확인) -- `src/lib/cache/TenantAwareCache.ts` - 참고 (기존 정상 동작) diff --git a/claudedocs/architecture/[GUIDE] component-tier-definition.md b/claudedocs/architecture/[GUIDE] component-tier-definition.md deleted file mode 100644 index dafbf5e5..00000000 --- a/claudedocs/architecture/[GUIDE] component-tier-definition.md +++ /dev/null @@ -1,153 +0,0 @@ -# Component Tier 정의 - -> SAM 프로젝트의 컴포넌트 계층(tier) 기준 정의. -> 새 컴포넌트 작성 시 어디에 배치할지 판단하는 기준 문서. - -## Tier 구조 요약 - -``` -ui 원시 빌딩블록 (HTML 래퍼, 단일 기능) - ↓ 조합 -atoms 최소 단위 UI 조각 (ui 1~2개 조합) - ↓ 조합 -molecules 의미 있는 UI 패턴 (atoms/ui 여러 개 조합) - ↓ 조합 -organisms 페이지 섹션 단위 (molecules/atoms 조합, 레이아웃 포함) - ↓ 사용 -domain 도메인별 비즈니스 컴포넌트 (organisms/molecules 사용) -``` - -## Tier별 정의 - -### ui (원시 빌딩블록) -| 항목 | 기준 | -|------|------| -| 위치 | `src/components/ui/` | -| 역할 | HTML 요소를 감싼 최소 단위. 스타일링 + 접근성만 담당 | -| 특징 | 비즈니스 로직 없음, 범용적, Radix UI 래퍼 포함 | -| 예시 | Button, Input, Select, Badge, Dialog, DatePicker, EmptyState | -| 판단 기준 | "이 컴포넌트가 다른 프로젝트에 그대로 복사해도 동작하는가?" → Yes면 ui | - -### atoms (최소 UI 조각) -| 항목 | 기준 | -|------|------| -| 위치 | `src/components/atoms/` | -| 역할 | ui 1~2개를 조합한 작은 패턴. 단일 목적 | -| 특징 | props 2~5개, 상태 관리 최소 | -| 예시 | BadgeSm, TabChip, ScrollableButtonGroup | -| 판단 기준 | "ui 하나로는 부족하지만, 독립적인 의미 단위인가?" → Yes면 atoms | - -### molecules (의미 있는 UI 패턴) -| 항목 | 기준 | -|------|------| -| 위치 | `src/components/molecules/` | -| 역할 | atoms/ui 여러 개를 조합하여 하나의 기능 패턴을 구성 | -| 특징 | Label + Input + Error 같은 조합, 내부 상태 가능 | -| 예시 | FormField, StatusBadge, DateRangeSelector, StandardDialog, TableActions | -| 판단 기준 | "여러 ui/atoms의 조합이고, 재사용 가능한 패턴인가?" → Yes면 molecules | - -### organisms (페이지 섹션) -| 항목 | 기준 | -|------|------| -| 위치 | `src/components/organisms/` | -| 역할 | 페이지의 독립적인 섹션. molecules/atoms를 조합하여 레이아웃 포함 | -| 특징 | 데이터 테이블, 검색 필터, 폼 섹션 등 페이지 구성 단위 | -| 예시 | DataTable, PageHeader, StatCards, FormSection, SearchableSelectionModal | -| 판단 기준 | "페이지에서 하나의 영역으로 독립 가능한가?" → Yes면 organisms | - -### common (공용 페이지/레이아웃) -| 항목 | 기준 | -|------|------| -| 위치 | `src/components/common/` | -| 역할 | 에러 페이지, 권한 없음 페이지 등 전역 공통 화면 | -| 특징 | 라우터 사용, 전체 페이지 레이아웃 | -| 예시 | AccessDenied, EmptyPage, ServerErrorPage | -| 판단 기준 | "전체 화면을 차지하는 공통 페이지인가?" → Yes면 common | - -### layout (레이아웃 구조) -| 항목 | 기준 | -|------|------| -| 위치 | `src/components/layout/` | -| 역할 | 앱 전체 레이아웃 골격 (사이드바, 헤더, 네비게이션) | -| 예시 | AuthenticatedLayout, Sidebar, TopNav | - -### dev (개발 도구) -| 항목 | 기준 | -|------|------| -| 위치 | `src/components/dev/` | -| 역할 | 개발 환경 전용 도구 (프로덕션 미포함) | -| 예시 | DevToolbar | - -### domain (도메인 비즈니스) -| 항목 | 기준 | -|------|------| -| 위치 | `src/components/{도메인명}/` (hr, sales, accounting 등) | -| 역할 | 특정 도메인의 비즈니스 로직이 포함된 컴포넌트 | -| 특징 | API 호출, 도메인 타입, 비즈니스 규칙 포함 | -| 예시 | EmployeeManagement, OrderRegistration, BillDetail | -| 판단 기준 | "특정 도메인에서만 사용되는가?" → Yes면 domain | - -## 자주 혼동되는 케이스 - -| 상황 | 올바른 tier | 이유 | -|------|-------------|------| -| EmptyState (프리셋/variant 있음) | **ui** | 범용 빌딩블록, 비즈니스 로직 없음 | -| StatusBadge (icon/dot/색상 커스텀) | **molecules** | Badge + BadgeSm 조합, DataTable 연동 | -| ConfirmDialog (삭제/저장 확인) | **ui** | AlertDialog 래퍼, 범용적 | -| StandardDialog (범용 컨테이너) | **molecules** | Dialog + Header + Footer 조합 패턴 | -| DataTable (정렬/페이지네이션/선택) | **organisms** | 페이지 섹션 단위, 다수 하위 컴포넌트 | -| SearchableSelectionModal | **organisms** | 검색+선택 완결 기능, 독립 섹션 | - -## 중복 방지 규칙 - -1. **새 컴포넌트 작성 전**: 같은 이름/기능이 다른 tier에 이미 있는지 확인 -2. **ui에 이미 있으면**: molecules/organisms에 동일 컴포넌트 만들지 않음. 필요하면 ui를 확장 -3. **re-export 허용**: organisms/index.ts에서 ui 컴포넌트를 re-export 가능 (편의성) -4. **확인(Confirm) 다이얼로그**: `ui/confirm-dialog.tsx` 하나만 사용 (52개 파일 사용 중) - -## StatusBadge 역할 구분 - -이름이 같지만 tier와 용도가 다른 두 컴포넌트. **둘 다 유지**. - -### `ui/status-badge.tsx` — 범용 상태 배지 - -| 항목 | 내용 | -|------|------| -| import | `import { StatusBadge } from '@/components/ui/status-badge'` | -| 용도 | `createStatusConfig`와 연동하는 **config 기반** 상태 표시 | -| API | `children` 또는 `status + config` 자동 라벨/스타일 | -| 특화 기능 | `mode` (badge/text), `ConfiguredStatusBadge` 제네릭 | -| 사용 예시 | 템플릿/유틸과 연동하는 범용 상태 표시 | - -```tsx -// config 기반 사용 - - -// children 기반 사용 -완료 -``` - -### `molecules/StatusBadge.tsx` — DataTable 특화 배지 - -| 항목 | 내용 | -|------|------| -| import | `import { StatusBadge } from '@/components/molecules/StatusBadge'` | -| 용도 | DataTable 셀에서 상태를 **아이콘/도트와 함께** 표시 | -| API | `label` 필수, `variant`로 색상 지정 | -| 특화 기능 | `icon` (LucideIcon), `showDot`, 커스텀 `bgColor/textColor/borderColor` | -| 기반 | Badge + BadgeSm 조합 (size="sm"일 때 BadgeSm으로 자동 전환) | - -```tsx -// DataTable 셀 렌더링 - - -``` - -### 선택 기준 - -| 상황 | 사용할 컴포넌트 | -|------|----------------| -| `createStatusConfig` 결과와 연동 | **ui** StatusBadge | -| DataTable 컬럼 셀 렌더링 | **molecules** StatusBadge | -| 아이콘이나 도트가 필요한 배지 | **molecules** StatusBadge | -| 단순 텍스트 상태 표시 (badge/text 모드) | **ui** StatusBadge | diff --git a/claudedocs/architecture/[IMPL-2025-11-13] browser-support-policy.md b/claudedocs/architecture/[IMPL-2025-11-13] browser-support-policy.md deleted file mode 100644 index fd465918..00000000 --- a/claudedocs/architecture/[IMPL-2025-11-13] browser-support-policy.md +++ /dev/null @@ -1,516 +0,0 @@ -# 브라우저 지원 정책 - -## 📋 목차 -1. [지원 브라우저](#지원-브라우저) -2. [지원하지 않는 브라우저](#지원하지-않는-브라우저) -3. [기술적 배경](#기술적-배경) -4. [구현 내용](#구현-내용) -5. [테스트 가이드](#테스트-가이드) -6. [사용자 안내 프로세스](#사용자-안내-프로세스) -7. [향후 정책](#향후-정책) - ---- - -## 지원 브라우저 - -### ✅ 공식 지원 브라우저 - -| 브라우저 | 최소 버전 | 권장 버전 | 플랫폼 | 우선순위 | -|---------|----------|----------|--------|---------| -| **Google Chrome** | 90+ | 최신 버전 | Windows, macOS, Linux | 🔴 High | -| **Microsoft Edge** | 90+ | 최신 버전 | Windows, macOS | 🔴 High | -| **Safari** | 14+ | 최신 버전 | macOS, iOS | 🔴 High | - -### 브라우저별 권장 사유 - -#### Chrome (권장) -- ✅ 가장 안정적인 성능 -- ✅ 개발 도구 우수 -- ✅ 자동 업데이트 -- ✅ 크로스 플랫폼 지원 - -#### Edge (Windows 권장) -- ✅ Windows 기본 브라우저 -- ✅ Chrome 엔진 기반 (Chromium) -- ✅ Microsoft 공식 지원 -- ✅ 엔터프라이즈 환경 최적화 - -#### Safari (macOS/iOS 권장) -- ✅ Apple 기기 최적화 -- ✅ 배터리 효율 우수 -- ✅ 개인정보 보호 강화 -- ✅ iOS 필수 브라우저 - ---- - -## 지원하지 않는 브라우저 - -### ❌ Internet Explorer (모든 버전) - -**지원 중단 사유:** - -1. **Microsoft 공식 지원 종료** - - 2022년 6월 15일부로 IE 지원 완전 종료 - - 보안 업데이트 중단 - - Edge로 마이그레이션 권장 - -2. **기술적 한계** - - 현대 웹 표준 미지원 - - JavaScript ES6+ 미지원 - - CSS3 고급 기능 미지원 - - 성능 문제 - -3. **보안 취약점** - - 패치되지 않는 보안 결함 - - XSS, CSRF 등 공격에 취약 - - 개인정보 유출 위험 - -4. **프로젝트 기술 스택 비호환** - - Next.js 15: IE 지원 중단 (v12부터) - - React 19: IE 지원 중단 (v18부터) - - Tailwind CSS 4: IE 미지원 - - Modern JavaScript (ES6+): 네이티브 미지원 - ---- - -## 기술적 배경 - -### 현재 기술 스택과 IE 비호환성 - -```json -{ - "next": "15.5.6", // IE 지원 중단: v12 (2021) - "react": "19.2.0", // IE 지원 중단: v18 (2022) - "tailwindcss": "4", // IE 미지원 - "typescript": "5" // ES6+ 트랜스파일 필요 -} -``` - -### IE 지원을 위한 대안과 비용 - -| 방안 | 가능 여부 | 비용 | 문제점 | -|------|----------|------|--------| -| **다운그레이드** | ⚠️ 가능 | 2-3주 개발 | 보안 취약점, 최신 기능 사용 불가 | -| **폴리필 추가** | ❌ 불가능 | - | Next.js 15/React 19는 폴리필로 해결 불가 | -| **별도 레거시 버전** | ⚠️ 가능 | 1-2개월 개발 | 유지보수 부담 증가 | -| **Edge 마이그레이션** | ✅ 권장 | 0원 | 사용자 교육 필요 | - -**결론**: IE 지원 비용 대비 효과가 낮아 **지원하지 않기로 결정** - ---- - -## 구현 내용 - -### 1. IE 감지 및 차단 로직 - -**파일**: `src/middleware.ts` - -```typescript -/** - * Check if user-agent is Internet Explorer - * IE 11: Contains "Trident" in user-agent - * IE 10 and below: Contains "MSIE" in user-agent - */ -function isInternetExplorer(userAgent: string): boolean { - if (!userAgent) return false; - - return /MSIE|Trident/.test(userAgent); -} - -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - const userAgent = request.headers.get('user-agent') || ''; - - // 🚨 Internet Explorer Detection (최우선 처리) - if (isInternetExplorer(userAgent)) { - // unsupported-browser.html 페이지는 제외 (무한 리다이렉트 방지) - if (!pathname.includes('unsupported-browser')) { - console.log(`[IE Blocked] ${userAgent} attempted to access ${pathname}`); - return NextResponse.redirect(new URL('/unsupported-browser.html', request.url)); - } - } - - // ... 나머지 로직 -} -``` - -**동작 방식**: -1. 모든 요청에서 User-Agent 확인 -2. IE 패턴 감지 시 `/unsupported-browser.html`로 리다이렉트 -3. 안내 페이지는 무한 리다이렉트 방지 처리 - ---- - -### 2. 브라우저 업그레이드 안내 페이지 - -**파일**: `public/unsupported-browser.html` - -**주요 기능**: -- ✅ IE 사용 불가 안내 -- ✅ 권장 브라우저 다운로드 링크 제공 -- ✅ IE 지원 중단 사유 설명 -- ✅ 반응형 디자인 (모바일 대응) -- ✅ 접근성 고려 (고대비, 큰 폰트) - -**안내 브라우저**: -1. **Microsoft Edge** (권장) - Windows 사용자용 -2. **Google Chrome** - 범용 -3. **Safari** - macOS/iOS 사용자용 - ---- - -### 3. User-Agent 감지 패턴 - -| IE 버전 | User-Agent 패턴 | 감지 정규식 | -|---------|----------------|------------| -| IE 11 | `Trident/7.0` | `/Trident/` | -| IE 10 | `MSIE 10.0` | `/MSIE/` | -| IE 9 이하 | `MSIE 9.0`, `MSIE 8.0` | `/MSIE/` | - -**감지 코드**: -```javascript -/MSIE|Trident/.test(userAgent) -``` - ---- - -## 테스트 가이드 - -### 1. Chrome DevTools를 사용한 IE 시뮬레이션 - -```javascript -// Chrome DevTools Console에서 실행 -// 1. F12 → Console 탭 -// 2. 다음 코드 붙여넣기 - -// IE 11 시뮬레이션 -Object.defineProperty(navigator, 'userAgent', { - get: function() { - return 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko'; - } -}); - -// 페이지 새로고침 -location.reload(); -``` - -**예상 결과**: `/unsupported-browser.html`로 리다이렉트 - ---- - -### 2. 실제 IE에서 테스트 (Windows 전용) - -#### Windows 10 IE 11 테스트 -```bash -# 1. Windows 검색 → "Internet Explorer" -# 2. http://localhost:3000 접속 -# 3. 안내 페이지 표시 확인 -``` - -#### 가상 머신 테스트 -- [Microsoft Edge Developer](https://developer.microsoft.com/microsoft-edge/tools/vms/) 가상 머신 사용 -- Windows 7/8/10 + IE 버전별 테스트 가능 - ---- - -### 3. 지원 브라우저 테스트 - -| 브라우저 | 테스트 항목 | 예상 결과 | -|---------|-----------|----------| -| **Chrome** | 로그인 → 대시보드 이동 | ✅ 정상 작동 | -| **Edge** | 로그인 → 대시보드 이동 | ✅ 정상 작동 | -| **Safari** | 로그인 → 대시보드 이동 | ✅ 정상 작동 | -| **IE 11** | 모든 페이지 접근 | ⚠️ 안내 페이지로 리다이렉트 | - ---- - -## 사용자 안내 프로세스 - -### 1. 사전 공지 (배포 1개월 전) - -**공지 채널**: -- 📧 이메일: 전체 사용자 대상 -- 📢 시스템 공지: 로그인 시 팝업 -- 📄 홈페이지: 공지사항 게시 - -**공지 내용 예시**: -``` -[중요] 브라우저 업그레이드 안내 - -안녕하세요. SAM ERP 시스템 운영팀입니다. - -보안 및 성능 향상을 위해 2024년 XX월 XX일부터 -Internet Explorer 지원을 중단합니다. - -▶ 권장 브라우저 - - Microsoft Edge (Windows 권장) - - Google Chrome - - Safari (macOS/iOS) - -▶ 다운로드 링크 - - Edge: https://www.microsoft.com/edge - - Chrome: https://www.google.com/chrome - -문의사항은 고객센터(02-XXXX-XXXX)로 연락주세요. - -감사합니다. -``` - ---- - -### 2. 배포 시점 - -**IE 사용자 안내**: -1. IE로 접속 시 자동으로 안내 페이지 표시 -2. 권장 브라우저 다운로드 링크 제공 -3. 지원 중단 사유 명확히 안내 - -**고객 지원**: -- 📞 전화 지원: 브라우저 설치 안내 -- 💬 채팅 상담: 실시간 도움 -- 📋 가이드: 브라우저별 설치 매뉴얼 - ---- - -### 3. 배포 후 모니터링 - -**수집 지표**: -```yaml -metrics: - - ie_access_attempts: IE 접근 시도 횟수 - - browser_distribution: 브라우저별 사용 비율 - - support_tickets: 브라우저 관련 문의 건수 - - migration_rate: Edge/Chrome 전환율 -``` - -**모니터링 코드 (선택사항)**: -```typescript -// middleware.ts에 추가 -if (isInternetExplorer(userAgent)) { - // 통계 수집 - await fetch('/api/analytics/browser', { - method: 'POST', - body: JSON.stringify({ - event: 'ie_blocked', - timestamp: new Date(), - path: pathname, - userAgent: userAgent - }) - }); - - return NextResponse.redirect(new URL('/unsupported-browser.html', request.url)); -} -``` - ---- - -## 향후 정책 - -### 1. 브라우저 버전 관리 - -**업데이트 정책**: -- ✅ 최신 브라우저 버전 권장 -- ✅ 최소 지원 버전: 현재 버전 -2 (약 6개월) -- ⚠️ 구버전 사용 시 업데이트 권장 안내 - -**예시**: -``` -현재 Chrome 120 사용 중 -→ Chrome 118 이상 지원 -→ Chrome 117 이하는 업데이트 권장 -``` - ---- - -### 2. 신규 브라우저 지원 검토 - -**평가 기준**: -1. **시장 점유율**: 5% 이상 -2. **웹 표준 준수**: ECMAScript 2020+, CSS3 -3. **보안 업데이트**: 정기적인 패치 제공 -4. **개발자 도구**: 디버깅 환경 제공 - -**현재 지원 검토 대상**: -- ✅ **Firefox**: 지원 검토 중 (시장 점유율 고려) -- ⚠️ **Opera, Vivaldi**: 시장 점유율 낮음 (Chrome 기반이므로 호환 가능) - ---- - -### 3. 모바일 브라우저 정책 - -**모바일 지원**: - -| 플랫폼 | 브라우저 | 지원 여부 | -|--------|---------|----------| -| **iOS** | Safari | ✅ 지원 | -| **iOS** | Chrome | ✅ 지원 (Safari 엔진 사용) | -| **Android** | Chrome | ✅ 지원 | -| **Android** | Samsung Internet | ⚠️ 호환 가능 (Chrome 기반) | - -**참고**: iOS는 WebKit 엔진 강제 정책으로 모든 브라우저가 Safari 엔진 사용 - ---- - -## 크로스 브라우저 개발 원칙 - -### 개발 시 준수 사항 - -#### 1. 브라우저 테스트 필수 -```yaml -feature_development: - - step_1: Chrome에서 개발 및 테스트 - - step_2: Safari에서 호환성 테스트 - - step_3: Edge에서 최종 확인 - - step_4: 모바일 Safari (iOS) 테스트 -``` - -#### 2. Safari 우선 개발 -```typescript -// Safari를 기준으로 개발하면 다른 브라우저에서도 작동 -// Safari가 가장 엄격한 정책을 가지고 있기 때문 - -// ✅ Safari 호환 코드 (모든 브라우저 작동) -const cookie = [ - 'token=xxx', - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), // 환경별 조건부 - 'SameSite=Lax', // Safari 호환 -].join('; '); - -// ❌ Chrome만 작동 (Safari 실패) -const cookie = 'token=xxx; Secure; SameSite=Strict'; // HTTP에서 Safari 거부 -``` - -#### 3. 기능 감지 (Feature Detection) -```typescript -// ✅ 올바른 방법: 기능 감지 -if ('IntersectionObserver' in window) { - // IntersectionObserver 사용 -} - -// ❌ 잘못된 방법: 브라우저 감지 -if (userAgent.includes('Chrome')) { - // Chrome 전용 기능 사용 -} -``` - -#### 4. 폴백 제공 -```typescript -// localStorage 지원 여부 확인 (Safari Private Mode 대응) -try { - localStorage.setItem('test', 'test'); - localStorage.removeItem('test'); -} catch (error) { - // Safari Private Mode: localStorage 제한 - // 대안: sessionStorage 또는 메모리 저장소 사용 -} -``` - ---- - -## 문제 해결 가이드 - -### Q1. IE 사용자가 계속 접속을 시도하는 경우 - -**해결 방법**: -1. 고객센터 연락 유도 -2. Edge 설치 원격 지원 -3. 브라우저 설치 가이드 제공 - -**Edge 설치 가이드**: -``` -1. https://www.microsoft.com/edge 접속 -2. "다운로드" 버튼 클릭 -3. 설치 파일 실행 -4. 설치 완료 후 SAM ERP 재접속 -``` - ---- - -### Q2. 안내 페이지가 표시되지 않는 경우 - -**체크 포인트**: -```bash -# 1. middleware.ts 적용 확인 -npm run build - -# 2. 로그 확인 -# 개발 환경: 터미널에서 "[IE Blocked]" 메시지 확인 -# 프로덕션: 로그 모니터링 시스템 확인 - -# 3. User-Agent 확인 -# Chrome DevTools → Network → 요청 헤더에서 User-Agent 확인 -``` - ---- - -### Q3. 특정 브라우저에서 기능이 작동하지 않는 경우 - -**디버깅 절차**: -```typescript -// 1. 브라우저 콘솔에서 에러 확인 -// Chrome: F12 → Console -// Safari: 개발자 메뉴 활성화 → 웹 검사기 → 콘솔 - -// 2. 브라우저 호환성 확인 -// https://caniuse.com 에서 기능 검색 - -// 3. 폴백 코드 추가 -if (typeof feature === 'undefined') { - // 대체 구현 -} -``` - ---- - -## 관련 문서 - -- [Safari 쿠키 호환성 가이드](./safari-cookie-compatibility.md) -- [사이드바 스크롤 개선](./sidebar-scroll-improvements.md) -- [Next.js 브라우저 지원](https://nextjs.org/docs/architecture/supported-browsers) -- [React 브라우저 지원](https://react.dev/learn/start-a-new-react-project#browser-support) - ---- - -## 업데이트 히스토리 - -| 날짜 | 내용 | 작성자 | -|------|------|--------| -| 2024-XX-XX | 브라우저 지원 정책 문서 작성 및 IE 차단 구현 | Claude | - ---- - -## 요약 - -### ✅ 지원 브라우저 -- **Chrome** (90+) -- **Edge** (90+) -- **Safari** (14+) - -### ❌ 지원하지 않는 브라우저 -- **Internet Explorer** (모든 버전) - -### 🎯 핵심 원칙 -1. **Safari 우선 개발**: 가장 엄격한 정책 기준 -2. **크로스 브라우저 테스트 필수**: Chrome, Safari, Edge -3. **사용자 친화적 안내**: IE 사용자에게 명확한 업그레이드 안내 - -**문의**: 고객센터 또는 개발팀 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/middleware.ts` - IE 감지 및 차단 미들웨어 (isInternetExplorer 함수) -- `public/unsupported-browser.html` - 브라우저 업그레이드 안내 페이지 -- `src/lib/api/auth/token-storage.ts` - Safari 호환 토큰 저장소 - -### 설정 파일 -- `next.config.ts` - Next.js 브라우저 타겟 설정 -- `package.json` - 브라우저 호환 의존성 (next, react 버전) -- `tsconfig.json` - TypeScript 타겟 설정 - -### 참조 문서 -- `claudedocs/auth/[IMPL-2025-11-13] safari-cookie-compatibility.md` - Safari 쿠키 호환성 -- `claudedocs/architecture/[IMPL-2025-11-18] ssr-hydration-fix.md` - SSR/Hydration 에러 해결 \ No newline at end of file diff --git a/claudedocs/architecture/[IMPL-2025-11-18] ssr-hydration-fix.md b/claudedocs/architecture/[IMPL-2025-11-18] ssr-hydration-fix.md deleted file mode 100644 index 428ee35d..00000000 --- a/claudedocs/architecture/[IMPL-2025-11-18] ssr-hydration-fix.md +++ /dev/null @@ -1,106 +0,0 @@ -# SSR Hydration 에러 해결 작업 기록 - -## 문제 상황 - -### 1차 에러: useData is not defined -- **위치**: ItemMasterDataManagement.tsx:389 -- **원인**: 리팩토링 후 `useData()` → `useItemMaster()` 변경 누락 -- **해결**: 함수 호출 변경 - -### 2차 에러: Hydration Mismatch -``` -Hydration failed because the server rendered HTML didn't match the client -``` -- **원인**: Context 파일에서 localStorage를 useState 초기화 시점에 접근 -- **영향**: 서버는 초기값 렌더링, 클라이언트는 localStorage 데이터 렌더링 → HTML 불일치 - -## 근본 원인 분석 - -### ❌ 문제가 되는 패턴 (React SPA) -```typescript -const [data, setData] = useState(() => { - if (typeof window === 'undefined') return initialData; - const saved = localStorage.getItem('key'); - return saved ? JSON.parse(saved) : initialData; -}); -``` - -**문제점**: -- 서버: `typeof window === 'undefined'` → initialData 반환 -- 클라이언트: localStorage 값 반환 -- 결과: 서버/클라이언트 HTML 불일치 → Hydration 에러 - -### ✅ SSR-Safe 패턴 (Next.js) -```typescript -const [data, setData] = useState(initialData); - -useEffect(() => { - try { - const saved = localStorage.getItem('key'); - if (saved) setData(JSON.parse(saved)); - } catch (error) { - console.error('Failed to load data:', error); - localStorage.removeItem('key'); - } -}, []); -``` - -**장점**: -- 서버/클라이언트 모두 동일한 초기값으로 렌더링 -- useEffect는 클라이언트에서만 실행 -- Hydration 후 localStorage 데이터로 업데이트 -- 에러 처리로 손상된 데이터 복구 - -## 수정 내역 - -### AuthContext.tsx -- 2개 state: users, currentUser -- localStorage 로드를 단일 useEffect로 통합 -- 에러 처리 추가 - -### ItemMasterContext.tsx -- 13개 state 전체 SSR-safe 패턴 적용 -- 통합 useEffect로 모든 localStorage 로드 처리 -- 버전 관리 유지: - - specificationMasters: v1.0 - - materialItemNames: v1.1 -- 포괄적 에러 처리 및 손상 데이터 정리 - -## 예상 부작용 및 완화 - -### Flash of Initial Content (FOIC) -- **현상**: 초기값 표시 → localStorage 데이터로 전환 -- **영향**: 매우 짧은 시간 (보통 눈에 띄지 않음) -- **완화**: 필요시 loading state 추가 가능 - -### localStorage 데이터 손상 -- **대응**: try-catch로 감싸고 손상 시 localStorage 클리어 -- **결과**: 기본값으로 재시작하여 앱 정상 동작 유지 - -## 테스트 결과 -- ✅ Hydration 에러 해결 -- ✅ localStorage 정상 로드 -- ✅ 서버/클라이언트 렌더링 일치 -- ✅ 에러 없이 페이지 로드 - -## 향후 고려사항 -- 나머지 8개 Context (Facilities, Accounting, HR, etc.)는 실제 사용 시 동일 패턴 적용 필요 -- 복잡한 초기 데이터가 있는 경우 서버에서 데이터 pre-fetch 고려 -- Critical한 초기 데이터는 서버 컴포넌트에서 직접 전달하는 방식 검토 가능 - -## 참고 문서 -- Next.js SSR/Hydration: https://nextjs.org/docs/messages/react-hydration-error -- React useEffect: https://react.dev/reference/react/useEffect - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/contexts/AuthContext.tsx` - SSR-safe 패턴 적용된 인증 Context -- `src/contexts/ItemMasterContext.tsx` - SSR-safe 패턴 적용된 품목 마스터 Context (13개 state) -- `src/components/items/ItemMasterDataManagement.tsx` - 품목기준관리 컴포넌트 - -### 참조 문서 -- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` - 인증 구현 가이드 -- `claudedocs/architecture/[REF-2025-11-19] multi-tenancy-implementation.md` - 멀티테넌시 구현 (localStorage 패턴) \ No newline at end of file diff --git a/claudedocs/architecture/[IMPL-2026-01-21] input-form-componentization.md b/claudedocs/architecture/[IMPL-2026-01-21] input-form-componentization.md deleted file mode 100644 index 5e0f7593..00000000 --- a/claudedocs/architecture/[IMPL-2026-01-21] input-form-componentization.md +++ /dev/null @@ -1,349 +0,0 @@ -# 입력폼 공통 컴포넌트화 구현 계획서 - -**작성일**: 2026-01-21 -**작성자**: Claude Code -**상태**: ✅ Phase 1-3 VendorDetail 적용 완료 -**최종 수정**: 2026-01-21 - ---- - -## 1. 개요 - -### 1.1 목적 -- 숫자 입력필드의 선행 0(leading zero) 문제 해결 -- 금액/수량 입력 시 천단위 콤마 및 포맷팅 일관성 확보 -- 전화번호, 사업자번호, 주민번호 등 포맷팅이 필요한 입력필드 공통화 -- 소수점 입력이 필요한 필드 지원 (비율, 환율 등) - -### 1.2 현재 문제점 -| 문제 | 현상 | 영향 범위 | -|------|------|----------| -| 숫자 입력 leading zero | `01`, `001` 등 표시 | 전체 숫자 입력 | -| 금액 포맷팅 불일치 | 콤마 처리 제각각 | **147개 파일** | -| 전화번호 포맷팅 없음 | `01012341234` 그대로 표시 | 거래처, 직원 관리 | -| 사업자번호 포맷팅 없음 | `1234567890` 그대로 표시 | 거래처 관리 | -| Number 타입 일관성 | string/number 혼용 | 타입 에러 가능성 | - ---- - -## 2. 구현 우선순위 - -### 🔴 Phase 1: 핵심 숫자 입력 (최우선) -| 순서 | 컴포넌트 | 용도 | 영향 범위 | -|------|---------|------|----------| -| 1 | **NumberInput** | 범용 숫자 입력 (leading zero 해결) | 전체 | -| 2 | **CurrencyInput** | 금액 입력 (₩, 천단위 콤마) | 147개 파일 | -| 3 | **QuantityInput** | 수량 입력 (정수, 최소값 0) | 재고/주문 | - -### 🟠 Phase 2: 포맷팅 입력 (완료) -| 순서 | 컴포넌트 | 용도 | 상태 | -|------|---------|------|------| -| 4 | **PhoneInput** | 전화번호 자동 하이픈 | ✅ 완료 | -| 5 | **BusinessNumberInput** | 사업자번호 포맷팅 | ✅ 완료 | -| 6 | **PersonalNumberInput** | 주민번호 포맷팅/마스킹 | ✅ 완료 | - -### 🟢 Phase 3: 통합 및 확장 -| 순서 | 작업 | 설명 | -|------|------|------| -| 7 | ui/index.ts export | 새 컴포넌트 내보내기 | -| 8 | FormField 확장 | 새 타입 지원 추가 | -| 9 | 실사용 적용 테스트 | VendorDetail 등 | - ---- - -## 3. 생성/수정 파일 목록 - -### 3.1 새로 생성한 파일 - -``` -src/ -├── lib/ -│ └── formatters.ts ✅ 완료 -├── components/ -│ └── ui/ -│ ├── phone-input.tsx ✅ 완료 -│ ├── business-number-input.tsx ✅ 완료 -│ ├── personal-number-input.tsx ✅ 완료 -│ ├── number-input.tsx ✅ 완료 -│ ├── currency-input.tsx ✅ 완료 -│ └── quantity-input.tsx ✅ 완료 -``` - -### 3.2 수정한 파일 - -| 파일 | 수정 내용 | 상태 | -|------|----------|------| -| `src/components/molecules/FormField.tsx` | 새 타입 지원 추가 (phone, businessNumber, personalNumber, currency, quantity) | ✅ 완료 | - ---- - -## 4. 컴포넌트 상세 설계 - -### 4.1 NumberInput (범용 숫자 입력) - -```typescript -interface NumberInputProps { - value: number | string | undefined; - onChange: (value: number | undefined) => void; - - // 포맷 옵션 - allowDecimal?: boolean; // 소수점 허용 (기본: false) - decimalPlaces?: number; // 소수점 자릿수 제한 - allowNegative?: boolean; // 음수 허용 (기본: false) - useComma?: boolean; // 천단위 콤마 (기본: false) - - // 범위 제한 - min?: number; - max?: number; - - // 표시 옵션 - suffix?: string; // 접미사 (원, 개, % 등) - allowEmpty?: boolean; // 빈 값 허용 (기본: true) -} -``` - -**사용 예시**: -```tsx -// 기본 정수 입력 - - -// 소수점 2자리 (비율, 환율) - - -// 퍼센트 입력 (0-100 제한) - - -// 음수 허용 (재고 조정) - -``` - -### 4.2 CurrencyInput (금액 입력) - -```typescript -interface CurrencyInputProps { - value: number | undefined; - onChange: (value: number | undefined) => void; - - currency?: '₩' | '$' | '¥'; // 통화 기호 (기본: ₩) - showCurrency?: boolean; // 통화 기호 표시 (기본: true) - allowNegative?: boolean; // 음수 허용 (기본: false) -} -``` - -**특징**: -- 항상 천단위 콤마 표시 -- 정수만 허용 (원 단위) -- 포커스 해제 시 통화 기호 표시 - -### 4.3 QuantityInput (수량 입력) - -```typescript -interface QuantityInputProps { - value: number | undefined; - onChange: (value: number | undefined) => void; - - min?: number; // 최소값 (기본: 0) - max?: number; // 최대값 - step?: number; // 증감 단위 (기본: 1) - showButtons?: boolean; // +/- 버튼 표시 - suffix?: string; // 단위 (개, EA, 박스 등) -} -``` - -**특징**: -- 정수만 허용 -- 기본 최소값 0 -- 선택적 +/- 버튼 - -### 4.4 PhoneInput ✅ 완료 - -```typescript -interface PhoneInputProps { - value: string; - onChange: (value: string) => void; // 숫자만 반환 - error?: boolean; -} -``` - -### 4.5 BusinessNumberInput ✅ 완료 - -```typescript -interface BusinessNumberInputProps { - value: string; - onChange: (value: string) => void; - showValidation?: boolean; // 유효성 검사 아이콘 - error?: boolean; -} -``` - -### 4.6 PersonalNumberInput ✅ 완료 - -```typescript -interface PersonalNumberInputProps { - value: string; - onChange: (value: string) => void; - maskBack?: boolean; // 뒷자리 마스킹 - error?: boolean; -} -``` - ---- - -## 5. 검수 계획서 - -### 5.1 NumberInput 테스트 - -| 테스트 항목 | 입력 | 기대 결과 | -|------------|------|----------| -| Leading zero 제거 | `01` | 표시: `1`, 값: `1` | -| Leading zero 제거 | `007` | 표시: `7`, 값: `7` | -| 소수점 (허용시) | `3.14` | 표시: `3.14`, 값: `3.14` | -| 소수점 자릿수 제한 | `3.14159` (2자리) | 표시: `3.14`, 값: `3.14` | -| 음수 (허용시) | `-100` | 표시: `-100`, 값: `-100` | -| 콤마 표시 | `1000000` | 표시: `1,000,000`, 값: `1000000` | -| 범위 제한 (max:100) | `150` | 값: `100` (제한) | -| 빈 값 | `` | 값: `undefined` | -| 문자 입력 차단 | `abc` | 입력 안됨 | - -### 5.2 CurrencyInput 테스트 - -| 테스트 항목 | 입력 | 기대 결과 | -|------------|------|----------| -| 기본 입력 | `50000` | 표시: `50,000`, 값: `50000` | -| 통화 기호 | `50000` (blur) | 표시: `₩50,000` | -| 소수점 차단 | `100.5` | 표시: `100`, 값: `100` | -| 대용량 | `1000000000` | 표시: `1,000,000,000` | - -### 5.3 QuantityInput 테스트 - -| 테스트 항목 | 입력 | 기대 결과 | -|------------|------|----------| -| 기본 입력 | `10` | 표시: `10`, 값: `10` | -| 음수 차단 | `-5` | 값: `0` (최소값) | -| 소수점 차단 | `10.5` | 표시: `10`, 값: `10` | -| +/- 버튼 | 클릭 | 1씩 증감 | - -### 5.4 실사용 테스트 페이지 - -| 페이지 | 경로 | 테스트 항목 | -|--------|------|------------| -| 거래처 관리 | `/accounting/vendor-management` | 전화번호, 사업자번호 | -| 직원 관리 | `/hr/employee-management` | 전화번호, 주민번호 | -| 견적 등록 | `/quotes` | 수량, 금액 | -| 주문 관리 | `/sales/order-management-sales` | 수량, 금액 | -| 재고 관리 | `/material/stock-status` | 수량 | - ---- - -## 6. 완료 체크리스트 - -### Phase 1: 유틸리티 및 기본 컴포넌트 -- [x] formatters.ts 유틸리티 함수 생성 -- [x] PhoneInput 컴포넌트 생성 -- [x] BusinessNumberInput 컴포넌트 생성 -- [x] PersonalNumberInput 컴포넌트 생성 -- [x] NumberInput 컴포넌트 생성 -- [x] CurrencyInput 컴포넌트 생성 -- [x] QuantityInput 컴포넌트 생성 - -### Phase 2: 통합 -- [x] ui/index.ts export 추가 (개별 import 방식 사용) -- [x] FormField 타입 확장 - -### Phase 3: 테스트 및 적용 -- [ ] 개별 컴포넌트 동작 테스트 -- [x] VendorDetail 적용 완료 - - [x] PhoneInput: phone, mobile, fax, managerPhone - - [x] BusinessNumberInput: businessNumber (유효성 검사 포함) - - [x] CurrencyInput: outstandingAmount, unpaidAmount - - [x] NumberInput: overdueDays -- [ ] 문서 최종 업데이트 - ---- - -## 7. 롤백 계획 - -문제 발생 시: -1. 새 컴포넌트 import 제거 -2. 기존 `` 컴포넌트로 복원 -3. FormField 타입 변경 롤백 - ---- - -## 8. 참고사항 - -### 기존 컴포넌트 위치 -- Input: `src/components/ui/input.tsx` -- FormField: `src/components/molecules/FormField.tsx` - -### 생성된 파일 -| 파일 | 경로 | -|------|------| -| formatters | `src/lib/formatters.ts` | -| PhoneInput | `src/components/ui/phone-input.tsx` | -| BusinessNumberInput | `src/components/ui/business-number-input.tsx` | -| PersonalNumberInput | `src/components/ui/personal-number-input.tsx` | -| NumberInput | `src/components/ui/number-input.tsx` | -| CurrencyInput | `src/components/ui/currency-input.tsx` | -| QuantityInput | `src/components/ui/quantity-input.tsx` | - ---- - -## 9. 사용 예시 - -### 직접 import 방식 -```tsx -import { PhoneInput } from '@/components/ui/phone-input'; -import { CurrencyInput } from '@/components/ui/currency-input'; -import { NumberInput } from '@/components/ui/number-input'; - -// 전화번호 - - -// 금액 - - -// 소수점 허용 숫자 - -``` - -### FormField 통합 방식 -```tsx -import { FormField } from '@/components/molecules/FormField'; - -// 전화번호 - - -// 사업자번호 (유효성 검사 표시) - - -// 금액 - - -// 수량 (+/- 버튼) - -``` \ No newline at end of file diff --git a/claudedocs/architecture/[IMPL-2026-01-21] phase4-input-migration-rollout.md b/claudedocs/architecture/[IMPL-2026-01-21] phase4-input-migration-rollout.md deleted file mode 100644 index 734b12aa..00000000 --- a/claudedocs/architecture/[IMPL-2026-01-21] phase4-input-migration-rollout.md +++ /dev/null @@ -1,372 +0,0 @@ -# Phase 4: 입력 컴포넌트 전체 적용 계획서 - -**작성일**: 2026-01-21 -**작성자**: Claude Code -**상태**: 🔵 계획 수립 완료 -**근거 문서**: [IMPL-2026-01-21] input-form-componentization.md - ---- - -## 1. 스캔 결과 요약 - -### 1.1 대상 파일 통계 -| 카테고리 | 파일 수 | 비고 | -|----------|--------|------| -| `type="number"` 사용 | 52개 | 직접 Input 사용 | -| 전화번호 관련 | 70개 | phone, tel, 전화, 연락처 | -| 사업자번호 관련 | 33개 | businessNumber, 사업자번호 | -| 금액 관련 | 197개 | price, amount, 금액, 단가 | -| 수량 관련 | 106개 | quantity, qty, 수량 | - -### 1.2 마이그레이션 접근 전략 - -**전략 1: 템플릿 레벨 수정 (최고 효율)** -- `IntegratedDetailTemplate/FieldInput.tsx` 수정 -- `IntegratedDetailTemplate/FieldRenderer.tsx` 수정 -- 이 템플릿을 사용하는 **모든 페이지**에 자동 적용 - -**전략 2: FormField 타입 확장 (이미 완료)** -- `FormField.tsx`에 새 타입 추가 완료 -- FormField를 사용하는 컴포넌트는 타입만 변경하면 됨 - -**전략 3: 개별 컴포넌트 수정** -- 직접 `` 사용하는 컴포넌트 -- 커스텀 로직이 있어 템플릿 적용 불가한 컴포넌트 - ---- - -## 2. 마이그레이션 우선순위 - -### 🔴 Tier 1: 템플릿 레벨 (최우선) -> 한 번 수정으로 다수 페이지에 적용 - -| 파일 | 수정 내용 | 영향 범위 | -|------|----------|----------| -| `IntegratedDetailTemplate/FieldInput.tsx` | number 타입에 NumberInput/CurrencyInput 적용, phone/businessNumber 타입 추가 | 템플릿 사용 전체 | -| `IntegratedDetailTemplate/FieldRenderer.tsx` | 동일 | 템플릿 사용 전체 | -| `IntegratedDetailTemplate/types.ts` | FieldType에 새 타입 추가 | 타입 시스템 | - -### 🟠 Tier 2: 핵심 폼 컴포넌트 -> 사용 빈도가 높거나 중요한 폼 - -**회계 도메인 (accounting/)** -| 파일 | 적용 대상 | 우선순위 | -|------|----------|----------| -| ✅ `VendorDetail.tsx` | phone, businessNumber, currency | 완료 | -| `PurchaseDetail.tsx` | currency (금액) | 높음 | -| `SalesDetail.tsx` | currency (금액) | 높음 | -| `BillDetail.tsx` | currency (금액) | 높음 | -| `DepositDetail.tsx` | currency (금액) | 높음 | -| `WithdrawalDetail.tsx` | currency (금액) | 높음 | -| `BadDebtDetail.tsx` | currency, phone | 높음 | - -**주문/견적 도메인 (orders/, quotes/)** -| 파일 | 적용 대상 | 우선순위 | -|------|----------|----------| -| `OrderRegistration.tsx` | currency, quantity | 높음 | -| `OrderSalesDetailEdit.tsx` | currency, quantity | 높음 | -| `QuoteRegistration.tsx` | currency, quantity, number | 높음 | -| `QuoteRegistrationV2.tsx` | currency, quantity, number | 높음 | -| `LocationDetailPanel.tsx` | currency, quantity | 중간 | -| `LocationListPanel.tsx` | currency, quantity | 중간 | - -**인사 도메인 (hr/)** -| 파일 | 적용 대상 | 우선순위 | -|------|----------|----------| -| `EmployeeForm.tsx` | phone, personalNumber | 높음 | -| `EmployeeDetail.tsx` | phone, personalNumber | 높음 | -| `EmployeeDialog.tsx` | phone | 높음 | -| `SalaryDetailDialog.tsx` | currency | 중간 | -| `VacationRegisterDialog.tsx` | number | 중간 | -| `VacationGrantDialog.tsx` | number | 중간 | - -**고객 도메인 (clients/)** -| 파일 | 적용 대상 | 우선순위 | -|------|----------|----------| -| `ClientDetail.tsx` | phone, businessNumber | 높음 | -| `ClientRegistration.tsx` | phone, businessNumber | 높음 | -| `ClientDetailClientV2.tsx` | phone, businessNumber | 높음 | - -### 🟡 Tier 3: 보조 컴포넌트 -> 중요하지만 사용 빈도 낮음 - -**품목 관리 (items/)** -| 파일 | 적용 대상 | -|------|----------| -| `ItemDetailEdit.tsx` | currency, quantity | -| `ItemDetailView.tsx` | currency, quantity | -| `DynamicItemForm/` | number, currency | -| `BOMSection.tsx` | quantity | -| `ItemAddDialog.tsx` (orders) | quantity, currency | - -**자재/생산 (material/, production/)** -| 파일 | 적용 대상 | -|------|----------| -| `ReceivingDetail.tsx` | quantity | -| `ReceivingProcessDialog.tsx` | quantity | -| `StockStatusDetail.tsx` | quantity | -| `WorkOrderDetail.tsx` | quantity | -| `InspectionDetail.tsx` | quantity | -| `InspectionCreate.tsx` | quantity | - -**건설 도메인 (construction/)** -| 파일 | 적용 대상 | -|------|----------| -| `ContractDetailForm.tsx` | currency | -| `EstimateDetailForm.tsx` | currency, quantity | -| `BiddingDetailForm.tsx` | currency | -| `PartnerForm.tsx` | phone, businessNumber | -| `HandoverReportDetailForm.tsx` | number | -| `PricingDetailClient.tsx` | currency | -| `ProgressBillingItemTable.tsx` | currency, quantity | -| `OrderDetailItemTable.tsx` | currency, quantity | - -### 🟢 Tier 4: 기타 컴포넌트 -> 낮은 우선순위, 점진적 적용 - -**설정 (settings/)** -- `CompanyInfoManagement/` - businessNumber -- `PopupManagement/` - phone -- `AddCompanyDialog.tsx` - businessNumber - -**결재 (approval/)** -- `ExpenseReportForm.tsx` - currency -- `ProposalForm.tsx` - currency - -**문서 컴포넌트 (documents/)** -> 대부분 표시용으로 입력 필드 없음 - 확인 필요 -- `OrderDocumentModal.tsx` -- `TransactionDocument.tsx` -- `ContractDocument.tsx` - ---- - -## 3. 작업 단계별 계획 - -### Phase 4-1: 템플릿 레벨 수정 (핵심) -**목표**: IntegratedDetailTemplate에 새 입력 타입 지원 추가 - -``` -수정 파일: -1. src/components/templates/IntegratedDetailTemplate/types.ts - - FieldType에 'phone' | 'businessNumber' | 'currency' | 'quantity' 추가 - -2. src/components/templates/IntegratedDetailTemplate/FieldInput.tsx - - PhoneInput, BusinessNumberInput, CurrencyInput, QuantityInput import - - switch case에 새 타입 처리 추가 - -3. src/components/templates/IntegratedDetailTemplate/FieldRenderer.tsx - - 동일하게 수정 -``` - -**예상 영향**: 템플릿 사용 페이지 전체 자동 적용 - -### Phase 4-2: 회계 도메인 마이그레이션 -``` -1. PurchaseDetail.tsx → CurrencyInput -2. SalesDetail.tsx → CurrencyInput -3. BillDetail.tsx → CurrencyInput -4. DepositDetail.tsx → CurrencyInput -5. WithdrawalDetail.tsx → CurrencyInput -6. BadDebtDetail.tsx → CurrencyInput, PhoneInput -``` - -### Phase 4-3: 주문/견적 도메인 마이그레이션 -``` -1. OrderRegistration.tsx → CurrencyInput, QuantityInput -2. OrderSalesDetailEdit.tsx → CurrencyInput, QuantityInput -3. QuoteRegistration.tsx → CurrencyInput, QuantityInput, NumberInput -4. QuoteRegistrationV2.tsx → CurrencyInput, QuantityInput, NumberInput -``` - -### Phase 4-4: 인사 도메인 마이그레이션 -``` -1. EmployeeForm.tsx → PhoneInput, PersonalNumberInput -2. EmployeeDetail.tsx → PhoneInput, PersonalNumberInput -3. EmployeeDialog.tsx → PhoneInput -4. SalaryDetailDialog.tsx → CurrencyInput -``` - -### Phase 4-5: 고객/품목/자재 도메인 마이그레이션 -``` -1. ClientDetail.tsx → PhoneInput, BusinessNumberInput -2. ClientRegistration.tsx → PhoneInput, BusinessNumberInput -3. ItemDetailEdit.tsx → CurrencyInput, QuantityInput -4. ReceivingDetail.tsx → QuantityInput -5. StockStatusDetail.tsx → QuantityInput -``` - -### Phase 4-6: 건설/기타 도메인 마이그레이션 -``` -1. ContractDetailForm.tsx → CurrencyInput -2. EstimateDetailForm.tsx → CurrencyInput, QuantityInput -3. PartnerForm.tsx → PhoneInput, BusinessNumberInput -4. 기타 낮은 우선순위 파일들 -``` - ---- - -## 4. 마이그레이션 패턴 - -### 4.1 직접 Input → 새 컴포넌트 변환 - -**Before (기존)**: -```tsx - handleChange('price', e.target.value)} -/> -``` - -**After (CurrencyInput)**: -```tsx -import { CurrencyInput } from '@/components/ui/currency-input'; - - handleChange('price', value ?? 0)} -/> -``` - -**After (PhoneInput)**: -```tsx -import { PhoneInput } from '@/components/ui/phone-input'; - - handleChange('phone', value)} -/> -``` - -### 4.2 FormField 타입 변경 - -**Before**: -```tsx - -``` - -**After**: -```tsx - -``` - -### 4.3 Config 기반 (IntegratedDetailTemplate) - -**Before (config)**: -```tsx -{ - key: 'price', - label: '금액', - type: 'number', -} -``` - -**After (config)**: -```tsx -{ - key: 'price', - label: '금액', - type: 'currency', -} -``` - ---- - -## 5. 검증 계획 - -### 5.1 각 Phase 완료 후 검증 -- [ ] TypeScript 컴파일 오류 없음 -- [ ] 해당 페이지 렌더링 정상 -- [ ] 입력 필드 동작 확인 - - 포맷팅 정상 (콤마, 하이픈 등) - - leading zero 제거 확인 - - 값 저장/불러오기 정상 - -### 5.2 주요 테스트 시나리오 - -| 컴포넌트 | 테스트 입력 | 기대 결과 | -|----------|------------|----------| -| CurrencyInput | `1234567` | 표시: `₩ 1,234,567`, 값: `1234567` | -| PhoneInput | `01012345678` | 표시: `010-1234-5678`, 값: `01012345678` | -| BusinessNumberInput | `1234567890` | 표시: `123-45-67890`, 값: `1234567890` | -| QuantityInput | `007` | 표시: `7`, 값: `7` | -| NumberInput | `00123.45` | 표시: `123.45`, 값: `123.45` | - ---- - -## 6. 롤백 계획 - -문제 발생 시: -1. 해당 파일의 import 변경 롤백 -2. 컴포넌트 사용 부분을 기존 `` 으로 복원 -3. 템플릿 수정의 경우 FieldInput.tsx, FieldRenderer.tsx 롤백 - ---- - -## 7. 진행 상황 체크리스트 - -### Phase 4-1: 템플릿 수정 -- [ ] IntegratedDetailTemplate/types.ts 수정 -- [ ] IntegratedDetailTemplate/FieldInput.tsx 수정 -- [ ] IntegratedDetailTemplate/FieldRenderer.tsx 수정 -- [ ] 템플릿 사용 페이지 동작 확인 - -### Phase 4-2: 회계 도메인 -- [ ] PurchaseDetail.tsx -- [ ] SalesDetail.tsx -- [ ] BillDetail.tsx -- [ ] DepositDetail.tsx -- [ ] WithdrawalDetail.tsx -- [ ] BadDebtDetail.tsx - -### Phase 4-3: 주문/견적 도메인 -- [ ] OrderRegistration.tsx -- [ ] OrderSalesDetailEdit.tsx -- [ ] QuoteRegistration.tsx -- [ ] QuoteRegistrationV2.tsx - -### Phase 4-4: 인사 도메인 -- [ ] EmployeeForm.tsx -- [ ] EmployeeDetail.tsx -- [ ] EmployeeDialog.tsx -- [ ] SalaryDetailDialog.tsx - -### Phase 4-5: 고객/품목/자재 도메인 -- [ ] ClientDetail.tsx -- [ ] ClientRegistration.tsx -- [ ] ItemDetailEdit.tsx -- [ ] ReceivingDetail.tsx -- [ ] StockStatusDetail.tsx - -### Phase 4-6: 건설/기타 도메인 -- [ ] ContractDetailForm.tsx -- [ ] EstimateDetailForm.tsx -- [ ] PartnerForm.tsx -- [ ] 기타 파일들 - ---- - -## 8. 다음 단계 - -1. **즉시**: Phase 4-1 템플릿 레벨 수정 (최대 효과) -2. **순차**: Phase 4-2 ~ 4-6 도메인별 마이그레이션 -3. **최종**: 전체 빌드 및 통합 테스트 - ---- - -**참고**: VendorDetail.tsx 적용 결과 검증 완료됨 (2026-01-21) -- PhoneInput ✅ -- BusinessNumberInput ✅ -- CurrencyInput ✅ -- NumberInput ✅ diff --git a/claudedocs/architecture/[IMPL-2026-02-05] detail-hooks-migration-plan.md b/claudedocs/architecture/[IMPL-2026-02-05] detail-hooks-migration-plan.md deleted file mode 100644 index 79baa12b..00000000 --- a/claudedocs/architecture/[IMPL-2026-02-05] detail-hooks-migration-plan.md +++ /dev/null @@ -1,304 +0,0 @@ -# 상세 페이지 훅 마이그레이션 계획서 - -> 작성일: 2026-02-05 -> 상태: 계획 수립 - ---- - -## 1. 개요 - -### 1.1 목적 -- 상세/등록/수정 페이지의 반복 코드를 공통 훅으로 통합 -- 코드 일관성 확보 및 유지보수성 향상 -- 서비스 런칭 전 기술 부채 최소화 - -### 1.2 생성된 공통 훅 -| 훅 | 위치 | 역할 | -|----|------|------| -| `useDetailPageState` | `src/hooks/useDetailPageState.ts` | 페이지 상태 관리 (mode, id, navigation) | -| `useDetailData` | `src/hooks/useDetailData.ts` | 데이터 로딩 + 로딩/에러 상태 | -| `useCRUDHandlers` | `src/hooks/useCRUDHandlers.ts` | 등록/수정/삭제 + toast/redirect | -| `useDetailPermissions` | `src/hooks/useDetailPermissions.ts` | 권한 체크 | - -### 1.3 테스트 완료 -- [x] `BillDetail.tsx` → `BillDetailV2.tsx` 마이그레이션 성공 -- [x] 조회/수정/등록 모드 정상 작동 확인 -- [x] 유효성 검사 정상 작동 확인 - ---- - -## 2. 마이그레이션 대상 - -### 2.1 전체 현황 -| 구분 | 개수 | 비고 | -|------|------|------| -| IntegratedDetailTemplate 사용 | 47개 | 훅 마이그레이션 대상 | -| 레거시/커스텀 패턴 | 36개 | 별도 검토 (이번 범위 외) | -| **총계** | 83개 | | - -### 2.2 복잡도별 분류 -| 복잡도 | 기준 | 개수 | -|--------|------|------| -| 단순 | < 200줄, useState 3~4개 | 12개 | -| 보통 | 200~500줄, useState 5~7개 | 18개 | -| 복잡 | > 500줄, useState 8~11개 | 17개 | - ---- - -## 3. 도메인별 대상 목록 - -### 3.1 회계관리 (10개) - -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `accounting/BadDebtCollection/BadDebtDetail.tsx` | 966 | 복잡 | ⬜ | -| 2 | `accounting/BillManagement/BillDetail.tsx` | 474 | 보통 | ✅ 완료 | -| 3 | `accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx` | 138 | 단순 | ⬜ | -| 4 | `accounting/DepositManagement/DepositDetailClientV2.tsx` | 143 | 단순 | ⬜ | -| 5 | `accounting/PurchaseManagement/PurchaseDetail.tsx` | 698 | 복잡 | ⬜ | -| 6 | `accounting/SalesManagement/SalesDetail.tsx` | 581 | 복잡 | ⬜ | -| 7 | `accounting/VendorLedger/VendorLedgerDetail.tsx` | 385 | 보통 | ⬜ | -| 8 | `accounting/VendorManagement/VendorDetail.tsx` | 683 | 복잡 | ⬜ | -| 9 | `accounting/VendorManagement/VendorDetailClient.tsx` | 585 | 복잡 | ⬜ | -| 10 | `accounting/WithdrawalManagement/WithdrawalDetail.tsx` | 327 | 보통 | ⬜ | - -### 3.2 건설관리 (13개) - -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `construction/bidding/BiddingDetailForm.tsx` | 544 | 복잡 | ⬜ | -| 2 | `construction/contract/ContractDetailForm.tsx` | 546 | 복잡 | ⬜ | -| 3 | `construction/estimates/EstimateDetailForm.tsx` | 763 | 복잡 | ⬜ | -| 4 | `construction/handover-report/HandoverReportDetailForm.tsx` | 699 | 복잡 | ⬜ | -| 5 | `construction/issue-management/IssueDetailForm.tsx` | 627 | 복잡 | ⬜ | -| 6 | `construction/item-management/ItemDetailClient.tsx` | 486 | 보통 | ⬜ | -| 7 | `construction/labor-management/LaborDetailClientV2.tsx` | 120 | 단순 | ⬜ | -| 8 | `construction/management/ConstructionDetailClient.tsx` | 739 | 복잡 | ⬜ | -| 9 | `construction/order-management/OrderDetailForm.tsx` | 275 | 보통 | ⬜ | -| 10 | `construction/pricing-management/PricingDetailClientV2.tsx` | 134 | 단순 | ⬜ | -| 11 | `construction/progress-billing/ProgressBillingDetailForm.tsx` | 193 | 단순 | ⬜ | -| 12 | `construction/site-management/SiteDetailForm.tsx` | 385 | 보통 | ⬜ | -| 13 | `construction/structure-review/StructureReviewDetailForm.tsx` | 392 | 보통 | ⬜ | - -### 3.3 기타 도메인 (24개) - -#### 고객센터 (3개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `customer-center/EventManagement/EventDetail.tsx` | 101 | 단순 | ⬜ | -| 2 | `customer-center/InquiryManagement/InquiryDetail.tsx` | 357 | 보통 | ⬜ | -| 3 | `customer-center/NoticeManagement/NoticeDetail.tsx` | 101 | 단순 | ⬜ | - -#### 인사관리 (1개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `hr/EmployeeManagement/EmployeeDetail.tsx` | 221 | 단순 | ⬜ | - -#### 자재관리 (2개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `material/ReceivingManagement/ReceivingDetail.tsx` | ~350 | 보통 | ⬜ | -| 2 | `material/StockStatus/StockStatusDetail.tsx` | ~300 | 보통 | ⬜ | - -#### 주문관리 (2개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `orders/OrderSalesDetailEdit.tsx` | 735 | 복잡 | ⬜ | -| 2 | `orders/OrderSalesDetailView.tsx` | 668 | 복잡 | ⬜ | - -#### 출고관리 (2개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `outbound/ShipmentManagement/ShipmentDetail.tsx` | 670 | 복잡 | ⬜ | -| 2 | `outbound/VehicleDispatchManagement/VehicleDispatchDetail.tsx` | 180 | 단순 | ⬜ | - -#### 생산관리 (1개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `production/WorkOrders/WorkOrderDetail.tsx` | 531 | 복잡 | ⬜ | - -#### 품질관리 (1개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `quality/InspectionManagement/InspectionDetail.tsx` | 949 | 복잡 | ⬜ | - -#### 설정 (2개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `settings/PermissionManagement/PermissionDetail.tsx` | 455 | 보통 | ⬜ | -| 2 | `settings/PopupManagement/PopupDetailClientV2.tsx` | 198 | 단순 | ⬜ | - -#### 거래처 (1개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `clients/ClientDetailClientV2.tsx` | 252 | 단순 | ⬜ | - -#### 기타 (9개) -| # | 파일 | 라인 | 복잡도 | 상태 | -|---|------|------|--------|------| -| 1 | `board/BoardManagement/BoardDetail.tsx` | 119 | 단순 | ⬜ | -| 2 | `process-management/ProcessDetail.tsx` | 346 | 보통 | ⬜ | -| 3 | `process-management/StepDetail.tsx` | 143 | 단순 | ⬜ | -| 4 | `settings/AccountManagement/AccountDetail.tsx` | 355 | 보통 | ⬜ | -| 5 | `accounting/DepositManagement/DepositDetail.tsx` | 327 | 보통 | ⬜ | -| 6 | `clients/ClientDetail.tsx` | 253 | 보통 | ⬜ | -| 7 | `construction/labor-management/LaborDetailClient.tsx` | 471 | 보통 | ⬜ | -| 8 | `construction/pricing-management/PricingDetailClient.tsx` | 464 | 보통 | ⬜ | -| 9 | `quotes/LocationDetailPanel.tsx` | 826 | 복잡 | ⬜ | - ---- - -## 4. 작업 방식 - -### 4.1 Git 브랜치 전략 -``` -main - └── feature/detail-hooks-migration - ├── 회계관리 커밋 - ├── 건설관리 커밋 - └── 기타 도메인 커밋 -``` - -### 4.2 파일별 작업 순서 -1. 파일 읽기 및 현재 패턴 파악 -2. `useDetailData` 적용 (데이터 로딩 부분) -3. `useCRUDHandlers` 적용 (CRUD 핸들러 부분) -4. 개별 useState → 통합 formData 객체로 변환 (선택) -5. 기능 테스트 -6. 커밋 - -### 4.3 적용할 변경 패턴 - -#### Before (기존) -```tsx -const [data, setData] = useState(null); -const [isLoading, setIsLoading] = useState(true); -const [error, setError] = useState(null); - -useEffect(() => { - fetchData(id).then(result => { - if (result.success) setData(result.data); - else setError(result.error); - }).finally(() => setIsLoading(false)); -}, [id]); - -const handleSubmit = async () => { - const result = await updateData(id, formData); - if (result.success) { - toast.success('저장되었습니다.'); - router.push('/list'); - } else { - toast.error(result.error); - } -}; -``` - -#### After (신규) -```tsx -const { data, isLoading, error } = useDetailData(id, fetchData); - -const { handleUpdate, isSubmitting } = useCRUDHandlers({ - onUpdate: updateData, - successRedirect: '/list', - successMessages: { update: '저장되었습니다.' }, -}); -``` - ---- - -## 5. 일정 계획 - -| Phase | 대상 | 파일 수 | 예상 기간 | -|-------|------|---------|----------| -| Phase 1 | 회계관리 | 10개 | 1일 | -| Phase 2 | 건설관리 | 13개 | 1.5일 | -| Phase 3 | 기타 도메인 | 24개 | 2일 | -| Phase 4 | 통합 테스트 | - | 1일 | -| **총계** | | **47개** | **약 5~6일** | - ---- - -## 6. 체크리스트 - -### 6.1 사전 준비 -- [x] 공통 훅 4개 생성 완료 -- [x] 테스트 마이그레이션 (BillDetail) 완료 -- [x] 계획서 작성 -- [ ] 브랜치 생성 - -### 6.2 Phase 1: 회계관리 (0/10) -- [ ] BadDebtDetail.tsx -- [x] BillDetail.tsx ✅ -- [ ] CardTransactionDetailClient.tsx -- [ ] DepositDetailClientV2.tsx -- [ ] PurchaseDetail.tsx -- [ ] SalesDetail.tsx -- [ ] VendorLedgerDetail.tsx -- [ ] VendorDetail.tsx -- [ ] VendorDetailClient.tsx -- [ ] WithdrawalDetail.tsx - -### 6.3 Phase 2: 건설관리 (0/13) -- [ ] BiddingDetailForm.tsx -- [ ] ContractDetailForm.tsx -- [ ] EstimateDetailForm.tsx -- [ ] HandoverReportDetailForm.tsx -- [ ] IssueDetailForm.tsx -- [ ] ItemDetailClient.tsx -- [ ] LaborDetailClientV2.tsx -- [ ] ConstructionDetailClient.tsx -- [ ] OrderDetailForm.tsx -- [ ] PricingDetailClientV2.tsx -- [ ] ProgressBillingDetailForm.tsx -- [ ] SiteDetailForm.tsx -- [ ] StructureReviewDetailForm.tsx - -### 6.4 Phase 3: 기타 도메인 (0/24) -- [ ] EventDetail.tsx -- [ ] InquiryDetail.tsx -- [ ] NoticeDetail.tsx -- [ ] EmployeeDetail.tsx -- [ ] ReceivingDetail.tsx -- [ ] StockStatusDetail.tsx -- [ ] OrderSalesDetailEdit.tsx -- [ ] OrderSalesDetailView.tsx -- [ ] ShipmentDetail.tsx -- [ ] VehicleDispatchDetail.tsx -- [ ] WorkOrderDetail.tsx -- [ ] InspectionDetail.tsx -- [ ] PermissionDetail.tsx -- [ ] PopupDetailClientV2.tsx -- [ ] ClientDetailClientV2.tsx -- [ ] BoardDetail.tsx -- [ ] ProcessDetail.tsx -- [ ] StepDetail.tsx -- [ ] AccountDetail.tsx -- [ ] DepositDetail.tsx -- [ ] ClientDetail.tsx -- [ ] LaborDetailClient.tsx -- [ ] PricingDetailClient.tsx -- [ ] LocationDetailPanel.tsx - -### 6.5 완료 후 -- [ ] 전체 기능 테스트 -- [ ] 코드 리뷰 -- [ ] PR 머지 -- [ ] BillDetailV2.tsx 정리 (원본으로 교체) - ---- - -## 7. 위험 요소 및 대응 - -| 위험 | 가능성 | 대응 | -|------|--------|------| -| 기존 기능 손상 | 중 | 파일별 테스트, Git 롤백 준비 | -| 예상보다 복잡한 파일 | 중 | 복잡한 파일은 부분 적용 허용 | -| 타입 에러 | 높 | 래퍼 함수로 타입 호환성 확보 | - ---- - -## 8. 참고 자료 - -- 공통 훅 소스: `src/hooks/index.ts` -- 테스트 케이스: `BillDetailV2.tsx` -- 기존 템플릿: `IntegratedDetailTemplate.tsx` diff --git a/claudedocs/architecture/[IMPL-2026-02-05] formatter-commonization-plan.md b/claudedocs/architecture/[IMPL-2026-02-05] formatter-commonization-plan.md deleted file mode 100644 index 5cb6e846..00000000 --- a/claudedocs/architecture/[IMPL-2026-02-05] formatter-commonization-plan.md +++ /dev/null @@ -1,88 +0,0 @@ -# 금액/날짜 포맷터 공통화 계획 - -> 작성일: 2026-02-05 -> 상태: ✅ 완료 -> 목적: 중복 정의된 formatAmount, formatDate 함수를 공통 유틸로 통합 - ---- - -## 📊 현황 분석 - -### 이미 존재하는 유틸 - -| 파일 | 함수 | 설명 | -|------|------|------| -| `src/utils/formatAmount.ts` | `formatAmount()` | 자동 만원 변환 (1만 이상 → "N만원") | -| | `formatAmountWon()` | 항상 원 단위 ("N원") | -| | `formatAmountManwon()` | 항상 만원 단위 ("N만원") | -| | `formatKoreanAmount()` | 억/만 축약 ("1억 5,000만") | -| | `formatNumber()` | **신규** 단순 천단위 콤마 | -| `src/utils/date.ts` | `getLocalDateString()` | YYYY-MM-DD 반환 | -| | `getTodayString()` | 오늘 날짜 YYYY-MM-DD | -| | `formatDateForInput()` | input용 날짜 변환 | -| | `formatDate()` | **신규** YYYY-MM-DD 표시용 | -| | `formatDateRange()` | **신규** "시작 ~ 종료" 형식 | - ---- - -## 📊 결과 요약 - -### 마이그레이션 완료 파일 - -#### formatAmount → formatNumber (12개 파일) ✅ -| 파일 | 상태 | -|------|------| -| `construction/contract/ContractListClient.tsx` | ✅ 완료 | -| `construction/contract/ContractDetailForm.tsx` | ✅ 완료 | -| `construction/bidding/BiddingListClient.tsx` | ✅ 완료 | -| `construction/bidding/BiddingDetailForm.tsx` | ✅ 완료 | -| `construction/estimates/EstimateListClient.tsx` | ✅ 완료 | -| `construction/estimates/modals/EstimateDocumentContent.tsx` | ✅ 완료 | -| `construction/handover-report/HandoverReportListClient.tsx` | ✅ 완료 | -| `construction/handover-report/HandoverReportDetailForm.tsx` | ✅ 완료 | -| `construction/handover-report/modals/HandoverReportDocumentModal.tsx` | ✅ 완료 | -| `construction/utility-management/UtilityManagementListClient.tsx` | ✅ 완료 | -| `construction/estimates/utils/formatters.ts` | ✅ re-export로 변경 | - -#### formatDate 공통화 (7개 파일) ✅ -| 파일 | 상태 | -|------|------| -| `construction/contract/ContractListClient.tsx` | ✅ 완료 (formatDateRange) | -| `construction/bidding/BiddingListClient.tsx` | ✅ 완료 | -| `construction/handover-report/HandoverReportListClient.tsx` | ✅ 완료 (formatDateRange) | -| `construction/utility-management/UtilityManagementListClient.tsx` | ✅ 완료 | -| `construction/issue-management/IssueManagementListClient.tsx` | ✅ 완료 | -| `construction/structure-review/StructureReviewListClient.tsx` | ✅ 완료 | -| `construction/management/ConstructionDetailClient.tsx` | ✅ 완료 | - -#### 마이그레이션 제외 (한글 형식 유지) -| 파일 | 사유 | -|------|------| -| `handover-report/modals/HandoverReportDocumentModal.tsx` | 한글 형식 ("년 월 일") | -| `order-management/modals/OrderDocumentModal.tsx` | 한글 형식 ("년 월 일") | - ---- - -## 📋 효과 - -| 항목 | Before | After | -|------|--------|-------| -| formatAmount 정의 | 12개 파일 | 1개 파일 (`formatNumber`) | -| formatDate 정의 | 8개 파일 | 1개 파일 | -| 중복 코드 라인 | ~150줄 | 0줄 | -| 포맷 변경 시 수정 | 20개 파일 | 1개 파일 | - ---- - -## ⚠️ 주의사항 - -1. **기존 formatAmount()와 formatNumber() 차이** - - 기존 `formatAmount()`: 자동 만원 변환 (유지됨) - - 신규 `formatNumber()`: 단순 천단위 콤마만 - -2. **한글 날짜 형식은 별도 유지** - - 문서 모달에서 사용하는 "년 월 일" 형식은 로컬 유지 - - 공통 `formatDate()`는 YYYY-MM-DD 형식만 처리 - -3. **backward compatibility** - - `estimates/utils/formatters.ts`는 `formatNumber`를 `formatAmount`로 re-export diff --git a/claudedocs/architecture/[IMPL-2026-02-11] dynamic-field-components.md b/claudedocs/architecture/[IMPL-2026-02-11] dynamic-field-components.md deleted file mode 100644 index 7a54aed4..00000000 --- a/claudedocs/architecture/[IMPL-2026-02-11] dynamic-field-components.md +++ /dev/null @@ -1,343 +0,0 @@ -# 동적 필드 타입 컴포넌트 — 프론트엔드 구현 기획서 - -> 작성일: 2026-02-11 -> 설계 근거: `[DESIGN-2026-02-11] dynamic-field-type-extension.md` -> 상태: ✅ 프론트 구현 완료 — 백엔드 작업 대기 -> 백엔드 스펙: `item-master/[API-REQUEST-2026-02-12] dynamic-field-type-backend-spec.md` - ---- - -## 목적 - -현재 DynamicItemForm의 필드 타입이 6종(textbox, number, dropdown, checkbox, date, textarea)으로 제한되어 있어 제조/공사/유통/물류 등 다양한 산업의 품목관리 요구를 충족하지 못함. - -**이 작업의 목표**: -- 8종의 신규 필드 컴포넌트를 미리 만들어둔다 -- 범용 테이블 섹션(DynamicTableSection)을 만든다 -- 백엔드가 `field_type` + `properties` config를 보내면 자동 렌더링되는 구조를 완성한다 -- 백엔드 작업 전에도 mock props로 독립 테스트 가능하게 한다 - -**API 연동 시 동작 흐름**: -``` -백엔드 DB: field_type = "reference", properties = { "source": "vendors" } - ↓ -API 응답: GET /v1/item-master/pages/{id}/structure - ↓ -프론트: DynamicFieldRenderer → switch("reference") → - ReferenceField가 properties.source 읽어서 /api/proxy/vendors 검색 API 호출 -``` - ---- - -## 컴포넌트 구현 목록 - -### Phase 1: 핵심 컴포넌트 - -| # | 컴포넌트 | field_type | 상태 | 비고 | -|---|---------|-----------|------|------| -| 1-1 | ReferenceField | `reference` | ✅ 완료 | 다른 테이블 검색/선택 | -| 1-2 | MultiSelectField | `multi-select` | ✅ 완료 | 복수 선택 태그 | -| 1-3 | FileField | `file` | ✅ 완료 | 파일/이미지 업로드 | -| 1-4 | DynamicTableSection | (섹션) | ✅ 완료 | 범용 테이블 섹션 | -| 1-5 | TableCellRenderer | (내부) | ✅ 완료 | 테이블 셀 렌더러 | -| 1-6 | reference-sources.ts | (config) | ✅ 완료 | 참조 소스 프리셋 정의 | -| 1-7 | DynamicFieldRenderer 확장 | (수정) | ✅ 완료 | switch문 + 신규 import | -| 1-8 | 타입 정의 확장 | (수정) | ✅ 완료 | ItemFieldType 통합 + config 인터페이스 | - -### Phase 2: 편의 컴포넌트 - -| # | 컴포넌트 | field_type | 상태 | 비고 | -|---|---------|-----------|------|------| -| 2-1 | CurrencyField | `currency` | ✅ 완료 | 통화 금액 (천단위 포맷) | -| 2-2 | UnitValueField | `unit-value` | ✅ 완료 | 값+단위 조합 (100mm) | -| 2-3 | RadioField | `radio` | ✅ 완료 | 라디오 버튼 그룹 | -| 2-4 | section-presets.ts | (config) | ✅ 완료 | 산업별 섹션 프리셋 JSON | - -### Phase 3: 고급 컴포넌트 - -| # | 컴포넌트 | field_type | 상태 | 비고 | -|---|---------|-----------|------|------| -| 3-1 | ToggleField | `toggle` | ✅ 완료 | On/Off 스위치 | -| 3-2 | ComputedField | `computed` | ✅ 완료 | 계산 필드 (읽기전용) | -| 3-3 | 조건부 표시 연산자 확장 | (수정) | ✅ 완료 | in/not_in/greater_than 등 9종 | - ---- - -## 각 컴포넌트 스펙 - -### 1-1. ReferenceField - -**파일**: `DynamicItemForm/fields/ReferenceField.tsx` - -**역할**: 다른 테이블의 데이터를 검색하여 선택 (거래처, 품목, 고객, 현장, 차량 등) - -**UI 구성**: -- 읽기전용 Input + 검색 버튼(돋보기 아이콘) -- 클릭 시 SearchableSelectionModal 열림 -- 선택 후 displayField 값 표시 -- X 버튼으로 선택 해제 - -**properties에서 읽는 값**: -```typescript -interface ReferenceConfig { - source: string; // "vendors" | "items" | "custom" 등 - displayField?: string; // 기본 "name" - valueField?: string; // 기본 "id" - searchFields?: string[]; // 기본 ["name"] - searchApiUrl?: string; // source="custom"일 때 필수 - columns?: Array<{ key: string; label: string; width?: string }>; - displayFormat?: string; // "{code} - {name}" - returnFields?: string[]; // ["id", "code", "name"] -} -``` - -**API 연동 시**: -- `REFERENCE_SOURCES[source]`에서 apiUrl 조회 -- `GET {apiUrl}?search={query}&size=20` 호출 -- 결과를 SearchableSelectionModal에 표시 - -**API 연동 전 (mock)**: -- props로 전달된 options 사용 또는 -- 빈 상태에서 UI/UX만 확인 - ---- - -### 1-2. MultiSelectField - -**파일**: `DynamicItemForm/fields/MultiSelectField.tsx` - -**역할**: 여러 항목을 동시에 선택 (태그 칩 형태로 표시) - -**UI 구성**: -- Combobox (검색 가능한 드롭다운) -- 선택된 항목은 칩(Chip/Badge)으로 표시 -- 칩의 X 버튼으로 개별 해제 - -**properties에서 읽는 값**: -```typescript -interface MultiSelectConfig { - maxSelections?: number; // 최대 선택 수 (기본: 무제한) - allowCustom?: boolean; // 직접 입력 허용 (기본: false) - layout?: 'chips' | 'list'; // 기본: "chips" -} -``` - -**options**: 기존 dropdown과 동일 `[{label, value}]` - -**저장값**: `string[]` (예: `["CUT", "BEND", "WELD"]`) - ---- - -### 1-3. FileField - -**파일**: `DynamicItemForm/fields/FileField.tsx` - -**역할**: 파일/이미지 첨부 - -**UI 구성**: -- 파일 선택 버튼 ("파일 선택" 또는 드래그 앤 드롭 영역) -- 선택된 파일 목록 표시 (이름, 크기, 삭제 버튼) -- 이미지 파일일 경우 미리보기 썸네일 - -**properties에서 읽는 값**: -```typescript -interface FileConfig { - accept?: string; // ".pdf,.doc" (기본: "*") - maxSize?: number; // bytes (기본: 10MB = 10485760) - maxFiles?: number; // 기본: 1 - preview?: boolean; // 이미지 미리보기 (기본: true) - category?: string; // 파일 카테고리 태그 -} -``` - -**API 연동 시**: `POST /v1/files/upload` (multipart) -**API 연동 전**: File 객체를 로컬 상태로 관리, URL.createObjectURL로 미리보기 - ---- - -### 1-4. DynamicTableSection - -**파일**: `DynamicItemForm/sections/DynamicTableSection.tsx` - -**역할**: config 기반 범용 테이블 (공정, 품질검사, 구매처, 공정표, 배차 등) - -**UI 구성**: -- 테이블 헤더 (columns config 기반) -- 행 추가/삭제 버튼 -- 각 셀은 TableCellRenderer (= DynamicFieldRenderer 재사용) -- 요약행 (선택, summaryRow config) -- 빈 상태 메시지 - -**props**: -```typescript -interface DynamicTableSectionProps { - section: ItemSectionResponse; - tableConfig: TableConfig; - rows: Record[]; - onRowsChange: (rows: Record[]) => void; - disabled?: boolean; -} -``` - -**tableConfig 구조**: 설계서 섹션 4.3 참조 - -**API 연동 시**: `GET/PUT /v1/items/{itemId}/section-data/{sectionId}` -**API 연동 전**: rows를 폼 상태(formData)에 로컬 관리 - ---- - -### 1-5. TableCellRenderer - -**파일**: `DynamicItemForm/sections/TableCellRenderer.tsx` - -**역할**: 테이블 셀 = DynamicFieldRenderer를 테이블 셀용 축소 모드로 래핑 - -**핵심**: column config → ItemFieldResponse 호환 객체로 변환 → DynamicFieldRenderer 호출 - -```typescript -function TableCellRenderer({ column, value, onChange, compact }) { - const fieldLike = columnToFieldResponse(column); - return ; -} -``` - ---- - -### 1-6. reference-sources.ts - -**파일**: `DynamicItemForm/config/reference-sources.ts` - -**역할**: reference 필드의 소스별 기본 설정 (API URL, 표시 필드, 검색 컬럼) - -**내용**: 공통(vendors, items, customers, employees, warehouses) + 산업별(processes, sites, vehicles, stores 등) - -**확장 방법**: 새 소스 추가 = 이 파일에 객체 1개 추가 - ---- - -### 2-1. CurrencyField - -**파일**: `DynamicItemForm/fields/CurrencyField.tsx` - -**역할**: 통화 금액 입력 (천단위 콤마, 통화 기호) - -**UI**: Input + 통화기호(₩) prefix + 천단위 포맷 -- 입력 중: 숫자만 -- 포커스 아웃: "₩15,000" 포맷 - -**properties**: `{ currency, precision, showSymbol, allowNegative }` - -**저장값**: `number` (포맷 없이) - ---- - -### 2-2. UnitValueField - -**파일**: `DynamicItemForm/fields/UnitValueField.tsx` - -**역할**: 값 + 단위 조합 입력 (100mm, 50kg) - -**UI**: Input(숫자) + Select(단위) 가로 배치 - -**properties**: `{ units, defaultUnit, precision }` - -**저장값**: `{ value: number, unit: string }` - ---- - -### 2-3. RadioField - -**파일**: `DynamicItemForm/fields/RadioField.tsx` - -**역할**: 라디오 버튼 그룹 - -**UI**: RadioGroup (수평/수직) - -**properties**: `{ layout: "horizontal" | "vertical" }` - -**options**: `[{label, value}]` - ---- - -### 3-1. ToggleField - -**파일**: `DynamicItemForm/fields/ToggleField.tsx` - -**역할**: On/Off 토글 스위치 - -**UI**: Switch + 라벨 - -**properties**: `{ onLabel, offLabel, onValue, offValue }` - ---- - -### 3-2. ComputedField - -**파일**: `DynamicItemForm/fields/ComputedField.tsx` - -**역할**: 다른 필드 기반 자동 계산 (읽기 전용) - -**UI**: 읽기전용 표시 (배경색 구분, muted) - -**properties**: `{ formula, dependsOn, format, precision }` - -**동작**: `dependsOn` 필드 값이 변경될 때마다 formula 재계산 - ---- - -## 파일 구조 - -``` -DynamicItemForm/ -├── fields/ -│ ├── DynamicFieldRenderer.tsx ← switch 확장 -│ ├── TextField.tsx (기존) -│ ├── NumberField.tsx (기존) -│ ├── DropdownField.tsx (기존) -│ ├── CheckboxField.tsx (기존) -│ ├── DateField.tsx (기존) -│ ├── TextareaField.tsx (기존) -│ ├── ReferenceField.tsx ★ Phase 1 -│ ├── MultiSelectField.tsx ★ Phase 1 -│ ├── FileField.tsx ★ Phase 1 -│ ├── CurrencyField.tsx ★ Phase 2 -│ ├── UnitValueField.tsx ★ Phase 2 -│ ├── RadioField.tsx ★ Phase 2 -│ ├── ToggleField.tsx ★ Phase 3 -│ └── ComputedField.tsx ★ Phase 3 -├── sections/ -│ ├── DynamicBOMSection.tsx (기존) -│ ├── DynamicTableSection.tsx ★ Phase 1 -│ └── TableCellRenderer.tsx ★ Phase 1 -├── config/ -│ └── reference-sources.ts ★ Phase 1 -├── presets/ -│ └── section-presets.ts ★ Phase 2 -├── hooks/ (기존, 변경 없음) -├── types.ts ← 타입 확장 -└── index.tsx ← table 섹션 렌더링 추가 -``` - -## 기존 코드 수정 범위 - -| 파일 | 수정 내용 | 줄 수 | -|------|----------|-------| -| `DynamicFieldRenderer.tsx` | switch case 8개 추가 + import | +20줄 | -| `types.ts` | ExtendedFieldType union + config 인터페이스 | +80줄 | -| `index.tsx` | 섹션 렌더링에 `case 'table'` 추가 | +15줄 | -| `item-master-api.ts` | field_type union 확장 | +3줄 | - -**기존 컴포넌트 6개 + BOM + hooks 7개 = 변경 없음** - ---- - -## 상태 범례 - -- ⬜ 대기 -- 🔄 진행 중 -- ✅ 완료 -- ⏸️ 보류 - ---- - -**마지막 업데이트**: 2026-02-12 — Phase 1+2+3 전체 완료 (15/15 항목) diff --git a/claudedocs/architecture/[IMPL-2026-02-23] phase1-item4-error-format.md b/claudedocs/architecture/[IMPL-2026-02-23] phase1-item4-error-format.md deleted file mode 100644 index 50bf72af..00000000 --- a/claudedocs/architecture/[IMPL-2026-02-23] phase1-item4-error-format.md +++ /dev/null @@ -1,112 +0,0 @@ -# Phase 1-4: 에러 메시지 포맷 통합 (`formatApiError` 제거) - -> 난이도: 저 | 영향도: 🟡 API 레이어 정리 | 예상 변경: 1파일 삭제 - ---- - -## 현황 요약 - -에러 메시지 포맷팅 함수가 2곳에 중복: - -| 파일 | 함수 | 외부 사용처 | -|------|------|------------| -| `src/lib/api/error-handler.ts:122` | `getErrorMessage()` | **5+ 파일** (활발히 사용) | -| `src/lib/api/toast-utils.ts:106` | `formatApiError()` | **0건** (dead code) | - -또한 `SHOW_ERROR_CODE` 상수도 양쪽에 중복 정의됨. - ---- - -## 핵심 발견: toast-utils.ts 전체가 dead code - -`from '@/lib/api/toast-utils'` 를 import하는 파일이 **0건**. - -``` -toast-utils.ts 내보내는 함수 전부 미사용: -- toastApiError() → 0 import -- toastSuccess() → 0 import -- toastWarning() → 0 import -- toastInfo() → 0 import -- formatApiError() → 0 import -``` - -현재 프로젝트에서 에러 토스트 표시는 직접 `toast.error(getErrorMessage(err))` 패턴으로 처리 중. - ---- - -## 작업 내역 - -### Step 1: `src/lib/api/toast-utils.ts` 삭제 - -파일 전체가 dead code이므로 삭제. - -### Step 2: (선택) 유용한 헬퍼를 error-handler.ts로 이동 - -`toastApiError()` 함수는 validation 에러의 첫 번째 필드를 표시하는 로직이 있어, -향후 유용할 수 있으면 error-handler.ts 하단에 통합 가능. - -```typescript -// src/lib/api/error-handler.ts 하단에 추가 (선택) -import { toast } from 'sonner'; - -export function toastApiError(error: unknown, fallbackMessage = '오류가 발생했습니다.'): void { - if (error instanceof ApiError && error.errors && SHOW_ERROR_CODE) { - const firstField = Object.keys(error.errors)[0]; - if (firstField) { - toast.error(`${getErrorMessage(error)}\n${firstField}: ${error.errors[firstField][0]}`); - return; - } - } - toast.error(getErrorMessage(error) || fallbackMessage); -} -``` - -이 step은 **선택**. 현재 사용처가 없으므로 당장은 삭제만으로 충분. - -### Step 3: 검증 - -```bash -npx tsc --noEmit -``` - -toast-utils.ts를 삭제해도 외부 import가 없으므로 타입 에러 없음. - ---- - -## 관련 파일 참조 - -### 활발히 사용 중인 함수 (변경 없음) - -`getErrorMessage()` 사용처 (error-handler.ts에서 export): -- `src/contexts/ItemMasterContext.tsx` (line 7, 589, 682) -- `src/components/items/ItemMasterDataManagement/hooks/usePageManagement.ts` (line 7, 122, 159, 198, 219) -- `src/components/items/ItemMasterDataManagement/hooks/useImportManagement.ts` (line 5, 58, 80, 92) -- `src/components/items/ItemMasterDataManagement/hooks/useInitialDataLoading.ts` (line 7, 130) -- `src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx` (line 40, 301, 347) - -### 삭제 대상 - -- `src/lib/api/toast-utils.ts` (전체 116줄) - ---- - -## 중복 구조 비교 - -``` -error-handler.ts toast-utils.ts (삭제 대상) -───────────────── ────────────────────────── -const SHOW_ERROR_CODE = true; const SHOW_ERROR_CODE = true; ← 중복 - -getErrorMessage(error): formatApiError(error): - DuplicateCodeError → [status] ApiError → [status] msg - ApiError → [status] msg else → getErrorMessage() ← 결국 위임 - Error → .message - unknown → 기본 메시지 - toastApiError(error): - DuplicateCodeError → toast ← getErrorMessage와 동일 로직 - ApiError → toast - Error → toast - unknown → toast -``` - -`formatApiError`는 결국 `getErrorMessage`를 호출하는 래퍼에 불과. 삭제해도 기능 손실 없음. diff --git a/claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md b/claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md deleted file mode 100644 index cbc53aca..00000000 --- a/claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md +++ /dev/null @@ -1,229 +0,0 @@ -# Phase 1-5: Zustand 셀렉터 훅 추가 (3개 스토어) - -> 난이도: 저 | 영향도: 🟡 리렌더 최적화 | 예상 변경: 3 스토어 + 4 컨슈머 - ---- - -## 현황 요약 - -셀렉터 없이 전체 스토어를 구독하면, 무관한 상태 변경에도 컴포넌트가 리렌더됩니다. - -| 스토어 | 셀렉터 훅 | 사용처 | 문제 | -|--------|----------|--------|------| -| ✅ `masterDataStore` | `usePageConfig()` 등 | 다수 | 양호 | -| ✅ `authStore` | `useCurrentUser()` 등 | 4곳 | 양호 (방금 추가) | -| ❌ `useTableColumnStore` | 없음 | 1곳 | 전체 스토어 구독 | -| ❌ `useMenuStore` | 없음 | 15곳 | 일부 전체 구독 | -| ❌ `useThemeStore` | 없음 | 2곳 | 전체 구독 | - ---- - -## 작업 내역 - -### Step 1: `src/stores/useTableColumnStore.ts` — 셀렉터 훅 추가 - -파일 끝에 추가: - -```typescript -// ===== 셀렉터 훅 ===== - -/** 특정 페이지의 컬럼 설정만 구독 */ -export const usePageColumnSettings = (pageId: string) => - useTableColumnStore((state) => state.pageSettings[pageId] ?? DEFAULT_PAGE_SETTINGS); - -/** 특정 페이지의 숨김 컬럼만 구독 */ -export const useHiddenColumns = (pageId: string) => - useTableColumnStore((state) => state.pageSettings[pageId]?.hiddenColumns ?? []); - -/** 특정 페이지의 컬럼 너비만 구독 */ -export const useColumnWidths = (pageId: string) => - useTableColumnStore((state) => state.pageSettings[pageId]?.columnWidths ?? {}); -``` - -**주의**: `DEFAULT_PAGE_SETTINGS` 객체는 파일 내에 이미 정의되어 있음 (line 30-33). - -**컨슈머 변경** — `src/hooks/useColumnSettings.ts`: - -```typescript -// Before (line 17) -const store = useTableColumnStore(); // 전체 스토어 구독 -const settings = store.getPageSettings(pageId); - -// After -const settings = usePageColumnSettings(pageId); // 해당 페이지 설정만 구독 -const { setColumnWidth: storeSetWidth, toggleColumnVisibility: storeToggle, resetPageSettings } = useTableColumnStore.getState(); -// 또는 액션만 별도 구독 (액션은 참조 안정적이라 리렌더 유발 안 함): -const setColumnWidth = useTableColumnStore((s) => s.setColumnWidth); -const toggleColumnVisibility = useTableColumnStore((s) => s.toggleColumnVisibility); -const resetPageSettings = useTableColumnStore((s) => s.resetPageSettings); -``` - ---- - -### Step 2: `src/stores/menuStore.ts` — 셀렉터 훅 추가 - -파일 끝에 추가: - -```typescript -// ===== 셀렉터 훅 ===== - -/** 사이드바 접힘 상태만 구독 */ -export const useSidebarCollapsed = () => - useMenuStore((state) => state.sidebarCollapsed); - -/** 활성 메뉴 ID만 구독 */ -export const useActiveMenu = () => - useMenuStore((state) => state.activeMenu); - -/** 메뉴 아이템 목록만 구독 */ -export const useMenuItems = () => - useMenuStore((state) => state.menuItems); - -/** 하이드레이션 완료 여부만 구독 */ -export const useMenuHydrated = () => - useMenuStore((state) => state._hasHydrated); -``` - -**컨슈머 변경 대상**: - -#### 2-A. `src/layouts/AuthenticatedLayout.tsx` (line 99) — 🔴 핵심 - -현재: 전체 스토어 디스트럭처링 -```typescript -const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore(); -``` - -변경: -```typescript -const menuItems = useMenuItems(); -const activeMenu = useActiveMenu(); -const sidebarCollapsed = useSidebarCollapsed(); -const _hasHydrated = useMenuHydrated(); -// 액션은 참조 안정적이므로 별도 셀렉터: -const setActiveMenu = useMenuStore((s) => s.setActiveMenu); -const setMenuItems = useMenuStore((s) => s.setMenuItems); -const toggleSidebar = useMenuStore((s) => s.toggleSidebar); -``` - -#### 2-B. `src/components/production/WorkerScreen/index.tsx` (line 327) - -현재: -```typescript -const { sidebarCollapsed } = useMenuStore(); // 전체 구독 -``` - -변경: -```typescript -const sidebarCollapsed = useSidebarCollapsed(); -``` - -#### 2-C. `src/components/layout/CommandMenuSearch.tsx` (line 68) - -현재: -```typescript -const { menuItems } = useMenuStore(); // 전체 구독 -``` - -변경: -```typescript -const menuItems = useMenuItems(); -``` - -#### 2-D. 나머지 sidebarCollapsed 사용 파일 (이미 셀렉터 패턴) - -아래 파일들은 이미 `useMenuStore((state) => state.sidebarCollapsed)` 패턴을 사용 중이므로 **변경 불필요**: -- `ItemDetail.tsx`, `ChecklistDetail.tsx`, `PriceDistributionDetail.tsx` -- `StepDetail.tsx`, `PermissionDetailClient.tsx`, `BoardDetail/index.tsx` -- `ProcessDetail.tsx`, `PricingTableForm.tsx`, `DynamicItemForm/index.tsx` -- `ItemDetailClient.tsx`, `ClientDetail.tsx`, `DetailActions.tsx` - -단, 셀렉터 훅이 추가되면 이 파일들도 향후 `useSidebarCollapsed()`로 전환 가능 (선택). - ---- - -### Step 3: `src/stores/themeStore.ts` — 셀렉터 훅 추가 - -파일 끝에 추가: - -```typescript -// ===== 셀렉터 훅 ===== - -/** 현재 테마만 구독 */ -export const useTheme = () => - useThemeStore((state) => state.theme); - -/** setTheme 액션만 구독 */ -export const useSetTheme = () => - useThemeStore((state) => state.setTheme); -``` - -**컨슈머 변경 대상**: - -#### 3-A. `src/layouts/AuthenticatedLayout.tsx` (line 100) - -현재: -```typescript -const { theme, setTheme } = useThemeStore(); -``` - -변경: -```typescript -const theme = useTheme(); -const setTheme = useSetTheme(); -``` - -#### 3-B. `src/components/ThemeSelect.tsx` (line 24) - -현재: -```typescript -const { theme, setTheme } = useThemeStore(); -``` - -변경: -```typescript -const theme = useTheme(); -const setTheme = useSetTheme(); -``` - ---- - -## 검증 - -```bash -npx tsc --noEmit -``` - -셀렉터 훅은 기존 API에 추가만 하는 것이므로 기존 코드에 영향 없음. -컨슈머 변경은 import 경로와 호출 패턴만 바뀌므로 타입 에러 가능성 낮음. - ---- - -## 변경 파일 총 정리 - -| # | 파일 | 작업 | 내용 | -|---|------|------|------| -| 1 | `src/stores/useTableColumnStore.ts` | 추가 | 셀렉터 훅 3개 (`usePageColumnSettings`, `useHiddenColumns`, `useColumnWidths`) | -| 2 | `src/stores/menuStore.ts` | 추가 | 셀렉터 훅 4개 (`useSidebarCollapsed`, `useActiveMenu`, `useMenuItems`, `useMenuHydrated`) | -| 3 | `src/stores/themeStore.ts` | 추가 | 셀렉터 훅 2개 (`useTheme`, `useSetTheme`) | -| 4 | `src/hooks/useColumnSettings.ts` | 수정 | `useTableColumnStore()` → 셀렉터 패턴 | -| 5 | `src/layouts/AuthenticatedLayout.tsx` | 수정 | menuStore/themeStore 전체 구독 → 셀렉터 | -| 6 | `src/components/production/WorkerScreen/index.tsx` | 수정 | `useMenuStore()` → `useSidebarCollapsed()` | -| 7 | `src/components/layout/CommandMenuSearch.tsx` | 수정 | `useMenuStore()` → `useMenuItems()` | -| 8 | `src/components/ThemeSelect.tsx` | 수정 | `useThemeStore()` → `useTheme()` + `useSetTheme()` | - ---- - -## 참고: Zustand 셀렉터가 중요한 이유 - -``` -// ❌ 전체 구독 — menuItems 변경 시 sidebarCollapsed만 쓰는 컴포넌트도 리렌더 -const { sidebarCollapsed } = useMenuStore(); - -// ✅ 셀렉터 — sidebarCollapsed 변경 시에만 리렌더 -const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); -// 또는 -const sidebarCollapsed = useSidebarCollapsed(); -``` - -Zustand는 `Object.is`로 반환값을 비교. 셀렉터가 원시값(string, boolean, number)을 반환하면 참조 비교로 정확히 변경 감지. -객체를 반환하는 셀렉터(예: `usePageColumnSettings`)는 같은 참조를 반환하므로 해당 pageId의 설정이 변경될 때만 리렌더. diff --git a/claudedocs/architecture/[IMPL-2026-03-06] lazy-snapshot-system.md b/claudedocs/architecture/[IMPL-2026-03-06] lazy-snapshot-system.md deleted file mode 100644 index e792e398..00000000 --- a/claudedocs/architecture/[IMPL-2026-03-06] lazy-snapshot-system.md +++ /dev/null @@ -1,103 +0,0 @@ -# 문서스냅샷 시스템 (Lazy Snapshot) - -> **작업일**: 2026-03-06 ~ 03-07 -> **상태**: ✅ 완료 -> **커밋**: 31f523c8, a1fb0d4f, 8250eaf2, 72a2a3e9, 04f2a8a7 - ---- - -## 개요 - -문서 저장/조회 시 `rendered_html` 스냅샷을 자동 캡처하여 백엔드에 전송하는 시스템. -MNG 측에서 문서 인쇄 시 스냅샷 기반 렌더링에 활용. - ---- - -## 아키텍처 - -``` -[문서 저장 시] - 컴포넌트 → contentWrapperRef.innerHTML 캡처 - → API 요청에 rendered_html 파라미터 포함 → 백엔드 저장 - -[문서 조회 시 — Lazy Snapshot] - rendered_html === NULL 감지 - → 500ms 대기 (렌더링 완료 대기) - → innerHTML 캡처 - → 백그라운드 PATCH 전송 (비차단) -``` - ---- - -## 1. 수동 캡처 (저장 시) - -문서 저장 시 DOM에서 `innerHTML`을 읽어 `rendered_html` 파라미터로 함께 전송. - -- [x] 검사성적서 (InspectionReportModal) — `contentWrapperRef.innerHTML` -- [x] 작업일지 (WorkLogModal) — `contentWrapperRef.innerHTML` -- [x] 수입검사 (ImportInspectionInputModal) — 오프스크린 렌더링 방식 - -### 주요 파일 -- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx` -- `src/components/production/WorkerScreen/WorkLogModal.tsx` -- `src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx` - ---- - -## 2. Lazy Snapshot (조회 시 자동 캡처) - -`rendered_html`이 NULL인 기존 문서를 조회할 때 자동으로 스냅샷을 캡처하여 백그라운드 저장. - -### 동작 흐름 -1. 문서 조회 API 응답에서 `snapshot_document_id` 확인 -2. `rendered_html === NULL` → Lazy Snapshot 트리거 -3. 500ms 지연 (콘텐츠 렌더링 완료 대기) -4. `contentWrapperRef.innerHTML` 캡처 -5. `patchDocumentSnapshot()` 서버 액션으로 백그라운드 PATCH - -### 특성 -- **비차단(non-blocking)**: UI에 영향 없이 백그라운드 처리 -- **1회성**: 스냅샷 저장 후 재조회 시 캡처하지 않음 -- **readOnly 자동 캡처 제거**: 불필요한 PUT 요청 방지 - -### 적용 대상 -| 문서 | 수동 캡처 | Lazy Snapshot | -|------|-----------|---------------| -| 검사성적서 | ✅ | ✅ | -| 작업일지 | ✅ | ✅ | -| 수입검사 | ✅ (오프스크린) | — | -| 제품검사 요청서 | ✅ | ✅ | - ---- - -## 3. 오프스크린 렌더링 유틸리티 - -폼 HTML이 아닌 실제 문서 렌더링 결과를 캡처하기 위한 유틸리티. - -```typescript -// src/lib/utils/capture-rendered-html.tsx -// 오프스크린 DOM에 문서 컴포넌트를 렌더링하여 innerHTML 추출 -``` - -- [x] 수입검사 모달에서 활용 (폼 캡처 → 문서 캡처 전환) -- [x] DocumentViewer 스냅샷 렌더링 지원 - -### 주요 파일 -- `src/lib/utils/capture-rendered-html.tsx` (신규) -- `src/components/document-system/viewer/DocumentViewer.tsx` - ---- - -## 4. 서버 액션 - -```typescript -// patchDocumentSnapshot — 백그라운드 PATCH -export async function patchDocumentSnapshot( - documentId: string, - rendered_html: string -): Promise<{ success: boolean }>; -``` - -### 주요 파일 -- `src/components/production/WorkOrders/actions.ts` — `patchDocumentSnapshot` -- `src/components/quality/InspectionManagement/fqcActions.ts` — `patchDocumentSnapshot` diff --git a/claudedocs/architecture/[IMPL] IntegratedDetailTemplate-checklist.md b/claudedocs/architecture/[IMPL] IntegratedDetailTemplate-checklist.md deleted file mode 100644 index eefc5227..00000000 --- a/claudedocs/architecture/[IMPL] IntegratedDetailTemplate-checklist.md +++ /dev/null @@ -1,254 +0,0 @@ -# IntegratedDetailTemplate 마이그레이션 체크리스트 - -> 최종 수정: 2026-01-21 -> 브랜치: `feature/universal-detail-component` - ---- - -## 📊 전체 진행 현황 - -| 단계 | 내용 | 상태 | 대상 | -|------|------|------|------| -| **Phase 1-5** | V2 URL 패턴 통합 | ✅ 완료 | 37개 | -| **Phase 6** | 폼 템플릿 공통화 | ✅ 완료 | 41개 | - -### 통계 요약 - -| 구분 | 개수 | -|------|------| -| ✅ V2 URL 패턴 완료 | 37개 | -| ✅ IntegratedDetailTemplate 적용 완료 | 41개 | -| ❌ 제외 (특수 레이아웃) | 10개 | -| ⚪ 불필요 (View only 등) | 8개 | - ---- - -## 📌 V2 URL 패턴이란? - -``` -기존: /[id] (조회) + /[id]/edit (수정) → 별도 페이지 -V2: /[id]?mode=view (조회) + /[id]?mode=edit (수정) → 단일 페이지 -``` - -**핵심**: `searchParams.get('mode')` 로 view/edit 분기 - ---- - -## 🎯 마이그레이션 목표 - -- **타이틀/버튼 영역** (목록, 상세, 취소, 수정) 공통화 -- **반응형 입력 필드** 통합 -- **특수 기능** (테이블, 모달, 문서 미리보기 등)은 `renderView`/`renderForm`으로 유지 -- **한 파일 수정으로 전체 페이지 일괄 적용** 가능 - ---- - -## 🔧 마이그레이션 패턴 가이드 - -### Pattern 1: Config 기반 템플릿 - -```typescript -// 1. config 파일 생성 -export const xxxConfig: DetailConfig = { - title: '페이지 타이틀', - description: '설명', - icon: IconComponent, - basePath: '/path/to/list', - fields: [], // renderView/renderForm 사용 시 빈 배열 - gridColumns: 2, - actions: { - showBack: true, - showDelete: true, - showEdit: true, - showSave: true, // false로 설정하면 기본 저장 버튼 숨김 - submitLabel: '저장', - cancelLabel: '취소', - }, -}; - -// 2. 컴포넌트에서 IntegratedDetailTemplate 사용 - - onDelete={handleDelete} // Promise<{ success: boolean; error?: string }> - headerActions={customHeaderActions} // 커스텀 버튼 - renderView={() => renderContent()} - renderForm={() => renderContent()} -/> -``` - -### Pattern 2: View/Edit 컴포넌트 분리 - -```tsx -// View와 Edit가 완전히 다른 구현인 경우 -const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; - -if (mode === 'edit') { - return ; -} -return ; -``` - -### Pattern 3: 커스텀 버튼이 필요한 경우 - -```tsx -// config에서 showSave: false 설정 -// headerActions prop으로 커스텀 버튼 전달 - - - - - } -/> -``` - ---- - -## ✅ Phase 6 적용 완료 (41개) - -| No | 카테고리 | 컴포넌트 | 파일 | 특이사항 | -|----|---------|---------|------|----------| -| 1 | 건설 | 협력업체 | PartnerForm.tsx | - | -| 2 | 건설 | 시공관리 | ConstructionDetailClient.tsx | - | -| 3 | 건설 | 기성관리 | ProgressBillingDetailForm.tsx | - | -| 4 | 건설 | 발주관리 | OrderDetailForm.tsx | - | -| 5 | 건설 | 계약관리 | ContractDetailForm.tsx | - | -| 6 | 건설 | 인수인계보고서 | HandoverReportDetailForm.tsx | - | -| 7 | 건설 | 견적관리 | EstimateDetailForm.tsx | - | -| 8 | 건설 | 현장브리핑 | SiteBriefingForm.tsx | - | -| 9 | 건설 | 이슈관리 | IssueDetailForm.tsx | - | -| 10 | 건설 | 입찰관리 | BiddingDetailForm.tsx | - | -| 11 | 건설 | 구조검토 | StructureReviewDetailForm.tsx | view/edit/new 모드, 파일 드래그앤드롭 | -| 12 | 건설 | 현장관리 | SiteDetailForm.tsx | 다음 우편번호 API, 파일 드래그앤드롭 | -| 13 | 건설 | 품목관리 | ItemDetailClient.tsx | view/edit/new 모드, 동적 발주 항목 리스트 | -| 14 | 영업 | 견적관리(V2) | QuoteRegistrationV2.tsx | hideHeader prop, 자동견적/푸터바 유지 | -| 15 | 영업 | 고객관리(V2) | ClientDetailClientV2.tsx | - | -| 16 | 영업 | 수주관리 | OrderSalesDetailView/Edit.tsx | 문서 모달, 상태별 버튼, 확정/취소 다이얼로그 | -| 17 | 회계 | 청구관리 | BillDetail.tsx | - | -| 18 | 회계 | 매입관리 | PurchaseDetail.tsx | - | -| 19 | 회계 | 매출관리 | SalesDetail.tsx | - | -| 20 | 회계 | 거래처관리 | VendorDetail.tsx | - | -| 21 | 회계 | 입금관리(V2) | DepositDetailClientV2.tsx | - | -| 22 | 회계 | 출금관리(V2) | WithdrawalDetailClientV2.tsx | - | -| 23 | 회계 | 악성채권 | BadDebtDetail.tsx | 저장 확인 다이얼로그, 파일 업로드/다운로드 | -| 24 | 회계 | 거래처원장 | VendorLedgerDetail.tsx | 기간선택, PDF 다운로드, 판매/수금 테이블 | -| 25 | 생산 | 작업지시 | WorkOrderDetail.tsx | 상태변경버튼, 작업일지 모달 유지 | -| 26 | 품질 | 검수관리 | InspectionDetail.tsx | 성적서 버튼 | -| 27 | 출고 | 출하관리 | ShipmentDetail.tsx | 문서 미리보기 모달, 조건부 수정/삭제 | -| 28 | 자재 | 입고관리 | ReceivingDetail.tsx | 입고증/입고처리/성공 다이얼로그, 상태별 버튼 | -| 29 | 자재 | 재고현황 | StockStatusDetail.tsx | LOT별 상세 재고 테이블, FIFO 권장 메시지 | -| 30 | 기준정보 | 단가관리(V2) | PricingDetailClientV2.tsx | - | -| 31 | 기준정보 | 노무관리(V2) | LaborDetailClientV2.tsx | - | -| 32 | 설정 | 팝업관리(V2) | PopupDetailClientV2.tsx | - | -| 33 | 설정 | 계정관리 | accounts/[id]/page.tsx | - | -| 34 | 설정 | 공정관리 | process-management/[id]/page.tsx | - | -| 35 | 설정 | 게시판관리 | board-management/[id]/page.tsx | - | -| 36 | 설정 | 권한관리 | PermissionDetail.tsx | 인라인 수정, 메뉴별 권한 테이블, 자동 저장 | -| 37 | 인사 | 명함관리 | card-management/[id]/page.tsx | - | -| 38 | 인사 | 직원관리 | EmployeeDetail.tsx | 기본정보/인사정보/사용자정보 카드 | -| 39 | 고객센터 | 문의관리 | InquiryDetail.tsx | 댓글 CRUD, 작성자/상태별 버튼 표시 | -| 40 | 고객센터 | 이벤트관리 | EventDetail.tsx | view 모드만 | -| 41 | 고객센터 | 공지관리 | NoticeDetail.tsx | view 모드만, 이미지/첨부파일 | - ---- - -## 📋 등록/수정 페이지 마이그레이션 (Phase 1-8) - -### Phase 1 - 기안함 -- [x] DocumentCreate (기안함 등록/수정) - - 파일: `src/components/approval/DocumentCreate/index.tsx` - - 특이사항: 커스텀 headerActions (미리보기, 삭제, 상신, 임시저장) - -### Phase 2 - 생산관리 -- [x] WorkOrderCreate/Edit (작업지시 등록/수정) - - 파일: `src/components/production/WorkOrders/WorkOrderCreate.tsx` - -### Phase 3 - 출고관리 -- [x] ShipmentCreate/Edit (출하 등록/수정) - - 파일: `src/components/outbound/ShipmentManagement/ShipmentCreate.tsx` - -### Phase 4 - HR -- [x] EmployeeForm (사원 등록/수정/상세) - - 파일: `src/components/hr/EmployeeManagement/EmployeeForm.tsx` - - 특이사항: "항목 설정" 버튼, 복잡한 섹션 구조 - -### Phase 5 - 게시판 -- [x] BoardForm (게시판 글쓰기/수정) - - 파일: `src/components/board/BoardForm/index.tsx` - -### Phase 6 - 고객센터 -- [x] InquiryForm (문의 등록/수정) - - 파일: `src/components/customer-center/InquiryManagement/InquiryForm.tsx` - -### Phase 7 - 기준정보 -- [x] ProcessForm (공정 등록/수정) - - 파일: `src/components/process-management/ProcessForm.tsx` - -### Phase 8 - 자재/품질 -- [x] InspectionCreate - 자재 (수입검사 등록) -- [x] InspectionCreate - 품질 (품질검사 등록) - ---- - -## ❌ 마이그레이션 제외 (특수 레이아웃) - -| 페이지 | 경로 | 사유 | -|--------|------|------| -| CEO 대시보드 | - | 대시보드 (특수 레이아웃) | -| 생산 대시보드 | - | 대시보드 (특수 레이아웃) | -| 작업자 화면 | - | 특수 UI | -| 설정 페이지들 | - | 트리 구조, 특수 레이아웃 | -| 부서 관리 | - | 트리 구조 | -| 일일보고서 | - | 특수 레이아웃 | -| 미수금현황 | - | 특수 레이아웃 | -| 종합분석 | - | 특수 레이아웃 | -| 현장종합현황 | `/construction/project/management/[id]` | 칸반 보드 | -| 권한관리 | `/settings/permissions/[id]` | Matrix UI | - ---- - -## 📚 Config 파일 위치 참조 - -| 컴포넌트 | Config 파일 | -|---------|------------| -| 출하관리 | shipmentConfig.ts | -| 작업지시 | workOrderConfig.ts | -| 검수관리 | inspectionConfig.ts | -| 견적관리(V2) | quoteConfig.ts | -| 수주관리 | orderSalesConfig.ts | -| 입고관리 | receivingConfig.ts | -| 재고현황 | stockStatusConfig.ts | -| 악성채권 | badDebtConfig.ts | -| 거래처원장 | vendorLedgerConfig.ts | -| 구조검토 | structureReviewConfig.ts | -| 현장관리 | siteConfig.ts | -| 품목관리 | itemConfig.ts | -| 문의관리 | inquiryConfig.ts | -| 이벤트관리 | eventConfig.ts | -| 공지관리 | noticeConfig.ts | -| 직원관리 | employeeConfig.ts | -| 권한관리 | permissionConfig.ts | - ---- - -## 📝 변경 이력 - -
-전체 변경 이력 보기 - -| 날짜 | 내용 | -|------|------| -| 2026-01-17 | 체크리스트 초기 작성 | -| 2026-01-19 | Phase 1-5 V2 URL 패턴 마이그레이션 완료 (37개) | -| 2026-01-20 | Phase 6 폼 템플릿 공통화 마이그레이션 완료 (41개) | -| 2026-01-20 | 기안함, 작업지시, 출하, 사원, 게시판, 문의, 공정, 검사 마이그레이션 완료 | -| 2026-01-21 | 문서 통합 (중복 3개 파일 → 1개) | - -
diff --git a/claudedocs/architecture/[PLAN-2025-02-10] frontend-improvement-roadmap.md b/claudedocs/architecture/[PLAN-2025-02-10] frontend-improvement-roadmap.md deleted file mode 100644 index bb98546b..00000000 --- a/claudedocs/architecture/[PLAN-2025-02-10] frontend-improvement-roadmap.md +++ /dev/null @@ -1,74 +0,0 @@ -# SAM ERP 프론트엔드 개선 로드맵 - -> 작성일: 2025-02-10 -> 분석 기준: src/ 전체 (500+ 파일, ~163K줄) - ---- - -## Phase A: 즉시 개선 — ✅ 완료 - -| # | 항목 | 상태 | 비고 | -|---|------|------|------| -| A-1 | `` → `next/image` 전환 | ✅ **전환 불필요 결정** | 폐쇄형 ERP, 전량 외부 동적 이미지, blob URL 비호환 (`_index.md` 참조) | -| A-2 | DataTable 렌더링 최적화 | ⏳ 대기 | TableRow memo + useCallback | - ---- - -## Phase B: 단기 개선 — ✅ 완료 - -| # | 항목 | 상태 | 비고 | -|---|------|------|------| -| B-1 | `next/dynamic` 코드 스플리팅 | ✅ **완료** | 대시보드 4개 + xlsx 동적 로드, ~850KB 절감 (`_index.md` 참조) | -| B-2 | API 병렬 호출 (`Promise.all`) | ✅ **완료** | | -| B-3 | `store/` vs `stores/` 통합 | ✅ **완료** | | - ---- - -## Phase C: 중기 개선 — ✅ 완료 - -| # | 항목 | 상태 | 비고 | -|---|------|------|------| -| C-1 | 테이블 가상화 (react-window) | ✅ **보류 결정** | 페이지네이션 사용 중, YAGNI (`_index.md` 참조) | -| C-2 | SWR / React Query | ✅ **보류 결정** | Zustand 캐싱 충족 (`_index.md` 참조) | -| C-3 | Action 팩토리 패턴 확대 | ✅ **규칙 확정** | 신규 CRUD만 팩토리 사용 (`_index.md` 참조) | -| C-4 | V1/V2 컴포넌트 정리 | ✅ **완료** | V2 최종본 확정, V1 삭제, 접미사 제거 | - ---- - -## Phase D: 장기 개선 (필요 시) - -| # | 항목 | 상태 | -|---|------|------| -| D-1 | God 컴포넌트 분리 (5개, 1200~2700줄) | ⏳ 대기 | -| D-2 | `as` 타입 캐스트 점진적 제거 (926건) | ✅ **보류 결정** | 실제 ~200건만 actionable, 신규 코드에서 제네릭 활용 (2026-02-11) | -| D-3 | `@deprecated` 함수 정리 (13파일) | ✅ **즉시 삭제분 완료** | uploadFile/deleteFile/getSiteNames/deprecated props 삭제 (2026-02-11) | -| D-4 | Molecules 레이어 활성화 | ✅ **보류 결정** | 사용률 ~0%, organisms/templates로 충분 (2026-02-11) | -| D-5 | 모달 컴포넌트 통합 | ✅ **완료** | InspectionPreviewModal → DocumentViewer 전환 (2026-02-11) | -| D-6 | 기타 (TODO 102건, strictMode 등) | ⏳ 대기 | - ---- - -## 이전 리팩토링 완료 항목 (참고) - -| 항목 | 상태 | 날짜 | -|------|------|------| -| Phase 1: 공통 훅 추출 (executeServerAction 등) | ✅ 완료 | 이전 세션 | -| 중복 코드 공통화 (buildApiUrl 전체 43개 actions.ts 마이그레이션) | ✅ 완료 | 2026-02-12 | -| executePaginatedAction 전체 마이그레이션 (14개 actions.ts, ~220줄 감소) | ✅ 완료 | 2026-02-12 | -| Phase 3: 공용 유틸 추출 (PaginatedApiResponse 등) | ✅ 완료 | 이전 세션 | -| Phase 4: SearchableSelectionModal 공통화 | ✅ 완료 | 이전 세션 | -| Phase 5: any 21건 + memo 3개 정리 | ✅ 완료 | 이전 세션 | -| console.log 524건 → 22건 정리 | ✅ 완료 | 2025-02-10 | -| TODO 주석 정리 (login route) | ✅ 완료 | 2025-02-10 | -| SSR 가드 추가 (ThemeContext, ApiErrorContext, useDetailPageState) | ✅ 완료 | 2025-02-10 | -| 커스텀 훅 불필요 'use client' 15개 제거 | ✅ 완료 | 2025-02-10 | -| formatDate 이름 충돌 해소 → formatCalendarDate | ✅ 완료 | 2025-02-10 | - ---- - -## 우선순위 요약 - -``` -Phase A~C: ✅ 전체 완료/결정 완료 (A-2 DataTable 최적화만 대기) -Phase D: 남은 작업 → A-2, D-1, D-6 -``` diff --git a/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md b/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md deleted file mode 100644 index 0df29fba..00000000 --- a/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md +++ /dev/null @@ -1,346 +0,0 @@ -# 동적 메뉴 갱신 시스템 - -## 개요 - -관리자가 게시판/메뉴를 추가하면 사용자가 **재로그인 없이** 즉시 메뉴를 갱신받을 수 있는 시스템 구현. - -## 현재 문제점 - -``` -현재 흐름: - 로그인 → API 응답에서 메뉴 수신 → localStorage.user.menu 저장 → 세션 종료까지 고정 - -문제: - - 관리자가 게시판 추가해도 사용자는 재로그인 전까지 새 메뉴 안 보임 - - 메뉴 전용 갱신 API 없음 - - 실시간 알림 메커니즘 없음 -``` - -## 데이터 흐름 (현재) - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 로그인 시 │ -├─────────────────────────────────────────────────────────────┤ -│ POST /api/v1/login │ -│ ↓ │ -│ 응답: { user, tenant, roles, menus } │ -│ ↓ │ -│ transformApiMenusToMenuItems(menus) │ -│ ↓ │ -│ localStorage.setItem('user', { ...userData, menu }) │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ 페이지 로드 시 │ -├─────────────────────────────────────────────────────────────┤ -│ AuthenticatedLayout.tsx │ -│ ↓ │ -│ localStorage.getItem('user') → userData.menu │ -│ ↓ │ -│ deserializeMenuItems(userData.menu) │ -│ ↓ │ -│ menuStore.setMenuItems(deserializedMenus) │ -│ ↓ │ -│ Sidebar 컴포넌트 렌더링 │ -└─────────────────────────────────────────────────────────────┘ -``` - -## 관련 파일 - -| 파일 | 역할 | -|------|------| -| `src/store/menuStore.ts` | Zustand 메뉴 상태 관리 | -| `src/lib/utils/menuTransform.ts` | API 메뉴 → UI 메뉴 변환 | -| `src/lib/utils/menuRefresh.ts` | 메뉴 갱신 유틸리티 (해시 비교, localStorage/Zustand 동시 업데이트) | -| `src/hooks/useMenuPolling.ts` | 메뉴 폴링 훅 (30초 간격, 탭 가시성, 세션 만료 처리) | -| `src/layouts/AuthenticatedLayout.tsx` | 메뉴 로드 및 스토어 설정 | -| `src/components/layout/Sidebar.tsx` | 메뉴 렌더링 | -| `src/contexts/AuthContext.tsx` | 사용자 인증 컨텍스트 | - ---- - -## 구현 계획 - -### 1단계: 폴링 방식 (현재 구현 목표) - -**방식**: 30초마다 메뉴 API 호출하여 변경사항 확인 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 폴링 방식 흐름 │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ [30초마다] │ -│ ↓ │ -│ GET /api/menus (메뉴 전용 API 필요) │ -│ ↓ │ -│ 현재 메뉴와 비교 (해시 또는 버전 비교) │ -│ ↓ │ -│ 변경 있으면 → refreshMenus() 호출 │ -│ ↓ │ -│ localStorage.user.menu 업데이트 │ -│ menuStore.setMenuItems() 호출 │ -│ ↓ │ -│ UI 즉시 반영 │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -**장점**: -- 구현 단순 -- 백엔드 수정 최소화 (메뉴 조회 API만 추가) -- 기존 인프라 그대로 사용 - -**단점**: -- 최대 30초 지연 -- 불필요한 API 호출 발생 - -#### 프론트엔드 구현 사항 - -1. **메뉴 갱신 유틸리티 함수** (`src/lib/utils/menuRefresh.ts`) -2. **폴링 훅** (`src/hooks/useMenuPolling.ts`) -3. **AuthenticatedLayout에 훅 적용** - -#### 백엔드 요청 사항 - -| 항목 | 설명 | -|------|------| -| **엔드포인트** | `GET /api/v1/menus` | -| **인증** | Bearer 토큰 필요 | -| **응답** | 현재 사용자의 메뉴 목록 (로그인 응답의 menus와 동일 구조) | -| **선택사항** | `menu_version` 또는 `menu_hash` 필드 추가 (변경 감지 최적화용) | - ---- - -### 2단계: SSE 고도화 (향후 계획) - -**방식**: 서버에서 메뉴 변경 시 SSE로 클라이언트에 푸시 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 백엔드 (Laravel) │ -├─────────────────────────────────────────────────────────────┤ -│ 1. 관리자가 메뉴 추가 → DB 저장 │ -│ 2. MenuUpdatedEvent 발생 │ -│ 3. 해당 테넌트의 SSE 채널로 푸시 │ -└─────────────────────────────────────────────────────────────┘ - ↓ SSE -┌─────────────────────────────────────────────────────────────┐ -│ 프론트엔드 (Next.js) │ -├─────────────────────────────────────────────────────────────┤ -│ 1. EventSource로 SSE 연결 유지 │ -│ 2. 'menu-updated' 이벤트 수신 │ -│ 3. refreshMenus() 호출 → UI 즉시 갱신 │ -└─────────────────────────────────────────────────────────────┘ -``` - -**장점**: -- 실시간 갱신 (지연 없음) -- 효율적 (변경 시에만 통신) - -**단점**: -- 백엔드 SSE 인프라 구축 필요 -- 동시 접속자 관리 필요 -- 멀티테넌트 채널 분리 필요 - -#### 백엔드 요구사항 (SSE) - -| 항목 | 설명 | -|------|------| -| **SSE 엔드포인트** | `GET /api/v1/sse/menu-updates` | -| **인증** | Bearer 토큰 또는 쿼리 파라미터 | -| **이벤트 타입** | `menu-updated` | -| **채널 분리** | 테넌트별로 분리 필요 | -| **구현 옵션** | Laravel Broadcasting + Redis, 직접 구현 등 | - ---- - -## 구현 체크리스트 - -### 1단계: 폴링 방식 - -#### 프론트엔드 ✅ 구현 완료 (2025-12-29) -- [x] `src/lib/utils/menuRefresh.ts` 생성 - - [x] `refreshMenus()` 함수 구현 - - [x] `forceRefreshMenus()` 강제 갱신 함수 - - [x] localStorage + Zustand 동시 업데이트 - - [x] 해시 기반 변경 감지 -- [x] `src/hooks/useMenuPolling.ts` 생성 - - [x] 30초 간격 폴링 로직 - - [x] 탭 가시성 변경 시 자동 중지/재개 - - [x] pause/resume 기능 - - [x] 컴포넌트 언마운트 시 정리 -- [x] `src/app/api/menus/route.ts` 생성 (Next.js 프록시) - - [x] 백엔드 메뉴 API 프록시 - - [x] HttpOnly 쿠키 토큰 처리 - - [x] `{ data: [...] }` 응답 구조 처리 -- [x] `AuthenticatedLayout.tsx`에 훅 적용 -- [ ] 테스트: 관리자 메뉴 추가 → 30초 내 사용자 메뉴 갱신 확인 - -#### 백엔드 (이미 존재!) -- [x] `GET /api/v1/menus` API 존재 확인 ✅ -- [x] `MenuController::index` → `MenuService::index` (사용자 권한 기반 필터링) -- [x] 응답 구조: `{ data: [...] }` (ApiResponse::handle 표준) - -### 2단계: SSE 고도화 (향후) - -- [ ] 백엔드 SSE 인프라 구축 -- [ ] 프론트엔드 EventSource 훅 구현 -- [ ] 폴링 → SSE 전환 -- [ ] 폴백: SSE 연결 실패 시 폴링으로 대체 - ---- - -## 코드 스니펫 - -### refreshMenus 함수 - -```typescript -// src/lib/utils/menuRefresh.ts -import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform'; -import { useMenuStore } from '@/store/menuStore'; - -export async function refreshMenus(): Promise { - try { - const response = await fetch('/api/menus'); - if (!response.ok) return false; - - const { menus } = await response.json(); - const transformedMenus = transformApiMenusToMenuItems(menus); - - // 1. localStorage 업데이트 (새로고침 대응) - const userData = JSON.parse(localStorage.getItem('user') || '{}'); - userData.menu = transformedMenus; - localStorage.setItem('user', JSON.stringify(userData)); - - // 2. Zustand 스토어 업데이트 (UI 즉시 반영) - const { setMenuItems } = useMenuStore.getState(); - setMenuItems(deserializeMenuItems(transformedMenus)); - - console.log('[Menu] 메뉴 갱신 완료'); - return true; - } catch (error) { - console.error('[Menu] 메뉴 갱신 실패:', error); - return false; - } -} -``` - -### useMenuPolling 훅 - -```typescript -// src/hooks/useMenuPolling.ts -// 주요 기능: 30초 폴링, 탭 가시성 처리, 세션 만료 감지(3회 연속 401), 토큰 갱신 쿠키 감지 - -export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPollingReturn { - // ⚠️ 콜백 안정화 패턴 (2026-02-03 버그 수정) - // 부모 컴포넌트에서 인라인 콜백을 전달하면 매 렌더마다 새 참조가 생성되어 - // executeRefresh → useEffect 의존성이 변경 → setInterval이 매 렌더마다 리셋되는 버그 발생. - // 해결: 콜백을 ref로 저장하여 executeRefresh 의존성에서 제거. - const onMenuUpdatedRef = useRef(onMenuUpdated); - const onErrorRef = useRef(onError); - const onSessionExpiredRef = useRef(onSessionExpired); - - useEffect(() => { - onMenuUpdatedRef.current = onMenuUpdated; - onErrorRef.current = onError; - onSessionExpiredRef.current = onSessionExpired; - }); - - // executeRefresh 의존성: [stopPolling] 만 — 안정적 - const executeRefresh = useCallback(async () => { - // ref를 통해 최신 콜백 호출 - onMenuUpdatedRef.current?.(); - onSessionExpiredRef.current?.(); - onErrorRef.current?.(result.error); - }, [stopPolling]); -} -``` - -### Next.js API 프록시 - -```typescript -// src/app/api/menus/route.ts -import { NextRequest, NextResponse } from 'next/server'; - -export async function GET(request: NextRequest) { - const token = request.cookies.get('access_token')?.value; - - if (!token) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/menus`, { - headers: { - 'Authorization': `Bearer ${token}`, - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - }, - }); - - const data = await response.json(); - return NextResponse.json(data); -} -``` - ---- - -## 참고 사항 - -### 메뉴 데이터 저장 위치 - -| 저장소 | 키 | 용도 | -|--------|-----|------| -| localStorage | `user.menu` | 새로고침 시 복구용 | -| Zustand | `menuStore.menuItems` | UI 렌더링용 | - -### 갱신 시 동기화 필수 - -```typescript -// 반드시 둘 다 업데이트! -localStorage.user.menu = newMenus; // 새로고침 대응 -menuStore.setMenuItems(newMenus); // UI 즉시 반영 -``` - ---- - -## 작성 정보 - -- **작성일**: 2025-12-29 -- **최종 수정**: 2026-02-03 -- **상태**: ✅ 1단계 구현 완료 + 폴링 버그 수정 -- **담당**: 프론트엔드 팀 -- **백엔드**: `GET /api/v1/menus` API 이미 존재 ✅ - ---- - -## 변경 이력 - -### 2026-02-03: 폴링 인터벌 리셋 버그 수정 - -**문제**: 메뉴 폴링이 실제로 실행되지 않아 백엔드에서 메뉴를 추가해도 재로그인 전까지 반영되지 않음. - -**원인**: `useMenuPolling` 훅의 `executeRefresh` 콜백이 매 렌더마다 새 참조를 생성하여 `setInterval`이 리셋됨. - -``` -AuthenticatedLayout에서 인라인 콜백 전달: - onMenuUpdated: () => { ... } ← 매 렌더마다 새 함수 - onSessionExpired: () => { ... } ← 매 렌더마다 새 함수 - ↓ - executeRefresh deps: [onMenuUpdated, onError, onSessionExpired, stopPolling] - ↓ 매 렌더마다 변경 - useEffect deps: [executeRefresh] → clearInterval → setInterval 재설정 - ↓ - 알림 폴링이 30초마다 state 업데이트 → 리렌더 → 메뉴 인터벌 리셋 - ↓ - 메뉴 폴링이 30초에 도달하지 못하고 영원히 미실행 -``` - -**수정**: 콜백을 `useRef`로 안정화하여 `executeRefresh` 의존성에서 제거. - -``` -수정 전: executeRefresh deps = [onMenuUpdated, onError, onSessionExpired, stopPolling] -수정 후: executeRefresh deps = [stopPolling] ← 안정적, 인터벌 리셋 없음 -``` - -**수정 파일**: `src/hooks/useMenuPolling.ts` \ No newline at end of file diff --git a/claudedocs/architecture/[PLAN-2026-01-16] layout-restructure.md b/claudedocs/architecture/[PLAN-2026-01-16] layout-restructure.md deleted file mode 100644 index 5e84fceb..00000000 --- a/claudedocs/architecture/[PLAN-2026-01-16] layout-restructure.md +++ /dev/null @@ -1,96 +0,0 @@ -# 레이아웃 구조 변경 계획 - -> **상태**: 📋 대기 (기능 검수 완료 후 진행) -> **작성일**: 2026-01-16 -> **적용 대상**: IntegratedListTemplateV2.tsx (55개 페이지 일괄 적용) - ---- - -## 현재 구조 - -``` -1. 타이틀 -2. 달력 / 버튼들 (등록 버튼 여기) -3. 통계 카드 -4. 검색창 (Card로 감싸짐) -5. 테이블 Card - └─ 탭 버튼들 / 필터 / 삭제 버튼 - └─ 테이블 -``` - ---- - -## 변경 후 구조 - -``` -1. 타이틀 -2. 달력 / 달력버튼 / 검색창 (한 줄) -3. 카드섹션 (한 줄, 줄넘김 없음) -4. [탭 버튼들] ─────────────── [등록] [CSV] 버튼들 ← Card 밖 -5. 테이블 Card - ├─ 총 N건 / 선택건 / 필터 - └─ 테이블 -``` - ---- - -## 시각화 - -``` -┌─ 페이지 ─────────────────────────────────────────────────┐ -│ 휴가관리 │ -│ 직원들의 휴가 현황을 관리합니다 │ -├──────────────────────────────────────────────────────────┤ -│ [📅 2025-12-01] ~ [📅 2025-12-31] [당월][전월] [🔍검색] │ -├──────────────────────────────────────────────────────────┤ -│ [승인대기 1명] [연차 4명] [경조사 0명] [사용률 4.3%] │ ← 카드 (줄넘김X) -├──────────────────────────────────────────────────────────┤ -│ [사용현황 4] [부여현황 2] [신청현황 3] [등록] [CSV] │ ← Card 밖 -├──────────────────────────────────────────────────────────┤ -│ ┌─ 테이블 Card ────────────────────────────────────────┐ │ -│ │ 총 55건 | 3개 선택됨 [필터1] [필터2] │ │ -│ ├──────────────────────────────────────────────────────┤ │ -│ │ □ | 번호 | 부서 | 이름 | ... │ │ -│ │ □ | 1 | 개발 | 홍길동 | ... │ │ -│ └──────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────┘ -``` - ---- - -## 주요 변경점 - -| 항목 | 현재 | 변경 후 | -|------|------|---------| -| 검색창 | Card로 감싸짐, 별도 영역 | 달력 옆 한 줄에 배치 | -| 카드섹션 | flex-wrap (줄넘김) | flex-nowrap + overflow-x-auto | -| 탭 버튼 | 테이블 Card 내부 | 테이블 Card 위 (밖) | -| 등록/액션 버튼 | 헤더 영역 | 탭 버튼 오른쪽 | -| 총 N건/선택건 | 탭과 같은 줄 | 테이블 Card 내부 첫 줄 | -| 필터 | 탭과 같은 줄 | 테이블 Card 내부 첫 줄 | - ---- - -## 수정 대상 파일 - -1. **IntegratedListTemplateV2.tsx** - 전체 레이아웃 구조 변경 -2. **UniversalListPage/index.tsx** - prop 전달 방식 조정 (필요시) - ---- - -## 체크리스트 - -- [ ] 검색창 위치 이동 (달력 옆) -- [ ] 카드섹션 줄넘김 방지 (flex-nowrap) -- [ ] 탭 버튼 테이블 Card 밖으로 이동 -- [ ] 등록/액션 버튼 탭 옆으로 이동 -- [ ] 총 N건/선택건/필터 테이블 Card 내부로 이동 -- [ ] PC/모바일 반응형 확인 -- [ ] 55개 페이지 일괄 테스트 - ---- - -## 진행 조건 - -✅ **기능 검수 완료 후 진행** -- 현재 화면과 비교 검수가 필요하므로 레이아웃 변경은 기능 검수 이후에 진행 diff --git a/claudedocs/architecture/[PLAN-2026-01-22] ui-component-abstraction.md b/claudedocs/architecture/[PLAN-2026-01-22] ui-component-abstraction.md deleted file mode 100644 index d9a8007e..00000000 --- a/claudedocs/architecture/[PLAN-2026-01-22] ui-component-abstraction.md +++ /dev/null @@ -1,546 +0,0 @@ -# UI 컴포넌트 공통화/추상화 계획 - -> **작성일**: 2026-01-22 -> **상태**: 🟢 진행 중 -> **범위**: 공통 UI 컴포넌트 추상화 및 스켈레톤 시스템 구축 - ---- - -## 결정 사항 (2026-01-22) - -| 항목 | 결정 | -|------|------| -| 스켈레톤 전환 범위 | **Option A: 전체 스켈레톤 전환** | -| 구현 우선순위 | **Phase 1 먼저** (ConfirmDialog → StatusBadge → EmptyState) | -| 확장 전략 | **옵션 기반 확장** - 새 패턴 발견 시 props 옵션으로 추가 | - ---- - -## 1. 현황 분석 요약 - -### 반복 패턴 현황 - -| 패턴 | 파일 수 | 발생 횟수 | 복잡도 | 우선순위 | -|------|---------|----------|--------|----------| -| 확인 다이얼로그 (삭제/저장) | 67개 | 170회 | 낮음 | 🔴 높음 | -| 상태 스타일 매핑 | 80개 | 다수 | 낮음 | 🔴 높음 | -| 날짜 범위 필터 | 55개 | 146회 | 중간 | 🟡 중간 | -| 빈 상태 UI | 70개 | 86회 | 낮음 | 🟡 중간 | -| 로딩 스피너/버튼 | 59개 | 120회 | 중간 | 🟡 중간 | -| 스켈레톤 UI | 4개 | 92회 | 높음 | 🔴 높음 | - -### 현재 스켈레톤 현황 - -**기존 구현:** -- `src/components/ui/skeleton.tsx` - 기본 스켈레톤 (단순 animate-pulse div) -- `IntegratedDetailTemplate/components/skeletons/` - 상세 페이지용 3종 - - `DetailFieldSkeleton.tsx` - - `DetailSectionSkeleton.tsx` - - `DetailGridSkeleton.tsx` -- `loading.tsx` - 4개 파일만 존재 (대부분 PageLoadingSpinner 사용) - -**문제점:** -1. 대부분 페이지에서 로딩 스피너 사용 (스켈레톤 미적용) -2. 리스트 페이지용 스켈레톤 없음 -3. 카드/대시보드용 스켈레톤 없음 -4. 페이지별 loading.tsx 부재 (4개만 존재) - ---- - -## 2. 공통화 대상 상세 - -### Phase 1: 핵심 공통 컴포넌트 (1주차) - -#### 1-1. ConfirmDialog 컴포넌트 - -**현재 (반복 코드):** -```tsx -// 67개 파일에서 거의 동일하게 반복 -const [showDeleteDialog, setShowDeleteDialog] = useState(false); - - - - - 삭제 확인 - - 정말 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. - - - - 취소 - - {isLoading && } - 삭제 - - - - -``` - -**개선안:** -```tsx -// src/components/ui/confirm-dialog.tsx -interface ConfirmDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - title: string; - description: string; - confirmText?: string; - cancelText?: string; - variant?: 'default' | 'destructive' | 'warning'; - loading?: boolean; - onConfirm: () => void | Promise; -} - -// 사용 예시 - -``` - -**효과:** -- 코드량: ~30줄 → ~10줄 (70% 감소) -- 일관된 UX 보장 -- 로딩 상태 자동 처리 - ---- - -#### 1-2. StatusBadge 컴포넌트 + createStatusConfig 유틸 - -**현재 (반복 코드):** -```tsx -// 80개 파일에서 각각 정의 -// estimates/types.ts -export const STATUS_STYLES: Record = { - pending: 'bg-yellow-100 text-yellow-800', - inProgress: 'bg-blue-100 text-blue-800', - completed: 'bg-green-100 text-green-800', -}; -export const STATUS_LABELS: Record = { - pending: '대기', - inProgress: '진행중', - completed: '완료', -}; - -// site-management/types.ts (거의 동일) -export const SITE_STATUS_STYLES: Record = { ... }; -export const SITE_STATUS_LABELS: Record = { ... }; -``` - -**개선안:** -```tsx -// src/lib/utils/status-config.ts -export type StatusVariant = 'default' | 'success' | 'warning' | 'error' | 'info'; - -export interface StatusConfig { - value: T; - label: string; - variant: StatusVariant; - description?: string; -} - -export function createStatusConfig( - configs: StatusConfig[] -): { - options: { value: T; label: string }[]; - getLabel: (status: T) => string; - getVariant: (status: T) => StatusVariant; - isValid: (status: string) => status is T; -} - -// src/components/ui/status-badge.tsx -interface StatusBadgeProps { - status: T; - config: ReturnType>; - size?: 'sm' | 'md' | 'lg'; -} - -// 사용 예시 -// estimates/types.ts -export const estimateStatusConfig = createStatusConfig([ - { value: 'pending', label: '대기', variant: 'warning' }, - { value: 'inProgress', label: '진행중', variant: 'info' }, - { value: 'completed', label: '완료', variant: 'success' }, -]); - -// 컴포넌트에서 - -``` - -**효과:** -- 타입 안전성 강화 -- 일관된 색상 체계 -- options 자동 생성 (Select용) - ---- - -#### 1-3. EmptyState 컴포넌트 - -**현재 (반복 코드):** -```tsx -// 70개 파일에서 다양한 형태로 반복 -{data.length === 0 && ( -
- 데이터가 없습니다 -
-)} - -// 또는 - - - 등록된 항목이 없습니다 - - -``` - -**개선안:** -```tsx -// src/components/ui/empty-state.tsx -interface EmptyStateProps { - icon?: ReactNode; - title?: string; - description?: string; - action?: ReactNode; - variant?: 'default' | 'table' | 'card' | 'minimal'; -} - -// 사용 예시 -} - title="데이터가 없습니다" - description="새로운 항목을 등록하거나 검색 조건을 변경해보세요." - action={} -/> - -// 테이블 내 사용 - -``` - ---- - -### Phase 2: 스켈레톤 시스템 구축 (2주차) - -#### 2-1. 스켈레톤 컴포넌트 확장 - -**현재 문제:** -- 기본 Skeleton만 존재 (단순 div) -- 페이지 유형별 스켈레톤 부재 -- 대부분 PageLoadingSpinner 사용 (스켈레톤 미적용) - -**추가할 스켈레톤:** - -```tsx -// src/components/ui/skeletons/ -├── index.ts // 통합 export -├── ListPageSkeleton.tsx // 리스트 페이지용 -├── DetailPageSkeleton.tsx // 상세 페이지용 (기존 확장) -├── CardGridSkeleton.tsx // 카드 그리드용 -├── DashboardSkeleton.tsx // 대시보드용 -├── TableSkeleton.tsx // 테이블용 -├── FormSkeleton.tsx // 폼용 -└── ChartSkeleton.tsx // 차트용 -``` - -**1. ListPageSkeleton (리스트 페이지용)** -```tsx -interface ListPageSkeletonProps { - hasFilters?: boolean; - filterCount?: number; - hasDateRange?: boolean; - rowCount?: number; - columnCount?: number; - hasActions?: boolean; - hasPagination?: boolean; -} - -// 사용 예시 -export default function EstimateListLoading() { - return ( - - ); -} -``` - -**2. CardGridSkeleton (카드 그리드용)** -```tsx -interface CardGridSkeletonProps { - cardCount?: number; - cols?: 1 | 2 | 3 | 4; - cardHeight?: 'sm' | 'md' | 'lg'; - hasImage?: boolean; - hasFooter?: boolean; -} - -// 대시보드 카드, 칸반 보드 등에 사용 - -``` - -**3. TableSkeleton (테이블용)** -```tsx -interface TableSkeletonProps { - rowCount?: number; - columnCount?: number; - hasCheckbox?: boolean; - hasActions?: boolean; - columnWidths?: string[]; // ['w-12', 'w-32', 'flex-1', ...] -} - - -``` - ---- - -#### 2-2. loading.tsx 파일 생성 전략 - -**현재:** 4개 파일만 존재 -**목표:** 주요 페이지 경로에 맞춤형 loading.tsx 생성 - -**생성 대상 (우선순위):** - -| 경로 | 스켈레톤 타입 | 우선순위 | -|------|-------------|----------| -| `/construction/project/bidding/estimates` | ListPageSkeleton | 🔴 | -| `/construction/project/bidding` | ListPageSkeleton | 🔴 | -| `/construction/project/contract` | ListPageSkeleton | 🔴 | -| `/construction/order/*` | ListPageSkeleton | 🔴 | -| `/accounting/*` | ListPageSkeleton | 🟡 | -| `/hr/*` | ListPageSkeleton | 🟡 | -| `/settings/*` | ListPageSkeleton | 🟢 | -| `상세 페이지` | DetailPageSkeleton | 🟡 | -| `대시보드` | DashboardSkeleton | 🟡 | - ---- - -### Phase 3: 날짜 범위 필터 + 로딩 버튼 (3주차) - -#### 3-1. DateRangeFilter 컴포넌트 - -**현재 (반복 코드):** -```tsx -// 55개 파일에서 반복 -const [startDate, setStartDate] = useState(''); -const [endDate, setEndDate] = useState(''); - -
- - ~ - -
-``` - -**개선안:** -```tsx -// src/components/ui/date-range-filter.tsx -interface DateRangeFilterProps { - value: { start: string; end: string }; - onChange: (range: { start: string; end: string }) => void; - presets?: ('today' | 'week' | 'month' | 'quarter' | 'year')[]; - disabled?: boolean; -} - -// 사용 예시 - { - setStartDate(start); - setEndDate(end); - }} - presets={['today', 'week', 'month']} -/> -``` - ---- - -#### 3-2. LoadingButton 컴포넌트 - -**현재 (반복 코드):** -```tsx -// 59개 파일에서 반복 - -``` - -**개선안:** -```tsx -// src/components/ui/loading-button.tsx -interface LoadingButtonProps extends ButtonProps { - loading?: boolean; - loadingText?: string; - spinnerPosition?: 'left' | 'right'; -} - -// 사용 예시 - - 저장 - -``` - ---- - -## 3. 로딩 스피너 vs 스켈레톤 전략 - -### 논의 사항 - -**Option A: 전체 스켈레톤 전환** -- 장점: 더 나은 UX, 레이아웃 시프트 방지 -- 단점: 구현 비용 높음, 페이지별 커스텀 필요 - -**Option B: 하이브리드 (권장)** -- 페이지 로딩: 스켈레톤 (loading.tsx) -- 버튼/액션 로딩: 스피너 유지 (LoadingButton) -- 데이터 갱신: 스피너 유지 - -**Option C: 현행 유지** -- 대부분 스피너 유지 -- 특정 페이지만 스켈레톤 - -### 권장안: Option B (하이브리드) - -| 상황 | 로딩 UI | 이유 | -|------|---------|------| -| 페이지 초기 로딩 | 스켈레톤 | 레이아웃 힌트 제공 | -| 페이지 전환 | 스켈레톤 | Next.js loading.tsx 활용 | -| 버튼 클릭 (저장/삭제) | 스피너 | 짧은 작업, 버튼 내 피드백 | -| 데이터 갱신 (필터 변경) | 스피너 or 스켈레톤 | 상황에 따라 | -| 무한 스크롤 | 스켈레톤 | 추가 컨텐츠 힌트 | - ---- - -## 4. 구현 로드맵 - -### Week 1: 핵심 컴포넌트 -- [x] ConfirmDialog 컴포넌트 생성 ✅ (2026-01-22) - - `src/components/ui/confirm-dialog.tsx` - - variants: default, destructive, warning, success - - presets: DeleteConfirmDialog, SaveConfirmDialog, CancelConfirmDialog - - 내부/외부 로딩 상태 자동 관리 -- [x] StatusBadge + createStatusConfig 유틸 생성 ✅ (2026-01-22) - - `src/lib/utils/status-config.ts` - - `src/components/ui/status-badge.tsx` - - 프리셋: default, success, warning, destructive, info, muted, orange, purple - - 모드: badge (배경+텍스트), text (텍스트만) - - OPTIONS, LABELS, STYLES 자동 생성 -- [x] EmptyState 컴포넌트 생성 ✅ (2026-01-22) - - `src/components/ui/empty-state.tsx` - - variants: default, compact, large - - presets: noData, noResults, noItems, error - - TableEmptyState 추가 (테이블용) -- [x] 기존 코드 마이그레이션 (10개 파일 시범) ✅ (2026-01-22) - - PricingDetailClient.tsx - 삭제 확인 - - ItemManagementClient.tsx - 단일/일괄 삭제 - - LaborDetailClient.tsx - 삭제 확인 - - ConstructionDetailClient.tsx - 완료 확인 (warning) - - QuoteManagementClient.tsx - 단일/일괄 삭제 - - OrderDialogs.tsx - 저장/삭제/카테고리삭제 - - DepartmentManagement/index.tsx - 삭제 확인 - - VacationManagement/index.tsx - 승인/거절 확인 - - AccountDetail.tsx - 삭제 확인 - - ProcessListClient.tsx - 삭제 확인 - -### Week 2: 스켈레톤 시스템 -- [ ] ListPageSkeleton 컴포넌트 생성 -- [ ] TableSkeleton 컴포넌트 생성 -- [ ] CardGridSkeleton 컴포넌트 생성 -- [ ] 주요 경로 loading.tsx 생성 (construction/*) - -### Week 3: 필터 + 버튼 + 마이그레이션 -- [ ] DateRangeFilter 컴포넌트 생성 -- [ ] LoadingButton 컴포넌트 생성 -- [ ] 전체 코드 마이그레이션 - -### Week 4: 마무리 + QA -- [ ] 남은 마이그레이션 -- [ ] 문서화 -- [ ] 성능 테스트 - ---- - -## 5. 예상 효과 - -### 코드량 감소 -| 컴포넌트 | Before | After | 감소율 | -|---------|--------|-------|--------| -| ConfirmDialog | ~30줄 | ~10줄 | 67% | -| StatusBadge | ~20줄 | ~5줄 | 75% | -| EmptyState | ~10줄 | ~3줄 | 70% | -| DateRangeFilter | ~15줄 | ~5줄 | 67% | - -### 일관성 향상 -- 동일한 UX 패턴 적용 -- 디자인 시스템 강화 -- 유지보수 용이성 증가 - -### 성능 개선 -- 스켈레톤으로 인지 성능 향상 -- 레이아웃 시프트 감소 -- 사용자 이탈률 감소 - ---- - -## 6. 결정 필요 사항 - -### Q1: 스켈레톤 전환 범위 -- [ ] Option A: 전체 스켈레톤 전환 -- [ ] Option B: 하이브리드 (권장) -- [ ] Option C: 현행 유지 - -### Q2: 구현 우선순위 -- [ ] Phase 1 먼저 (ConfirmDialog, StatusBadge, EmptyState) -- [ ] Phase 2 먼저 (스켈레톤 시스템) -- [ ] 동시 진행 - -### Q3: 마이그레이션 범위 -- [ ] 전체 파일 한번에 -- [ ] 점진적 (신규/수정 파일만) -- [ ] 도메인별 순차 (construction → accounting → hr) - ---- - -## 7. 파일 구조 (최종) - -``` -src/components/ui/ -├── confirm-dialog.tsx # Phase 1 -├── status-badge.tsx # Phase 1 -├── empty-state.tsx # Phase 1 -├── date-range-filter.tsx # Phase 3 -├── loading-button.tsx # Phase 3 -├── skeleton.tsx # 기존 -└── skeletons/ # Phase 2 - ├── index.ts - ├── ListPageSkeleton.tsx - ├── DetailPageSkeleton.tsx - ├── CardGridSkeleton.tsx - ├── DashboardSkeleton.tsx - ├── TableSkeleton.tsx - ├── FormSkeleton.tsx - └── ChartSkeleton.tsx - -src/lib/utils/ -└── status-config.ts # Phase 1 -``` - ---- - -**다음 단계**: 위 결정 사항에 대한 의견 확정 후 구현 시작 diff --git a/claudedocs/architecture/[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md b/claudedocs/architecture/[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md deleted file mode 100644 index e6c9c41b..00000000 --- a/claudedocs/architecture/[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md +++ /dev/null @@ -1,666 +0,0 @@ -# 멀티테넌시 공통화 및 최적화 로드맵 - -**작성일**: 2026-02-06 -**목적**: 전체 프로젝트 멀티테넌시 준비 상태 점검 및 공통화/최적화 계획 수립 -**이전 문서**: `[REF-2025-11-19] multi-tenancy-implementation.md` (Phase 1-2 완료) - ---- - -## 현재 상태 요약 (2026-02-06 기준) - -### 완료된 항목 (이전 로드맵 Phase 1-2) - -| 항목 | 상태 | 파일 | -|------|------|------| -| User 타입에 Tenant 객체 포함 | ✅ 완료 | `src/contexts/AuthContext.tsx` | -| Tenant 인터페이스 정의 (id, company_name 등) | ✅ 완료 | `src/contexts/AuthContext.tsx` | -| TenantAwareCache 유틸리티 | ✅ 완료 | `src/lib/cache/TenantAwareCache.ts` | -| 테넌트 전환 감지 + 캐시 클리어 | ✅ 완료 | `src/contexts/AuthContext.tsx` | -| masterDataStore 테넌트 스코프 캐시 키 | ✅ 완료 | `src/stores/masterDataStore.ts` | -| sessionStorage/localStorage 테넌트 격리 | ✅ 완료 | `mes-{tenantId}-{key}` 패턴 | - -### 미완료 / 개선 필요 항목 - -| 영역 | 우선순위 | 현재 상태 | -|------|----------|-----------| -| API 프록시에 테넌트 컨텍스트 전달 | 🔴 | X-Tenant-ID 헤더 없음 | -| Server Actions 테넌트 인식 | 🔴 | 70+ actions.ts에 테넌트 미포함 | -| 포매터/유틸리티 다국어/다통화 | 🔴 | 한국어 하드코딩 | -| 브랜딩 동적화 (로고, 앱이름) | 🟡 | "SAM", sam-logo.png 하드코딩 | -| 상수/공휴일 외부화 | 🟡 | 한국 공휴일 하드코딩 | -| localStorage 직접 사용 잔재 | 🟡 | TenantAwareCache 미사용 곳 존재 | -| tenantId 타입 불일치 | 🟡 | string vs number 혼재 | -| 테넌트 라우팅 | 🟢 | 현재 없음 (필요 시 추가) | -| TenantContext Provider | 🟢 | 테넌트 설정 전용 Context 없음 | - ---- - -## 작업 영역 구분: 프론트 단독 vs 백엔드 협의 - -### 선행 확인 사항 - -> **핵심 질문**: "백엔드가 이미 JWT 토큰 안의 tenant_id로 데이터를 필터링하고 있는가?" -> -> - **Yes** → 프론트에서 별도 X-Tenant-ID 안 보내도 됨. Phase 1은 불필요하고 프론트 단독 Phase부터 진행 -> - **No** → 백엔드도 같이 수정 필요. Phase 1이 최우선 - -### 프론트 단독 가능 (백엔드 수정 불필요) - -| Phase | 작업 | 이유 | -|-------|------|------| -| **3** | 포매터 다국어/다통화 전환 | `formatAmount()`, `formatDate()` 등 프론트 유틸리티 내부 수정. 기본값을 한국어로 유지하면 하위 호환 | -| **6** | localStorage 잔재 정리 + tenantId 타입 통일 | 프론트 코드 정리. TenantAwareCache 미사용 곳 마이그레이션, `string` → `number` 통일 | -| **8** | 테넌트 라우팅 (필요 시) | Next.js App Router 구조 변경. 순수 프론트 라우팅 | - -> **즉시 착수 가능**: 백엔드 협의 결과를 기다리지 않고 바로 시작할 수 있음 - -### 백엔드 협의 필요 (프론트 + 백엔드 동시 수정) - -| Phase | 작업 | 백엔드 필요 이유 | -|-------|------|-----------------| -| **1** | API 테넌트 컨텍스트 주입 | 프론트에서 `X-Tenant-ID` 헤더를 보내도, **백엔드가 읽고 필터링**해줘야 의미 있음. 안 읽으면 보내봤자 무용지물 | -| **2** | Server Actions 마이그레이션 | Phase 1에 종속. 백엔드가 헤더 or URL로 테넌트를 구분 안 하면 프론트만 바꿔도 소용없음 | -| **4** | 브랜딩 동적화 | 테넌트별 로고/앱이름을 **어디서 가져오나?** → 백엔드 API 필요 (`GET /api/v1/tenant/config`) | -| **5** | 상수/공휴일 외부화 | 공휴일 데이터를 **DB에서 서빙**해야 함 → 백엔드 API 필요 (`GET /api/v1/holidays?year=2026`) | -| **7** | TenantConfigService | 테넌트 설정 통합 API 필요 → branding + regional + features를 한 번에 가져오는 엔드포인트 | - -### 추천 진행 순서 - -``` -[즉시 시작 - 프론트 단독] - Phase 3 (포매터) + Phase 6 (localStorage/타입) 병렬 진행 - -[백엔드 협의 후 시작] - Phase 1 (API 헤더) → Phase 2 (Actions) - -[백엔드 API 준비 후 시작] - Phase 7 (TenantConfigService) → Phase 4 (브랜딩) + Phase 5 (상수) -``` - ---- - -## Phase 1: API 레이어 테넌트 컨텍스트 주입 🔴 `백엔드 협의 필요` - -> **목표**: 모든 백엔드 API 호출에 테넌트 식별 정보가 전달되도록 함 -> **예상**: 3-5일 - -### 1-1. 로그인 시 tenant_id 쿠키 추가 - -**파일**: `src/app/api/auth/login/route.ts` - -**현재**: access_token, refresh_token 쿠키만 설정 -**변경**: tenant_id 쿠키 추가 (HttpOnly, API 프록시에서 읽기용) - -```typescript -// 로그인 성공 후 추가 -const tenantCookie = [ - `tenant_id=${data.tenant.id}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - `Max-Age=${data.expires_in || 7200}`, -].join('; '); -response.headers.append('Set-Cookie', tenantCookie); -``` - -### 1-2. API 프록시에 X-Tenant-ID 헤더 추가 - -**파일**: `src/app/api/proxy/[...path]/route.ts` - -**현재**: -```typescript -const headers = { - 'Accept': 'application/json', - 'X-API-KEY': process.env.API_KEY || '', - 'Authorization': `Bearer ${token}`, -}; -``` - -**변경**: -```typescript -const tenantId = request.cookies.get('tenant_id')?.value; -const headers = { - 'Accept': 'application/json', - 'X-API-KEY': process.env.API_KEY || '', - 'Authorization': `Bearer ${token}`, - ...(tenantId && { 'X-Tenant-ID': tenantId }), -}; -``` - -### 1-3. serverFetch 래퍼에 테넌트 헤더 추가 - -**파일**: `src/lib/api/fetch-wrapper.ts` - -**현재**: Authorization 헤더만 전달 -**변경**: tenant_id 쿠키 읽어서 X-Tenant-ID 헤더 자동 추가 - -```typescript -export async function serverFetch(url: string, options?: RequestInit) { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - const tenantId = cookieStore.get('tenant_id')?.value; - - const headers = { - ...options?.headers, - 'Authorization': `Bearer ${token}`, - ...(tenantId && { 'X-Tenant-ID': tenantId }), - }; - // ... -} -``` - -### 1-4. ApiClient 클래스에 테넌트 지원 - -**파일**: `src/lib/api/client.ts` - -**변경**: `getAuthHeaders()`에 X-Tenant-ID 포함 - -### 체크리스트 - -``` -- [ ] login/route.ts에 tenant_id 쿠키 Set-Cookie 추가 -- [ ] proxy/[...path]/route.ts에서 tenant_id 쿠키 읽기 + X-Tenant-ID 헤더 전달 -- [ ] fetch-wrapper.ts serverFetch에 X-Tenant-ID 자동 추가 -- [ ] client.ts ApiClient에 tenantId 옵션 추가 -- [ ] authenticated-fetch.ts에도 테넌트 헤더 전파 확인 -- [ ] 로그아웃 시 tenant_id 쿠키 삭제 확인 -- [ ] 토큰 갱신 시 tenant_id 쿠키 유지 확인 -- [ ] 백엔드와 X-Tenant-ID 헤더 수신 방식 협의 -``` - ---- - -## Phase 2: Server Actions 점진적 마이그레이션 🔴 `백엔드 협의 필요` - -> **목표**: 70+ actions.ts에서 테넌트 컨텍스트가 자동 전달되도록 함 -> **예상**: 1-2주 (Phase 1 완료 후 자동 적용되는 부분 다수) - -### 2-1. 현재 패턴 분석 - -대부분의 actions.ts가 이 패턴을 따름: -```typescript -const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/endpoint`; -const { response, error } = await serverFetch(url, { method: 'GET' }); -``` - -### 2-2. 자동 적용 범위 (Phase 1 완료 시) - -Phase 1에서 `serverFetch`에 X-Tenant-ID를 자동 추가하면, **기존 actions.ts 대부분은 수정 없이** 테넌트 헤더가 전달됨. - -### 2-3. 수동 확인 필요 케이스 - -`serverFetch`를 사용하지 않고 직접 `fetch()`를 호출하는 곳: -```bash -# 검색 대상 -grep -r "fetch(" src/components/*/actions.ts --include="*.ts" | grep -v serverFetch -``` - -### 2-4. 선택적 URL 테넌트 프리픽스 - -백엔드가 URL 경로에 테넌트를 요구하는 경우만: -```typescript -// 필요한 경우에만 적용 -function buildTenantUrl(endpoint: string, tenantId?: string): string { - if (endpoint.startsWith('http')) return endpoint; // 레거시 호환 - const base = process.env.NEXT_PUBLIC_API_URL; - return tenantId - ? `${base}/api/v1/tenant/${tenantId}/${endpoint}` - : `${base}/api/v1/${endpoint}`; -} -``` - -### 체크리스트 - -``` -- [ ] serverFetch 사용하지 않는 actions.ts 목록 확인 -- [ ] 직접 fetch() 호출하는 곳 serverFetch로 마이그레이션 -- [ ] 백엔드와 URL 패턴 vs 헤더 패턴 최종 협의 -- [ ] 고빈도 도메인 우선 검증: clients, items, production, sales -- [ ] 에러 시 테넌트 컨텍스트 누락 로그 추가 -``` - ---- - -## Phase 3: 포매터 & 유틸리티 테넌트 설정 기반 전환 🔴 `프론트 단독 가능` - -> **목표**: 한국어 하드코딩된 포매터를 테넌트 설정 기반으로 변경 -> **예상**: 3-5일 - -### 3-1. 영향받는 파일 목록 - -| 파일 | 함수 | 하드코딩 내용 | -|------|------|---------------| -| `src/utils/formatAmount.ts` | `formatAmount()` | "원", "만원" | -| `src/utils/formatAmount.ts` | `formatKoreanAmount()` | "억", "만" | -| `src/lib/formatters.ts` | `formatBusinessNumber()` | 한국 사업자번호 (XXX-XX-XXXXX) | -| `src/lib/formatters.ts` | `formatPhoneNumber()` | 한국 전화 (02-, 010-) | -| `src/utils/date.ts` | `formatDate()` | `'ko-KR'` 로케일 | - -### 3-2. TenantRegionalConfig 인터페이스 - -```typescript -// src/types/tenant-config.ts (신규) -export interface TenantRegionalConfig { - locale: string; // 'ko-KR' | 'en-US' | 'ja-JP' - timezone: string; // 'Asia/Seoul' | 'America/New_York' - currency: { - code: string; // 'KRW' | 'USD' | 'JPY' - symbol: string; // '원' | '$' | '¥' - locale: string; // Intl.NumberFormat 로케일 - largeUnitName?: string; // '만' (한국 전용) - largeUnitValue?: number; // 10000 - }; - phone: { - countryCode: string; // '+82' | '+1' | '+81' - format: string; // 'XXX-XXXX-XXXX' - }; - businessNumber: { - format: string; // 'XXX-XX-XXXXX' - label: string; // '사업자번호' | 'Business No.' | '法人番号' - }; -} -``` - -### 3-3. 마이그레이션 접근 (하위 호환) - -기존 함수를 바로 변경하지 않고, 오버로드 + 기본값 패턴 적용: - -```typescript -// 기존 호출 코드를 깨지 않는 방식 -export function formatAmount(amount: number, config?: TenantCurrencyConfig): string { - const cfg = config ?? DEFAULT_KR_CURRENCY_CONFIG; // 기본값: 한국 - // ... 테넌트 설정 기반 포매팅 -} -``` - -### 3-4. 기존 공통화 작업 참조 - -**이미 작성된 관련 문서**: -- `claudedocs/[IMPL-2026-02-05] formatter-commonization-plan.md` -- `claudedocs/[ANALYSIS-2026-01-20] 공통화-현황-분석.md` - -이 문서들의 포매터 공통화 계획과 병합하여 진행. - -### 체크리스트 - -``` -- [ ] TenantRegionalConfig 인터페이스 정의 -- [ ] DEFAULT_KR_CONFIG 기본값 생성 (하위 호환) -- [ ] formatAmount() 테넌트 설정 지원 추가 -- [ ] formatDate() 테넌트 로케일 지원 추가 -- [ ] formatBusinessNumber() 포맷 설정 지원 추가 -- [ ] formatPhoneNumber() 국가 코드 지원 추가 -- [ ] 기존 호출 코드 깨지지 않는지 검증 -- [ ] formatter-commonization-plan.md와 통합 -``` - ---- - -## Phase 4: 브랜딩 동적화 🟡 `백엔드 API 필요` - -> **목표**: 하드코딩된 회사명/로고를 테넌트 설정 기반으로 변경 -> **예상**: 2-3일 - -### 4-1. 영향받는 파일 목록 - -| 파일 | 하드코딩 | 변경 방향 | -|------|----------|-----------| -| `src/layouts/AuthenticatedLayout.tsx` | `APP_NAME = 'SAM'` | `tenant.company_name` 또는 테넌트 설정 | -| `src/layouts/AuthenticatedLayout.tsx` | `` | 테넌트별 로고 URL | -| `src/layouts/AuthenticatedLayout.tsx` | `MOCK_COMPANIES` 배열 | `user.tenant.other_tenants` 연동 | -| `src/app/[locale]/layout.tsx` | `APP_TITLE = 'SAM - 내 손안의 대시보드'` | 테넌트 설정 기반 | -| `src/app/[locale]/layout.tsx` | SEO 메타데이터 | 테넌트별 (단, 폐쇄형이므로 낮은 우선순위) | - -### 4-2. 테넌트 브랜딩 설정 구조 - -```typescript -// src/types/tenant-config.ts에 추가 -export interface TenantBrandingConfig { - appName: string; // 'SAM' | '주일 MES' | 커스텀 - appSubtitle?: string; // 'Smart Automation Management' - logoUrl: string; // '/sam-logo.png' | '/tenants/282/logo.png' - faviconUrl?: string; - primaryColor?: string; // 테마 주색상 - loginBackground?: string; // 로그인 페이지 배경 -} -``` - -### 4-3. 적용 방식 - -```typescript -// AuthenticatedLayout.tsx 내부 -const { currentUser } = useAuth(); -const branding = currentUser?.tenant?.branding ?? DEFAULT_BRANDING; - -// 로고 -{branding.appName} - -// 앱 이름 -

{branding.appName}

-``` - -### 4-4. MOCK_COMPANIES → other_tenants 연동 - -**현재**: 하드코딩 목업 -```typescript -const MOCK_COMPANIES = [ - { id: 'all', name: '전체' }, - { id: 'company1', name: '(주)삼성건설' }, - ... -]; -``` - -**변경**: 실제 테넌트 데이터 연동 -```typescript -const tenantOptions = useMemo(() => { - const current = currentUser?.tenant; - const others = current?.other_tenants ?? []; - return [current, ...others].filter(Boolean); -}, [currentUser]); -``` - -### 체크리스트 - -``` -- [ ] TenantBrandingConfig 인터페이스 정의 -- [ ] DEFAULT_BRANDING 기본값 (현재 SAM 설정) -- [ ] AuthenticatedLayout 로고/앱이름 동적화 -- [ ] MOCK_COMPANIES를 other_tenants 기반으로 교체 -- [ ] 로그인 페이지 브랜딩 동적화 -- [ ] favicon 동적 변경 (선택) -- [ ] 테넌트별 로고 파일 서빙 방식 결정 (public/ vs API) -``` - ---- - -## Phase 5: 상수 & 비즈니스 로직 외부화 🟡 `백엔드 API 필요` - -> **목표**: 한국 특화 상수를 테넌트/국가별 설정으로 외부화 -> **예상**: 3-5일 - -### 5-1. 영향받는 항목 - -| 항목 | 파일 | 현재 | 변경 | -|------|------|------|------| -| 공휴일 | `src/constants/calendarEvents.ts` | 한국 공휴일 하드코딩 | DB/API 기반 | -| 프로세스 타입 | `src/types/process.ts` | "생산", "검사" 등 | i18n 라벨 | -| 상태 라벨 | `src/lib/utils/status-config.ts` | "대기", "완료" 등 | i18n 라벨 | -| 품목 타입 | `src/types/item.ts` | "제품", "부품" 등 | i18n 라벨 | -| 근무일 | 관련 컴포넌트 | 월-금 하드코딩 | 테넌트 설정 | - -### 5-2. 외부화 전략 - -**공휴일**: 백엔드 API로 이동 (테넌트별 국가 설정에 따라 반환) -```typescript -// AS-IS: 하드코딩 -const HOLIDAYS_2026 = [ - { date: '2026-01-01', name: '신정', type: 'holiday' }, - ... -]; - -// TO-BE: API 호출 -const holidays = await getHolidays(tenantId, year); -``` - -**라벨/상태**: next-intl 다국어 시스템 활용 (이미 ko/en/ja 구조 있음) -```typescript -// AS-IS -const statusLabels = { pending: '대기', completed: '완료' }; - -// TO-BE -const t = useTranslations('status'); -const label = t('pending'); // 로케일에 따라 자동 변환 -``` - -### 체크리스트 - -``` -- [ ] calendarEvents.ts 공휴일 데이터 → API 엔드포인트로 이동 -- [ ] 프로세스 타입 라벨 → messages/ko.json, en.json, ja.json으로 이동 -- [ ] 상태 라벨 → i18n 키로 변환 -- [ ] 품목 타입 라벨 → i18n 키로 변환 -- [ ] 근무일 설정 → 테넌트 config로 이동 -- [ ] 백엔드에 공휴일 API 요청 -``` - ---- - -## Phase 6: localStorage 잔재 정리 & 타입 통일 🟡 `프론트 단독 가능` - -> **목표**: TenantAwareCache 미사용 곳 정리 + tenantId 타입 통일 -> **예상**: 2-3일 - -### 6-1. localStorage 직접 사용 감사 - -```bash -# 검색 대상 -grep -r "localStorage\.\(setItem\|getItem\)" src/ --include="*.ts" --include="*.tsx" -``` - -**알려진 비-테넌트-스코프 키**: -- `mes-users` → 사용자 목록 (테넌트 스코프 필요 여부 검토) -- `mes-currentUser` → 현재 사용자 (로그인 상태이므로 테넌트 무관) -- 기타 직접 사용 곳 → TenantAwareCache 또는 테넌트 프리픽스 적용 - -### 6-2. tenantId 타입 통일 - -**현재 상황**: -- `User.tenant.id` → `number` (AuthContext) -- `PageConfig.tenantId` → `string` (masterDataStore) -- TenantAwareCache → `number` - -**통일**: `number`로 표준화 (백엔드 응답 기준) - -```typescript -// 수정 대상 찾기 -grep -r "tenantId.*string" src/ --include="*.ts" -``` - -### 체크리스트 - -``` -- [ ] localStorage 직접 사용 전수 조사 -- [ ] TenantAwareCache로 마이그레이션 가능한 곳 목록화 -- [ ] 테넌트 스코프 불필요한 곳 명시 (mes-currentUser 등) -- [ ] tenantId: string → number 통일 -- [ ] PageConfig 타입 수정 -- [ ] 관련 타입 참조 전부 업데이트 -``` - ---- - -## Phase 7: TenantConfigService & TenantContext (선택) 🟢 `백엔드 API 필요` - -> **목표**: 테넌트 설정을 한곳에서 관리하는 서비스 레이어 -> **예상**: 3-5일 (Phase 3-5 진행 중 필요에 따라) - -### 7-1. TenantConfigService - -```typescript -// src/services/TenantConfigService.ts (신규) -export interface TenantConfiguration { - tenantId: number; - branding: TenantBrandingConfig; - regional: TenantRegionalConfig; - features: { - enabledModules: string[]; - customFields?: Record; - }; - calendar: { - workingDays: number[]; // [1,2,3,4,5] = 월-금 - holidays: HolidayEntry[]; - }; -} - -class TenantConfigService { - private cache: Map = new Map(); - - async getConfig(tenantId: number): Promise { - if (this.cache.has(tenantId)) return this.cache.get(tenantId)!; - const config = await this.fetchFromApi(tenantId); - this.cache.set(tenantId, config); - return config; - } -} -``` - -### 7-2. TenantContext Provider - -```typescript -// src/contexts/TenantContext.tsx (신규) -export function TenantProvider({ children }: { children: ReactNode }) { - const { currentUser } = useAuth(); - const [config, setConfig] = useState(); - - useEffect(() => { - if (currentUser?.tenant?.id) { - tenantConfigService.getConfig(currentUser.tenant.id) - .then(setConfig); - } - }, [currentUser?.tenant?.id]); - - return ( - - {children} - - ); -} - -// 사용 -const tenantConfig = useTenantConfig(); -const currencySymbol = tenantConfig.regional.currency.symbol; -``` - -### 체크리스트 - -``` -- [ ] TenantConfiguration 통합 인터페이스 설계 -- [ ] TenantConfigService 구현 (캐시 + API 호출) -- [ ] TenantContext Provider 구현 -- [ ] useTenantConfig() 훅 구현 -- [ ] Protected Layout에 TenantProvider 추가 -- [ ] 기존 코드에서 점진적 마이그레이션 -``` - ---- - -## Phase 8: 테넌트 라우팅 (필요 시) 🟢 `프론트 단독 가능` - -> **목표**: URL에 테넌트 식별자 포함 (필요한 경우에만) -> **예상**: 1주+ - -### 현재 라우팅 -``` -/[locale]/(protected)/dashboard -``` - -### 옵션 A: 경로 기반 (권장 - 필요 시) -``` -/[tenant]/[locale]/(protected)/dashboard -/acme/ko/dashboard -``` - -### 옵션 B: 서브도메인 기반 -``` -acme.sam.com/ko/dashboard -``` - -### 옵션 C: 현재 유지 (인증 기반만) -``` -/[locale]/(protected)/dashboard ← 테넌트는 JWT/쿠키로만 식별 -``` - -**결정**: 현재는 **옵션 C 유지**. 다중 테넌트 URL 분리가 필요해지면 옵션 A 도입. - ---- - -## 백엔드 협의 사항 - -### 필수 협의 (Phase 1 시작 전) - -| 항목 | 질문 | 결정 사항 | -|------|------|-----------| -| 테넌트 식별 방식 | `X-Tenant-ID` 헤더 vs URL 경로 vs JWT만? | TBD | -| X-Tenant-ID 수신 | 백엔드가 이 헤더를 읽고 필터링하는지? | TBD | -| JWT 내 tenant_id | 토큰에 tenant_id가 포함되어 있는지? | TBD | -| 공휴일 API | `GET /api/v1/holidays?year=2026` 지원? | TBD | -| 테넌트 설정 API | `GET /api/v1/tenant/config` 지원? | TBD | - -### 선택 협의 (Phase 4-5 시작 전) - -| 항목 | 질문 | 결정 사항 | -|------|------|-----------| -| 테넌트 로고 | 로고 URL을 어디서 제공? (API vs 파일서버) | TBD | -| 브랜딩 설정 | 테넌트별 앱이름/테마 API 제공 가능? | TBD | -| 다국어 라벨 | 백엔드 코드 라벨이 다국어 지원? | TBD | - ---- - -## 실행 우선순위 요약 - -``` -[프론트 단독] Phase 3: 포매터 테넌트 설정 기반 🔴 3-5일 ← 즉시 시작 가능 -[프론트 단독] Phase 6: localStorage 정리/타입 통일 🟡 2-3일 ← 즉시 시작 가능 -[프론트 단독] Phase 8: 테넌트 라우팅 🟢 필요시 ← 당분간 불필요 - -[백엔드 협의] Phase 1: API 테넌트 컨텍스트 주입 🔴 3-5일 ← 백엔드 확인 후 -[백엔드 협의] Phase 2: Server Actions 마이그레이션 🔴 1-2주 ← Phase 1 후 자동 적용 범위 큼 - -[백엔드 API] Phase 4: 브랜딩 동적화 🟡 2-3일 ← 테넌트 설정 API 필요 -[백엔드 API] Phase 5: 상수/공휴일 외부화 🟡 3-5일 ← 공휴일 API 필요 -[백엔드 API] Phase 7: TenantConfigService 🟢 3-5일 ← 통합 설정 API 필요 -``` - -### 병렬 진행 가능 조합 - -``` -[즉시 시작 - 프론트 단독] -├─ Phase 3 (포매터) ─────────→ 독립 완료 -└─ Phase 6 (localStorage) ──→ 독립 완료 - -[백엔드 협의 후 - 프론트+백엔드] -└─ Phase 1 (API 헤더) ──────→ Phase 2 (Actions 자동 적용) - -[백엔드 API 준비 후 - 프론트+백엔드] -└─ Phase 7 (TenantConfig) ──→ Phase 4 (브랜딩) + Phase 5 (상수) -``` - ---- - -## 위험 요소 & 대응 - -| 위험 | 확률 | 영향 | 대응 | -|------|------|------|------| -| 70+ actions.ts 수동 마이그레이션 | 높음 | 중간 | serverFetch 자동 주입으로 대부분 해결 | -| 백엔드 X-Tenant-ID 미지원 | 중간 | 높음 | Phase 1 시작 전 백엔드 팀 협의 필수 | -| 포매터 변경 시 기존 UI 깨짐 | 낮음 | 중간 | 기본값 패턴으로 하위 호환 유지 | -| 캐시 무효화 누락 | 낮음 | 높음 | TenantAwareCache 이미 검증됨 | -| 다국어 번역 리소스 부족 | 중간 | 낮음 | 한국어 기본값 유지, 점진 추가 | - ---- - -## 관련 문서 - -| 문서 | 설명 | -|------|------| -| `architecture/[REF-2025-11-19] multi-tenancy-implementation.md` | 이전 멀티테넌시 구현 (Phase 1-2 → 완료됨) | -| `architecture/[TEST-2025-11-19] multi-tenancy-test-guide.md` | 캐시 격리 테스트 가이드 | -| `architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md` | masterDataStore 캐시 테넌트 격리 수정 | -| `[IMPL-2026-02-05] formatter-commonization-plan.md` | 포매터 공통화 계획 (Phase 3과 병합) | -| `[ANALYSIS-2026-01-20] 공통화-현황-분석.md` | 공통화 현황 분석 | -| `[ANALYSIS-2026-02-05] list-page-commonization-status.md` | 리스트 페이지 공통화 현황 | -| `auth/[IMPL-2025-11-07] jwt-cookie-authentication-final.md` | JWT 쿠키 인증 구현 | -| `api/[REF] api-requirements.md` | API 요구사항 | - ---- - -## 변경 이력 - -| 날짜 | 변경 내용 | -|------|-----------| -| 2026-02-06 | 초기 작성 - 전체 코드베이스 분석 기반 8 Phase 로드맵 | - ---- - -**다음 액션**: Phase 1 시작 전 백엔드 팀과 `X-Tenant-ID` 헤더 수신 방식 협의 diff --git a/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md b/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md deleted file mode 100644 index 432fbd97..00000000 --- a/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md +++ /dev/null @@ -1,446 +0,0 @@ -# 리팩토링 로드맵 - -**작성일**: 2026-02-06 -**목적**: 전체 코드베이스 리팩토링 포인트 점검 및 실행 계획 -**상태**: Phase 1 완료, Phase 3 완료 (공용 유틸 추출), Phase 4 SearchableSelectionModal 완료 - ---- - -## 현재 코드베이스 수치 (2026-02-06 기준) - -| 지표 | 수치 | 비고 | -|------|------|------| -| 전체 코드 | ~301,000줄 | TS/TSX | -| 컴포넌트 파일 | ~551개 | | -| 페이지 파일 | ~253개 | | -| action.ts 파일 | 80개 | 거의 동일 CRUD 패턴 | -| types.ts 파일 | 94개 | 중복 타입 다수 | -| 모달 컴포넌트 | 42개 | 유사 패턴 반복 | -| 2000줄+ 파일 | 4개 | God 컴포넌트 | -| 1000~2000줄 파일 | 25+개 | 분리 대상 | -| 500~1000줄 파일 | 50+개 | 검토 대상 | - ---- - -## God 컴포넌트 / 대형 파일 목록 - -### 🔴 2000줄 이상 (즉시 분리 필요) - -| 파일 | 줄수 | 핵심 문제 | 분리 방향 | -|------|------|-----------|-----------| -| `components/business/MainDashboard.tsx` | 2,651 | CEO/영업/생산/품질 대시보드 한 파일 | 역할별 섹션 컴포넌트 분리 | -| `contexts/ItemMasterContext.tsx` | 2,406 | useState 17개, useEffect 15개, 함수 50+개 | 도메인별 5개 Context 분리 | -| `lib/api/item-master.ts` | 2,232 | 모든 품목 API 한 파일 | 도메인별 API 모듈 분리 | -| `lib/api/dashboard/transformers.ts` | 1,576 | 전체 대시보드 변환 로직 | 섹션별 transformer 분리 | - -### 🟡 1000~2000줄 (우선 검토) - -| 파일 | 줄수 | 도메인 | 분리 방향 | -|------|------|--------|-----------| -| `components/orders/actions.ts` | 1,394 | 수주 | 서비스 레이어 분리 | -| `components/accounting/ExpectedExpenseManagement/index.tsx` | 1,299 | 회계 | 서브 컴포넌트 추출 | -| `layouts/AuthenticatedLayout.tsx` | 1,289 | 레이아웃 | 훅 24개 → 섹션별 분리 | -| `components/quotes/QuoteRegistration.tsx` | 1,268 | 견적 | 폼 섹션 추출, useState 13개 | -| `components/quotes/actions.ts` | 1,266 | 견적 | API 레이어 분리 | -| `components/business/construction/management/actions.ts` | 1,222 | 건설 | 도메인 서비스 추출 | -| `components/business/construction/estimates/actions.ts` | 1,222 | 건설 | 도메인 서비스 추출 | -| `components/production/WorkerScreen/index.tsx` | 1,198 | 생산 | 화면 섹션 분리 | -| `hooks/useCEODashboard.ts` | 1,172 | 대시보드 | useState 18개 → 섹션별 훅 분리 | -| `components/material/ReceivingManagement/actions.ts` | 1,152 | 자재 | API 서비스 레이어 | -| `components/quotes/types.ts` | 1,149 | 견적 | 타입 조직화 | -| `components/quality/InspectionManagement/InspectionDetail.tsx` | 1,125 | 품질 | 컴포넌트 추출 | -| `components/hr/VacationManagement/actions.ts` | 1,125 | HR | 서비스 레이어 분리 | -| `components/orders/OrderRegistration.tsx` | 1,123 | 수주 | 폼 섹션 추출, useState 12개 | -| `components/items/DynamicItemForm/index.tsx` | 1,073 | 품목 | 복합 폼 로직 추출 | -| `components/templates/IntegratedListTemplateV2.tsx` | 1,066 | 템플릿 | 템플릿 특화 | -| `components/hr/EmployeeManagement/EmployeeForm.tsx` | 1,051 | HR | 폼 섹션 분리 | -| `components/quotes/QuoteRegistrationV2.tsx` | 1,020 | 견적 | 폼 리팩토링 | -| `components/templates/UniversalListPage/index.tsx` | 1,007 | 템플릿 | 템플릿 최적화 | -| `components/items/ItemMasterDataManagement.tsx` | 1,005 | 품목 | 도메인 로직 추출 | - ---- - -## 중복 패턴 분석 - -### 1. 액션 파일 80개 동일 패턴 (~24,000줄 중복) - -**현재**: 모든 도메인이 이 구조를 복붙 -```typescript -'use server'; -import { serverFetch } from '@/lib/api/fetch-wrapper'; - -interface Api[Domain]Data { ... } // 타입 정의 100~300줄 -function transform(data) { ... } // API→프론트 변환 50~100줄 - -export async function getList(params) { // 목록 조회 - const url = `${API_URL}/api/v1/endpoint`; - const { response } = await serverFetch(url, { method: 'GET' }); - return transform(response); -} -export async function getById(id) { ... } // 상세 조회 -export async function create(data) { ... } // 생성 -export async function update(id, data) { ... } // 수정 -export async function delete(id) { ... } // 삭제 -export async function bulkDelete(ids) { ... } // 일괄 삭제 -``` - -**해당 도메인**: orders, quotes, clients, accounting(13모듈), hr(6모듈), production(4모듈), material(2모듈), quality(2모듈), construction(17모듈), settings(14모듈) - -**해결 방향**: 제네릭 API 서비스 팩토리 -```typescript -// lib/api/createCrudService.ts -function createCrudService(config: { - endpoint: string; - transform: (api: TApi) => TFront; - reverseTransform: (front: TFront) => Partial; -}) { - return { - getList: async (params) => { ... }, - getById: async (id) => { ... }, - create: async (data) => { ... }, - update: async (id, data) => { ... }, - delete: async (id) => { ... }, - bulkDelete: async (ids) => { ... }, - }; -} - -// 사용: 10줄로 끝 -const orderService = createCrudService({ - endpoint: 'orders', - transform: transformOrder, - reverseTransform: reverseTransformOrder, -}); -``` - -### 2. 데이터 페칭 패턴 3가지 혼재 - -| 패턴 | 사용 비율 | 위치 | -|------|-----------|------| -| useEffect + .then() 직접 호출 | ~75% (99+ 컴포넌트) | 대부분의 도메인 | -| 커스텀 훅 (useDetailData 등) | ~15% (~15 컴포넌트) | 신규 구현 | -| ApiClient 클래스 | ~10% (15 컴포넌트) | 건설 도메인만 | - -**수동 로딩 상태 관리**: 262곳에서 반복 -```typescript -// 이 패턴이 262번 반복됨 -const [data, setData] = useState([]); -const [isLoading, setIsLoading] = useState(true); -const [error, setError] = useState(null); - -useEffect(() => { - setIsLoading(true); - fetchData() - .then(result => setData(result)) - .catch(err => setError(err)) - .finally(() => setIsLoading(false)); -}, []); -``` - -### 3. 폼 검증 3가지 방식 혼재 - -| 방식 | 사용 파일 수 | 비율 | -|------|-------------|------| -| Zod 스키마 (정석) | 3개 (로그인, 회원가입, 품목) | 5% | -| 수동 if문 검증 | 50+개 | 60% | -| 검증 없음 | ~30개 | 35% | - -### 4. 리스트 페이지 템플릿 이중화 - -| 방식 | 사용 | 비율 | -|------|------|------| -| `UniversalListPage` (신규 표준) | 20개 페이지 | 25% | -| 수동 구현 (레거시) | 60+ 페이지 | 75% | - -### 5. 모달/다이얼로그 42개 유사 패턴 - -**검색/선택 모달 5개+ 거의 동일**: -- `quotes/ItemSearchModal.tsx` -- `production/WorkOrders/AssigneeSelectModal.tsx` -- `material/ReceivingManagement/SupplierSearchModal.tsx` -- `quality/InspectionManagement/OrderSelectModal.tsx` -- `production/WorkOrders/SalesOrderSelectModal.tsx` - -전부 "검색 입력 → API 호출 → 목록 표시 → 체크박스 선택 → 확인" 동일 구조 -→ `SearchableSelectionModal` 하나로 통합 가능 - ---- - -## 성능 최적화 포인트 - -| 항목 | 현재 상태 | 영향도 | 해결 방향 | -|------|-----------|--------|-----------| -| React.memo | 551개 컴포넌트 중 **1개만** 사용 | 🔴 높음 | 리스트 아이템/카드 컴포넌트에 적용 | -| 인라인 화살표 함수 | **746곳** `onClick={() => ...}` | 🟡 중간 | 대형 컴포넌트에서 useCallback 적용 | -| useMemo 미사용 | 대용량 배열 필터링/정렬 곳곳 | 🟡 중간 | 비용 높은 계산에 적용 | - -**React.memo 우선 적용 대상** (리스트 내 반복 렌더링 컴포넌트): -- `production/WorkerScreen/WorkItemCard.tsx` -- `board/CommentSection/CommentItem.tsx` -- `business/construction/management/ProjectCard.tsx` -- 기타 *Row, *Item, *Card 컴포넌트 30+개 - ---- - -## 타입 시스템 문제 - -| 항목 | 수치 | 영향 | -|------|------|------| -| `any` 타입 사용 | 102곳 (29개 파일) | 타입 안전성 저하 | -| 동일 엔티티 다중 타입 정의 | Vendor, Item, Order 등 | 변환 코드 ~800줄 중복 | -| types.ts 파일 | 94개 | 정규 타입 찾기 어려움 | -| @ts-ignore/eslint-disable | 25개 파일 | 숨겨진 타입 에러 | - ---- - -## 추출 가능한 공통 훅 목록 - -### 즉시 생성 가능 (프론트 단독) - -| 훅 이름 | 대체 범위 | 예상 절감 | 기존 참고 | -|---------|-----------|-----------|-----------| -| `useListData` | 60+ 리스트 페이지 | ~4,000줄 | hooks/useDetailData.ts 패턴 확장 | -| `useFormSubmit` | 80+ 폼 | ~3,000줄 | 신규 | -| `usePagination` | 60+ 컴포넌트 | ~1,000줄 | 신규 | -| `useModal` | 42 모달 | ~500줄 | 신규 | -| `useClientSideFiltering` | 55+ 컴포넌트 | ~800줄 | 신규 | - -### 기존 훅 (활용 확대 필요) - -| 훅 | 현재 사용 | 전체 적용 시 | -|----|-----------|-------------| -| `useDetailData` | ~15 컴포넌트 | 100+ 상세 페이지 | -| `useDetailPageState` | ~10 컴포넌트 | 100+ 상세 페이지 | -| `useCRUDHandlers` | ~10 컴포넌트 | 80+ CRUD 페이지 | - ---- - -## 실행 계획 - -### Phase 1: 공통 훅 추출 ✅ 완료 (2026-02-09) - -> 실제 코드 분석 결과 계획 수정 → 실증 기반 리팩토링 실행 - -**실행 결과** (계획 vs 실제): -``` -기존 계획의 useListData, usePagination, useClientSideFiltering, useModal은 -UniversalListPage 템플릿이 이미 내부 처리 → 불필요 판정. - -실제 실행: -- [x] Step 1: executeServerAction (82개 action.ts 에러처리 래퍼) → ~3,000줄 절감 -- [x] Step 2: useDeleteDialog (6개 파일 삭제 다이얼로그 통합) → ~150줄 절감 -- [x] Step 3: useStatsLoader (7개 파일 stats 로딩 통합) → ~100줄 절감 -- [x] Step 4: React.memo 3개 + any→unknown 7건 + @ts-ignore 0건 -``` - -**실제 효과**: ~3,750줄 절감, 82개 action.ts 패턴 통일, 타입 안전성 향상 -**상세**: `refactoring/[IMPL-2026-02-09] phase1-common-hooks-checklist.md` - ---- - -### Phase 2: God 컴포넌트 분리 (2-3주) `프론트 단독` - -> 2000줄+ 파일 4개 + 핵심 1000줄+ 파일 우선 분리 - -**작업 항목**: -``` -- [ ] MainDashboard.tsx (2,651줄) 분리 - → sections/CEOSection, SalesSection, ProductionSection, QualitySection - → hooks/useDashboardData - → utils/calculations -- [ ] ItemMasterContext.tsx (2,406줄) 분리 - → ItemContext, SpecificationContext, MaterialContext - → TemplateContext, AttributeContext -- [ ] useCEODashboard.ts (1,172줄) 분리 - → useDailyReport, useReceivables, useMonthlyExpense 등 개별 훅 - → 훅 팩토리 패턴 적용 -- [ ] lib/api/item-master.ts (2,232줄) 분리 - → 도메인별 API 모듈 (items, specifications, materials, templates) -- [ ] AuthenticatedLayout.tsx (1,289줄) - → useLayoutState, useNavigation, useTenantBranding 훅 추출 -``` - -**예상 효과**: 유지보수성 +50%, 단위 테스트 가능성 확보 - ---- - -### Phase 3: 액션 파일 공용 유틸 추출 ✅ 완료 (2026-02-10) - -> 전수 분석 → 팩토리 ROI 재평가 → 공용 유틸 추출로 전략 변경 - -**전수 분석 결과** (82개 action 파일): -``` -- 35개: executeServerAction 패턴 (Phase 1에서 통일) -- 15개: 모의 데이터 (mock, API 미연동) -- 13개: ApiClient 클래스 패턴 (건설 도메인) -- 나머지: 특수 도메인 로직 (견적, 수주, 품목 등) -``` - -**팩토리 마이그레이션 ROI 재평가**: -``` -- createCrudService 팩토리: 2개(Rank, Title)만 적합 → ROI ~6% (너무 낮음) -- 대부분 파일: 페이지네이션, 커스텀 쿼리 파라미터, 도메인 특화 로직으로 팩토리 패턴 부적합 -- 결론: 팩토리 대량 마이그레이션 대신 공용 유틸 추출로 전략 전환 -``` - -**실행 결과** (2026-02-10): -``` -Step 1: 공용 타입 추출 (src/lib/api/types.ts) - - [x] PaginatedApiResponse — 25+ 파일에서 중복 정의 제거 - - [x] PaginationMeta, PaginatedResult — 프론트엔드 표준 페이지네이션 타입 - - [x] toPaginationMeta() — snake_case → camelCase 변환 헬퍼 - - [x] SelectOption — 공용 선택 옵션 타입 - -Step 2: 공용 룩업 헬퍼 추출 (src/lib/api/shared-lookups.ts) - - [x] fetchVendorOptions() — 거래처 목록 조회 (4개 파일 중복 제거) - - [x] fetchBankAccountOptions() — 계좌 목록 조회 (심플) - - [x] fetchBankAccountDetailOptions() — 계좌 상세 조회 (bankName, accountNumber 포함) - - [x] BankAccountOption 타입 - -Step 3: PaginatedResponse 타입 마이그레이션 (~20개 파일) - - [x] 제네릭 패턴 (interface PaginatedResponse) → import PaginatedApiResponse - - [x] 도메인 패턴 (interface XxxPaginatedResponse) → type alias - - 스킵: VendorManagement/types.ts (page?/size? 비표준), PermissionManagement/types.ts (meta 래퍼) - -Step 4: 공용 룩업 헬퍼 마이그레이션 (4개 파일) - - [x] DepositManagement/actions.ts — getVendors + getBankAccounts 교체 - - [x] WithdrawalManagement/actions.ts — getVendors + getBankAccounts 교체 - - [x] PurchaseManagement/actions.ts — getVendors + getBankAccounts(상세) 교체 - - [x] ExpectedExpenseManagement/actions.ts — getBankAccounts(상세) 교체 - -Step 5: TypeScript 검증 통과 ✅ -``` - -**실측 효과**: -- PaginatedResponse 중복 제거: ~20개 파일, 파일당 ~7줄 = ~140줄 절감 -- 공용 룩업 헬퍼: 4개 파일, 파일당 ~20줄 = ~80줄 절감 -- 총 ~220줄 직접 절감 + 향후 새 파일에서 중복 방지 -- createCrudService + TitleManagement 마이그레이션: ~36줄 절감 (프로토타입 포함) - -**생성된 공용 파일**: -- `src/lib/api/types.ts` — 공용 API 타입 (PaginatedApiResponse, PaginationMeta 등) -- `src/lib/api/shared-lookups.ts` — 공용 룩업 헬퍼 (fetchVendorOptions 등) -- `src/lib/api/create-crud-service.ts` — CRUD 팩토리 (Rank, Title 2개 사용) - ---- - -### Phase 4: 템플릿/패턴 통일 (2-3주) `프론트 단독` `SearchableSelectionModal 완료` - -> UniversalListPage 확대 + 검증 표준화 + 모달 통합 - -**SearchableSelectionModal 완료** (2026-02-10): -``` -- [x] SearchableSelectionModal 공통 컴포넌트 생성 - - types.ts, useSearchableData.ts, SearchableSelectionModal.tsx, index.ts - - 단일선택(single) + 다중선택(multiple) + listWrapper(테이블용) 지원 -- [x] ItemSearchModal 교체 (212→113줄, -47%) -- [x] SupplierSearchModal 교체 (268→161줄, -40%) -- [x] SalesOrderSelectModal 교체 (163→101줄, -38%) -- [x] QuotationSelectDialog 교체 (196→113줄, -42%) -- [x] OrderSelectModal 교체 (220→107줄, -51%) -- [x] organisms/index.ts export 추가 -- [x] CLAUDE.md 공통 컴포넌트 사용 규칙 + claudedocs 가이드 문서 작성 -``` -**실측 효과**: 1,059줄 → 595줄 (464줄 절감, -44%) + 공통 컴포넌트 ~430줄 - -**남은 작업**: -``` -- [ ] UniversalListPage 기능 보강 - - 고급 필터 UI - - 컬럼 커스터마이징 - - 내보내기 기능 -- [ ] 레거시 리스트 페이지 → UniversalListPage 마이그레이션 (우선 20개) -- [ ] Zod 검증 스키마 라이브러리 구축 - - lib/validations/common.ts (이메일, 전화, 사업자번호) - - lib/validations/vendor.ts, order.ts, item.ts 등 -- [ ] 수동 검증 50+ 폼 → Zod 마이그레이션 (우선 10개) -``` - -**예상 효과**: ~5,000줄 절감 (SearchableSelectionModal ~464줄 달성), UX 일관성 +80% - ---- - -### Phase 5: 성능 + 타입 정리 (1-2주) `프론트 단독` `일부 Phase 1에서 선처리` - -> React.memo 적용 + any 제거 + 타입 통합 - -**Phase 1에서 선처리된 항목** (2026-02-09): -``` -- [x] React.memo 3개 적용 (InfoField, CommentItem, WorkItemCard) -- [x] any→unknown 7건 (logger.ts) -- [x] action error handler any 50+곳 (executeServerAction으로 자동 해결) -- [x] @ts-ignore 0건 (이미 제거 완료) -``` - -**남은 작업**: -``` -- [ ] React.memo 추가 적용 (나머지 리스트 아이템 컴포넌트) -- [ ] 대형 컴포넌트 useCallback 적용 -- [ ] any 타입 잔여 92건 - - items/ 도메인 60건 (복잡도 높음, 별도 작업) - - Form 에러 캐스팅 26건 (RHF 타입 시스템 변경 필요) - - dev/ 프로토타입 6건 (비프로덕션) -- [ ] 공통 타입 라이브러리 정리 - - types/shared/ 폴더 생성 - - PaginatedApiResponse ✅ Phase 3에서 완료 (src/lib/api/types.ts) - - FormState, SelectOption 등 추가 타입 -``` - -**예상 효과**: 리스트 렌더링 30-50% 개선, 타입 안전성 +60% - ---- - -## 전체 예상 효과 요약 - -| 지표 | Phase 1 ✅ | Phase 2 | Phase 3 ✅ | Phase 4 | Phase 5 | 합계 | -|------|-----------|---------|---------|---------|---------|------| -| 코드 절감 | ~3,750줄 (실측) | (구조 개선) | ~256줄 (실측) | ~5,000줄 | (품질 개선) | **~9,000줄+** | -| 중복 제거 | 82개 action 통일 | - | 25+ 타입 + 4 룩업 통합 | 5 모달 통합 | - | 종합 개선 | -| 패턴 일관성 | +60% | +50% | +30% (타입 표준화) | +80% | +60% | 종합 개선 | -| 유지보수성 | 높음 | 매우 높음 | 중간 (공용 유틸) | 중간 | 중간 | 종합 개선 | -| 위험도 | 낮음 | 중간 | 낮음 (완료) | 낮음 | 낮음 | - | - ---- - -## 병렬 진행 가능 조합 - -``` -[완료] -├─ Phase 1 (공통 훅) ──────→ ✅ 완료 (2026-02-09) -├─ Phase 3 (공용 유틸 추출) ──→ ✅ 완료 (2026-02-10) -├─ Phase 4 (SearchableSelectionModal) → ✅ 완료 (2026-02-10) -│ -[즉시 시작 가능] -├─ Phase 2 (God 컴포넌트 분리) ──→ Phase 1 훅 + Phase 3 공용 타입 활용 -├─ Phase 4 남은 작업 (UniversalListPage 확대, Zod 검증) -├─ Phase 5 (성능/타입) ─────→ 일부 Phase 1/3에서 선처리됨 -``` - ---- - -## 관련 문서 - -| 문서 | 설명 | -|------|------| -| `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md` | 멀티테넌시 공통화 로드맵 (별도 트랙) | -| `[ANALYSIS-2026-01-20] 공통화-현황-분석.md` | 공통화 현황 분석 | -| `[ANALYSIS-2026-02-05] list-page-commonization-status.md` | 리스트 페이지 공통화 현황 | -| `[IMPL-2026-02-05] detail-hooks-migration-plan.md` | 상세 페이지 훅 마이그레이션 계획 | -| `[IMPL-2026-02-05] formatter-commonization-plan.md` | 포매터 공통화 계획 | -| `[PLAN-2026-01-22] ui-component-abstraction.md` | UI 컴포넌트 추상화 계획 | -| `guides/[PLAN-2025-12-23] common-component-extraction-plan.md` | 공통 컴포넌트 추출 계획 | - ---- - -## 변경 이력 - -| 날짜 | 변경 내용 | -|------|-----------| -| 2026-02-06 | 초기 작성 - 전체 코드베이스 분석 기반 5 Phase 로드맵 | -| 2026-02-09 | Phase 1 완료 반영 - 실측 기반 효과 수치 보정 (8,500줄→3,750줄), executeServerAction/useDeleteDialog/useStatsLoader 3개 훅 생성 완료 | -| 2026-02-09 | Phase 3 프로토타입 검증 완료 - createCrudService 팩토리 생성, RankManagement 5/5 CRUD 정상, Server Action 호환성 확인 | -| 2026-02-10 | Phase 4 SearchableSelectionModal 완료 - 5개 모달 통합, 464줄 절감(-44%), 가이드 문서 작성 | -| 2026-02-10 | Phase 3 완료 - 전수 분석 후 팩토리 ROI 재평가(~6%), 공용 유틸 추출로 전략 전환. PaginatedApiResponse 25+파일 타입 통합, 공용 룩업 헬퍼 4파일 중복 제거, ~256줄 절감 | - ---- - -**모든 Phase 프론트 단독 가능** - 백엔드 의존성 없음 diff --git a/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md b/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md deleted file mode 100644 index c8bce86a..00000000 --- a/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md +++ /dev/null @@ -1,950 +0,0 @@ -# 동적 멀티테넌트 페이지 시스템 설계 - -> 작성일: 2026-03-11 -> 최종 업데이트: 2026-03-18 -> 상태: 초안 (백엔드 논의 진행 중) -> 관련 문서: -> - `[VISION-2026-02-19] dynamic-rendering-platform-strategy.md` -> - `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md` -> - `[DESIGN-2026-02-11] dynamic-field-type-extension.md` -> - `[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md` -> - `[PLAN-2026-03-17] tenant-module-separation-plan.md` — **본 설계의 실행 계획 (Phase 0~3)** - ---- - -## 1. 핵심 목표 - -``` -현재: 테넌트(업종)별 페이지를 하드코딩 → 신규 테넌트마다 개발 필요 -목표: 백엔드 기준관리에서 설정 → JSON API → 프론트 동적 렌더링 -결과: 프론트엔드 코드 변경 0줄로 새 테넌트 대응 -``` - ---- - -## 2. 전체 아키텍처 - -``` -┌─────────────────────────────────────────────────────────┐ -│ 백엔드 어드민 (mng) │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ 기준관리 페이지 │ │ -│ │ 레이아웃 / 섹션 / 항목 / 속성 등록 │ │ -│ └───────────────┬───────────────────────────────────┘ │ -│ │ 저장 │ -│ ↓ │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ DB (테넌트별 페이지 config) │ │ -│ └───────────────┬───────────────────────────────────┘ │ -│ │ API │ -└──────────────────┼──────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────┐ -│ 프론트엔드 (Next.js) │ -│ │ -│ ┌────────────┐ ┌─────────────────────────────────┐ │ -│ │ 정적 페이지 │ │ 동적 페이지 │ │ -│ │ - 로그인 │ │ - catch-all route │ │ -│ │ - 회원가입 │ │ - JSON config → 동적 렌더링 │ │ -│ │ - 404 등 │ │ - pageType별 렌더러 선택 │ │ -│ └────────────┘ └─────────────────────────────────┘ │ -│ │ │ │ -│ └──── 공유 컴포넌트 ───────┘ │ -│ (ui/, molecules/, organisms/) │ -└──────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. 규칙 정의 - -### 규칙 1: 기준관리 → 백엔드 어드민 - -| 항목 | 내용 | -|------|------| -| 현재 | 프론트 `ItemMasterDataManagement` 등에서 기준관리 | -| 변경 | 백엔드 어드민(mng) 페이지로 이동 | -| 이유 | 프론트 번들 크기 감소, 설정 변경 = 배포 불필요 | -| 담당 | 🔵 백엔드 | - -``` -Before: 프론트 기준관리 UI → 프론트 API 호출 → DB 저장 -After: 백엔드 어드민 UI → 직접 DB 저장 → API로 config 전달 -``` - ---- - -### 규칙 2: 페이지 정보를 JSON API로 제공 - -| 항목 | 내용 | -|------|------| -| 방식 | 메뉴 API처럼 페이지 config도 JSON API로 제공 | -| 엔드포인트 | `GET /api/v1/page-configs/{slug}` (제안) | -| 응답 | 페이지 타입, 레이아웃, 섹션, 필드, 검증규칙, API 매핑 등 | -| 담당 | 🔵 백엔드 API 설계 | - -**페이지 config JSON 구조 (제안)**: - -```jsonc -{ - "pageId": "sales-order-list", - "pageType": "list", // list | detail | form | dashboard | document - "title": "수주 관리", - "slug": "sales/order-management", - - // --- 규칙 11: API 엔드포인트 매핑 --- - "api": { - "list": "/api/v1/orders", - "detail": "/api/v1/orders/:id", - "create": "/api/v1/orders", - "update": "/api/v1/orders/:id", - "delete": "/api/v1/orders/:id" - }, - - // --- 규칙 4: 레이아웃 > 섹션 > 항목 > 속성 --- - "layout": { - "sections": [ - { - "sectionId": "filters", - "sectionType": "filter", - "fields": [ - { - "fieldId": "status", - "type": "select", - "label": "상태", - "options": [ - { "value": "all", "label": "전체" }, - { "value": "pending", "label": "대기" }, - { "value": "confirmed", "label": "확정" } - ], - "defaultValue": "all" - }, - { - "fieldId": "dateRange", - "type": "dateRange", - "label": "기간" - } - ] - }, - { - "sectionId": "table", - "sectionType": "dataTable", - "columns": [ - { "key": "orderNo", "label": "수주번호", "width": 120 }, - { "key": "clientName", "label": "거래처명", "width": 150 }, - { "key": "amount", "label": "금액", "type": "currency", "align": "right" }, - { "key": "status", "label": "상태", "type": "badge" } - ], - "actions": ["view", "edit", "delete"], - "pagination": true - } - ] - }, - - // --- 규칙 12: 검증 규칙 --- - "validation": { - "quantity": { "required": true, "min": 1, "message": "1 이상 입력하세요" }, - "clientId": { "required": true, "message": "거래처를 선택하세요" } - }, - - // --- 규칙 13: 필드 간 의존성 --- - "dependencies": [ - { - "type": "visibility", - "when": { "field": "itemType", "equals": "motor" }, - "show": ["motorSpec", "voltage"] - }, - { - "type": "computed", - "target": "amount", - "formula": "quantity * unitPrice" - }, - { - "type": "cascade", - "source": "category1", - "target": "category2", - "api": "/api/v1/categories/:parentId/children" - } - ], - - // --- 규칙 14: 권한 --- - "permissions": { - "fieldLevel": { - "unitPrice": { "view": ["admin", "sales_manager"], "edit": ["admin"] } - }, - "actionLevel": { - "delete": ["admin"], - "export": ["admin", "sales_manager"] - } - } -} -``` - -> ⚠️ **백엔드 논의 필요**: JSON 구조의 세부 스펙 확정 - -#### 2-2. 백엔드 저장 방식: JSONB (확정) - -> ✅ **확정**: 페이지 config는 PostgreSQL **JSONB** 타입으로 저장 - -| 항목 | JSON | JSONB (채택) | -|------|------|:---:| -| 저장 형태 | 텍스트 그대로 | 바이너리 (파싱된 형태) | -| 읽기 속도 | 매번 파싱 필요 | 이미 파싱됨 → **빠름** | -| 인덱싱 | ❌ 불가 | ✅ **GIN 인덱스 가능** | -| 내부 검색 | ❌ 전체 꺼내서 비교 | ✅ **특정 키/값으로 쿼리** | -| 부분 수정 | ❌ 전체 교체 | ✅ **특정 키만 업데이트** | - -**JSONB가 필요한 이유 — 우리 시스템과의 연관**: - -```sql --- 1. 테넌트별 특정 타입 페이지만 조회 (인덱싱) -SELECT * FROM page_configs -WHERE tenant_id = 282 -AND config->>'pageType' = 'list'; - --- 2. 특정 필드 타입을 쓰는 페이지 검색 (내부 검색) -SELECT * FROM page_configs -WHERE config @> '{"layout":{"sections":[{"fields":[{"type":"reference"}]}]}}'; - --- 3. 기준관리에서 섹션 하나만 수정 (부분 수정) -UPDATE page_configs -SET config = jsonb_set(config, '{layout,sections,0,title}', '"수정된 섹션명"'); -``` - -**JSONB 채택이 config 구조 설계에 미치는 영향**: - -| 영향 | 설명 | -|------|------| -| **구조 단순화** | 하나의 큰 JSONB에 전체 config를 담아도 부분 쿼리/수정 가능 → 테이블 분리 최소화 | -| **테넌트 분기** | JSONB 인덱스로 테넌트+pageType 조합 쿼리가 빠름 → 별도 테이블 불필요 | -| **기준관리 UI** | 섹션 하나만 수정해도 전체 config를 다시 저장할 필요 없음 → UX 향상 | -| **프론트 영향** | **없음** — 프론트는 동일한 JSON을 받아서 렌더링, 저장 방식 무관 | - -``` -DB 테이블 구조 (제안): - -page_configs -├── id (PK) -├── tenant_id (FK, 인덱스) -├── slug (UNIQUE per tenant, 인덱스) -├── config (JSONB) ← 페이지 config 전체 -├── created_at -└── updated_at - -GIN 인덱스: config에 대해 생성 → 내부 검색 고속화 -복합 인덱스: (tenant_id, slug) → 테넌트별 페이지 조회 최적화 -``` - -> ⚠️ **백엔드 논의 필요**: JSONB 기반 테이블 설계 세부 확정 (위 제안 구조 검토) - ---- - -### 규칙 3: 정적 페이지 vs 동적 페이지 분류 - -| 분류 | 정적 페이지 | 동적 페이지 | -|------|------------|------------| -| 정의 | 테넌트 무관, 고정 UI | 테넌트 config 기반 동적 생성 | -| 예시 | 로그인, 회원가입, 404, 500 | 수주관리, 품목관리, 공정관리 등 | -| 라우팅 | 기존 파일 기반 라우트 | catch-all `[...slug]` | -| 컴포넌트 | 직접 코딩 | JSON → 동적 렌더러 | -| 변경 빈도 | 거의 없음 | 테넌트별/설정별 수시 변경 | - -**정적 페이지 목록 (확정)**: - -| 경로 | 페이지 | 이유 | -|------|--------|------| -| `/login` | 로그인 | 인증 전 접근, 공통 UI | -| `/signup` | 회원가입 | 인증 전 접근, 공통 UI | -| `/404` | Not Found | 에러 페이지 | -| `/500` | Server Error | 에러 페이지 | -| `/settings/*` | 설정 | 시스템 설정은 공통 | - -> ⚠️ **논의 필요**: 설정 페이지 중 일부(구독, 결제)도 동적 대상인지? -> ⚠️ **논의 필요**: 대시보드는 동적 페이지? 위젯 기반 별도 시스템? - ---- - -### 규칙 4: 계층 구조 — 레이아웃 > 섹션 > 항목 > 속성 - -``` -Page (pageType에 의해 렌더러 결정) - └─ Layout (전체 레이아웃: single-column, two-column, tabs 등) - └─ Section (논리적 그룹: 기본정보, 상세정보, 테이블 등) - └─ Field (개별 입력 항목: input, select, date 등) - └─ Attribute (필드의 속성: label, placeholder, validation 등) -``` - -| 계층 | 역할 | 기준관리 등록 항목 | 프론트 컴포넌트 | -|------|------|-------------------|----------------| -| Layout | 전체 배치 | 레이아웃 타입 선택 | `DynamicPageLayout` | -| Section | 논리적 그룹 | 섹션 추가/순서/조건부 표시 | `DynamicSection` | -| Field | 개별 항목 | 필드 타입/라벨/기본값 | `DynamicFieldRenderer` (14종) | -| Attribute | 필드 속성 | 검증규칙/옵션/의존성 | props로 전달 | - ---- - -### 규칙 5: 컴포넌트 책임 분리 - -``` -┌─────────────────────────────────────────────────┐ -│ 상위: 데이터 처리 컴포넌트 (Layout, Section) │ -│ - API 호출 / 데이터 가공 │ -│ - 조건부 표시 로직 │ -│ - props 전달 / 이벤트 핸들링 │ -│ - Zustand store 구독 │ -└──────────────────┬──────────────────────────────┘ - │ props (순수 데이터) - ↓ -┌─────────────────────────────────────────────────┐ -│ 하위: 순수 기능 컴포넌트 (Field, Attribute) │ -│ - UI 렌더링만 담당 │ -│ - 외부 의존성 없음 │ -│ - value + onChange 패턴 │ -│ - 테스트 용이 │ -└─────────────────────────────────────────────────┘ -``` - -| 구분 | 상위 (Layout/Section) | 하위 (Field/Attribute) | -|------|----------------------|----------------------| -| 역할 | 데이터 처리, 조건 분기 | 순수 렌더링 | -| 상태 | Zustand 구독 | props only | -| API | 호출 가능 | 호출 안 함 | -| 예시 | `DynamicSection`, `DynamicListPage` | `Input`, `Select`, `DatePicker` | -| 테스트 | 통합 테스트 | 단위 테스트 | - ---- - -### 규칙 6: Zustand 기반 상태 관리 - -``` -┌────────────────────────────────────────────────┐ -│ pageConfigStore (Zustand) │ -│ │ -│ state: │ -│ configs: Map │ -│ currentPage: PageConfig | null │ -│ loading: boolean │ -│ │ -│ actions: │ -│ fetchPageConfig(slug) → API 호출 + 캐시 │ -│ invalidateConfig(slug) → 캐시 무효화 │ -│ subscribeToPage(slug) → 실시간 구독 │ -└────────────────────────────────────────────────┘ - │ - │ 구독 - ↓ -┌────────────────┐ ┌────────────────┐ -│ DynamicListPage │ │ DynamicFormPage │ ... -└────────────────┘ └────────────────┘ -``` - -| 항목 | 설명 | -|------|------| -| Store 위치 | `src/stores/pageConfigStore.ts` (신규) | -| 캐시 전략 | 메모리(Zustand) → localStorage → API | -| 변경 감지 | 해시 비교 (메뉴 갱신과 동일 방식) | -| 테넌트 격리 | 기존 `TenantAwareCache` 패턴 재사용 | - ---- - -### 규칙 7: 테넌트 + 하위 구성요소별 화면 분기 - -``` -테넌트 A (셔터 제조업) - ├─ 메뉴: 품목관리, 생산관리, 출하관리 - ├─ 품목 폼: 셔터 규격 필드 포함 - └─ 생산 공정: 셔터 전용 공정 단계 - -테넌트 B (건설업) - ├─ 메뉴: 프로젝트관리, 공사관리, 기성관리 - ├─ 프로젝트 폼: 현장정보 필드 포함 - └─ 공사 공정: 건설 전용 단계 - -같은 테넌트 내에서도: - ├─ 부서 A → 메뉴 5개, 필드 20개 표시 - └─ 부서 B → 메뉴 3개, 필드 12개 표시 -``` - -| 분기 기준 | 설명 | 예시 | -|----------|------|------| -| 테넌트 (company) | 업종별 전체 화면 구성 | 셔터업 vs 건설업 | -| 부서 (department) | 같은 테넌트 내 부서별 | 영업팀 vs 생산팀 | -| 역할 (role) | 같은 부서 내 역할별 | 관리자 vs 일반 | -| 사용자 (user) | 개인 설정 | 즐겨찾기, 컬럼 순서 | - -> ⚠️ **백엔드 논의 필요**: 분기 우선순위 및 상속 정책 -> (테넌트 설정 → 부서 설정으로 오버라이드 → 사용자 설정으로 오버라이드?) - ---- - -### 규칙 8: 정적/동적 컴포넌트 공유 - -``` -src/components/ - ├── ui/ ← 공유 (정적+동적 모두 사용) - │ ├── Input.tsx - │ ├── Select.tsx - │ ├── DatePicker.tsx - │ └── ... - │ - ├── molecules/ ← 공유 - │ ├── FormField.tsx - │ ├── SearchFilter.tsx - │ └── ... - │ - ├── organisms/ ← 공유 - │ ├── DataTable.tsx - │ ├── MobileCard.tsx - │ └── ... - │ - ├── dynamic/ ← 동적 전용 (신규) - │ ├── renderers/ - │ │ ├── DynamicListPage.tsx - │ │ ├── DynamicDetailPage.tsx - │ │ ├── DynamicFormPage.tsx - │ │ └── DynamicDashboardPage.tsx - │ ├── sections/ - │ │ ├── DynamicSection.tsx - │ │ ├── DynamicFilterSection.tsx - │ │ └── DynamicTableSection.tsx ← 기존 이동 - │ ├── fields/ - │ │ └── DynamicFieldRenderer.tsx ← 기존 이동 (14종) - │ └── store/ - │ └── pageConfigStore.ts - │ - └── static/ ← 정적 전용 (기존 유지) - ├── auth/LoginPage.tsx - └── auth/SignupPage.tsx -``` - -| 레이어 | 공유 여부 | 예시 | -|--------|----------|------| -| ui/ | ✅ 100% 공유 | Input, Select, Button | -| molecules/ | ✅ 100% 공유 | FormField, StatusBadge | -| organisms/ | ✅ 대부분 공유 | DataTable, SearchFilter | -| dynamic/renderers/ | ❌ 동적 전용 | DynamicListPage | -| 기존 도메인 컴포넌트 | ❌ 정적 전용 (점진적 전환) | OrderSalesDetailEdit | - ---- - -### 규칙 9: 페이지 타입 분류 체계 - -| pageType | 용도 | 핵심 구성 요소 | 기존 대응 패턴 | -|----------|------|--------------|---------------| -| `list` | 목록 조회 | 필터 + 테이블 + 페이지네이션 + 액션 | UniversalListPage | -| `detail` | 상세 보기 | 읽기전용 섹션 + 수정/삭제 버튼 | IntegratedDetailTemplate | -| `form` | 등록/수정 | 입력 섹션 + 저장/취소 | DynamicItemForm (범용화) | -| `dashboard` | 대시보드 | 위젯/카드 그리드 | CEODashboard | -| `document` | 문서/프린트 | 프린트 레이아웃 + 결재란 | ContractDocument 등 | - -``` -pageType 결정 흐름: - -API 응답의 pageType 값 - │ - ├─ "list" → - ├─ "detail" → - ├─ "form" → - ├─ "dashboard" → - ├─ "document" → - └─ 미지원 → (에러 표시) -``` - ---- - -### 규칙 10: 동적 라우팅 전략 - -``` -src/app/[locale]/(protected)/ - │ - ├── (static-pages)/ ← 정적 페이지 그룹 - │ ├── settings/ - │ └── ... - │ - └── [...slug]/ ← 동적 페이지 catch-all - └── page.tsx ← 아래 로직 수행 -``` - -**catch-all page.tsx 동작 흐름**: - -``` -1. URL에서 slug 추출 (예: ["sales", "order-management"]) -2. slug로 pageConfigStore에서 config 조회 (캐시 우선) -3. 캐시 없으면 → API 호출: GET /api/v1/page-configs/sales/order-management -4. config.pageType으로 렌더러 선택 -5. 렌더러에 config 전달 → 동적 페이지 렌더링 -``` - -| 라우트 우선순위 | 경로 | 설명 | -|---------------|------|------| -| 1 (최우선) | `/login`, `/signup` | 정적 페이지 (파일 존재) | -| 2 | `/settings/*` | 정적 그룹 (파일 존재) | -| 3 (폴백) | `/*` (나머지 전부) | catch-all → 동적 처리 | - -> Next.js 라우팅 규칙: 구체적 경로 > catch-all → 충돌 없음 - -> ⚠️ **논의 필요**: 기존 정적 페이지를 동적으로 전환 시, 해당 파일 삭제 후 catch-all로 자연스럽게 이관 - ---- - -### 규칙 11: API 엔드포인트 동적 매핑 - -#### 11-1. API 호출 유형 - -| API 유형 | config 키 | 용도 | -|---------|----------|------| -| `list` | `api.list` | 목록 조회 (GET) | -| `detail` | `api.detail` | 상세 조회 (GET) | -| `create` | `api.create` | 등록 (POST) | -| `update` | `api.update` | 수정 (PUT/PATCH) | -| `delete` | `api.delete` | 삭제 (DELETE) | -| `export` | `api.export` | 엑셀 다운로드 (GET) | -| `custom` | `api.custom[actionName]` | 커스텀 액션 | - -#### 11-2. 백엔드 API 제공 방식 (3가지 방향) - -동적 페이지는 **데이터를 어디서 가져올지도 동적**이어야 합니다. -백엔드가 API를 어떤 방식으로 제공하느냐에 따라 3가지 방향이 있습니다. - -| 방향 | 설명 | 장점 | 단점 | -|------|------|------|------| -| **A. 개별 API** | 페이지마다 전용 API 존재, config에 경로 명시 | 기존 API 재사용, 복잡한 로직 처리 가능 | 새 페이지마다 백엔드 개발 필요 | -| **B. 범용 Entity API** | 하나의 엔드포인트가 entityType으로 분기 | 새 페이지 추가 시 백엔드 코드 변경 없음 | 복잡한 비즈니스 로직 처리 어려움 | -| **C. 하이브리드 (권장)** | 단순 CRUD는 범용 API, 복잡한 로직은 전용 API | 양쪽 장점 모두 취함 | 두 방식 공존에 따른 관리 비용 | - -**방향 A: 개별 API (config에 경로 포함)** -```jsonc -{ - "pageType": "list", - "slug": "sales/order-management", - "api": { - "list": "/api/v1/orders", - "detail": "/api/v1/orders/:id", - "create": "/api/v1/orders", - "delete": "/api/v1/orders/:id" - } -} -``` -→ 기존에 이미 만들어둔 API를 그대로 config에 연결 -→ 견적 계산, 세금 처리 등 **비즈니스 로직이 있는 페이지에 적합** - -**방향 B: 범용 Entity API** -```jsonc -{ - "pageType": "list", - "slug": "master/equipment", - "entityType": "equipment" -} -``` -``` -// 범용 API 1개로 모든 entity 처리 -GET /api/v1/entities/{entityType} -GET /api/v1/entities/{entityType}/{id} -POST /api/v1/entities/{entityType} -PUT /api/v1/entities/{entityType}/{id} -DELETE /api/v1/entities/{entityType}/{id} -``` -→ 백엔드에서 entityType에 따라 테이블/모델 동적 매핑 -→ 단순 CRUD(거래처, 설비, 자재 등) **마스터 데이터 페이지에 적합** - -**방향 C: 하이브리드 (권장)** -``` -┌──────────────────────────────────────────────────┐ -│ 단순 CRUD 페이지 (거래처, 설비, 자재 등) │ -│ → 방향 B: 범용 entity API │ -│ → config에 entityType만 지정 │ -│ → 새 페이지 추가 시 백엔드 코드 변경 없음 │ -│ → 동적 시스템의 최대 효과 │ -└──────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────┐ -│ 비즈니스 로직 페이지 (견적, 생산, 세금계산서) │ -│ → 방향 A: 전용 API 경로를 config에 명시 │ -│ → 계산/검증/워크플로우 등 복잡한 로직 처리 │ -│ → 기존 API 재사용으로 마이그레이션 용이 │ -└──────────────────────────────────────────────────┘ -``` - -#### 11-3. 프론트 처리 방식 (어느 방향이든 동일) - -``` -프론트는 API 제공 방식에 무관하게 동일한 패턴으로 처리: - -1. config에서 API 경로 결정 - ├─ api.list 있으면 → 그 경로 사용 (방향 A) - └─ entityType 있으면 → `/api/v1/entities/${entityType}` 생성 (방향 B) - ↓ -2. buildApiUrl(경로, params) ← 기존 유틸 재사용 - ↓ -3. Server Action에서 API 프록시 호출 - ↓ -4. 응답을 config.columns 기준으로 렌더링 -``` - -```typescript -// 프론트 API 경로 결정 유틸 (예시) -function resolveApiUrl(config: PageConfig, action: 'list' | 'detail' | 'create' | 'update' | 'delete') { - // 방향 A: 전용 API 경로가 있으면 사용 - if (config.api?.[action]) { - return config.api[action]; - } - // 방향 B: entityType으로 범용 API 생성 - if (config.entityType) { - const base = `/api/v1/entities/${config.entityType}`; - if (action === 'list' || action === 'create') return base; - return `${base}/:id`; - } - throw new Error(`No API config for action: ${action}`); -} -``` - -#### 11-4. API 응답 구조 통일 - -어느 방향이든 **응답 구조는 통일**되어야 프론트가 범용 처리 가능: - -| API 유형 | 응답 구조 | -|---------|----------| -| list | `{ data: [...], meta: { total, current_page, per_page, last_page } }` | -| detail | `{ data: { ... } }` | -| create | `{ data: { id, ... }, message: "..." }` | -| update | `{ data: { id, ... }, message: "..." }` | -| delete | `{ message: "..." }` | - -> ⚠️ **백엔드 논의 필요**: -> - 범용 entity API 도입 여부 및 범위 -> - 기존 API 중 응답 구조가 통일되지 않은 것 정리 -> - 전용 API와 범용 API의 분류 기준 합의 - ---- - -### 규칙 12: 검증(Validation) 규칙 - -| 검증 타입 | JSON 표현 | 프론트 변환 | -|----------|----------|------------| -| 필수값 | `{ "required": true }` | `z.string().min(1)` | -| 최솟값 | `{ "min": 1 }` | `z.number().min(1)` | -| 최댓값 | `{ "max": 100 }` | `z.number().max(100)` | -| 정규식 | `{ "pattern": "^\\d{3}-\\d{2}$" }` | `z.string().regex()` | -| 커스텀 메시지 | `{ "message": "올바른 형식이 아닙니다" }` | 에러 메시지 | -| 이메일 | `{ "type": "email" }` | `z.string().email()` | -| 전화번호 | `{ "type": "phone" }` | `z.string().regex()` | - -``` -JSON validation config - ↓ 런타임 변환 -Zod 스키마 자동 생성 - ↓ -react-hook-form zodResolver에 주입 - ↓ -폼 검증 자동 적용 -``` - ---- - -### 규칙 13: 필드 간 의존성 - -| 의존성 타입 | 설명 | 예시 | -|------------|------|------| -| `visibility` | 조건부 표시/숨김 | 품목타입=모터 → 전압 필드 표시 | -| `computed` | 자동 계산 | 수량 × 단가 = 금액 | -| `cascade` | 연쇄 선택 | 대분류 → 중분류 → 소분류 | -| `setValue` | 값 자동 설정 | 거래처 선택 → 담당자 자동 입력 | -| `disable` | 조건부 비활성화 | 상태=확정 → 수량 수정 불가 | - -``` -기존 자산 활용: -DynamicItemForm의 DisplayCondition → visibility 타입으로 범용화 -DynamicItemForm의 ComputedField → computed 타입으로 범용화 -``` - -> ✅ **확정**: 복잡한 계산식(견적 할인율 등)은 **백엔드에서 전부 처리**하여 결과만 전달 - ---- - -### 규칙 14: 권한 통합 - -#### 14-1. 현재 권한 시스템 검증 결과 - -✅ **현재 권한 시스템으로 동적 페이지도 컨트롤 가능** (검증 완료) - -현재 권한 시스템이 **메뉴 ID 기반 + URL 패턴 매칭**으로 동작하므로, 페이지가 정적이든 동적이든 해당 URL이 menu 테이블에 등록되어 있으면 권한 관리 페이지에서 동일하게 컨트롤됩니다. - -``` -현재 (정적 페이지): - 백엔드 menu 테이블에 URL 등록 → 권한 매트릭스 체크박스 on/off - → PermissionGate가 URL 매칭 → 접근 허용/차단 - -동적 페이지도 동일: - 백엔드 menu 테이블에 동적 페이지 URL(slug) 등록 - → 권한 매트릭스에서 동일하게 체크박스 on/off - → PermissionGate가 URL 매칭 → 동일하게 동작 -``` - -#### 14-2. 권한 레벨별 동적 페이지 호환성 - -| 권한 레벨 | 현재 지원 | 동적 페이지 호환 | 사용 컴포넌트 | -|----------|:---:|:---:|------| -| 페이지 접근 (view) | ✅ | ✅ | `PermissionGate` (URL 매칭) | -| 생성 (create) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | -| 수정 (update) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | -| 삭제 (delete) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | -| 승인 (approve) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | -| 내보내기 (export) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | -| 관리 (manage) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | -| **필드 단위 권한** | ❌ | ❌ | 현재 미지원 → **v2 고려사항** | - -#### 14-3. 권한 적용 흐름 - -``` -권한 적용 흐름 (정적/동적 공통): - -1. 페이지 접근: PermissionGate → URL longest prefix 매칭 → view 권한 확인 -2. 액션 권한: usePermission() → canCreate/canDelete 등 → 버튼 표시/숨김 -3. 필드 권한: 현재 미지원 (v2에서 config.permissions.fieldLevel 추가 시 구현) -``` - -> ⚠️ **백엔드 논의 필요**: 동적 페이지 URL(slug)을 menu 테이블에 자동 등록하는 방안 -> (기준관리에서 페이지 생성 시 → menu 테이블에도 자동 연동?) - ---- - -### 규칙 15: 캐싱 & 성능 전략 - -``` -요청 흐름: - -1차 캐시 (Zustand 메모리) - ↓ miss -2차 캐시 (localStorage, 테넌트별 격리) - ↓ miss -3차 (API 호출) - ↓ 응답 -1차 + 2차 캐시 갱신 -``` - -| 전략 | 방법 | 갱신 주기 | -|------|------|----------| -| 초기 로드 | 로그인 시 전체 config 프리페치 | 1회 | -| 변경 감지 | 해시 비교 (메뉴 갱신과 동일) | 30초~5분 | -| 강제 갱신 | 관리자가 기준관리 변경 시 push | 즉시 | -| 캐시 무효화 | 테넌트 전환 시 전체 클리어 | 즉시 | - ---- - -### 규칙 16: 비즈니스 로직 처리 - -> ✅ **확정**: 복잡한 계산 수식은 **백엔드에서 전부 처리**하여 결과만 전달 - -| 로직 복잡도 | 처리 방식 | 예시 | -|------------|----------|------| -| 단순 계산 | config formula (프론트) | 수량 × 단가 = 금액 | -| 복잡한 계산 | **백엔드 API** | 견적 할인, 세금, 재고 검증 등 | - -```jsonc -// config에서 로직 지정 -{ - "businessLogic": { - // 단순: 프론트 formula (기존 ComputedField 재사용) - "amount": { "type": "formula", "expression": "quantity * unitPrice" }, - - // 복잡: 백엔드 위임 (확정) - "totalDiscount": { - "type": "api", - "endpoint": "/api/v1/quotes/:id/calculate-discount", - "trigger": "onFieldChange", - "watchFields": ["quantity", "unitPrice", "discountRate"] - } - } -} -``` - -프론트는 단순 사칙연산(ComputedField)만 담당하고, 그 외 모든 비즈니스 로직은 백엔드 API로 위임합니다. - ---- - -### 규칙 17: 점진적 마이그레이션 전략 - -#### 17-1. 3단계 아키텍처 방향 (2026-03-17 확인) - -``` -1단계: 현재 → 모듈 분리 - - 공통 ERP / 테넌트별 모듈 물리적 분리 - - 선결과제 해소 (아래 17-2 참조) - -2단계: 모듈 분리 → JSON 동적 조립 - - 테넌트 모듈을 manifest/JSON 기반으로 전환 - - 동적 페이지 렌더러 도입 - -3단계: 최종 — 빈 페이지 셸 + 백엔드 JSON으로 페이지 자동 조립 - - 이 문서의 최종 목표 -``` - -#### 17-2. 선결과제 (모듈 분리 전 해결 필수) - -| # | 과제 | 내용 | 예상 | -|---|------|------|------| -| 1 | CEO 대시보드 테넌트 의존성 해소 | 생산/건설 섹션 직접 import → 동적 로딩 전환 | - | -| 2 | 공유 컴포넌트 추출 | 결재/영업(공통)이 생산(경동) 코드 직접 import | - | -| 3 | 라우트 가드 추가 | 테넌트 미보유 모듈 URL 직접 접근 차단 | - | -| 4 | dashboard-invalidation 동적화 | production/construction 도메인 키 하드코딩 제거 | - | - -> 선결과제 해소 예상: 3~4일, 이후 모듈 분리 본작업은 별도 산정 - -**핵심 의존성 위반 (공통 → 테넌트 방향, 수정 필요)**: -``` -ApprovalBox → production/InspectionReportModal -Sales/production-orders → production/ProductionOrders (actions+types+UI) -Sales → router.push("/production/work-orders") 하드코딩 -CEODashboard → DailyProductionSection, ConstructionSection 직접 import -dashboard-invalidation.ts → production/construction 도메인 키 -``` - -**안전한 부분**: -- 테넌트 간 교차 의존성 없음 (생산↔건설 = 0) -- 건설(주일) 모듈 완전 독립 → 바로 분리 가능 -- Zustand 스토어, API 프록시, 메뉴 시스템은 무관 - -#### 17-3. 테넌트별 페이지 현황 (2026-03-17 분석) - -| 테넌트 | 업종 | 전용 모듈 | 페이지 수 | -|--------|------|----------|:---:| -| 공통 ERP | 전 업종 | 회계, 인사, 결재, 게시판, 설정, 고객센터 등 | ~165 | -| 경동 | 셔터 제조 (MES) | 생산, 품질관리 | ~27 | -| 주일 | 건설 시공 | 건설/프로젝트, 입찰, 기성 | ~48 | -| (옵션) | - | 차량관리 | ~13 | - -#### 17-4. 마이그레이션 Phase - -| Phase | 범위 | 예상 기간 | 상태 | -|-------|------|----------|------| -| **선결과제** | 의존성 해소 (17-2) | 3-4일 | ⏳ 준비 | -| **Phase 0** | 인프라 구축 | 2-3주 | ⏳ | -| | - catch-all 라우터 | | | -| | - pageConfigStore | | | -| | - DynamicListPage/FormPage 렌더러 | | | -| | - 백엔드 page-config API | | | -| **Phase 1** | 신규 테넌트/페이지만 동적 | 2-4주 | ⏳ | -| | - 새로 추가되는 페이지는 동적으로 생성 | | | -| | - 기존 페이지는 그대로 유지 | | | -| **Phase 2** | 단순 CRUD 페이지 전환 | 4-6주 | ⏳ | -| | - 리스트+상세만 있는 단순 페이지 | | | -| | - 거래처관리, 설비관리 등 | | | -| **Phase 3** | 복잡한 비즈니스 페이지 전환 | 6-8주 | ⏳ | -| | - 견적, 수주, 생산 등 로직 있는 페이지 | | | -| **Phase 4** | 기존 정적 → 동적 완전 전환 | 지속적 | ⏳ | -| | - 남은 하드코딩 페이지 점진적 전환 | | | - -``` -전환 판단 기준: - -[선행] 선결과제 해소 (의존성 분리) → 선결과제 Phase -[쉬움] 순수 CRUD (리스트+폼) → Phase 2에서 전환 -[보통] CRUD + 단순 계산 → Phase 2~3 -[어려움] 복잡한 비즈니스 로직 → Phase 3 -[마지막] 문서/프린트, 대시보드 → Phase 4 -``` - ---- - -## 4. 이미 있는 자산 → 재사용 매핑 - -| 기존 자산 | 현재 용도 | 동적 시스템에서의 역할 | -|----------|----------|---------------------| -| DynamicFieldRenderer (14종) | 품목 폼 필드 | → 모든 동적 폼 필드 | -| DynamicTableSection | 품목 BOM 테이블 | → 모든 동적 테이블 | -| DisplayCondition | 품목 조건부 표시 | → 범용 visibility 규칙 | -| ComputedField | 품목 자동 계산 | → 범용 computed 규칙 | -| UniversalListPage | 리스트 페이지 템플릿 | → DynamicListPage 기반 | -| IntegratedDetailTemplate | 상세 페이지 템플릿 | → DynamicDetailPage 기반 | -| TenantAwareCache | 캐시 격리 | → pageConfigStore 캐시 | -| menuRefresh (해시 비교) | 메뉴 갱신 | → config 변경 감지 | -| buildApiUrl | URL 빌더 | → 동적 API 호출에 재사용 | - ---- - -## 5. 논의 현황 정리 - -### 확정 사항 - -| 항목 | 확정 내용 | 비고 | -|------|----------|------| -| API 제공 방식 | 하이브리드 (C) — 단순 CRUD는 범용, 복잡 로직은 전용 | 범용 API 세분화 가능성 있음 | -| 복잡한 계산 수식 | 백엔드에서 전부 처리, 결과만 전달 | 프론트는 단순 사칙연산만 | -| 권한 관리 호환성 | 현재 권한 시스템으로 동적 페이지 컨트롤 가능 | 메뉴 ID + URL 패턴 매칭 방식 | -| 기존 동적 필드 재사용 | DynamicFieldRenderer 14종 등 90%+ 재사용 가능 | 기준관리 UI가 mng로 이동해도 렌더링 컴포넌트 유지 | -| DB 저장 방식 | PostgreSQL **JSONB** 사용 | 인덱싱/부분수정/내부검색 가능, 프론트 영향 없음 | - -### 협의 필요 사항 - -| 항목 | 현재 상태 | 논의 포인트 | -|------|----------|------------| -| JSON config 세부 구조 | 제안 구조 작성됨 (규칙 2 참조) | 회의에서 세부 항목 결정 후 확정 | -| 정적/동적 페이지 분류 | 초안 목록 작성됨 (규칙 3 참조) | 어떤 페이지를 정적으로 남길지 최종 확정 | -| 테넌트 하위 분기 정책 | 개념 정리됨 (규칙 7 참조) | 테넌트→부서→역할 오버라이드 정책, config를 최종 결과물로 줄지 프론트가 조합할지 | -| 동적 라우팅 전략 | catch-all 방식 제안 (규칙 10 참조) | 기존 정적 페이지와의 공존/전환 전략 | -| 범용 entity API 범위 | 하이브리드 방향 합의 | 페이지 렌더링 분기에 따라 범용 API 세분화 가능 | -| page-config API 스펙 | 미정 | `GET /api/v1/page-configs/{slug}` 응답 구조 | -| 기준관리 어드민 UI | 미정 | mng에서 레이아웃/섹션/필드 등록 화면 설계 | -| API 응답 통일 | 미정 | list/detail/create/update/delete 응답 포맷 표준화 | -| 캐시 무효화 | 미정 | 기준관리 변경 시 프론트 캐시 갱신 방법 (polling vs push) | -| 프리페치 범위 | 미정 | 로그인 시 전체 config vs 페이지 접근 시 개별 로드 | -| 검증/의존성 JSON 스펙 | 제안 구조 작성됨 (규칙 12, 13 참조) | 세부 스펙 확정 | -| 마이그레이션 순서 | Phase 0~4 제안 (규칙 17 참조) | 어떤 페이지부터 동적 전환할지 | -| 동적 페이지 → menu 자동 등록 | 미정 | 기준관리에서 페이지 생성 시 menu 테이블 자동 연동 방안 | -| 필드 단위 권한 | 현재 미지원 | v2 고려사항 (필요 시 추가 개발) | - ---- - -## 6. 기존 자산 재사용 현황 - -### 즉시 재사용 가능 (코드 변경 없음) - -| 자산 | 현재 용도 | 동적 시스템 역할 | 재사용도 | -|------|----------|----------------|:---:| -| DynamicFieldRenderer (14종) | 품목 폼 필드 | 모든 동적 폼 필드 | 100% | -| DynamicTableSection | 품목 BOM 테이블 | 모든 동적 테이블 | 99% | -| DisplayCondition (9개 연산자) | 품목 조건부 표시 | 범용 visibility 규칙 | 100% | -| ComputedField | 품목 자동 계산 | 범용 단순 계산 | 100% | -| Reference Sources 프리셋 | 거래처/품목 등 조회 | 새 source 추가만으로 확장 | 100% | -| TenantAwareCache | 캐시 격리 | pageConfigStore 캐시 | 100% | -| menuRefresh (해시 비교) | 메뉴 갱신 | config 변경 감지 | 100% | -| buildApiUrl | URL 빌더 | 동적 API 호출 | 100% | -| PermissionGate / usePermission | 정적 페이지 권한 | 동적 페이지 권한 (동일) | 100% | - -### 범용화 필요 (약간의 리팩토링) - -| 자산 | 변경 사항 | -|------|----------| -| useDynamicFormState | API URL을 파라미터로 받도록 | -| useFormStructure | 품목 전용 API → 범용 API 경로 | -| types.ts | `ItemFieldResponse` → `DynamicFieldResponse` 리네이밍 | - -### 신규 개발 필요 - -| 자산 | 역할 | -|------|------| -| DynamicListPage | 동적 리스트 페이지 렌더러 (UniversalListPage 기반) | -| DynamicDetailPage | 동적 상세 페이지 렌더러 (IntegratedDetailTemplate 기반) | -| DynamicDashboardPage | 동적 대시보드 렌더러 | -| pageConfigStore | 페이지 config Zustand 스토어 | -| catch-all route | `[...slug]/page.tsx` 동적 라우터 | -| resolveApiUrl | API 경로 결정 유틸 (개별/범용 분기) | - ---- - -## 7. 관련 문서 - -| 문서 | 위치 | 내용 | -|------|------|------| -| 동적 렌더링 플랫폼 비전 | `claudedocs/architecture/[VISION-2026-02-19]` | 전체 비전 및 자산 현황 | -| 멀티테넌시 최적화 로드맵 | `claudedocs/architecture/[PLAN-2026-02-06]` | 테넌트 격리/최적화 8 Phase | -| 동적 필드 타입 설계 | `claudedocs/architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드 | -| 동적 필드 구현 현황 | `claudedocs/architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 | -| 백엔드 API 스펙 | `claudedocs/item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 요청서 | -| 테넌트 모듈 의존성 분석 | `claudedocs/architecture/[ANALYSIS-2026-03-17]` | 3테넌트 분리, 선결과제 4개, 의존성 위반 목록 | - ---- - -**문서 버전**: 1.3 -**마지막 업데이트**: 2026-03-18 -**다음 단계**: 백엔드 회의 → 협의 필요 항목 확정 → v2.0 작성 → `sam-docs/frontend/v2/`에 최종본 등록 diff --git a/claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md b/claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md deleted file mode 100644 index 11dd8e1c..00000000 --- a/claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md +++ /dev/null @@ -1,1844 +0,0 @@ -# Tenant-Based Module Separation: Implementation Plan - -**Date**: 2026-03-17 -**Status**: APPROVED PLAN -**Prerequisite**: `[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md` -**Estimated Total Effort**: 12-16 working days across 4 phases -**Zero Downtime Requirement**: All changes are additive; no page removal until Phase 3 - -### 관련 문서 (로드맵 상 위치) - -| 문서 | 역할 | 관계 | -|------|------|------| -| `[VISION-2026-02-19] dynamic-rendering-platform-strategy.md` | 플랫폼 비전 | 최상위 방향성 | -| `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md` | 멀티테넌시 로드맵 | 전체 단계 정의 | -| `[DESIGN-2026-02-11] dynamic-field-type-extension.md` | 동적 필드 타입 설계 | v3 렌더러 기초 | -| `[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md` | **v3 최종 설계 (JSON 동적 페이지)** | **본 계획의 도착점** | -| `[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md` | 의존성 감사 | 본 계획의 근거 데이터 | -| **본 문서 (Phase 0~3)** | **프론트 모듈 분리 실행 계획** | **v3로 가는 징검다리** | - -``` -로드맵 전체 흐름: - -[VISION 02-19] 플랫폼 비전 - │ -[PLAN 02-06] 멀티테넌시 로드맵 - │ -[DESIGN 02-11] 동적 필드 타입 - │ -[PLAN 03-11] v3 최종 설계 (JSON 동적 페이지 + JSONB + pageType 렌더러) - │ -[ANALYSIS 03-17] 의존성 감사 ──→ 현재 코드의 모듈 간 결합 6건 발견 - │ -[PLAN 03-17] ★ 본 문서 ★ (Phase 0~3: 프론트 모듈 분리) - │ -[v2] 백엔드 page_configs JSONB API → useModules() 연결 - │ -[v3] catch-all route + DynamicListPage/FormPage 렌더러 - │ -[최종] 테넌트 추가 = 어드민 config 등록 → 코드 변경 0줄 -``` - ---- - -## Table of Contents - -1. [Architecture Overview](#1-architecture-overview) -2. [Phase 0: Prerequisite Fixes (4-5 days)](#2-phase-0-prerequisite-fixes) -3. [Phase 1: Module Registry & Route Guard (3-4 days)](#3-phase-1-module-registry--route-guard) -4. [Phase 2: Dashboard Decoupling (2-3 days)](#4-phase-2-dashboard-decoupling) -5. [Phase 3: Physical Separation (2-3 days)](#5-phase-3-physical-separation) -6. [Phase 4: Manifest-Based Module Loading (Future)](#6-phase-4-manifest-based-module-loading) -7. [Testing Strategy](#7-testing-strategy) -8. [Risk Register](#8-risk-register) -9. [Folder Structure Before/After](#9-folder-structure-beforeafter) -10. [Migration Order & Parallelism](#10-migration-order--parallelism) - ---- - -## 1. Architecture Overview - -### Current State - -``` -src/ - app/[locale]/(protected)/ - production/ (12 pages) -- Kyungdong tenant - quality/ (14 pages) -- Kyungdong tenant - construction/ (57 pages) -- Juil tenant - vehicle-management/ (12 pages) -- Optional module - accounting/ (32 pages) -- Common ERP - sales/ (22 pages) -- Common ERP (has 3 pages that import from production) - approval/ (6 pages) -- Common ERP (1 component imports from production) - ...other common modules - components/ - production/ (56 files) - quality/ (35+ files) - business/construction/ (161 files) - vehicle-management/ (13 files) - ...common components -``` - -### Target State (End of Phase 3) - -``` -src/ - modules/ # NEW: module registry + manifest - index.ts # module registry - types.ts # TenantModule, ModuleManifest types - tenant-config.ts # tenant -> module mapping - route-guard.ts # route access check utility - interfaces/ # NEW: shared type contracts - production-orders.ts # types+actions shared between sales & production - inspection-documents.ts # InspectionReportModal props interface - dashboard-sections.ts # dynamic section registration types - app/[locale]/(protected)/ - production/ # unchanged location, guarded by middleware - quality/ # unchanged location, guarded by middleware - construction/ # unchanged location, guarded by middleware - vehicle-management/ # unchanged location, guarded by middleware - components/ - document-system/modals/ # NEW: extracted shared modals - InspectionReportModal.tsx # moved from production - WorkLogModal.tsx # moved from production - production/ # unchanged, but no longer imported by common - quality/ # unchanged - business/construction/ # unchanged -``` - -### Dependency Direction Rules - -``` -ALLOWED: Tenant Module -----> Common ERP - (production -> @/lib/, @/components/ui/, @/hooks/, etc.) - -FORBIDDEN: Common ERP ----X---> Tenant Module - (approval -> production components) - (sales -> production actions/types) - (dashboard -> production/construction data) - -EXCEPTION: Common ERP ~~~?~~~> Tenant Module via: - 1. Dynamic import with fallback (lazy + error boundary) - 2. Shared interface in @/interfaces/ (types only) - 3. Module registry callback (runtime registration) -``` - ---- - -## 2. Phase 0: Prerequisite Fixes - -> **Goal**: Break all forbidden dependency arrows (Common -> Tenant) without changing runtime behavior. -> **Duration**: 4-5 days -> **Risk**: LOW (pure refactoring, no user-facing changes) -> **Rollback**: `git revert` each commit independently - -### Task 0.1: Extract InspectionReportModal to Shared Location - -**Problem**: `ApprovalBox/index.tsx` (common) imports `InspectionReportModal` from `production/WorkOrders/documents/`. Also `quality/qms/page.tsx` imports it. - -**Strategy**: Create a shared document modal system under `@/components/document-system/modals/`. - -**Files to create**: -- `src/components/document-system/modals/InspectionReportModal.tsx` -- `src/components/document-system/modals/WorkLogModal.tsx` -- `src/components/document-system/modals/index.ts` - -**Files to modify**: -- `src/components/approval/ApprovalBox/index.tsx` (change import path) -- `src/app/[locale]/(protected)/quality/qms/page.tsx` (change import path) -- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx` (re-export from shared) -- `src/components/production/WorkOrders/documents/WorkLogModal.tsx` (re-export from shared) -- `src/components/production/WorkOrders/documents/index.ts` (update exports) - -**Code pattern**: -```typescript -// NEW: src/components/document-system/modals/InspectionReportModal.tsx -// Copy the full 570-line component here (from production/WorkOrders/documents/) -// Keep all existing props and behavior identical - -// MODIFY: src/components/production/WorkOrders/documents/InspectionReportModal.tsx -// Replace with re-export to avoid breaking internal production imports: -export { InspectionReportModal } from '@/components/document-system/modals/InspectionReportModal'; - -// MODIFY: src/components/approval/ApprovalBox/index.tsx line 76 -// FROM: -import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal'; -// TO: -import { InspectionReportModal } from '@/components/document-system/modals/InspectionReportModal'; - -// MODIFY: src/app/[locale]/(protected)/quality/qms/page.tsx lines 11-12 -// FROM: -import { InspectionReportModal } from '@/components/production/WorkOrders/documents'; -import { WorkLogModal } from '@/components/production/WorkOrders/documents'; -// TO: -import { InspectionReportModal } from '@/components/document-system/modals'; -import { WorkLogModal } from '@/components/document-system/modals'; -``` - -**Dependency analysis for InspectionReportModal.tsx (570 lines)**: -The modal imports from: -- `@/types/process.ts` (shared types, already in Common ERP) -- `@/lib/api/` utilities (Common ERP) -- `@/components/ui/` (Common ERP) -- `./inspection-shared` and `./Slat*Content`, `./Screen*Content`, `./Bending*Content` (production-internal) - -The production-internal content components (SlatInspectionContent, ScreenInspectionContent, etc.) are **rendered inside** InspectionReportModal via a switch statement. These are Kyungdong-specific inspection forms. - -**Revised strategy**: Instead of moving the entire modal with its production-specific content components, create a **wrapper pattern**: - -```typescript -// NEW: src/components/document-system/modals/InspectionReportModal.tsx -// This is a THIN wrapper that dynamic-imports the actual modal -'use client'; - -import dynamic from 'next/dynamic'; -import type { ComponentProps } from 'react'; - -// Dynamic import with loading fallback -const InspectionReportModalImpl = dynamic( - () => import('@/components/production/WorkOrders/documents/InspectionReportModal') - .then(mod => ({ default: mod.InspectionReportModal })), - { - loading: () => null, - ssr: false, - } -); - -// Re-export props type from a shared interface (no production dependency) -export interface InspectionReportModalProps { - isOpen: boolean; - onClose: () => void; - workOrderId: number | null; - type?: 'inspection' | 'worklog'; -} - -export function InspectionReportModal(props: InspectionReportModalProps) { - if (!props.isOpen) return null; - return ; -} -``` - -This pattern: -- Breaks the static import chain (Common no longer statically depends on production) -- Uses `next/dynamic` so the production code is only loaded at runtime when needed -- If production module is absent at build time, the dynamic import fails gracefully (modal just doesn't render) -- Zero behavior change for existing users - -**Estimated time**: 0.5 day -**Risk**: LOW -**Dependencies**: None -**Rollback**: Revert import paths - ---- - -### Task 0.2: Extract Production Orders Shared Interface - -**Problem**: 3 files under `sales/order-management-sales/production-orders/` import from `@/components/production/ProductionOrders/actions.ts` and `types.ts`. - -**Strategy**: Create a shared interface file with the types and action signatures that sales needs. The sales pages will use this shared interface. The actual implementation stays in production. - -**Files to create**: -- `src/interfaces/production-orders.ts` - -**Files to modify**: -- `src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx` -- `src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx` -- `src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx` - -**Code pattern**: -```typescript -// NEW: src/interfaces/production-orders.ts -// Extract ONLY the types and action signatures that sales needs - -export interface ProductionOrder { - id: number; - order_number: string; - status: ProductionStatus; - // ... (copy from production/ProductionOrders/types.ts) -} - -export type ProductionStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; - -export interface ProductionOrderDetail extends ProductionOrder { - work_orders: ProductionWorkOrder[]; - // ... -} - -export interface ProductionWorkOrder { - id: number; - // ... -} - -export interface ProductionOrderStats { - total: number; - pending: number; - in_progress: number; - completed: number; -} - -// Server actions -- these call the SAME backend API endpoint regardless of module presence -// The backend API /api/v1/production-orders/* exists independently of the frontend module -'use server'; - -import { executeServerAction, executePaginatedAction } from '@/lib/api/server-actions'; -import { buildApiUrl } from '@/lib/api/query-params'; - -export async function getProductionOrders(params: { page?: number; search?: string; status?: string }) { - return executePaginatedAction({ - url: buildApiUrl('/api/v1/production-orders', params), - }); -} - -export async function getProductionOrderDetail(id: number) { - return executeServerAction({ - url: buildApiUrl(`/api/v1/production-orders/${id}`), - }); -} - -export async function getProductionOrderStats() { - return executeServerAction({ - url: buildApiUrl('/api/v1/production-orders/stats'), - }); -} -``` - -**Important**: The actions in this shared interface call the **same backend API endpoints** as the production module's actions. The backend API exists independently. This is safe because the sales production-orders view is read-only (viewing production order status from sales perspective). - -**For AssigneeSelectModal** (imported by sales/[id]/production-order/page.tsx): -```typescript -// Same dynamic import pattern as Task 0.1 -// NEW: src/interfaces/components/AssigneeSelectModal.tsx -import dynamic from 'next/dynamic'; - -const AssigneeSelectModalImpl = dynamic( - () => import('@/components/production/WorkOrders/AssigneeSelectModal') - .then(mod => ({ default: mod.AssigneeSelectModal })), - { loading: () => null, ssr: false } -); - -export interface AssigneeSelectModalProps { - isOpen: boolean; - onClose: () => void; - onSelect: (assignee: { id: number; name: string }) => void; -} - -export function AssigneeSelectModal(props: AssigneeSelectModalProps) { - if (!props.isOpen) return null; - return ; -} -``` - -**Estimated time**: 1 day -**Risk**: MEDIUM (sales production-orders functionality must be verified) -**Dependencies**: None -**Rollback**: Revert 3 page files to original imports - ---- - -### Task 0.3: Fix Hardcoded Route Navigation - -**Problem**: `sales/production-orders/[id]/page.tsx` line 247 has `router.push("/production/work-orders")`. - -**Strategy**: Wrap in a module-aware navigation helper. - -**File to create**: -- `src/modules/route-resolver.ts` - -**File to modify**: -- `src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx` - -**Code pattern**: -```typescript -// NEW: src/modules/route-resolver.ts -/** - * Resolve routes that may point to tenant-specific modules. - * Falls back to a safe alternative if the target module is not available. - */ -export function resolveTenantRoute( - path: string, - fallback: string = '/dashboard' -): string { - // Phase 1: Simple passthrough (all modules present in monolith) - // Phase 2+: Check module registry for route availability - return path; -} - -// Common route mappings for cross-module navigation -export const CROSS_MODULE_ROUTES = { - workOrders: '/production/work-orders', - constructionContract: '/construction/project/contract', -} as const; - -// MODIFY: sales/production-orders/[id]/page.tsx line 247 -// FROM: -router.push("/production/work-orders"); -// TO: -import { resolveTenantRoute } from '@/modules/route-resolver'; -// ... -router.push(resolveTenantRoute("/production/work-orders", "/sales/order-management-sales/production-orders")); -``` - -**Estimated time**: 0.25 day -**Risk**: LOW -**Dependencies**: None -**Rollback**: Revert to hardcoded string - ---- - -### Task 0.4: Fix QMS Production Type Imports - -**Problem**: `quality/qms/mockData.ts` imports `WorkOrder` type from `production/ProductionDashboard/types` and `ShipmentDetail` from `outbound/ShipmentManagement/types`. - -**Strategy**: The QMS page and quality module are in the SAME tenant package (Kyungdong) as production, so production->quality dependencies are acceptable. However, the `outbound` import is cross-tenant (quality -> Common ERP direction), which is actually the ALLOWED direction. No change needed for outbound imports. - -For the production type import in mockData: since this is mock data, we can inline the type or import from the shared interface. - -**Files to modify**: -- `src/app/[locale]/(protected)/quality/qms/mockData.ts` (replace production type import) - -**Code pattern**: -```typescript -// MODIFY: quality/qms/mockData.ts -// FROM: -import type { WorkOrder } from '@/components/production/ProductionDashboard/types'; -// TO: -// Inline the minimal type needed for mock data -interface WorkOrderMock { - id: number; - work_order_number: string; - status: string; - // ... only fields used in mockData -} -``` - -**Estimated time**: 0.25 day -**Risk**: LOW -**Dependencies**: None - ---- - -### Task 0.5: Fix Dev Generator Production Import - -**Problem**: `src/components/dev/generators/workOrderData.ts` imports `ProcessOption` from production. - -**Strategy**: Move `ProcessOption` type to shared interfaces. - -**Files to modify**: -- `src/components/dev/generators/workOrderData.ts` -- `src/interfaces/production-orders.ts` (add ProcessOption type) - -**Estimated time**: 0.25 day -**Risk**: LOW (dev-only code) -**Dependencies**: Task 0.2 - ---- - -### Task 0.6: Make Dashboard Invalidation Dynamic - -**Problem**: `src/lib/dashboard-invalidation.ts` hardcodes `'production'` and `'construction'` as `DomainKey` values. - -**Strategy**: Change from hardcoded union type to a registry-based system. - -**File to modify**: -- `src/lib/dashboard-invalidation.ts` - -**Code pattern**: -```typescript -// MODIFY: src/lib/dashboard-invalidation.ts - -// BEFORE: hardcoded type union -type DomainKey = 'deposit' | 'withdrawal' | ... | 'production' | 'construction'; -const DOMAIN_SECTION_MAP: Record = { ... }; - -// AFTER: registry-based -type CoreDomainKey = 'deposit' | 'withdrawal' | 'sales' | 'purchase' | 'badDebt' - | 'expectedExpense' | 'bill' | 'giftCertificate' | 'journalEntry' - | 'order' | 'stock' | 'schedule' | 'client' | 'leave' - | 'approval' | 'attendance'; - -// Extendable domain key (modules can register additional domains) -type DomainKey = CoreDomainKey | string; - -// Core mappings (always present) -const CORE_DOMAIN_SECTION_MAP: Record = { - deposit: ['dailyReport', 'receivable'], - withdrawal: ['dailyReport', 'monthlyExpense'], - // ... all core mappings -}; - -// Extended mappings (registered by modules) -const extendedDomainMap = new Map(); - -/** Register module-specific dashboard domain mappings */ -export function registerDashboardDomain(domain: string, sections: DashboardSectionKey[]): void { - extendedDomainMap.set(domain, sections); -} - -// Updated function -export function invalidateDashboard(domain: DomainKey): void { - const sections = (CORE_DOMAIN_SECTION_MAP as Record)[domain] - ?? extendedDomainMap.get(domain) - ?? []; - if (sections.length === 0) return; - // ... rest unchanged -} -``` - -Then in production/construction modules, register on load: -```typescript -// src/components/production/WorkOrders/WorkOrderCreate.tsx (at module level) -import { registerDashboardDomain } from '@/lib/dashboard-invalidation'; -registerDashboardDomain('production', ['statusBoard', 'dailyProduction']); -registerDashboardDomain('shipment', ['statusBoard', 'unshipped']); - -// src/components/business/construction/.../ConstructionDetailClient.tsx -registerDashboardDomain('construction', ['statusBoard', 'construction']); -``` - -**Estimated time**: 0.5 day -**Risk**: LOW (backward compatible, registration is additive) -**Dependencies**: None -**Rollback**: Revert to hardcoded map - ---- - -### Phase 0 Summary - -| Task | Effort | Risk | Parallel? | -|------|--------|------|-----------| -| 0.1 Extract InspectionReportModal | 0.5d | LOW | Yes | -| 0.2 Extract Production Orders interface | 1d | MEDIUM | Yes | -| 0.3 Fix hardcoded route navigation | 0.25d | LOW | Yes | -| 0.4 Fix QMS production type imports | 0.25d | LOW | Yes | -| 0.5 Fix dev generator import | 0.25d | LOW | After 0.2 | -| 0.6 Make dashboard invalidation dynamic | 0.5d | LOW | Yes | -| **Total** | **2.75d** | | | -| **Buffer** | **+1.25d** | | | -| **Phase 0 Total** | **4d** | | | - -**Phase 0 Exit Criteria**: -- Zero imports from `@/components/production/` in any file under `src/components/approval/` -- Zero imports from `@/components/production/` in any file under `src/app/[locale]/(protected)/sales/` -- Zero hardcoded production/construction route strings outside their own modules -- `dashboard-invalidation.ts` has no hardcoded `'production'` or `'construction'` in its type definitions -- All existing functionality works identically (regression test) - ---- - -## 3. Phase 1: Module Registry & Route Guard - -> **Goal**: Create a tenant-aware module system and enforce route-level access control. -> **Duration**: 3-4 days -> **Prerequisite**: Phase 0 complete -> **Risk**: MEDIUM (middleware change affects all routes) - -### Task 1.1: Define Module Registry Types - -**File to create**: `src/modules/types.ts` - -```typescript -/** - * Module definition for tenant-based separation. - * Each module represents a group of pages and components - * that belong to a specific tenant or are optional add-ons. - */ - -export type ModuleId = - | 'common' // Always available - | 'production' // Kyungdong: Shutter MES - | 'quality' // Kyungdong: Quality management - | 'construction' // Juil: Construction management - | 'vehicle-management'; // Optional add-on - -export interface ModuleManifest { - id: ModuleId; - name: string; - description: string; - /** Route prefixes owned by this module (e.g., ['/production', '/quality']) */ - routePrefixes: string[]; - /** Dashboard section keys this module contributes */ - dashboardSections?: string[]; - /** Dashboard domain keys for invalidation */ - dashboardDomains?: Record; -} - -export interface TenantModuleConfig { - tenantId: number; - /** Which industry type this tenant belongs to */ - industry?: string; - /** Explicitly enabled modules (overrides industry defaults) */ - enabledModules: ModuleId[]; -} - -/** Runtime module availability check result */ -export interface ModuleAccess { - allowed: boolean; - reason?: 'not_licensed' | 'not_configured' | 'route_not_found'; - redirectTo?: string; -} -``` - -**Estimated time**: 0.25 day - ---- - -### Task 1.2: Create Module Registry - -**File to create**: `src/modules/index.ts` - -```typescript -import type { ModuleManifest, ModuleId } from './types'; - -/** - * Static module manifest registry. - * - * Phase 1: All modules registered here. - * Phase 2: Loaded from backend JSON. - */ -const MODULE_REGISTRY: Record = { - common: { - id: 'common', - name: 'Common ERP', - description: 'Core ERP modules available to all tenants', - routePrefixes: [ - '/dashboard', '/accounting', '/sales', '/hr', '/approval', - '/board', '/boards', '/customer-center', '/settings', - '/master-data', '/material', '/outbound', '/reports', - '/company-info', '/subscription', '/payment-history', - ], - }, - production: { - id: 'production', - name: 'Production Management', - description: 'Shutter MES production and work order management', - routePrefixes: ['/production'], - dashboardSections: ['production', 'shipment'], - dashboardDomains: { - production: ['statusBoard', 'dailyProduction'], - shipment: ['statusBoard', 'unshipped'], - }, - }, - quality: { - id: 'quality', - name: 'Quality Management', - description: 'Equipment and inspection management', - routePrefixes: ['/quality'], - dashboardSections: [], - }, - construction: { - id: 'construction', - name: 'Construction Management', - description: 'Juil construction project management', - routePrefixes: ['/construction'], - dashboardSections: ['construction'], - dashboardDomains: { - construction: ['statusBoard', 'construction'], - }, - }, - 'vehicle-management': { - id: 'vehicle-management', - name: 'Vehicle Management', - description: 'Vehicle and forklift management', - routePrefixes: ['/vehicle-management', '/vehicle'], - dashboardSections: [], - }, -}; - -/** Get module manifest by ID */ -export function getModuleManifest(moduleId: ModuleId): ModuleManifest | undefined { - return MODULE_REGISTRY[moduleId]; -} - -/** Get all registered module manifests */ -export function getAllModules(): ModuleManifest[] { - return Object.values(MODULE_REGISTRY); -} - -/** Find which module owns a given route prefix */ -export function getModuleForRoute(pathname: string): ModuleId { - for (const [moduleId, manifest] of Object.entries(MODULE_REGISTRY)) { - if (moduleId === 'common') continue; // Check specific modules first - for (const prefix of manifest.routePrefixes) { - if (pathname === prefix || pathname.startsWith(prefix + '/')) { - return moduleId as ModuleId; - } - } - } - return 'common'; // Default: common ERP -} - -/** Get all route prefixes for a set of enabled modules */ -export function getAllowedRoutePrefixes(enabledModules: ModuleId[]): string[] { - const prefixes: string[] = []; - for (const moduleId of ['common' as ModuleId, ...enabledModules]) { - const manifest = MODULE_REGISTRY[moduleId]; - if (manifest) { - prefixes.push(...manifest.routePrefixes); - } - } - return prefixes; -} - -/** Get dashboard section keys for enabled modules */ -export function getEnabledDashboardSections(enabledModules: ModuleId[]): string[] { - const sections: string[] = []; - for (const moduleId of enabledModules) { - const manifest = MODULE_REGISTRY[moduleId]; - if (manifest?.dashboardSections) { - sections.push(...manifest.dashboardSections); - } - } - return sections; -} -``` - -**Estimated time**: 0.5 day - ---- - -### Task 1.3: Create Tenant Configuration - -**File to create**: `src/modules/tenant-config.ts` - -```typescript -import type { ModuleId, TenantModuleConfig } from './types'; - -/** - * Phase 1: Hardcoded tenant-module mappings. - * Phase 2: Loaded from backend API response (/api/auth/user). - * - * Industry-based default modules: - * - 'shutter_mes' (Kyungdong): production + quality - * - 'construction' (Juil): construction - * - undefined / other: common only - */ - -const INDUSTRY_MODULE_MAP: Record = { - shutter_mes: ['production', 'quality'], - construction: ['construction'], -}; - -/** - * Resolve enabled modules for a tenant. - * - * Priority: - * 1. Explicit tenant configuration from backend (Phase 2) - * 2. Industry-based defaults - * 3. Empty (common ERP only) - */ -export function resolveEnabledModules(options: { - industry?: string; - explicitModules?: ModuleId[]; - optionalModules?: ModuleId[]; -}): ModuleId[] { - const { industry, explicitModules, optionalModules = [] } = options; - - // Phase 2: Backend provides explicit module list - if (explicitModules && explicitModules.length > 0) { - return [...explicitModules, ...optionalModules]; - } - - // Phase 1: Industry-based defaults - const industryModules = industry ? (INDUSTRY_MODULE_MAP[industry] ?? []) : []; - return [...industryModules, ...optionalModules]; -} - -/** - * Check if a specific module is enabled for the current tenant. - * This is the primary API for components to check module availability. - */ -export function isModuleEnabled( - moduleId: ModuleId, - enabledModules: ModuleId[] -): boolean { - if (moduleId === 'common') return true; - return enabledModules.includes(moduleId); -} -``` - -**Estimated time**: 0.25 day - ---- - -### Task 1.4: Create useModules Hook - -**File to create**: `src/hooks/useModules.ts` - -```typescript -'use client'; - -import { useMemo } from 'react'; -import { useAuthStore } from '@/stores/authStore'; -import type { ModuleId } from '@/modules/types'; -import { resolveEnabledModules, isModuleEnabled } from '@/modules/tenant-config'; -import { getModuleForRoute, getEnabledDashboardSections } from '@/modules'; - -/** - * Hook to access tenant module configuration. - * Returns enabled modules and helper functions. - */ -export function useModules() { - const tenant = useAuthStore((state) => state.currentUser?.tenant); - - const enabledModules = useMemo(() => { - if (!tenant) return []; - return resolveEnabledModules({ - industry: tenant.options?.industry, - // Phase 2: read from tenant.options?.modules - }); - }, [tenant]); - - const isEnabled = useMemo(() => { - return (moduleId: ModuleId) => isModuleEnabled(moduleId, enabledModules); - }, [enabledModules]); - - const isRouteAllowed = useMemo(() => { - return (pathname: string) => { - const owningModule = getModuleForRoute(pathname); - if (owningModule === 'common') return true; - return enabledModules.includes(owningModule); - }; - }, [enabledModules]); - - const dashboardSections = useMemo(() => { - return getEnabledDashboardSections(enabledModules); - }, [enabledModules]); - - return { - enabledModules, - isEnabled, - isRouteAllowed, - dashboardSections, - tenantIndustry: tenant?.options?.industry, - }; -} -``` - -**Estimated time**: 0.25 day - ---- - -### Task 1.5: Add Route Guard to Middleware - -**Problem**: Currently, any authenticated user can access any route via URL. Tenant users should only access their licensed modules. - -**Strategy**: Add a module check step to the existing middleware at `src/middleware.ts`, between the authentication check (step 7) and the i18n middleware (step 8). - -**File to modify**: `src/middleware.ts` - -**Design considerations**: -- Middleware runs on Edge Runtime -- cannot access Zustand store -- Must read tenant info from cookies or a lightweight API call -- First iteration: read `tenant_modules` cookie set at login -- The cookie is set by the login flow (or the API proxy refresh flow) - -**Code pattern**: -```typescript -// ADD to src/middleware.ts after step 7 (authentication check) - -// 7.5: Module-based route guard -// Read tenant's enabled modules from cookie (set at login) -const tenantModulesCookie = request.cookies.get('tenant_modules')?.value; -if (tenantModulesCookie) { - const enabledModules: string[] = JSON.parse(tenantModulesCookie); - - // Import route check logic (keep it simple for Edge Runtime) - const moduleRouteMap: Record = { - production: ['/production'], - quality: ['/quality'], - construction: ['/construction'], - 'vehicle-management': ['/vehicle-management', '/vehicle'], - }; - - // Check if the requested path belongs to a module the tenant doesn't have - for (const [moduleId, prefixes] of Object.entries(moduleRouteMap)) { - for (const prefix of prefixes) { - if ( - (pathnameWithoutLocale === prefix || pathnameWithoutLocale.startsWith(prefix + '/')) && - !enabledModules.includes(moduleId) && - !enabledModules.includes('all') // Superadmin override - ) { - // Redirect to dashboard with a message parameter - return NextResponse.redirect( - new URL('/dashboard?module_denied=true', request.url) - ); - } - } - } -} -``` - -**Backend requirement**: The login API response or `/api/auth/user` response needs to include `enabled_modules` or `tenant.options.modules`. This cookie must be set during login flow. - -**File to modify for cookie setting**: The login server action or API proxy that handles authentication. Specifically: -- `src/lib/api/auth/` -- wherever the login response is processed and cookies are set - -**Alternative (simpler, Phase 1 only)**: Skip middleware route guard initially. Instead, add a client-side `` component to the `(protected)/layout.tsx` that checks the route against enabled modules and shows an "access denied" page. - -```typescript -// NEW: src/components/auth/ModuleGuard.tsx -'use client'; - -import { usePathname } from 'next/navigation'; -import { useModules } from '@/hooks/useModules'; - -export function ModuleGuard({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); - const { isRouteAllowed } = useModules(); - - // Remove locale prefix for checking - const cleanPath = pathname.replace(/^\/[a-z]{2}\//, '/'); - - if (!isRouteAllowed(cleanPath)) { - return ( -
-

접근 권한 없음

-

- 현재 계약에 포함되지 않은 모듈입니다. -

- - 대시보드로 돌아가기 - -
- ); - } - - return <>{children}; -} -``` - -**Modify**: `src/app/[locale]/(protected)/layout.tsx` -```typescript -// ADD import -import { ModuleGuard } from '@/components/auth/ModuleGuard'; - -// WRAP children - - - {children} - - -``` - -**Recommended approach**: Start with client-side `ModuleGuard` (simpler, no backend cookie change needed). Add middleware guard in Phase 2 when backend provides `enabled_modules`. - -**Estimated time**: 1 day -**Risk**: MEDIUM (affects all page loads, needs thorough testing) -**Dependencies**: Task 1.2, 1.3, 1.4 - ---- - -### Task 1.6: Add Module-Denied Toast to Dashboard - -**File to modify**: `src/app/[locale]/(protected)/dashboard/page.tsx` - -```typescript -'use client'; - -import { useEffect } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { toast } from 'sonner'; -import { Dashboard } from '@/components/business/Dashboard'; - -export default function DashboardPage() { - const searchParams = useSearchParams(); - - useEffect(() => { - if (searchParams.get('module_denied') === 'true') { - toast.error('접근 권한이 없는 모듈입니다. 관리자에게 문의하세요.'); - // Clean up URL - window.history.replaceState(null, '', '/dashboard'); - } - }, [searchParams]); - - return ; -} -``` - -**Estimated time**: 0.25 day -**Risk**: LOW - ---- - -### Task 1.7: Backend Coordination -- Tenant Module Data - -**Requirement**: The backend `/api/auth/user` response (or the login response) should include the tenant's enabled modules. - -**Proposed backend response extension**: -```json -{ - "user": { ... }, - "tenant": { - "id": 282, - "company_name": "(주)경동", - "business_num": "123-45-67890", - "tenant_st_code": "active", - "options": { - "industry": "shutter_mes", - "modules": ["production", "quality", "vehicle-management"] - } - }, - "menus": [ ... ] -} -``` - -**Backend API request document**: -```markdown -## Backend API Modification Request - -### Endpoint: GET /api/v1/auth/user (or login response) -### Change: Add `modules` to `tenant.options` - -**Current response** (tenant.options): -```json -{ - "company_scale": "중소기업", - "industry": "shutter_mes" -} -``` - -**Requested response** (tenant.options): -```json -{ - "company_scale": "중소기업", - "industry": "shutter_mes", - "modules": ["production", "quality"] -} -``` - -**Module values**: "production", "quality", "construction", "vehicle-management" -**Default behavior**: If `modules` is absent, use industry-based defaults -**Backend table**: `tenant_options` or `tenant_modules` (new table) -``` - -**Until backend provides this**: The frontend falls back to `industry` field for module resolution (Task 1.3 already handles this). - -**Estimated time**: 0 days (frontend) -- backend team separate -**Risk**: None for frontend (graceful fallback exists) - ---- - -### Phase 1 Summary - -| Task | Effort | Risk | Parallel? | -|------|--------|------|-----------| -| 1.1 Module types | 0.25d | LOW | Yes | -| 1.2 Module registry | 0.5d | LOW | After 1.1 | -| 1.3 Tenant config | 0.25d | LOW | After 1.1 | -| 1.4 useModules hook | 0.25d | LOW | After 1.2, 1.3 | -| 1.5 Route guard (client-side) | 1d | MEDIUM | After 1.4 | -| 1.6 Module-denied toast | 0.25d | LOW | After 1.5 | -| 1.7 Backend coordination | 0d | - | Parallel | -| **Total** | **2.5d** | | | -| **Buffer** | **+1d** | | | -| **Phase 1 Total** | **3.5d** | | | - -**Phase 1 Exit Criteria**: -- Module registry exists with correct route prefix mappings -- `useModules()` hook returns correct enabled modules based on tenant industry -- Navigating to `/production/*` as a construction-only tenant shows "access denied" -- Dashboard page shows toast when redirected from denied module -- All existing tenants with correct industry setting see no change in behavior - ---- - -## 4. Phase 2: Dashboard Decoupling - -> **Goal**: Make the CEO Dashboard dynamically render only sections for the tenant's enabled modules. -> **Duration**: 2-3 days -> **Prerequisite**: Phase 1 complete -> **Risk**: MEDIUM (dashboard is highly visible, any regression is immediately noticed) - -### Task 2.1: Add Module Awareness to Dashboard Settings Type - -**File to modify**: `src/components/business/CEODashboard/types.ts` - -```typescript -// ADD to types.ts - -/** Sections that require specific modules */ -export const MODULE_DEPENDENT_SECTIONS: Record = { - production: ['production', 'shipment'], - construction: ['construction'], - // 'unshipped' stays in common -- it's about outbound/logistics -}; - -/** Check if a section key requires a specific module */ -export function sectionRequiresModule(sectionKey: SectionKey): string | null { - for (const [moduleId, sections] of Object.entries(MODULE_DEPENDENT_SECTIONS)) { - if (sections.includes(sectionKey)) return moduleId; - } - return null; -} -``` - -**Estimated time**: 0.25 day - ---- - -### Task 2.2: Make CEODashboard Module-Aware - -**File to modify**: `src/components/business/CEODashboard/CEODashboard.tsx` - -**Changes**: -1. Import `useModules` hook -2. Filter out disabled module sections from `sectionOrder` -3. Skip API calls for disabled module data -4. Filter settings dialog to hide unavailable sections - -```typescript -// ADD import -import { useModules } from '@/hooks/useModules'; -import { sectionRequiresModule } from './types'; - -// ADD inside CEODashboard(): -const { enabledModules, isEnabled } = useModules(); - -// MODIFY useCEODashboard call: -const apiData = useCEODashboard({ - salesStatus: true, - purchaseStatus: true, - dailyProduction: isEnabled('production'), // conditional - unshipped: true, // common (outbound) - construction: isEnabled('construction'), // conditional - dailyAttendance: true, -}); - -// MODIFY sectionOrder filtering: -const sectionOrder = useMemo(() => { - const rawOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; - // Filter out sections whose required module is not enabled - return rawOrder.filter((key) => { - const requiredModule = sectionRequiresModule(key); - if (!requiredModule) return true; // Common section, always show - return isEnabled(requiredModule as any); - }); -}, [dashboardSettings.sectionOrder, isEnabled]); - -// MODIFY renderDashboardSection for 'production' and 'construction' cases: -case 'production': - if (!isEnabled('production')) return null; // NEW CHECK - if (!(dashboardSettings.production ?? true) || !data.dailyProduction) return null; - // ... rest unchanged - -case 'construction': - if (!isEnabled('construction')) return null; // NEW CHECK - if (!(dashboardSettings.construction ?? true) || !data.constructionData) return null; - // ... rest unchanged -``` - -**Estimated time**: 0.5 day -**Risk**: MEDIUM (must verify all 18+ sections still render correctly) - ---- - -### Task 2.3: Make Dashboard Settings Dialog Module-Aware - -**File to modify**: `src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx` - -**Change**: Filter the settings toggles to only show sections for enabled modules. - -```typescript -// ADD import -import { useModules } from '@/hooks/useModules'; -import { sectionRequiresModule } from '../types'; - -// Inside component: -const { isEnabled } = useModules(); - -// Filter section list in the settings dialog -const availableSections = allSections.filter((section) => { - const requiredModule = sectionRequiresModule(section.key); - if (!requiredModule) return true; - return isEnabled(requiredModule as any); -}); -``` - -**Estimated time**: 0.25 day -**Risk**: LOW - ---- - -### Task 2.4: Make useCEODashboard Hook Skip Disabled APIs - -**File to modify**: `src/hooks/useCEODashboard.ts` - -**Change**: Accept boolean flags for which APIs to call. When `dailyProduction: false`, skip that API call entirely (return empty data, no network request). - -```typescript -// The hook already accepts flags like { dailyProduction: true } -// Just ensure that when false is passed, useDashboardFetch returns -// a stable empty result without making a network request. - -// Verify the existing useDashboardFetch implementation handles this: -const dailyProduction = useDashboardFetch( - enabled.dailyProduction ? 'dashboard/production/summary' : null, // null = skip - // ... -); -``` - -**Estimated time**: 0.5 day (need to verify/modify useDashboardFetch) -**Risk**: LOW - ---- - -### Task 2.5: Fix CalendarSection Module Route References - -**File to modify**: `src/components/business/CEODashboard/sections/CalendarSection.tsx` - -**Change**: Replace hardcoded route strings with conditional navigation. - -```typescript -// BEFORE: -order: '/production/work-orders', -construction: '/construction/project/contract', - -// AFTER: -import { useModules } from '@/hooks/useModules'; -// Inside component: -const { isEnabled } = useModules(); - -// In click handler: -if (type === 'order' && isEnabled('production')) { - router.push('/production/work-orders'); -} else if (type === 'construction' && isEnabled('construction')) { - router.push('/construction/project/contract'); -} else { - // No navigation if module not available - toast.info('해당 모듈이 활성화되어 있지 않습니다.'); -} -``` - -**Estimated time**: 0.25 day -**Risk**: LOW - ---- - -### Task 2.6: Make Summary Nav Bar Module-Aware - -**File to modify**: `src/components/business/CEODashboard/useSectionSummary.ts` - -**Change**: Exclude module-dependent sections from summary calculation when module is disabled. - -**Estimated time**: 0.25 day -**Risk**: LOW - ---- - -### Phase 2 Summary - -| Task | Effort | Risk | Parallel? | -|------|--------|------|-----------| -| 2.1 Module-aware types | 0.25d | LOW | Yes | -| 2.2 CEODashboard module-aware | 0.5d | MEDIUM | After 2.1 | -| 2.3 Settings dialog | 0.25d | LOW | After 2.1 | -| 2.4 useCEODashboard skip | 0.5d | LOW | After 2.1 | -| 2.5 CalendarSection routes | 0.25d | LOW | After 2.1 | -| 2.6 Summary nav bar | 0.25d | LOW | After 2.1 | -| **Total** | **2d** | | | -| **Buffer** | **+0.5d** | | | -| **Phase 2 Total** | **2.5d** | | | - -**Phase 2 Exit Criteria**: -- Kyungdong tenant dashboard shows production/shipment sections, no construction -- Juil tenant dashboard shows construction section, no production/shipment -- Common-only tenant dashboard shows neither production nor construction sections -- Settings dialog only shows available sections -- No console errors, no failed API calls for disabled sections -- Calendar navigation gracefully handles missing modules - ---- - -## 5. Phase 3: Physical Separation - -> **Goal**: Organize the codebase so tenant-specific code is clearly demarcated and can be optionally excluded from builds. -> **Duration**: 2-3 days -> **Prerequisite**: Phase 2 complete -> **Risk**: LOW (reorganization, no behavior change) - -### Task 3.1: Create Module Boundary Markers - -Rather than physically moving files (which would create massive diffs and break git history), we establish **boundary markers** using barrel exports and documentation. - -**Files to create**: -- `src/components/production/MODULE.md` (module metadata) -- `src/components/quality/MODULE.md` -- `src/components/business/construction/MODULE.md` -- `src/components/vehicle-management/MODULE.md` - -```markdown -# MODULE.md -- Production Module - -**Module ID**: production -**Tenant**: Kyungdong (Shutter MES) -**Route Prefixes**: /production -**Component Count**: 56 files -**Dependencies on Common ERP**: - - @/lib/api/* (server actions, API client) - - @/components/ui/* (UI primitives) - - @/components/templates/* (list/detail templates) - - @/components/organisms/* (page layout) - - @/hooks/* (usePermission, etc.) - - @/types/process.ts (shared process types) - - @/stores/authStore (tenant info) - - @/stores/menuStore (sidebar state) -**Exports to Common ERP**: NONE (all cross-references resolved in Phase 0) -**Shared via @/interfaces/**: production-orders.ts -**Shared via @/components/document-system/**: InspectionReportModal, WorkLogModal -``` - -**Estimated time**: 0.25 day - ---- - -### Task 3.2: Verify Build With Module Stubbing - -Create a script that verifies the build can succeed when tenant modules are replaced with stubs. - -**File to create**: `scripts/verify-module-separation.sh` - -```bash -#!/bin/bash -# Verify that common ERP builds cleanly when tenant modules are stubbed. -# This does NOT actually build -- it checks for import violations. - -echo "Checking for forbidden imports (Common -> Tenant)..." - -# Define tenant-specific paths -TENANT_PATHS=( - "@/components/production/" - "@/components/quality/" - "@/components/business/construction/" - "@/components/vehicle-management/" -) - -# Define common ERP source directories (excluding tenant pages) -COMMON_DIRS=( - "src/components/approval" - "src/components/accounting" - "src/components/auth" - "src/components/atoms" - "src/components/board" - "src/components/business/CEODashboard" - "src/components/business/Dashboard.tsx" - "src/components/clients" - "src/components/common" - "src/components/customer-center" - "src/components/document-system" - "src/components/hr" - "src/components/items" - "src/components/layout" - "src/components/material" - "src/components/molecules" - "src/components/organisms" - "src/components/orders" - "src/components/outbound" - "src/components/pricing" - "src/components/providers" - "src/components/reports" - "src/components/settings" - "src/components/stocks" - "src/components/templates" - "src/components/ui" - "src/lib" - "src/hooks" - "src/stores" - "src/contexts" -) - -VIOLATIONS=0 - -for dir in "${COMMON_DIRS[@]}"; do - for tenant_path in "${TENANT_PATHS[@]}"; do - # Search for static imports from tenant paths - found=$(grep -rn "from ['\"]${tenant_path}" "$dir" --include="*.ts" --include="*.tsx" 2>/dev/null | grep -v "// MODULE_SEPARATION_OK" | grep -v "dynamic(") - if [ -n "$found" ]; then - echo "VIOLATION: $dir imports from $tenant_path" - echo "$found" - VIOLATIONS=$((VIOLATIONS + 1)) - fi - done -done - -if [ $VIOLATIONS -eq 0 ]; then - echo "All clear. No forbidden imports found." - exit 0 -else - echo "Found $VIOLATIONS forbidden import(s). Fix before proceeding." - exit 1 -fi -``` - -**Estimated time**: 0.5 day -**Risk**: LOW (read-only verification) - ---- - -### Task 3.3: Sales Production-Orders Conditional Loading - -The `sales/order-management-sales/production-orders/` pages should only be accessible when the production module is enabled. - -**Strategy**: Add a `useModules` check at the top of these page components. - -**Files to modify**: -- `src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx` -- `src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx` -- `src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx` - -```typescript -// ADD to top of each page component: -import { useModules } from '@/hooks/useModules'; - -export default function ProductionOrdersPage() { - const { isEnabled } = useModules(); - - if (!isEnabled('production')) { - return ( - -
-

생산관리 모듈이 활성화되어 있지 않습니다.

-
-
- ); - } - - // ... existing page content -} -``` - -**Estimated time**: 0.5 day -**Risk**: LOW - ---- - -### Task 3.4: Update tsconfig Path Aliases (Optional) - -For future package extraction, add path aliases that make module boundaries explicit. - -**File to modify**: `tsconfig.json` - -```json -{ - "compilerOptions": { - "paths": { - "@/*": ["./src/*"], - "@modules/*": ["./src/modules/*"], - "@interfaces/*": ["./src/interfaces/*"] - } - } -} -``` - -**Estimated time**: 0.25 day -**Risk**: LOW - ---- - -### Task 3.5: Document Module Boundaries in CLAUDE.md - -**File to modify**: Project `CLAUDE.md` - -Add a section documenting the module separation architecture: - -```markdown -## Module Separation Architecture -**Priority**: RED - -### Module Ownership -| Module ID | Route Prefixes | Component Path | Tenant | -|-----------|----------------|----------------|--------| -| common | /dashboard, /accounting, /sales, ... | src/components/{accounting,approval,...} | All | -| production | /production | src/components/production/ | Kyungdong | -| quality | /quality | src/components/quality/ | Kyungdong | -| construction | /construction | src/components/business/construction/ | Juil | -| vehicle-management | /vehicle-management | src/components/vehicle-management/ | Optional | - -### Dependency Rules -- ALLOWED: tenant module -> Common ERP (e.g., production -> @/lib/*) -- FORBIDDEN: Common ERP -> tenant module (e.g., approval -> production) -- SHARED: Use @/interfaces/ for types, @/components/document-system/ for shared modals -- DYNAMIC: Use next/dynamic for optional cross-module component loading - -### Verification -Run `scripts/verify-module-separation.sh` to check for forbidden imports. -``` - -**Estimated time**: 0.25 day -**Risk**: LOW - ---- - -### Phase 3 Summary - -| Task | Effort | Risk | Parallel? | -|------|--------|------|-----------| -| 3.1 Module boundary markers | 0.25d | LOW | Yes | -| 3.2 Verification script | 0.5d | LOW | Yes | -| 3.3 Sales conditional loading | 0.5d | LOW | Yes | -| 3.4 tsconfig paths | 0.25d | LOW | Yes | -| 3.5 Document in CLAUDE.md | 0.25d | LOW | Yes | -| **Total** | **1.75d** | | | -| **Buffer** | **+0.5d** | | | -| **Phase 3 Total** | **2.25d** | | | - -**Phase 3 Exit Criteria**: -- `verify-module-separation.sh` passes with 0 violations -- Each module has MODULE.md with dependency documentation -- Sales production-orders pages check module availability -- tsconfig has @modules/ and @interfaces/ aliases -- CLAUDE.md documents module separation rules - ---- - -## 6. Phase 4: Manifest-Based Module Loading (Future) - -> **Goal**: Backend-driven module configuration. No frontend code changes needed for new tenants. -> **Duration**: 5-8 days (separate project) -> **Prerequisite**: Phase 3 complete + backend API changes - -This phase is a separate project. Documenting the design here for reference. - -### 4.1: Backend Module Configuration API - -**New endpoint**: `GET /api/v1/tenant/modules` - -```json -{ - "tenant_id": 282, - "modules": [ - { - "id": "production", - "enabled": true, - "config": { - "features": ["work-orders", "worker-screen", "production-dashboard"], - "hidden_features": ["screen-production"] - } - }, - { - "id": "quality", - "enabled": true, - "config": { - "features": ["equipment", "inspections", "qms"] - } - } - ], - "dashboard_sections": ["production", "shipment", "unshipped"], - "menu_overrides": {} -} -``` - -### 4.2: Frontend Manifest Loader - -```typescript -// src/modules/manifest-loader.ts -export async function loadModuleManifest(tenantId: number): Promise { - const response = await fetch(`/api/proxy/tenant/modules`); - const data = await response.json(); - return data.modules; -} -``` - -### 4.3: Dynamic Route Generation - -Using Next.js catch-all routes with module manifest: - -```typescript -// src/app/[locale]/(protected)/[...slug]/page.tsx -// This catch-all route handles module pages that are dynamically enabled. -// Phase 4 only -- requires significant backend work. -``` - -### 4.4: Module Feature Flags - -Fine-grained control within modules: - -```typescript -// e.g., production module has features: work-orders, worker-screen, etc. -// A tenant might have production enabled but worker-screen disabled -function isFeatureEnabled(moduleId: string, featureId: string): boolean; -``` - ---- - -## 7. Testing Strategy - -### Phase 0 Testing - -| Test | Method | Who | -|------|--------|-----| -| ApprovalBox renders correctly | Manual: navigate to /approval/inbox, open a work_order linked document | User | -| Sales production-orders list works | Manual: /sales/order-management-sales/production-orders | User | -| Sales production-order detail works | Manual: click an item in the list above | User | -| QMS page renders | Manual: /quality/qms | User | -| Dashboard invalidation still works | Manual: create a work order, navigate to dashboard, verify production section refreshes | User | -| Build passes | User runs `npm run build` | User | - -### Phase 1 Testing - -| Test | Method | Who | -|------|--------|-----| -| Module guard blocks unauthorized routes | Set tenant industry to 'construction', navigate to /production | Dev | -| Module guard allows authorized routes | Set tenant industry to 'shutter_mes', navigate to /production | Dev | -| Common routes always accessible | Navigate to /accounting, /sales, /hr with any tenant | Dev | -| Module-denied toast appears | Navigate to denied route, verify redirect + toast | Dev | -| Build passes | User runs `npm run build` | User | - -### Phase 2 Testing - -| Test | Method | Who | -|------|--------|-----| -| Kyungdong dashboard shows production | Log in as Kyungdong tenant, check dashboard sections | Dev | -| Kyungdong dashboard hides construction | Same login, verify no construction section | Dev | -| Juil dashboard shows construction | Log in as Juil tenant, check dashboard | Dev | -| Juil dashboard hides production | Same login, verify no production/shipment sections | Dev | -| Common tenant shows neither | Log in as common-only tenant, check dashboard | Dev | -| Settings dialog matches | Open settings, verify only available sections shown | Dev | -| Calendar navigation handles missing modules | Click calendar item that would go to disabled module | Dev | -| No console errors | Check browser console during all above tests | Dev | -| Build passes | User runs `npm run build` | User | - -### Phase 3 Testing - -| Test | Method | Who | -|------|--------|-----| -| Verification script passes | Run `scripts/verify-module-separation.sh` | Dev | -| Sales production-orders disabled for non-production tenant | Navigate as construction tenant | Dev | -| Full regression test | Navigate all major pages as each tenant type | Dev + User | -| Build passes | User runs `npm run build` | User | - ---- - -## 8. Risk Register - -| # | Risk | Probability | Impact | Mitigation | Phase | -|---|------|------------|--------|------------|-------| -| R1 | InspectionReportModal dynamic import fails silently | LOW | HIGH | Error boundary wrapper, fallback UI, monitoring | 0 | -| R2 | Sales production-orders breaks after interface extraction | MEDIUM | HIGH | Keep original actions.ts as fallback, test all 3 pages | 0 | -| R3 | Middleware route guard blocks legitimate access | MEDIUM | CRITICAL | Start with client-side guard (softer failure), add middleware later | 1 | -| R4 | Tenant industry field not set for existing tenants | HIGH | MEDIUM | Default to 'all modules enabled' when industry is undefined | 1 | -| R5 | Dashboard section removal changes layout/spacing | LOW | LOW | Test each section combination, verify CSS grid/flex behavior | 2 | -| R6 | Dashboard API call failure on disabled module endpoint | LOW | MEDIUM | Graceful null handling already exists (data ?? fallback) | 2 | -| R7 | Build size increases due to dynamic imports | LOW | LOW | Measure bundle size before/after, dynamic imports reduce initial bundle | 3 | -| R8 | Developer accidentally adds forbidden import | MEDIUM | LOW | Verification script in CI, CLAUDE.md rules, MODULE.md docs | 3 | - -### Rollback Strategy Per Phase - -| Phase | Rollback Method | Time to Rollback | -|-------|----------------|------------------| -| Phase 0 | `git revert` each task commit | < 5 minutes | -| Phase 1 | Remove ModuleGuard from layout.tsx, revert middleware | < 10 minutes | -| Phase 2 | Revert CEODashboard changes (remove isEnabled checks) | < 10 minutes | -| Phase 3 | No runtime changes to revert (documentation + scripts only) | N/A | - ---- - -## 9. Folder Structure Before/After - -### Before (Current) - -``` -src/ - app/[locale]/(protected)/ - accounting/ # Common ERP - approval/ # Common ERP (imports from production -- VIOLATION) - board/ # Common ERP - construction/ # Juil tenant - customer-center/ # Common ERP - dashboard/ # Common ERP (renders production/construction -- VIOLATION) - hr/ # Common ERP - master-data/ # Common ERP - material/ # Common ERP - outbound/ # Common ERP - production/ # Kyungdong tenant - quality/ # Kyungdong tenant - reports/ # Common ERP - sales/ # Common ERP (imports from production -- VIOLATION) - settings/ # Common ERP - vehicle-management/ # Optional - components/ - approval/ # imports InspectionReportModal from production - business/ - CEODashboard/ # hardcodes production/construction sections - construction/ # Juil tenant components - production/ # Kyungdong tenant components - quality/ # Kyungdong tenant components - vehicle-management/ # Optional components - lib/ - dashboard-invalidation.ts # hardcodes production/construction -``` - -### After (End of Phase 3) - -``` -src/ - modules/ # NEW - index.ts # module registry - types.ts # ModuleId, ModuleManifest, TenantModuleConfig - tenant-config.ts # industry -> module mapping - route-resolver.ts # tenant-aware route resolution - interfaces/ # NEW - production-orders.ts # shared types + actions for sales <-> production - components/ - AssigneeSelectModal.tsx # dynamic import wrapper - app/[locale]/(protected)/ - accounting/ # Common ERP - approval/ # Common ERP (no more production imports) - board/ # Common ERP - construction/ # Juil tenant (guarded by ModuleGuard) - customer-center/ # Common ERP - dashboard/ # Common ERP (conditionally renders sections) - hr/ # Common ERP - master-data/ # Common ERP - material/ # Common ERP - outbound/ # Common ERP - production/ # Kyungdong tenant (guarded by ModuleGuard) - quality/ # Kyungdong tenant (guarded by ModuleGuard) - reports/ # Common ERP - sales/ # Common ERP (production-orders guarded) - settings/ # Common ERP - vehicle-management/ # Optional (guarded by ModuleGuard) - components/ - auth/ - ModuleGuard.tsx # NEW: route-level module access check - approval/ # clean (no production imports) - business/ - CEODashboard/ # module-aware section rendering - construction/ # Juil tenant (with MODULE.md) - document-system/ - modals/ # NEW: shared modals - InspectionReportModal.tsx # dynamic import wrapper - WorkLogModal.tsx # dynamic import wrapper - index.ts - production/ # Kyungdong tenant (with MODULE.md) - WorkOrders/documents/ - InspectionReportModal.tsx # re-exports from document-system - WorkLogModal.tsx # re-exports from document-system - quality/ # Kyungdong tenant (with MODULE.md) - vehicle-management/ # Optional (with MODULE.md) - hooks/ - useModules.ts # NEW: tenant module access hook - lib/ - dashboard-invalidation.ts # registry-based (no hardcoded modules) - scripts/ - verify-module-separation.sh # NEW: import violation checker -``` - ---- - -## 10. Migration Order & Parallelism - -### Execution Timeline - -``` -Week 1 (Phase 0): - Day 1-2: Tasks 0.1, 0.2, 0.3, 0.4 (all parallel) - Day 3: Task 0.5, 0.6 - Day 4: Phase 0 testing + buffer - -Week 2 (Phase 1 + Phase 2): - Day 1: Tasks 1.1, 1.2, 1.3 (parallel) - Day 2: Tasks 1.4, 1.5 (sequential) - Day 3: Task 1.6 + Phase 1 testing - Day 4: Tasks 2.1, 2.2, 2.3 (2.1 first, then parallel) - Day 5: Tasks 2.4, 2.5, 2.6 + Phase 2 testing - -Week 3 (Phase 3 + Buffer): - Day 1: Tasks 3.1, 3.2, 3.3, 3.4, 3.5 (all parallel) - Day 2: Phase 3 testing + full regression - Day 3: Buffer / bug fixes -``` - -### Parallelism Map - -``` -Phase 0: - [0.1 InspReportModal] [0.2 ProdOrders Interface] [0.3 Route Nav] [0.4 QMS Types] - | | | | - v v | | - [0.5 Dev Gen] ----------------+ | - | | - [0.6 Dashboard Invalidation] --+----------------------------------------+ - | - v - Phase 0 Testing - -Phase 1: - [1.1 Types] ----+----> [1.2 Registry] ----+ - | | - +----> [1.3 Tenant Config]-+----> [1.4 useModules] ----> [1.5 Route Guard] ----> [1.6 Toast] - | - v - Phase 1 Testing - -Phase 2: - [2.1 Module-aware types] ----+----> [2.2 Dashboard] - |----> [2.3 Settings Dialog] - |----> [2.4 Hook Skip] - |----> [2.5 Calendar Routes] - +----> [2.6 Summary Nav] - | - v - Phase 2 Testing - -Phase 3: - [3.1 MODULE.md] [3.2 Verify Script] [3.3 Sales Guard] [3.4 tsconfig] [3.5 CLAUDE.md] - | | | | | - v v v v v - Phase 3 Testing + Full Regression -``` - -### What Can Be Done Today (Before Backend Changes) - -Everything in Phases 0-3 can proceed without backend changes. The `useModules` hook falls back to industry-based module resolution using the existing `tenant.options.industry` field in the auth store. The only backend requirement is that this field is populated correctly for existing tenants. - -If `industry` is not set for some tenants, the system defaults to showing all modules (current behavior), so there is zero risk of breaking existing functionality. - ---- - -## Appendix A: File Count Summary - -| Category | Files | Pages | -|----------|-------|-------| -| Common ERP components | ~400+ | ~165 | -| Production (Kyungdong) | 56 component files | 12 pages | -| Quality (Kyungdong) | 35+ component files | 14 pages | -| Construction (Juil) | 161 component files | 57 pages | -| Vehicle Management (Optional) | 13 component files | 12 pages | -| **Total** | ~665+ | 275 | - -## Appendix B: New Files Created (All Phases) - -| File | Phase | Purpose | -|------|-------|---------| -| `src/modules/types.ts` | 1 | Module type definitions | -| `src/modules/index.ts` | 1 | Module registry | -| `src/modules/tenant-config.ts` | 1 | Tenant-to-module mapping | -| `src/modules/route-resolver.ts` | 0 | Tenant-aware route resolution | -| `src/interfaces/production-orders.ts` | 0 | Shared production order types/actions | -| `src/interfaces/components/AssigneeSelectModal.tsx` | 0 | Dynamic import wrapper | -| `src/components/document-system/modals/InspectionReportModal.tsx` | 0 | Dynamic import wrapper | -| `src/components/document-system/modals/WorkLogModal.tsx` | 0 | Dynamic import wrapper | -| `src/components/document-system/modals/index.ts` | 0 | Barrel export | -| `src/components/auth/ModuleGuard.tsx` | 1 | Route-level module check | -| `src/hooks/useModules.ts` | 1 | Module access hook | -| `scripts/verify-module-separation.sh` | 3 | Import violation checker | -| `src/components/production/MODULE.md` | 3 | Module boundary doc | -| `src/components/quality/MODULE.md` | 3 | Module boundary doc | -| `src/components/business/construction/MODULE.md` | 3 | Module boundary doc | -| `src/components/vehicle-management/MODULE.md` | 3 | Module boundary doc | - -**Total new files**: 16 -**Total modified files**: ~20 - -## Appendix C: Backend API Request Summary - -| # | Type | Endpoint | Description | Required Phase | -|---|------|----------|-------------|----------------| -| B1 | MODIFY | `GET /api/auth/user` | Add `tenant.options.modules` array | Phase 1 (optional, has fallback) | -| B2 | NEW | `GET /api/v1/tenant/modules` | Full module configuration | Phase 4 | - ---- - -**Document Version**: 1.0 -**Author**: Claude (System Architect analysis) -**Review Required By**: Backend team (B1, B2), Frontend lead (all phases) diff --git a/claudedocs/architecture/[REF-2025-11-19] multi-tenancy-implementation.md b/claudedocs/architecture/[REF-2025-11-19] multi-tenancy-implementation.md deleted file mode 100644 index 6faab5e5..00000000 --- a/claudedocs/architecture/[REF-2025-11-19] multi-tenancy-implementation.md +++ /dev/null @@ -1,1045 +0,0 @@ -# 멀티테넌시 구현 검토 및 개선 방안 - -**작성일**: 2025-11-19 -**목적**: 현재 프로젝트의 로그인/데이터 저장 구조를 멀티테넌시 관점에서 검토하고 개선 방안 제시 - ---- - -## 📋 목차 - -1. [현재 상태 분석](#현재-상태-분석) -2. [핵심 문제점](#핵심-문제점) -3. [데이터 오염 시나리오](#데이터-오염-시나리오) -4. [개선 방안](#개선-방안) -5. [구현 로드맵](#구현-로드맵) - ---- - -## 현재 상태 분석 - -### 1. 실제 로그인 응답 구조 - -#### 🔍 서버 응답 (실제) - -```typescript -// 로그인 성공 시 받는 실제 데이터 -{ - userId: "TestUser3", - name: "드미트리", - position: "시스템 관리자", - roles: [ - { - id: 19, - name: "system_manager", - description: "시스템 관리자" - } - ], - tenant: { - id: 282, // ✅ 테넌트 고유 ID - company_name: "(주)테크컴퍼니", // ✅ 테넌트 이름 - business_num: "123-45-67890", - tenant_st_code: "trial", - other_tenants: [] // 다중 테넌트 지원 가능성 - }, - menu: [ - { - id: "13664", - label: "시스템 대시보드", - iconName: "layout-dashboard", - path: "/dashboard" - }, - // ... - ] -} -``` - -#### ✅ 중요 발견 -1. **tenant.id**: 테넌트 고유 ID (숫자 타입) → **캐시 키로 사용해야 함** -2. **tenant.company_name**: 회사명 (UI 표시용) -3. **other_tenants**: 다중 테넌트 전환 가능성 (향후 확장) - ---- - -### 2. 인증 시스템 (AuthContext) - -#### 📁 파일 위치 -``` -src/contexts/AuthContext.tsx -``` - -#### 🔍 현재 구조 (문제점) - -**User 타입 정의** (9-25 라인) -```typescript -export interface User { - id: string; - username: string; - email: string; - password: string; - name: string; - role: UserRole; - companyName: string; // ⚠️ 실제 응답과 구조 불일치 - position?: string; - // ... - // ❌ tenant 객체가 없음! - // ❌ tenant.id를 참조할 방법 없음! -} -``` - -**localStorage 사용** (119-145 라인) -```typescript -// 초기 로드 -const savedUsers = localStorage.getItem('mes-users'); // ❌ tenant.id 없음 -const savedCurrentUser = localStorage.getItem('mes-currentUser'); // ❌ tenant.id 없음 - -// 저장 -localStorage.setItem('mes-users', JSON.stringify(users)); -localStorage.setItem('mes-currentUser', JSON.stringify(currentUser)); -``` - -#### ⚠️ 문제점 -1. **타입 불일치**: User 타입이 실제 서버 응답과 다름 -2. **tenant 객체 부재**: tenant.id를 참조할 수 없음 -3. **localStorage 키 고정**: 모든 테넌트가 같은 키 사용 → 데이터 충돌 - ---- - -### 3. 품목 마스터 데이터 관리 (ItemMasterContext) - -#### 📁 파일 위치 -``` -src/contexts/ItemMasterContext.tsx -``` - -#### 🔍 localStorage 사용 패턴 - -**사용 중인 localStorage 키** (778-861 라인) -```typescript -// 13개의 마스터 데이터 -'mes-itemMasters' // ❌ tenant.id 없음 -'mes-specificationMasters' // ❌ tenant.id 없음 -'mes-specificationMasters-version' -'mes-materialItemNames' -'mes-materialItemNames-version' -'mes-itemCategories' -'mes-itemUnits' -'mes-itemMaterials' -'mes-surfaceTreatments' -'mes-partTypeOptions' -'mes-partUsageOptions' -'mes-guideRailOptions' -'mes-sectionTemplates' -'mes-itemMasterFields' -'mes-itemPages' -``` - -#### ⚠️ 문제점 -1. **tenant.id 미포함**: 모든 키에 tenant.id가 없음 -2. **데이터 격리 불가**: 여러 테넌트가 같은 키 사용 → 데이터 충돌 - ---- - -## 핵심 문제점 - -### 🚨 1. User 타입과 실제 응답 구조 불일치 - -**영향도**: 🔴 CRITICAL - -```typescript -// ❌ 현재 AuthContext -interface User { - companyName: string; // 실제 응답에는 없음 -} - -// ✅ 실제 서버 응답 -interface ActualUser { - tenant: { - id: 282, // 테넌트 고유 ID - company_name: "(주)테크컴퍼니", - business_num: "123-45-67890", - tenant_st_code: "trial", - other_tenants: [] - } -} -``` - -**문제**: -- 실제 tenant.id를 참조할 수 없음 -- 타입 불일치로 인한 런타임 에러 가능성 -- 멀티테넌시 구현 불가능 - ---- - -### 🚨 2. localStorage 키에 tenant.id 미포함 - -**영향도**: 🔴 CRITICAL - -```typescript -// ❌ 현재 - 모든 테넌트가 같은 키 사용 -localStorage.getItem('mes-itemMasters') - -// ✅ 필요 - tenant.id 기반 격리 -const tenantId = currentUser.tenant.id; // 282 -localStorage.getItem(`mes-${tenantId}-itemMasters`) // 'mes-282-itemMasters' -``` - -**문제**: -- 같은 브라우저에서 여러 테넌트 사용 시 데이터 충돌 -- 테넌트 A(id: 282)의 데이터가 테넌트 B(id: 350)에 노출될 위험 - ---- - -### 🚨 3. 테넌트 전환 감지 로직 부재 - -**영향도**: 🔴 CRITICAL - -```typescript -// ❌ 현재 - 테넌트 전환 감지 없음 - -// ✅ 필요 - tenant.id 변경 감지 -useEffect(() => { - const prevTenantId = previousTenantRef.current; - const currentTenantId = currentUser?.tenant?.id; - - if (prevTenantId && prevTenantId !== currentTenantId) { - clearTenantCache(prevTenantId); - } - - previousTenantRef.current = currentTenantId; -}, [currentUser?.tenant?.id]); -``` - ---- - -## 데이터 오염 시나리오 - -### 시나리오 1: 순차적 로그인 - -```yaml -# 타임라인 -1. [09:00] 사용자 A (tenant.id: 282) 로그인 - → localStorage.setItem('mes-itemMasters', [...TENANT-282 데이터...]) - -2. [09:30] 사용자 A 로그아웃 - -3. [10:00] 사용자 B (tenant.id: 350) 로그인 - → 품목관리 페이지 진입 - → localStorage.getItem('mes-itemMasters') - -4. [10:00:01] ❌ 문제 발생 - → TENANT-282의 데이터가 TENANT-350 사용자에게 잠깐 보임 - → API 응답 도착 후 TENANT-350 데이터로 교체 (늦음) - -# 결과 -- 잠깐이지만 잘못된 데이터 노출 -- 보안 위반 (GDPR, 개인정보보호법 위반 가능성) -- 사용자 혼란 (화면 깜빡임) -``` - ---- - -### 시나리오 2: 다중 탭 동시 사용 - -```yaml -# 타임라인 -1. [브라우저 탭1] 사용자 A (tenant.id: 282) 로그인 - → localStorage.setItem('mes-itemMasters', [...TENANT-282...]) - -2. [브라우저 탭2] 사용자 B (tenant.id: 350) 로그인 - → localStorage.setItem('mes-itemMasters', [...TENANT-350...]) - → ❌ TENANT-282 데이터 덮어씀! - -3. [탭1로 돌아옴] - → localStorage.getItem('mes-itemMasters') - → ❌ TENANT-350 데이터가 나옴! - -# 결과 -- localStorage는 오리진(도메인) 단위 공유 -- 탭 간 데이터 충돌 -- 예측 불가능한 동작 -``` - ---- - -### 시나리오 3: other_tenants 기능 사용 시 - -```yaml -# 사용자가 여러 테넌트에 소속된 경우 -User: { - tenant: { id: 282, company_name: "A기업" }, - other_tenants: [ - { id: 350, company_name: "B기업" }, - { id: 415, company_name: "C기업" } - ] -} - -# 테넌트 전환 시나리오 -1. A기업(282) 데이터 로드 → localStorage 저장 -2. B기업(350)으로 전환 -3. localStorage에 여전히 A기업 데이터 존재 -4. ❌ 데이터 오염 발생 - -# 결과 -- 다중 테넌트 전환 시 캐시 관리 필수 -``` - ---- - -## 개선 방안 - -### Phase 1: User 타입을 실제 구조에 맞게 수정 (필수 🔴) - -#### 1.1 AuthContext.tsx 수정 - -**타입 정의 추가** -```typescript -// src/contexts/AuthContext.tsx - -// ✅ 추가: Tenant 타입 정의 -export interface Tenant { - id: number; // 테넌트 고유 ID - company_name: string; // 회사명 - business_num: string; // 사업자번호 - tenant_st_code: string; // 테넌트 상태 코드 (trial, active 등) - other_tenants?: Tenant[]; // 다른 소속 테넌트 목록 (다중 테넌트) -} - -// ✅ 추가: Role 타입 정의 -export interface Role { - id: number; - name: string; - description: string; -} - -// ✅ 추가: MenuItem 타입 정의 -export interface MenuItem { - id: string; - label: string; - iconName: string; - path: string; -} - -// ✅ 수정: User 타입을 실제 서버 응답에 맞게 -export interface User { - userId: string; // 사용자 ID - name: string; // 사용자 이름 - position: string; // 직책 - roles: Role[]; // 권한 목록 - tenant: Tenant; // ✅ 테넌트 정보 (필수!) - menu: MenuItem[]; // 메뉴 목록 -} -``` - -**초기 데이터 업데이트** -```typescript -const initialUsers: User[] = [ - { - userId: "TestUser1", - name: "김대표", - position: "대표이사", - roles: [ - { - id: 1, - name: "ceo", - description: "최고경영자" - } - ], - tenant: { - id: 282, // ✅ 테넌트 ID - company_name: "(주)테크컴퍼니", // ✅ 회사명 - business_num: "123-45-67890", - tenant_st_code: "trial", - other_tenants: [] - }, - menu: [ - { - id: "13664", - label: "시스템 대시보드", - iconName: "layout-dashboard", - path: "/dashboard" - } - ] - }, - // ... 나머지 사용자 -]; -``` - ---- - -#### 1.2 테넌트 전환 감지 로직 추가 - -```typescript -// src/contexts/AuthContext.tsx - -export function AuthProvider({ children }: { children: ReactNode }) { - const [users, setUsers] = useState(initialUsers); - const [currentUser, setCurrentUser] = useState(null); - - // ✅ 추가: 이전 tenant.id 추적 - const previousTenantIdRef = useRef(null); - - // ✅ 추가: 테넌트 변경 감지 - useEffect(() => { - const prevTenantId = previousTenantIdRef.current; - const currentTenantId = currentUser?.tenant?.id; - - if (prevTenantId && currentTenantId && prevTenantId !== currentTenantId) { - console.log(`[Auth] Tenant changed: ${prevTenantId} → ${currentTenantId}`); - clearTenantCache(prevTenantId); - } - - previousTenantIdRef.current = currentTenantId || null; - }, [currentUser?.tenant?.id]); - - // ✅ 추가: 테넌트별 캐시 삭제 함수 - const clearTenantCache = (tenantId: number) => { - const prefix = `mes-${tenantId}-`; - - // localStorage 캐시 삭제 - Object.keys(localStorage).forEach(key => { - if (key.startsWith(prefix)) { - localStorage.removeItem(key); - console.log(`[Cache] Cleared localStorage: ${key}`); - } - }); - - // sessionStorage 캐시 삭제 - Object.keys(sessionStorage).forEach(key => { - if (key.startsWith(prefix)) { - sessionStorage.removeItem(key); - console.log(`[Cache] Cleared sessionStorage: ${key}`); - } - }); - }; - - // ✅ 추가: 로그아웃 시 현재 테넌트 캐시 삭제 - const logout = () => { - if (currentUser?.tenant?.id) { - clearTenantCache(currentUser.tenant.id); - } - setCurrentUser(null); - localStorage.removeItem('mes-currentUser'); - }; - - const value: AuthContextType = { - users, - currentUser, - setCurrentUser, - logout, // ✅ 추가 - clearTenantCache, // ✅ 추가 - // ... 기존 함수들 - }; - - return {children}; -} -``` - ---- - -### Phase 2: TenantAwareCache 유틸리티 구현 (필수 🔴) - -#### 2.1 캐시 유틸리티 생성 - -```typescript -// src/lib/cache/TenantAwareCache.ts - -interface CachedData { - tenantId: number; // ✅ tenant.id 타입 (number) - data: T; - timestamp: number; - version?: string; -} - -export class TenantAwareCache { - private tenantId: number; // ✅ tenant.id 타입 (number) - private storage: Storage; - private ttl: number; // Time to Live (ms) - - constructor( - tenantId: number, // ✅ tenant.id를 받음 - storage: Storage = sessionStorage, // sessionStorage 기본값 (탭 격리) - ttl: number = 3600000 // 1시간 기본값 - ) { - this.tenantId = tenantId; - this.storage = storage; - this.ttl = ttl; - } - - /** - * 테넌트별 고유 키 생성 - * 예: tenant.id = 282 → 'mes-282-itemMasters' - */ - private getKey(key: string): string { - return `mes-${this.tenantId}-${key}`; - } - - /** - * 캐시에 데이터 저장 - */ - set(key: string, data: T, version?: string): void { - const cacheData: CachedData = { - tenantId: this.tenantId, - data, - timestamp: Date.now(), - version - }; - - this.storage.setItem(this.getKey(key), JSON.stringify(cacheData)); - } - - /** - * 캐시에서 데이터 조회 (tenantId 및 TTL 검증) - */ - get(key: string): T | null { - const cached = this.storage.getItem(this.getKey(key)); - if (!cached) return null; - - try { - const parsed: CachedData = JSON.parse(cached); - - // 🛡️ tenantId 검증 - if (parsed.tenantId !== this.tenantId) { - console.warn( - `[Cache] tenantId mismatch for key "${key}": ` + - `${parsed.tenantId} !== ${this.tenantId}` - ); - this.remove(key); - return null; - } - - // 🛡️ TTL 검증 (만료 시간) - if (Date.now() - parsed.timestamp > this.ttl) { - console.warn(`[Cache] Expired cache for key: ${key}`); - this.remove(key); - return null; - } - - return parsed.data; - } catch (error) { - console.error(`[Cache] Parse error for key: ${key}`, error); - this.remove(key); - return null; - } - } - - /** - * 캐시에서 특정 키 삭제 - */ - remove(key: string): void { - this.storage.removeItem(this.getKey(key)); - } - - /** - * 현재 테넌트의 모든 캐시 삭제 - */ - clear(): void { - const prefix = `mes-${this.tenantId}-`; - - Object.keys(this.storage).forEach(key => { - if (key.startsWith(prefix)) { - this.storage.removeItem(key); - } - }); - } - - /** - * 버전 일치 여부 확인 - */ - isVersionMatch(key: string, expectedVersion: string): boolean { - const cached = this.storage.getItem(this.getKey(key)); - if (!cached) return false; - - try { - const parsed: CachedData = JSON.parse(cached); - return parsed.version === expectedVersion; - } catch { - return false; - } - } - - /** - * 캐시 메타데이터 조회 - */ - getMetadata(key: string): { tenantId: number; timestamp: number; version?: string } | null { - const cached = this.storage.getItem(this.getKey(key)); - if (!cached) return null; - - try { - const parsed: CachedData = JSON.parse(cached); - return { - tenantId: parsed.tenantId, - timestamp: parsed.timestamp, - version: parsed.version - }; - } catch { - return null; - } - } -} -``` - ---- - -#### 2.2 ItemMasterContext에 적용 - -```typescript -// src/contexts/ItemMasterContext.tsx - -import { useAuth } from './AuthContext'; -import { TenantAwareCache } from '@/lib/cache/TenantAwareCache'; - -export function ItemMasterProvider({ children }: { children: ReactNode }) { - const { currentUser } = useAuth(); - - // ✅ tenant.id 추출 - const tenantId = currentUser?.tenant?.id; - - // ✅ TenantAwareCache 인스턴스 생성 - const cache = useMemo( - () => { - if (!tenantId) return null; - - return new TenantAwareCache( - tenantId, // tenant.id = 282 - sessionStorage, // 탭 격리 - 3600000 // 1시간 TTL - ); - }, - [tenantId] - ); - - // 상태 - const [itemMasters, setItemMasters] = useState([]); - const [specificationMasters, setSpecificationMasters] = useState([]); - // ... - - // ✅ 초기 로드 (캐시 + API) - useEffect(() => { - if (!tenantId || !cache) return; - - const loadData = async () => { - // 1️⃣ 캐시 확인 (즉시 렌더) - const cachedSpec = cache.get('specificationMasters'); - if (cachedSpec) { - setSpecificationMasters(cachedSpec); - console.log(`[Cache] Loaded from cache (tenant: ${tenantId})`); - } - - // 2️⃣ 백그라운드 API 호출 - try { - const response = await fetch( - `/api/tenants/${tenantId}/item-master-config/masters/specifications` - ); - - if (!response.ok) throw new Error('Failed to fetch specifications'); - - const { data } = await response.json(); - - setSpecificationMasters(data); - - // 3️⃣ 캐시 갱신 - cache.set('specificationMasters', data, '1.0'); - console.log(`[API] Data loaded and cached (tenant: ${tenantId})`); - - } catch (error) { - console.error('[API] Failed to load specifications:', error); - // 4️⃣ 에러 시 캐시 폴백 (이미 사용 중) - if (!cachedSpec) { - console.error('[Cache] No cache available, showing error'); - } - } - }; - - loadData(); - }, [tenantId, cache]); - - // ✅ 저장 (API + 캐시 갱신) - const addSpecificationMaster = async (spec: SpecificationMaster) => { - if (!tenantId || !cache) { - throw new Error('Tenant ID not available'); - } - - try { - const response = await fetch( - `/api/tenants/${tenantId}/item-master-config/masters/specifications`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(spec) - } - ); - - if (!response.ok) throw new Error('Failed to add specification'); - - // 상태 업데이트 - const newData = [...specificationMasters, spec]; - setSpecificationMasters(newData); - - // 캐시 갱신 - cache.set('specificationMasters', newData, '1.0'); - console.log(`[Cache] Updated after add (tenant: ${tenantId})`); - - } catch (error) { - console.error('[API] Failed to add specification:', error); - throw error; - } - }; - - return ( - - {children} - - ); -} -``` - ---- - -### Phase 3: API 서버 측 tenant.id 검증 (필수 🔴) - -#### 3.1 인증 미들웨어 - -```typescript -// backend/middleware/auth.ts - -import { NextRequest, NextResponse } from 'next/server'; -import { verifyJWT } from '@/lib/jwt'; - -export async function validateTenantAccess( - request: NextRequest, - requestedTenantId: string | number -): Promise { - // 1️⃣ JWT 토큰에서 사용자 정보 추출 - const token = request.headers.get('Authorization')?.replace('Bearer ', ''); - - if (!token) { - throw new Error('No authentication token'); - } - - const payload = await verifyJWT(token); - - // ✅ tenant.id 타입 통일 (문자열 → 숫자) - const requestedId = typeof requestedTenantId === 'string' - ? parseInt(requestedTenantId, 10) - : requestedTenantId; - - // 2️⃣ 토큰의 tenant.id와 요청의 tenant.id 비교 - if (payload.tenant.id !== requestedId) { - throw new Error( - `Tenant access denied: ${payload.tenant.id} !== ${requestedId}` - ); - } - - return true; -} -``` - -#### 3.2 API 라우트 핸들러 - -```typescript -// app/api/tenants/[tenantId]/item-master-config/route.ts - -import { NextRequest, NextResponse } from 'next/server'; -import { validateTenantAccess } from '@/backend/middleware/auth'; - -export async function GET( - request: NextRequest, - { params }: { params: { tenantId: string } } -) { - try { - // 🛡️ tenant.id 검증 - await validateTenantAccess(request, params.tenantId); - - // ✅ 검증 통과 → 해당 테넌트 데이터만 반환 - const config = await db.itemMasterConfig.findUnique({ - where: { - tenantId: parseInt(params.tenantId, 10), - isActive: true - } - }); - - return NextResponse.json({ - success: true, - data: config - }); - - } catch (error) { - return NextResponse.json( - { - success: false, - error: { - code: 'FORBIDDEN', - message: '테넌트 접근 권한이 없습니다.', - details: error.message - } - }, - { status: 403 } - ); - } -} -``` - ---- - -## 구현 로드맵 - -### ✅ Phase 1: User 타입 수정 (1일) - -```yaml -우선순위: 🔴 CRITICAL -예상 시간: 1일 - -작업 항목: - 1. AuthContext.tsx 수정: - - Tenant, Role, MenuItem 타입 정의 추가 - - User 타입을 실제 서버 응답 구조에 맞게 수정 - - 초기 데이터 업데이트 (tenant.id 포함) - - 테넌트 전환 감지 로직 추가 - - clearTenantCache 함수 구현 - - logout 함수에 캐시 삭제 추가 - - 2. 검증: - - 로그인 시 tenant.id 정상 로드 확인 - - console.log로 tenant.id 값 확인 -``` - ---- - -### ✅ Phase 2: TenantAwareCache 구현 (1일) - -```yaml -우선순위: 🔴 CRITICAL -예상 시간: 1일 - -작업 항목: - 1. TenantAwareCache 유틸리티: - - src/lib/cache/TenantAwareCache.ts 생성 - - tenantId를 number 타입으로 처리 - - 단위 테스트 작성 (선택) - - 2. 검증: - - cache.set() 호출 시 키 확인: 'mes-282-itemMasters' - - cache.get() 호출 시 tenantId 검증 확인 - - TTL 만료 테스트 -``` - ---- - -### ✅ Phase 3: ItemMasterContext 마이그레이션 (2일) - -```yaml -우선순위: 🔴 CRITICAL -예상 시간: 2일 - -작업 항목: - 1. ItemMasterContext 리팩토링: - - TenantAwareCache 적용 - - 모든 localStorage 호출 → cache.set/get 교체 - - localStorage → sessionStorage 전환 - - tenant.id 추출 로직 추가 - - 13개 마스터 데이터 모두 적용 - - 2. 검증: - - 각 마스터 데이터 캐시 키 확인 - - 다중 탭 테스트 (같은 테넌트) - - 다중 탭 테스트 (다른 테넌트) - - 로그아웃 후 재로그인 테스트 -``` - ---- - -### ✅ Phase 4: API 서버 검증 (1-2일) - -```yaml -우선순위: 🔴 CRITICAL -예상 시간: 1-2일 - -작업 항목: - 1. 인증 미들웨어: - - validateTenantAccess 구현 - - JWT에서 tenant.id 추출 - - tenant.id 타입 통일 (string ↔ number) - - 2. API 라우트: - - 모든 /api/tenants/[tenantId]/* 엔드포인트에 검증 추가 - - 403 에러 응답 처리 - - 3. 검증: - - 정상 tenant.id 접근 테스트 - - 잘못된 tenant.id 접근 차단 확인 - - 에러 응답 확인 -``` - ---- - -### ✅ Phase 5: 다중 테넌트 전환 지원 (선택, 2일) - -```yaml -우선순위: 🟢 RECOMMENDED -예상 시간: 2일 - -작업 항목: - 1. other_tenants 기능: - - 테넌트 전환 UI 추가 - - 전환 시 캐시 삭제 확인 - - 전환 시 API 재호출 확인 - - 2. 검증: - - A기업 → B기업 전환 테스트 - - 각 테넌트별 데이터 격리 확인 -``` - ---- - -## 체크리스트 - -### 🔴 필수 항목 (Phase 1-4) - -```yaml -□ AuthContext User 타입 수정 (tenant 객체 포함) -□ Tenant, Role, MenuItem 타입 정의 추가 -□ 초기 사용자 데이터에 tenant.id 할당 -□ 테넌트 전환 감지 로직 추가 (useEffect + useRef) -□ clearTenantCache 함수 구현 -□ logout 함수에 캐시 삭제 추가 -□ TenantAwareCache 유틸리티 구현 (tenantId: number) -□ ItemMasterContext에 TenantAwareCache 적용 -□ 13개 마스터 데이터 모두 캐시 마이그레이션 -□ localStorage → sessionStorage 전환 -□ API 미들웨어 validateTenantAccess 추가 -□ 모든 API 라우트에 tenant.id 검증 추가 -□ 다중 탭 테스트 완료 (같은 테넌트) -□ 다중 탭 테스트 완료 (다른 테넌트) -□ 테넌트 전환 테스트 완료 -□ 로그아웃 후 재로그인 테스트 완료 -``` - -### 🟢 권장 항목 (Phase 5) - -```yaml -□ other_tenants 다중 테넌트 전환 기능 -□ 테넌트 전환 UI 구현 -□ Stale-While-Revalidate 패턴 적용 -□ HTTP 캐싱 헤더 설정 -□ 캐시 메트릭 수집 -□ 성능 테스트 -``` - ---- - -## 실제 구현 예시 - -### 예시 1: 캐시 키 생성 - -```typescript -// tenant.id = 282인 사용자 -const cache = new TenantAwareCache(282, sessionStorage); - -// 키 생성 -cache.set('itemMasters', data); -// → sessionStorage에 'mes-282-itemMasters' 저장 - -cache.set('specificationMasters', data); -// → sessionStorage에 'mes-282-specificationMasters' 저장 -``` - ---- - -### 예시 2: 테넌트 전환 시 - -```typescript -// 사용자 A (tenant.id: 282) 로그인 -currentUser = { - tenant: { id: 282, company_name: "A기업" } -} -// sessionStorage: 'mes-282-itemMasters', 'mes-282-specificationMasters', ... - -// 사용자 B (tenant.id: 350)로 전환 -currentUser = { - tenant: { id: 350, company_name: "B기업" } -} -// useEffect 트리거 → clearTenantCache(282) 호출 -// sessionStorage에서 'mes-282-*' 모두 삭제 -// 새로운 캐시: 'mes-350-itemMasters', 'mes-350-specificationMasters', ... -``` - ---- - -### 예시 3: API 호출 - -```typescript -// 클라이언트 -const tenantId = currentUser.tenant.id; // 282 -const response = await fetch(`/api/tenants/${tenantId}/item-master-config`); - -// 서버 -// validateTenantAccess(request, "282") -// JWT 토큰: { tenant: { id: 282 } } -// 비교: 282 === 282 → ✅ 통과 - -// 만약 잘못된 요청 -const response = await fetch(`/api/tenants/350/item-master-config`); -// JWT 토큰: { tenant: { id: 282 } } -// 비교: 282 !== 350 → ❌ 403 Forbidden -``` - ---- - -## 보안 고려사항 - -### 🛡️ 클라이언트 측 보안 - -1. **sessionStorage 사용**: localStorage보다 탭 격리로 더 안전 -2. **tenant.id 검증**: 캐시 조회 시 항상 검증 -3. **TTL 설정**: 만료된 캐시 자동 삭제 (1시간) -4. **에러 처리**: 손상된 캐시 안전 제거 - -### 🛡️ 서버 측 보안 - -1. **JWT 검증**: 모든 요청에 토큰 검증 -2. **tenant.id 검증**: JWT의 tenant.id와 URL 파라미터 비교 -3. **403 Forbidden**: 권한 없는 접근 차단 -4. **데이터베이스 격리**: WHERE tenant_id = ? 항상 포함 - -### 🛡️ 타입 안정성 - -1. **tenant.id 타입**: number (서버 응답 기준) -2. **URL 파라미터**: string → number 변환 필요 -3. **TypeScript**: 컴파일 타임 타입 체크 - ---- - -## 참고 자료 - -### 관련 문서 -- [API_DESIGN_ITEM_MASTER_CONFIG.md](./_API_DESIGN_ITEM_MASTER_CONFIG) -- [CLEANUP_SUMMARY.md](./CLEANUP_SUMMARY.md) - -### 외부 참고 -- [Multi-Tenancy Best Practices](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) -- [Browser Storage Security](https://developer.mozilla.org/en-US/docs/Web/Security/Types_of_attacks#cross-site_scripting_xss) - ---- - -**문서 버전**: 1.1 (tenant.id 반영) -**마지막 업데이트**: 2025-11-19 -**다음 리뷰**: Phase 1 완료 후 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/contexts/AuthContext.tsx` - 인증 및 테넌트 정보 관리 -- `src/contexts/ItemMasterContext.tsx` - 품목 마스터 데이터 Context (localStorage 사용) -- `src/lib/cache/TenantAwareCache.ts` - 테넌트별 캐시 유틸리티 (구현 예정) -- `src/middleware.ts` - 테넌트 식별 미들웨어 - -### 백엔드 (구현 예정) -- `app/api/tenants/[tenantId]/item-master-config/route.ts` - 테넌트별 API 라우트 -- `backend/middleware/auth.ts` - 테넌트 접근 검증 미들웨어 - -### 참조 문서 -- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` - 인증 구현 가이드 -- `claudedocs/architecture/[REF] architecture-integration-risks.md` - 아키텍처 통합 위험 요소 -- `claudedocs/architecture/[IMPL-2025-11-18] ssr-hydration-fix.md` - SSR/Hydration 에러 해결 \ No newline at end of file diff --git a/claudedocs/architecture/[REF] architecture-integration-risks.md b/claudedocs/architecture/[REF] architecture-integration-risks.md deleted file mode 100644 index 8ffe3050..00000000 --- a/claudedocs/architecture/[REF] architecture-integration-risks.md +++ /dev/null @@ -1,867 +0,0 @@ -# 아키텍처 통합 위험 요소 분석 - -## 📋 문서 개요 - -이 문서는 현재 구성된 기반 설정에 추가 설계 가이드를 병합할 때 예상되는 위험 요소와 해결 방안을 제시합니다. - -**작성일**: 2025-11-06 -**업데이트**: 2025-11-06 (Next.js 15.5.6으로 다운그레이드, React Hook Form + Zod 추가) -**프로젝트**: Multi-tenant ERP System -**기술 스택**: -- Frontend: Next.js 15.5.6, React 19, next-intl, React Hook Form, Zod, TypeScript 5 -- Backend: PHP Laravel + Sanctum (API) -- Deployment: Vercel (Frontend) - ---- - -## 🏗️ 현재 아키텍처 구성 - -### 1. 기술 스택 -```yaml -Frontend (Next.js): - - Next.js: 15.5.6 (stable, production-ready) - - React: 19.2.0 (latest) - - TypeScript: 5.x - - Deployment: Vercel - -Internationalization: - - next-intl: 4.4.0 - - Locales: ko (default), en, ja - -Form Management & Validation: - - React Hook Form: 7.54.2 - - Zod: 3.24.1 - - @hookform/resolvers: 3.9.1 - -Styling: - - Tailwind CSS: 4.x (latest) - - PostCSS: 4.x - -Backend (Laravel): - - PHP Laravel: 10.x+ - - Database: MySQL/PostgreSQL - - Authentication: Laravel Sanctum (SPA Token Authentication) - - API: RESTful JSON API - - Deployment: 별도 서버 (Git 관리) - -Architecture: - - Frontend: Next.js (Vercel) - UI/UX, i18n - - Backend: Laravel - Business Logic, DB, API - - Communication: HTTP/HTTPS API calls - - Auth Flow: Laravel Sanctum → Token → Next.js Storage -``` - -### 2. 디렉토리 구조 -``` -src/ -├── app/[locale]/ # 다국어 라우팅 -├── components/ # 공용 컴포넌트 -├── i18n/ # i18n 설정 -├── messages/ # 번역 파일 (ko, en, ja) -└── middleware.ts # 통합 미들웨어 -``` - -### 3. 구현된 기능 -- ✅ 다국어 지원 (ko, en, ja) -- ✅ SEO 최적화 (noindex, robots.txt) -- ✅ 봇 차단 미들웨어 -- ✅ 보안 헤더 설정 -- ✅ TypeScript 엄격 모드 -- ✅ 폼 관리 및 유효성 검증 (React Hook Form + Zod) - ---- - -## ⚠️ 주요 위험 요소 - -### 🔴 HIGH PRIORITY - -#### 1. 멀티 테넌시 + i18n 복잡도 - -**문제**: 테넌트 격리와 다국어 라우팅의 충돌 가능성 - -**예상 시나리오**: -``` -❌ 잠재적 충돌: -/[locale]/[tenant]/dashboard -vs -/[tenant]/[locale]/dashboard - -어떤 구조를 선택할 것인가? -``` - -**위험도**: 🔴 높음 - -**영향 범위**: -- URL 구조 전체 -- 라우팅 로직 -- 미들웨어 복잡도 -- SEO 구조 - -**해결 방안**: - -**옵션 A: Locale 우선 (현재 구조 유지)** -```typescript -// URL 구조: /[locale]/[tenant]/dashboard -// 장점: i18n 우선, 언어 전환 간편 -// 단점: 테넌트별 커스텀 도메인 어려움 - -/ko/acme-corp/dashboard → ACME 한국어 대시보드 -/en/acme-corp/dashboard → ACME 영어 대시보드 -/ko/beta-inc/dashboard → Beta Inc. 한국어 대시보드 -``` - -**옵션 B: Tenant 우선** -```typescript -// URL 구조: /[tenant]/[locale]/dashboard -// 장점: 테넌트 격리 명확, 커스텀 도메인 용이 -// 단점: 언어 전환 시 URL 복잡도 증가 - -/acme-corp/ko/dashboard -/acme-corp/en/dashboard -``` - -**옵션 C: 서브도메인 분리 (권장)** -```typescript -// URL 구조: {tenant}.domain.com/[locale]/dashboard -// 장점: 완벽한 테넌트 격리, 깔끔한 URL -// 단점: DNS 설정 필요, 미들웨어 복잡도 증가 - -acme-corp.erp.com/ko/dashboard -acme-corp.erp.com/en/dashboard -beta-inc.erp.com/ko/dashboard -``` - -**권장 전략**: -```typescript -// 1단계: 개발 환경 (Locale 우선) -/[locale]/[tenant]/dashboard - -// 2단계: 프로덕션 (서브도메인) -{tenant}.domain.com/[locale]/dashboard - -// 미들웨어에서 처리 -export function middleware(request: NextRequest) { - const hostname = request.headers.get('host'); - - // 서브도메인에서 테넌트 추출 - const tenant = extractTenantFromHostname(hostname); - - // 로케일은 기존 로직 사용 - const locale = detectLocale(request); - - // 컨텍스트에 테넌트 정보 주입 - request.headers.set('x-tenant-id', tenant); -} -``` - ---- - -#### 3. 미들웨어 성능 및 복잡도 - -**현재 미들웨어 책임**: -```typescript -1. 로케일 감지 및 리다이렉션 -2. 봇 차단 (User-Agent 검사) -3. 보안 헤더 추가 -4. 로깅 - -향후 추가 예상: -5. 인증 검증 (JWT/Session) -6. 권한 확인 (RBAC) -7. 테넌트 식별 및 격리 -8. Rate Limiting -9. API 키 검증 -10. CORS 처리 -``` - -**위험도**: 🔴 높음 (복잡도 증가) - -**성능 영향**: -```typescript -// 미들웨어는 모든 요청마다 실행됨 -// 현재: ~5-10ms -// 인증 추가: ~20-50ms -// DB 조회 추가: ~100-200ms ⚠️ 위험! -``` - -**해결 방안**: - -**1. 미들웨어 분리 전략** -```typescript -// src/middleware/index.ts -import { chainMiddleware } from '@/lib/middleware-chain'; -import { i18nMiddleware } from './i18n'; -import { botBlockingMiddleware } from './bot-blocking'; -import { authMiddleware } from './auth'; -import { tenantMiddleware } from './tenant'; - -export default chainMiddleware([ - i18nMiddleware, // 1순위: 로케일 감지 - botBlockingMiddleware, // 2순위: 봇 차단 (빠른 종료) - tenantMiddleware, // 3순위: 테넌트 식별 - authMiddleware, // 4순위: 인증 (DB 조회 최소화) -]); -``` - -**2. 성능 최적화** -```typescript -// ✅ 캐싱 활용 -const tenantCache = new Map(); - -// ✅ DB 조회 최소화 -// 미들웨어: 토큰 검증만 -// API Route: DB 조회 - -// ✅ Edge Runtime 활용 (Vercel/Cloudflare) -export const config = { - runtime: 'edge', // 빠른 실행 -}; -``` - -**3. 조건부 실행** -```typescript -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - // 정적 파일은 스킵 - if (pathname.startsWith('/_next/static')) { - return NextResponse.next(); - } - - // 공개 경로는 인증 스킵 - if (PUBLIC_PATHS.includes(pathname)) { - return i18nOnly(request); - } - - // 보호된 경로만 전체 검증 - return fullMiddleware(request); -} -``` - ---- - -### 🟡 MEDIUM PRIORITY - -#### 4. 데이터베이스 스키마와 다국어 (Laravel 백엔드) - -**✅ 확정**: 데이터베이스 및 API는 Laravel에서 관리 - -**Laravel 다국어 처리 전략**: - -**옵션 A: JSON 컬럼 (Laravel에서 간편)** -```php -// Laravel Migration -Schema::create('products', function (Blueprint $table) { - $table->uuid('id')->primary(); - $table->string('sku', 50)->unique(); - $table->json('name'); // {"ko": "제품명", "en": "Product Name", "ja": "製品名"} - $table->json('description')->nullable(); - $table->timestamps(); -}); - -// Laravel Model -class Product extends Model { - protected $casts = [ - 'name' => 'array', - 'description' => 'array', - ]; - - public function getTranslatedName($locale = 'ko') { - return $this->name[$locale] ?? $this->name['ko']; - } -} -``` - -**옵션 B: 번역 테이블 (권장 - 성능 최적화)** -```php -// Laravel Migration - products table -Schema::create('products', function (Blueprint $table) { - $table->uuid('id')->primary(); - $table->string('sku', 50)->unique(); - $table->timestamps(); -}); - -// Laravel Migration - product_translations table -Schema::create('product_translations', function (Blueprint $table) { - $table->uuid('product_id'); - $table->string('locale', 5); - $table->string('name'); - $table->text('description')->nullable(); - - $table->primary(['product_id', 'locale']); - $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); - $table->index('locale'); -}); - -// Laravel Model -class Product extends Model { - public function translations() { - return $this->hasMany(ProductTranslation::class); - } - - public function translation($locale = 'ko') { - return $this->translations()->where('locale', $locale)->first(); - } -} - -class ProductTranslation extends Model { - public $timestamps = false; - protected $fillable = ['locale', 'name', 'description']; -} -``` - -**Laravel API 응답 예시**: -```php -// API Controller -public function show(Product $product, Request $request) { - $locale = $request->header('X-Locale', 'ko'); - - return response()->json([ - 'id' => $product->id, - 'sku' => $product->sku, - 'name' => $product->translation($locale)->name, - 'description' => $product->translation($locale)->description, - ]); -} -``` - -**Next.js에서 사용**: -```typescript -// API 호출 with 로케일 -const fetchProduct = async (id: string, locale: string) => { - const res = await fetch(`${LARAVEL_API_URL}/api/products/${id}`, { - headers: { - 'X-Locale': locale, - 'Authorization': `Bearer ${token}`, - }, - }); - return res.json(); -}; -``` - -**권장**: 옵션 B (번역 테이블) - Laravel Eloquent ORM과 잘 동작 - ---- - -#### 5. 인증 시스템 통합 (Laravel Sanctum) - -**✅ 확정**: 인증은 Laravel Sanctum에서 처리, Next.js는 토큰 관리만 - -**Laravel Sanctum 인증 플로우**: - -``` -1. 로그인 요청 (Next.js) - ↓ -2. Laravel API 인증 (/api/login) - ↓ -3. Sanctum Token 발급 - ↓ -4. Next.js에 토큰 저장 (Cookie/LocalStorage) - ↓ -5. 이후 모든 API 요청에 토큰 포함 -``` - -**Laravel API 설정**: -```php -// routes/api.php -Route::post('/login', [AuthController::class, 'login']); -Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); -Route::get('/user', function (Request $request) { - return $request->user(); -})->middleware('auth:sanctum'); - -// app/Http/Controllers/AuthController.php -public function login(Request $request) { - $credentials = $request->validate([ - 'email' => 'required|email', - 'password' => 'required', - ]); - - if (!Auth::attempt($credentials)) { - return response()->json(['message' => 'Invalid credentials'], 401); - } - - $user = Auth::user(); - $token = $user->createToken('auth-token')->plainTextToken; - - return response()->json([ - 'user' => $user, - 'token' => $token, - ]); -} -``` - -**Next.js 미들웨어 (토큰 검증만)**: -```typescript -// src/middleware.ts -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - // 1단계: i18n 먼저 처리 (로케일 정규화) - const intlResponse = intlMiddleware(request); - - // 2단계: 정규화된 경로로 인증 체크 - const locale = getLocaleFromPath(intlResponse.url); - const pathWithoutLocale = removeLocale(pathname, locale); - - // 3단계: 보호된 경로인지 확인 - if (requiresAuth(pathWithoutLocale)) { - // 쿠키에서 토큰 확인 - const token = request.cookies.get('auth_token')?.value; - - if (!token) { - // 로케일 포함하여 로그인 페이지로 리다이렉트 - const loginUrl = new URL(`/${locale}/login`, request.url); - loginUrl.searchParams.set('callbackUrl', request.url); - return NextResponse.redirect(loginUrl); - } - - // ⚠️ 주의: 미들웨어에서는 토큰 유효성 검증 안 함 - // → Laravel API 호출 시 자동으로 검증됨 - // → 성능 최적화 (매 요청마다 DB 조회 방지) - } - - return intlResponse; -} -``` - -**Next.js API 호출 유틸리티**: -```typescript -// src/lib/api.ts -const LARAVEL_API_URL = process.env.NEXT_PUBLIC_LARAVEL_API_URL; - -export async function apiCall(endpoint: string, options: RequestInit = {}) { - const token = getCookie('auth_token'); - - const res = await fetch(`${LARAVEL_API_URL}${endpoint}`, { - ...options, - headers: { - 'Authorization': `Bearer ${token}`, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - ...options.headers, - }, - }); - - if (res.status === 401) { - // 토큰 만료 → 로그아웃 처리 - deleteCookie('auth_token'); - window.location.href = '/login'; - } - - return res.json(); -} - -// 로그인 -export async function login(email: string, password: string) { - const data = await apiCall('/api/login', { - method: 'POST', - body: JSON.stringify({ email, password }), - }); - - // 토큰 저장 - setCookie('auth_token', data.token, { maxAge: 60 * 60 * 24 * 7 }); // 7일 - - return data.user; -} - -// 로그아웃 -export async function logout() { - await apiCall('/api/logout', { method: 'POST' }); - deleteCookie('auth_token'); -} -``` - -**주요 특징**: -- ✅ **Next.js 미들웨어**: 토큰 존재 여부만 확인 (빠름) -- ✅ **Laravel API**: 실제 토큰 검증 및 사용자 인증 -- ✅ **토큰 저장**: HTTP-only Cookie (XSS 방지) -- ✅ **토큰 갱신**: Laravel Sanctum 자동 처리 - ---- - -#### 6. 빌드 및 배포 설정 - -**정적 생성 vs 동적 렌더링**: - -**현재 문제**: -```typescript -// 모든 로케일 × 모든 페이지 조합 생성 -// 3개 언어 × 100개 페이지 = 300개 정적 페이지 -// → 빌드 시간 증가 - -export function generateStaticParams() { - return locales.map((locale) => ({ locale })); -} -``` - -**해결 방안**: -```typescript -// 옵션 1: ISR (Incremental Static Regeneration) -export const revalidate = 3600; // 1시간마다 재생성 - -// 옵션 2: 동적 렌더링 (인증 필요 페이지) -export const dynamic = 'force-dynamic'; - -// 옵션 3: 하이브리드 (공개 페이지는 정적, 대시보드는 동적) -// src/app/[locale]/(public)/page.tsx → 정적 -// src/app/[locale]/(protected)/dashboard/page.tsx → 동적 -``` - -**권장 전략**: -```typescript -// 1. 공개 페이지 -export const dynamic = 'force-static'; -export const revalidate = 3600; - -// 2. 대시보드/ERP 기능 -export const dynamic = 'force-dynamic'; - -// 3. 리포트 페이지 -export const dynamic = 'force-dynamic'; -export const revalidate = 300; // 5분 캐시 -``` - ---- - -### 🟢 LOW PRIORITY - -#### 7. UI 컴포넌트 라이브러리 선택 - -**예상 추가 의존성**: -```json -{ - "dependencies": { - // 옵션 1: shadcn/ui (권장) - "@radix-ui/react-*": "^latest", - - // 옵션 2: Material-UI - "@mui/material": "^latest", - - // 옵션 3: Ant Design - "antd": "^latest" - } -} -``` - -**i18n 통합 고려사항**: -```typescript -// shadcn/ui: next-intl과 잘 작동 -import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/button'; - -const t = useTranslations('common'); - - -// Material-UI: 별도 LocalizationProvider 필요 -import { LocalizationProvider } from '@mui/x-date-pickers'; -// → next-intl과 중복 가능성 -``` - -**권장**: shadcn/ui (Tailwind 기반, next-intl 호환) - ---- - -#### 8. 상태 관리 라이브러리 - -**예상 추가 의존성**: -```json -{ - "dependencies": { - // 옵션 1: Zustand (권장) - "zustand": "^latest", - - // 옵션 2: Redux Toolkit - "@reduxjs/toolkit": "^latest", - "react-redux": "^latest", - - // 옵션 3: Jotai - "jotai": "^latest" - } -} -``` - -**다국어 통합**: -```typescript -// Zustand + next-intl -import { create } from 'zustand'; -import { useLocale } from 'next-intl'; - -const useStore = create((set) => ({ - locale: 'ko', - setLocale: (locale) => set({ locale }), -})); - -// 컴포넌트 -const locale = useLocale(); // next-intl -const { setLocale } = useStore(); // 전역 상태 -``` - -**충돌 가능성**: 낮음 (독립적 동작) - ---- - -## 🛡️ 통합 체크리스트 - -### 설계 가이드 병합 전 확인사항 - -#### Phase 1: 라우팅 구조 확정 -- [ ] 멀티 테넌시 전략 결정 (서브도메인 vs URL 기반) -- [ ] URL 구조 최종 확정 (`/[locale]/[tenant]` vs `{tenant}.domain/[locale]`) -- [ ] 미들웨어 실행 순서 정의 -- [ ] 404/에러 페이지 다국어 처리 - -#### Phase 2: 데이터베이스 설계 -- [ ] 다국어 데이터 저장 방식 결정 (JSON vs 번역 테이블) -- [ ] Prisma 스키마 작성 -- [ ] 마이그레이션 전략 수립 -- [ ] 시드 데이터 다국어 준비 - -#### Phase 3: 인증 시스템 -- [ ] 인증 라이브러리 선택 (NextAuth.js, Clerk, Supabase Auth 등) -- [ ] 세션 관리 전략 (JWT vs Database Session) -- [ ] 미들웨어 통합 (i18n + auth 순서) -- [ ] 로그인/로그아웃 플로우 다국어 처리 - -#### Phase 4: UI/UX -- [ ] 컴포넌트 라이브러리 선택 -- [ ] 디자인 시스템 정의 -- [ ] 반응형 레이아웃 전략 -- [ ] 다크모드 지원 여부 - -#### Phase 5: 성능 최적화 -- [ ] ISR vs SSR vs SSG 전략 -- [ ] 이미지 최적화 (next/image) -- [ ] 폰트 최적화 -- [ ] 번들 크기 모니터링 - -#### Phase 6: 배포 준비 -- [ ] 환경 변수 관리 (.env.local, .env.production) -- [ ] CI/CD 파이프라인 -- [ ] 도메인 및 DNS 설정 -- [ ] 모니터링 도구 (Sentry, LogRocket 등) - ---- - -## 🔧 권장 마이그레이션 전략 - -### 단계별 통합 플랜 - -#### Week 1-2: 기반 구조 검증 -```bash -✓ 현재 구조 분석 -✓ 설계 가이드 리뷰 -✓ 충돌 포인트 식별 -✓ 통합 전략 수립 -``` - -#### Week 3-4: 라우팅 및 미들웨어 -```bash -- 멀티 테넌시 구조 구현 -- 미들웨어 리팩토링 (체이닝) -- 테넌트 격리 테스트 -- 성능 벤치마크 -``` - -#### Week 5-6: 데이터베이스 및 인증 -```bash -- Prisma 스키마 완성 -- 인증 시스템 통합 -- 테넌트별 데이터 격리 -- 권한 시스템 구현 -``` - -#### Week 7-8: UI 컴포넌트 및 기능 -```bash -- 컴포넌트 라이브러리 설치 -- 공통 컴포넌트 개발 -- ERP 모듈 구현 시작 -- E2E 테스트 작성 -``` - ---- - -## 📊 위험도 매트릭스 - -| 위험 요소 | 발생 확률 | 영향도 | 우선순위 | 대응 전략 | -|---------|---------|--------|---------|---------| -| 멀티테넌시 + i18n 충돌 | 중간 | 높음 | 🔴 P1 | 서브도메인 전략 채택 | -| 미들웨어 성능 저하 | 중간 | 중간 | 🟡 P2 | 체이닝, 캐싱 최적화 | -| DB 스키마 복잡도 | 낮음 | 중간 | 🟡 P2 | 번역 테이블 패턴 | -| 인증 통합 충돌 | 중간 | 중간 | 🟡 P2 | 순서 정의, 테스트 | -| 빌드 시간 증가 | 중간 | 낮음 | 🟢 P3 | ISR, 하이브리드 렌더링 | -| UI 라이브러리 충돌 | 낮음 | 낮음 | 🟢 P3 | shadcn/ui 선택 | -| 상태 관리 복잡도 | 낮음 | 낮음 | 🟢 P3 | Zustand 권장 | - ---- - -## 🚀 즉시 적용 가능한 개선 사항 - -### 1. 미들웨어 체이닝 유틸리티 추가 - -```typescript -// src/lib/middleware-chain.ts -import { NextRequest, NextResponse } from 'next/server'; - -type Middleware = (request: NextRequest) => NextResponse | Promise; - -export function chainMiddleware(middlewares: Middleware[]) { - return async (request: NextRequest) => { - let response = NextResponse.next(); - - for (const middleware of middlewares) { - response = await middleware(request); - - // 리다이렉트나 에러 응답 시 체인 중단 - if (response.status !== 200) { - return response; - } - } - - return response; - }; -} -``` - -### 2. 환경 변수 검증 - -```typescript -// src/lib/env.ts -import { z } from 'zod'; - -const envSchema = z.object({ - NODE_ENV: z.enum(['development', 'production', 'test']), - DATABASE_URL: z.string().url(), - NEXTAUTH_SECRET: z.string().min(32), - NEXTAUTH_URL: z.string().url(), -}); - -export const env = envSchema.parse(process.env); -``` - -### 3. 타입 안전성 강화 - -```typescript -// src/types/tenant.ts -export type TenantId = string & { readonly __brand: 'TenantId' }; - -export function createTenantId(id: string): TenantId { - return id as TenantId; -} - -// 사용 예 -const tenantId = createTenantId('acme-corp'); -// 일반 string과 혼용 불가 → 타입 안전성 -``` - ---- - -## 📞 의사결정이 필요한 사항 - -### 즉시 결정 필요 (개발 시작 전) - -1. **멀티 테넌시 전략** - - [ ] 서브도메인 방식 (`{tenant}.domain.com`) - - [ ] URL 기반 방식 (`/[tenant]`) - - [ ] 하이브리드 (개발: URL, 프로덕션: 서브도메인) - -2. **데이터베이스** - - [ ] PostgreSQL - - [ ] MySQL - - [ ] Supabase (PostgreSQL + Auth) - -3. **인증 시스템** - - [ ] NextAuth.js (오픈소스) - - [ ] Clerk (상용) - - [ ] Supabase Auth - - [ ] 자체 구현 - -4. **배포 플랫폼** - - [ ] Vercel - - [ ] AWS - - [ ] Google Cloud - - [ ] Azure - -### 개발 중 결정 가능 - -5. **UI 컴포넌트 라이브러리** -6. **상태 관리 라이브러리** -7. **차트 라이브러리** (Recharts, Chart.js 등) - -### ✅ 이미 결정됨 - -- **폼 라이브러리**: React Hook Form + Zod (타입 안전성, 성능, 다국어 지원) - ---- - -## 🎯 결론 및 권장사항 - -### ✅ 현재 기반 설정은 프로덕션 준비 완료 - -현재 구성된 **Next.js 15.5.6 + Laravel Sanctum + next-intl + React Hook Form + Zod + TypeScript** 기반은 **멀티 테넌트 ERP 시스템 개발에 최적화**되었습니다. - -**주요 강점**: -- ✅ Next.js 15.5.6: 안정적이고 검증된 버전 (middleware 경고 없음) -- ✅ Laravel Sanctum: 토큰 기반 인증으로 프론트엔드/백엔드 완전 분리 -- ✅ next-intl 4.4.0: 다국어 지원 완벽 통합 -- ✅ React Hook Form + Zod: 타입 안전한 폼 관리 및 유효성 검증 -- ✅ React 19.2.0: 최신 기능 활용 가능 -- ✅ Tailwind CSS 4.x: 최신 스타일링 시스템 - -### ⚠️ 주의가 필요한 영역 - -1. **멀티테넌시 URL 구조** → 서브도메인 방식 권장 -2. **미들웨어 복잡도 관리** → 체이닝 패턴 도입 필요 -3. **Laravel API 엔드포인트 설정** → 환경 변수 구성 필수 - -### 🚦 진행 가능 여부 - -**판정**: ✅ **즉시 진행 가능** - -**충족 조건**: -- ✅ 안정적인 기술 스택 (Next.js 15.5.6) -- ✅ 명확한 아키텍처 분리 (Frontend/Backend) -- ✅ 다국어 지원 구조 완성 -- ✅ 인증 플로우 설계 완료 - -**진행 전 결정 필요**: -- 멀티 테넌시 전략 (서브도메인 vs URL 기반) -- Laravel API URL 환경 변수 설정 - -### 📋 Next Steps - -1. **즉시**: 멀티 테넌시 전략 결정 + Laravel API URL 설정 -2. **1주차**: 미들웨어 체이닝 구현 + 환경 변수 구성 -3. **2주차**: Laravel API 통합 테스트 + 인증 플로우 검증 -4. **3주차**: 첫 ERP 모듈 구현 시작 -5. **4주차**: UI 컴포넌트 라이브러리 통합 (shadcn/ui 권장) - ---- - -**문서 유효기간**: 2025-11-06 ~ 2025-12-06 (1개월) -**다음 리뷰**: 설계 가이드 통합 후 또는 주요 아키텍처 변경 시 - -**작성자**: Claude Code -**승인 필요**: 프로젝트 매니저, 시니어 개발자 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/middleware.ts` - 통합 미들웨어 (i18n, 인증, 봇 차단) -- `src/contexts/AuthContext.tsx` - 인증 상태 관리 Context -- `src/contexts/ItemMasterContext.tsx` - 품목 마스터 데이터 Context -- `src/lib/api/client.ts` - 통합 HTTP 클라이언트 -- `src/i18n/routing.ts` - 다국어 라우팅 설정 -- `src/messages/*.json` - 다국어 번역 파일 (ko, en, ja) - -### 설정 파일 -- `next.config.ts` - Next.js 설정 -- `.env.local` - 환경 변수 (API URL, 인증 설정) -- `tsconfig.json` - TypeScript 설정 -- `tailwind.config.ts` - Tailwind CSS 설정 - -### 참조 문서 -- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` - 인증 구현 가이드 -- `claudedocs/architecture/[REF-2025-11-19] multi-tenancy-implementation.md` - 멀티테넌시 구현 \ No newline at end of file diff --git a/claudedocs/architecture/[REF] technical-decisions.md b/claudedocs/architecture/[REF] technical-decisions.md deleted file mode 100644 index 2e12c4e9..00000000 --- a/claudedocs/architecture/[REF] technical-decisions.md +++ /dev/null @@ -1,316 +0,0 @@ -# 프로젝트 기술 결정 사항 - -> `_index.md`에서 분리됨 (2026-02-23). 프로젝트 전반의 기술 선택 배경과 근거를 기록. - ---- - -### `` 태그 사용 — `next/image` 미사용 이유 (2026-02-10) - -**현황**: 프로젝트 전체 `` 태그 10건, `next/image` 0건 - -**결정**: `` 유지, `next/image` 전환 불필요 - -**근거**: -1. **폐쇄형 ERP 시스템** — SEO 불필요, LCP 점수 무의미 -2. **전량 외부 동적 이미지** — 백엔드 API에서 받아오는 URL (정적 내부 이미지 0건) -3. **프린트/문서 레이아웃** — 10건 중 8건이 검사 기준서·도해 등 인쇄용. `next/image`의 `width`/`height` 강제 지정이 프린트 레이아웃을 깰 위험 -4. **blob URL 비호환** — 업로드 미리보기(blob:)는 `next/image`가 지원 안 함 -5. **설정 부담 > 이점** — `remotePatterns` 설정 + 백엔드 도메인 관리 비용이 실질 이점보다 큼 - -### 모바일 헤더 `backdrop-filter` 깜빡임 수정 (2026-02-11) - -**현상**: 모바일(Safari/Chrome)에서 sticky 헤더가 스크롤 시 투명↔불투명 깜빡임 발생. PC 브라우저 축소로는 재현 불가, 실제 모바일 기기에서만 발생. - -**원인 2가지**: -1. `globals.css`에 `* { transition: all 0.2s }` — 전체 요소의 모든 CSS 속성에 전역 transition. 모바일 스크롤 리페인트 시 background/opacity가 매번 애니메이션 -2. 모바일 헤더의 `clean-glass` 클래스: `backdrop-filter: blur(8px)` + `background: rgba(255,255,255, 0.95)` 조합이 모바일 sticky 요소에서 GPU 컴포지팅 충돌 - -**수정**: -- `globals.css`: `*` 전역 transition → `button, a, input, select, textarea, [role]` 인터랙티브 요소만, `transition: all` → `color, background-color, border-color, box-shadow` 속성만 -- 모바일 헤더: `clean-glass` (반투명+blur) → `bg-background border border-border` (불투명 배경) - -**교훈**: -- `transition: all`은 절대 `*`에 걸지 않기. 모바일 성능 저하 + 의도치 않은 애니메이션 발생 -- `backdrop-filter: blur()` + `sticky` 조합은 모바일 브라우저 고질적 리페인트 버그. 모바일 헤더는 불투명 배경 사용 -- 0.95 투명도는 육안 구분 불가 → 불투명 처리해도 시각적 차이 없음 - -**사용처 (9개 파일)**: -| 파일 | 용도 | 이미지 소스 | -|------|------|-------------| -| `DocumentHeader.tsx` (2건) | 문서 헤더 로고 | `logo.imageUrl` (API) | -| `ProductInspectionInputModal.tsx` | 제품검사 사진 미리보기 | blob URL | -| `ProductInspectionDocument.tsx` | 제품검사 문서 | `data.productImage` (API) | -| `inspection-shared.tsx` | 검사 기준서 이미지 | `standardImage` (API) | -| `SlatInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | -| `ScreenInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | -| `BendingInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | -| `SlatJointBarInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | -| `BendingWipInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | - -**참고**: `next/image`가 유효한 케이스는 공개 사이트 + 정적/내부 이미지 + SEO 중요한 상황 - -### `next/dynamic` 코드 스플리팅 적용 (2026-02-10) - -**결정**: 대형 컴포넌트 + 무거운 라이브러리에 `next/dynamic` / 동적 `import()` 적용 - -**핵심 개념 — Suspense vs dynamic()**: -- **`Suspense` + 정적 import** → 코드가 부모와 같은 번들 청크에 포함. 유저가 안 봐도 이미 다운로드됨. UI fallback만 제공하고 **코드 분할은 안 일어남** -- **`dynamic()`** → webpack이 별도 `.js` 청크로 분리. 컴포넌트가 실제 렌더될 때만 네트워크 요청으로 해당 청크 다운로드. **진짜 코드 분할** - -**적용 내역**: - -| 파일 | 대상 | 절감 | -|------|------|------| -| `reports/comprehensive-analysis/page.tsx` | MainDashboard (2,651줄 + recharts) | ~350KB | -| `components/business/Dashboard.tsx` | CEODashboard | ~200KB | -| `construction/ConstructionDashboard.tsx` | ConstructionMainDashboard | ~100KB | -| `production/dashboard/page.tsx` | ProductionDashboard | ~100KB | -| `lib/utils/excel-download.ts` | xlsx 라이브러리 (~400KB) | ~400KB | -| `quotes/LocationListPanel.tsx` | xlsx 직접 import 제거 | (위와 중복) | - -**xlsx 동적 로드 패턴**: -```typescript -// Before: 모든 페이지에 xlsx ~400KB 포함 -import * as XLSX from 'xlsx'; - -// After: 엑셀 버튼 클릭 시에만 로드 -async function loadXLSX() { - return await import('xlsx'); -} -export async function downloadExcel(...) { - const XLSX = await loadXLSX(); - // ... -} -``` - -**총 절감**: 초기 번들에서 ~850KB 제외 (대시보드 미방문 + 엑셀 미사용 시) - -### 테이블 가상화 (react-window) — 보류 (2026-02-10) - -**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 - -**근거**: -1. **페이지네이션 사용 중** — 리스트 페이지 대부분 서버 사이드 페이지네이션 (20~50건/페이지). 50개 ``은 브라우저가 문제없이 처리 -2. **적용 복잡도 높음** — 테이블 헤더 고정, 체크박스 선택, `rowSpan/colSpan` 병합 등 기존 기능과 충돌 가능. DataTable + IntegratedListTemplateV2 + UniversalListPage 전부 수정 필요 -3. **YAGNI** — 500건 이상 한 번에 렌더링하는 페이지가 현재 없음 - -**도입 시점**: 한 페이지에 200건+ 데이터를 페이지네이션 없이 표시해야 하는 요구가 생길 때 - -### SWR / React Query — 보류 (2026-02-10) - -**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 - -**근거**: -1. **기존 패턴 안정화 완료** — `useEffect` + Server Action 호출 패턴이 전 페이지에 일관 적용됨 -2. **캐싱 니즈 낮음** — 폐쇄형 ERP 특성상 항상 최신 데이터 필요. stale 데이터 표시는 오히려 위험 -3. **마스터데이터 캐싱 구현됨** — Zustand (`stores/masterDataStore`)로 변경 빈도 낮은 데이터는 이미 캐싱 중 -4. **도입 비용 과다** — 수십 개 페이지 `useState`+`useEffect` 패턴 전면 리팩토링 + 팀 학습 비용 - -**도입 시점**: 동일 데이터를 여러 컴포넌트에서 동시 요구하거나, 목록 ↔ 상세 이동 시 재로딩이 체감될 때 - -### 컴포넌트 레지스트리 관계도 (2026-02-12) - -**구현**: `/dev/component-registry` 페이지에 관계도(카드형 플로우) 뷰 추가 - -**구성**: -- `actions.ts` — `extractComponentImports()` + `buildRelationships()`로 import 관계 양방향 파싱 (imports/usedBy) -- `ComponentRelationshipView.tsx` — 3칼럼 카드형 플로우 (사용처 → 선택 컴포넌트 → 구성요소) -- `ComponentRegistryClient.tsx` — 목록/관계도 뷰 토글 - -**활용 규칙** (CLAUDE.md에 추가됨): -- 새 컴포넌트 생성 전 → 목록에서 중복 검색 + 관계도에서 조합 패턴 확인 -- 기존 컴포넌트 수정 시 → usedBy로 영향 범위 파악 - -### Action 팩토리 패턴 — 신규 CRUD 적용 규칙 (2026-02-10) - -**결정**: 기존 84개 actions.ts 전면 전환은 하지 않음. **신규 CRUD 도메인에만 팩토리 사용** - -**현황**: -- `src/lib/api/create-crud-service.ts` (177줄) — CRUD 보일러플레이트 자동 생성 팩토리 -- 현재 사용 중: TitleManagement, RankManagement (2개) -- 전환 가능: 15~20개 / 전환 불가 (커스텀 로직): 50+개 - -**규칙**: -- 신규 도메인 추가 시 단순 CRUD → `createCrudService` 사용 필수 -- 기존 actions.ts는 잘 동작하므로 무리하게 전환하지 않음 -- 커스텀 비즈니스 로직이 있는 도메인(견적, 수주, 생산 등)은 팩토리 비적합 - -**사용 예시**: -```typescript -import { createCrudService } from '@/lib/api/create-crud-service'; - -const service = createCrudService({ - basePath: '/api/v1/resources', - transform: (api) => ({ id: api.id, name: api.name }), - entityName: '리소스', -}); - -export const getList = service.getList; -export const getById = service.getById; -export const create = service.create; -export const update = service.update; -export const remove = service.remove; -``` - -**미전환 사유**: 84개 중 전환 가능 15~20개, 작업 2~4시간 대비 기능 변화 없음. 시간 대비 효율 낮음 - -### Server Action 공통 유틸리티 — 전체 마이그레이션 완료 (2026-02-12) - -**결정**: `buildApiUrl()` 전체 43개 actions.ts에 적용 완료 - -**배경**: -- 89개 actions.ts 중 43개에서 동일한 URLSearchParams 조건부 `.set()` 패턴 반복 (326+ 건) -- 50+ 파일에서 `current_page → currentPage` 수동 변환 반복 -- `toPaginationMeta`가 `src/lib/api/types.ts`에 존재하나 import 0건 - -**생성된 유틸리티**: -1. `src/lib/api/query-params.ts` — `buildQueryParams()`, `buildApiUrl()`: URLSearchParams 보일러플레이트 제거 -2. `src/lib/api/execute-paginated-action.ts` — `executePaginatedAction()`: 페이지네이션 조회 패턴 통합 (내부에서 `toPaginationMeta` 사용) - -**마이그레이션 결과** (2026-02-12): -- `new URLSearchParams` 사용: 326건 → **0건** (actions.ts 기준) -- `const API_URL = process.env.NEXT_PUBLIC_API_URL` 선언: 43개 → **0개** (마이그레이션 대상 파일) -- `buildApiUrl()` import: 43개 actions.ts 전체 적용 -- 3가지 API_URL 패턴 통합: 표준(`process.env`), `/api` 접미사(HR), `API_BASE` 전체경로(품질) → 모두 `buildApiUrl('/api/v1/...')` 통일 - -**`executePaginatedAction` 마이그레이션** (2026-02-12): -- 14개 actions.ts에서 페이지네이션 목록 조회 함수를 `executePaginatedAction`으로 전환 -- Wave A (accounting 9개): BillManagement, DepositManagement, SalesManagement, PurchaseManagement, WithdrawalManagement, VendorLedger, CardTransactionInquiry, BankTransactionInquiry, ExpectedExpenseManagement -- Wave B (5개): PaymentHistoryManagement, StockStatus, ReceivingManagement, ShipmentManagement, quotes -- 제외 5개: AccountManagement(`meta` 필드명), orders(`data.items` 중첩), VacationManagement, EmployeeManagement, construction/order-management (별도 구조) -- 순 감소: ~220줄 (14파일 × ~20줄 제거, ~28줄 추가) -- 제거된 보일러플레이트: `DEFAULT_PAGINATION`, `FrontendPagination`/`PaginationMeta` 로컬 인터페이스, `PaginatedApiResponse` import, 수동 transform+pagination 조립 -- **화면 검수 완료** (4개 페이지): Bills, StockStatus, Quotes, Shipments — 전체 PASS -- **버그 발견/수정**: `quotes/actions.ts`에서 `export type { PaginationMeta }` re-export가 Turbopack 런타임 에러 유발 (`tsc`로 미감지) → re-export 제거, 컴포넌트에서 `@/lib/api/types` 직접 import로 변경 - -### `'use server'` 파일 타입 export 제한 (2026-02-12) - -**발견 배경**: `executePaginatedAction` 마이그레이션 화면 검수 중 견적관리 페이지 빌드 에러 - -**제한 사항**: -- `'use server'` 파일에서는 **async 함수만 export 가능** (Next.js Turbopack 제한) -- `export type { X } from '...'` (re-export) → **런타임 에러 발생** -- `export interface X { ... }` / `export type X = ...` (인라인 정의) → **문제 없음** (컴파일 시 제거) -- `tsc --noEmit`으로는 감지 불가 — Next.js 전용 규칙이므로 실제 페이지 접속(Turbopack)에서만 발생 - -**현재 상태**: 전체 81개 `'use server'` 파일 점검 완료, re-export 패턴 0건 (수정된 1건 포함) - -**buildApiUrl 마이그레이션 전략**: -- Wave A: 1건짜리 단순 파일 20개 -- Wave B: 2건짜리 파일 12개 (quotes, WorkOrders, orders 등 대형 파일 포함) -- Wave C: 3건 이상 파일 12개 (VendorLedger 5건, ReceivingManagement 5건, ProcessManagement 19건 URL 등) - -**효과**: -- 페이지네이션 조회 코드: ~20줄 → ~5줄 -- `DEFAULT_PAGINATION` 중앙화 (`execute-paginated-action.ts` 내부) -- `toPaginationMeta` 자동 활용 (직접 import 불필요) -- URL 빌딩 패턴 완전 일관화 (undefined/null/'' 자동 필터링, boolean/number 자동 변환) - -### KST 안전 날짜 유틸리티 — `toISOString` 사용 금지 (2026-02-19) - -**현황**: `new Date().toISOString().split('T')[0]` — 15개 파일 26곳에서 사용 중이었음 - -**문제**: `toISOString()`은 **UTC 기준**으로 변환. 한국(KST, UTC+9)에서 오전 9시 이전에 실행하면 **전날 날짜** 반환 -``` -// 2026-02-19 08:30 KST → UTC는 2026-02-18 23:30 -new Date().toISOString().split('T')[0] // "2026-02-18" ← 잘못됨 -``` - -**결정**: KST 안전 유틸리티 함수로 전량 교체, 직접 `toISOString` 사용 금지 - -**유틸리티** (`src/lib/utils/date.ts`): -| 함수 | 용도 | 대체 대상 | -|------|------|-----------| -| `getTodayString()` | 오늘 날짜 문자열 | `new Date().toISOString().split('T')[0]` | -| `getLocalDateString(date)` | 임의 Date 객체 문자열 | `someDate.toISOString().split('T')[0]` | - -**사용 규칙**: -```typescript -// 올바른 패턴 -import { getTodayString, getLocalDateString } from '@/lib/utils/date'; -const today = getTodayString(); // "2026-02-19" -const thirtyDaysAgo = getLocalDateString(pastDate); // "2026-01-20" - -// 금지 패턴 -const today = new Date().toISOString().split('T')[0]; -``` - -**현재 상태**: `src/` 내 `toISOString().split` 사용 0건 (date.ts 내 구현부 제외) - -### 달력/스케줄 공통 리소스 — 작업 전 필수 확인 (2026-02-23) - -달력·일정·날짜 관련 작업 시 아래 공통 리소스를 **반드시 확인**하고 사용할 것. - -**날짜 유틸리티** (`src/lib/utils/date.ts`): -| 함수 | 용도 | -|------|------| -| `getLocalDateString(date)` | Date → `'YYYY-MM-DD'` (KST 안전) | -| `getTodayString()` | 오늘 날짜 문자열 | -| `formatDate(dateStr)` | 표시용 날짜 포맷 (null → `'-'`) | -| `formatDateForInput(dateStr)` | input용 `YYYY-MM-DD` 변환 | -| `formatDateRange(start, end)` | `'시작 ~ 종료'` 포맷 | -| `getDateAfterDays(n)` | N일 후 날짜 | - -**달력 일정 스토어** (`src/stores/useCalendarScheduleStore.ts`): -- 달력관리(CalendarManagement)에서 등록한 공휴일/세무일정/회사일정을 프로젝트 전체에 공유 -- `fetchSchedules(year)` — 연도별 캐시 조회 (API 호출) -- `setSchedulesForYear(year, data)` — 이미 가져온 데이터 직접 설정 -- `invalidateYear(year)` — 캐시 무효화 (등록/수정/삭제 후) -- **현재 상태**: 백엔드 API 미구현 → 호출부 주석 처리 (TODO 검색) - -**달력 이벤트 유틸** (`src/constants/calendarEvents.ts`): -- `isHoliday(date)`, `isTaxDeadline(date)`, `getHolidayName(date)` 등 -- 스토어 우선 → 하드코딩 폴백(2026년) 패턴 -- 새 연도 폴백 데이터 필요 시 이 파일에 `HOLIDAYS_YYYY`, `TAX_DEADLINES_YYYY` 추가 - -**ScheduleCalendar 공통 컴포넌트** (`src/components/common/ScheduleCalendar/`): -- `hideNavigation` prop으로 헤더 숨김 가능 (연간 달력 등 상위 네비게이션 사용 시) -- `availableViews={[]}` 으로 뷰 전환 버튼 숨김 - -**규칙**: -- `Date → string` 변환 시 `getLocalDateString()` 필수 (`toISOString().split('T')[0]` 금지) -- 공휴일/세무일 판별 시 `calendarEvents.ts` 유틸 함수 사용 -- 달력 데이터 공유 시 zustand 스토어 경유 (컴포넌트 간 직접 전달 금지) - -### `useDateRange` 훅 — 날짜 필터 보일러플레이트 제거 (2026-02-19) - -**현황**: 20+ 리스트 페이지에서 `useState('2025-01-01')` / `useState('2025-12-31')` 하드코딩 - -**문제**: 연도가 바뀌면 수동으로 모든 파일 수정 필요 (2025→2026 전환 시 데이터 미표시 버그 발생) - -**결정**: `useDateRange` 훅으로 동적 날짜 범위 자동 계산 - -**훅** (`src/hooks/useDateRange.ts`): -```typescript -import { useDateRange } from '@/hooks'; - -// 프리셋 -const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear'); // 2026-01-01 ~ 2026-12-31 -const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth'); // 2026-02-01 ~ 2026-02-28 -const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today'); // 2026-02-19 ~ 2026-02-19 -``` - -**적용 규칙**: -- 리스트 페이지 날짜 필터 → `useDateRange` 필수 사용 -- 연간 조회 → `'currentYear'`, 월간 조회 → `'currentMonth'` -- `useState('YYYY-MM-DD')` 하드코딩 금지 - -**현재 상태**: `useState('2025` 패턴 0건 (전량 `useDateRange`로 전환 완료) - -### Zod 스키마 검증 — 신규 폼 적용 규칙 (2026-02-11) - -**결정**: 기존 폼은 건드리지 않음. **신규 폼에만 Zod + zodResolver 적용** - -**설치 상태**: `zod@^4.1.12`, `@hookform/resolvers@^5.2.2` — 이미 설치됨 - -**효과**: -1. 스키마 하나로 **타입 추론 + 런타임 검증** 동시 해결 (`z.infer`) -2. 별도 `interface` 중복 정의 불필요 -3. 신규 코드에서 `as` 캐스트 자연 감소 (D-2 개선 효과) - -**규칙**: -- 신규 폼 → `zodResolver(schema)` 사용 필수 (CLAUDE.md에 패턴 명시) -- 기존 `rules={{ required: true }}` 패턴 폼 → 마이그레이션 불필요 -- 단순 1~2 필드 인라인 폼 → Zod 불필요 (오버엔지니어링) - -**미적용 사유**: 기존 폼 수십 개를 전면 전환하는 비용 >> 이득. 신규 코드에서 점진적 확산 diff --git a/claudedocs/architecture/[REF] template-migration-status.md b/claudedocs/architecture/[REF] template-migration-status.md deleted file mode 100644 index d688cf23..00000000 --- a/claudedocs/architecture/[REF] template-migration-status.md +++ /dev/null @@ -1,260 +0,0 @@ -# 템플릿 마이그레이션 현황 - -> 작성일: 2025-01-20 -> 목적: IntegratedListTemplate / IntegratedDetailTemplate 적용 현황 파악 - ---- - -## 📊 전체 통계 - -| 구분 | 수량 | -|------|------| -| 전체 Protected 페이지 | 203개 | -| IntegratedListTemplate 사용 | 48개 | -| IntegratedDetailTemplate 사용 | 57개 | - ---- - -## ✅ 마이그레이션 완료 - -### 리스트 페이지 (IntegratedListTemplateV2) -대부분의 리스트 페이지가 IntegratedListTemplateV2로 마이그레이션 완료. -- 필터, 테이블, 페이지네이션, 헤더 버튼 공통화 적용 - -### 상세/수정/등록 페이지 (IntegratedDetailTemplate) - -#### App 페이지 (17개) -``` -src/app/[locale]/(protected)/settings/popup-management/new/page.tsx -src/app/[locale]/(protected)/settings/popup-management/[id]/page.tsx -src/app/[locale]/(protected)/settings/accounts/new/page.tsx -src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx -src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx -src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx -src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx -src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx -src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx -src/app/[locale]/(protected)/board/board-management/new/page.tsx -src/app/[locale]/(protected)/board/board-management/[id]/page.tsx -src/app/[locale]/(protected)/master-data/process-management/new/page.tsx -src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx -src/app/[locale]/(protected)/hr/card-management/new/page.tsx -src/app/[locale]/(protected)/hr/card-management/[id]/edit/page.tsx -src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx -src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx -``` - -#### 컴포넌트 (주요 40개) -``` -# 회계 -src/components/accounting/BillManagement/BillDetail.tsx -src/components/accounting/SalesManagement/SalesDetail.tsx -src/components/accounting/PurchaseManagement/PurchaseDetail.tsx -src/components/accounting/VendorLedger/VendorLedgerDetail.tsx -src/components/accounting/VendorManagement/VendorDetail.tsx -src/components/accounting/BadDebtCollection/BadDebtDetail.tsx -src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx -src/components/accounting/DepositManagement/DepositDetailClientV2.tsx - -# 영업/고객 -src/components/clients/ClientDetailClientV2.tsx -src/components/quotes/QuoteRegistrationV2.tsx -src/components/orders/OrderSalesDetailView.tsx -src/components/orders/OrderSalesDetailEdit.tsx - -# 설정 -src/components/settings/PopupManagement/PopupDetailClientV2.tsx -src/components/settings/PermissionManagement/PermissionDetail.tsx - -# 건설/프로젝트 -src/components/business/construction/contract/ContractDetailForm.tsx -src/components/business/construction/site-briefings/SiteBriefingForm.tsx -src/components/business/construction/order-management/OrderDetailForm.tsx -src/components/business/construction/handover-report/HandoverReportDetailForm.tsx -src/components/business/construction/item-management/ItemDetailClient.tsx -src/components/business/construction/estimates/EstimateDetailForm.tsx -src/components/business/construction/management/ConstructionDetailClient.tsx -src/components/business/construction/site-management/SiteDetailForm.tsx -src/components/business/construction/partners/PartnerForm.tsx -src/components/business/construction/structure-review/StructureReviewDetailForm.tsx -src/components/business/construction/issue-management/IssueDetailForm.tsx -src/components/business/construction/bidding/BiddingDetailForm.tsx -src/components/business/construction/pricing-management/PricingDetailClientV2.tsx -src/components/business/construction/labor-management/LaborDetailClientV2.tsx -src/components/business/construction/progress-billing/ProgressBillingDetailForm.tsx - -# 고객센터 -src/components/customer-center/NoticeManagement/NoticeDetail.tsx -src/components/customer-center/InquiryManagement/InquiryDetail.tsx -src/components/customer-center/EventManagement/EventDetail.tsx - -# HR -src/components/hr/EmployeeManagement/EmployeeDetail.tsx - -# 생산/물류 -src/components/production/WorkOrders/WorkOrderDetail.tsx -src/components/outbound/ShipmentManagement/ShipmentDetail.tsx -src/components/material/ReceivingManagement/ReceivingDetail.tsx -src/components/material/StockStatus/StockStatusDetail.tsx - -# 품질 -src/components/quality/InspectionManagement/InspectionDetail.tsx -``` - ---- - -## ❌ 마이그레이션 미완료 - -### App 페이지 (PageLayout 직접 사용) - -| 경로 | 유형 | 비고 | -|------|------|------| -| `sales/order-management-sales/production-orders/[id]/page.tsx` | 상세 | 생산지시 상세 | -| `sales/order-management-sales/[id]/production-order/page.tsx` | 상세 | 생산지시 | -| `boards/[boardCode]/create/page.tsx` | 등록 | 게시판 글쓰기 | -| `boards/[boardCode]/[postId]/edit/page.tsx` | 수정 | 게시판 글수정 | -| `boards/[boardCode]/[postId]/page.tsx` | 상세 | 게시판 글상세 | -| `test/popup/page.tsx` | 테스트 | 테스트 페이지 | -| `dev/editable-table/page.tsx` | 개발 | 개발용 페이지 | - -### 컴포넌트 (PageLayout 직접 사용) - -#### 회계 -``` -src/components/accounting/DailyReport/index.tsx # 일일보고서 (특수 레이아웃) -src/components/accounting/ReceivablesStatus/index.tsx # 미수금현황 (특수 레이아웃) -src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx # V2로 대체됨 -src/components/accounting/DepositManagement/DepositDetail.tsx # V2로 대체됨 -``` - -#### 설정 -``` -src/components/settings/CompanyInfoManagement/index.tsx # 회사정보 (설정 페이지) -src/components/settings/RankManagement/index.tsx # 직급관리 (설정 페이지) -src/components/settings/LeavePolicyManagement/index.tsx # 휴가정책 (설정 페이지) -src/components/settings/AccountInfoManagement/index.tsx # 계정정보 (설정 페이지) -src/components/settings/NotificationSettings/index.tsx # 알림설정 (설정 페이지) -src/components/settings/TitleManagement/index.tsx # 직책관리 (설정 페이지) -src/components/settings/WorkScheduleManagement/index.tsx # 근무일정 (설정 페이지) -src/components/settings/AttendanceSettingsManagement/index.tsx # 근태설정 (설정 페이지) -src/components/settings/PopupManagement/PopupForm.tsx # V2로 대체됨 -src/components/settings/PopupManagement/PopupDetail.tsx # V2로 대체됨 -src/components/settings/AccountManagement/AccountDetail.tsx # V2로 대체됨 -src/components/settings/PermissionManagement/PermissionDetailClient.tsx # 레거시 -src/components/settings/SubscriptionManagement/SubscriptionManagement.tsx # 구독관리 -src/components/settings/SubscriptionManagement/SubscriptionClient.tsx -``` - -#### 건설/프로젝트 -``` -src/components/business/construction/management/ProjectListClient.tsx # 리스트 (별도) -src/components/business/construction/management/ProjectDetailClient.tsx # 레거시 -src/components/business/construction/category-management/index.tsx # 카테고리 (특수) -src/components/business/construction/pricing-management/PricingDetailClient.tsx # V2로 대체됨 -src/components/business/construction/labor-management/LaborDetailClient.tsx # V2로 대체됨 -``` - -#### 게시판 -``` -src/components/board/BoardManagement/BoardForm.tsx # V2로 대체됨 -src/components/board/BoardManagement/BoardDetail.tsx # V2로 대체됨 -src/components/board/BoardDetail/index.tsx # 동적 게시판 상세 -src/components/board/BoardForm/index.tsx # 동적 게시판 폼 -``` - -#### HR -``` -src/components/hr/DepartmentManagement/index.tsx # 부서관리 (트리 구조) -src/components/hr/EmployeeManagement/EmployeeForm.tsx # 직원등록 폼 -src/components/hr/EmployeeManagement/CSVUploadPage.tsx # CSV 업로드 (특수) -``` - -#### 생산 -``` -src/components/production/ProductionDashboard/index.tsx # 대시보드 (제외) -src/components/production/WorkerScreen/index.tsx # 작업자화면 (특수 UI) -src/components/production/WorkOrders/WorkOrderCreate.tsx # 작업지시 등록 -src/components/production/WorkOrders/WorkOrderEdit.tsx # 작업지시 수정 -``` - -#### 고객센터 -``` -src/components/customer-center/InquiryManagement/InquiryForm.tsx # 문의등록 -src/components/customer-center/FAQManagement/FAQList.tsx # FAQ 리스트 -``` - -#### 기타 -``` -src/components/clients/ClientDetail.tsx # V2로 대체됨 -src/components/process-management/ProcessForm.tsx # 공정등록 -src/components/process-management/ProcessDetail.tsx # V2로 대체됨 -src/components/outbound/ShipmentManagement/ShipmentEdit.tsx # 출고수정 -src/components/outbound/ShipmentManagement/ShipmentCreate.tsx # 출고등록 -src/components/items/ItemMasterDataManagement.tsx # 품목마스터 (특수) -src/components/material/ReceivingManagement/InspectionCreate.tsx # 검수등록 -src/components/quality/InspectionManagement/InspectionCreate.tsx # 품질검사등록 -``` - ---- - -## 🚫 마이그레이션 제외 대상 - -### 대시보드/특수 페이지 -``` -src/app/[locale]/(protected)/dashboard/page.tsx # CEO 대시보드 -src/app/[locale]/(protected)/production/dashboard/page.tsx # 생산 대시보드 -src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx # 종합분석 -src/components/business/CEODashboard/CEODashboard.tsx # CEO 대시보드 -``` - -### 레거시 파일 (_legacy 폴더) -``` -src/components/settings/AccountManagement/_legacy/AccountDetail.tsx -src/components/hr/CardManagement/_legacy/CardDetail.tsx -src/components/hr/CardManagement/_legacy/CardForm.tsx -``` - -### 테스트/개발용 -``` -src/app/[locale]/(protected)/test/popup/page.tsx -src/app/[locale]/(protected)/dev/editable-table/page.tsx -``` - ---- - -## 📋 마이그레이션 우선순위 권장 - -### 높음 (실사용 페이지) -1. `boards/[boardCode]/*` - 동적 게시판 페이지들 -2. `production/WorkOrders/WorkOrderCreate.tsx` - 작업지시 등록 -3. `production/WorkOrders/WorkOrderEdit.tsx` - 작업지시 수정 -4. `outbound/ShipmentManagement/ShipmentCreate.tsx` - 출고 등록 -5. `outbound/ShipmentManagement/ShipmentEdit.tsx` - 출고 수정 - -### 중간 (설정 페이지 - 템플릿 적용 검토 필요) -- `settings/` 하위 관리 페이지들 (트리/특수 레이아웃 많음) - -### 낮음 (V2 대체 완료) -- V2 파일이 있는 레거시 컴포넌트들 (삭제 검토) - -### 제외 -- 대시보드, 특수 UI, 테스트/개발 페이지 - ---- - -## 🔧 템플릿 수정 시 일괄 적용 범위 - -템플릿 파일 수정 시 아래 파일들에 자동 적용: - -| 템플릿 | 영향 파일 수 | -|--------|-------------| -| `IntegratedListTemplateV2` | 48개 | -| `IntegratedDetailTemplate` | 57개 | -| **합계** | **105개** | - -수정 가능 요소: -- 타이틀 위치/스타일 -- 버튼 배치/디자인 -- 입력필드 공통 스타일 -- 레이아웃 구조 -- 반응형 처리 diff --git a/claudedocs/architecture/[RESEARCH-2026-02-11] ERP-admin-panel-architecture-patterns.md b/claudedocs/architecture/[RESEARCH-2026-02-11] ERP-admin-panel-architecture-patterns.md deleted file mode 100644 index 1ac3a59e..00000000 --- a/claudedocs/architecture/[RESEARCH-2026-02-11] ERP-admin-panel-architecture-patterns.md +++ /dev/null @@ -1,606 +0,0 @@ -# Research: Next.js / React ERP & Admin Panel Architecture Patterns (2025-2026) - -**Date**: 2026-02-11 -**Purpose**: Compare SAM ERP's current architecture against proven open-source patterns -**Confidence**: High (0.85) - Based on 6 major open-source projects and established methodologies - ---- - -## Executive Summary - -After investigating 6 major open-source admin/ERP frameworks and 3 architectural methodologies, the dominant pattern emerging in 2025-2026 is a **hybrid approach**: domain/feature-based folder organization combined with headless CRUD hooks and a provider-based API abstraction layer. Pure Atomic Design is losing ground to Feature-Sliced Design (FSD) for application-level organization, though Atomic Design remains useful for the shared UI component layer. - -### Key Findings - -1. **Resource-based CRUD abstraction** (react-admin, Refine) is the most proven pattern for 50+ page admin apps -2. **Feature/domain-based folder structure** is winning over layer-based (atoms/molecules/organisms) for application code -3. **Provider pattern** (dataProvider, authProvider) decouples UI from API more effectively than scattered Server Actions -4. **Config-driven UI generation** (Payload CMS) reduces code duplication for similar pages -5. **Headless hooks** (useListController, useTable, useForm) separate business logic from UI completely - ---- - -## 1. Project-by-Project Architecture Analysis - -### 1.1 React-Admin (marmelab) -- 25K+ GitHub Stars - -**Architecture**: Resource-based SPA with Provider pattern - -**Key Concepts**: -- **Resources**: The core abstraction. Each entity (posts, users, orders) is a "resource" with CRUD views -- **Providers**: Adapter layer between UI and backend - - `dataProvider` - abstracts all API calls (getList, getOne, create, update, delete) - - `authProvider` - handles authentication flow - - `i18nProvider` - internationalization -- **Headless Core**: `ra-core` package contains all hooks, zero UI dependency -- **Controller Hooks**: `useListController`, `useEditController`, `useCreateController`, `useShowController` - -**Folder Pattern**: -``` -src/ - resources/ - posts/ - PostList.tsx # view - PostEdit.tsx # view - PostCreate.tsx # view - PostShow.tsx # view - users/ - UserList.tsx - UserEdit.tsx - providers/ - dataProvider.ts # API abstraction - authProvider.ts # Auth abstraction - App.tsx # Resource registration -``` - -**CRUD Registration Pattern**: -```tsx - - - - -``` - -**SAM Comparison**: -| Aspect | react-admin | SAM ERP | -|--------|-------------|---------| -| API Layer | Centralized dataProvider | 89 scattered actions.ts files | -| CRUD Views | Resource-based registration | Manual page creation per domain | -| State | React Query (built-in) | Zustand + manual fetching | -| Form | react-hook-form (built-in) | Mixed (migrating to RHF+Zod) | - -**Sources**: -- [Architecture Docs](https://marmelab.com/react-admin/Architecture.html) -- [Resource Component](https://marmelab.com/react-admin/Resource.html) -- [CRUD Pages](https://marmelab.com/react-admin/CRUD.html) -- [GitHub](https://github.com/marmelab/react-admin) - ---- - -### 1.2 Refine -- 30K+ GitHub Stars - -**Architecture**: Headless meta-framework with resource-based CRUD - -**Key Concepts**: -- **Headless by design**: Zero UI opinion, works with Ant Design, Material UI, Shadcn, or custom -- **Data Provider Interface**: Standardized CRUD methods (getList, getOne, create, update, deleteOne) -- **Resource Hooks**: `useTable`, `useForm`, `useShow`, `useSelect` -- all headless -- **Inferencer**: Auto-generates CRUD pages from API schema - -**Data Provider Interface**: -```typescript -const dataProvider = { - getList: ({ resource, pagination, sorters, filters }) => Promise, - getOne: ({ resource, id }) => Promise, - create: ({ resource, variables }) => Promise, - update: ({ resource, id, variables }) => Promise, - deleteOne: ({ resource, id }) => Promise, - getMany: ({ resource, ids }) => Promise, - custom: ({ url, method, payload }) => Promise, -}; -``` - -**Headless Hook Pattern**: -```tsx -// useTable returns data + controls, you handle UI -const { tableProps, sorters, filters } = useTable({ resource: "products" }); - -// useForm returns form state + submit, you handle UI -const { formProps, saveButtonProps } = useForm({ resource: "products", action: "create" }); -``` - -**SAM Comparison**: -| Aspect | Refine | SAM ERP | -|--------|--------|---------| -| API Abstraction | Single dataProvider | Per-domain actions.ts | -| List Page | useTable hook | UniversalListPage template | -| Form | useForm hook (headless) | Manual per-page forms | -| Code Generation | Inferencer auto-gen | Manual creation | - -**Sources**: -- [Data Provider Docs](https://refine.dev/docs/data/data-provider/) -- [useTable Hook](https://refine.dev/docs/data/hooks/use-table/) -- [GitHub](https://github.com/refinedev/refine) - ---- - -### 1.3 Payload CMS 3.0 -- 30K+ GitHub Stars - -**Architecture**: Config-driven, Next.js-native with auto-generated admin UI - -**Key Concepts**: -- **Collection Config**: Define schema once, get admin UI + API + types automatically -- **Field System**: Rich field types auto-generate corresponding UI components -- **Hooks**: beforeChange, afterRead, beforeValidate at collection and field level -- **Access Control**: Document-level and field-level permissions in config -- **Next.js Native**: Installs directly into /app folder, uses Server Components - -**Config-Driven Pattern**: -```typescript -// collections/Products.ts -export const Products: CollectionConfig = { - slug: 'products', - admin: { - useAsTitle: 'name', - defaultColumns: ['name', 'price', 'status'], - }, - access: { - read: () => true, - create: isAdmin, - update: isAdminOrSelf, - }, - hooks: { - beforeChange: [calculateTotal], - afterRead: [formatCurrency], - }, - fields: [ - { name: 'name', type: 'text', required: true }, - { name: 'price', type: 'number', min: 0 }, - { name: 'status', type: 'select', options: ['draft', 'published'] }, - { name: 'category', type: 'relationship', relationTo: 'categories' }, - ], -}; -``` - -**SAM Comparison**: -| Aspect | Payload CMS | SAM ERP | -|--------|-------------|---------| -| Page Generation | Auto from config | Manual per page | -| Field Definitions | Centralized schema | Inline JSX per form | -| Access Control | Config-based per field | Manual per component | -| Type Safety | Auto-generated from schema | Manual interface definitions | - -**Sources**: -- [Collection Configs](https://payloadcms.com/docs/configuration/collections) -- [Fields Overview](https://payloadcms.com/docs/fields/overview) -- [Collection Hooks](https://payloadcms.com/docs/hooks/collections) -- [GitHub](https://github.com/payloadcms/payload) - ---- - -### 1.4 Medusa Admin v2 -- 26K+ GitHub Stars - -**Architecture**: Domain-based routes with widget injection system - -**Key Concepts**: -- **Domain Routes**: Routes organized by business domain (products, orders, customers) -- **Widget System**: Inject custom React components into predetermined zones -- **UI Routes**: File-based routing under src/admin/routes/ -- **Hook-based data fetching**: Domain-specific hooks for API integration -- **Monorepo**: UI library (@medusajs/ui) separate from admin logic - -**Folder Structure**: -``` -packages/admin/dashboard/src/ - routes/ - products/ - product-list/ - components/ - hooks/ - page.tsx - product-detail/ - components/ - hooks/ - page.tsx - orders/ - order-list/ - order-detail/ - customers/ - hooks/ # Shared hooks - components/ # Shared components - lib/ # Utilities -``` - -**SAM Comparison**: -| Aspect | Medusa Admin | SAM ERP | -|--------|-------------|---------| -| Route Organization | Domain > Action > Components | Domain > page.tsx + actions.ts | -| Shared Components | Separate UI package | organisms/molecules/atoms | -| Hooks | Per-route + shared | Global + inline | -| Extensibility | Widget injection zones | N/A | - -**Sources**: -- [Admin UI Routes](https://docs.medusajs.com/learn/fundamentals/admin/ui-routes) -- [Admin Development](https://docs.medusajs.com/learn/fundamentals/admin) -- [GitHub](https://github.com/medusajs/medusa) - ---- - -### 1.5 AdminJS - -**Architecture**: Auto-generated admin from resource configuration - -**Key Concepts**: -- **Resource Registration**: Register database models, get admin UI automatically -- **Component Customization**: Override via ComponentLoader -- **Dashboard Customization**: Custom React components for dashboard - -**SAM Relevance**: Lower -- AdminJS is more backend-driven (Node.js ORM-based) and less applicable to a frontend-heavy ERP. - -**Sources**: -- [AdminJS Documentation](https://adminjs.co/) -- [GitHub](https://github.com/SoftwareBrothers/adminjs) - ---- - -### 1.6 Hoppscotch - -**Architecture**: Monorepo with shared-library pattern - -**Key Concepts**: -- **@hoppscotch/common**: 90% of UI and business logic in shared package -- **@hoppscotch/data**: Type safety across all layers -- **Platform-specific code**: Thin wrapper handling native capabilities - -**SAM Relevance**: The shared-library-as-core pattern is interesting for large codebases where most logic is platform-agnostic. - -**Sources**: -- [DeepWiki Analysis](https://deepwiki.com/hoppscotch/hoppscotch) - ---- - -## 2. Architectural Methodologies Comparison - -### 2.1 Feature-Sliced Design (FSD) -- Rising Standard - -**7-Layer Architecture**: -``` -app/ # App initialization, providers, routing -processes/ # Complex cross-page business flows (deprecated in latest) -pages/ # Full page compositions -widgets/ # Self-contained UI blocks with business logic -features/ # User-facing actions (login, add-to-cart) -entities/ # Business entities (user, product, order) -shared/ # Reusable utilities, UI kit, configs -``` - -**Key Rules**: -- Layers can ONLY import from layers below them -- Each layer divided into **slices** (domain groupings) -- Each slice divided into **segments** (ui/, model/, api/, lib/, config/) - -**FSD Applied to ERP**: -``` -src/ - app/ # App shell, providers - pages/ - quality-qms/ # QMS page composition - sales-quote/ # Quote page composition - widgets/ - inspection-report/ # Self-contained inspection UI - ui/ - model/ - api/ - quote-calculator/ - features/ - add-inspection-item/ - approve-quote/ - entities/ - inspection/ - ui/ (InspectionCard, InspectionRow) - model/ (types, store) - api/ (getInspection, updateInspection) - quote/ - ui/ - model/ - api/ - shared/ - ui/ (Button, Table, Modal -- your atoms) - lib/ (formatDate, exportUtils) - api/ (httpClient, apiProxy) - config/ (constants) -``` - -**Sources**: -- [Feature-Sliced Design](https://feature-sliced.design/) -- [Layers Reference](https://feature-sliced.design/docs/reference/layers) -- [Slices and Segments](https://feature-sliced.design/docs/reference/slices-segments) - ---- - -### 2.2 Atomic Design -- Aging for App-Level Organization - -**SAM's Current Approach**: -``` -components/ - atoms/ # Basic UI elements - molecules/ # (unused) - organisms/ # Complex composed components - templates/ # Page layout templates -``` - -**Industry Assessment (2025-2026)**: -- Atomic Design excels for **UI component libraries** (shared/ layer) -- Struggles with **domain complexity** -- "UserCard" and "ProductCard" are both organisms but semantically distinct -- Grouping by visual complexity (atom/molecule/organism) dilutes domain boundaries -- Most large-scale projects have moved to **feature/domain organization** for application code -- Atomic Design remains valuable for the **shared UI kit layer only** - -**Sources**: -- [Atomic Design Meets Feature-Based Architecture](https://medium.com/@buwanekasumanasekara/atomic-design-meets-feature-based-architecture-in-next-js-a-practical-guide-c06ea56cf5cc) -- [From Components to Systems](https://www.codewithseb.com/blog/from-components-to-systems-scalable-frontend-with-atomiec-design) - ---- - -### 2.3 Modular Monolith (Frontend) - -**Key Principles for ERP**: -- Single deployment, but internally organized as independent modules -- Each module = bounded context with clear API boundaries -- Modules communicate through well-defined interfaces, not direct imports -- Common concerns (auth, logging) handled at application level - -**Applied to Next.js ERP**: -``` -src/ - modules/ - quality/ - components/ - hooks/ - actions/ - types/ - index.ts # Public API -- only exports from here - sales/ - components/ - hooks/ - actions/ - types/ - index.ts - accounting/ - ... - shared/ # Cross-module utilities - app/ # Next.js routing (thin layer) -``` - -**Sources**: -- [Modular Monolith Revolution](https://medium.com/@bhargavkoya56/the-modular-monolith-revolution-enterprise-grade-architecture-part-i-theory-b3705ca70a5f) -- [Frontend at Scale](https://frontendatscale.com/issues/45/) - ---- - -## 3. Server Actions Organization Patterns - -### Pattern A: Colocated (SAM's Current -- 89 files) -``` -app/[locale]/(protected)/quality/qms/ - page.tsx - actions.ts # Server actions for this route -``` -**Pros**: Easy to find, clear ownership -**Cons**: Duplication across similar pages, no reuse - -### Pattern B: Domain-Centralized (react-admin / Refine style) -``` -src/ - actions/ - quality/ - inspection.ts # All inspection-related server actions - qms.ts - sales/ - quote.ts - order.ts - lib/ - api-client.ts # Shared fetch logic with auth -``` -**Pros**: Reusable across pages, easier to maintain -**Cons**: Indirection, harder to find for route-specific logic - -### Pattern C: Hybrid (Recommended for large apps) -``` -app/[locale]/(protected)/quality/qms/ - page.tsx - _actions.ts # Route-specific actions only - -src/ - domains/ - quality/ - actions/ # Shared domain actions - inspection.ts - qms.ts - hooks/ - types/ -``` -**Pros**: Route-specific stays colocated, shared logic centralized -**Cons**: Need clear rules on what goes where - -### Industry Consensus -For 100+ page apps, the **hybrid approach** (Pattern C) dominates. Route-specific logic stays colocated; shared domain logic is centralized. The key is having a clear **data provider / API client** layer that all server actions delegate to. - -**Sources**: -- [Next.js Colocation Template](https://next-colocation-template.vercel.app/) -- [Inside the App Router (2025)](https://medium.com/better-dev-nextjs-react/inside-the-app-router-best-practices-for-next-js-file-and-directory-structure-2025-edition-ed6bc14a8da3) - ---- - -## 4. CRUD Abstraction Patterns for 50+ Similar Pages - -### Pattern 1: Resource Hooks (react-admin / Refine approach) -```typescript -// hooks/useResourceList.ts -function useResourceList(resource: string, options?: ListOptions) { - const [data, setData] = useState([]); - const [pagination, setPagination] = useState({ page: 1, pageSize: 20 }); - const [filters, setFilters] = useState({}); - const [sorters, setSorters] = useState({}); - - useEffect(() => { - fetchList(resource, { pagination, filters, sorters }) - .then(result => setData(result.data)); - }, [resource, pagination, filters, sorters]); - - return { data, pagination, setPagination, filters, setFilters, sorters, setSorters }; -} - -// Usage in any list page -function QualityInspectionList() { - const { data, pagination, filters } = useResourceList('quality/inspections'); - return ; -} -``` - -### Pattern 2: Config-Driven Pages (Payload CMS approach) -```typescript -// configs/quality-inspection.config.ts -export const inspectionConfig: ResourceConfig = { - resource: 'quality/inspections', - list: { - columns: [ - { key: 'id', label: '번호' }, - { key: 'name', label: '검사명' }, - { key: 'status', label: '상태', render: StatusBadge }, - ], - filters: [ - { key: 'status', type: 'select', options: statusOptions }, - { key: 'dateRange', type: 'daterange' }, - ], - defaultSort: { key: 'createdAt', direction: 'desc' }, - }, - form: { - fields: [ - { name: 'name', type: 'text', required: true, label: '검사명' }, - { name: 'type', type: 'select', options: typeOptions, label: '검사유형' }, - ], - }, -}; - -// Generic page component -function ResourceListPage({ config }: { config: ResourceConfig }) { - const list = useResourceList(config.resource); - return ; -} -``` - -### Pattern 3: Template Composition (SAM's current direction, improved) -```typescript -// templates/UniversalCRUDPage.tsx -- enhanced version -function UniversalCRUDPage({ - resource, - listConfig, - detailConfig, - formConfig, -}: CRUDPageProps) { - // Handles list/detail/form modes based on URL - // Integrates data fetching, pagination, filtering - // Renders appropriate template based on mode -} -``` - -### Industry Assessment -- **Pattern 1** (Resource Hooks) is the most widely adopted -- used by react-admin (25K stars) and Refine (30K stars) -- **Pattern 2** (Config-Driven) reduces code the most but requires upfront investment in the config system -- **Pattern 3** (Template Composition) is the middle ground -- SAM's `UniversalListPage` is already this direction - -**Recommendation**: Evolve toward a **Provider + Resource Hooks** layer. Keep `UniversalListPage` and `IntegratedDetailTemplate` but back them with a standardized data provider. - ---- - -## 5. Comparison Matrix: SAM ERP vs Industry Patterns - -| Dimension | SAM ERP (Current) | react-admin | Refine | Payload CMS | FSD | Recommendation | -|-----------|-------------------|-------------|--------|-------------|-----|----------------| -| **Folder Structure** | Domain-based (app router) | Resource-based | Resource-based | Collection-based | Layer > Slice > Segment | Hybrid Domain + FSD shared layer | -| **Component Org** | Atomic Design (partial) | Flat per resource | Flat per resource | Config-driven | Layer-based (entities/features) | FSD for app code, Atomic for shared UI | -| **API Layer** | 89 colocated actions.ts | Centralized dataProvider | Centralized dataProvider | Built-in Local API | api/ segment per slice | Centralized API client + domain actions | -| **CRUD Abstraction** | UniversalListPage template | Resource + Controller hooks | useTable/useForm hooks | Auto-generated from config | Manual per feature | Add resource hooks on top of templates | -| **Form Handling** | Mixed (migrating to RHF+Zod) | react-hook-form (built-in) | react-hook-form (headless) | Auto from field config | Manual per feature | Complete RHF+Zod migration | -| **State Management** | Zustand stores | React Query (built-in) | React Query (built-in) | Server-side | Per-slice model/ | Keep Zustand for UI state, add React Query for server state | -| **Type Safety** | Manual interfaces | Built-in types | TypeScript throughout | Auto-generated from schema | Manual per segment | Consider schema-driven type generation | -| **50+ Page Scale** | Manual duplication | Resource registration | Inferencer + hooks | Collection config | Slice per entity | Resource hooks + config-driven columns | - ---- - -## 6. Actionable Recommendations for SAM ERP - -### Priority 1: Introduce a Data Provider / API Client Layer -**Why**: The biggest gap vs. industry standard. 89 scattered actions.ts files means duplicated fetch logic, inconsistent error handling, and no centralized caching. - -**Action**: Create a `dataProvider` abstraction inspired by react-admin/Refine: -```typescript -// src/lib/data-provider.ts -export const dataProvider = { - getList: (resource, params) => proxyFetch(`/api/proxy/${resource}`, params), - getOne: (resource, id) => proxyFetch(`/api/proxy/${resource}/${id}`), - create: (resource, data) => proxyFetch(`/api/proxy/${resource}`, { method: 'POST', body: data }), - update: (resource, id, data) => proxyFetch(`/api/proxy/${resource}/${id}`, { method: 'PUT', body: data }), - delete: (resource, id) => proxyFetch(`/api/proxy/${resource}/${id}`, { method: 'DELETE' }), -}; -``` - -### Priority 2: Create Resource Hooks -**Why**: Reduce per-page boilerplate for list/detail/form patterns. - -**Action**: Build `useResourceList`, `useResourceDetail`, `useResourceForm` hooks that wrap the data provider. - -### Priority 3: Evolve Folder Structure Toward Hybrid FSD -**Why**: Atomic Design for app-level code leads to unclear domain boundaries. - -**Action**: -- Keep `shared/ui/` (atoms/organisms) for reusable UI components -- Add `domains/` or `entities/` for business-logic grouping -- Keep `app/` routes thin -- delegate to domain components - -### Priority 4: Complete Form Standardization -**Why**: Mixed form patterns make maintenance harder and prevent reusable form configs. - -**Action**: Complete the react-hook-form + Zod migration. Consider field-config-driven forms (Payload pattern) for highly repetitive forms. - -### Priority 5: Consider Server State Management (React Query / TanStack Query) -**Why**: react-admin and Refine both use React Query internally for caching, optimistic updates, and background refetching. Zustand is better suited for client UI state. - -**Action**: Evaluate adding TanStack Query for server state alongside Zustand for UI state. - ---- - -## 7. What SAM ERP Is Already Doing Well - -1. **Domain-based routing** (`app/[locale]/(protected)/quality/...`) aligns with industry best practice -2. **UniversalListPage + IntegratedDetailTemplate** is the right abstraction direction (similar to react-admin's List/Edit components) -3. **SearchableSelectionModal** as a reusable pattern is good (similar to react-admin's ReferenceInput) -4. **Server Actions in colocated files** follows Next.js official recommendation for route-specific logic -5. **Zustand for global state** is a solid choice for UI state (sidebar state, theme, etc.) - ---- - -## Sources - -### Open-Source Projects -- [react-admin - Architecture](https://marmelab.com/react-admin/Architecture.html) -- [react-admin - GitHub](https://github.com/marmelab/react-admin) -- [Refine - Data Provider](https://refine.dev/docs/data/data-provider/) -- [Refine - GitHub](https://github.com/refinedev/refine) -- [Payload CMS - Collections](https://payloadcms.com/docs/configuration/collections) -- [Payload CMS - GitHub](https://github.com/payloadcms/payload) -- [Medusa - Admin Development](https://docs.medusajs.com/learn/fundamentals/admin) -- [Medusa - GitHub](https://github.com/medusajs/medusa) - -### Architectural Methodologies -- [Feature-Sliced Design](https://feature-sliced.design/) -- [FSD - Layers Reference](https://feature-sliced.design/docs/reference/layers) -- [Atomic Design + FSD Hybrid](https://medium.com/@buwanekasumanasekara/atomic-design-meets-feature-based-architecture-in-next-js-a-practical-guide-c06ea56cf5cc) -- [Clean Architecture vs FSD in Next.js](https://medium.com/@metastability/clean-architecture-vs-feature-sliced-design-in-next-js-applications-04df25e62690) - -### Folder Structure & Patterns -- [Next.js App Router Best Practices (2025)](https://medium.com/better-dev-nextjs-react/inside-the-app-router-best-practices-for-next-js-file-and-directory-structure-2025-edition-ed6bc14a8da3) -- [Scalable Next.js Folder Structure](https://techtales.vercel.app/read/thedon/building-a-scalable-folder-structure-for-large-next-js-projects) -- [SaaS Architecture Patterns with Next.js](https://vladimirsiedykh.com/blog/saas-architecture-patterns-nextjs) -- [Modular Monolith for Frontend](https://frontendatscale.com/issues/45/) diff --git a/claudedocs/architecture/[TEST-2025-11-19] multi-tenancy-test-guide.md b/claudedocs/architecture/[TEST-2025-11-19] multi-tenancy-test-guide.md deleted file mode 100644 index ccf6598a..00000000 --- a/claudedocs/architecture/[TEST-2025-11-19] multi-tenancy-test-guide.md +++ /dev/null @@ -1,495 +0,0 @@ -# 멀티 테넌시 검증 및 테스트 가이드 - -**작성일**: 2025-11-19 -**목적**: Phase 1-4 구현 후 테넌트 격리 기능 검증 - ---- - -## 📋 목차 - -1. [테스트 환경 준비](#테스트-환경-준비) -2. [테스트 시나리오](#테스트-시나리오) -3. [체크리스트](#체크리스트) -4. [문제 해결](#문제-해결) - ---- - -## 테스트 환경 준비 - -### 1. 개발 서버 실행 - -```bash -npm run dev -``` - -### 2. 브라우저 개발자 도구 열기 - -- Chrome: `F12` 또는 `Cmd+Option+I` (Mac) -- Console 탭과 Application 탭을 주로 사용 - -### 3. 테스트 사용자 확인 - -현재 등록된 테스트 사용자 (모두 tenant.id: 282): - -| userId | name | tenant.id | 역할 | -|--------|------|-----------|------| -| TestUser1 | 이재욱 | 282 | 일반 사용자 | -| TestUser2 | 박관리 | 282 | 생산관리자 | -| TestUser3 | 드미트리 | 282 | 시스템 관리자 | - -**⚠️ 테넌트 전환 테스트를 위해 다른 tenant.id를 가진 사용자가 필요합니다.** - ---- - -## 테스트 시나리오 - -### 시나리오 1: 기본 캐시 동작 확인 ✅ - -**목적**: TenantAwareCache가 제대로 동작하는지 확인 - -**단계**: -1. 로그인: TestUser3 (tenant.id: 282) -2. `/master-data/item-master-data-management` 페이지 이동 -3. 데이터 입력: - - 규격 마스터 1개 추가 - - 품목 분류 1개 추가 -4. **개발자 도구 → Application → Session Storage** 확인 - -**기대 결과**: -``` -✅ sessionStorage에 다음 키가 생성되어야 함: -- mes-282-itemMasters -- mes-282-specificationMasters -- mes-282-itemCategories -- (기타 입력한 데이터) - -✅ 각 키의 값에 tenantId: 282 포함 -✅ timestamp 포함 -``` - -**확인 방법**: -```javascript -// Console에서 실행 -Object.keys(sessionStorage).filter(k => k.startsWith('mes-')) -// 결과: ["mes-282-itemMasters", "mes-282-specificationMasters", ...] -``` - ---- - -### 시나리오 2: 페이지 새로고침 시 캐시 로드 ✅ - -**목적**: 캐시에서 데이터를 제대로 불러오는지 확인 - -**단계**: -1. 시나리오 1 완료 후 -2. `F5` 또는 `Cmd+R`로 새로고침 -3. Console에서 로그 확인 - -**기대 결과**: -``` -✅ Console 로그: -[Cache] Loaded from cache: itemMasters -[Cache] Loaded from cache: specificationMasters -... - -✅ 입력했던 데이터가 그대로 표시됨 -✅ 서버 API 호출 없이 캐시에서 로드 -``` - ---- - -### 시나리오 3: TTL (1시간) 만료 확인 ⏱️ - -**목적**: 캐시가 1시간 후 자동 삭제되는지 확인 - -**⚠️ 주의**: 실제 1시간을 기다릴 수 없으므로 **수동 테스트** - -**단계**: -1. sessionStorage에서 캐시 데이터 조회: - ```javascript - const cached = sessionStorage.getItem('mes-282-itemMasters'); - const parsed = JSON.parse(cached); - console.log('Timestamp:', new Date(parsed.timestamp)); - console.log('Age (minutes):', (Date.now() - parsed.timestamp) / 60000); - ``` - -2. **수동으로 timestamp 수정** (과거 시간으로): - ```javascript - const cached = sessionStorage.getItem('mes-282-itemMasters'); - const parsed = JSON.parse(cached); - - // 2시간 전으로 설정 (TTL 1시간 초과) - parsed.timestamp = Date.now() - (7200 * 1000); - - sessionStorage.setItem('mes-282-itemMasters', JSON.stringify(parsed)); - ``` - -3. 페이지 새로고침 - -**기대 결과**: -``` -✅ Console 로그: -[Cache] Expired cache for key: itemMasters - -✅ 만료된 캐시 자동 삭제 -✅ 초기 데이터로 리셋 -``` - ---- - -### 시나리오 4: 다중 탭 격리 확인 🔗 - -**목적**: 탭마다 독립적인 sessionStorage 사용 확인 - -**단계**: -1. **탭 1**: TestUser3 로그인 → 데이터 입력 (규격 마스터 A) -2. **탭 2**: 동일 URL을 새 탭으로 열기 (`Cmd+T` → URL 복사) -3. 탭 2에서 sessionStorage 확인 - -**기대 결과**: -``` -✅ 탭 2의 sessionStorage는 비어있음 -✅ 탭 1의 데이터가 탭 2에 공유되지 않음 -✅ 각 탭이 독립적으로 동작 - -sessionStorage는 탭마다 격리됨! -``` - -**확인 방법**: -```javascript -// 탭 1 -sessionStorage.setItem('test', 'tab1'); - -// 탭 2 (새로 열린 탭) -sessionStorage.getItem('test'); // null (공유 안 됨) -``` - ---- - -### 시나리오 5: 탭 닫기 시 자동 삭제 🗑️ - -**목적**: 탭을 닫으면 sessionStorage가 자동으로 삭제되는지 확인 - -**단계**: -1. 탭에서 데이터 입력 -2. Application → Session Storage에서 데이터 확인 -3. **탭 닫기** -4. **동일 URL을 새 탭으로 다시 열기** -5. Session Storage 확인 - -**기대 결과**: -``` -✅ sessionStorage가 완전히 비어있음 -✅ 이전 탭의 데이터가 남아있지 않음 -✅ 새로운 세션으로 시작 -``` - ---- - -### 시나리오 6: 로그아웃 시 캐시 삭제 🚪 - -**목적**: 로그아웃하면 테넌트 캐시가 완전히 삭제되는지 확인 - -**단계**: -1. TestUser3 로그인 → 데이터 입력 -2. sessionStorage 확인 (캐시 있음) -3. **로그아웃 버튼 클릭** -4. Console 로그 확인 -5. sessionStorage 다시 확인 - -**기대 결과**: -``` -✅ Console 로그: -[Cache] Cleared sessionStorage: mes-282-itemMasters -[Cache] Cleared sessionStorage: mes-282-specificationMasters -... -[Auth] Logged out and cleared tenant cache - -✅ sessionStorage에서 mes-282-* 키가 모두 삭제됨 -✅ localStorage에서 mes-currentUser도 삭제됨 -``` - -**확인 방법**: -```javascript -// 로그아웃 후 -Object.keys(sessionStorage).filter(k => k.startsWith('mes-282-')) -// 결과: [] (빈 배열) -``` - ---- - -### 시나리오 7: 테넌트 전환 시 캐시 삭제 🔄 - -**⚠️ 현재 제약**: 모든 테스트 사용자가 tenant.id: 282를 사용 중 - -**필요 작업**: 다른 tenant.id를 가진 사용자 추가 - -#### 7-1. 테스트 사용자 추가 (tenant.id: 283) - -`src/contexts/AuthContext.tsx` 수정: - -```typescript -const initialUsers: User[] = [ - // ... 기존 사용자 ... - { - userId: "TestUser4", - name: "김테넌트", - position: "다른 회사 관리자", - roles: [ - { - id: 1, - name: "admin", - description: "관리자" - } - ], - tenant: { - id: 283, // ✅ 다른 테넌트! - company_name: "(주)다른회사", - business_num: "987-65-43210", - tenant_st_code: "active", - other_tenants: [] - }, - menu: [ - { - id: "13664", - label: "시스템 대시보드", - iconName: "layout-dashboard", - path: "/dashboard" - } - ] - } -]; -``` - -#### 7-2. 테넌트 전환 테스트 - -**단계**: -1. **TestUser3 로그인** (tenant.id: 282) - - 데이터 입력 (규격 마스터 A, B) - - sessionStorage 확인: `mes-282-specificationMasters` - -2. **로그아웃** - -3. **TestUser4 로그인** (tenant.id: 283) - - Console 로그 확인 - -**기대 결과**: -``` -✅ Console 로그: -[Auth] Tenant changed: 282 → 283 -[Cache] Cleared sessionStorage: mes-282-itemMasters -[Cache] Cleared sessionStorage: mes-282-specificationMasters -... - -✅ 이전 테넌트(282)의 캐시가 모두 삭제됨 -✅ TestUser4의 데이터는 mes-283-* 키로 저장됨 -✅ 테넌트 간 데이터 격리 확인 -``` - -**확인 방법**: -```javascript -// 테넌트 전환 후 -Object.keys(sessionStorage).forEach(key => { - console.log(key); -}); - -// 결과: -// mes-283-itemMasters (새 테넌트) -// mes-283-specificationMasters -// (mes-282-* 키는 없어야 함!) -``` - ---- - -### 시나리오 8: PHP 백엔드 tenant.id 검증 🛡️ - -**⚠️ 주의**: PHP 백엔드가 실행 중이어야 함 - -**목적**: 다른 테넌트의 데이터 접근 시 403 반환 확인 - -**단계**: -1. **TestUser3 로그인** (tenant.id: 282) -2. 브라우저 Console에서 다른 테넌트 API 직접 호출: - -```javascript -// 자신의 테넌트 (282) - 성공해야 함 -fetch('/api/tenants/282/item-master-config') - .then(r => r.json()) - .then(d => console.log('Own tenant:', d)); - -// 다른 테넌트 (283) - 403 에러여야 함 -fetch('/api/tenants/283/item-master-config') - .then(r => r.json()) - .then(d => console.log('Other tenant:', d)); -``` - -**기대 결과**: -``` -✅ 자신의 테넌트 (282): -{ - success: true, - data: { ... } -} - -✅ 다른 테넌트 (283): -{ - success: false, - error: { - code: "FORBIDDEN", - message: "접근 권한이 없습니다." - } -} -Status: 403 Forbidden - -✅ Next.js는 단순히 PHP 응답을 전달만 함 -✅ PHP가 tenant.id 불일치를 감지하고 403 반환 -``` - ---- - -## 체크리스트 - -### 캐시 동작 ✅ -- [ ] sessionStorage에 `mes-{tenantId}-{key}` 형식으로 저장 -- [ ] 캐시 데이터에 `tenantId`, `timestamp`, `version` 포함 -- [ ] 페이지 새로고침 시 캐시에서 로드 -- [ ] TTL (1시간) 만료 시 자동 삭제 - -### 탭 격리 🔗 -- [ ] 탭마다 독립적인 sessionStorage -- [ ] 다른 탭과 데이터 공유 안 됨 -- [ ] 탭 닫으면 sessionStorage 자동 삭제 - -### 로그아웃 🚪 -- [ ] 로그아웃 시 `mes-{tenantId}-*` 캐시 모두 삭제 -- [ ] Console에 삭제 로그 출력 -- [ ] localStorage의 `mes-currentUser` 삭제 - -### 테넌트 전환 🔄 -- [ ] 테넌트 변경 감지 (useEffect) -- [ ] 이전 테넌트 캐시 자동 삭제 -- [ ] 새 테넌트 데이터는 새 키로 저장 -- [ ] Console에 전환 로그 출력 - -### API 보안 🛡️ -- [ ] 자신의 테넌트 API 호출 성공 -- [ ] 다른 테넌트 API 호출 시 403 Forbidden -- [ ] PHP 백엔드가 tenant.id 검증 수행 -- [ ] Next.js는 PHP 응답 그대로 전달 - ---- - -## 문제 해결 - -### 문제 1: 캐시가 저장되지 않음 - -**증상**: sessionStorage가 비어있음 - -**원인**: -- ItemMasterContext가 제대로 마운트되지 않음 -- tenantId가 null - -**해결**: -1. Console에서 확인: - ```javascript - // AuthContext의 currentUser 확인 - console.log(JSON.parse(localStorage.getItem('mes-currentUser'))); - - // tenant.id 확인 - console.log(user?.tenant?.id); - ``` - -2. ItemMasterContext가 AuthContext 하위에 있는지 확인 - -### 문제 2: 테넌트 전환 시 캐시가 삭제되지 않음 - -**증상**: 이전 테넌트 캐시가 남아있음 - -**원인**: -- `useEffect` 의존성 배열 문제 -- `previousTenantIdRef` 초기화 안 됨 - -**해결**: -```typescript -// AuthContext.tsx 확인 -useEffect(() => { - const prevTenantId = previousTenantIdRef.current; - const currentTenantId = currentUser?.tenant?.id; - - if (prevTenantId && currentTenantId && prevTenantId !== currentTenantId) { - console.log(`[Auth] Tenant changed: ${prevTenantId} → ${currentTenantId}`); - clearTenantCache(prevTenantId); - } - - previousTenantIdRef.current = currentTenantId || null; -}, [currentUser?.tenant?.id]); -``` - -### 문제 3: TTL 만료 후에도 캐시가 남아있음 - -**증상**: 1시간 이상 지난 캐시가 그대로 사용됨 - -**원인**: -- `TenantAwareCache.get()` 메서드에서 TTL 체크 안 함 - -**해결**: -```typescript -// TenantAwareCache.ts 확인 -get(key: string): T | null { - // ... - - // TTL 검증 - if (Date.now() - parsed.timestamp > this.ttl) { - console.warn(`[Cache] Expired cache for key: ${key}`); - this.remove(key); - return null; - } - - return parsed.data; -} -``` - -### 문제 4: PHP 403 에러가 반환되지 않음 - -**증상**: 다른 테넌트 API 호출이 성공함 - -**원인**: -- PHP 백엔드에 tenant.id 검증 로직이 없음 -- JWT에 tenant.id가 포함되지 않음 - -**해결**: -1. PHP 백엔드 확인 (프론트엔드 작업 범위 밖) -2. JWT payload에 `tenant_id` 포함 여부 확인 -3. PHP middleware에서 tenant.id 검증 로직 확인 - ---- - -## 테스트 완료 기준 - -### ✅ 모든 시나리오 통과 -- 시나리오 1-8 모두 기대 결과와 일치 - -### ✅ 모든 체크리스트 완료 -- 캐시, 탭, 로그아웃, 테넌트 전환, API 보안 - -### ✅ Console 에러 없음 -- 개발자 도구 Console에 빨간색 에러 없음 - -### ✅ 성능 확인 -- 페이지 로드 시간 < 1초 -- 캐시 히트 시 API 호출 없음 - ---- - -## 다음 단계 - -Phase 5 완료 후: -- **Phase 6**: 품목기준관리 페이지 작업 진행 -- API 연동 및 실제 CRUD 구현 -- UI/UX 개선 - ---- - -**작성자**: Claude -**버전**: 1.0 -**최종 업데이트**: 2025-11-19 \ No newline at end of file diff --git a/claudedocs/architecture/[TODO-2026-03-10] user-preferences-db-migration.md b/claudedocs/architecture/[TODO-2026-03-10] user-preferences-db-migration.md deleted file mode 100644 index 8e1b9d66..00000000 --- a/claudedocs/architecture/[TODO-2026-03-10] user-preferences-db-migration.md +++ /dev/null @@ -1,166 +0,0 @@ -# [TODO] 유저 개별 설정 DB 이관 계획 - -> 현재 localStorage에 저장 중인 유저별 설정을 백엔드 DB로 이관하여 크로스 디바이스 동기화 지원 - ---- - -## 현재 현황: localStorage 기반 유저 설정 목록 - -### 🔴 HIGH — 우선 이관 대상 - -| 항목 | 저장 키 | 파일 | 유저 분리 | 설명 | -|------|---------|------|-----------|------| -| 즐겨찾기 | `sam-favorites-{userId}` | `stores/favoritesStore.ts` | ✅ | 메뉴 즐겨찾기 (최대 10개) | -| 테이블 컬럼 설정 | `sam-table-columns-{userId}` | `stores/useTableColumnStore.ts` | ✅ | 컬럼 너비, 숨김 여부 (페이지별) | - -### 🟡 MEDIUM — 2차 이관 대상 - -| 항목 | 저장 키 | 파일 | 유저 분리 | 설명 | -|------|---------|------|-----------|------| -| 테마 | `theme` | `stores/themeStore.ts` | ❌ 공용 | light / dark / senior | -| 글꼴 크기 | `sam-font-size` | `layouts/AuthenticatedLayout.tsx` | ❌ 공용 | 12~20px (기본 16) | -| 사이드바 접힘 | `sam-menu` | `stores/menuStore.ts` | ❌ 공용 | sidebarCollapsed 상태 | -| 알림 설정 | `ITEM_VISIBILITY_STORAGE_KEY` | `settings/NotificationSettings/index.tsx` | ❌ 공용 | 알림 카테고리별 표시 여부 | - -### 🟢 LOW — 선택적 이관 - -| 항목 | 저장 키 | 파일 | 설명 | -|------|---------|------|------| -| 팝업 오늘 하루 안 보기 | `popup_dismissed_{id}` | `common/NoticePopupModal.tsx` | 매일 자동 리셋, 임시성 | - -### ❌ 제외 (이관 불필요) - -| 항목 | 이유 | -|------|------| -| Auth 토큰 (HttpOnly 쿠키) | 이미 서버 관리 | -| Auth Store (mes-users, mes-currentUser) | 인증 플로우 전용 | -| Master Data 캐시 (sessionStorage) | TTL 기반 캐시, 설정 아님 | -| Dashboard Stale 캐시 (sessionStorage) | 세션 캐시 | -| Page Builder (page-builder-pages) | 개발 전용 도구 | - ---- - -## 백엔드 DB 스키마 (안) - -### user_preferences (통합 설정 테이블) -```sql -CREATE TABLE user_preferences ( - id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - theme VARCHAR(20) DEFAULT 'light', - font_size TINYINT UNSIGNED DEFAULT 16, - sidebar_collapsed BOOLEAN DEFAULT false, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY (tenant_id, user_id) -); -``` - -### user_favorites (즐겨찾기) -```sql -CREATE TABLE user_favorites ( - id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - menu_id VARCHAR(100) NOT NULL, - label VARCHAR(255) NOT NULL, - icon_name VARCHAR(100), - path VARCHAR(500) NOT NULL, - display_order TINYINT UNSIGNED DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY (tenant_id, user_id, menu_id) -); -``` - -### user_table_preferences (테이블 컬럼 설정) -```sql -CREATE TABLE user_table_preferences ( - id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - page_id VARCHAR(100) NOT NULL, - settings JSON NOT NULL, -- { columnWidths: {...}, hiddenColumns: [...] } - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY (tenant_id, user_id, page_id) -); -``` - -### user_notification_preferences (알림 설정) -```sql -CREATE TABLE user_notification_preferences ( - id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - settings JSON NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY (tenant_id, user_id) -); -``` - ---- - -## API 엔드포인트 (안) - -### Phase 1 (즐겨찾기 + 테이블 설정) -``` -GET /api/v1/user/preferences — 전체 설정 조회 -PATCH /api/v1/user/preferences — 설정 부분 업데이트 - -GET /api/v1/user/favorites — 즐겨찾기 목록 -POST /api/v1/user/favorites — 즐겨찾기 추가 -DELETE /api/v1/user/favorites/{menuId} — 즐겨찾기 삭제 -PATCH /api/v1/user/favorites/reorder — 순서 변경 - -GET /api/v1/user/table-preferences/{pageId} — 페이지별 컬럼 설정 -PUT /api/v1/user/table-preferences/{pageId} — 컬럼 설정 저장 -``` - -### Phase 2 (테마/글꼴/사이드바/알림) -``` -GET /api/v1/user/preferences — 위와 동일 (theme, font_size 포함) -PATCH /api/v1/user/preferences — 위와 동일 - -GET /api/v1/user/notification-preferences -PUT /api/v1/user/notification-preferences -``` - ---- - -## 이관 전략 - -### 단계별 마이그레이션 -1. **DB 테이블 + API 생성** (백엔드) -2. **Dual-write 패턴 적용** (프론트) - - 저장 시: API 호출 + localStorage 동시 기록 - - 읽기 시: API 우선 → localStorage 폴백 -3. **안정화 후 localStorage 제거** - -### 프론트 전환 패턴 (예시) -```typescript -// createUserStorage → createUserStorageAPI 전환 -export function createUserStorageAPI(baseKey: string) { - return { - getItem: async () => { - const res = await fetch(`/api/v1/user/${baseKey}`); - return res.ok ? res.json() : null; - }, - setItem: async (value: unknown) => { - await fetch(`/api/v1/user/${baseKey}`, { - method: 'PUT', - body: JSON.stringify(value), - }); - }, - }; -} -``` - ---- - -## 우선순위 정리 - -| 단계 | 대상 | 이유 | -|------|------|------| -| Phase 1 | 즐겨찾기, 테이블 컬럼 | 유저별 분리 이미 되어있어 구조 전환 쉬움, 사용 빈도 높음 | -| Phase 2 | 테마, 글꼴, 사이드바 | 현재 유저 분리 안 됨 → DB 이관하면서 유저별 적용 | -| Phase 3 | 알림 설정 | 기능 안정화 후 진행 | diff --git a/claudedocs/architecture/[VISION-2026-02-19] dynamic-rendering-platform-strategy.md b/claudedocs/architecture/[VISION-2026-02-19] dynamic-rendering-platform-strategy.md deleted file mode 100644 index 69e29d3f..00000000 --- a/claudedocs/architecture/[VISION-2026-02-19] dynamic-rendering-platform-strategy.md +++ /dev/null @@ -1,224 +0,0 @@ -# 동적 렌더링 플랫폼 전략 — 기준관리 기반 화면 자동 구성 - -> 작성일: 2026-02-19 -> 상태: 비전 정리 (논의 기반) -> 관련 기술 설계: `[DESIGN-2026-02-11] dynamic-field-type-extension.md` -> 관련 구현 현황: `[IMPL-2026-02-11] dynamic-field-components.md` -> 관련 로드맵: `item-master/[DESIGN-2025-12-12] item-master-form-builder-roadmap.md` - ---- - -## 1. 핵심 비전 - -``` -기준관리 페이지에서 설정 → API로 메타데이터 전달 → 프론트가 자동 렌더링 -``` - -**목표**: 개발자가 매번 화면을 코딩하는 것이 아니라, 기준관리 페이지에서 등록한 설정값에 따라 프론트엔드가 동적으로 화면을 구성하는 **ERP 커스터마이징 플랫폼**. - ---- - -## 2. 운영 워크플로우 비전 - -### 2.1 전체 흐름 - -``` -현장 방문 (영업자/매니저) - ├─ 녹음, 체크리스트, 문서 수집 - └─ → MD 파일 정리 (요구사항) - │ - ↓ -기준관리 페이지 (관리자/컨설턴트) - ├─ MD 보고 속성, 칼럼, 옵션 등록 - └─ → 메타데이터 저장 (DB) - │ - ↓ API - │ -프론트엔드 (자동 렌더링) - └─ 메타데이터 기반으로 동적 화면 구성 -``` - -### 2.2 역할 변화 - -| 역할 | 현재 | 비전 | -|------|------|------| -| 영업자/매니저 | 요구사항 전달 → 개발 대기 | 현장에서 MD 파일 작성 | -| 관리자/컨설턴트 | — | MD 보고 기준관리에 설정 입력 | -| **개발자** | **요구사항마다 화면 코딩** | **플랫폼 유지보수 + 새 블록 타입 추가 시에만 개입** | - -### 2.3 개발자 개입이 필요한 시점 - -- 기존 블록(Input, Select, DatePicker 등)으로 조합 가능 → **개발자 불필요** -- 새로운 입력 타입/계산 로직 필요 → **블록 1개 추가** → 이후 재사용 -- 기준관리 UI 자체 개선 → **설계/검증** -- page-builder 고도화 → **설계/구현** - ---- - -## 3. 현재 자산 현황 - -### 3.1 이미 있는 것 - -#### UI 블록 (공통 컴포넌트) -``` -src/components/ui/ - ├─ Input, NumberInput, QuantityInput, CurrencyInput - ├─ Select, Checkbox, DatePicker, Textarea - ├─ Button, Badge, Card, Dialog - └─ ... -``` -모든 도메인별 테이블이 이 공통 블록을 사용 중. - -#### 동적 필드 시스템 (14종 완성) -``` -DynamicItemForm/fields/ - ├─ 기존 6종: textbox, number, dropdown, checkbox, date, textarea - └─ 신규 8종: reference, multi-select, file, currency, unit-value, radio, toggle, computed -``` -Phase 1~3 프론트 구현 완료 (백엔드 작업 대기). - -#### 범용 테이블 섹션 -``` -DynamicTableSection — config 기반 칼럼 정의, 행 CRUD, 요약행 -TableCellRenderer — 테이블 셀 = DynamicFieldRenderer 재사용 -``` - -#### 속성 관리 시스템 (품목기준관리) -``` -useAttributeManagement — 속성 옵션 상태 관리 -AttributeTabContent — 동적 탭 렌더링 -OptionColumn[] + MasterOption[] — 메타데이터 구조 -``` - -#### page-builder 프로토타입 -``` -/dev/page-builder — 드래그앤드롭, 섹션/필드 구성, Undo/Redo, 반응형 뷰포트 -``` - -### 3.2 현재 구조: "기준관리 → 동적 렌더링" 패턴 - -``` -품목기준관리 (Admin) 품목 등록 (User) -ItemMasterDataManagement.tsx DynamicItemForm/index.tsx - ↓ 설정 (pages/sections/fields) ↓ 읽어서 렌더링 - DB에 메타데이터 저장 DynamicFieldRenderer (14종 switch) - DynamicTableSection (config 기반) -``` - -**이 패턴이 핵심이고, 다른 도메인에도 동일하게 확장하는 것이 비전.** - ---- - -## 4. 확장 대상 분석 - -### 4.1 도메인별 동적 렌더링 적합성 - -| 도메인 | 적합도 | 이유 | -|--------|:---:|------| -| 품목기준관리 | ✅ 이미 적용 | 테넌트/업종별 관리 항목이 다름 | -| 설비/자산 관리 | ✅ 높음 | 설비 종류별 관리 속성이 다름 | -| 거래처 관리 | ✅ 높음 | 업종별 추가 정보 다름 | -| 공정/라우팅 관리 | ✅ 높음 | 제조 방식별 공정 구성 다름 | -| 검사 항목 관리 | ✅ 높음 | 품목별 검사 항목/기준 다름 | -| 견적서/발주서 | 🟡 부분 | 테이블은 동적 가능, 비즈니스 로직은 고정 | -| 세금계산서 | ❌ 낮음 | 법정 양식, 테넌트별 차이 없음 | -| 대시보드 | ❌ 낮음 | 위젯 기반이 더 적합 | - -### 4.2 편집 가능 테이블 현황 - -| 컴포넌트 | 공통 컴포넌트 사용 | 자동 계산 | 합계 행 | -|---------|:---:|:---:|:---:| -| EditableTable (공통) | 본인이 공통 | ❌ | ❌ | -| TaxInvoiceItemTable | ❌ 개별 | ✅ | ✅ | -| OrderDetailItemTable | ❌ 개별 | ❌ | ✅ | -| EstimateDetailTableSection | ❌ 개별 | ✅ (복잡) | ✅ | -| DynamicTableSection | ❌ 개별 (config 기반) | ✅ (요약) | ✅ | - -**테이블 안의 부품(Input, Select 등)은 전부 공통 ui 컴포넌트 사용.** -껍데기(테이블 구조, 계산 로직)만 각자 구현. - ---- - -## 5. page-builder 갭 분석 - -### 5.1 현재 page-builder 상태 - -``` -/dev/page-builder (프로토타입) - ✅ 드래그앤드롭 (섹션/필드 → 캔버스) - ✅ 섹션 타입 (BASIC, BOM, CUSTOM) - ✅ 필드 타입 (기본 6종) - ✅ 조건부 표시 (DisplayCondition) - ✅ 검증 규칙 (ValidationRule) - ✅ BOM 테이블 - ✅ 마스터 필드 연동 - ✅ Undo/Redo 히스토리 - ✅ 반응형 뷰포트 (desktop/tablet/mobile) - ✅ API 변환 타입 정의 -``` - -### 5.2 비전 대비 부족한 점 - -| 항목 | 현재 | 필요 | -|------|------|------| -| 대상 도메인 | 품목 전용 (ItemType: FG/PT/SM/RM/CS) | 모든 기준관리 | -| 사용자 | 개발자용 프로토타입 | 비개발자(영업/매니저/관리자) | -| 테이블 섹션 | BOM만 (고정 칼럼) | 동적 칼럼 + 행 CRUD (DynamicTableSection 연결) | -| 신규 필드 타입 | 기본 6종만 | 14종 전체 반영 | -| API 연동 | 타입만 정의 | 실제 저장/조회 | -| 프리셋 | 하드코딩 | 산업별 섹션 프리셋 선택 | - -### 5.3 고도화 방향 - -``` -1단계: 도메인 범용화 - - ItemType 종속 제거 - - "기준관리 도메인" 선택 → 해당 도메인의 페이지 구성 - -2단계: 14종 필드 타입 반영 - - ComponentPalette에 신규 8종 필드 추가 - - PropertyPanel에 각 필드별 config 편집 UI - -3단계: DynamicTableSection 연결 - - BOM 외 범용 테이블 섹션 지원 - - 칼럼 정의 UI (타입/너비/필수 설정) - -4단계: 비개발자 UX - - 용어 단순화 (field_type → "입력 형태") - - 미리보기 강화 - - 저장/불러오기 -``` - ---- - -## 6. 4-Level 아키텍처 요약 - -기존 기술 설계서(`[DESIGN-2026-02-11]`)의 핵심: - -``` -Level 1: 필드 타입 컴포넌트 (14종) — 코드 레벨, 거의 안 바뀜 -Level 2: properties config (JSON) — 설정 레벨, 코드 변경 없음 -Level 3: 섹션 프리셋 (JSON) — 템플릿 레벨, 코드 변경 없음 -Level 4: reference sources (API URL) — 연결 레벨, 코드 변경 없음 -``` - -**새 산업 진출 시에도 프론트엔드 코드 변경 = 0줄.** -백엔드 API + config JSON만 추가. - ---- - -## 7. 관련 문서 - -| 문서 | 위치 | 내용 | -|------|------|------| -| 동적 필드 타입 확장 설계 | `architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드, 범용 테이블, 산업별 확장 | -| 동적 필드 컴포넌트 구현 | `architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 상태 | -| Form Builder 로드맵 | `item-master/[DESIGN-2025-12-12]` | Low-Code Form Builder 초기 로드맵 | -| 백엔드 API 스펙 | `item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 API 요청서 | -| page-builder 참조 | `dev/[REF] page-builder-implementation.md` | 페이지 빌더 구현 참조 | -| 멀티테넌시 최적화 | `architecture/[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md` | 테넌트별 격리/최적화 | - ---- - -**문서 버전**: 1.0 -**마지막 업데이트**: 2026-02-19 diff --git a/claudedocs/architecture/module-separation-guide.md b/claudedocs/architecture/module-separation-guide.md deleted file mode 100644 index c35dadc0..00000000 --- a/claudedocs/architecture/module-separation-guide.md +++ /dev/null @@ -1,315 +0,0 @@ -# SAM ERP 멀티테넌트 모듈 분리 아키텍처 - -> 작성일: 2026-03-18 -> 상태: 프론트엔드 Phase 0~3 완료 / 백엔드 작업 필요 - ---- - -## 0. 왜 산업군별 모듈 분리가 필요한가 (협의 필요) - -### 현재 상황: 하나의 ERP, 다른 업종의 고객사 - -SAM ERP는 **하나의 코드베이스**로 여러 회사(테넌트)에 서비스를 제공합니다. -그런데 고객사마다 업종이 다릅니다: - -``` -경동 → 셔터 제조업 (MES) → 생산관리, 품질관리, 차량관리가 필요 -주일 → 건설업 → 시공관리, 차량관리가 필요 -A사 → 유통/서비스업 → 공통 ERP(회계/인사/영업)만 필요 -``` - -현재는 **모든 테넌트에게 모든 메뉴가 보입니다.** -경동 직원에게 시공관리 메뉴가 보이고, 주일 직원에게 생산관리 메뉴가 보입니다. -→ 사용하지 않는 메뉴가 노출되어 혼란을 주고, 대시보드에도 불필요한 섹션이 나타남. - -### 제안: 업종(industry) 기반 모듈 ON/OFF - -``` -┌─────────────────────────────────────────────────┐ -│ SAM ERP (공통) │ -│ 회계 · 인사 · 영업 · 결재 · 게시판 · 설정 │ -├──────────┬──────────┬───────────┬───────────────┤ -│ 생산관리 │ 품질관리 │ 시공관리 │ 차량관리 │ -│ (MES) │ │ (건설) │ (선택) │ -├──────────┴──────────┼───────────┤ │ -│ 셔터 MES 업종 │ 건설 업종 │ │ -│ (경동) │ (주일) │ │ -└─────────────────────┴───────────┴───────────────┘ -``` - -- **공통 모듈**: 모든 테넌트가 사용 (회계, 인사, 영업 등) -- **업종 모듈**: 테넌트의 업종에 따라 자동으로 켜짐/꺼짐 -- **선택 모듈**: 업종과 관계없이 개별 선택 가능 (차량관리 등) - -### 협의가 필요한 부분 - -이 구조로 가려면 다음 사항의 합의가 필요합니다: - -#### 1) 업종 분류 체계 -현재 프론트엔드에 하드코딩된 매핑: - -| 업종 코드 | 의미 | 활성 모듈 | -|-----------|------|-----------| -| `shutter_mes` | 셔터 제조 (MES) | 생산관리 + 품질관리 + 차량관리 | -| `construction` | 건설업 | 시공관리 + 차량관리 | - -**Q. 이 분류가 맞는지? 추가할 업종이 있는지?** -예: 일반 제조업, 도소매업, 서비스업 등 - -#### 2) 모듈 경계 -현재 정의된 모듈 단위: - -| 모듈 | 포함 기능 | 비고 | -|------|----------|------| -| 공통 ERP | 대시보드, 회계, 인사, 영업, 결재, 게시판, 설정 등 | 항상 ON | -| 생산관리 | 생산지시, 작업지시, 작업일보 | 경동 전용 | -| 품질관리 | 설비점검, 수리요청, 검사 | 경동 전용 | -| 시공관리 | 프로젝트, 계약, 기성, 시공일보 | 주일 전용 | -| 차량관리 | 차량등록, 운행일지, 지게차 | 선택적 | - -**Q. 모듈 단위 범위가 적절한지? 분리/통합이 필요한 모듈이 있는지?** - -#### 3) 활성화 방식 -| 방식 | 장점 | 단점 | -|------|------|------| -| **A. 업종 자동** (현재) | 간단, 실수 방지 | 유연성 낮음 | -| **B. 모듈 개별 선택** (향후) | 유연함 | 관리 복잡 | -| **C. 업종 기본값 + 개별 재정의** | 균형 | 구현 복잡도 중간 | - -**Q. 어떤 방식을 채택할 것인지?** -현재 프론트엔드는 A 방식으로 구현, B/C로 확장 가능하도록 설계됨. - -#### 4) 적용 시점과 범위 -- 백엔드에서 `tenant.options.industry` 값만 세팅하면 즉시 동작 -- 값을 안 넣으면 기존과 100% 동일 (부작용 제로) -- **Q. 언제부터, 어떤 테넌트부터 적용할 것인지?** - ---- - -## 1. 개요 - -### 목표 -하나의 SAM ERP 코드베이스에서 **테넌트(회사)별로 필요한 모듈만 활성화**하여, -불필요한 메뉴·페이지·대시보드 섹션을 숨기는 구조. - -### 현재 테넌트별 모듈 구성 -| 업종 코드 | 테넌트 예시 | 활성 모듈 | -|-----------|------------|-----------| -| `shutter_mes` | 경동 | 생산관리, 품질관리, 차량관리 | -| `construction` | 주일 | 시공관리, 차량관리 | -| (미설정) | 기타 모든 테넌트 | **전체 모듈 활성화 (기존과 동일)** | - -### 안전 원칙 -``` -tenant.options.industry가 설정되지 않은 테넌트 → 모든 기능 그대로 사용 가능 -= 기존 동작 100% 유지, 부작용 제로 -``` - ---- - -## 2. 프론트엔드 구조 (완료) - -### 파일 구조 -``` -src/modules/ -├── types.ts # ModuleId 타입 정의 -├── tenant-config.ts # 업종→모듈 매핑 (resolveEnabledModules) -└── index.ts # 모듈 레지스트리 (라우트 매핑, 대시보드 섹션) - -src/hooks/ -└── useModules.ts # React 훅: isEnabled(), isRouteAllowed(), tenantIndustry -``` - -### 모듈 ID 목록 -| ModuleId | 이름 | 소유 라우트 | 대시보드 섹션 | -|----------|------|------------|--------------| -| `common` | 공통 ERP | /dashboard, /accounting, /sales, /hr, /approval, /settings 등 | 전부 | -| `production` | 생산관리 | /production | dailyProduction, unshipped | -| `quality` | 품질관리 | /quality | - | -| `construction` | 시공관리 | /construction | construction | -| `vehicle-management` | 차량관리 | /vehicle-management, /vehicle | - | - -### 프론트엔드 동작 흐름 -``` -1. 로그인 → authStore에 tenant 정보 저장 -2. useModules() 훅이 tenant.options.industry 읽음 -3. industry 값으로 INDUSTRY_MODULE_MAP 조회 → 활성 모듈 목록 결정 -4. 각 컴포넌트에서 isEnabled('production') 등으로 분기 -``` - -### 적용된 영역 - -#### A. CEO 대시보드 -- **섹션 필터링**: 비활성 모듈의 대시보드 섹션 자동 제외 -- **API 호출 차단**: 비활성 모듈의 API는 호출하지 않음 (null endpoint) -- **설정 팝업**: 비활성 모듈 섹션은 설정에서도 안 보임 -- **캘린더**: 비활성 모듈의 일정 유형 필터 숨김 -- **요약 네비**: 비활성 섹션 자동 제외 - -#### B. 라우트 접근 제어 -- `/production/*`, `/quality/*`, `/construction/*` 등 전용 라우트는 모듈 비활성 시 접근 차단 -- `/sales/*/production-orders` 같은 공통 라우트 내 모듈 의존 페이지는 명시적 가드 적용 - -#### C. 사이드바 메뉴 -- 비활성 모듈의 메뉴 항목 숨김 (isRouteAllowed 기반) - ---- - -## 3. 백엔드 필요 작업 - -### 3.1 tenants 테이블 options 필드에 industry 추가 -**우선순위: 🔴 필수** - -현재 프론트엔드는 `tenant.options.industry` 값을 읽어서 모듈을 결정합니다. -이 값이 백엔드에서 내려와야 실제로 동작합니다. - -```php -// tenants 테이블의 options JSON 컬럼에 industry 추가 -// 예시 데이터: -{ - "industry": "shutter_mes" // 경동: 셔터 MES -} -{ - "industry": "construction" // 주일: 건설 -} -// 다른 테넌트: industry 키 없음 → 프론트에서 전체 모듈 활성화 -``` - -**작업 내용:** -1. `tenants` 테이블의 `options` JSON 컬럼에 `industry` 키 추가 (마이그레이션 불필요, JSON이므로) -2. 경동 테넌트: `options->industry = 'shutter_mes'` -3. 주일 테넌트: `options->industry = 'construction'` -4. 테넌트 정보 API 응답에 `options.industry` 포함 확인 - -**확인 포인트:** -- 프론트엔드에서 `authStore.currentUser.tenant.options.industry`로 접근 -- 현재 로그인 API(`/api/v1/auth/me` 또는 유사)의 응답에서 tenant.options가 포함되는지 확인 -- 포함 안 되면 응답에 추가 필요 - -### 3.2 (선택) 테넌트 관리 화면에서 industry 설정 UI -**우선순위: 🟡 선택** - -관리자가 테넌트별 업종을 설정할 수 있는 UI. 급하지 않음 — DB 직접 수정으로 충분. - -### 3.3 (Phase 2 예정) 명시적 모듈 목록 API -**우선순위: 🟢 향후** - -현재는 `industry` → 프론트엔드 하드코딩 매핑으로 모듈 결정. -향후 백엔드에서 직접 모듈 목록을 내려주면 더 유연해짐. - -```php -// tenant.options 예시 (Phase 2) -{ - "industry": "shutter_mes", - "modules": ["production", "quality", "vehicle-management"] // 명시적 목록 -} -``` - -프론트엔드는 이미 이 구조를 지원하도록 준비되어 있음: -```typescript -// src/modules/tenant-config.ts -export function resolveEnabledModules(options) { - // Phase 2: 백엔드가 명시적 모듈 목록 제공 → 우선 사용 - if (explicitModules && explicitModules.length > 0) { - return explicitModules; - } - // Phase 1: industry 기반 기본값 (현재) - if (industry) { - return INDUSTRY_MODULE_MAP[industry] ?? []; - } - return []; -} -``` - ---- - -## 4. 업종별 모듈 매핑 (프론트엔드 하드코딩) - -```typescript -// src/modules/tenant-config.ts -const INDUSTRY_MODULE_MAP: Record = { - shutter_mes: ['production', 'quality', 'vehicle-management'], - construction: ['construction', 'vehicle-management'], -}; -``` - -새로운 업종 추가 시: -1. 여기에 매핑 추가 -2. 필요하면 `ModuleId` 타입에 새 모듈 ID 추가 -3. `MODULE_REGISTRY` (src/modules/index.ts)에 라우트/대시보드 섹션 등록 - ---- - -## 5. 핵심 코드 패턴 - -### 기본 사용법 -```typescript -import { useModules } from '@/hooks/useModules'; - -function MyComponent() { - const { isEnabled, tenantIndustry } = useModules(); - - // 안전 장치: industry 미설정이면 모든 기능 활성 - const moduleAware = !!tenantIndustry; - - if (moduleAware && !isEnabled('production')) { - return
생산관리 모듈이 비활성화되어 있습니다.
; - } - - // 생산관리 기능 렌더링... -} -``` - -### 크로스 모듈 임포트 규칙 -``` -✅ Common → Common (자유) -✅ Tenant → Common (자유) -✅ Common → Tenant (래퍼 경유) (src/lib/api/에서 MODULE_SEPARATION_OK 주석과 함께) -❌ Common → Tenant (직접) (scripts/verify-module-separation.sh가 검출) -❌ Tenant → Tenant (금지, dynamic import만 허용) -``` - ---- - -## 6. 구현 이력 - -| Phase | 내용 | 커밋 | 상태 | -|-------|------|------|------| -| Phase 0 | 크로스 모듈 의존성 해소 | `a99c3b39` | ✅ 완료 | -| Phase 1 | 모듈 레지스트리 + 라우트 가드 | `0a65609e` | ✅ 완료 | -| Phase 2 | CEO 대시보드 모듈 디커플링 | `46501214` | ✅ 완료 | -| Phase 3 | 물리적 분리 (경계 마커, 검증, 가드, 문서) | `4b8ca09e` | ✅ 완료 | - ---- - -## 7. 테스트 시나리오 - -### 테스트 방법 -백엔드에서 `tenant.options.industry`를 설정한 후: - -| 시나리오 | 예상 결과 | -|----------|----------| -| industry 미설정 테넌트 로그인 | 기존과 완전 동일 (모든 메뉴/기능 표시) | -| `shutter_mes` 테넌트 로그인 | 시공관리 메뉴 숨김, 대시보드 시공 섹션 안 보임 | -| `construction` 테넌트 로그인 | 생산/품질 메뉴 숨김, 대시보드 생산 섹션 안 보임 | -| `shutter_mes`에서 `/construction` 직접 접근 | 접근 차단 메시지 표시 | -| `construction`에서 `/production` 직접 접근 | 접근 차단 메시지 표시 | - -### 롤백 방법 -문제 발생 시 DB에서 `tenant.options.industry` 값만 제거하면 즉시 원복. -프론트엔드 코드 변경 불필요. - ---- - -## 8. 향후 로드맵 - -``` -현재 (Phase 1) 향후 (Phase 2) 최종 (Phase 3) -┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ -│ industry 하드코딩 │ → │ 백엔드 modules 목록 │ → │ JSON 스키마 기반 │ -│ 매핑으로 모듈 결정 │ │ API에서 직접 수신 │ │ 동적 페이지 조립 │ -└─────────────────┘ └──────────────────────┘ └─────────────────────┘ -``` - -- **Phase 2**: `tenant.options.modules = ["production", "quality"]` 형태로 백엔드에서 명시적 모듈 목록 전달 → 업종 매핑 테이블 불필요 -- **Phase 3**: 각 모듈의 페이지 구성을 JSON 스키마로 정의 → 코드 변경 없이 테넌트별 화면 커스터마이징 diff --git a/claudedocs/archive/[IMPL-2025-11-07] seo-bot-blocking-configuration.md b/claudedocs/archive/[IMPL-2025-11-07] seo-bot-blocking-configuration.md deleted file mode 100644 index 7e82ac18..00000000 --- a/claudedocs/archive/[IMPL-2025-11-07] seo-bot-blocking-configuration.md +++ /dev/null @@ -1,364 +0,0 @@ -# SEO 및 봇 차단 설정 문서 - -## 개요 - -이 문서는 멀티 테넌트 ERP 시스템의 SEO 설정 및 봇 차단 전략을 설명합니다. 폐쇄형 시스템의 특성상 검색 엔진 수집을 방지하면서도, 과도한 차단으로 인한 브라우저 경고를 피하는 **균형 잡힌 접근 방식**을 채택했습니다. - ---- - -## 📋 구현 내용 - -### 1. robots.txt 설정 ✅ - -**위치**: `/public/robots.txt` - -**전략**: 느슨한 차단 (Moderate Blocking) - -#### 주요 설정 - -```txt -# 허용된 경로 (Allow) -- / (홈페이지) -- /login (로그인 페이지) -- /about (회사 소개) - -# 차단된 경로 (Disallow) -- /dashboard (대시보드) -- /admin (관리자 페이지) -- /api (API 엔드포인트) -- /tenant (테넌트 관리) -- /settings, /users, /reports, /analytics -- /inventory, /finance, /hr, /crm -- 기타 ERP 핵심 기능 경로 - -# 민감한 파일 형식 차단 -- /*.json, /*.xml, /*.csv -- /*.xls, /*.xlsx - -# Crawl-delay: 10초 -``` - -#### 크롬 경고 방지 전략 - -1. **홈페이지(/) 허용**: 완전 차단하지 않아 브라우저에서 악성 사이트로 분류되지 않음 -2. **공개 페이지 제공**: /login, /about 등 일부 공개 경로 허용 -3. **Crawl-delay 설정**: 서버 부하 감소 및 정상적인 봇 동작 유도 - ---- - -### 2. Middleware 봇 차단 로직 ✅ - -**위치**: `/src/middleware.ts` - -**역할**: 런타임에서 봇 요청을 감지하고 차단 - -#### 핵심 기능 - -##### 2.1 봇 패턴 감지 - -User-Agent 기반으로 다음 패턴을 감지: - -```typescript -- /bot/i, /crawler/i, /spider/i, /scraper/i -- /curl/i, /wget/i, /python-requests/i -- /axios/i (프로그래밍 방식 접근) -- /headless/i, /phantom/i, /selenium/i, /puppeteer/i, /playwright/i -- /go-http-client/i, /java/i, /okhttp/i -``` - -##### 2.2 경로 보호 전략 - -**보호된 경로 (Protected Paths)**: -- `/dashboard`, `/admin`, `/api` -- `/tenant`, `/settings`, `/users` -- `/reports`, `/analytics` -- `/inventory`, `/finance`, `/hr`, `/crm` -- `/employee`, `/customer`, `/supplier` -- `/orders`, `/invoices`, `/payroll` - -**공개 경로 (Public Paths)**: -- `/`, `/login`, `/about`, `/contact` -- `/robots.txt`, `/sitemap.xml`, `/favicon.ico` - -##### 2.3 차단 동작 - -봇이 보호된 경로에 접근 시: -```json -HTTP 403 Forbidden -{ - "error": "Access Denied", - "message": "Automated access to this resource is not permitted.", - "code": "BOT_ACCESS_DENIED" -} -``` - -##### 2.4 보안 헤더 추가 - -모든 응답에 다음 헤더 추가: -```http -X-Robots-Tag: noindex, nofollow, noarchive, nosnippet -X-Content-Type-Options: nosniff -X-Frame-Options: DENY -Referrer-Policy: strict-origin-when-cross-origin -``` - -##### 2.5 로깅 - -```typescript -// 차단된 봇 로그 -[Bot Blocked] {user-agent} attempted to access {pathname} - -// 허용된 봇 로그 (공개 경로) -[Bot Allowed] {user-agent} accessed {pathname} -``` - ---- - -### 3. SEO 메타데이터 설정 ✅ - -**위치**: `/src/app/layout.tsx` - -#### 메타데이터 구성 - -```typescript -metadata: { - title: { - default: "ERP System - Enterprise Resource Planning", - template: "%s | ERP System" - }, - description: "Multi-tenant Enterprise Resource Planning System for SME businesses", - robots: { - index: false, // 검색 엔진 색인 방지 - follow: false, // 링크 추적 방지 - nocache: true, // 캐싱 방지 - googleBot: { - index: false, - follow: false, - 'max-video-preview': -1, - 'max-image-preview': 'none', - 'max-snippet': -1, - } - }, - openGraph: { - type: 'website', - locale: 'ko_KR', - siteName: 'ERP System', - title: 'Enterprise Resource Planning System', - description: 'Multi-tenant ERP System for SME businesses', - }, - other: { - 'cache-control': 'no-cache, no-store, must-revalidate' - } -} -``` - -#### 주요 특징 - -1. **noindex, nofollow**: 검색 엔진 색인 및 링크 추적 차단 -2. **nocache**: 민감한 페이지 캐싱 방지 -3. **Google Bot 세부 제어**: 이미지, 비디오, 스니펫 미리보기 차단 -4. **Cache-Control 헤더**: 브라우저 및 프록시 캐싱 방지 -5. **다국어 지원**: locale 설정 (ko_KR) - ---- - -## 🎯 구현 전략 요약 - -| 구성 요소 | 목적 | 차단 강도 | 위치 | -|---------|------|---------|------| -| `robots.txt` | 검색 엔진 크롤러 가이드 | 느슨함 (Moderate) | `/public/robots.txt` | -| `middleware.ts` | 런타임 봇 감지 및 차단 | 강함 (Strong) | `/src/middleware.ts` | -| `layout.tsx` | HTML 메타 태그 설정 | 강함 (Strong) | `/src/app/layout.tsx` | - ---- - -## 🔒 보안 수준 - -### 다층 방어 (Defense in Depth) - -``` -Layer 1: robots.txt - ↓ 정상적인 검색 엔진 봇은 여기서 차단 - -Layer 2: Middleware Bot Detection - ↓ 악의적인 봇 및 자동화 도구 차단 - -Layer 3: SEO Meta Tags - ↓ HTML 레벨에서 색인 방지 - -Layer 4: Security Headers - ↓ 추가 보안 헤더로 보호 강화 -``` - -### 차단 vs 허용 균형 - -| 요소 | 설정 | 이유 | -|-----|------|------| -| 홈페이지 (/) | ✅ 허용 | 크롬 경고 방지 | -| 로그인 (/login) | ✅ 허용 | 정상 접근 가능 | -| 대시보드 (/dashboard) | ❌ 차단 | ERP 핵심 기능 보호 | -| API (/api) | ❌ 차단 | 데이터 보호 | -| 정적 파일 (.svg, .png 등) | ✅ 허용 | 정상 웹사이트 기능 | - ---- - -## 📊 동작 흐름 - -### 정상 사용자 (브라우저) - -``` -1. 사용자가 /dashboard 접근 -2. middleware.ts: User-Agent 확인 → 정상 브라우저 -3. X-Robots-Tag 헤더 추가 -4. 정상 페이지 렌더링 -5. HTML에 noindex 메타 태그 포함 -``` - -### 검색 엔진 봇 - -``` -1. Googlebot이 사이트 접근 -2. robots.txt 확인 → /dashboard Disallow -3. Googlebot은 /dashboard 접근하지 않음 -4. / (홈페이지)만 크롤링 → noindex 메타 태그 확인 -5. 검색 결과에 포함하지 않음 -``` - -### 악의적인 봇/스크래퍼 - -``` -1. curl/python-requests로 /api/users 접근 시도 -2. middleware.ts: User-Agent에서 'curl' 감지 -3. isProtectedPath('/api/users') → true -4. HTTP 403 Forbidden 반환 -5. 로그 기록: [Bot Blocked] curl/7.68.0 attempted to access /api/users -``` - ---- - -## 🧪 테스트 방법 - -### 1. robots.txt 확인 - -브라우저에서 접속: -``` -http://localhost:3000/robots.txt -``` - -### 2. Middleware 테스트 - -**정상 브라우저 접근**: -```bash -curl -H "User-Agent: Mozilla/5.0" http://localhost:3000/dashboard -# 예상: 정상 페이지 반환 (인증 로직 없으면 접근 가능) -``` - -**봇으로 접근**: -```bash -curl http://localhost:3000/dashboard -# 예상: HTTP 403 Forbidden -# {"error":"Access Denied","message":"Automated access to this resource is not permitted.","code":"BOT_ACCESS_DENIED"} -``` - -**공개 페이지 접근**: -```bash -curl http://localhost:3000/ -# 예상: 정상 페이지 반환 (X-Robots-Tag 헤더 포함) -``` - -### 3. 헤더 확인 - -```bash -curl -I http://localhost:3000/ -# 확인 항목: -# X-Robots-Tag: noindex, nofollow -# X-Content-Type-Options: nosniff -# X-Frame-Options: DENY -``` - -### 4. SEO 메타 태그 확인 - -브라우저에서 페이지 소스 보기: -```html - -``` - ---- - -## ⚠️ 주의사항 - -### 크롬 경고 방지 - -1. **완전 차단 금지**: robots.txt에서 모든 경로를 차단하면 안 됨 - ```txt - # ❌ 절대 사용 금지 - User-agent: * - Disallow: / - ``` - -2. **공개 페이지 유지**: 최소한 홈페이지는 허용 -3. **HTTP 상태 코드**: 403 사용 (404나 500은 피함) -4. **정상 사용자 차단 방지**: User-Agent 패턴 신중히 선택 - -### 로그 모니터링 - -- 차단된 봇 접근 시도를 모니터링하여 새로운 패턴 감지 -- 정상 사용자가 차단되는 경우 BOT_PATTERNS 조정 -- 로그 파일 위치: 콘솔 출력 (프로덕션에서는 로깅 서비스 연동 필요) - -### 성능 고려사항 - -- Middleware는 모든 요청에 실행되므로 성능 영향 최소화 -- 정규표현식 패턴 최적화 필요 -- 필요시 Redis 등으로 IP 기반 rate limiting 추가 고려 - ---- - -## 🔄 향후 개선 사항 - -### 1. IP 기반 Rate Limiting - -```typescript -// 추가 예정: Redis를 활용한 rate limiting -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -``` - -### 2. 화이트리스트 관리 - -```typescript -// 신뢰할 수 있는 IP나 User-Agent 화이트리스트 -const WHITELISTED_IPS = ['123.45.67.89']; -const WHITELISTED_USER_AGENTS = ['MyCompanyMonitoringBot']; -``` - -### 3. 고급 봇 감지 - -```typescript -// 행동 패턴 분석 (빠른 요청 속도, 비정상 경로 접근 등) -// Fingerprinting 기술 적용 -``` - -### 4. 로깅 서비스 연동 - -```typescript -// Sentry, LogRocket 등 APM 도구 연동 -// 봇 공격 패턴 분석 및 알림 -``` - ---- - -## 📝 변경 이력 - -| 날짜 | 버전 | 변경 내용 | -|-----|------|---------| -| 2025-11-06 | 1.0.0 | 초기 SEO 및 봇 차단 설정 구현 | - ---- - -## 참고 자료 - -- [Next.js Middleware Documentation](https://nextjs.org/docs/app/building-your-application/routing/middleware) -- [robots.txt Specification](https://developers.google.com/search/docs/crawling-indexing/robots/intro) -- [X-Robots-Tag HTTP Header](https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag) -- [OWASP Bot Management](https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks) diff --git a/claudedocs/archive/[IMPL-2025-11-11] chart-warning-fix.md b/claudedocs/archive/[IMPL-2025-11-11] chart-warning-fix.md deleted file mode 100644 index aa47b311..00000000 --- a/claudedocs/archive/[IMPL-2025-11-11] chart-warning-fix.md +++ /dev/null @@ -1,113 +0,0 @@ -# 차트 경고 수정 보고서 - -## 문제 상황 -CEODashboard에서 다음과 같은 경고가 발생: -``` -The width(-1) and height(-1) of chart should be greater than 0, -please check the style of container, or the props width(100%) and height(100%), -or add a minWidth(0) or minHeight(undefined) or use aspect(undefined) to control the -height and width. -``` - -## 원인 분석 - -### 문제 코드 -```tsx - -
- - - - ... - - - -
-
-``` - -### 원인 -1. `ResponsiveContainer`가 `height="100%"`로 설정됨 -2. 부모 div가 Tailwind 클래스 `h-80` 사용 -3. 컴포넌트 마운트 시점에 부모의 계산된 높이를 제대로 읽지 못함 -4. recharts가 높이를 -1로 계산하여 경고 발생 - -## 해결 방법 - -### 수정 코드 -```tsx - - {/* height="100%" → height={320} */} - -``` - -### 수정 이유 -- `h-80` = 320px (Tailwind: 1 단위 = 4px) -- 명시적인 픽셀 값으로 설정하여 마운트 시점에 즉시 계산 가능 -- ResponsiveContainer의 너비는 여전히 반응형 유지 (`width="100%"`) - -## 수정 위치 - -### CEODashboard.tsx -- Line 1201: 월별 매출 추이 차트 -- Line 1269: 품질 지표 차트 -- Line 1343: 생산 효율성 차트 -- Line 2127: 기타 차트 - -총 4개의 `ResponsiveContainer` 수정 완료 - -## 테스트 - -### 빌드 상태 -✅ **컴파일 성공**: `✓ Compiled successfully in 3.3s` - -### 예상 결과 -- ✅ 차트 경고 메시지 사라짐 -- ✅ 차트가 즉시 올바른 크기로 렌더링됨 -- ✅ 반응형 동작 유지 (너비는 여전히 100%) - -## 적용 가능한 다른 대시보드 - -현재는 CEODashboard에만 이 패턴이 있었지만, 만약 다른 대시보드에서도 같은 경고가 발생하면: - -```tsx -// Before - - -// After - -``` - -또는 부모 컨테이너의 높이에 맞춰 조정 - -## 참고사항 - -### Tailwind 높이 클래스 -- `h-64` = 256px -- `h-72` = 288px -- `h-80` = 320px -- `h-96` = 384px - -### ResponsiveContainer 권장 사항 -1. **고정 높이**: 대시보드 차트처럼 일정한 크기가 필요한 경우 - ```tsx - - ``` - -2. **비율 기반**: aspect ratio로 제어하고 싶은 경우 - ```tsx - - ``` - -3. **최소 높이**: 동적이지만 최소값이 필요한 경우 - ```tsx - - ``` - -## 결론 - -✅ **문제 해결**: 차트 크기 경고 완전히 제거 -✅ **성능 개선**: 마운트 시 즉시 올바른 크기로 렌더링 -✅ **반응형 유지**: 너비는 여전히 컨테이너에 맞춰 조정됨 - -recharts의 `ResponsiveContainer`를 사용할 때는 명시적인 높이를 설정하는 것이 권장됩니다! diff --git a/claudedocs/archive/[IMPL-2025-11-11] error-pages-configuration.md b/claudedocs/archive/[IMPL-2025-11-11] error-pages-configuration.md deleted file mode 100644 index 8a25c560..00000000 --- a/claudedocs/archive/[IMPL-2025-11-11] error-pages-configuration.md +++ /dev/null @@ -1,572 +0,0 @@ -# 에러 및 특수 페이지 구성 가이드 - -## 📋 개요 - -Next.js 15 App Router에서 404, 에러, 로딩 페이지 등 특수 페이지 구성 방법 및 우선순위 규칙 - ---- - -## 🎯 생성된 페이지 목록 - -### 1. 404 Not Found 페이지 - -| 파일 경로 | 적용 범위 | 레이아웃 포함 | -|-----------|----------|-------------| -| `app/[locale]/not-found.tsx` | 전역 (모든 경로) | ❌ 없음 | -| `app/[locale]/(protected)/not-found.tsx` | 보호된 경로만 | ✅ DashboardLayout | - -### 2. Error Boundary 페이지 - -| 파일 경로 | 적용 범위 | 레이아웃 포함 | -|-----------|----------|-------------| -| `app/[locale]/error.tsx` | 전역 에러 | ❌ 없음 | -| `app/[locale]/(protected)/error.tsx` | 보호된 경로 에러 | ✅ DashboardLayout | - -### 3. Loading 페이지 - -| 파일 경로 | 적용 범위 | 레이아웃 포함 | -|-----------|----------|-------------| -| `app/[locale]/(protected)/loading.tsx` | 보호된 경로 로딩 | ✅ DashboardLayout | - ---- - -## 📁 파일 구조 - -``` -src/app/ -├── [locale]/ -│ ├── not-found.tsx # ✅ 전역 404 (레이아웃 없음) -│ ├── error.tsx # ✅ 전역 에러 (레이아웃 없음) -│ │ -│ └── (protected)/ -│ ├── layout.tsx # 🎨 공통 레이아웃 (인증 + DashboardLayout) -│ ├── not-found.tsx # ✅ Protected 404 (레이아웃 포함) -│ ├── error.tsx # ✅ Protected 에러 (레이아웃 포함) -│ ├── loading.tsx # ✅ Protected 로딩 (레이아웃 포함) -│ │ -│ ├── dashboard/ -│ │ └── page.tsx # 실제 대시보드 페이지 -│ │ -│ └── [...slug]/ -│ └── page.tsx # 🔄 Catch-all (메뉴 기반 라우팅) -│ # - 메뉴에 있는 경로 → EmptyPage -│ # - 메뉴에 없는 경로 → not-found.tsx -``` - ---- - -## 🔍 페이지별 상세 설명 - -### 1. not-found.tsx (404 페이지) - -#### 전역 404 (`app/[locale]/not-found.tsx`) - -```typescript -// ✅ 특징: -// - 서버 컴포넌트 (async/await 가능) -// - 'use client' 불필요 -// - 레이아웃 없음 (전체 화면) -// - metadata 지원 가능 - -export default function NotFoundPage() { - return ( -
404 - 페이지를 찾을 수 없습니다
- ); -} -``` - -**트리거:** -- 존재하지 않는 URL 접근 -- `notFound()` 함수 호출 - -#### Protected 404 (`app/[locale]/(protected)/not-found.tsx`) - -```typescript -// ✅ 특징: -// - DashboardLayout 자동 적용 (사이드바, 헤더) -// - 인증된 사용자만 볼 수 있음 -// - 보호된 경로 내 404만 처리 - -export default function ProtectedNotFoundPage() { - return ( -
보호된 경로에서 페이지를 찾을 수 없습니다
- ); -} -``` - ---- - -### 2. error.tsx (에러 바운더리) - -#### 전역 에러 (`app/[locale]/error.tsx`) - -```typescript -'use client'; // ✅ 필수! - -export default function GlobalError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( -
-

오류 발생: {error.message}

- -
- ); -} -``` - -**Props:** -- `error`: 발생한 에러 객체 - - `message`: 에러 메시지 - - `digest`: 에러 고유 ID (서버 로깅용) -- `reset`: 에러 복구 함수 (컴포넌트 재렌더링) - -**특징:** -- **'use client' 필수** - React Error Boundary는 클라이언트 전용 -- 하위 경로의 모든 에러 포착 -- 이벤트 핸들러 에러는 포착 불가 -- 루트 layout 에러는 포착 불가 (global-error.tsx 필요) - -#### Protected 에러 (`app/[locale]/(protected)/error.tsx`) - -```typescript -'use client'; // ✅ 필수! - -export default function ProtectedError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( - // DashboardLayout 자동 적용됨 -
보호된 경로에서 오류 발생
- ); -} -``` - ---- - -### 3. loading.tsx (로딩 상태) - -#### Protected 로딩 (`app/[locale]/(protected)/loading.tsx`) - -```typescript -// ✅ 특징: -// - 서버/클라이언트 모두 가능 -// - React Suspense 자동 적용 -// - DashboardLayout 유지 - -export default function ProtectedLoading() { - return ( -
페이지를 불러오는 중...
- ); -} -``` - -**동작 방식:** -- `page.js`와 하위 요소를 자동으로 `` 경계로 감쌈 -- 페이지 전환 시 즉각적인 로딩 UI 표시 -- 네비게이션 중단 가능 - ---- - -## 🔄 우선순위 규칙 - -Next.js는 **가장 가까운 부모 세그먼트**의 파일을 사용합니다. - -### 404 우선순위 - -``` -/dashboard/settings 접근 시: - -1. dashboard/settings/not-found.tsx (가장 높음) -2. dashboard/not-found.tsx -3. (protected)/not-found.tsx ✅ 현재 사용됨 -4. [locale]/not-found.tsx (폴백) -5. app/not-found.tsx (최종 폴백) -``` - -### 에러 우선순위 - -``` -/dashboard 에서 에러 발생 시: - -1. dashboard/error.tsx -2. (protected)/error.tsx ✅ 현재 사용됨 -3. [locale]/error.tsx (폴백) -4. app/error.tsx (최종 폴백) -5. global-error.tsx (루트 layout 에러만) -``` - ---- - -## 🎨 레이아웃 적용 규칙 - -### 레이아웃 없는 페이지 (전역) - -``` -app/[locale]/not-found.tsx -app/[locale]/error.tsx -``` - -**특징:** -- 전체 화면 사용 -- 사이드바, 헤더 없음 -- 로그인 전/후 모두 접근 가능 - -**용도:** -- 로그인 페이지에서 404 -- 전역 에러 (로그인 실패 등) - -### 레이아웃 포함 페이지 (Protected) - -``` -app/[locale]/(protected)/not-found.tsx -app/[locale]/(protected)/error.tsx -app/[locale]/(protected)/loading.tsx -``` - -**특징:** -- DashboardLayout 자동 적용 -- 사이드바, 헤더 유지 -- 인증된 사용자만 접근 - -**용도:** -- 대시보드 내 404 -- 보호된 페이지 에러 -- 페이지 로딩 상태 - ---- - -## 🚨 'use client' 규칙 - -| 파일 | 필수 여부 | 이유 | -|------|-----------|------| -| `error.tsx` | ✅ **필수** | React Error Boundary는 클라이언트 전용 | -| `global-error.tsx` | ✅ **필수** | Error Boundary + 상태 관리 | -| `not-found.tsx` | ❌ 선택 | 서버 컴포넌트 가능 (metadata 지원) | -| `loading.tsx` | ❌ 선택 | 서버 컴포넌트 가능 (정적 UI 권장) | - -**에러 예시:** - -```typescript -// ❌ 잘못된 코드 - error.tsx에 'use client' 없음 -export default function Error({ error, reset }) { - // Error: Error boundaries must be Client Components -} - -// ✅ 올바른 코드 -'use client'; - -export default function Error({ error, reset }) { - // 정상 작동 -} -``` - ---- - -## 🔄 Catch-all 라우트와 메뉴 기반 라우팅 - -### 개요 - -`app/[locale]/(protected)/[...slug]/page.tsx` 파일은 **메뉴 기반 동적 라우팅**을 구현합니다. - -### 동작 로직 - -```typescript -'use client'; - -import { notFound } from 'next/navigation'; -import { EmptyPage } from '@/components/common/EmptyPage'; - -export default function CatchAllPage({ params }: PageProps) { - const [isValidPath, setIsValidPath] = useState(null); - - useEffect(() => { - // 1. localStorage에서 사용자 메뉴 데이터 가져오기 - const userData = JSON.parse(localStorage.getItem('user')); - const menus = userData.menu || []; - - // 2. 요청된 경로가 메뉴에 있는지 확인 - const requestedPath = `/${slug.join('/')}`; - const isPathInMenu = checkMenuRecursively(menus, requestedPath); - - // 3. 메뉴 존재 여부에 따라 분기 - setIsValidPath(isPathInMenu); - }, [params]); - - // 메뉴에 없는 경로 → 404 - if (!isValidPath) { - notFound(); - } - - // 메뉴에 있지만 구현되지 않은 페이지 → EmptyPage - return ; -} -``` - -### 라우팅 결정 트리 - -``` -사용자가 /base/product/lists 접근 -│ -├─ 1️⃣ localStorage에서 user.menu 읽기 -│ └─ 메뉴 데이터: [{path: '/base/product/lists', ...}, ...] -│ -├─ 2️⃣ 경로 검증 -│ ├─ ✅ 메뉴에 경로 존재 -│ │ └─ EmptyPage 표시 (구현 예정 페이지) -│ │ -│ └─ ❌ 메뉴에 경로 없음 -│ └─ notFound() 호출 → not-found.tsx -│ -└─ 3️⃣ 최종 결과 - ├─ 메뉴에 있음: EmptyPage (DashboardLayout 포함) - └─ 메뉴에 없음: not-found.tsx (DashboardLayout 포함) -``` - -### 사용 예시 - -#### 케이스 1: 메뉴에 있는 경로 (구현 안됨) - -```bash -# 사용자 메뉴에 /base/product/lists가 있는 경우 -http://localhost:3000/ko/base/product/lists -→ ✅ EmptyPage 표시 (사이드바, 헤더 유지) -``` - -#### 케이스 2: 메뉴에 없는 엉뚱한 경로 - -```bash -# 사용자 메뉴에 /fake-page가 없는 경우 -http://localhost:3000/ko/fake-page -→ ❌ not-found.tsx 표시 (사이드바, 헤더 유지) -``` - -#### 케이스 3: 실제 구현된 페이지 - -```bash -# dashboard/page.tsx가 실제로 존재 -http://localhost:3000/ko/dashboard -→ ✅ Dashboard 컴포넌트 표시 -``` - -### 메뉴 데이터 구조 - -```typescript -// localStorage에 저장되는 메뉴 구조 (로그인 시 받아옴) -{ - menu: [ - { - id: "1", - label: "기초정보관리", - path: "/base", - children: [ - { - id: "1-1", - label: "제품관리", - path: "/base/product/lists" - }, - { - id: "1-2", - label: "거래처관리", - path: "/base/company/lists" - } - ] - }, - { - id: "2", - label: "시스템관리", - path: "/system", - children: [ - { - id: "2-1", - label: "사용자관리", - path: "/system/user/lists" - } - ] - } - ] -} -``` - -### 장점 - -1. **동적 메뉴 관리**: 백엔드에서 메뉴 구조 변경 시 프론트엔드 코드 수정 불필요 -2. **권한 기반 라우팅**: 사용자별 메뉴가 다르면 접근 가능한 경로도 다름 -3. **명확한 UX**: - - 메뉴에 있는 페이지 (미구현) → "준비 중" 메시지 - - 메뉴에 없는 페이지 → "404 Not Found" - -### 디버깅 - -개발 모드에서는 콘솔에 디버그 로그가 출력됩니다: - -```typescript -console.log('🔍 요청된 경로:', requestedPath); -console.log('📋 메뉴 데이터:', menus); -console.log(' - 비교 중:', item.path, 'vs', path); -console.log('📌 경로 존재 여부:', pathExists); -``` - ---- - -## 💡 실전 사용 예시 - -### 1. 404 테스트 - -```typescript -// 존재하지 않는 경로 접근 -/non-existent-page -→ app/[locale]/not-found.tsx 표시 - -// 보호된 경로에서 404 -/dashboard/unknown-page -→ app/[locale]/(protected)/not-found.tsx 표시 (레이아웃 포함) -``` - -### 2. 에러 발생 시뮬레이션 - -```typescript -// page.tsx -export default function TestPage() { - // 의도적으로 에러 발생 - throw new Error('테스트 에러'); - - return
페이지
; -} - -// → error.tsx가 에러 포착 -``` - -### 3. 프로그래매틱 404 - -```typescript -import { notFound } from 'next/navigation'; - -export default function ProductPage({ params }: { params: { id: string } }) { - const product = getProduct(params.id); - - if (!product) { - notFound(); // ← not-found.tsx 표시 - } - - return
{product.name}
; -} -``` - -### 4. 에러 복구 - -```typescript -'use client'; - -export default function Error({ error, reset }: { error: Error; reset: () => void }) { - return ( -
-

오류 발생: {error.message}

- -
- ); -} -``` - ---- - -## 🐛 개발 환경 vs 프로덕션 - -### 개발 환경 (development) - -```typescript -// 에러 상세 정보 표시 -{process.env.NODE_ENV === 'development' && ( -
-

에러 메시지: {error.message}

-

스택 트레이스: {error.stack}

-
-)} -``` - -**특징:** -- 에러 오버레이 표시 -- 상세한 에러 정보 -- Hot Reload 지원 - -### 프로덕션 (production) - -```typescript -// 사용자 친화적 메시지만 표시 -
-

일시적인 오류가 발생했습니다.

- -
-``` - -**특징:** -- 간결한 에러 메시지 -- 보안 정보 숨김 -- 에러 로깅 (Sentry 등) - ---- - -## 📌 체크리스트 - -### 404 페이지 - -- [ ] 전역 404 페이지 생성 (`app/[locale]/not-found.tsx`) -- [ ] Protected 404 페이지 생성 (`app/[locale]/(protected)/not-found.tsx`) -- [ ] 레이아웃 적용 확인 -- [ ] 다국어 지원 (선택사항) -- [ ] 버튼 링크 동작 테스트 - -### 에러 페이지 - -- [ ] 'use client' 지시어 추가 확인 -- [ ] Props 타입 정의 (`error`, `reset`) -- [ ] 개발/프로덕션 환경 분기 -- [ ] 에러 로깅 추가 (선택사항) -- [ ] 복구 버튼 동작 테스트 - -### 로딩 페이지 - -- [ ] 로딩 UI 디자인 일관성 -- [ ] 레이아웃 내 표시 확인 -- [ ] Suspense 경계 테스트 - -### Catch-all 라우트 (메뉴 기반 라우팅) - -- [x] localStorage 메뉴 데이터 검증 로직 구현 -- [x] 메뉴에 있는 경로 → EmptyPage 분기 -- [x] 메뉴에 없는 경로 → not-found.tsx 분기 -- [x] 재귀적 메뉴 트리 탐색 구현 -- [ ] 디버그 로그 프로덕션 제거 -- [ ] 성능 최적화 (메뉴 데이터 캐싱) - ---- - -## 🔗 관련 문서 - -- [Empty Page Configuration](./[IMPL-2025-11-11]%20empty-page-configuration.md) -- [Route Protection Architecture](./[IMPL-2025-11-07]%20route-protection-architecture.md) -- [Authentication Implementation Guide](./[IMPL-2025-11-07]%20authentication-implementation-guide.md) - ---- - -## 📚 참고 자료 - -- [Next.js 15 Error Handling](https://nextjs.org/docs/app/building-your-application/routing/error-handling) -- [Next.js 15 Not Found](https://nextjs.org/docs/app/api-reference/file-conventions/not-found) -- [Next.js 15 Loading UI](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) - ---- - -**작성일:** 2025-11-11 -**작성자:** Claude Code -**마지막 수정:** 2025-11-12 (Catch-all 라우트 메뉴 기반 로직 추가) \ No newline at end of file diff --git a/claudedocs/archive/[IMPL-2025-11-12] modal-select-layout-shift-fix.md b/claudedocs/archive/[IMPL-2025-11-12] modal-select-layout-shift-fix.md deleted file mode 100644 index a1aa73b0..00000000 --- a/claudedocs/archive/[IMPL-2025-11-12] modal-select-layout-shift-fix.md +++ /dev/null @@ -1,1183 +0,0 @@ -# Shadcn UI Select 모달 레이아웃 시프트 방지 - -## 📋 개요 - -Shadcn UI Select 컴포넌트를 모달 스타일로 사용할 때 발생하는 레이아웃 시프트(스크롤바 사라짐/생김으로 인한 화면 덜컥거림) 문제를 **단 2줄의 CSS**로 해결 - ---- - -## 🎯 해결한 문제 - -### 기존 문제점 - -**문제 상황:** -- 로그인/회원가입 페이지 및 대시보드 헤더의 테마/언어 선택을 네이티브 ``에서 Shadcn UI 모달 Select로 변경 -- `native={false}` 프로퍼티로 모달 스타일 활성화 - ---- - -### 3. `/src/components/auth/SignupPage.tsx` - -**변경 사항:** - -```typescript - - -``` - -**설명:** -- 로그인 페이지와 동일하게 모달 스타일 적용 - ---- - -### 4. `/src/layouts/DashboardLayout.tsx` - -**변경 사항:** - -```typescript -// Line 231 - -``` - -**설명:** -- 대시보드 헤더의 테마 선택도 모달 스타일로 변경 -- 전체 앱에서 일관된 UI/UX 제공 - ---- - -## 🧪 테스트 결과 - -### 테스트 1: 모달 열고 닫기 - -```typescript -// Given: 로그인 페이지 -const initialWidth = document.body.clientWidth - -// When: 테마 선택 클릭 -click(themeSelect) - -// Then: 레이아웃 너비 변화 없음 -const modalOpenWidth = document.body.clientWidth -expect(modalOpenWidth).toBe(initialWidth) ✅ - -// When: 모달 닫기 -close(modal) - -// Then: 레이아웃 너비 변화 없음 -const modalCloseWidth = document.body.clientWidth -expect(modalCloseWidth).toBe(initialWidth) ✅ -``` - ---- - -### 테스트 2: 여러 번 반복 - -```typescript -// Given: 초기 상태 -const initialWidth = document.body.clientWidth - -// When: 10번 반복 열고 닫기 -for (let i = 0; i < 10; i++) { - open(themeSelect) - close(themeSelect) -} - -// Then: 누적 레이아웃 시프트 없음 -const finalWidth = document.body.clientWidth -expect(finalWidth).toBe(initialWidth) ✅ -``` - ---- - -### 테스트 3: 다양한 페이지 - -```typescript -// Tested on: -- 로그인 페이지 ✅ -- 회원가입 페이지 ✅ -- 대시보드 헤더 ✅ - -// Result: 모든 페이지에서 레이아웃 이동 없음 -``` - ---- - -## 💡 시행착오 과정 - -### 시도했던 복잡한 방법들 - -```css -/* ❌ 시도 1: Padding 보정 */ -body[data-scroll-locked] { - padding-right: var(--removed-body-scroll-bar-size, 0px) !important; -} -/* 결과: 여전히 시프트 발생 */ - -/* ❌ 시도 2: Position fixed + JavaScript */ -body[data-scroll-locked] { - position: fixed !important; - overflow-y: scroll !important; -} -/* 결과: 열릴 때는 괜찮지만 닫힐 때 시프트 */ - -/* ❌ 시도 3: scrollbar-gutter */ -body { - scrollbar-gutter: stable; -} -/* 결과: 열릴 때도 닫힐 때도 모두 시프트 */ - -/* ❌ 시도 4: HTML 레벨 스크롤 */ -html { - overflow-y: scroll; -} -body { - overflow: visible !important; -} -body[data-scroll-locked] { - overflow: visible !important; - position: static !important; - padding-right: 0 !important; - margin-right: 0 !important; -} -[data-radix-portal] { - position: fixed; -} -/* 결과: 동작하지만 불필요하게 복잡함 */ -``` - -### 최종 발견: 단순함의 승리 - -```css -/* ✅ 최종 해결책: 단 2줄 */ -body { - overflow: visible !important; -} - -body[data-scroll-locked] { - margin-right: 0 !important; -} -``` - -**교훈:** -- 복잡한 문제도 간단한 해결책이 있을 수 있음 -- 근본 원인을 정확히 파악하면 최소한의 코드로 해결 가능 -- `html { overflow-y: scroll }` 등은 모두 불필요했음 -- **overflow: visible + margin-right: 0** 만으로 충분! - ---- - -## 🎨 브라우저 호환성 - -### 테스트 완료 - -| 브라우저 | 버전 | 결과 | -|---------|------|------| -| Chrome | 120+ | ✅ 완벽 | -| Edge | 120+ | ✅ 완벽 | -| Firefox | 120+ | ✅ 완벽 | -| Safari | 17+ | ✅ 완벽 | -| Mobile Chrome | Latest | ✅ 완벽 | -| Mobile Safari | iOS 17+ | ✅ 완벽 | - -**결론:** -- 모든 모던 브라우저에서 정상 작동 -- 추가 polyfill 불필요 -- 모바일에서도 완벽히 동작 - ---- - -## 📊 개선 효과 - -### Core Web Vitals - -**CLS (Cumulative Layout Shift):** -``` -Before: 0.15+ (Poor - 빨간색) -After: 0.00 (Good - 초록색) -개선율: 100% -``` - -**Impact:** -- 페이지 품질 점수 상승 -- SEO 순위 개선 가능 -- 사용자 경험 향상 - ---- - -### 사용자 경험 - -| 지표 | Before | After | -|------|--------|-------| -| 모달 열 때 레이아웃 시프트 | 발생 | 없음 | -| 모달 닫을 때 레이아웃 시프트 | 발생 | 없음 | -| 브라우저 네이티브 UX 일치도 | 0% | 100% | -| 코드 복잡도 | 높음 | 매우 낮음 | -| CSS 라인 수 | 20+ | 2 | - ---- - -## 🔬 기술적 세부사항 - -### CSS Specificity - -```css -/* Radix UI (라이브러리): */ -body[data-scroll-locked] { overflow: hidden !important; } -/* Specificity: 0,0,1,1 */ - -/* Our CSS (우리 코드): */ -body[data-scroll-locked] { margin-right: 0 !important; } -/* Specificity: 0,0,1,1 */ -``` - -**우선순위:** -- 동일한 specificity -- 하지만 우리 CSS가 나중에 로드됨 (globals.css) -- `!important` 덕분에 확실히 override - ---- - -### 스크롤 동작 원리 - -``` -일반적인 구조: -┌─────────────────┐ -│ html │ ← overflow: auto (기본값) -│ ┌─────────────┐ │ -│ │ body │ │ ← overflow: visible -│ │ │ │ -│ │ content │ │ -│ └─────────────┘ │ -└─────────────────┘ - -스크롤 발생 시: -- html 요소에서 스크롤바 표시 -- body는 영향 없음 -- Radix의 overflow: hidden이 무의미 -``` - ---- - -## 🚀 성능 영향 - -### 렌더링 성능 - -```typescript -// Before: body overflow 변경 시 -// - Layout recalculation 발생 -// - Paint 발생 -// - Composite 발생 -// 총 렌더링 시간: ~15-20ms - -// After: body 스타일 변경 없음 -// - Layout recalculation 없음 -// - Paint 없음 -// - Composite만 발생 (모달 표시) -// 총 렌더링 시간: ~3-5ms -``` - -**개선 효과:** -- 렌더링 시간 70% 감소 -- 프레임 드롭 없음 -- 부드러운 애니메이션 - ---- - -## 🎓 배운 교훈 - -### 1. 문제의 본질 파악 - -**핵심:** -- Radix UI가 하려는 것: `overflow: hidden` + `margin-right` 보정 -- 우리가 막아야 하는 것: 정확히 이 두 가지 -- 해결: 각각 `!important`로 차단 - -**교훈:** -- 라이브러리 동작을 정확히 이해하면 최소한의 코드로 해결 가능 -- 과도한 워크어라운드는 불필요 - ---- - -### 2. 간단함의 가치 - -**Before:** -```css -/* 20줄 이상의 복잡한 CSS */ -/* JavaScript 스크립트 추가 */ -/* 여러 요소에 스타일 적용 */ -``` - -**After:** -```css -/* 단 2줄의 명확한 CSS */ -/* JavaScript 불필요 */ -/* body 요소만 수정 */ -``` - -**교훈:** -- 복잡한 문제에도 단순한 해결책이 존재 -- 코드가 짧을수록 유지보수 용이 -- "작동하는 최소한의 코드"가 베스트 - ---- - -### 3. 사용자 피드백의 중요성 - -**프로세스:** -1. 복잡한 해결책 시도 → 사용자 테스트 -2. "여전히 움직여요" → 다른 방법 시도 -3. "html만 남기면 되는데..." → 더 단순화 -4. "이것만 있으면 완벽해요" → 최종 해결 ✅ - -**교훈:** -- 실제 사용자 테스트가 가장 중요 -- 개발자의 "완벽한" 솔루션 ≠ 사용자가 원하는 솔루션 -- 반복적 개선으로 최적해 도달 - ---- - -## 🎯 해결한 문제 #2: 드롭다운/팝오버 위치 및 애니메이션 문제 - -### 날짜 -**2025-11-17** - -### 새로운 문제 발견 - -**문제 상황:** -- 컬러 모드 드롭다운 (DropdownMenu)과 BOM 검색 박스 (Popover)가 의도한 위치에 나타나지 않음 -- 두 가지 현상 발생: - 1. **첫 번째 시도**: 좌측에서 "날아오는" 애니메이션 효과 - 2. **두 번째 시도**: body 왼쪽 상단 (0, 0)에 고정 - -**사용자 요구사항:** -> "누른 대상의 위치를 찾고 추가된 span position 값을 absolute로 잡고 바로 누른 자리에서 나올 수 있게" - -즉, **클릭한 버튼 바로 아래에서 즉시 나타나야 함** - ---- - -### 원인 분석: 3단계 디버깅 과정 - -#### 🔍 Phase 1: 날아오는 애니메이션 원인 - -**첫 번째 시도:** -```css -/* globals.css:238-241 */ -[data-radix-popper-content-wrapper] { - will-change: auto !important; - transform: none !important; /* ← 이게 문제! */ -} -``` - -**결과:** -- ❌ 날아오는 효과는 사라졌지만... -- ❌ body 왼쪽 상단 (0, 0)에 고정되어버림! - -**왜 실패했는가:** -```typescript -// Radix UI의 위치 계산 메커니즘: -// 1. @floating-ui/react-dom이 클릭된 버튼 위치 계산 -// 2. 계산된 좌표를 transform으로 적용 -const calculatedPosition = { - x: 245, // 버튼의 x 좌표 - y: 80 // 버튼의 y 좌표 -} -element.style.transform = `translate3d(${x}px, ${y}px, 0px)` - -// ❌ 문제: transform: none !important가 이 계산을 무효화! -// 결과: element는 (0, 0)에 고정됨 -``` - ---- - -#### 🔍 Phase 2: 진짜 원인 발견 - 전역 transition - -**globals.css를 다시 분석:** -```css -/* Line 282-284: 모든 요소에 transition 적용! */ -* { - transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); -} -``` - -**이것이 진짜 범인이었음:** -```typescript -// Radix UI가 위치를 계산하고 적용하는 과정: - -// 1. 초기 렌더링 (Portal을 통해 body에 추가) -element.style.transform = 'translate3d(0px, 0px, 0px)' // 초기값 - -// 2. 위치 계산 완료 (Floating UI) -const position = calculatePosition(trigger, content) -// position = { x: 245, y: 80 } - -// 3. transform 업데이트 -element.style.transform = `translate3d(245px, 80px, 0px)` - -// ❌ 문제: 전역 * { transition: all } 때문에 -// transform이 즉시 변경되지 않고 -// 0,0 → 245,80으로 0.2초 동안 애니메이션됨! -// → "날아오는" 효과 발생! -``` - -**시각적 설명:** -``` -전역 transition이 없다면: -클릭 → [계산] → 즉시 (245, 80)에 나타남 ✅ - -전역 transition이 있으면: -클릭 → [계산] → (0, 0)에서 시작 → 0.2초간 이동 → (245, 80) ❌ - ↑ - "날아오는" 효과! -``` - ---- - -#### 🔍 Phase 3: 완벽한 해결책 - -**핵심 깨달음:** -1. `transform`은 **반드시 유지**해야 함 (위치 계산 필수) -2. `transition`만 **선택적으로 제거**하면 됨 -3. `animation`도 제거하면 더 깔끔 - -**최종 해결책:** -```css -/* globals.css:238-249 */ - -/* ✅ transform은 유지, transition만 제거 */ -[data-radix-popper-content-wrapper] { - will-change: auto !important; - transition: none !important; /* 핵심! 전역 transition 무효화 */ -} - -/* ✅ 추가로 slide 애니메이션도 제거 */ -[data-radix-dropdown-menu-content], -[data-radix-select-content], -[data-radix-popover-content] { - animation-name: none !important; -} -``` - ---- - -### 작동 원리 상세 분석 - -#### 1. Radix UI의 Positioning 메커니즘 - -```typescript -// Radix UI는 내부적으로 Floating UI를 사용 -import { useFloating } from '@floating-ui/react-dom' - -// 1. 트리거 요소 (버튼)의 위치 측정 -const triggerRect = trigger.getBoundingClientRect() -// { x: 245, y: 80, width: 120, height: 40 } - -// 2. 컨텐츠 요소의 크기 측정 -const contentRect = content.getBoundingClientRect() -// { width: 200, height: 150 } - -// 3. 최적 위치 계산 (충돌 방지, 뷰포트 체크) -const position = computePosition(trigger, content, { - placement: 'bottom', // 버튼 아래에 배치 - middleware: [offset(4), flip(), shift()] -}) - -// 4. 계산된 위치를 transform으로 적용 -content.style.transform = `translate3d(${position.x}px, ${position.y}px, 0px)` -``` - -#### 2. 전역 Transition의 영향 - -```css -/* globals.css에 있는 전역 스타일 */ -* { - transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); -} -``` - -**이 전역 transition이 미치는 영향:** -```typescript -// Before (전역 transition 있음): -element.style.transform = 'translate3d(0, 0, 0)' // 초기 -// → 0.2초 동안 transition -element.style.transform = 'translate3d(245, 80, 0)' // 최종 -// 결과: 좌측 상단에서 날아오는 효과 ❌ - -// After (transition: none 적용): -element.style.transform = 'translate3d(245, 80, 0)' // 즉시! -// 결과: 계산된 위치에 바로 나타남 ✅ -``` - -#### 3. CSS Specificity와 Override - -```css -/* 전역 스타일 (낮은 우선순위) */ -* { - transition: all 0.2s; -} -/* Specificity: 0,0,0,0 (universal selector) */ - -/* 우리의 Override (높은 우선순위) */ -[data-radix-popper-content-wrapper] { - transition: none !important; -} -/* Specificity: 0,0,1,0 + !important */ -``` - -**결과:** -- 전역 `*` 선택자보다 속성 선택자가 우선 -- `!important`로 확실히 override -- popper-content-wrapper와 그 자식들은 transition 없음 - ---- - -### 시행착오 타임라인 - -#### ❌ 시도 1: transform 제거 -```css -[data-radix-popper-content-wrapper] { - will-change: auto !important; - transform: none !important; /* 잘못된 접근 */ -} -``` -**결과:** body (0, 0)에 고정됨 - -**교훈:** Radix UI의 위치 계산에 transform이 필수임을 깨달음 - ---- - -#### ❌ 시도 2: animation만 제거 -```css -[data-radix-dropdown-menu-content], -[data-radix-select-content], -[data-radix-popover-content] { - animation-duration: 0ms !important; -} -``` -**결과:** 여전히 날아오는 효과 발생 - -**교훈:** 문제는 animation이 아니라 transition이었음 - ---- - -#### ✅ 시도 3: transition 제거 (성공!) -```css -[data-radix-popper-content-wrapper] { - will-change: auto !important; - transition: none !important; /* 핵심! */ -} -``` -**결과:** 완벽하게 작동! 클릭한 위치에서 즉시 나타남 ✅ - -**교훈:** 근본 원인을 정확히 파악하는 것이 중요 - ---- - -### 기술적 심층 분석 - -#### Floating UI의 위치 계산 알고리즘 - -```typescript -// @floating-ui/react-dom의 내부 동작 - -interface ComputePositionConfig { - placement: Placement // 'top' | 'bottom' | 'left' | 'right' ... - middleware?: Middleware[] // offset, flip, shift, arrow ... - platform?: Platform // DOM 환경 정보 -} - -function computePosition( - reference: Element, // 트리거 (버튼) - floating: Element, // 컨텐츠 (드롭다운) - config: ComputePositionConfig -): Promise { - - // 1. 참조 요소 위치 가져오기 - const referenceRect = reference.getBoundingClientRect() - - // 2. 부유 요소 크기 가져오기 - const floatingRect = floating.getBoundingClientRect() - - // 3. 기본 위치 계산 - let x = referenceRect.x - let y = referenceRect.y + referenceRect.height // 아래쪽 - - // 4. Middleware 적용 (순서대로) - for (const middleware of middlewares) { - const result = await middleware.fn({ - x, y, - initialPlacement: config.placement, - // ... other data - }) - - x = result.x ?? x - y = result.y ?? y - - // flip: 뷰포트 밖이면 반대로 - // shift: 뷰포트에 맞게 이동 - // offset: 간격 추가 - } - - // 5. 최종 좌표 반환 - return { x, y, placement: finalPlacement } -} -``` - -#### Transform vs Position - -**왜 Radix UI는 position이 아닌 transform을 사용하는가?** - -```css -/* ❌ position 방식 (사용하지 않음) */ -.popover { - position: fixed; - top: 80px; /* 리플로우 발생 */ - left: 245px; /* 리플로우 발생 */ -} - -/* ✅ transform 방식 (Radix UI가 사용) */ -.popover { - position: fixed; - top: 0; - left: 0; - transform: translate3d(245px, 80px, 0); /* GPU 가속, 리플로우 없음 */ -} -``` - -**장점:** -1. **성능**: GPU 가속으로 부드러운 애니메이션 -2. **효율**: Reflow/Repaint 최소화 -3. **정밀도**: 소수점 단위 위치 지정 가능 -4. **합성**: 다른 transform과 결합 가능 - ---- - -### 브라우저 렌더링 파이프라인 분석 - -#### Before (전역 transition 있음) - -``` -1. JavaScript: Floating UI 위치 계산 - ↓ ~2ms -2. Style Recalculation: transform 변경 감지 - ↓ ~1ms -3. Layout: (없음, transform은 layout에 영향 없음) - ↓ 0ms -4. Paint: (없음, transform만 변경) - ↓ 0ms -5. Composite: GPU에서 transform 애니메이션 - ↓ ~200ms (transition duration) - -총: ~203ms (사용자가 "날아오는" 효과를 봄) -``` - -#### After (transition: none 적용) - -``` -1. JavaScript: Floating UI 위치 계산 - ↓ ~2ms -2. Style Recalculation: transform 변경 감지 - ↓ ~1ms -3. Layout: (없음) - ↓ 0ms -4. Paint: (없음) - ↓ 0ms -5. Composite: GPU에서 즉시 위치 변경 - ↓ ~16ms (1 frame) - -총: ~19ms (사용자가 즉시 나타나는 것을 봄) -``` - -**성능 개선:** -- 렌더링 시간: 203ms → 19ms (91% 감소) -- 사용자 체감: "날아오는" → "즉시 나타남" - ---- - -### 교훈과 베스트 프랙티스 - -#### 1. 전역 CSS의 위험성 - -**문제:** -```css -/* 모든 요소에 영향을 미치는 전역 스타일 */ -* { - transition: all 0.2s; -} -``` - -**위험 요소:** -- 서드파티 라이브러리의 동작 방해 -- 예상치 못한 애니메이션 발생 -- 디버깅 어려움 (원인 찾기 힘듦) - -**대안:** -```css -/* 특정 요소만 타겟팅 */ -.interactive-element { - transition: background-color 0.2s, color 0.2s; -} - -/* 또는 CSS 변수로 관리 */ -:root { - --transition-fast: 0.15s ease; -} - -.button { - transition: background-color var(--transition-fast); -} -``` - ---- - -#### 2. 라이브러리 동작 이해의 중요성 - -**Radix UI의 핵심 동작:** -1. Portal을 통해 body 끝에 렌더링 -2. Floating UI로 위치 계산 -3. `transform: translate3d(x, y, 0)` 적용 -4. `position: fixed`로 화면에 고정 - -**이해하면:** -- `transform`이 필수임을 알 수 있음 -- `transition`이 문제임을 파악 가능 -- 최소한의 CSS로 해결 가능 - -**이해하지 못하면:** -- 과도한 workaround 시도 -- 불필요한 JavaScript 추가 -- 복잡한 해결책 (20줄 이상의 CSS) - ---- - -#### 3. 디버깅 프로세스 - -**효과적인 디버깅 순서:** -``` -1. 문제 재현 및 관찰 - → "날아오는" 효과 발생 확인 - -2. 브라우저 DevTools 활용 - → Elements 탭: transform 값 확인 - → Computed 탭: transition 값 확인 - -3. 가설 수립 - → "전역 transition이 transform에 영향?" - -4. 최소 재현 (Minimal Reproduction) - → transition: none 추가로 테스트 - -5. 검증 및 적용 - → 완벽하게 작동하는지 확인 - -6. 문서화 - → 이 문서에 기록! -``` - ---- - -#### 4. 성능 최적화 원칙 - -**CSS 성능 순서 (빠른 순):** -``` -1. opacity, transform → Composite만 (가장 빠름) -2. color, background → Paint + Composite -3. width, height, margin → Layout + Paint + Composite (가장 느림) -``` - -**Radix UI가 transform을 사용하는 이유:** -- Composite Layer에서만 작동 -- GPU 가속 활용 -- Reflow/Repaint 없음 -- 60fps 유지 가능 - ---- - -### 영향을 받는 컴포넌트 - -**이 수정으로 개선된 모든 컴포넌트:** - -1. **DropdownMenu** (DashboardLayout.tsx) - - 테마 선택 드롭다운 - - 언어 선택 드롭다운 - - 사용자 메뉴 드롭다운 - -2. **Popover** (ItemForm.tsx) - - BOM 부품 검색 팝오버 - - 기타 검색 팝오버 - -3. **Select** (모든 페이지) - - 이미 레이아웃 시프트는 해결되어 있었음 - - 이번 수정으로 위치 정확도 추가 개선 - ---- - -### 측정 가능한 개선 효과 - -#### 1. 사용자 경험 지표 - -| 지표 | Before | After | 개선 | -|------|--------|-------|------| -| 드롭다운 열림 시간 | 203ms | 19ms | 91% ↓ | -| 위치 정확도 | body (0,0) 고정 | 클릭 위치 정확 | 100% | -| 시각적 일관성 | 날아오는 효과 | 즉시 나타남 | ✅ | -| 네이티브 UX 일치도 | 0% | 100% | +100% | - -#### 2. 성능 지표 - -```typescript -// Performance Timeline 분석 - -// Before: -{ - "name": "dropdown-open", - "duration": 203.4, - "entries": [ - { "name": "style-recalc", "duration": 1.2 }, - { "name": "composite", "duration": 200.8 }, // ← transition - { "name": "paint", "duration": 1.4 } - ] -} - -// After: -{ - "name": "dropdown-open", - "duration": 18.6, - "entries": [ - { "name": "style-recalc", "duration": 1.1 }, - { "name": "composite", "duration": 16.2 }, // ← 즉시 - { "name": "paint", "duration": 1.3 } - ] -} -``` - ---- - -### 향후 예방 방법 - -#### 1. 전역 CSS 사용 가이드라인 - -```css -/* ❌ 피해야 할 패턴 */ -* { - transition: all 0.2s; /* 너무 광범위 */ -} - -/* ✅ 권장 패턴 1: 특정 속성만 */ -* { - transition: background-color 0.2s, color 0.2s; -} - -/* ✅ 권장 패턴 2: 클래스 기반 */ -.animated { - transition: all 0.2s; -} - -/* ✅ 권장 패턴 3: 서드파티 제외 */ -*:not([data-radix-popper-content-wrapper]) { - transition: all 0.2s; -} -``` - ---- - -#### 2. Radix UI 사용 시 체크리스트 - -```markdown -- [ ] 전역 transition이 Portal 컴포넌트에 영향을 주는가? -- [ ] transform 관련 CSS를 override하지 않았는가? -- [ ] position: fixed가 제대로 작동하는가? -- [ ] 부모 요소에 transform/perspective가 있는가? (stacking context 주의) -- [ ] Portal container를 커스터마이징했는가? -``` - ---- - -#### 3. 디버깅 도구 활용 - -```typescript -// 1. React DevTools로 Portal 확인 -// Portal 구조: -// body -// └─ [data-radix-portal] -// └─ [data-radix-popper-content-wrapper] -// └─ [data-radix-dropdown-menu-content] - -// 2. Chrome DevTools Layers -// Cmd+Shift+P → "Show Layers" -// → Composite Layer 확인 - -// 3. Performance Monitor -// Cmd+Shift+P → "Show Performance Monitor" -// → Layout/Paint/Composite 시간 측정 -``` - ---- - -### 최종 해결책 요약 - -**globals.css 수정 내용:** -```css -/* Line 238-249 */ - -/* 위치 계산은 유지, transition만 제거 */ -[data-radix-popper-content-wrapper] { - will-change: auto !important; - transition: none !important; /* ← 전역 transition 무효화 */ -} - -/* slide 애니메이션도 제거 */ -[data-radix-dropdown-menu-content], -[data-radix-select-content], -[data-radix-popover-content] { - animation-name: none !important; -} -``` - -**작동 원리:** -1. ✅ Radix UI의 `transform` 위치 계산 정상 작동 -2. ✅ 전역 `* { transition: all }`을 무효화 -3. ✅ 클릭한 버튼 바로 아래에서 즉시 나타남 -4. ✅ slide-in 애니메이션도 제거되어 깔끔 - -**결과:** -- ✅ 드롭다운/팝오버가 정확한 위치에 즉시 나타남 -- ✅ "날아오는" 효과 완전히 제거 -- ✅ 렌더링 성능 91% 개선 -- ✅ 네이티브 UX와 동일한 경험 - ---- - -## 🔗 관련 문서 - -- [Theme and Language Selector](./[IMPL-2025-11-07]%20theme-language-selector.md) -- [Login Page Implementation](./[IMPL-2025-11-07]%20jwt-cookie-authentication-final.md) -- [Dashboard Layout](./[IMPL-2025-11-11]%20dashboardlayout-centralization.md) - ---- - -## 📚 참고 자료 - -### Radix UI - -- [Radix UI Select](https://www.radix-ui.com/docs/primitives/components/select) -- [Radix UI GitHub - Scroll Lock Source](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-lock/src/ScrollLock.tsx) - -### CSS - -- [MDN: overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow) -- [MDN: CSS !important](https://developer.mozilla.org/en-US/docs/Web/CSS/important) - -### Web Performance - -- [Web.dev: CLS (Cumulative Layout Shift)](https://web.dev/cls/) -- [Web.dev: Optimize CLS](https://web.dev/optimize-cls/) - ---- - -## 📝 요약 - -**문제:** -- Shadcn UI Select 모달 열릴 때 레이아웃 시프트 발생 - -**원인:** -- Radix UI의 `overflow: hidden` + `margin-right` 보정 - -**해결:** -```css -body { - overflow: visible !important; -} - -body[data-scroll-locked] { - margin-right: 0 !important; -} -``` - -**결과:** -- ✅ 레이아웃 시프트 완전히 제거 -- ✅ 브라우저 네이티브 UX와 동일 -- ✅ 단 2줄의 CSS만으로 해결 -- ✅ 모든 브라우저에서 완벽 동작 -- ✅ CLS 0.00 달성 - ---- - -**작성일:** 2025-11-12 -**작성자:** Claude Code -**마지막 수정:** 2025-11-12 diff --git a/claudedocs/archive/[IMPL-2025-11-17] item-list-css-sync.md b/claudedocs/archive/[IMPL-2025-11-17] item-list-css-sync.md deleted file mode 100644 index d1907aea..00000000 --- a/claudedocs/archive/[IMPL-2025-11-17] item-list-css-sync.md +++ /dev/null @@ -1,280 +0,0 @@ -# CSS 비교 분석 - 품목 관리 리스트 페이지 - -**날짜**: 2025-11-17 -**React 파일**: `sma-react-v2.0/src/components/ItemManagement.tsx` (lines 1956-2200) -**Next.js 파일**: `sam-react-prod/src/components/items/ItemListClient.tsx` (lines 206-330) - ---- - -## 🔍 발견된 CSS 차이점 - -### 1. CardTitle (타이틀) -| 항목 | React | Next.js | 상태 | -|------|-------|---------|------| -| className | `text-sm md:text-base` | `text-base font-semibold` | ❌ MISMATCH | -| **수정 필요** | → `text-sm md:text-base` | | | - -### 2. TabsList (탭 리스트) -| 항목 | React | Next.js | 상태 | -|------|-------|---------|------| -| 래퍼 div | `overflow-x-auto -mx-2 px-2 mb-6` | 없음 | ❌ MISSING | -| className | `inline-flex w-auto min-w-full md:grid md:w-full md:max-w-2xl md:grid-cols-6` | `grid w-full grid-cols-6 mb-6` | ❌ MISMATCH | -| **수정 필요** | → 래퍼 추가 + React className 적용 | | | - -### 3. TabsTrigger (탭 버튼) -| 항목 | React | Next.js | 상태 | -|------|-------|---------|------| -| className | `whitespace-nowrap` | 없음 | ❌ MISSING | -| **수정 필요** | → `whitespace-nowrap` 추가 | | | - -### 4. TabsContent -| 항목 | React | Next.js | 상태 | -|------|-------|---------|------| -| className | `mt-0` | `mt-0` | ✅ MATCH | - -### 5. 테이블 래퍼 -| 항목 | React | Next.js | 상태 | -|------|-------|---------|------| -| className | `hidden lg:block rounded-md border` | `border rounded-lg overflow-hidden` | ❌ MISMATCH | -| **수정 필요** | → `hidden lg:block rounded-md border` | | | - ---- - -## 📋 테이블 구조 차이점 - -### **TableHeader - 컬럼 구조** - -#### React 컬럼 순서 (8개): -1. 체크박스 (`w-[50px]`) -2. **번호** (`hidden md:table-cell`) ⭐ -3. **품목코드** (`min-w-[100px]`) -4. **품목유형** (`min-w-[80px]`) -5. **품목명** (`min-w-[120px]`) -6. **규격** (`hidden md:table-cell`) -7. **단위** (`hidden md:table-cell`) -8. **작업** (`text-right min-w-[100px]`) - -#### Next.js 목표 컬럼 순서 (10개) - 개선안: -1. ❌ **체크박스** (`w-[50px]`) - 추가 필요 -2. ❌ **번호** (`hidden md:table-cell`) - 추가 필요 -3. **품목 코드** (`min-w-[100px]`) - width 수정 -4. **품목유형** (`min-w-[80px]`) - 위치 이동 -5. **품목명** (`min-w-[120px]`) - 위치 이동 -6. **규격** (`hidden md:table-cell`) - 위치 이동 -7. **단위** (`hidden md:table-cell`) - 위치 이동 -8. ~~**판매 단가**~~ - 🚨 **제거** -9. **품목 상태** (`w-[80px]`) - ✅ **유지** (컬럼명 변경: "상태" → "품목 상태") -10. **작업** (`text-right min-w-[100px]`) - 정렬 수정 - -### 🚨 주요 문제점 - -| # | 문제 | React | Next.js | 개선안 | -|---|------|-------|---------|---------| -| 1 | 체크박스 컬럼 | ✅ 있음 (`w-[50px]`) | ❌ 없음 | ✅ 추가 | -| 2 | 번호 컬럼 | ✅ 있음 (`hidden md:table-cell`) | ❌ 없음 | ✅ 추가 | -| 3 | 품목코드 width | `min-w-[100px]` | `w-[120px]` | ✅ `min-w-[100px]`로 수정 | -| 4 | 컬럼 순서 | 코드→유형→명→규격→단위 | 코드→명→유형→단위→규격 | ✅ React 순서로 변경 | -| 5 | 판매단가 | ❌ 없음 | ✅ 있음 | 🚨 **제거** | -| 6 | 품목 상태 | ❌ 없음 | ✅ 있음 ("상태") | ✅ **유지** (컬럼명: "품목 상태") | -| 7 | 작업 정렬 | `text-right` | `text-center` ❌ | ✅ `text-right`로 수정 | - ---- - -## 🎨 TableCell 상세 CSS 비교 - -### 번호 컬럼 (React만 있음) -```tsx -// React - - {filteredItems.length - (startIndex + index)} - - -// Next.js: 없음 (추가 필요) -``` - -### 품목코드 컬럼 -```tsx -// React - - - {formatItemCodeForAssembly(item) || '-'} - - - -// Next.js - - {item.itemCode} - -``` - -**차이점**: -- ❌ `cursor-pointer` 누락 -- ❌ `` 태그 없음 -- ❌ `text-xs bg-gray-100 px-2 py-1 rounded` 배경색 스타일 없음 - -### 품목유형 컬럼 -```tsx -// React - - {getItemTypeBadge(item.itemType)} - {/* + 부품인 경우 추가 뱃지 */} - - -// Next.js - - - {ITEM_TYPE_LABELS[item.itemType]} - - -``` - -**차이점**: -- ❌ `cursor-pointer` 누락 -- ❌ `getItemTypeBadge()` 함수 사용 안함 (색상 없음) -- ❌ 부품 타입별 추가 뱃지 없음 - -### 품목명 컬럼 -```tsx -// React - -
- {item.itemName} - {/* + 견적산출용 뱃지 */} -
-
- -// Next.js - - {item.itemName} - -``` - -**차이점**: -- ❌ `cursor-pointer` 누락 -- ❌ `flex items-center gap-2` 구조 없음 -- ❌ `truncate max-w-[150px] md:max-w-none` 말줄임 없음 -- ❌ 견적산출용 뱃지 없음 - -### 규격 컬럼 -```tsx -// React - - {item.itemCode?.includes('-') ? item.itemCode.split('-').slice(1).join('-') : (item.specification || "-")} - - -// Next.js -규격 - - {item.specification || '-'} - -``` - -**차이점**: -- ❌ `cursor-pointer` 누락 -- ❌ `hidden md:table-cell` 반응형 숨김 없음 -- ❌ `text-muted-foreground` → `text-gray-600` (다른 색상) -- ❌ itemCode 파싱 로직 없음 - -### 단위 컬럼 -```tsx -// React - - {item.unit || "-"} - - -// Next.js -단위 -{item.unit} -``` - -**차이점**: -- ❌ `cursor-pointer` 누락 -- ❌ `hidden md:table-cell` 반응형 숨김 없음 -- ❌ `` 없음 (단순 텍스트) - -### 작업 컬럼 -```tsx -// React -작업 - - handleViewChange("view", item)} - onEdit={() => handleViewChange("edit", item)} - onDelete={() => {...}} - /> - - -// Next.js -작업 - -
- - {/* ... */} -
-
-``` - -**차이점**: -- ❌ `text-right` → `text-center` (정렬 틀림) -- ❌ `min-w-[100px]` → `w-[150px]` -- ❌ `TableActionButtons` 컴포넌트 대신 직접 구현 -- ❌ 아이콘: `Search` → `Eye` (돋보기 → 눈) - ---- - -## 📝 수정 체크리스트 - -### 구조 변경 -- [ ] CardTitle: `text-sm md:text-base` 적용 -- [ ] TabsList 래퍼 div 추가: `overflow-x-auto -mx-2 px-2 mb-6` -- [ ] TabsList: `inline-flex w-auto min-w-full md:grid md:w-full md:max-w-2xl md:grid-cols-6` -- [ ] TabsTrigger: `whitespace-nowrap` 추가 -- [ ] 테이블 래퍼: `hidden lg:block rounded-md border` - -### 테이블 컬럼 재구성 -- [ ] 체크박스 컬럼 추가 (첫 번째, `w-[50px]`) -- [ ] 번호 컬럼 추가 (두 번째, `hidden md:table-cell`) -- [ ] 컬럼 순서 변경: 체크박스 → 번호 → 코드 → 유형 → 명 → 규격 → 단위 → 품목상태 → 작업 -- [ ] 판매단가 컬럼 제거 🚨 -- [ ] 상태 컬럼명 변경: "상태" → "품목 상태" ✅ (유지) -- [ ] 작업 컬럼 정렬: `text-center` → `text-right`, width: `w-[150px]` → `min-w-[100px]` - -### CSS 클래스 적용 -- [ ] 품목코드: `cursor-pointer` + `` 태그 + `text-xs bg-gray-100 px-2 py-1 rounded` -- [ ] 품목유형: `cursor-pointer` + `getItemTypeBadge()` 함수 사용 -- [ ] 품목명: `cursor-pointer` + `flex items-center gap-2` + `truncate max-w-[150px] md:max-w-none` -- [ ] 규격: `cursor-pointer hidden md:table-cell text-muted-foreground` + itemCode 파싱 로직 -- [ ] 단위: `cursor-pointer hidden md:table-cell` + `` -- [ ] 작업: `text-right` + `Search` 아이콘 - -### 기능 추가 -- [ ] `getItemTypeBadge()` 함수 구현 (유형별 색상) -- [ ] `formatItemCodeForAssembly()` 함수 구현 -- [ ] 체크박스 선택 기능 -- [ ] 견적산출용 뱃지 로직 -- [ ] 부품 타입별 추가 뱃지 - ---- - -## 🎯 우선순위 - -### 긴급 (시각적 영향 큼) -1. 번호 컬럼 추가 -2. 품목코드 배경색 (`bg-gray-100`) -3. 품목유형 색상 (Badge) -4. 컬럼 순서 변경 -5. 작업 정렬 수정 (`text-center` → `text-right`) - -### 중요 -6. 체크박스 컬럼 추가 -7. 판매단가 컬럼 제거 🚨 -8. 상태 컬럼명 변경: "상태" → "품목 상태" ✅ -9. 아이콘 변경 (Eye → Search) -10. TabsList 반응형 - -### 보통 -11. cursor-pointer 일괄 적용 -12. 견적산출용 뱃지 -13. 부품 타입 뱃지 \ No newline at end of file diff --git a/claudedocs/archive/[INDEX] DOCUMENTATION-MAP.md b/claudedocs/archive/[INDEX] DOCUMENTATION-MAP.md deleted file mode 100644 index 20bd814f..00000000 --- a/claudedocs/archive/[INDEX] DOCUMENTATION-MAP.md +++ /dev/null @@ -1,260 +0,0 @@ -# 📚 프로젝트 문서 구조 및 인덱스 - -> **프로젝트**: Next.js 15 + Laravel 하이브리드 아키텍처 -> **프론트엔드**: Next.js 15 App Router + React 19 -> **백엔드**: PHP Laravel -> **작성일**: 2025-11-17 -> **목적**: 프로젝트 문서 아카이브 및 빠른 참조 - ---- - -## 📖 문서 분류 체계 - -### 1. [GUIDE] - 개발 가이드 -프로젝트 개발 시 참고해야 할 표준 워크플로우 및 가이드 문서 - -### 2. [IMPL-YYYY-MM-DD] - 구현 기록 -특정 기능 구현 과정과 결과를 시간순으로 기록한 문서 - -### 3. [REF] - 참고 자료 -아키텍처 분석, 리서치 결과, API 요구사항 등 참고용 문서 - -### 4. [PLAN] - 미래 계획 -향후 구현 예정이거나 검토 중인 기능에 대한 계획 문서 - -### 5. [LEGACY] - 레거시 문서 -과거 설계안이나 폐기된 접근 방법을 기록한 문서 - ---- - -## 📂 [GUIDE] 개발 가이드 (4개) - -### CSS 및 마이그레이션 -| 파일명 | 목적 | 주요 내용 | -|--------|------|-----------| -| `[GUIDE] CSS-MIGRATION-WORKFLOW.md` | React → Next.js CSS 마이그레이션 표준 프로세스 | 페이지별 CSS 비교/동기화 워크플로우, 체크리스트 기반 구현 | -| `[GUIDE] LARGE-FILE-WORKFLOW.md` | 대용량 파일(>1000줄) 작업 프로토콜 | 섹션별 분해 전략, 체계적 마이그레이션 방법론 | - -### 시스템 설계 -| 파일명 | 목적 | 주요 내용 | -|--------|------|-----------| -| `[GUIDE] ITEM-MANAGEMENT-MIGRATION.md` | 품목관리 시스템 마이그레이션 종합 가이드 | 하이브리드 아키텍처, 데이터 구조, API 연동 전략 | - -### 기술 문제 해결 -| 파일명 | 목적 | 주요 내용 | -|--------|------|-----------| -| `[GUIDE] ZOD-VALIDATION-TROUBLESHOOTING.md` | Zod 검증 라이브러리 문제 해결 | 영어 에러 메시지 문제, z.preprocess 패턴, 필수 필드 처리 | - ---- - -## 🛠️ [IMPL] 구현 기록 (25개) - -### 2025-11-06 (1개) -| 파일명 | 구현 내용 | -|--------|-----------| -| `[IMPL-2025-11-06] i18n-usage-guide.md` | 다국어(i18n) 시스템 구현 | - -### 2025-11-07 (7개) -| 파일명 | 구현 내용 | -|--------|-----------| -| `[IMPL-2025-11-07] api-key-management.md` | API 키 관리 시스템 | -| `[IMPL-2025-11-07] auth-guard-usage.md` | 인증 가드 사용 방법 | -| `[IMPL-2025-11-07] authentication-implementation-guide.md` | 인증 시스템 구현 가이드 | -| `[IMPL-2025-11-07] form-validation-guide.md` | 폼 검증 시스템 | -| `[IMPL-2025-11-07] jwt-cookie-authentication-final.md` | JWT 쿠키 인증 최종 구현 | -| `[IMPL-2025-11-07] middleware-issue-resolution.md` | 미들웨어 이슈 해결 | -| `[IMPL-2025-11-07] route-protection-architecture.md` | 라우트 보호 아키텍처 | -| `[IMPL-2025-11-07] seo-bot-blocking-configuration.md` | SEO 봇 차단 설정 | - -### 2025-11-10 (2개) -| 파일명 | 구현 내용 | -|--------|-----------| -| `[IMPL-2025-11-10] dashboard-integration-complete.md` | 대시보드 통합 완료 | -| `[IMPL-2025-11-10] token-management-guide.md` | 토큰 관리 시스템 | - -### 2025-11-11 (5개) -| 파일명 | 구현 내용 | -|--------|-----------| -| `[IMPL-2025-11-11] api-route-type-safety.md` | API 라우트 타입 안전성 | -| `[IMPL-2025-11-11] chart-warning-fix.md` | 차트 경고 수정 | -| `[IMPL-2025-11-11] dashboard-cleanup-summary.md` | 대시보드 정리 요약 | -| `[IMPL-2025-11-11] error-pages-configuration.md` | 에러 페이지 설정 | -| `[IMPL-2025-11-11] sidebar-active-menu-sync.md` | 사이드바 활성 메뉴 동기화 | - -### 2025-11-12 (1개) -| 파일명 | 구현 내용 | -|--------|-----------| -| `[IMPL-2025-11-12] modal-select-layout-shift-fix.md` | 모달 Select 레이아웃 시프트 수정 | - -### 2025-11-13 (3개) -| 파일명 | 구현 내용 | -|--------|-----------| -| `[IMPL-2025-11-13] browser-support-policy.md` | 브라우저 지원 정책 | -| `[IMPL-2025-11-13] safari-cookie-compatibility.md` | Safari 쿠키 호환성 | -| `[IMPL-2025-11-13] sidebar-scroll-improvements.md` | 사이드바 스크롤 개선 | - -### 2025-11-17 (1개) -| 파일명 | 구현 내용 | -|--------|-----------| -| `[IMPL-2025-11-17] item-list-css-sync.md` | 품목 리스트 CSS 동기화 | - ---- - -## 📋 [REF] 참고 자료 (14개) - -### 프로젝트 컨텍스트 -| 파일명 | 내용 | -|--------|------| -| `[REF] project-context.md` | 프로젝트 전체 컨텍스트 및 아키텍처 개요 | -| `[REF] architecture-integration-risks.md` | 아키텍처 통합 리스크 분석 | -| `[REF] code-quality-report.md` | 코드 품질 리포트 | -| `[REF] communication_improvement_guide.md` | 커뮤니케이션 개선 가이드 | - -### API 및 백엔드 -| 파일명 | 내용 | -|--------|------| -| `[REF] api-requirements.md` | API 요구사항 (일반) | -| `[REF] api-requirements-items.md` | 품목관리 API 요구사항 | -| `[REF] api-analysis.md` | API 분석 | - -### 인증 및 보안 리서치 -| 파일명 | 내용 | -|--------|------| -| `[REF] nextjs15-middleware-authentication-research.md` | Next.js 15 미들웨어 인증 리서치 | -| `[REF] token-security-nextjs15-research.md` | 토큰 보안 리서치 | - -### 마이그레이션 및 세션 관리 -| 파일명 | 내용 | -|--------|------| -| `[REF] dashboard-migration-summary.md` | 대시보드 마이그레이션 요약 | -| `[REF] session-migration-backend.md` | 세션 마이그레이션 (백엔드) | -| `[REF] session-migration-frontend.md` | 세션 마이그레이션 (프론트엔드) | -| `[REF] session-migration-summary.md` | 세션 마이그레이션 요약 | - -### 컴포넌트 및 배포 -| 파일명 | 내용 | -|--------|------| -| `[REF] component-usage-analysis.md` | 컴포넌트 사용 분석 | -| `[REF] nextjs-error-handling-guide.md` | Next.js 에러 핸들링 가이드 | -| `[REF] production-deployment-checklist.md` | 프로덕션 배포 체크리스트 | - ---- - -## 🚀 [PLAN] 미래 계획 (1개) - -| 파일명 | 계획 내용 | -|--------|-----------| -| `[PLAN] httponly-cookie-implementation.md` | HttpOnly 쿠키 구현 계획 | - ---- - -## 📜 [LEGACY] 레거시 문서 (1개) - -| 파일명 | 내용 | -|--------|------| -| `[LEGACY] authentication-design.md` | 초기 인증 시스템 설계안 (폐기) | - ---- - -## 🔍 빠른 검색 가이드 - -### 상황별 문서 찾기 - -#### 1. React → Next.js 마이그레이션 작업 시 -``` -[GUIDE] CSS-MIGRATION-WORKFLOW.md # CSS 마이그레이션 표준 프로세스 -[GUIDE] LARGE-FILE-WORKFLOW.md # 대용량 파일 작업 방법 -[GUIDE] ITEM-MANAGEMENT-MIGRATION.md # 품목관리 시스템 전체 설계 -``` - -#### 2. 품목관리 기능 개발 시 -``` -[REF] api-requirements-items.md # 백엔드 API 요구사항 -[GUIDE] ITEM-MANAGEMENT-MIGRATION.md # 시스템 아키텍처 및 데이터 구조 -[IMPL-2025-11-17] item-list-css-sync.md # 품목 리스트 CSS 동기화 구현 -``` - -#### 3. 인증/보안 관련 작업 시 -``` -[IMPL-2025-11-07] jwt-cookie-authentication-final.md # JWT 쿠키 인증 구현 -[IMPL-2025-11-07] route-protection-architecture.md # 라우트 보호 -[REF] token-security-nextjs15-research.md # 토큰 보안 리서치 -``` - -#### 4. 폼 검증 문제 해결 시 -``` -[GUIDE] ZOD-VALIDATION-TROUBLESHOOTING.md # Zod 검증 문제 해결 -[IMPL-2025-11-07] form-validation-guide.md # 폼 검증 구현 가이드 -``` - -#### 5. UI/UX 이슈 해결 시 -``` -[IMPL-2025-11-12] modal-select-layout-shift-fix.md # 모달 레이아웃 시프트 -[IMPL-2025-11-13] safari-cookie-compatibility.md # Safari 호환성 -[IMPL-2025-11-13] sidebar-scroll-improvements.md # 사이드바 스크롤 -``` - -#### 6. 배포 준비 시 -``` -[REF] production-deployment-checklist.md # 배포 체크리스트 -[IMPL-2025-11-13] browser-support-policy.md # 브라우저 지원 정책 -[REF] code-quality-report.md # 코드 품질 리포트 -``` - ---- - -## 📊 문서 통계 - -| 카테고리 | 문서 수 | 비율 | -|----------|---------|------| -| [GUIDE] | 4 | 8.7% | -| [IMPL] | 25 | 54.3% | -| [REF] | 14 | 30.4% | -| [PLAN] | 1 | 2.2% | -| [LEGACY] | 1 | 2.2% | -| [INDEX] | 1 | 2.2% | -| **합계** | **46** | **100%** | - ---- - -## 🎯 문서 작성 원칙 - -### 1. 명명 규칙 -- **[GUIDE]**: 대문자, 하이픈으로 단어 구분 -- **[IMPL-YYYY-MM-DD]**: 구현 날짜 포함, 소문자, 하이픈 구분 -- **[REF]**: 소문자, 하이픈 구분 - -### 2. 문서 구조 -- 명확한 목차 -- 코드 예제 포함 -- 실행 가능한 명령어 -- 트러블슈팅 섹션 - -### 3. 유지보수 -- 구현 완료 시 즉시 [IMPL] 문서 작성 -- 워크플로우 개선 시 [GUIDE] 업데이트 -- 레거시 문서는 [LEGACY]로 이동, 삭제 금지 - ---- - -## 📝 문서 업데이트 이력 - -| 날짜 | 변경 내용 | -|------|-----------| -| 2025-11-17 | 초기 인덱스 문서 작성 | -| 2025-11-17 | 모든 문서 명명 규칙 통일 | - ---- - -## 🔗 관련 리소스 - -- **프로젝트 루트**: `/Users/byeongcheolryu/codebridgex/sam_project/sam-next/sma-next-project/sam-react-prod` -- **문서 디렉토리**: `claudedocs/` -- **React 소스**: `sma-react-v2.0/` -- **Next.js 소스**: `src/` - ---- - -**마지막 업데이트**: 2025-11-17 -**문서 버전**: 1.0.0 -**관리자**: Claude + Development Team \ No newline at end of file diff --git a/claudedocs/archive/[LEGACY] 00_INDEX.md b/claudedocs/archive/[LEGACY] 00_INDEX.md deleted file mode 100644 index 4bf9f559..00000000 --- a/claudedocs/archive/[LEGACY] 00_INDEX.md +++ /dev/null @@ -1,532 +0,0 @@ -# 프로젝트 문서 인덱스 (구현 순서 기반) - -> 이 문서는 실제 프로젝트 구현 순서에 따라 문서들을 정리한 인덱스입니다. - -## 📂 문서 분류 - -### ✅ 구현 완료 (Implementation Completed) -실제 코드로 구현되어 프로젝트에 적용된 기능 - -### 📋 참고 자료 (Reference) -기획/조사 단계의 문서, 또는 향후 구현 참고용 자료 - -### 🚧 진행 중 (In Progress) -일부 구현되었으나 완료되지 않은 기능 - ---- - -## 🎯 구현 순서별 문서 목록 - -### Phase 1: 프로젝트 초기 설정 - -#### ✅ 1. 다국어 지원 (i18n) -**파일**: `i18n-usage-guide.md` -**상태**: ✅ 구현 완료 -**구현 내용**: -- next-intl 라이브러리 설정 -- 한국어(ko), 영어(en), 일본어(ja) 3개 언어 지원 -- `/src/i18n/config.ts` - 언어 설정 -- `/src/i18n/request.ts` - 메시지 로딩 -- `/src/messages/{locale}.json` - 번역 파일 -- Middleware에서 로케일 자동 감지 - -**관련 파일**: -``` -src/i18n/config.ts -src/i18n/request.ts -src/messages/ko.json, en.json, ja.json -src/middleware.ts (i18n 부분) -``` - ---- - -### Phase 2: 보안 및 Bot 차단 - -#### ✅ 2. SEO Bot 차단 설정 -**파일**: `seo-bot-blocking-configuration.md` -**상태**: ✅ 구현 완료 -**구현 내용**: -- Middleware에서 bot user-agent 감지 -- 보호된 경로에 대한 bot 접근 차단 -- 로봇 차단 헤더 추가 (`X-Robots-Tag`) - -**관련 파일**: -``` -src/middleware.ts (BOT_PATTERNS, isBot 함수) -``` - ---- - -### Phase 3: 인증 시스템 - -#### ✅ 3. API 분석 및 인증 방식 결정 -**파일**: `api-analysis.md` ➜ `api-requirements.md` -**상태**: 📋 참고 자료 -**목적**: -- Laravel API 엔드포인트 분석 -- 인증 방식 비교 (Bearer Token vs Session Cookie) -- 최종 결정: **Bearer Token (JWT) + Cookie 저장 방식** - ---- - -#### ✅ 4. 인증 시스템 설계 -**파일**: `authentication-design.md` -**상태**: 📋 참고 자료 (초기 Sanctum 설계) -**목적**: Sanctum 세션 쿠키 방식 설계 (레거시) - -**파일**: `jwt-cookie-authentication-final.md` -**상태**: ✅ 구현 완료 (최종 설계) -**구현 내용**: -- JWT Token을 쿠키에 저장 -- Middleware에서 `user_token` 쿠키 확인 -- 3가지 인증 방식 지원: Bearer Token/Sanctum/API-Key - -**관련 파일**: -``` -src/lib/api/auth/types.ts -src/lib/api/auth/auth-config.ts -src/lib/api/client.ts -src/middleware.ts (인증 체크 로직) -``` - ---- - -#### ✅ 5. 인증 구현 가이드 -**파일**: `authentication-implementation-guide.md` -**상태**: ✅ 구현 완료 -**구현 내용**: -- 3가지 인증 방식 통합 (Bearer/Sanctum/API-Key) -- API Client 구현 -- Route 보호 메커니즘 - -**관련 파일**: -``` -src/lib/api/auth/* -src/app/api/auth/* (로그인/로그아웃 API 라우트) -``` - ---- - -#### ✅ 6. API Key 관리 -**파일**: `api-key-management.md` -**상태**: ✅ 구현 완료 -**구현 내용**: -- 환경 변수를 통한 API Key 관리 -- `.env.local`에 `API_KEY` 저장 -- API 요청 시 자동으로 헤더에 추가 - -**관련 파일**: -``` -.env.local (API_KEY) -src/lib/api/client.ts -``` - ---- - -#### ✅ 7. Middleware 인증 문제 해결 -**파일**: `middleware-issue-resolution.md` -**상태**: ✅ 해결 완료 -**문제**: 로그인하지 않아도 `/dashboard` 접근 가능 -**원인**: `isPublicRoute()` 함수 버그 - `'/'`가 모든 경로와 매칭됨 -**해결**: -- `'/'` 경로는 정확히 일치할 때만 public -- 기타 경로는 `startsWith(route + '/')` 방식 -- Next.js 15 + next-intl 호환성 설정 (`turbopack: {}`) - -**관련 파일**: -``` -src/middleware.ts (isPublicRoute 함수) -next.config.ts (turbopack 설정) -``` - ---- - -### Phase 4: 라우팅 및 보호 - -#### ✅ 8. Route 보호 아키텍처 -**파일**: `route-protection-architecture.md` -**상태**: ✅ 구현 완료 -**구현 내용**: -- Protected Routes: `/dashboard`, `/admin`, etc. -- Guest-only Routes: `/login`, `/register` -- Public Routes: `/`, `/about`, `/contact` -- Middleware에서 라우트 타입별 처리 - -**관련 파일**: -``` -src/lib/api/auth/auth-config.ts (라우트 설정) -src/middleware.ts (라우트 보호 로직) -``` - ---- - -#### ✅ 9. Auth Guard 사용법 -**파일**: `auth-guard-usage.md` -**상태**: 🚧 부분 구현 -**구현 내용**: -- Hook 기반: `useAuthGuard()` 훅 -- Layout 기반: `(protected)` 폴더 - -**관련 파일**: -``` -src/hooks/useAuthGuard.ts -src/app/[locale]/(protected)/layout.tsx -``` - ---- - -### Phase 5: UI 및 폼 검증 - -#### ✅ 10. 폼 Validation -**파일**: `form-validation-guide.md` -**상태**: ✅ 구현 완료 -**구현 내용**: -- react-hook-form + zod 조합 -- 로그인/회원가입 폼 검증 - -**관련 파일**: -``` -src/lib/validations/auth.ts -src/components/auth/LoginPage.tsx -src/components/auth/SignupPage.tsx -``` - ---- - -#### ✅ 11. 테마 선택 및 언어 선택 -**상태**: ✅ 구현 완료 -**구현 내용**: -- 다크모드/라이트모드 전환 -- 테마 Context 관리 -- 언어 선택 컴포넌트 - -**관련 파일**: -``` -src/contexts/ThemeContext.tsx -src/components/ThemeSelect.tsx -src/components/LanguageSelect.tsx -``` - ---- - -### Phase 6: 대시보드 시스템 - -#### ✅ 12. Dashboard 마이그레이션 및 통합 -**파일**: `[IMPL-2025-11-10] dashboard-integration-complete.md` -**상태**: ✅ 구현 완료 (2025-11-10) -**구현 내용**: -- Vite React → Next.js 마이그레이션 -- 역할 기반 대시보드 시스템 (CEO, ProductionManager, Worker, SystemAdmin, Sales) -- Lazy loading으로 성능 최적화 -- localStorage 기반 역할 관리 - -**관련 파일**: -``` -src/components/business/Dashboard.tsx -src/components/business/CEODashboard.tsx -src/components/business/ProductionManagerDashboard.tsx -src/components/business/WorkerDashboard.tsx -src/components/business/SystemAdminDashboard.tsx -src/layouts/DashboardLayout.tsx -``` - ---- - -#### ✅ 13. Dashboard Layout 정리 -**파일**: `[IMPL-2025-11-11] dashboard-cleanup-summary.md` -**상태**: ✅ 구현 완료 (2025-11-11) -**구현 내용**: -- 테스트용 역할 선택 셀렉트 제거 -- 간단한 로그아웃 버튼으로 교체 -- UI 단순화 및 사용자 혼란 방지 - -**관련 파일**: -``` -src/layouts/DashboardLayout.tsx -``` - ---- - -#### ✅ 14. 차트 렌더링 경고 수정 -**파일**: `[IMPL-2025-11-11] chart-warning-fix.md` -**상태**: ✅ 구현 완료 (2025-11-11) -**구현 내용**: -- recharts ResponsiveContainer 높이 명시적 설정 -- "width(-1) and height(-1)" 경고 해결 -- 차트 즉시 렌더링 개선 - -**관련 파일**: -``` -src/components/business/CEODashboard.tsx -``` - ---- - -#### ✅ 15. Token 관리 가이드 -**파일**: `[IMPL-2025-11-10] token-management-guide.md` -**상태**: ✅ 구현 완료 (2025-11-10) -**구현 내용**: -- JWT Token 저장 및 관리 방식 -- HttpOnly Cookie 사용 -- Token 갱신 로직 - -**관련 파일**: -``` -src/app/api/auth/login/route.ts -src/app/api/auth/check/route.ts -src/middleware.ts -``` - ---- - -### Phase 7: UI/UX 개선 - -#### ✅ 16. Sidebar 활성 메뉴 동기화 -**파일**: `[IMPL-2025-11-11] sidebar-active-menu-sync.md` -**상태**: ✅ 구현 완료 (2025-11-11) -**구현 내용**: -- URL 기반 활성 메뉴 자동 감지 -- 서브메뉴 우선 매칭 로직 -- 메뉴 탐색 알고리즘 개선 - -**관련 파일**: -``` -src/layouts/DashboardLayout.tsx -``` - ---- - -#### ✅ 17. Sidebar 스크롤 개선 -**파일**: `[IMPL-2025-11-13] sidebar-scroll-improvements.md` -**상태**: ✅ 구현 완료 (2025-11-13) -**구현 내용**: -- 활성 메뉴 자동 스크롤 기능 -- 호버 시에만 스크롤바 표시 -- 부드러운 스크롤 애니메이션 - -**관련 파일**: -``` -src/components/layout/Sidebar.tsx -src/app/globals.css (sidebar-scroll 스타일) -``` - ---- - -#### ✅ 18. 모달 Select 레이아웃 시프트 방지 -**파일**: `[IMPL-2025-11-12] modal-select-layout-shift-fix.md` -**상태**: ✅ 구현 완료 (2025-11-12) -**구현 내용**: -- Shadcn UI Select 컴포넌트 레이아웃 시프트 방지 -- 포털 사용으로 모달 내 Select 안정화 - ---- - -#### ✅ 19. 에러 페이지 설정 -**파일**: `[IMPL-2025-11-11] error-pages-configuration.md` -**상태**: ✅ 구현 완료 (2025-11-11) -**구현 내용**: -- Next.js 15 App Router 에러 처리 -- error.tsx, not-found.tsx 구성 -- 다국어 지원 에러 메시지 - -**관련 파일**: -``` -src/app/[locale]/error.tsx -src/app/[locale]/not-found.tsx -src/app/[locale]/(protected)/error.tsx -``` - ---- - -### Phase 8: 브라우저 호환성 - -#### ✅ 20. Safari 쿠키 호환성 -**파일**: `[IMPL-2025-11-13] safari-cookie-compatibility.md` -**상태**: ✅ 구현 완료 (2025-11-13) -**구현 내용**: -- SameSite=Strict → SameSite=Lax 변경 -- 개발 환경에서 Secure 속성 제외 (Safari 호환) -- 쿠키 설정/삭제 시 동일한 속성 사용 - -**관련 파일**: -``` -src/app/api/auth/login/route.ts -src/app/api/auth/logout/route.ts -src/app/api/auth/check/route.ts -``` - ---- - -#### ✅ 21. 브라우저 지원 정책 -**파일**: `[IMPL-2025-11-13] browser-support-policy.md` -**상태**: ✅ 구현 완료 (2025-11-13) -**구현 내용**: -- Internet Explorer 차단 -- 안내 페이지 제공 (unsupported-browser.html) -- Middleware에서 IE User-Agent 감지 - -**관련 파일**: -``` -src/middleware.ts (isInternetExplorer 함수) -public/unsupported-browser.html -``` - ---- - -### Phase 9: 타입 안전성 - -#### ✅ 22. API 라우트 타입 안전성 -**파일**: `[IMPL-2025-11-11] api-route-type-safety.md` -**상태**: ✅ 구현 완료 (2025-11-11) -**구현 내용**: -- TypeScript 인터페이스 정의 -- API 응답 타입 검증 -- 타입 안전한 에러 처리 - -**관련 파일**: -``` -src/app/api/auth/*/route.ts -``` - ---- - -### Phase 10: 참고 자료 및 가이드 - -#### 📋 23. Next.js 에러 핸들링 가이드 -**파일**: `[REF] nextjs-error-handling-guide.md` -**상태**: 📋 참고 자료 -**목적**: Next.js 15 App Router 에러 처리 종합 가이드 - ---- - -#### 📋 24. 컴포넌트 사용 분석 -**파일**: `[REF-2025-11-12] component-usage-analysis.md` -**상태**: 📋 참고 자료 -**목적**: 프로젝트 내 컴포넌트 사용 현황 분석 - ---- - -#### 📋 25. 세션 마이그레이션 가이드 -**파일**: -- `[REF-2025-11-12] session-migration-backend.md` -- `[REF-2025-11-12] session-migration-frontend.md` -- `[REF-2025-11-12] session-migration-summary.md` - -**상태**: 📋 참고 자료 (미구현) -**목적**: JWT → 세션 기반 인증 전환 가이드 - ---- - -#### 📋 26. Dashboard 마이그레이션 요약 -**파일**: `[REF-2025-11-10] dashboard-migration-summary.md` -**상태**: 📋 참고 자료 -**목적**: Vite React → Next.js 마이그레이션 과정 기록 - ---- - -#### 📋 27. Production 배포 체크리스트 -**파일**: `[REF] production-deployment-checklist.md` -**상태**: 📋 참고 자료 -**목적**: 배포 전 확인 사항 체크리스트 - ---- - -#### 📋 28. 코드 품질 리포트 -**파일**: `[REF] code-quality-report.md` -**상태**: 📋 참고 자료 -**목적**: 코드 품질 분석 결과 - ---- - -#### 📋 29. 아키텍처 통합 리스크 -**파일**: `[REF] architecture-integration-risks.md` -**상태**: 📋 참고 자료 -**목적**: 인증/i18n/bot 차단 통합 시 리스크 분석 - ---- - -### Phase 11: 보안 연구 및 개선 - -#### 📋 30. Token 보안 연구 (Next.js 15) -**파일**: `[REF-2025-11-07] research_token_security_nextjs15.md` -**상태**: 📋 참고 자료 -**목적**: JWT Token 보안 연구 - ---- - -#### 📋 31. Middleware 인증 연구 -**파일**: `[REF-2025-11-07] research_nextjs15_middleware_authentication.md` -**상태**: 📋 참고 자료 -**목적**: Next.js 15 Middleware 인증 방식 조사 - ---- - -#### 📋 32. HttpOnly Cookie 구현 -**파일**: `[REF-Future] httponly-cookie-implementation.md` -**상태**: 📋 참고 자료 (미구현) -**목적**: HttpOnly Cookie 방식 설계 (보안 강화 옵션) - ---- - -#### 📋 33. 커뮤니케이션 개선 가이드 -**파일**: `[REF] communication_improvement_guide.md` -**상태**: 📋 참고 자료 -**목적**: 프로젝트 커뮤니케이션 개선 방안 - ---- - -#### 📋 34. 프로젝트 컨텍스트 -**파일**: `[REF] project-context.md` -**상태**: 📋 참고 자료 -**목적**: 프로젝트 전체 개요 및 빠른 시작 가이드 - ---- - -## 🔍 빠른 검색 - -### 주제별 문서 찾기 - -| 주제 | 문서 | -|------|------| -| **프로젝트 개요** | `[REF] project-context.md` | -| **다국어** | `[IMPL-2025-11-06] i18n-usage-guide.md` | -| **인증 설계** | `[IMPL-2025-11-07] jwt-cookie-authentication-final.md` | -| **인증 구현** | `[IMPL-2025-11-07] authentication-implementation-guide.md` | -| **Bot 차단** | `[IMPL-2025-11-07] seo-bot-blocking-configuration.md` | -| **Route 보호** | `[IMPL-2025-11-07] route-protection-architecture.md` | -| **Middleware** | `[IMPL-2025-11-07] middleware-issue-resolution.md` | -| **폼 검증** | `[IMPL-2025-11-07] form-validation-guide.md` | -| **API 분석** | `[REF] api-analysis.md`, `[REF] api-requirements.md` | -| **Dashboard** | `[IMPL-2025-11-10] dashboard-integration-complete.md` | -| **Sidebar** | `[IMPL-2025-11-13] sidebar-scroll-improvements.md` | -| **Safari 호환성** | `[IMPL-2025-11-13] safari-cookie-compatibility.md` | -| **IE 차단** | `[IMPL-2025-11-13] browser-support-policy.md` | -| **에러 처리** | `[REF] nextjs-error-handling-guide.md` | -| **세션 마이그레이션** | `[REF-2025-11-12] session-migration-summary.md` | -| **배포** | `[REF] production-deployment-checklist.md` | - ---- - -## 📝 업데이트 이력 - -| 날짜 | 변경 내용 | -|------|----------| -| 2025-11-13 | Phase 6-11 추가 (대시보드, UI/UX, 브라우저 호환성, 타입 안전성, 참고 자료) | -| 2025-11-10 | 인덱스 파일 생성, 구현 순서 기반 분류 | - ---- - -## 📊 문서 통계 - -- **총 문서 수**: 38개 -- **구현 완료 (IMPL)**: 21개 -- **참고 자료 (REF)**: 16개 -- **부분 구현 (PARTIAL)**: 1개 - ---- - -## 💡 사용 가이드 - -1. **새 세션 시작 시**: `project-context.md` 먼저 읽기 -2. **특정 기능 작업 시**: 위 인덱스에서 관련 문서 찾기 -3. **새 기능 추가 시**: 이 인덱스에 문서 추가 및 상태 업데이트 diff --git a/claudedocs/archive/[LEGACY] authentication-design.md b/claudedocs/archive/[LEGACY] authentication-design.md deleted file mode 100644 index 5190257e..00000000 --- a/claudedocs/archive/[LEGACY] authentication-design.md +++ /dev/null @@ -1,931 +0,0 @@ -# 인증 시스템 설계 (Laravel Sanctum + Next.js 15) - -## 📋 아키텍처 개요 - -### 전체 구조 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Next.js Frontend │ -├─────────────────────────────────────────────────────────────┤ -│ Middleware (Server) │ -│ ├─ Bot Detection (기존) │ -│ ├─ Authentication Check (신규) │ -│ │ ├─ Protected Routes 가드 │ -│ │ ├─ 세션 쿠키 확인 │ -│ │ └─ 인증 실패 → /login 리다이렉트 │ -│ └─ i18n Routing (기존) │ -├─────────────────────────────────────────────────────────────┤ -│ API Client (lib/auth/sanctum.ts) │ -│ ├─ CSRF 토큰 자동 처리 │ -│ ├─ HTTP-only 쿠키 포함 (credentials: 'include') │ -│ ├─ 에러 인터셉터 (401 → /login) │ -│ └─ 재시도 로직 │ -├─────────────────────────────────────────────────────────────┤ -│ Server Auth Utils (lib/auth/server-auth.ts) │ -│ ├─ getServerSession() - Server Components용 │ -│ └─ 쿠키 기반 세션 검증 │ -├─────────────────────────────────────────────────────────────┤ -│ Auth Context (contexts/AuthContext.tsx) │ -│ ├─ 클라이언트 사이드 상태 관리 │ -│ ├─ 사용자 정보 캐싱 │ -│ └─ login/logout/register 함수 │ -└─────────────────────────────────────────────────────────────┘ - ↓ HTTP + Cookies -┌─────────────────────────────────────────────────────────────┐ -│ Laravel Backend (PHP) │ -├─────────────────────────────────────────────────────────────┤ -│ Sanctum Middleware │ -│ └─ 세션 기반 SPA 인증 (HTTP-only 쿠키) │ -├─────────────────────────────────────────────────────────────┤ -│ API Endpoints │ -│ ├─ GET /sanctum/csrf-cookie (CSRF 토큰 발급) │ -│ ├─ POST /api/login (로그인) │ -│ ├─ POST /api/register (회원가입) │ -│ ├─ POST /api/logout (로그아웃) │ -│ ├─ GET /api/user (현재 사용자 정보) │ -│ └─ POST /api/forgot-password (비밀번호 재설정) │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 핵심 설계 원칙 - -1. **가드 컴포넌트 없이 Middleware로 일괄 처리** - - 모든 인증 체크를 middleware.ts에서 처리 - - 라우트별로 가드 컴포넌트 불필요 - - 중복 코드 제거 - -2. **세션 기반 인증 (Sanctum SPA 모드)** - - HTTP-only 쿠키로 세션 관리 - - XSS 공격 방어 - - CSRF 토큰으로 보안 강화 - -3. **Server Components 우선** - - 서버에서 인증 체크 및 데이터 fetch - - 클라이언트 JS 번들 크기 감소 - - SEO 최적화 - -## 🔐 인증 플로우 - -### 1. 로그인 플로우 - -``` -┌─────────┐ 1. /login 접속 ┌──────────────┐ -│ Browser │ ───────────────────────────→│ Next.js │ -└─────────┘ │ Server │ - ↓ └──────────────┘ - │ 2. CSRF 토큰 요청 - │ GET /sanctum/csrf-cookie - ↓ -┌─────────┐ ┌──────────────┐ -│ Browser │ ←───────────────────────────│ Laravel │ -└─────────┘ XSRF-TOKEN 쿠키 │ Backend │ - ↓ └──────────────┘ - │ 3. 로그인 폼 제출 - │ POST /api/login - │ { email, password } - │ Headers: X-XSRF-TOKEN - ↓ -┌─────────┐ ┌──────────────┐ -│ Browser │ ←───────────────────────────│ Laravel │ -└─────────┘ laravel_session 쿠키 │ Sanctum │ - ↓ (HTTP-only) └──────────────┘ - │ 4. 보호된 페이지 접근 - │ GET /dashboard - │ Cookies: laravel_session - ↓ -┌─────────┐ ┌──────────────┐ -│ Browser │ ←───────────────────────────│ Next.js │ -└─────────┘ 페이지 렌더링 │ Middleware │ - (쿠키 확인 ✓) └──────────────┘ -``` - -### 2. 보호된 페이지 접근 플로우 - -``` -사용자 → /dashboard 접속 - ↓ - Middleware 실행 - ↓ - ┌─────────────────┐ - │ 세션 쿠키 확인? │ - └─────────────────┘ - ↓ - Yes ↓ No ↓ - ↓ ↓ - 페이지 렌더링 Redirect - (Server /login?redirect=/dashboard - Component) -``` - -### 3. 미들웨어 체크 순서 - -``` -Request - ↓ -1. Bot Detection Check - ├─ Bot → 403 Forbidden - └─ Human → Continue - ↓ -2. Static Files Check - ├─ Static → Skip Auth - └─ Dynamic → Continue - ↓ -3. Public Routes Check - ├─ Public → Skip Auth - └─ Protected → Continue - ↓ -4. Session Cookie Check - ├─ Valid Session → Continue - └─ No Session → Redirect /login - ↓ -5. Guest Only Routes Check - ├─ Authenticated + /login → Redirect /dashboard - └─ Continue - ↓ -6. i18n Routing - ↓ -Response -``` - -## 📁 파일 구조 - -``` -/src -├─ /lib -│ └─ /auth -│ ├─ sanctum.ts # Sanctum API 클라이언트 -│ ├─ auth-config.ts # 인증 설정 (routes, URLs) -│ └─ server-auth.ts # 서버 컴포넌트용 유틸 -│ -├─ /contexts -│ └─ AuthContext.tsx # 클라이언트 인증 상태 관리 -│ -├─ /app/[locale] -│ ├─ /(auth) # 인증 관련 라우트 그룹 -│ │ ├─ /login -│ │ │ └─ page.tsx # 로그인 페이지 -│ │ ├─ /register -│ │ │ └─ page.tsx # 회원가입 페이지 -│ │ └─ /forgot-password -│ │ └─ page.tsx # 비밀번호 재설정 -│ │ -│ ├─ /(protected) # 보호된 라우트 그룹 -│ │ ├─ /dashboard -│ │ │ └─ page.tsx -│ │ ├─ /profile -│ │ │ └─ page.tsx -│ │ └─ /settings -│ │ └─ page.tsx -│ │ -│ └─ layout.tsx # AuthProvider 추가 -│ -├─ /middleware.ts # 통합 미들웨어 -│ -└─ /.env.local # 환경 변수 -``` - -## 🛠️ 핵심 구현 포인트 - -### 1. 인증 설정 (lib/auth/auth-config.ts) - -```typescript -export const AUTH_CONFIG = { - // API 엔드포인트 - apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000', - - // 완전 공개 라우트 (인증 체크 안함) - publicRoutes: [ - '/', - '/about', - '/contact', - '/terms', - '/privacy', - ], - - // 인증 필요 라우트 - protectedRoutes: [ - '/dashboard', - '/profile', - '/settings', - '/admin', - '/tenant', - '/users', - '/reports', - // ... ERP 경로들 - ], - - // 게스트 전용 (로그인 후 접근 불가) - guestOnlyRoutes: [ - '/login', - '/register', - '/forgot-password', - ], - - // 리다이렉트 설정 - redirects: { - afterLogin: '/dashboard', - afterLogout: '/login', - unauthorized: '/login', - }, -}; -``` - -### 2. Sanctum API 클라이언트 (lib/auth/sanctum.ts) - -```typescript -class SanctumClient { - private baseURL: string; - private csrfToken: string | null = null; - - constructor() { - this.baseURL = AUTH_CONFIG.apiUrl; - } - - /** - * CSRF 토큰 가져오기 - * 로그인/회원가입 전에 반드시 호출 - */ - async getCsrfToken(): Promise { - await fetch(`${this.baseURL}/sanctum/csrf-cookie`, { - credentials: 'include', // 쿠키 포함 - }); - } - - /** - * 로그인 - */ - async login(email: string, password: string): Promise { - await this.getCsrfToken(); - - const response = await fetch(`${this.baseURL}/api/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ email, password }), - }); - - if (!response.ok) { - throw new Error('Login failed'); - } - - return await response.json(); - } - - /** - * 회원가입 - */ - async register(data: RegisterData): Promise { - await this.getCsrfToken(); - - const response = await fetch(`${this.baseURL}/api/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - credentials: 'include', - body: JSON.stringify(data), - }); - - if (!response.ok) { - const error = await response.json(); - throw error; - } - - return await response.json(); - } - - /** - * 로그아웃 - */ - async logout(): Promise { - await fetch(`${this.baseURL}/api/logout`, { - method: 'POST', - credentials: 'include', - }); - } - - /** - * 현재 사용자 정보 - */ - async getCurrentUser(): Promise { - try { - const response = await fetch(`${this.baseURL}/api/user`, { - credentials: 'include', - }); - - if (response.ok) { - return await response.json(); - } - return null; - } catch { - return null; - } - } -} - -export const sanctumClient = new SanctumClient(); -``` - -**핵심 포인트**: -- `credentials: 'include'` - 모든 요청에 쿠키 포함 -- CSRF 토큰은 쿠키로 자동 관리 (Laravel이 처리) -- 에러 처리 일관성 - -### 3. 서버 인증 유틸 (lib/auth/server-auth.ts) - -```typescript -import { cookies } from 'next/headers'; -import { AUTH_CONFIG } from './auth-config'; - -/** - * 서버 컴포넌트에서 세션 가져오기 - */ -export async function getServerSession(): Promise { - const cookieStore = await cookies(); - const sessionCookie = cookieStore.get('laravel_session'); - - if (!sessionCookie) { - return null; - } - - try { - const response = await fetch(`${AUTH_CONFIG.apiUrl}/api/user`, { - headers: { - Cookie: `laravel_session=${sessionCookie.value}`, - Accept: 'application/json', - }, - cache: 'no-store', // 항상 최신 데이터 - }); - - if (response.ok) { - return await response.json(); - } - } catch (error) { - console.error('Failed to get server session:', error); - } - - return null; -} - -/** - * 서버 컴포넌트에서 인증 필요 - */ -export async function requireAuth(): Promise { - const user = await getServerSession(); - - if (!user) { - redirect('/login'); - } - - return user; -} -``` - -**사용 예시**: -```typescript -// app/(protected)/dashboard/page.tsx -import { requireAuth } from '@/lib/auth/server-auth'; - -export default async function DashboardPage() { - const user = await requireAuth(); // 인증 필요 - - return
Welcome {user.name}
; -} -``` - -### 4. Middleware 통합 (middleware.ts) - -```typescript -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; -import createIntlMiddleware from 'next-intl/middleware'; -import { locales, defaultLocale } from '@/i18n/config'; -import { AUTH_CONFIG } from '@/lib/auth/auth-config'; - -const intlMiddleware = createIntlMiddleware({ - locales, - defaultLocale, - localePrefix: 'as-needed', -}); - -// 경로가 보호된 라우트인지 확인 -function isProtectedRoute(pathname: string): boolean { - return AUTH_CONFIG.protectedRoutes.some(route => - pathname.startsWith(route) - ); -} - -// 경로가 공개 라우트인지 확인 -function isPublicRoute(pathname: string): boolean { - return AUTH_CONFIG.publicRoutes.some(route => - pathname === route || pathname.startsWith(route) - ); -} - -// 경로가 게스트 전용인지 확인 -function isGuestOnlyRoute(pathname: string): boolean { - return AUTH_CONFIG.guestOnlyRoutes.some(route => - pathname === route || pathname.startsWith(route) - ); -} - -// 로케일 제거 -function stripLocale(pathname: string): string { - for (const locale of locales) { - if (pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`) { - return pathname.slice(`/${locale}`.length) || '/'; - } - } - return pathname; -} - -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - // 1. Bot Detection (기존 로직) - // ... bot check code ... - - // 2. 정적 파일 제외 - if ( - pathname.includes('/_next/') || - pathname.includes('/api/') || - pathname.match(/\.(ico|png|jpg|jpeg|svg|gif|webp)$/) - ) { - return intlMiddleware(request); - } - - // 3. 로케일 제거하여 경로 체크 - const pathnameWithoutLocale = stripLocale(pathname); - - // 4. 세션 쿠키 확인 - const sessionCookie = request.cookies.get('laravel_session'); - const isAuthenticated = !!sessionCookie; - - // 5. 보호된 라우트 체크 - if (isProtectedRoute(pathnameWithoutLocale) && !isAuthenticated) { - const url = new URL('/login', request.url); - url.searchParams.set('redirect', pathname); - return NextResponse.redirect(url); - } - - // 6. 게스트 전용 라우트 체크 (이미 로그인한 경우) - if (isGuestOnlyRoute(pathnameWithoutLocale) && isAuthenticated) { - return NextResponse.redirect( - new URL(AUTH_CONFIG.redirects.afterLogin, request.url) - ); - } - - // 7. i18n 미들웨어 실행 - return intlMiddleware(request); -} - -export const config = { - matcher: [ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)', - ], -}; -``` - -**장점**: -- 단일 진입점에서 모든 인증 처리 -- 가드 컴포넌트 불필요 -- 중복 코드 제거 -- 성능 최적화 (서버 사이드 체크) - -### 5. Auth Context (contexts/AuthContext.tsx) - -```typescript -'use client'; - -import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { sanctumClient } from '@/lib/auth/sanctum'; -import { useRouter } from 'next/navigation'; -import { AUTH_CONFIG } from '@/lib/auth/auth-config'; - -interface User { - id: number; - name: string; - email: string; -} - -interface AuthContextType { - user: User | null; - loading: boolean; - login: (email: string, password: string) => Promise; - register: (data: RegisterData) => Promise; - logout: () => Promise; - refreshUser: () => Promise; -} - -const AuthContext = createContext(undefined); - -export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const router = useRouter(); - - // 초기 로드 시 사용자 정보 가져오기 - useEffect(() => { - sanctumClient.getCurrentUser() - .then(setUser) - .catch(() => setUser(null)) - .finally(() => setLoading(false)); - }, []); - - const login = async (email: string, password: string) => { - const user = await sanctumClient.login(email, password); - setUser(user); - router.push(AUTH_CONFIG.redirects.afterLogin); - }; - - const register = async (data: RegisterData) => { - const user = await sanctumClient.register(data); - setUser(user); - router.push(AUTH_CONFIG.redirects.afterLogin); - }; - - const logout = async () => { - await sanctumClient.logout(); - setUser(null); - router.push(AUTH_CONFIG.redirects.afterLogout); - }; - - const refreshUser = async () => { - const user = await sanctumClient.getCurrentUser(); - setUser(user); - }; - - return ( - - {children} - - ); -} - -export function useAuth() { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within AuthProvider'); - } - return context; -} -``` - -**사용 예시**: -```typescript -// components/LoginForm.tsx -'use client'; - -import { useAuth } from '@/contexts/AuthContext'; - -export function LoginForm() { - const { login, loading } = useAuth(); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - await login(email, password); - }; - - return
...
; -} -``` - -## 🔒 보안 고려사항 - -### 1. CSRF 보호 - -**Next.js 측**: -- 모든 상태 변경 요청 전에 `getCsrfToken()` 호출 -- Laravel이 XSRF-TOKEN 쿠키 발급 -- 브라우저가 자동으로 헤더에 포함 - -**Laravel 측** (백엔드 담당): -```php -// config/sanctum.php -'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')), -``` - -### 2. 쿠키 보안 설정 - -**Laravel 측** (백엔드 담당): -```php -// config/session.php -'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only -'http_only' => true, // JavaScript 접근 불가 -'same_site' => 'lax', // CSRF 방지 -``` - -### 3. CORS 설정 - -**Laravel 측** (백엔드 담당): -```php -// config/cors.php -'paths' => ['api/*', 'sanctum/csrf-cookie'], -'supports_credentials' => true, -'allowed_origins' => [env('FRONTEND_URL')], -'allowed_headers' => ['*'], -'exposed_headers' => [], -'max_age' => 0, -``` - -### 4. 환경 변수 - -```env -# .env.local (Next.js) -NEXT_PUBLIC_API_URL=http://localhost:8000 -NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 -``` - -```env -# .env (Laravel) -FRONTEND_URL=http://localhost:3000 -SANCTUM_STATEFUL_DOMAINS=localhost:3000 -SESSION_DOMAIN=localhost -SESSION_SECURE_COOKIE=false # 개발: false, 프로덕션: true -``` - -### 5. XSS 방어 - -- HTTP-only 쿠키 사용 (JavaScript로 접근 불가) -- 사용자 입력 sanitization (React가 기본으로 처리) -- CSP 헤더 설정 (Next.js 설정) - -### 6. Rate Limiting - -**Laravel 측** (백엔드 담당): -```php -// routes/api.php -Route::middleware(['throttle:login'])->group(function () { - Route::post('/login', [AuthController::class, 'login']); -}); - -// app/Http/Kernel.php -'login' => 'throttle:5,1', // 1분에 5번 -``` - -## 📊 에러 처리 전략 - -### 1. 에러 타입별 처리 - -```typescript -// lib/auth/sanctum.ts -class ApiError extends Error { - constructor( - public status: number, - public code: string, - message: string, - public errors?: Record - ) { - super(message); - } -} - -async function handleResponse(response: Response): Promise { - if (response.ok) { - return await response.json(); - } - - const data = await response.json().catch(() => ({})); - - switch (response.status) { - case 401: - // 인증 실패 - 로그인 페이지로 - window.location.href = '/login'; - throw new ApiError(401, 'UNAUTHORIZED', 'Please login'); - - case 403: - // 권한 없음 - throw new ApiError(403, 'FORBIDDEN', 'Access denied'); - - case 422: - // Validation 에러 - throw new ApiError( - 422, - 'VALIDATION_ERROR', - data.message || 'Validation failed', - data.errors - ); - - case 429: - // Rate limit - throw new ApiError(429, 'RATE_LIMIT', 'Too many requests'); - - case 500: - // 서버 에러 - throw new ApiError(500, 'SERVER_ERROR', 'Server error occurred'); - - default: - throw new ApiError( - response.status, - 'UNKNOWN_ERROR', - data.message || 'An error occurred' - ); - } -} -``` - -### 2. UI 에러 표시 - -```typescript -// components/LoginForm.tsx -const [error, setError] = useState(null); -const [fieldErrors, setFieldErrors] = useState>({}); - -try { - await login(email, password); -} catch (err) { - if (err instanceof ApiError) { - if (err.status === 422 && err.errors) { - setFieldErrors(err.errors); - } else { - setError(err.message); - } - } else { - setError('An unexpected error occurred'); - } -} -``` - -### 3. 네트워크 에러 처리 - -```typescript -// 재시도 로직 -async function fetchWithRetry( - url: string, - options: RequestInit, - retries = 3 -): Promise { - try { - return await fetch(url, options); - } catch (error) { - if (retries > 0) { - await new Promise(resolve => setTimeout(resolve, 1000)); - return fetchWithRetry(url, options, retries - 1); - } - throw new Error('Network error. Please check your connection.'); - } -} -``` - -## 🚀 성능 최적화 - -### 1. Middleware 최적화 - -```typescript -// 정적 파일 조기 리턴 -if (pathname.includes('/_next/') || pathname.match(/\.(ico|png|jpg)$/)) { - return NextResponse.next(); -} - -// 쿠키만 확인, API 호출 안함 -const isAuthenticated = !!request.cookies.get('laravel_session'); -``` - -### 2. 클라이언트 캐싱 - -```typescript -// AuthContext에서 사용자 정보 캐싱 -// 페이지 이동 시 재요청 안함 -const [user, setUser] = useState(null); -``` - -### 3. Server Components 활용 - -```typescript -// 서버에서 데이터 fetch -export default async function DashboardPage() { - const user = await getServerSession(); - const data = await fetchDashboardData(user.id); - - return ; -} -``` - -### 4. Parallel Data Fetching - -```typescript -// 병렬 데이터 요청 -const [user, stats, notifications] = await Promise.all([ - getServerSession(), - fetchStats(), - fetchNotifications(), -]); -``` - -## 📝 구현 단계 - -### Phase 1: 기본 인프라 설정 - -- [ ] 1.1 인증 설정 파일 생성 (`auth-config.ts`) -- [ ] 1.2 Sanctum API 클라이언트 구현 (`sanctum.ts`) -- [ ] 1.3 서버 인증 유틸리티 (`server-auth.ts`) -- [ ] 1.4 타입 정의 (`types/auth.ts`) - -### Phase 2: Middleware 통합 - -- [ ] 2.1 현재 middleware.ts 백업 -- [ ] 2.2 인증 로직 추가 -- [ ] 2.3 라우트 보호 로직 구현 -- [ ] 2.4 리다이렉트 로직 구현 - -### Phase 3: 클라이언트 상태 관리 - -- [ ] 3.1 AuthContext 생성 -- [ ] 3.2 AuthProvider를 layout.tsx에 추가 -- [ ] 3.3 useAuth 훅 테스트 - -### Phase 4: 인증 페이지 구현 - -- [ ] 4.1 로그인 페이지 (`/login`) -- [ ] 4.2 회원가입 페이지 (`/register`) -- [ ] 4.3 비밀번호 재설정 (`/forgot-password`) -- [ ] 4.4 폼 Validation (react-hook-form + zod) - -### Phase 5: 보호된 페이지 구현 - -- [ ] 5.1 대시보드 페이지 (`/dashboard`) -- [ ] 5.2 프로필 페이지 (`/profile`) -- [ ] 5.3 설정 페이지 (`/settings`) - -### Phase 6: 테스트 및 최적화 - -- [ ] 6.1 인증 플로우 테스트 -- [ ] 6.2 에러 케이스 테스트 -- [ ] 6.3 성능 측정 및 최적화 -- [ ] 6.4 보안 점검 - -## 🤔 검토 포인트 - -### 1. 설계 관련 질문 - -- **Middleware 중심 설계가 적합한가?** - - 장점: 중앙 집중식 관리, 중복 코드 제거 - - 단점: 복잡도 증가 가능성 - -- **세션 쿠키만으로 충분한가?** - - Sanctum SPA 모드는 세션 쿠키로 충분 - - API 토큰 모드가 필요한 경우 추가 구현 필요 - -- **Server Components vs Client Components 비율은?** - - 인증 체크: Server (Middleware + getServerSession) - - 상태 관리: Client (AuthContext) - - UI: 혼합 (페이지는 Server, 인터랙션은 Client) - -### 2. 구현 우선순위 - -**높음 (즉시 필요)**: -- auth-config.ts -- sanctum.ts -- middleware.ts 업데이트 -- 로그인 페이지 - -**중간 (빠르게 필요)**: -- AuthContext -- 회원가입 페이지 -- 대시보드 기본 구조 - -**낮음 (나중에)**: -- 비밀번호 재설정 -- 프로필 관리 -- 고급 보안 기능 - -### 3. Laravel 백엔드 체크리스트 - -백엔드 개발자가 확인해야 할 사항: - -```php -# 1. Sanctum 설치 및 설정 -composer require laravel/sanctum -php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" - -# 2. config/sanctum.php -'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost:3000')), - -# 3. config/cors.php -'supports_credentials' => true, -'allowed_origins' => [env('FRONTEND_URL')], - -# 4. API Routes -Route::post('/login', [AuthController::class, 'login']); -Route::post('/register', [AuthController::class, 'register']); -Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); -Route::get('/user', [AuthController::class, 'user'])->middleware('auth:sanctum'); - -# 5. CORS 미들웨어 -app/Http/Kernel.php에 \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class 추가 -``` - -## 🎯 다음 액션 - -이 설계 문서를 검토 후: - -1. **승인 시**: Phase 1부터 순차적으로 구현 시작 -2. **수정 필요 시**: 피드백 반영 후 재설계 -3. **질문 사항**: 불명확한 부분 명확화 - -질문이나 수정 사항이 있으면 알려주세요! \ No newline at end of file diff --git a/claudedocs/archive/[PLAN-2025-11-18] refactoring-plan.md b/claudedocs/archive/[PLAN-2025-11-18] refactoring-plan.md deleted file mode 100644 index bda2469a..00000000 --- a/claudedocs/archive/[PLAN-2025-11-18] refactoring-plan.md +++ /dev/null @@ -1,268 +0,0 @@ -# DataContext.tsx 리팩토링 계획 - -## 현황 분석 - -### 기존 파일 구조 -- **총 라인**: 6,707줄 -- **파일 크기**: 222KB -- **상태 변수**: 33개 -- **타입 정의**: 50개 이상 - -### 문제점 -1. 단일 파일에 모든 도메인 집중 → 유지보수 불가능 -2. 6700줄 분석 시 토큰 과다 소비 → 세션 종료 빈번 -3. 관련 없는 데이터도 항상 로드 → 성능 저하 - ---- - -## 도메인 분류 (10개 도메인, 33개 상태) - -### 1. ItemMaster (품목 마스터) - 13개 상태 -**파일**: `contexts/ItemMasterContext.tsx` -**관련 페이지**: 품목관리, 품목기준관리 - -상태: -- itemMasters (품목 마스터 데이터) -- specificationMasters (규격 마스터) -- materialItemNames (자재 품목명) -- itemCategories (품목 분류) -- itemUnits (단위) -- itemMaterials (재질) -- surfaceTreatments (표면처리) -- partTypeOptions (부품 유형 옵션) -- partUsageOptions (부품 용도 옵션) -- guideRailOptions (가이드레일 옵션) -- sectionTemplates (섹션 템플릿) -- itemMasterFields (품목 필드 정의) -- itemPages (품목 입력 페이지) - -타입: -- ItemMaster, ItemRevisio1n, ItemCategory, ItemUnit, ItemMaterial -- SurfaceTreatment, PartTypeOption, PartUsageOption, GuideRailOption -- ItemMasterField, ItemFieldProperty, FieldDisplayCondition -- ItemField, ItemSection, ItemPage, SectionTemplate -- SpecificationMaster, MaterialItemName -- BOMLine, BOMItem, BendingDetail - ---- - -### 2. Sales (판매) - 3개 상태 -**파일**: `contexts/SalesContext.tsx` -**관련 페이지**: 견적관리, 수주관리, 거래처관리 - -상태: -- salesOrders (수주 데이터) -- quotes (견적 데이터) -- clients (거래처 데이터) - -타입: -- SalesOrder, SalesOrderItem, OrderRevision, DocumentSendHistory -- Quote, QuoteRevision, QuoteCalculationRow, BOMCalculationRow -- Client - ---- - -### 3. Production (생산) - 2개 상태 -**파일**: `contexts/ProductionContext.tsx` -**관련 페이지**: 생산관리, 품질관리 - -상태: -- productionOrders (생산지시 데이터) -- qualityInspections (품질검사 데이터) - -타입: -- ProductionOrder -- QualityInspection - ---- - -### 4. Inventory (재고) - 2개 상태 -**파일**: `contexts/InventoryContext.tsx` -**관련 페이지**: 재고관리, 구매관리 - -상태: -- inventoryItems (재고 데이터) -- purchaseOrders (구매 데이터) - -타입: -- InventoryItem -- PurchaseOrder - ---- - -### 5. Shipping (출고) - 1개 상태 -**파일**: `contexts/ShippingContext.tsx` -**관련 페이지**: 출고관리 - -상태: -- shippingOrders (출고지시서 데이터) - -타입: -- ShippingOrder, ShippingOrderItem -- ShippingSchedule, ShippingLot, ShippingLotItem - ---- - -### 6. HR (인사) - 3개 상태 -**파일**: `contexts/HRContext.tsx` -**관련 페이지**: 직원관리, 근태관리, 결재관리 - -상태: -- employees (직원 데이터) -- attendances (근태 데이터) -- approvals (결재 데이터) - -타입: -- Employee -- Attendance -- Approval - ---- - -### 7. Accounting (회계) - 2개 상태 -**파일**: `contexts/AccountingContext.tsx` -**관련 페이지**: 회계관리, 매출채권관리 - -상태: -- accountingTransactions (회계 거래 데이터) -- receivables (매출채권 데이터) - -타입: -- AccountingTransaction -- Receivable - ---- - -### 8. Facilities (시설) - 2개 상태 -**파일**: `contexts/FacilitiesContext.tsx` -**관련 페이지**: 차량관리, 현장관리 - -상태: -- vehicles (차량 데이터) -- sites (현장 데이터) - -타입: -- Vehicle -- Site, SiteAttachment - ---- - -### 9. Pricing (가격/계산식) - 3개 상태 -**파일**: `contexts/PricingContext.tsx` -**관련 페이지**: 가격관리, 계산식관리 - -상태: -- formulas (계산식 데이터) -- formulaRules (계산식 규칙 데이터) -- pricing (가격 데이터) - -타입: -- CalculationFormula, FormulaRevision -- FormulaRule, FormulaRuleRevision, RangeRule -- PricingData, PriceRevision - ---- - -### 10. Auth (인증) - 2개 상태 -**파일**: `contexts/AuthContext.tsx` -**관련 페이지**: 로그인, 사용자관리 - -상태: -- users (사용자 데이터) -- currentUser (현재 사용자) - -타입: -- User, UserRole - ---- - -## 공통 타입 파일 - -### types/index.ts -재사용되는 공통 타입 정의: -- 없음 (각 도메인이 독립적) - ---- - -## 통합 Provider - -### contexts/RootProvider.tsx -모든 Context를 통합하는 최상위 Provider - -```tsx -export function RootProvider({ children }: { children: ReactNode }) { - return ( - - - - - - - - - - - {children} - - - - - - - - - - - ); -} -``` - ---- - -## 마이그레이션 체크리스트 - -### Phase 1: 준비 -- [x] 전체 구조 분석 -- [x] 도메인 분류 설계 -- [ ] 기존 파일 백업 - -### Phase 2: Context 생성 (10개) -- [ ] AuthContext.tsx -- [ ] ItemMasterContext.tsx -- [ ] SalesContext.tsx -- [ ] ProductionContext.tsx -- [ ] InventoryContext.tsx -- [ ] ShippingContext.tsx -- [ ] HRContext.tsx -- [ ] AccountingContext.tsx -- [ ] FacilitiesContext.tsx -- [ ] PricingContext.tsx - -### Phase 3: 통합 -- [ ] RootProvider.tsx 생성 -- [ ] app/layout.tsx에서 RootProvider 적용 -- [ ] 기존 DataContext.tsx 삭제 - -### Phase 4: 검증 -- [ ] 빌드 테스트 (npm run build) -- [ ] 타입 체크 (npm run type-check) -- [ ] 품목관리 페이지 동작 확인 -- [ ] 기타 페이지 동작 확인 - ---- - -## 예상 효과 - -### 파일 크기 감소 -- 기존: 6,707줄 → 각 도메인: 평균 500-1,500줄 -- ItemMaster: ~2,000줄 (가장 큼) -- Auth: ~300줄 (가장 작음) - -### 토큰 사용량 감소 -- 품목관리 작업 시: 70% 감소 -- 기타 페이지 작업 시: 60-80% 감소 - -### 유지보수성 향상 -- 도메인별 독립적 관리 -- 수정 시 영향 범위 명확 -- 협업 시 충돌 최소화 \ No newline at end of file diff --git a/claudedocs/archive/[PLAN-2025-11-21] component-separation.md b/claudedocs/archive/[PLAN-2025-11-21] component-separation.md deleted file mode 100644 index 6d274f33..00000000 --- a/claudedocs/archive/[PLAN-2025-11-21] component-separation.md +++ /dev/null @@ -1,703 +0,0 @@ -# ItemMasterDataManagement.tsx 컴포넌트 분리 계획 - -**작성일**: 2025-11-18 -**원본 파일 크기**: 5,231줄 -**현재 파일 크기**: 3,254줄 (37.8% 절감!) -**목표 파일 크기**: 1,500-2,000줄 (60-65% 감소) - ---- - -## 📊 현재 상태 분석 - -### 파일 구성 -``` -ItemMasterDataManagement.tsx (5,231줄) -├── State 선언 (121개 useState) -├── Handler 함수 (31개) -├── 유틸리티 함수 (59개) -├── TabsContent 블록들 (약 895줄) -│ ├── attributes (558줄) ✅ 분리 완료 → MasterFieldTab.tsx -│ ├── items (12줄) -│ ├── sections (242줄) -│ ├── hierarchy (43줄) ✅ 분리 완료 → HierarchyTab.tsx -│ └── categories (40줄) ✅ 분리 완료 → CategoryTab.tsx -└── Dialog/Drawer 블록들 (약 2,302줄, 18개) -``` - -### 이미 분리 완료된 컴포넌트 ✅ -1. **CategoryTab.tsx** (약 40줄) -2. **MasterFieldTab.tsx** (약 558줄) -3. **HierarchyTab.tsx** (약 43줄) - -**총 분리 완료**: 약 641줄 - ---- - -## 🎯 분리 계획 상세 - -### Phase 1: Dialog 컴포넌트 분리 (우선순위 1) -**예상 절감**: 약 2,300줄 - -#### 1.1 필드 관리 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/FieldDialog.tsx -``` -- **위치**: line 3647-4156 (약 510줄) -- **기능**: 필드 추가/편집 -- **Props 필요**: - - isOpen, onOpenChange - - selectedSection - - editingFieldId - - onSave (handleSaveField) - - masterFields - - fieldType states (name, key, inputType, etc.) - -#### 1.2 필드 드로어 (모바일) -``` -src/components/items/ItemMasterDataManagement/dialogs/FieldDrawer.tsx -``` -- **위치**: line 4157-4665 (약 508줄) -- **기능**: 모바일용 필드 편집 드로어 -- **Props**: FieldDialog와 동일 - -#### 1.3 페이지 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/PageDialog.tsx -``` -- **위치**: line 3559-3595 (약 36줄) -- **기능**: 페이지(섹션) 추가 -- **Props**: - - isOpen, onOpenChange - - onSave (handleAddPage) - -#### 1.4 섹션 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/SectionDialog.tsx -``` -- **위치**: line 3596-3646 (약 50줄) -- **기능**: 하위섹션 추가 -- **Props**: - - isOpen, onOpenChange - - selectedPage - - onSave (handleAddSection) - -#### 1.5 마스터 필드 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/MasterFieldDialog.tsx -``` -- **위치**: line 4729-4908 (약 180줄) -- **기능**: 마스터 항목 추가/편집 -- **Props**: - - isOpen, onOpenChange - - editingMasterFieldId - - onSave (handleSaveMasterField) - - field states - -#### 1.6 섹션 템플릿 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/SectionTemplateDialog.tsx -``` -- **위치**: line 4909-5005 (약 97줄) -- **기능**: 섹션 템플릿 생성 -- **Props**: - - isOpen, onOpenChange - - onSave (handleSaveTemplate) - -#### 1.7 템플릿 필드 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/TemplateFieldDialog.tsx -``` -- **위치**: line 5006-5146 (약 141줄) -- **기능**: 템플릿 항목 추가/편집 -- **Props**: - - isOpen, onOpenChange - - currentTemplateId - - editingTemplateFieldId - - onSave - -#### 1.8 템플릿 불러오기 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/LoadTemplateDialog.tsx -``` -- **위치**: line 5147-5230 (약 84줄) -- **기능**: 섹션 템플릿 불러오기 -- **Props**: - - isOpen, onOpenChange - - sectionTemplates - - onLoad (handleLoadTemplate) - -#### 1.9 옵션 관리 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/OptionDialog.tsx -``` -- **위치**: line 3236-3382 (약 147줄) -- **기능**: 단위/재질/표면처리 옵션 추가 -- **Props**: - - isOpen, onOpenChange - - optionType - - onSave (handleAddOption) - -#### 1.10 칼럼 관리 다이얼로그들 -``` -src/components/items/ItemMasterDataManagement/dialogs/ColumnManageDialog.tsx -src/components/items/ItemMasterDataManagement/dialogs/ColumnDialog.tsx -``` -- **위치**: line 3383-3518, 4666-4728 (약 210줄) -- **기능**: 칼럼 구조 관리 -- **Props**: 칼럼 관련 states 및 handlers - -#### 1.11 탭 관리 다이얼로그들 -``` -src/components/items/ItemMasterDataManagement/dialogs/TabManagementDialogs.tsx -``` -- **위치**: line 2929-3235 (약 307줄) -- **포함 다이얼로그**: - - ManageTabsDialog - - DeleteTabDialog (AlertDialog) - - AddTabDialog - - ManageAttributeTabsDialog - - DeleteAttributeTabDialog (AlertDialog) - - AddAttributeTabDialog -- **Props**: 탭 관련 모든 states 및 handlers - -#### 1.12 경로 편집 다이얼로그 -``` -src/components/items/ItemMasterDataManagement/dialogs/PathEditDialog.tsx -``` -- **위치**: line 3519-3558 (약 40줄) -- **기능**: 절대경로 편집 -- **Props**: - - editingPathPageId - - onOpenChange, onSave - ---- - -### Phase 2: 타입 정의 분리 (우선순위 2) ⭐ 순서 변경 -**예상 절감**: 약 25줄 (수정됨) -**변경 이유**: 빠른 작업, 코드 정리 -**참고**: 주요 타입들은 ItemMasterContext에 이미 정의되어 있음 - -``` -src/components/items/ItemMasterDataManagement/types.ts -``` - -#### 분리할 로컬 타입들 (3개) -- **ItemCategoryStructure** - 품목 카테고리 구조 (4줄) -- **OptionColumn** - 옵션 컬럼 타입 (7줄) -- **MasterOption** - 마스터 옵션 타입 (14줄) - -#### Context에서 이미 Import하는 타입들 (분리 불필요) -- ItemPage, ItemSection, ItemField -- FieldDisplayCondition, ItemMasterField -- ItemFieldProperty, SectionTemplate - ---- - -### Phase 3: 추가 탭 컴포넌트 분리 (우선순위 3) ⭐ 순서 변경 -**예상 절감**: 약 254줄 -**변경 이유**: 가시적 효과, Dialog 분리와 유사한 패턴 - -#### 3.1 섹션 관리 탭 -``` -src/components/items/ItemMasterDataManagement/tabs/SectionsTab.tsx -``` -- **위치**: line 2604-2846 (약 242줄) -- **기능**: 섹션 템플릿 관리 -- **Props**: - - sectionTemplates - - handlers (CRUD) - -#### 3.2 아이템 탭 -``` -src/components/items/ItemMasterDataManagement/tabs/ItemsTab.tsx -``` -- **위치**: line 2592-2604 (약 12줄) -- **기능**: 아이템 목록 (단순) -- **Props**: itemMasters - ---- - -### Phase 4: 유틸리티 & Hooks 통합 분리 (우선순위 4) ⭐ Phase 통합 -**예상 절감**: 약 900줄 (Utils 500줄 + Hooks 400줄) -**변경 이유**: 순수 Utils가 적음, Hooks와 함께 정리하는 게 효율적 - -#### 4.1 Utils 파일 생성 -``` -src/components/items/ItemMasterDataManagement/utils/ -├── pathUtils.ts - 경로 생성/관리 함수 -├── fieldUtils.ts - 필드 생성/검증 함수 -├── sectionUtils.ts - 섹션 관리 함수 -└── validationUtils.ts - 유효성 검증 함수 -``` - -**주요 유틸리티 함수들**: -- `generateAbsolutePath()` - 절대경로 생성 -- `generateFieldKey()` - 필드 키 생성 -- `validateField()` - 필드 검증 -- `findFieldByKey()` - 필드 검색 -- 기타 순수 함수들 - -#### 4.2 Custom Hooks 생성 -``` -src/components/items/ItemMasterDataManagement/hooks/ -├── usePageManagement.ts - 페이지 관리 로직 -├── useSectionManagement.ts - 섹션 관리 로직 -├── useFieldManagement.ts - 필드 관리 로직 -├── useTemplateManagement.ts - 템플릿 관리 로직 -└── useTabManagement.ts - 탭 관리 로직 -``` - -**분리할 Handler들**: -- Page 관련 (5개): handleAddPage, handleDeletePage, handleUpdatePage, etc. -- Section 관련 (8개): handleAddSection, handleDeleteSection, handleUpdateSection, etc. -- Field 관련 (10개): handleAddField, handleEditField, handleDeleteField, etc. -- Template 관련 (6개): handleSaveTemplate, handleLoadTemplate, etc. -- Tab 관련 (6개): handleAddTab, handleDeleteTab, handleUpdateTab, etc. - ---- - -## 📦 최종 디렉토리 구조 - -``` -src/components/items/ItemMasterDataManagement/ -├── index.tsx # 메인 컴포넌트 (약 1,500-2,000줄) -├── tabs/ -│ ├── CategoryTab.tsx # ✅ 완료 (40줄) -│ ├── MasterFieldTab.tsx # ✅ 완료 (558줄) -│ ├── HierarchyTab.tsx # ✅ 완료 (43줄) -│ ├── SectionsTab.tsx # ⏳ 예정 (242줄) -│ └── ItemsTab.tsx # ⏳ 예정 (12줄) -├── dialogs/ -│ ├── FieldDialog.tsx # ⏳ 예정 (510줄) -│ ├── FieldDrawer.tsx # ⏳ 예정 (508줄) -│ ├── PageDialog.tsx # ⏳ 예정 (36줄) -│ ├── SectionDialog.tsx # ⏳ 예정 (50줄) -│ ├── MasterFieldDialog.tsx # ⏳ 예정 (180줄) -│ ├── SectionTemplateDialog.tsx # ⏳ 예정 (97줄) -│ ├── TemplateFieldDialog.tsx # ⏳ 예정 (141줄) -│ ├── LoadTemplateDialog.tsx # ⏳ 예정 (84줄) -│ ├── OptionDialog.tsx # ⏳ 예정 (147줄) -│ ├── ColumnManageDialog.tsx # ⏳ 예정 (100줄) -│ ├── ColumnDialog.tsx # ⏳ 예정 (110줄) -│ ├── TabManagementDialogs.tsx # ⏳ 예정 (307줄) -│ └── PathEditDialog.tsx # ⏳ 예정 (40줄) -├── hooks/ -│ ├── usePageManagement.ts # ⏳ 예정 -│ ├── useSectionManagement.ts # ⏳ 예정 -│ ├── useFieldManagement.ts # ⏳ 예정 -│ ├── useTemplateManagement.ts # ⏳ 예정 -│ └── useTabManagement.ts # ⏳ 예정 -├── utils/ -│ ├── pathUtils.ts # ⏳ 예정 -│ ├── fieldUtils.ts # ⏳ 예정 -│ ├── sectionUtils.ts # ⏳ 예정 -│ └── validationUtils.ts # ⏳ 예정 -└── types.ts # ⏳ 예정 (200줄) -``` - ---- - -## 📈 예상 효과 - -### 파일 크기 변화 (⭐ Phase 순서 변경됨) -| 단계 | 작업 | 예상 감소 | 누적 감소 | 남은 크기 | -|-----|-----|---------|---------|---------| -| **시작** | - | - | - | **5,231줄** | -| Phase 0 (완료) | Tabs 분리 | 641줄 | 641줄 | 4,590줄 | -| Phase 1 (완료) | Dialogs 분리 | 1,977줄 | 2,618줄 | 2,613줄 | -| **Phase 2 (다음)** | **Types 분리** | **200줄** | **2,818줄** | **2,413줄** | -| Phase 3 | 추가 Tabs | 254줄 | 3,072줄 | 2,159줄 | -| Phase 4 | Utils + Hooks | 900줄 | 3,972줄 | **1,259줄** | - -### 최종 목표 -- **메인 파일**: 약 936-1,500줄 (현재 대비 70-82% 감소) -- **분리된 컴포넌트**: 13개 다이얼로그, 5개 탭, 5개 hooks, 4개 utils, 1개 types -- **총 파일 수**: 약 28개 파일 - ---- - -## 🚀 실행 계획 - -### 우선순위별 작업 순서 - -#### 1단계: 대형 다이얼로그 분리 (즉시 시작) -```bash -# 가장 큰 것부터 분리 -1. FieldDialog.tsx (510줄) -2. FieldDrawer.tsx (508줄) -3. TabManagementDialogs.tsx (307줄) -4. ColumnDialogs (210줄) -5. MasterFieldDialog.tsx (180줄) -``` -**예상 절감**: 약 1,700줄 - -#### 2단계: 나머지 다이얼로그 분리 -```bash -6. OptionDialog.tsx (147줄) -7. TemplateFieldDialog.tsx (141줄) -8. SectionTemplateDialog.tsx (97줄) -9. LoadTemplateDialog.tsx (84줄) -10. SectionDialog.tsx (50줄) -11. PathEditDialog.tsx (40줄) -12. PageDialog.tsx (36줄) -``` -**예상 절감**: 약 600줄 - -#### 3단계: 유틸리티 함수 분리 -```bash -- pathUtils.ts -- fieldUtils.ts -- sectionUtils.ts -- validationUtils.ts -``` -**예상 절감**: 약 500줄 - -#### 4단계: 타입 정의 분리 -```bash -- types.ts -``` -**예상 절감**: 약 200줄 - -#### 5단계: Custom Hooks 분리 -```bash -- usePageManagement.ts -- useSectionManagement.ts -- useFieldManagement.ts -- useTemplateManagement.ts -- useTabManagement.ts -``` -**예상 절감**: 약 400줄 - ---- - -## ✅ 작업 체크리스트 (세션 중단 시 여기서 이어서 진행) - -### Phase 0: 기존 Tab 분리 (완료) -- [x] CategoryTab.tsx (40줄) - ✅ **완료** -- [x] MasterFieldTab.tsx (558줄) - ✅ **완료** -- [x] HierarchyTab.tsx (43줄) - ✅ **완료** -- [x] 분리 계획 문서 작성 - ✅ **완료** - -### Phase 1: Dialog 컴포넌트 분리 (2,300줄 절감 목표) - -#### 1-1. 디렉토리 구조 준비 -- [x] `dialogs/` 디렉토리 생성 - ✅ **완료** - -#### 1-2. 대형 다이얼로그 (우선순위 최상) -- [x] **FieldDialog.tsx** (510줄) - line 3647-4156 - ✅ **완료 (462줄 절감)** - - [x] 컴포넌트 추출 및 파일 생성 - - [x] Props 인터페이스 정의 - - [x] 메인 파일에서 import로 교체 - - [x] 빌드 테스트 - ✅ **통과** - -- [x] **FieldDrawer.tsx** (508줄) - line 3696-4203 - ✅ **완료 (462줄 절감)** - - [x] 컴포넌트 추출 및 파일 생성 - - [x] Props 인터페이스 정의 - - [x] 메인 파일에서 import로 교체 - - [x] 빌드 테스트 - ✅ **통과** - -- [x] **TabManagementDialogs.tsx** (307줄) - line 2930-3236 - ✅ **완료 (265줄 절감)** - - [x] 6개 다이얼로그 추출 - - [x] Props 인터페이스 정의 - - [x] 메인 파일에서 import로 교체 - - [x] 빌드 테스트 - ✅ **통과** - -#### 1-3. 칼럼 관리 다이얼로그 -- [x] **ColumnManageDialog.tsx** (135줄) - ✅ **완료 (119줄 절감)** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - - [x] 빌드 테스트 - ✅ **통과** - -- [x] **ColumnDialog.tsx** (110줄) - ✅ **완료 (48줄 절감)** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - - [x] 빌드 테스트 - ✅ **통과** - -#### 1-4. 필드 관련 다이얼로그 -- [x] **MasterFieldDialog.tsx** (180줄) - ✅ **완료 (148줄 절감)** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - - [x] 빌드 테스트 - ✅ **통과** - -- [x] **OptionDialog.tsx** (147줄) - line 2973-3119 - ✅ **완료 (122줄 절감)** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - - [x] 빌드 테스트 - ✅ **통과** - -#### 1-5. 템플릿 관련 다이얼로그 -- [x] **TemplateFieldDialog.tsx** (141줄) - ✅ **완료 (113줄 절감)** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - - [x] 빌드 테스트 - ✅ **통과** - -- [x] **SectionTemplateDialog.tsx** (97줄) - ✅ **완료 (78줄 절감)** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - - [x] 빌드 테스트 - ✅ **통과** - -- [x] **LoadTemplateDialog.tsx** (84줄) - ✅ **완료 (74줄 절감)** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - - [x] 빌드 테스트 - ✅ **통과** - -#### 1-6. 기타 다이얼로그 -- [x] **PathEditDialog.tsx** (40줄) - ✅ **완료** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - -- [x] **PageDialog.tsx** (36줄) - ✅ **완료** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - -- [x] **SectionDialog.tsx** (50줄) - ✅ **완료 (총 95줄 절감)** - - [x] 컴포넌트 추출 - - [x] Props 정의 - - [x] 메인 파일 교체 - - [x] 빌드 테스트 - ✅ **통과** - -#### 1-7. Phase 1 완료 검증 -- [x] 모든 다이얼로그 분리 완료 확인 - ✅ **13개 다이얼로그 분리 완료** -- [x] TypeScript 에러 없음 확인 - ✅ **통과** -- [x] 빌드 성공 확인 - ✅ **통과** -- [x] **현재 파일 크기 확인** - ✅ **3,254줄 (목표 2,900줄 이하 달성!)** - ---- - -### Phase 2: 타입 정의 분리 (25줄 절감 목표) ⭐ 순서 변경 - -#### 2-1. 타입 파일 생성 -- [x] `types.ts` 생성 ✅ - -#### 2-2. 로컬 타입 정의 이동 (2개 - ItemCategoryStructure는 존재하지 않음) -- [x] OptionColumn 타입 ✅ -- [x] MasterOption 타입 ✅ - -#### 2-3. Phase 2 완료 검증 -- [x] types.ts 생성 완료 ✅ -- [x] 메인 파일에서 import 확인 ✅ -- [x] Dialog 파일에서 import 확인 (ColumnManageDialog) ✅ -- [x] 빌드 테스트 진행 중 ✅ -- [ ] **현재 파일 크기 확인** (목표: ~3,230줄 이하) - ---- - -### Phase 3: 추가 탭 컴포넌트 분리 (254줄 절감 목표) ⭐ 순서 변경 - -#### 3-1. 섹션 탭 분리 -- [x] **SectionsTab.tsx** (239줄) - line 2878-3117 - ✅ **완료** - - [x] 컴포넌트 추출 ✅ - - [x] Props 정의 ✅ - - [x] 메인 파일 교체 ✅ - - [x] tabs/index.ts export 추가 ✅ - - [x] 빌드 테스트 ✅ - -#### 3-2. 아이템 탭 분리 -- [x] **MasterFieldTab.tsx** (558줄) - ✅ **Phase 1에서 이미 완료** - - [x] 컴포넌트 추출 (Phase 1 완료) - - [x] Props 정의 (Phase 1 완료) - - [x] 메인 파일 교체 (Phase 1 완료) - - ℹ️ ItemsTab은 MasterFieldTab으로 이미 분리됨 - -#### 3-3. Phase 3 완료 검증 -- [x] 탭 컴포넌트 분리 완료 ✅ (SectionsTab + MasterFieldTab) -- [ ] 빌드 성공 확인 -- [ ] **현재 파일 크기 확인** (목표: ~3,000줄 이하) - ---- - -### Phase 4: Utils & Hooks 통합 분리 (900줄 절감 목표) ⭐ Phase 통합 - -#### 4-1. Utils 분리 -- [x] `utils/` 디렉토리 생성 ✅ -- [x] **pathUtils.ts** ✅ **완료** - - [x] generateAbsolutePath() 이동 ✅ - - [x] getItemTypeLabel() 추가 ✅ - - [x] 메인 파일에서 import 적용 ✅ -- [ ] **fieldUtils.ts** ⏸️ **주말 작업으로 연기** - - [ ] generateFieldKey() 이동 - - [ ] findFieldByKey() 이동 - - [ ] 필드 관련 helper 함수들 이동 -- [ ] **sectionUtils.ts** ⏸️ **주말 작업으로 연기** - - [ ] moveSection() 이동 - - [ ] 섹션 관련 helper 함수들 이동 -- [ ] **validationUtils.ts** ⏸️ **주말 작업으로 연기** - - [ ] validateField() 이동 - - [ ] 유효성 검증 함수들 이동 - -#### 4-2. Hooks 분리 ⏸️ **주말 작업으로 연기** -- [ ] `hooks/` 디렉토리 생성 ⏸️ **주말 작업** -- [ ] **usePageManagement.ts** ⏸️ **주말 작업** - - [ ] handleAddPage, handleDeletePage, handleUpdatePage 등 - - [ ] 관련 state 및 handler 5개 이동 -- [ ] **useSectionManagement.ts** ⏸️ **주말 작업** - - [ ] handleAddSection, handleDeleteSection 등 - - [ ] 관련 state 및 handler 8개 이동 -- [ ] **useFieldManagement.ts** ⏸️ **주말 작업** - - [ ] handleAddField, handleEditField 등 - - [ ] 관련 state 및 handler 10개 이동 -- [ ] **useTemplateManagement.ts** ⏸️ **주말 작업** - - [ ] handleSaveTemplate, handleLoadTemplate 등 - - [ ] 관련 state 및 handler 6개 이동 -- [ ] **useTabManagement.ts** ⏸️ **주말 작업** - - [ ] handleAddTab, handleDeleteTab 등 - - [ ] 관련 state 및 handler 6개 이동 - -#### 4-3. Phase 4 Utils 부분 완료 검증 -- [x] pathUtils 분리 완료 ✅ -- [x] 메인 파일에서 import 적용 ✅ -- [ ] **Hooks 분리는 주말 작업으로 연기** ⏸️ -- [ ] **빌드 성공 확인** (다음 작업) -- [ ] **최종 파일 크기 확인** (목표: ~1,300줄 이하 - Hooks 완료 후) - ---- - -### 최종 검증 체크리스트 - -- [ ] **메인 파일 크기**: 1,500줄 이하 달성 -- [ ] **TypeScript 에러**: 0개 -- [ ] **빌드 에러**: 0개 -- [ ] **ESLint 경고**: 최소화 -- [ ] **기능 테스트**: 모든 다이얼로그 정상 동작 -- [ ] **탭 테스트**: 모든 탭 전환 정상 동작 -- [ ] **데이터 저장**: localStorage 정상 동작 -- [ ] **코드 리뷰**: 가독성 향상 확인 - ---- - -## 📝 작업 이력 (날짜별) - -### 2025-11-18 (오전) -- ✅ CategoryTab 분리 완료 (40줄) -- ✅ MasterFieldTab 분리 완료 (558줄) -- ✅ HierarchyTab 분리 완료 (43줄) -- ✅ 분리 계획 문서 작성 완료 -- ✅ 체크리스트 기반 작업 문서로 업데이트 - -### 2025-11-18 (오후) - Phase 1 Dialog 분리 완료 ✅ -- ✅ dialogs/ 디렉토리 생성 완료 -- ✅ **FieldDialog.tsx** 분리 완료 (462줄 절감) - 빌드 테스트 통과 -- ✅ **FieldDrawer.tsx** 분리 완료 (462줄 절감) - 빌드 테스트 통과 -- ✅ **TabManagementDialogs.tsx** 분리 완료 (265줄 절감) - 6개 다이얼로그 통합 -- ✅ **OptionDialog.tsx** 분리 완료 (122줄 절감) -- ✅ **ColumnManageDialog.tsx** 분리 완료 (119줄 절감) -- ✅ **PathEditDialog.tsx, PageDialog.tsx, SectionDialog.tsx** 분리 완료 (95줄 절감) -- ✅ **MasterFieldDialog.tsx** 분리 완료 (148줄 절감) -- ✅ **TemplateFieldDialog.tsx** 분리 완료 (113줄 절감) -- ✅ **SectionTemplateDialog.tsx** 분리 완료 (78줄 절감) -- ✅ **LoadTemplateDialog.tsx** 분리 완료 (74줄 절감) -- ✅ **ColumnDialog.tsx** 분리 완료 (48줄 절감) -- 📊 **최종 상태**: 5,231줄 → 3,254줄 (1,977줄 절감, 37.8%) -- 🎉 **Phase 1 완료!** 목표 ~2,900줄 이하 달성 (3,254줄) - -### 2025-11-18 (저녁) - Phase 순서 재조정 및 Phase 2 조사 완료 ⭐ -- 📋 **Phase 순서 변경 결정**: 효율성 극대화를 위해 순서 조정 - - **Phase 2**: Utils → **Types 분리** (빠른 효과, 다른 Phase 기반) - - **Phase 3**: Types → **Tabs 분리** (가시적 효과) - - **Phase 4**: Tabs/Hooks → **Utils + Hooks 통합** (대규모 정리) -- 🔍 **Phase 2 범위 조사 완료**: - - 초기 예상: 200줄 → 실제: 25줄 (로컬 타입 3개만 존재) - - 주요 타입들은 이미 ItemMasterContext에서 import 중 - - 분리 대상: ItemCategoryStructure, OptionColumn, MasterOption -- ✅ COMPONENT_SEPARATION_PLAN.md 문서 업데이트 완료 (정확한 Phase 2 범위 반영) - ---- - -### 🎯 세션 체크포인트 (2025-11-18 종료) - -#### ✅ 완료된 작업 -- **Phase 1 완전 완료**: 13개 다이얼로그 분리 -- **파일 크기 절감**: 5,231줄 → 3,254줄 (1,977줄 절감, 37.8%) -- **Phase 순서 최적화**: 효율성 기반 순서 재조정 완료 -- **Phase 2 사전 조사**: 실제 범위 확인 및 문서 업데이트 - -#### 📋 다음 세션 시작 시 작업 -1. **Phase 2: Types 분리** (25줄 절감 목표) - - types.ts 파일 생성 - - ItemCategoryStructure, OptionColumn, MasterOption 추출 - - 메인 파일에서 import 수정 - - 빌드 테스트 - -2. **Phase 3: Tabs 분리** (254줄 절감 목표) - - SectionsTab.tsx (242줄) - - ItemsTab.tsx (12줄) - -3. **Phase 4: Utils + Hooks 통합 분리** (900줄 절감 목표) - -#### 📊 현재 상태 -- **메인 파일**: 3,254줄 -- **분리된 컴포넌트**: 13개 다이얼로그, 3개 탭 -- **최종 목표까지**: 약 2,000줄 추가 절감 필요 - -#### 💾 세션 재개 명령 -```bash -# 다음 세션 시작 시: -1. COMPONENT_SEPARATION_PLAN.md 확인 -2. Phase 2 체크리스트부터 시작 -3. 문서의 "### Phase 2: 타입 정의 분리" 섹션 참고 -``` - ---- - -### 🚀 **다음 작업**: Phase 2 (Types 분리) - 내일 시작 예정 - ---- - -## 🔄 세션 재개 가이드 - -**세션이 중단되었을 때 이 문서를 기준으로 작업 재개:** - -1. 위 체크리스트에서 **체크되지 않은 첫 번째 항목** 찾기 -2. 해당 항목의 **line 번호**와 **예상 라인 수** 확인 -3. `ItemMasterDataManagement.tsx` 파일에서 해당 섹션 Read -4. 새 파일 생성 및 컴포넌트 추출 -5. Props 인터페이스 정의 -6. 메인 파일에서 해당 부분을 import로 교체 -7. 빌드 테스트 (`npm run build`) -8. 체크리스트 업데이트 (체크 표시) -9. 다음 항목으로 이동 - -**현재 진행 상태**: Phase 0 완료, Phase 1 시작 대기 - ---- - -## 💡 주의사항 - -### Props Drilling 방지 -- Context API 또는 Zustand 활용 고려 -- 현재 ItemMasterContext가 있으므로 최대한 활용 - -### 타입 안정성 유지 -- 모든 분리된 컴포넌트에 명확한 Props 타입 정의 -- types.ts에서 중앙 관리 - -### 재사용성 고려 -- Dialog 컴포넌트는 독립적으로 재사용 가능하게 -- Utils는 순수 함수로 작성 - -### 테스트 필요성 -- 각 분리 단계마다 빌드 테스트 필수 -- 기능 동작 검증 필요 - ---- - -## 🎯 성공 기준 - -1. ✅ 메인 파일 크기 1,500줄 이하 달성 -2. ✅ 빌드 에러 없음 -3. ✅ 모든 기능 정상 동작 -4. ✅ 타입 에러 없음 -5. ✅ 코드 가독성 향상 - ---- - -**문서 버전**: 1.0 -**마지막 업데이트**: 2025-11-18 \ No newline at end of file diff --git a/claudedocs/archive/[REF-2025-11-18] cleanup-summary.md b/claudedocs/archive/[REF-2025-11-18] cleanup-summary.md deleted file mode 100644 index f425f488..00000000 --- a/claudedocs/archive/[REF-2025-11-18] cleanup-summary.md +++ /dev/null @@ -1,243 +0,0 @@ -# 미사용 파일 정리 완료 보고서 - -**작업 일시**: 2025-11-18 -**작업 범위**: 미사용 Context 파일 및 컴포넌트 정리 - ---- - -## ✅ 작업 완료 내역 - -### Phase 1: 미사용 Context 8개 정리 - -#### 이동된 파일 (contexts/_unused/) -1. FacilitiesContext.tsx -2. AccountingContext.tsx -3. HRContext.tsx -4. ShippingContext.tsx -5. InventoryContext.tsx -6. ProductionContext.tsx -7. PricingContext.tsx -8. SalesContext.tsx - -#### 수정된 파일 -- **RootProvider.tsx** - - 8개 Context import 제거 - - Provider 중첩 10개 → 2개로 단순화 - - 현재 사용: AuthProvider, ItemMasterProvider만 유지 - - 주석 업데이트로 미사용 Context 목록 명시 - -#### 이동된 컴포넌트 -- **BOMManager.tsx** → `components/_unused/business/` - - 485 라인의 구형 컴포넌트 - - BOMManagementSection으로 대체됨 - -#### 빌드 검증 -- ✅ `npm run build` 성공 -- ✅ 모든 페이지 정상 빌드 (36개 라우트) -- ✅ 에러 없음 - ---- - -### Phase 2: DeveloperModeContext 정리 - -#### 이동된 파일 -- **DeveloperModeContext.tsx** → `contexts/_unused/` - - Provider는 연결되어 있었으나 실제 devMetadata 기능 미사용 - - 향후 필요 시 복원 가능 - -#### 수정된 파일 -1. **src/app/[locale]/(protected)/layout.tsx** - - DeveloperModeProvider import 제거 - - Provider 래핑 제거 - - 주석 업데이트 - -2. **src/components/organisms/PageLayout.tsx** - - useDeveloperMode import 제거 - - devMetadata prop 제거 - - useEffect 및 관련 로직 제거 - - ComponentMetadata interface 의존성 제거 - -#### 빌드 검증 -- ✅ `npm run build` 성공 -- ✅ 모든 페이지 정상 빌드 -- ✅ 에러 없음 - ---- - -### Phase 3: .gitignore 업데이트 - -#### 추가된 항목 -```gitignore -# ---> Unused components and contexts (archived) -src/components/_unused/ -src/contexts/_unused/ -``` - -**효과**: _unused 디렉토리가 git 추적에서 제외됨 - ---- - -## 📊 정리 결과 - -### 파일 구조 (Before → After) - -**src/contexts/ (Before)** -``` -contexts/ -├── AuthContext.tsx ✅ -├── FacilitiesContext.tsx ❌ -├── AccountingContext.tsx ❌ -├── HRContext.tsx ❌ -├── ShippingContext.tsx ❌ -├── InventoryContext.tsx ❌ -├── ProductionContext.tsx ❌ -├── PricingContext.tsx ❌ -├── SalesContext.tsx ❌ -├── ItemMasterContext.tsx ✅ -├── ThemeContext.tsx ✅ -├── DeveloperModeContext.tsx ❌ -├── RootProvider.tsx (10개 Provider 중첩) -└── DataContext.tsx.backup -``` - -**src/contexts/ (After)** -``` -contexts/ -├── AuthContext.tsx ✅ (사용 중) -├── ItemMasterContext.tsx ✅ (사용 중) -├── ThemeContext.tsx ✅ (사용 중) -├── RootProvider.tsx (2개 Provider만 유지) -├── DataContext.tsx.backup -└── _unused/ (git 무시) - ├── FacilitiesContext.tsx - ├── AccountingContext.tsx - ├── HRContext.tsx - ├── ShippingContext.tsx - ├── InventoryContext.tsx - ├── ProductionContext.tsx - ├── PricingContext.tsx - ├── SalesContext.tsx - └── DeveloperModeContext.tsx -``` - -### 코드 감소량 - -| 항목 | Before | After | 감소량 | -|------|--------|-------|--------| -| Context Provider 중첩 | 10개 | 2개 | -8개 (80% 감소) | -| RootProvider.tsx | 81 lines | 48 lines | -33 lines | -| Active Context 파일 | 13개 | 4개 | -9개 | -| 미사용 코드 | ~3,000 lines | 0 lines | ~3,000 lines | - -### 성능 개선 - -1. **앱 초기화 속도** - - Provider 중첩 10개 → 2개 - - 불필요한 Context 초기화 제거 - -2. **번들 크기** - - Tree-shaking으로 미사용 코드 제거 - - First Load JS 유지: ~102 kB (변화 없음, 원래 사용 안했으므로) - -3. **유지보수성** - - 코드베이스 명확성 증가 - - 혼란 방지 (어떤 Context를 사용하는지 명확) - ---- - -## 🎯 현재 활성 Context - -### 1. AuthContext.tsx -**용도**: 사용자 인증 및 권한 관리 -**상태 수**: 2개 (users, currentUser) -**사용처**: LoginPage, SignupPage, useAuth hook - -### 2. ItemMasterContext.tsx -**용도**: 품목 마스터 데이터 관리 -**상태 수**: 13개 (itemMasters, specificationMasters, etc.) -**사용처**: ItemMasterDataManagement - -### 3. ThemeContext.tsx -**용도**: 다크모드/라이트모드 테마 관리 -**사용처**: DashboardLayout, ThemeSelect - -### 4. RootProvider.tsx -**용도**: 전역 Context 통합 -**Provider**: AuthProvider, ItemMasterProvider - ---- - -## 📁 _unused 디렉토리 관리 - -### 위치 -- `src/contexts/_unused/` (9개 Context 파일) -- `src/components/_unused/` (43개 구형 컴포넌트) - -### Git 설정 -- ✅ .gitignore에 추가됨 -- ✅ 버전 관리에서 제외 -- ✅ 로컬에만 보관 (팀원과 공유 안됨) - -### 복원 방법 -필요 시 다음 단계로 복원 가능: - -1. **파일 이동** - ```bash - mv src/contexts/_unused/SalesContext.tsx src/contexts/ - ``` - -2. **RootProvider.tsx 수정** - ```typescript - import { SalesProvider } from './SalesContext'; - - // Provider 추가 - - {/* ... */} - - ``` - -3. **빌드 검증** - ```bash - npm run build - ``` - ---- - -## ⚠️ 주의사항 - -### 향후 기능 추가 시 - -**미사용 Context를 사용해야 하는 경우:** -1. _unused에서 필요한 Context 복원 -2. RootProvider에 Provider 추가 -3. 필요한 페이지/컴포넌트에서 hook 사용 -4. 빌드 및 테스트 - -**새로운 Context 추가 시:** -1. 새 Context 파일 생성 -2. RootProvider에 Provider 추가 -3. SSR-safe 패턴 준수 (localStorage 접근 시) - ---- - -## 📝 관련 문서 - -- [UNUSED_FILES_REPORT.md](./UNUSED_FILES_REPORT.md) - 미사용 파일 분석 보고서 -- [SSR_HYDRATION_FIX.md](./SSR_HYDRATION_FIX.md) - SSR Hydration 에러 해결 - ---- - -## ✨ 작업 요약 - -**정리된 항목**: 10개 파일 (Context 9개 + 컴포넌트 1개) -**수정된 파일**: 4개 (RootProvider, layout, PageLayout, .gitignore) -**빌드 검증**: 2회 성공 (Phase 1, Phase 2) -**코드 감소**: ~3,000 라인 -**Provider 감소**: 80% (10개 → 2개) - -**결과**: -- ✅ 코드베이스 단순화 완료 -- ✅ 유지보수성 향상 -- ✅ 성능 개선 (Provider 초기화 감소) -- ✅ 향후 복원 가능 (_unused 보관) -- ✅ 빌드 에러 없음 \ No newline at end of file diff --git a/claudedocs/archive/[REF-2025-11-18] unused-files-report.md b/claudedocs/archive/[REF-2025-11-18] unused-files-report.md deleted file mode 100644 index 60f3ea0b..00000000 --- a/claudedocs/archive/[REF-2025-11-18] unused-files-report.md +++ /dev/null @@ -1,248 +0,0 @@ -# 미사용 파일 분석 보고서 - -## 📊 요약 - -**총 미사용 파일: 51개** -- Context 파일: 8개 (전혀 사용 안함) -- Active 컴포넌트: 1개 (BOMManager.tsx) -- 부분 사용: 1개 (DeveloperModeContext.tsx) -- 이미 정리됨: 42개 (components/_unused/) - -## 🔴 완전 미사용 파일 (삭제 권장) - -### Context 파일 (8개) -모두 `RootProvider.tsx`에만 포함되어 있고, 실제 페이지/컴포넌트에서는 전혀 사용되지 않음 - -| 파일명 | 경로 | 사용처 | 상태 | -|--------|------|--------|------| -| FacilitiesContext.tsx | src/contexts/ | RootProvider만 | ❌ 미사용 | -| AccountingContext.tsx | src/contexts/ | RootProvider만 | ❌ 미사용 | -| HRContext.tsx | src/contexts/ | RootProvider만 | ❌ 미사용 | -| ShippingContext.tsx | src/contexts/ | RootProvider만 | ❌ 미사용 | -| InventoryContext.tsx | src/contexts/ | RootProvider만 | ❌ 미사용 | -| ProductionContext.tsx | src/contexts/ | RootProvider만 | ❌ 미사용 | -| PricingContext.tsx | src/contexts/ | RootProvider만 | ❌ 미사용 | -| SalesContext.tsx | src/contexts/ | RootProvider만 | ❌ 미사용 | - -**영향 분석:** -- 이 8개 Context는 React SPA에서 있었던 것으로 추정 -- Next.js 마이그레이션 후 관련 페이지가 구현되지 않음 -- `RootProvider.tsx`에서만 import되고 실제 사용은 없음 -- 안전하게 제거 가능 (빌드/런타임 영향 없음) - -### 컴포넌트 (1개) - -| 파일명 | 경로 | 라인수 | 사용처 | 상태 | -|--------|------|--------|--------|------| -| BOMManager.tsx | src/components/items/ | 485 | 없음 | ❌ 미사용 | - -**영향 분석:** -- BOMManagementSection.tsx가 대신 사용됨 (ItemMasterDataManagement에서 사용) -- 485줄의 구형 컴포넌트 -- `_unused/` 디렉토리로 이동 권장 - -## 🟡 부분 사용 파일 (검토 필요) - -### DeveloperModeContext.tsx - -**현재 상태:** -- ✅ Provider는 `(protected)/layout.tsx`에 연결됨 -- ✅ `PageLayout.tsx`에서 import하고 사용 -- ❌ 하지만 실제로 `devMetadata` prop을 전달하는 곳은 없음 - -**사용 분석:** -```typescript -// PageLayout.tsx - devMetadata를 받지만... -export function PageLayout({ devMetadata, ... }) { - const { setCurrentMetadata } = useDeveloperMode(); - - useEffect(() => { - if (devMetadata) { // 실제로 devMetadata를 전달하는 곳이 없음 - setCurrentMetadata(devMetadata); - } - }, []); -} - -// ItemMasterDataManagement.tsx - 유일하게 PageLayout을 사용 - {/* devMetadata 전달 안함 */} - ... - -``` - -**권장 사항:** -1. **Option 1 (삭제)**: 개발자 모드 기능을 사용하지 않는다면 제거 -2. **Option 2 (활용)**: 개발자 모드 기능이 필요하면 devMetadata 전달 구현 -3. **Option 3 (보류)**: 향후 사용 계획이 있으면 유지 - -## ✅ 정상 사용 파일 - -### Context (3개) -| 파일명 | 사용처 | -|--------|--------| -| AuthContext.tsx | LoginPage, SignupPage, useAuth hook 사용 중 | -| ItemMasterContext.tsx | ItemMasterDataManagement 등에서 사용 중 | -| ThemeContext.tsx | DashboardLayout, ThemeSelect에서 사용 중 | - -### 컴포넌트 -| 파일명 | 사용처 | -|--------|--------| -| FileUpload.tsx | ItemForm.tsx에서 import 및 사용 | -| DrawingCanvas.tsx | ItemForm.tsx에서 사용 (` | null; // NOT displayCondition - validation_rules?: Record | null; - options?: Array<{ label: string; value: string }> | null; - properties?: Record | null; - created_at: string; - updated_at: string; -} -``` - -### 수정 패턴 -- [ ] `field.name` → `field.field_name` -- [ ] `field.displayCondition` → `field.display_condition` -- [ ] `field.order` → `field.order_no` -- [ ] `{ name: x }` → `{ field_name: x }` -- [ ] `{ displayCondition: x }` → `{ display_condition: x }` - -### 주요 위치 -- [ ] Line 783-822: Field 수정/추가 핸들러 -- [ ] Line 906-920: Field 편집 다이얼로그 -- [ ] Line 1437-1447: 템플릿 필드 편집 -- [ ] 기타 필드 관련 핸들러들 - -**완료 후 확인**: ItemField 관련 오류 0개 - ---- - -## Phase 4: 존재하지 않는 속성 제거/수정 - -**목표**: 타입에 정의되지 않은 속성 제거 또는 올바른 속성으로 대체 - -### ItemMasterField 타입 참조 -```typescript -interface ItemMasterField { - id: number; - field_name: string; // NOT name, NOT fieldKey - field_type: 'TEXT' | 'NUMBER' | 'DATE' | 'SELECT' | 'TEXTAREA' | 'CHECKBOX'; - category?: string | null; - description?: string | null; - validation_rules?: Record | null; // NOT default_validation - properties?: Record | null; // NOT property, NOT default_properties - created_at: string; - updated_at: string; -} -``` - -### SectionTemplate 타입 참조 -```typescript -interface SectionTemplate { - id: number; - template_name: string; // NOT title - section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // NOT type - description?: string | null; - default_fields?: Record | null; // NOT fields, NOT bomItems - created_at: string; - updated_at: string; - - // 주의: category, fields, bomItems, isCollapsible, isCollapsed 속성은 존재하지 않음! -} -``` - -### 제거/수정할 속성들 -- [ ] `field.fieldKey` → 제거 또는 `field.field_name` 사용 -- [ ] `field.property` → `field.properties` (복수형!) -- [ ] `field.default_properties` → 제거 (ItemField에 없음) -- [ ] `template.fields` → 제거 (SectionTemplate에 없음) -- [ ] `template.bomItems` → 제거 (SectionTemplate에 없음) -- [ ] `template.category` → 제거 (SectionTemplate에 없음) -- [ ] `template.isCollapsible` → 제거 -- [ ] `template.isCollapsed` → 제거 - -### 주요 위치 -- [ ] Line 226-241: ItemMasterField fieldKey 참조 -- [ ] Line 437-460: property 속성 접근 -- [ ] Line 793: field.property -- [ ] Line 815: field.property -- [ ] Line 831: field.property (여러 곳) -- [ ] Line 910-913: field.default_properties -- [ ] Line 1154, 1157: field.fieldKey -- [ ] Line 1247-1248: template.category, template.type -- [ ] Line 1300-1313: template.fields, template.bomItems -- [ ] Line 1440-1447: field.default_properties -- [ ] Line 2192, 2205: properties 접근 - -**완료 후 확인**: 존재하지 않는 속성 관련 오류 0개 - ---- - -## Phase 5: ID 타입 통일 - -**목표**: 모든 ID를 string에서 number로 통일 - -### 수정할 ID 타입들 -- [ ] `selectedPageId`: `string | null` → `number | null` -- [ ] `editingPageId`: `string | null` → `number | null` -- [ ] `editingFieldId`: `string | null` → `number | null` -- [ ] `editingMasterFieldId`: `string | null` → `number | null` -- [ ] `currentTemplateId`: `string | null` → `number | null` -- [ ] `editingTemplateId`: `string | null` → `number | null` -- [ ] `editingTemplateFieldId`: `string | null` → `number | null` - -### 관련 수정 -- [ ] 모든 ID 비교: `=== 'string'` → `=== number` -- [ ] 함수 파라미터: `(id: string)` → `(id: number)` -- [ ] State setter 호출: 타입 변환 제거 - -### 주요 위치 -- [ ] Line 313: selectedPageIdFromStorage 타입 -- [ ] Line 314: 비교 연산 -- [ ] Line 591, 701, 723, 934, 1147, 1169, 1190, 1289, 1330, 1453, 1487: ID 비교 -- [ ] Line 623: setSelectedPageId -- [ ] Line 906-907: setEditingFieldId, setSelectedPageId -- [ ] Line 1069: setEditingMasterFieldId -- [ ] Line 1105, 1150: deleteItemMasterField ID -- [ ] Line 1178: deleteItemPage ID -- [ ] Line 1244: setCurrentTemplateId -- [ ] Line 1263, 1277, 1419, 1457: Template ID 함수 호출 -- [ ] Line 1437: setEditingTemplateFieldId - -**완료 후 확인**: ID 타입 불일치 오류 0개 - ---- - -## Phase 6: State 타입 수정 - -**목표**: 로컬 state 타입을 타입 정의와 일치시키기 - -### 수정할 State들 -- [ ] `customTabs` ID: `string` → `number` -- [ ] `MasterOption`: `is_active` → `isActive` (로컬 타입은 camelCase 유지) -- [ ] 기타 타입 불일치 state들 - -### 주요 위치 -- [ ] Line 491: MasterOption `is_active` vs `isActive` -- [ ] Line 1014-1017: customAttributeOptions 타입 -- [ ] Line 1371-1374: customAttributeOptions 타입 -- [ ] Line 1465, 1483: BOM ID 타입 -- [ ] Line 1528: customTabs ID 타입 - -**완료 후 확인**: State 타입 불일치 오류 0개 - ---- - -## Phase 7: 함수 시그니처 수정 및 최종 검증 - -**목표**: 컴포넌트 props와 Context 함수 시그니처 일치시키기 - -### 수정할 함수 시그니처들 -- [ ] `handleDeleteMasterField`: `(id: string)` → `(id: number)` -- [ ] `handleDeleteSectionTemplate`: `(id: string)` → `(id: number)` -- [ ] `handleAddBOMItemToTemplate`: 시그니처 확인 -- [ ] `handleUpdateBOMItemInTemplate`: 시그니처 확인 -- [ ] Tab props 시그니처들 - -### 누락된 Props 추가 -- [ ] MasterFieldTab: `hasUnsavedChanges`, `pendingChanges` props -- [ ] HierarchyTab: `trackChange`, `hasUnsavedChanges`, `pendingChanges` props -- [ ] TabManagementDialogs: `setIsAddAttributeTabDialogOpen` prop - -### 주요 위치 -- [ ] Line 2404: MasterFieldTab props -- [ ] Line 2423-2424: BOM 함수 시그니처 -- [ ] Line 2433: HierarchyTab props -- [ ] Line 2435: selectedPage null vs undefined -- [ ] Line 2451-2452: selectedSectionForField 타입 -- [ ] Line 2454: newSectionType 타입 -- [ ] Line 2455: updateItemPage 시그니처 -- [ ] Line 2465: updateSection 시그니처 -- [ ] Line 2494: TabManagementDialogs props -- [ ] Line 2584, 2594: Path 관련 함수 시그니처 -- [ ] Line 2800: SectionTemplate 타입 - -### 기타 수정 -- [ ] Line 598: `section.fields` optional 체크 -- [ ] Line 817: `category` 타입 (string[] → string) -- [ ] Line 1175, 1194: `s.fields`, `sectionToDelete.fields` optional 체크 -- [ ] Line 1302, 1307: Spread types 오류 -- [ ] Line 1413, 1456, 1499, 1500, 1508: `never` 타입 오류 -- [ ] Line 1731: fields optional 체크 - -**완료 후 확인**: -- [ ] 모든 함수 시그니처 일치 -- [ ] 모든 props 타입 일치 -- [ ] 타입 오류 0개 - ---- - -## Phase 8: Import 및 최종 정리 - -**목표**: 불필요한 import 제거 및 코드 정리 - -### 제거할 Import들 -- [ ] Line 43: `Save` (사용하지 않음) - -### 제거할 변수들 -- [ ] Line 103: `clearCache` -- [ ] Line 110: `_itemSections` -- [ ] Line 118: `mounted` -- [ ] Line 126: `isLoading` -- [ ] Line 432: `bomItems` -- [ ] Line 697: `_handleMoveSectionUp` -- [ ] Line 719: `_handleMoveSectionDown` -- [ ] Line 1206-1207: `pageId`, `sectionId` -- [ ] Line 1462: `_handleAddBOMItem` -- [ ] Line 1471: `_handleUpdateBOMItem` -- [ ] Line 1475: `_handleDeleteBOMItem` -- [ ] Line 1512: `_toggleSection` -- [ ] Line 1534: `_handleEditTab` -- [ ] Line 1700: `_getAllFieldsInSection` -- [ ] Line 1739: `handleResetAllData` - -### 기타 정리 -- [ ] 불필요한 주석 제거 -- [ ] 중복 코드 정리 -- [ ] 사용하지 않는 any 타입 수정 - -**완료 후 확인**: ESLint 경고 최소화 - ---- - -## 최종 검증 - -- [ ] `npm run build` 성공 (타입 검증 포함) -- [ ] IDE에서 타입 오류 0개 -- [ ] ESLint 경고 최소화 -- [ ] 기능 테스트 통과 - ---- - -## 진행 기록 - -### 2025-11-21 -- 체크리스트 생성 -- 작업 시작 준비 완료 diff --git a/claudedocs/archive/[REF] code-quality-report.md b/claudedocs/archive/[REF] code-quality-report.md deleted file mode 100644 index 98787af5..00000000 --- a/claudedocs/archive/[REF] code-quality-report.md +++ /dev/null @@ -1,354 +0,0 @@ -# 코드 품질 및 일관성 검사 결과 - -**검사 일자**: 2025-11-07 -**검사자**: Claude Code - -## 📊 전체 요약 - -**프로젝트**: Next.js 15 + TypeScript + next-intl (다국어 지원) -**언어**: TypeScript/TSX -**린트**: ESLint 9 (Next.js config) -**타입 체크**: ✅ 통과 (에러 없음) -**린트 상태**: ⚠️ 12개 문제 (9 errors, 3 warnings) - ---- - -## 🔴 Critical Issues (즉시 수정 필요) - -### 1. **src/lib/api/client.ts** - Type 정의 누락 (5 errors) - -**문제**: -- `RequestInit`, `Response`, `fetch`, `URL` 등 글로벌 타입이 인식되지 않음 -- 브라우저/Node.js 환경 타입 정의 누락 - -**수정 방법**: -```typescript -// 파일 상단에 타입 선언 추가 -/// - -// 또는 tsconfig.json에서 lib 설정 확인 -"lib": ["dom", "dom.iterable", "esnext"] -``` - -**위치**: -- src/lib/api/client.ts:50 - `token` 변수 선언 (case block) -- src/lib/api/client.ts:70 - `RequestInit` 타입 미정의 -- src/lib/api/client.ts:78 - `RequestInit` 타입 미정의 -- src/lib/api/client.ts:88 - `fetch` 미정의 -- src/lib/api/client.ts:139 - `Response` 타입 미정의 - ---- - -### 2. **src/middleware.ts** - 미사용 함수/변수 (2 errors) - -**문제 1**: `isProtectedRoute` 함수 정의되었으나 사용되지 않음 -```typescript -// Line 161 -function isProtectedRoute(pathname: string): boolean { - return AUTH_CONFIG.protectedRoutes.some(route => - pathname.startsWith(route) - ); -} -``` - -**문제 2**: `URL` 글로벌 타입 인식 안됨 -```typescript -// Line 231, 247 -new URL(AUTH_CONFIG.redirects.afterLogin, request.url) -new URL('/login', request.url) -``` - -**수정 방법**: -- `isProtectedRoute` 함수 앞에 `_` 추가 (unused 규칙 준수) 또는 삭제 -- tsconfig.json lib 설정 확인 - -**위치**: -- src/middleware.ts:161 - `isProtectedRoute` 미사용 -- src/middleware.ts:231 - `URL` 타입 미정의 -- src/middleware.ts:247 - `URL` 타입 미정의 - ---- - -### 3. **src/components/auth/LoginPage.tsx** (2 issues) - -**Error**: 미사용 변수 `response` -```typescript -// Line 43 -const response = await sanctumClient.login({ - user_id: userId, - user_pwd: password, -}); -// response 변수가 사용되지 않음 -``` - -**Warning**: `any` 타입 사용 -```typescript -// Line 55 -} catch (err: any) { - // any 대신 구체적인 타입 필요 -} -``` - -**수정 방법**: -```typescript -// Option 1: response 사용하지 않으면 제거 -await sanctumClient.login({ user_id: userId, user_pwd: password }); - -// Option 2: 타입 개선 -} catch (err: unknown) { - const error = err as { status?: number; message?: string }; - // ... -} -``` - -**위치**: -- src/components/auth/LoginPage.tsx:43 - `response` 미사용 -- src/components/auth/LoginPage.tsx:55 - `any` 타입 사용 - ---- - -## 🟡 Warnings (개선 권장) - -### 4. **src/lib/api/auth/token-storage.ts** - any 타입 사용 (2 warnings) - -**위치**: Line 30, 38 - -```typescript -// Line 30, 38 -} catch (e: any) { - // any 대신 unknown 사용 권장 -} -``` - -**개선 방법**: -```typescript -} catch (e: unknown) { - console.error('Token parse error:', e); -} -``` - -**위치**: -- src/lib/api/auth/token-storage.ts:30 - `any` 타입 사용 -- src/lib/api/auth/token-storage.ts:38 - `any` 타입 사용 - ---- - -## ✅ 긍정적인 부분 - -1. **TypeScript 타입 체크 통과** - 타입 시스템이 올바르게 작동 중 -2. **명확한 디렉토리 구조**: - ``` - src/ - ├── app/[locale]/ # Next.js 15 App Router - ├── components/ # 재사용 컴포넌트 - │ ├── ui/ # UI 컴포넌트 (shadcn/ui) - │ └── auth/ # 인증 관련 - ├── contexts/ # React Context - ├── lib/ # 유틸리티/API - │ ├── api/ - │ │ └── auth/ # 인증 API 로직 - │ └── validations/ # Zod 스키마 - └── i18n/ # 다국어 설정 - ``` - -3. **Zod 검증 사용** - 런타임 타입 안전성 확보 -4. **일관된 명명 규칙**: - - 컴포넌트: PascalCase (`LoginPage.tsx`) - - 유틸: camelCase (`auth-config.ts`) - - 상수: UPPER_SNAKE_CASE (`AUTH_CONFIG`) - ---- - -## 🎯 스타일 일관성 - -### ✅ 긍정적 패턴 -- **Import 순서**: 외부 라이브러리 → 내부 모듈 → 컴포넌트 순서 일관됨 -- **"use client" 지시자**: 클라이언트 컴포넌트에 올바르게 적용 -- **경로 별칭**: `@/*` 패턴 일관되게 사용 -- **함수형 컴포넌트**: 모든 컴포넌트가 함수형으로 작성됨 - -### ⚠️ 개선 필요 -1. **하드코딩된 한글 텍스트**: - ```tsx - // SignupPage.tsx:148 -

회원가입

- - // 다국어 지원 누락 (LoginPage는 useTranslations 사용) - ``` - -2. **인라인 스타일 사용**: - ```tsx - // LoginPage.tsx:79 -
- - // Tailwind 클래스 사용 권장: bg-blue-500 - ``` - -3. **주석 처리된 코드**: - ```tsx - // SignupPage.tsx:448-521 - // 대량의 주석 처리된 플랜 선택 UI (73줄) - - // 제거 또는 별도 파일로 분리 권장 - ``` - ---- - -## 🔧 추천 개선 사항 - -### 우선순위 1 (High) - 즉시 수정 -1. ✅ **tsconfig.json** lib 설정 확인 (DOM 타입 포함) -2. ✅ **any 타입 제거** → `unknown` 또는 구체적 타입으로 변경 -3. ✅ **미사용 변수 제거** (response, isProtectedRoute) - -### 우선순위 2 (Medium) - 단기 개선 -4. **하드코딩 텍스트 다국어화**: - ```typescript - // messages/ko.json에 추가 - { - "signup": { - "title": "회원가입", - "companyInfo": "회사 정보를 입력해주세요" - } - } - ``` - -5. **인라인 스타일 → Tailwind 클래스**: - ```tsx - // Before -
- - // After -
- ``` - -6. **주석 처리된 코드 정리**: - - 필요 시 별도 브랜치로 보존 - - 불필요하면 삭제 - -### 우선순위 3 (Low) - 장기 개선 -7. **에러 타입 정의**: - ```typescript - // lib/api/types.ts - export interface ApiError { - status: number; - message: string; - errors?: Record; - code?: string; - } - ``` - -8. **ESLint 규칙 커스터마이징**: - ```json - // .eslintrc.json 생성 - { - "extends": "next/core-web-vitals", - "rules": { - "@typescript-eslint/no-unused-vars": ["error", { - "argsIgnorePattern": "^_" - }] - } - } - ``` - ---- - -## 📈 메트릭스 - -| 항목 | 상태 | 점수 | -|------|------|------| -| TypeScript 타입 체크 | ✅ 통과 | 100% | -| ESLint 오류 | ⚠️ 9개 | 65% | -| 코드 구조 | ✅ 우수 | 90% | -| 명명 규칙 | ✅ 일관됨 | 95% | -| 다국어 적용 | ⚠️ 부분적 | 75% | -| 스타일 일관성 | ✅ 양호 | 85% | - -**전체 코드 품질**: **82/100** (양호) - ---- - -## 🚀 빠른 수정 가이드 - -```bash -# 1. tsconfig.json 확인 (이미 올바르게 설정됨) -cat tsconfig.json | grep -A5 "lib" - -# 2. ESLint 오류 확인 -npm run lint - -# 3. 자동 수정 가능한 항목 수정 -npm run lint -- --fix - -# 4. TypeScript 타입 체크 -npx tsc --noEmit -``` - ---- - -## 📋 상세 에러 목록 - -### ESLint Errors (9개) - -1. **src/components/auth/LoginPage.tsx:43:13** - - `response` is assigned a value but never used - - Rule: `@typescript-eslint/no-unused-vars` - -2. **src/lib/api/client.ts:50:9** - - Unexpected lexical declaration in case block - - Rule: `no-case-declarations` - -3. **src/lib/api/client.ts:70:15** - - `RequestInit` is not defined - - Rule: `no-undef` - -4. **src/lib/api/client.ts:78:19** - - `RequestInit` is not defined - - Rule: `no-undef` - -5. **src/lib/api/client.ts:88:28** - - `fetch` is not defined - - Rule: `no-undef` - -6. **src/lib/api/client.ts:139:39** - - `Response` is not defined - - Rule: `no-undef` - -7. **src/middleware.ts:161:10** - - `isProtectedRoute` is defined but never used - - Rule: `@typescript-eslint/no-unused-vars` - -8. **src/middleware.ts:231:40** - - `URL` is not defined - - Rule: `no-undef` - -9. **src/middleware.ts:247:21** - - `URL` is not defined - - Rule: `no-undef` - -### ESLint Warnings (3개) - -1. **src/components/auth/LoginPage.tsx:55:19** - - Unexpected any. Specify a different type - - Rule: `@typescript-eslint/no-explicit-any` - -2. **src/lib/api/auth/token-storage.ts:30:17** - - Unexpected any. Specify a different type - - Rule: `@typescript-eslint/no-explicit-any` - -3. **src/lib/api/auth/token-storage.ts:38:14** - - Unexpected any. Specify a different type - - Rule: `@typescript-eslint/no-explicit-any` - ---- - -## 💡 결론 - -프로젝트는 전반적으로 **양호한 품질**을 유지하고 있으나, 위 9개 ESLint 오류를 수정하면 더욱 견고한 코드베이스가 될 것입니다. - -주요 개선 포인트: -1. 타입 정의 완성도 향상 (no-undef 에러 해결) -2. any 타입 제거로 타입 안전성 강화 -3. 미사용 변수/함수 정리로 코드 가독성 향상 -4. 다국어 지원 일관성 개선 -5. 스타일 일관성 유지 (인라인 스타일 제거) \ No newline at end of file diff --git a/claudedocs/archive/[REF] communication_improvement_guide.md b/claudedocs/archive/[REF] communication_improvement_guide.md deleted file mode 100644 index 6228035b..00000000 --- a/claudedocs/archive/[REF] communication_improvement_guide.md +++ /dev/null @@ -1,292 +0,0 @@ -# Claude Code 커뮤니케이션 개선 가이드 - -**작성일**: 2025-11-06 -**적용 범위**: 모든 세션 -**목적**: Claude와 사용자 간 효율적 커뮤니케이션 프로토콜 - ---- - -## 📊 Claude 응답 패턴 분석 및 개선 - -### 1️⃣ 식별된 문제점 - -#### 🔴 과도한 설명 (Over-explanation) -**문제**: 간단한 질문에도 긴 설명 + 예시 + 대안 + 원리까지 -**원인**: 사용자 의도 파악 전에 모든 가능성 커버하려는 습관 -**개선**: 핵심 답변 먼저 → 필요시 추가 설명 제공 - -**예시**: -``` -❌ 현재 방식: -Q: "이 함수 뭐하는 거야?" -A: [함수 설명 500자] + [동작 원리] + [사용 예시] + [대안] + [최적화 팁] - -✅ 개선 방식: -Q: "이 함수 뭐하는 거야?" -A: "사용자 인증 토큰 검증. 만료 체크 + 서명 확인. - 더 알고 싶으신 부분 있나요? (원리/사용법/대안)" -``` - -#### 🟡 불필요한 TodoWrite 남발 -**문제**: 간단한 작업도 TodoWrite 생성 → 오버헤드 -**원인**: MODE_Task_Management의 ">3 steps" 기준 오해석 -**개선**: 진짜 복잡한 작업만 TodoWrite 사용 - -**예시**: -``` -❌ 불필요한 경우: -- 단일 파일 읽기 -- 간단한 변수명 변경 -- 한 줄 수정 - -✅ 필요한 경우: -- 다중 파일 리팩토링 (>5개) -- 다단계 구현 (인증 시스템 전체) -- 의존성 있는 순차 작업 -``` - -#### 🟡 선제적 도구 제안 (Premature Tool Suggestion) -**문제**: "이것도 해드릴까요?" 식의 불필요한 제안 -**원인**: 도움이 되려는 과도한 의욕 -**개선**: 요청받은 것만 정확히 수행 - -**예시**: -``` -❌ 현재: -A: "로그인 폼 만들었습니다. - 회원가입도 만들어드릴까요? - 비밀번호 찾기도 추가할까요?" - -✅ 개선: -A: "로그인 폼 완료. src/components/auth/LoginForm.tsx - 추가 필요하신 기능 있으면 말씀해주세요." -``` - -#### 🔴 모호한 질문에 대한 가정 기반 답변 -**문제**: 불명확한 질문에 확인 없이 추측으로 답변 -**원인**: 빠른 응답 시도 → 잘못된 방향 -**개선**: AskUserQuestion 활용해 명확화 먼저 - -**예시**: -``` -❌ 현재: -Q: "이거 고쳐줘" -A: [맥락 추측해서 A를 수정함 → 실제로는 B를 원했음] - -✅ 개선: -Q: "이거 고쳐줘" -A: "무엇을 수정하실까요? - 1) 성능 문제 - 2) 버그 수정 - 3) UI 개선 - 4) 기능 변경" -``` - ---- - -## 🎯 사용자 질문 패턴 분석 - -### ✅ 잘하고 계신 점 -1. **직관적 의사소통**: "이거", "저거" → 맥락 파악 가능한 수준 -2. **점진적 상세화**: 첫 질문 간단 → 필요시 구체화 -3. **자연스러운 대화**: 형식보다 내용 중심 - -### ⚠️ 개선 가능한 부분 - -#### 1. 파일 경로 명시 부족 -``` -현재: "이 코드 분석해줘" -개선: "src/app/page.tsx 분석해줘" -``` - -#### 2. 범위 지정 누락 -``` -현재: "에러 고쳐줘" -개선: "빌드 에러 고쳐줘" or "런타임 에러 고쳐줘" -``` - -#### 3. 우선순위 미명시 -``` -현재: "A, B, C 해줘" -개선: "A 먼저, 그 다음 B, C는 나중에" -``` - ---- - -## 💡 상호 개선 제안 - -### 🔹 Claude가 개선할 것 - -#### 1. 간결성 우선 (Concise-First) -```yaml -원칙: - - 핵심 답변 먼저 (2-3문장) - - "더 알고 싶으면" 선택지 제공 - - 긴 설명은 명시적 요청 시에만 - -적용: - - 간단한 질문 → 짧은 답변 - - 복잡한 질문 → 구조화된 답변 + 요약 -``` - -#### 2. 명확화 우선 (Clarify-First) -```yaml -원칙: - - 모호함 감지 → 즉시 AskUserQuestion - - 가정 기반 진행 금지 - - 2가지 이상 해석 가능 → 선택지 제시 - -트리거: - - "이거", "저거" + 맥락 불충분 - - 범위 불명확 (파일? 모듈? 프로젝트?) - - 목적 불명확 (분석? 수정? 삭제?) -``` - -#### 3. 작업 범위 확인 (Scope-Check) -```yaml -원칙: - - 큰 작업 시작 전 범위 확인 - - 예상 영향 파일/시간 사전 공유 - - 승인 후 진행 - -예시: - "이 작업은 12개 파일 수정 예상 (약 10분). - 진행할까요?" -``` - -#### 4. 결과물 우선 (Outcome-First) -```yaml -원칙: - - 작업 완료 → 결과 먼저 보고 - - 과정 설명은 필요시에만 - - 파일 경로 + 변경사항 요약 - -템플릿: - "✅ 완료: [핵심 결과] - 변경: [파일1:라인] [파일2:라인] - 테스트: [검증 결과]" -``` - ---- - -### 🔹 사용자가 고려할 수 있는 것 - -#### 1. 컨텍스트 첨부 습관 -``` -현재: "이 에러 뭐야?" -개선: "app/api/auth/route.ts:45에서 TypeError 발생. 뭐야?" - -효과: 즉시 파일 확인 가능 → 왕복 질문 감소 -``` - -#### 2. 기대 결과물 언급 -``` -현재: "로그인 만들어줘" -개선: "로그인 만들어줘. 폼만 있으면 돼 (API 연동 X)" - -효과: 불필요한 구현 방지 → 시간 절약 -``` - -#### 3. 긴급도 표시 -``` -현재: "A, B, C 해줘" -개선: "A(급함), B(보통), C(나중에)" - -효과: 우선순위 기반 작업 → 효율 증가 -``` - -#### 4. 제약사항 사전 공유 -``` -현재: "디자인 바꿔줘" → [작업 완료] → "아 이건 바꾸면 안 되는데..." -개선: "디자인 바꿔줘. 단, 헤더 높이는 유지" - -효과: 재작업 방지 -``` - ---- - -## 🎯 실전 커뮤니케이션 프로토콜 - -### Phase 1: 간단한 작업 (< 5분) -``` -사용자: "src/utils/format.ts의 formatDate 함수 버그 수정" -Claude: [파일 확인] → [수정] → "✅ 완료: ISO 8601 포맷 지원 추가" -``` - -### Phase 2: 중간 작업 (5-15분) -``` -사용자: "로그인 폼 만들어줘" -Claude: "확인: - - 이메일/비밀번호 필드 - - 클라이언트 검증만 (API 연동 X) - - 기존 Input 컴포넌트 사용 - 맞나요?" -사용자: "응, API 연동도 해줘" -Claude: "알겠습니다. 예상 15분, 진행할게요." - [작업 진행 + TodoWrite] -``` - -### Phase 3: 복잡한 작업 (> 15분) -``` -사용자: "인증 시스템 전체 구현" -Claude: [AskUserQuestion 실행] - "범위 확인: - 1) JWT vs Session 방식? - 2) 소셜 로그인 필요? - 3) 권한 관리 포함? - 4) 리프레시 토큰?" -사용자: [선택] -Claude: [Plan 작성 + TodoWrite + 승인 요청] -사용자: "OK" -Claude: [실행] -``` - ---- - -## 📋 빠른 체크리스트 - -### Claude 답변 전 체크리스트 -- [ ] 질문이 명확한가? → 아니면 AskUserQuestion -- [ ] 파일/범위 확인 가능한가? -- [ ] 가정이 필요한가? → 필요하면 확인 -- [ ] 작업 시간 > 5분? → 범위 사전 공유 -- [ ] TodoWrite 진짜 필요한가? → 단순 작업은 스킵 - -### 사용자 질문 전 체크리스트 (선택사항) -- [ ] 파일 경로 명시 가능? -- [ ] 범위 명확? (파일/모듈/프로젝트) -- [ ] 기대 결과 명확? -- [ ] 제약사항 있음? -- [ ] 우선순위 있음? - ---- - -## 🎬 실험 모드 (1주일) - -### 적용 방침 -**Claude**: -- 모호하면 즉시 질문 (가정 금지) -- 답변 간결화 (핵심 우선) -- TodoWrite 최소화 (진짜 복잡한 것만) - -**사용자**: -- 가능하면 파일 경로 포함 -- 범위/우선순위 명시 (필요시) - -### 1주 후 평가 -- 효과 측정 -- 불편한 점 수집 -- 프로토콜 조정 - ---- - -## 📌 핵심 원칙 요약 - -1. **간결성**: 핵심 먼저, 상세는 나중 -2. **명확성**: 모호하면 물어보기 -3. **효율성**: 필요한 것만, 정확하게 -4. **투명성**: 예상 범위/시간 사전 공유 -5. **유연성**: 피드백 기반 지속 개선 - -**적용 시작일**: 2025-11-06 -**다음 리뷰**: 2025-11-13 \ No newline at end of file diff --git a/claudedocs/archive/[REF] component-usage-analysis.md b/claudedocs/archive/[REF] component-usage-analysis.md deleted file mode 100644 index ee0ab96c..00000000 --- a/claudedocs/archive/[REF] component-usage-analysis.md +++ /dev/null @@ -1,444 +0,0 @@ -# 컴포넌트 사용 분석 리포트 - -생성일: 2025-11-12 -프로젝트: sam-react-prod - -## 📋 요약 - -- **총 컴포넌트 수**: 50개 -- **실제 사용 중**: 8개 -- **미사용 컴포넌트**: 42개 (84%) -- **중복 파일**: 2개 (LoginPage.tsx, SignupPage.tsx) - ---- - -## ✅ 1. 실제 사용 중인 컴포넌트 - -### 1.1 인증 컴포넌트 (src/components/auth/) -| 컴포넌트 | 사용 위치 | 상태 | -|---------|---------|------| -| **LoginPage.tsx** | `src/app/[locale]/login/page.tsx` | ✅ 사용 중 | -| **SignupPage.tsx** | `src/app/[locale]/signup/page.tsx` | ✅ 사용 중 | - -**의존성**: -- `LanguageSelect` (src/components/LanguageSelect.tsx) -- `ThemeSelect` (src/components/ThemeSelect.tsx) - ---- - -### 1.2 비즈니스 컴포넌트 (src/components/business/) -| 컴포넌트 | 사용 위치 | 상태 | -|---------|---------|------| -| **Dashboard.tsx** | `src/app/[locale]/(protected)/dashboard/page.tsx` | ✅ 사용 중 | - -**Dashboard.tsx의 lazy-loaded 의존성** (간접 사용 중): -- `CEODashboard.tsx` → Dashboard에서 lazy import -- `ProductionManagerDashboard.tsx` → Dashboard에서 lazy import -- `WorkerDashboard.tsx` → Dashboard에서 lazy import -- `SystemAdminDashboard.tsx` → Dashboard에서 lazy import - ---- - -### 1.3 레이아웃 컴포넌트 (src/components/layout/) -| 컴포넌트 | 사용 위치 | 상태 | -|---------|---------|------| -| **Sidebar.tsx** | `src/layouts/DashboardLayout.tsx` | ✅ 사용 중 | - ---- - -### 1.4 공통 컴포넌트 (src/components/common/) -| 컴포넌트 | 사용 위치 | 상태 | -|---------|---------|------| -| **EmptyPage.tsx** | `src/app/[locale]/(protected)/[...slug]/page.tsx` | ✅ 사용 중 | - -**용도**: 미구현 페이지의 폴백(fallback) UI - ---- - -### 1.5 루트 레벨 컴포넌트 (src/components/) -| 컴포넌트 | 사용 위치 | 상태 | -|---------|---------|------| -| **LanguageSelect.tsx** | `LoginPage.tsx`, `SignupPage.tsx` | ✅ 사용 중 | -| **ThemeSelect.tsx** | `LoginPage.tsx`, `SignupPage.tsx`, `DashboardLayout.tsx` | ✅ 사용 중 | - -| 컴포넌트 | 상태 | 비고 | -|---------|------|------| -| **WelcomeMessage.tsx** | ❌ 미사용 | 삭제 가능 | -| **NavigationMenu.tsx** | ❌ 미사용 | 삭제 가능 | -| **LanguageSwitcher.tsx** | ❌ 미사용 | LanguageSelect로 대체됨 | - ---- - -## ❌ 2. 미사용 컴포넌트 목록 (삭제 가능) - -### 2.1 src/components/business/ (35개 미사용) - -#### 데모/예제 페이지 (7개) -``` -❌ LandingPage.tsx - 데모용 랜딩 페이지 -❌ DemoRequestPage.tsx - 데모 신청 페이지 -❌ ContactModal.tsx - 문의 모달 -❌ LoginPage.tsx - 🔴 중복! (auth/LoginPage.tsx 사용 중) -❌ SignupPage.tsx - 🔴 중복! (auth/SignupPage.tsx 사용 중) -❌ Board.tsx - 게시판 -❌ MenuCustomization.tsx - 메뉴 커스터마이징 -❌ MenuCustomizationGuide.tsx - 메뉴 가이드 -``` - -#### 대시보드 (2개 미사용, 4개 사용 중) -``` -✅ CEODashboard.tsx - Dashboard.tsx에서 lazy import -✅ ProductionManagerDashboard.tsx - Dashboard.tsx에서 lazy import -✅ WorkerDashboard.tsx - Dashboard.tsx에서 lazy import -✅ SystemAdminDashboard.tsx - Dashboard.tsx에서 lazy import -❌ SalesLeadDashboard.tsx - 미사용 -``` - -#### 관리 모듈 (28개) -``` -❌ AccountingManagement.tsx - 회계 관리 -❌ ApprovalManagement.tsx - 결재 관리 -❌ BOMManagement.tsx - BOM 관리 -❌ CodeManagement.tsx - 코드 관리 -❌ EquipmentManagement.tsx - 설비 관리 -❌ HRManagement.tsx - 인사 관리 -❌ ItemManagement.tsx - 품목 관리 -❌ LotManagement.tsx - 로트 관리 -❌ MasterData.tsx - 마스터 데이터 -❌ MaterialManagement.tsx - 자재 관리 -❌ OrderManagement.tsx - 수주 관리 -❌ PricingManagement.tsx - 가격 관리 -❌ ProductManagement.tsx - 제품 관리 -❌ ProductionManagement.tsx - 생산 관리 -❌ QualityManagement.tsx - 품질 관리 -❌ QuoteCreation.tsx - 견적 생성 -❌ QuoteSimulation.tsx - 견적 시뮬레이션 -❌ ReceivingWrite.tsx - 입고 작성 -❌ Reports.tsx - 보고서 -❌ SalesManagement.tsx - 영업 관리 -❌ SalesManagement-clean.tsx - 영업 관리 (정리 버전) -❌ ShippingManagement.tsx - 출하 관리 -❌ SystemManagement.tsx - 시스템 관리 -❌ UserManagement.tsx - 사용자 관리 -❌ WorkerPerformance.tsx - 작업자 실적 -❌ DrawingCanvas.tsx - 도면 캔버스 -``` - -### 2.2 src/components/ (3개 미사용) -``` -❌ WelcomeMessage.tsx - 환영 메시지 -❌ NavigationMenu.tsx - 네비게이션 메뉴 -❌ LanguageSwitcher.tsx - 언어 전환 (LanguageSelect로 대체) -``` - ---- - -## 🔴 3. 중복 파일 문제 - -### LoginPage.tsx 중복 -- **src/components/auth/LoginPage.tsx** ✅ 사용 중 -- **src/components/business/LoginPage.tsx** ❌ 미사용 (삭제 권장) - -### SignupPage.tsx 중복 -- **src/components/auth/SignupPage.tsx** ✅ 사용 중 -- **src/components/business/SignupPage.tsx** ❌ 미사용 (삭제 권장) - -**권장 조치**: `src/components/business/` 내 중복 파일 삭제 - ---- - -## 📊 4. UI 컴포넌트 사용 현황 (src/components/ui/) - -### 실제 사용 중인 UI 컴포넌트 -``` -✅ badge.tsx - 배지 -✅ button.tsx - 버튼 -✅ calendar.tsx - 달력 (CEODashboard) -✅ card.tsx - 카드 -✅ chart-wrapper.tsx - 차트 래퍼 (CEODashboard) -✅ checkbox.tsx - 체크박스 (CEODashboard) -✅ dialog.tsx - 다이얼로그 -✅ dropdown-menu.tsx - 드롭다운 메뉴 -✅ input.tsx - 입력 필드 -✅ label.tsx - 라벨 -✅ progress.tsx - 진행 바르 -✅ select.tsx - 선택 박스 -✅ sheet.tsx - 시트 (DashboardLayout) -``` - -**모든 UI 컴포넌트가 사용 중** (미사용 UI 컴포넌트 없음) - ---- - -## 📁 5. 파일 구조 분석 - -### 현재 프로젝트 구조 -``` -src/ -├── app/ -│ └── [locale]/ -│ ├── login/page.tsx → LoginPage -│ ├── signup/page.tsx → SignupPage -│ ├── (protected)/ -│ │ ├── dashboard/page.tsx → Dashboard -│ │ └── [...slug]/page.tsx → EmptyPage (폴백) -│ ├── layout.tsx -│ ├── error.tsx -│ └── not-found.tsx -├── components/ -│ ├── auth/ ✅ 2개 사용 중 -│ │ ├── LoginPage.tsx -│ │ └── SignupPage.tsx -│ ├── business/ ⚠️ 5/40개만 사용 (12.5%) -│ │ ├── Dashboard.tsx ✅ -│ │ ├── CEODashboard.tsx ✅ (lazy) -│ │ ├── ProductionManagerDashboard.tsx ✅ (lazy) -│ │ ├── WorkerDashboard.tsx ✅ (lazy) -│ │ ├── SystemAdminDashboard.tsx ✅ (lazy) -│ │ └── [35개 미사용 컴포넌트] ❌ -│ ├── common/ ✅ 1/1개 사용 -│ │ └── EmptyPage.tsx -│ ├── layout/ ✅ 1/1개 사용 -│ │ └── Sidebar.tsx -│ ├── ui/ ✅ 14/14개 사용 -│ ├── LanguageSelect.tsx ✅ -│ ├── ThemeSelect.tsx ✅ -│ ├── WelcomeMessage.tsx ❌ -│ ├── NavigationMenu.tsx ❌ -│ └── LanguageSwitcher.tsx ❌ -└── layouts/ - └── DashboardLayout.tsx ✅ (Sidebar 사용) -``` - ---- - -## 🎯 6. 정리 권장사항 - -### 우선순위 1: 중복 파일 삭제 (즉시) -```bash -rm src/components/business/LoginPage.tsx -rm src/components/business/SignupPage.tsx -``` - -### 우선순위 2: 명확한 미사용 컴포넌트 삭제 -```bash -# 데모/예제 페이지 -rm src/components/business/LandingPage.tsx -rm src/components/business/DemoRequestPage.tsx -rm src/components/business/ContactModal.tsx -rm src/components/business/Board.tsx -rm src/components/business/MenuCustomization.tsx -rm src/components/business/MenuCustomizationGuide.tsx - -# 미사용 대시보드 -rm src/components/business/SalesLeadDashboard.tsx - -# 루트 레벨 미사용 컴포넌트 -rm src/components/WelcomeMessage.tsx -rm src/components/NavigationMenu.tsx -rm src/components/LanguageSwitcher.tsx -``` - -### 우선순위 3: 관리 모듈 컴포넌트 정리 (신중히) - -**⚠️ 주의**: 다음 35개 컴포넌트는 현재 미사용이지만, 향후 기능 구현 계획에 따라 보존 여부 결정 필요 - -#### 옵션 A: 전체 삭제 (프로토타입 프로젝트인 경우) -```bash -# 모든 미사용 관리 모듈 삭제 -rm src/components/business/AccountingManagement.tsx -rm src/components/business/ApprovalManagement.tsx -# ... (28개 전체) -``` - -#### 옵션 B: 별도 디렉토리로 이동 (향후 사용 가능성이 있는 경우) -```bash -mkdir src/components/business/_unused -mv src/components/business/AccountingManagement.tsx src/components/business/_unused/ -# ... (미사용 컴포넌트 이동) -``` - -#### 옵션 C: 보존 (ERP 시스템 구축 중인 경우) -- 현재 미구현 상태지만 향후 기능 구현 예정이라면 보존 권장 -- EmptyPage.tsx가 폴백으로 작동하고 있으므로 점진적 구현 가능 - ---- - -## 📈 7. 영향도 분석 - -### 삭제 시 영향 없음 (안전) -- **중복 파일** (business/LoginPage.tsx, business/SignupPage.tsx) -- **데모 페이지** (LandingPage, DemoRequestPage, ContactModal 등) -- **루트 레벨 미사용 컴포넌트** (WelcomeMessage, NavigationMenu, LanguageSwitcher) - -### 삭제 시 신중 검토 필요 -- **관리 모듈 컴포넌트** (35개) - - 이유: 메뉴 구조와 연결된 기능일 가능성 - - 조치: 메뉴 설정 (menu configuration) 확인 후 결정 - -### 절대 삭제 금지 -- **auth/** 내 컴포넌트 (LoginPage, SignupPage) -- **business/Dashboard.tsx** 및 lazy-loaded 대시보드 (5개) -- **common/EmptyPage.tsx** -- **layout/Sidebar.tsx** -- **LanguageSelect.tsx, ThemeSelect.tsx** -- **ui/** 내 모든 컴포넌트 - ---- - -## 🔍 8. 추가 분석 필요 사항 - -### 메뉴 설정 확인 -```typescript -// src/store/menuStore.ts 또는 사용자 메뉴 설정 확인 필요 -// 메뉴 구조에 미사용 컴포넌트가 연결되어 있는지 확인 -``` - -### API 연동 확인 -```bash -# API 응답에서 메뉴 구조를 동적으로 받아오는지 확인 -grep -r "menu" src/lib/api/ -grep -r "menuItems" src/ -``` - ---- - -## 📝 9. 실행 스크립트 - -### 안전한 정리 스크립트 (중복 + 데모만 삭제) -```bash -#!/bin/bash -# safe-cleanup.sh - -echo "🧹 컴포넌트 정리 시작 (안전 모드)..." - -# 중복 파일 삭제 -rm -v src/components/business/LoginPage.tsx -rm -v src/components/business/SignupPage.tsx - -# 데모/예제 페이지 삭제 -rm -v src/components/business/LandingPage.tsx -rm -v src/components/business/DemoRequestPage.tsx -rm -v src/components/business/ContactModal.tsx -rm -v src/components/business/Board.tsx -rm -v src/components/business/MenuCustomization.tsx -rm -v src/components/business/MenuCustomizationGuide.tsx -rm -v src/components/business/SalesLeadDashboard.tsx - -# 루트 레벨 미사용 컴포넌트 -rm -v src/components/WelcomeMessage.tsx -rm -v src/components/NavigationMenu.tsx -rm -v src/components/LanguageSwitcher.tsx - -echo "✅ 안전한 정리 완료!" -``` - -### 전체 정리 스크립트 (관리 모듈 포함) -```bash -#!/bin/bash -# full-cleanup.sh - -echo "⚠️ 전체 컴포넌트 정리 시작..." -echo "이 스크립트는 모든 미사용 컴포넌트를 삭제합니다." -read -p "계속하시겠습니까? (y/N): " confirm - -if [[ $confirm != [yY] ]]; then - echo "취소되었습니다." - exit 0 -fi - -# 안전 정리 실행 -bash safe-cleanup.sh - -# 관리 모듈 삭제 -rm -v src/components/business/AccountingManagement.tsx -rm -v src/components/business/ApprovalManagement.tsx -rm -v src/components/business/BOMManagement.tsx -rm -v src/components/business/CodeManagement.tsx -rm -v src/components/business/EquipmentManagement.tsx -rm -v src/components/business/HRManagement.tsx -rm -v src/components/business/ItemManagement.tsx -rm -v src/components/business/LotManagement.tsx -rm -v src/components/business/MasterData.tsx -rm -v src/components/business/MaterialManagement.tsx -rm -v src/components/business/OrderManagement.tsx -rm -v src/components/business/PricingManagement.tsx -rm -v src/components/business/ProductManagement.tsx -rm -v src/components/business/ProductionManagement.tsx -rm -v src/components/business/QualityManagement.tsx -rm -v src/components/business/QuoteCreation.tsx -rm -v src/components/business/QuoteSimulation.tsx -rm -v src/components/business/ReceivingWrite.tsx -rm -v src/components/business/Reports.tsx -rm -v src/components/business/SalesManagement.tsx -rm -v src/components/business/SalesManagement-clean.tsx -rm -v src/components/business/ShippingManagement.tsx -rm -v src/components/business/SystemManagement.tsx -rm -v src/components/business/UserManagement.tsx -rm -v src/components/business/WorkerPerformance.tsx -rm -v src/components/business/DrawingCanvas.tsx - -echo "✅ 전체 정리 완료!" -``` - ---- - -## 💡 10. 최종 권장 사항 - -### 즉시 조치 (안전) -1. **중복 파일 삭제**: `business/LoginPage.tsx`, `business/SignupPage.tsx` -2. **데모 페이지 삭제**: 10개의 데모/예제 컴포넌트 -3. Git 커밋: `[chore]: Remove duplicate and unused demo components` - -### 단계적 조치 (신중) -1. **메뉴 구조 확인**: 메뉴 설정에서 미사용 컴포넌트 참조 여부 확인 -2. **기능 로드맵 확인**: 관리 모듈 구현 계획 확인 -3. **결정 후 삭제**: 향후 사용 계획 없으면 삭제, 있으면 `_unused/` 폴더로 이동 - -### 장기 계획 -1. **컴포넌트 문서화**: 사용 중인 컴포넌트에 JSDoc 주석 추가 -2. **린팅 규칙 추가**: ESLint에 unused imports/exports 체크 규칙 추가 -3. **자동 탐지**: CI/CD에 미사용 컴포넌트 탐지 스크립트 추가 - ---- - -## 📎 부록: 상세 의존성 그래프 - -``` -app/[locale]/login/page.tsx -└── components/auth/LoginPage.tsx - ├── components/LanguageSelect.tsx - ├── components/ThemeSelect.tsx - └── components/ui/* (button, input, label) - -app/[locale]/signup/page.tsx -└── components/auth/SignupPage.tsx - ├── components/LanguageSelect.tsx - ├── components/ThemeSelect.tsx - └── components/ui/* (button, input, label, select) - -app/[locale]/(protected)/dashboard/page.tsx -└── components/business/Dashboard.tsx - ├── components/business/CEODashboard.tsx (lazy) - │ └── components/ui/* (card, badge, chart-wrapper, calendar, checkbox) - ├── components/business/ProductionManagerDashboard.tsx (lazy) - │ └── components/ui/* (card, badge, button) - ├── components/business/WorkerDashboard.tsx (lazy) - │ └── components/ui/* (card, badge, button) - └── components/business/SystemAdminDashboard.tsx (lazy) - -app/[locale]/(protected)/[...slug]/page.tsx -└── components/common/EmptyPage.tsx - └── components/ui/* (card, button) - -layouts/DashboardLayout.tsx -├── components/layout/Sidebar.tsx -├── components/ThemeSelect.tsx -└── components/ui/* (input, button, sheet) -``` - ---- - -**분석 완료일**: 2025-11-12 -**분석 도구**: Grep, Bash, Read -**정확도**: 100% (전체 프로젝트 스캔 완료) \ No newline at end of file diff --git a/claudedocs/archive/[REF] production-deployment-checklist.md b/claudedocs/archive/[REF] production-deployment-checklist.md deleted file mode 100644 index 65da608d..00000000 --- a/claudedocs/archive/[REF] production-deployment-checklist.md +++ /dev/null @@ -1,233 +0,0 @@ -# 운영 배포 체크리스트 - -**문서 목적**: 로컬/개발 환경에서 운영 환경으로 전환 시 필요한 변경사항 정리 -**작성일**: 2025-11-07 -**상태**: 내부 개발용 → 추후 운영 배포 시 참고 - ---- - -## 🔴 필수 변경 사항 (운영 배포 전 필수) - -### 1. Frontend URL 변경 -**현재 설정** (로컬 개발용): -```bash -# .env.local -NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 -``` - -**운영 배포 시 변경**: -```bash -# .env.production 또는 배포 플랫폼 환경 변수 -NEXT_PUBLIC_FRONTEND_URL=https://your-production-domain.com -# 예시: https://5130.co.kr -``` - -**영향 범위**: -- `src/lib/api/auth/auth-config.ts:8` - CORS 설정 -- 백엔드 PHP API의 CORS 허용 도메인 추가 필요 - ---- - -### 2. API Key 보안 강화 ⚠️ - -**현재 상태** (내부 개발용): -```bash -# .env.local -NEXT_PUBLIC_API_KEY=42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a -``` - -**보안 위험**: -- `NEXT_PUBLIC_` 접두사로 인해 브라우저에서 API Key 노출 -- 개발자 도구 → Network/Console에서 키 확인 가능 -- 클라이언트 측 JavaScript에서 접근 가능 - -**운영 배포 시 해결 방안** (택 1): - -#### 방안 A: 서버 전용 API Key로 전환 -```bash -# .env.production (서버 사이드 전용) -API_KEY=your-production-secret-key -``` -- `NEXT_PUBLIC_` 접두사 제거 -- Next.js API Routes에서만 사용 -- 브라우저 접근 불가 - -#### 방안 B: 운영용 별도 Public API Key 발급 -```bash -# PHP 백엔드 팀에 운영용 Public API Key 요청 -NEXT_PUBLIC_API_KEY=production-public-safe-key -``` -- 제한된 권한으로 발급 (읽기 전용 등) -- IP 화이트리스트 적용 -- Rate Limiting 설정 - -**코드 수정 필요 위치**: -- `src/lib/api/client.ts:40` - API Key 사용 로직 -- `.env.example:32` - 문서 불일치 해결 - ---- - -## 🟡 권장 변경 사항 - -### 3. 백엔드 CORS 설정 -**PHP API 서버 설정 확인**: -```php -// Laravel sanctum config 예시 -'allowed_origins' => [ - 'http://localhost:3000', // 개발 - 'https://5130.co.kr', // 운영 (추가 필요) -], -``` - -**Sanctum 쿠키 도메인**: -```php -// config/sanctum.php -'stateful' => explode(',', env( - 'SANCTUM_STATEFUL_DOMAINS', - 'localhost,localhost:3000,127.0.0.1,5130.co.kr' -)), -``` - ---- - -### 4. Next.js 운영 최적화 -**next.config.ts 추가 권장**: -```typescript -const nextConfig: NextConfig = { - turbopack: {}, - - // 운영 환경 추가 설정 - reactStrictMode: true, - poweredByHeader: false, // 보안: X-Powered-By 헤더 제거 - output: 'standalone', // Docker 배포용 - compress: true, // Gzip 압축 -}; -``` - ---- - -### 5. 빌드 스크립트 추가 -**package.json 추가 권장**: -```json -{ - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint", - - // 추가 권장 - "build:prod": "NODE_ENV=production next build", - "type-check": "tsc --noEmit", - "lint:fix": "eslint --fix" - } -} -``` - ---- - -## 🟢 배포 플랫폼별 설정 - -### Vercel 배포 -**프로젝트 설정 → Environment Variables**: -``` -NEXT_PUBLIC_API_URL=https://api.5130.co.kr -NEXT_PUBLIC_FRONTEND_URL=https://your-app.vercel.app -NEXT_PUBLIC_AUTH_MODE=sanctum -API_KEY=<서버 전용 키> -``` - -### Docker 배포 -**docker-compose.yml 예시**: -```yaml -version: '3.8' -services: - nextjs-app: - build: . - environment: - - NEXT_PUBLIC_API_URL=https://api.5130.co.kr - - NEXT_PUBLIC_FRONTEND_URL=https://your-domain.com - - API_KEY=${API_KEY} - ports: - - "3000:3000" -``` - -### 전통적인 서버 배포 -**`.env.production` 파일 생성**: -```bash -NEXT_PUBLIC_API_URL=https://api.5130.co.kr -NEXT_PUBLIC_FRONTEND_URL=https://your-domain.com -NEXT_PUBLIC_AUTH_MODE=sanctum -API_KEY=<서버 전용 키> -``` - ---- - -## 📋 최종 배포 체크리스트 - -### 환경 변수 -- [ ] `NEXT_PUBLIC_FRONTEND_URL` → 운영 도메인으로 변경 -- [ ] `NEXT_PUBLIC_API_KEY` → 보안 방안 적용 (서버 전용 또는 제한된 Public Key) -- [ ] `NEXT_PUBLIC_AUTH_MODE` → `sanctum` 또는 `bearer` 확인 -- [ ] `.env.local` Git 커밋 안 됨 확인 (`.gitignore:100`) - -### 백엔드 연동 -- [ ] PHP API CORS 설정에 운영 도메인 추가 -- [ ] Sanctum 쿠키 도메인 설정 확인 -- [ ] 운영용 API Key 발급 (필요 시) -- [ ] API 엔드포인트 테스트 (`https://api.5130.co.kr`) - -### 빌드 & 테스트 -- [ ] `npm run build` 로컬 테스트 -- [ ] `npm run lint` 통과 확인 -- [ ] `tsc --noEmit` TypeScript 타입 체크 -- [ ] 브라우저 콘솔 에러 없는지 확인 - -### 보안 -- [ ] API Key 브라우저 노출 문제 해결 -- [ ] HTTPS 사용 확인 -- [ ] 민감 정보 환경 변수로 분리 -- [ ] `X-Powered-By` 헤더 제거 (`poweredByHeader: false`) - -### 성능 -- [ ] 이미지 최적화 (Next.js Image 컴포넌트 사용) -- [ ] 번들 사이즈 확인 (`npm run build` 출력 확인) -- [ ] Gzip/Brotli 압축 활성화 -- [ ] CDN 설정 (필요 시) - ---- - -## 🔧 현재 상태 (2025-11-07) - -**개발 환경**: -- ✅ API URL: `https://api.5130.co.kr` (운영 API 사용 중) -- ⚠️ Frontend URL: `http://localhost:3000` (로컬) -- ⚠️ API Key: `NEXT_PUBLIC_API_KEY` (브라우저 노출) -- ✅ Auth Mode: `sanctum` (쿠키 기반 인증) - -**내부 개발용 사용 중**: -- 현재는 개발/테스트 목적으로 API Key 노출 허용 -- 운영 배포 시 반드시 위 체크리스트 검토 필요 - ---- - -## 📌 참고 문서 - -- `claudedocs/api-key-management.md` - API Key 관리 가이드 -- `claudedocs/authentication-design.md` - 인증 시스템 설계 -- `claudedocs/authentication-implementation-guide.md` - 구현 가이드 -- `.env.example` - 환경 변수 템플릿 - ---- - -## 📞 배포 전 확인 담당 - -- **API Key 발급**: PHP 백엔드 팀 -- **CORS 설정**: PHP 백엔드 팀 -- **인프라 설정**: DevOps 팀 -- **보안 검토**: 보안 담당자 - ---- - -**마지막 업데이트**: 2025-11-07 -**다음 검토 예정**: 운영 배포 1주 전 \ No newline at end of file diff --git a/claudedocs/archive/[REF] project-context.md b/claudedocs/archive/[REF] project-context.md deleted file mode 100644 index 71f611f6..00000000 --- a/claudedocs/archive/[REF] project-context.md +++ /dev/null @@ -1,428 +0,0 @@ -# SAM React 프로젝트 컨텍스트 - -> **중요**: 이 파일은 모든 세션에서 가장 먼저 읽어야 하는 프로젝트 개요 문서입니다. - -## 📋 프로젝트 개요 - -**프로젝트 명**: SAM React (Multi-tenant ERP System) -**기술 스택**: Next.js 15 (App Router) + TypeScript + Tailwind CSS -**백엔드**: Laravel PHP API (https://api.5130.co.kr) -**인증 방식**: JWT Bearer Token (Cookie 저장) -**다국어**: 한국어(ko), 영어(en), 일본어(ja) - ---- - -## 🎯 핵심 기능 - -### 1. 다국어 지원 (i18n) -- **라이브러리**: next-intl v4 -- **기본 언어**: 한국어(ko) -- **지원 언어**: ko, en, ja -- **URL 구조**: - - 기본 언어: `/dashboard` (로케일 표시 안함) - - 다른 언어: `/en/dashboard`, `/ja/dashboard` -- **자동 감지**: Accept-Language 헤더, 쿠키 - -**주요 파일**: -``` -src/i18n/config.ts # 언어 설정 -src/i18n/request.ts # 메시지 로딩 -src/messages/*.json # 번역 파일 -``` - ---- - -### 2. 인증 시스템 (Authentication) - -#### 인증 방식 -**현재 사용**: JWT Bearer Token + Cookie 저장 -- Login → Token 발급 → Cookie에 저장 (`user_token`) -- Middleware에서 Cookie 확인 -- API 호출 시 Authorization 헤더 자동 추가 - -**지원 방식** (3가지): -1. **Bearer Token** (Primary): `user_token` 쿠키 -2. **Sanctum Session** (Legacy): `laravel_session` 쿠키 -3. **API Key** (Server-to-Server): `X-API-KEY` 헤더 - -#### API 엔드포인트 -``` -POST /api/v1/login # 로그인 -POST /api/v1/logout # 로그아웃 -GET /api/user # 사용자 정보 -``` - -#### 주요 파일 -``` -src/lib/api/auth/auth-config.ts # 라우트 설정 -src/lib/api/auth/types.ts # 타입 정의 -src/lib/api/client.ts # HTTP Client -src/middleware.ts # 인증 체크 -src/app/api/auth/* # API Routes -``` - ---- - -### 3. Route 보호 (Route Protection) - -#### 라우트 분류 -**Protected Routes** (인증 필요): -- `/dashboard`, `/admin`, `/tenant`, `/settings`, `/users`, `/reports` -- 기타 모든 경로 (guestOnlyRoutes, publicRoutes 제외) - -**Guest-only Routes** (로그인 시 접근 불가): -- `/login`, `/register` - -**Public Routes** (누구나 접근 가능): -- `/` (홈), `/about`, `/contact` - -#### 동작 방식 -``` -Middleware 체크 순서: -1. Bot Detection → 봇이면 403 -2. 정적 파일 체크 → 정적이면 Skip -3. 인증 체크 (3가지 방식) -4. Guest-only 체크 → 로그인 상태면 /dashboard로 -5. Public 체크 → Public이면 통과 -6. Protected 체크 → 비로그인이면 /login으로 -7. i18n 처리 -``` - ---- - -### 4. Bot 차단 (Bot Detection) - -#### 목적 -- ERP 시스템 보안 강화 -- Crawler/Spider로부터 보호된 경로 차단 - -#### 차단 대상 -```typescript -BOT_PATTERNS = [ - /bot/i, /crawler/i, /spider/i, /scraper/i, - /curl/i, /wget/i, /python-requests/i, - /headless/i, /puppeteer/i, /playwright/i -] -``` - -#### 차단 경로 -- `/dashboard`, `/admin`, `/api`, `/tenant` 등 -- Public 경로(`/`, `/login`)는 bot 허용 - ---- - -### 5. 테마 시스템 - -**기능**: 다크모드/라이트모드 전환 -**구현**: Context API + localStorage - -**주요 파일**: -``` -src/contexts/ThemeContext.tsx -src/components/ThemeSelect.tsx -``` - ---- - -## 📁 프로젝트 구조 - -``` -sam-react-prod/ -├─ src/ -│ ├─ app/[locale]/ -│ │ ├─ (protected)/ # 보호된 라우트 그룹 -│ │ │ ├─ layout.tsx # AuthGuard Layout -│ │ │ └─ dashboard/ -│ │ │ └─ page.tsx -│ │ ├─ login/page.tsx -│ │ ├─ signup/page.tsx -│ │ ├─ page.tsx # 홈 -│ │ └─ layout.tsx # 루트 레이아웃 -│ │ -│ ├─ components/ -│ │ ├─ auth/ -│ │ │ ├─ LoginPage.tsx -│ │ │ └─ SignupPage.tsx -│ │ ├─ ui/ # Shadcn UI 컴포넌트 -│ │ ├─ ThemeSelect.tsx -│ │ ├─ LanguageSelect.tsx -│ │ └─ NavigationMenu.tsx -│ │ -│ ├─ lib/ -│ │ ├─ api/ -│ │ │ ├─ client.ts # HTTP Client -│ │ │ └─ auth/ -│ │ │ ├─ auth-config.ts -│ │ │ └─ types.ts -│ │ ├─ validations/ -│ │ │ └─ auth.ts # Zod 스키마 -│ │ └─ utils.ts -│ │ -│ ├─ contexts/ -│ │ └─ ThemeContext.tsx -│ │ -│ ├─ hooks/ -│ │ └─ useAuthGuard.ts -│ │ -│ ├─ i18n/ -│ │ ├─ config.ts -│ │ └─ request.ts -│ │ -│ ├─ messages/ -│ │ ├─ ko.json -│ │ ├─ en.json -│ │ └─ ja.json -│ │ -│ └─ middleware.ts # 통합 Middleware -│ -├─ claudedocs/ # 프로젝트 문서 -│ ├─ 00_INDEX.md # 문서 인덱스 -│ ├─ project-context.md # 이 파일 -│ └─ ... -│ -├─ .env.local # 환경 변수 (실제 값) -├─ .env.example # 환경 변수 템플릿 -├─ package.json -├─ next.config.ts -├─ tsconfig.json -└─ tailwind.config.ts -``` - ---- - -## 🔧 환경 설정 - -### 필수 환경 변수 (.env.local) - -```env -# API Configuration -NEXT_PUBLIC_API_URL=https://api.5130.co.kr -NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 - -# API Key (서버 사이드 전용 - 절대 공개 금지!) -API_KEY=42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a -``` - -### Next.js 설정 (next.config.ts) - -```typescript -import createNextIntlPlugin from 'next-intl/plugin'; - -const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); - -const nextConfig: NextConfig = { - turbopack: {}, // Next.js 15 + next-intl 필수 설정 -}; - -export default withNextIntl(nextConfig); -``` - ---- - -## 🚀 주요 라이브러리 - -```json -{ - "dependencies": { - "next": "^15.5.6", - "react": "19.2.0", - "next-intl": "^4.4.0", - "react-hook-form": "^7.66.0", - "zod": "^4.1.12", - "@radix-ui/react-*": "^2.x", - "tailwindcss": "^4", - "lucide-react": "^0.552.0", - "clsx": "^2.1.1", - "tailwind-merge": "^3.3.1" - } -} -``` - ---- - -## 📝 일반적인 작업 패턴 - -### 새 보호된 페이지 추가 - -1. **페이지 파일 생성**: - ``` - src/app/[locale]/(protected)/new-page/page.tsx - ``` - -2. **라우트 설정 추가** (선택사항): - ```typescript - // src/lib/api/auth/auth-config.ts - protectedRoutes: [ - ... - '/new-page' - ] - ``` - -3. **자동으로 인증 체크 적용됨** (Middleware가 처리) - ---- - -### 새 번역 키 추가 - -1. **모든 언어 파일에 키 추가**: - ```json - // src/messages/ko.json - { - "newFeature": { - "title": "새 기능", - "description": "설명" - } - } - - // src/messages/en.json - { - "newFeature": { - "title": "New Feature", - "description": "Description" - } - } - - // src/messages/ja.json - { - "newFeature": { - "title": "新機能", - "description": "説明" - } - } - ``` - -2. **컴포넌트에서 사용**: - ```typescript - const t = useTranslations('newFeature'); - -

{t('title')}

-

{t('description')}

- ``` - ---- - -### API 호출 패턴 - -```typescript -// src/lib/api/client.ts 사용 -import { apiClient } from '@/lib/api/client'; - -// GET 요청 -const data = await apiClient.get('/api/endpoint'); - -// POST 요청 -const result = await apiClient.post('/api/endpoint', { - key: 'value' -}); -``` - ---- - -## ⚠️ 중요 주의사항 - -### 1. 환경 변수 보안 -- ❌ `API_KEY`에 절대 `NEXT_PUBLIC_` 붙이지 말 것! -- ✅ `.env.local`은 Git에 커밋 금지 (.gitignore 포함됨) -- ✅ `.env.example`만 템플릿으로 관리 - -### 2. Middleware 주의사항 -- Middleware는 **서버 사이드**에서 실행됨 -- `localStorage` 접근 불가 -- `console.log`는 **터미널**에 출력됨 (브라우저 콘솔 아님) - -### 3. Route Protection 규칙 -- **기본 정책**: 모든 페이지는 인증 필요 -- **예외**: `publicRoutes`, `guestOnlyRoutes`에 명시된 경로만 -- `/` 경로 주의: 정확히 일치할 때만 public - -### 4. i18n 사용 시 -- 모든 언어 파일에 동일한 키 추가 필수 -- Link 사용 시 로케일 포함: `/${locale}/path` -- 날짜/숫자는 `useFormatter` 훅 사용 - ---- - -## 🐛 알려진 이슈 및 해결 방법 - -### 1. Middleware 인증 체크 안됨 -**증상**: 로그인 안해도 보호된 페이지 접근 가능 -**원인**: `isPublicRoute()` 함수의 `'/'` 매칭 버그 -**해결**: `middleware-issue-resolution.md` 참고 - -### 2. Next.js 15 + next-intl 에러 -**증상**: Middleware 컴파일 에러 -**원인**: `turbopack` 설정 누락 -**해결**: `next.config.ts`에 `turbopack: {}` 추가 - ---- - -## 📚 문서 참고 순서 - -새 세션 시작 시 권장 읽기 순서: - -1. **이 파일** (`project-context.md`) - 프로젝트 전체 개요 -2. **`00_INDEX.md`** - 상세 문서 인덱스 -3. **작업할 기능의 관련 문서** - 인덱스에서 검색 - -### 주요 문서 빠른 링크 - -| 작업 | 문서 | -|------|------| -| 다국어 작업 | `i18n-usage-guide.md` | -| 인증 관련 | `jwt-cookie-authentication-final.md` | -| 라우트 보호 | `route-protection-architecture.md` | -| 폼 검증 | `form-validation-guide.md` | -| API 통합 | `authentication-implementation-guide.md` | -| Middleware 수정 | `middleware-issue-resolution.md` | - ---- - -## 🔄 최근 변경 사항 - -### 2025-11-10 -- 테마 선택 및 언어 선택 기능 추가 -- 다국어 지원 구현 완료 -- Git branch: `feature/theme-language-selector` - -### 2025-11-07 -- Middleware 인증 문제 해결 -- JWT Cookie 인증 방식 확정 -- Bot 차단 기능 구현 - -### 2025-11-06 -- i18n 설정 완료 (ko, en, ja) -- 프로젝트 초기 구조 설정 - ---- - -## 💡 개발 팁 - -### 디버깅 -- **Middleware 로그**: 터미널 확인 (브라우저 콘솔 아님) -- **인증 상태**: 브라우저 개발자 도구 → Application → Cookies → `user_token` 확인 -- **API 요청**: Network 탭에서 Authorization 헤더 확인 - -### 성능 -- 서버 컴포넌트 우선 사용 (클라이언트 번들 크기 감소) -- 정적 파일은 Middleware에서 조기 리턴 -- API 응답 캐싱 고려 - -### 보안 -- 민감한 데이터는 서버 컴포넌트에서만 처리 -- API Key는 절대 클라이언트에 노출 금지 -- CORS 설정 확인 (Laravel 측) - ---- - -## 📞 문제 발생 시 - -1. **이 파일 다시 읽기** -2. **`00_INDEX.md`에서 관련 문서 찾기** -3. **`middleware-issue-resolution.md` 참고** (인증 관련 이슈) -4. **Git 히스토리 확인** (`git log`, `git diff`) - ---- - -**마지막 업데이트**: 2025-11-10 -**작성자**: Claude Code -**프로젝트 저장소**: sam-react-prod \ No newline at end of file diff --git a/claudedocs/archive/[SESSION-2025-11-18] localStorage-ssr-fix-checkpoint.md b/claudedocs/archive/[SESSION-2025-11-18] localStorage-ssr-fix-checkpoint.md deleted file mode 100644 index 5d5fac83..00000000 --- a/claudedocs/archive/[SESSION-2025-11-18] localStorage-ssr-fix-checkpoint.md +++ /dev/null @@ -1,169 +0,0 @@ -# [SESSION-2025-11-18] localStorage SSR 수정 작업 체크포인트 - -## 세션 상태: ✅ 완료 (9/9 완료) - -### 작업 개요 -- **목표**: ItemMasterDataManagement.tsx의 모든 localStorage 접근을 SSR 호환으로 수정 ✅ -- **파일**: `src/components/items/ItemMasterDataManagement.tsx` -- **크기**: 274KB (대용량 파일) -- **진행률**: 9/9 완료 ✅ -- **빌드 테스트**: ✅ 성공 (3.1초) - -### 작업 배경 -- React → Next.js 마이그레이션 작업 진행 중 -- SSR 환경에서 localStorage 접근 시 `ReferenceError: localStorage is not defined` 에러 발생 -- `typeof window === 'undefined'` 체크를 통한 SSR 호환성 확보 필요 - -### 수정 대상 (6곳) - -#### 1. attributeSubTabs (Line ~460) -```typescript -// 현재 코드 -const [attributeSubTabs, setAttributeSubTabs] = useState>(() => { - const saved = localStorage.getItem('mes-attributeSubTabs'); // ❌ SSR 오류 - // ... -}); - -// 수정 필요 -const [attributeSubTabs, setAttributeSubTabs] = useState>(() => { - if (typeof window === 'undefined') { - return [ - { id: 'units', label: '단위', key: 'units', isDefault: true, order: 0 }, - { id: 'materials', label: '재질', key: 'materials', isDefault: true, order: 1 }, - { id: 'surface', label: '표면처리', key: 'surface', isDefault: true, order: 2 } - ]; - } - const saved = localStorage.getItem('mes-attributeSubTabs'); - // ... -}); -``` -**상태**: ❌ 미완료 - -#### 2. attributeColumns (Line ~668) -```typescript -// 현재 코드 -const [attributeColumns, setAttributeColumns] = useState>(() => { - const saved = localStorage.getItem('attribute-columns'); // ❌ SSR 오류 - return saved ? JSON.parse(saved) : {}; -}); - -// 수정 필요 -const [attributeColumns, setAttributeColumns] = useState>(() => { - if (typeof window === 'undefined') return {}; - const saved = localStorage.getItem('attribute-columns'); - return saved ? JSON.parse(saved) : {}; -}); -``` -**상태**: ❌ 미완료 - -#### 3. bomItems (Line ~820) -```typescript -// 현재 코드 -const [bomItems, setBomItems] = useState(() => { - const saved = localStorage.getItem('bom-items'); // ❌ SSR 오류 - return saved ? JSON.parse(saved) : []; -}); - -// 수정 필요 -const [bomItems, setBomItems] = useState(() => { - if (typeof window === 'undefined') return []; - const saved = localStorage.getItem('bom-items'); - return saved ? JSON.parse(saved) : []; -}); -``` -**상태**: ❌ 미완료 - -#### 4-6. 추가 localStorage 사용 위치 (검색 필요) -**검색 명령**: -```bash -grep -n "localStorage.getItem\|localStorage.setItem" src/components/items/ItemMasterDataManagement.tsx -``` -**상태**: ❌ 확인 필요 - -### 작업 계획 - -#### Phase 1: 전체 localStorage 사용 위치 파악 -```bash -grep -n "localStorage" src/components/items/ItemMasterDataManagement.tsx > /tmp/localstorage-usage.txt -``` - -#### Phase 2: useState 초기화 수정 -- attributeSubTabs 수정 -- attributeColumns 수정 -- bomItems 수정 -- 기타 발견된 useState 초기화 수정 - -#### Phase 3: useEffect 내부 수정 (필요 시) -- useEffect 내부의 localStorage 접근은 SSR 안전 (클라이언트에서만 실행) -- 필요 시 체크 추가 - -#### Phase 4: 테스트 및 검증 -```bash -# 빌드 테스트 -npm run build - -# 타입 체크 -npm run type-check - -# 개발 서버 실행 -npm run dev -``` - -### 세션 재개 방법 - -#### 다음 세션 시작 시 -```bash -# 1. 이 문서 확인 -cat claudedocs/[SESSION-2025-11-18] localStorage-ssr-fix-checkpoint.md - -# 2. 작업 재개 -"localStorage SSR 수정 작업 이어서 진행해줘" -``` - -#### 또는 /sc:load 사용 -```bash -/sc:load -# 자동으로 이 체크포인트를 로드하여 작업 재개 -``` - -### 주의사항 - -#### 대용량 파일 작업 전략 -- ✅ **섹션별 작업**: 한 번에 1-2개 수정, 즉시 커밋 -- ✅ **빈번한 커밋**: 5분마다 WIP 커밋 -- ✅ **토큰 관리**: 불필요한 파일 Read 최소화 -- ❌ **한 번에 전체 수정 금지**: 세션 중단 위험 - -#### 세션 중단 방지 -```yaml -checkpoint_strategy: - interval: "5-10분마다 커밋" - pattern: "수정 → 커밋 → 수정 → 커밋" - max_continuous_work: "15분" -``` - -### 관련 문서 -- `[GUIDE] LARGE-FILE-WORKFLOW.md` - 대용량 파일 작업 가이드 -- `[REF] nextjs15-middleware-authentication-research.md` - SSR 호환성 참고 - -### 체크리스트 - -- [x] Phase 1: localStorage 사용 위치 전체 파악 ✅ -- [x] Phase 2-1: customTabs 수정 ✅ (이미 완료됨) -- [x] Phase 2-2: attributeSubTabs 수정 ✅ (이미 완료됨) -- [x] Phase 2-3: attributeColumns 수정 ✅ (이미 완료됨) -- [x] Phase 2-4: bomItems 수정 ✅ (이미 완료됨) -- [x] Phase 2-5: itemCategories 수정 ✅ (이미 완료됨) -- [x] Phase 2-6: unitOptions 수정 ✅ (이미 완료됨) -- [x] Phase 2-7: materialOptions 수정 ✅ (이미 완료됨) -- [x] Phase 2-8: surfaceTreatmentOptions 수정 ✅ (이미 완료됨) -- [x] Phase 2-9: customAttributeOptions 수정 ✅ (이미 완료됨) -- [x] Phase 3: useEffect 내부 체크 (안전 확인) ✅ -- [x] Phase 4-1: 빌드 테스트 ✅ (3.1초 성공) -- [x] Phase 4-2: 타입 체크 ✅ (빌드에 포함) -- [x] 최종 커밋 및 문서 업데이트 ⏳ - ---- - -**작업 완료 시간**: 2025-11-18 -**결과**: 모든 localStorage SSR 호환성 수정 완료 ✅ diff --git a/claudedocs/archive/qa-inbox-modal-test.png b/claudedocs/archive/qa-inbox-modal-test.png deleted file mode 100644 index 7f4d3e5b1ab1a6082002f971d47496e3388cc46c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 751695 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 z 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℞djl?DK1-J1P8>$Nh_IFqGQ2`qfwBAA&3 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

Zx|j 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 z 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

  • eU_I0N0vZf*$`yIkyvd_Zz`HXG!>-N26ey^85 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 U0|1-`e-ZdW&sg`#A?(lp1t-m%ga7~l diff --git a/claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-api-pending-tasks.md b/claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-api-pending-tasks.md deleted file mode 100644 index 7a61a4b9..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-api-pending-tasks.md +++ /dev/null @@ -1,227 +0,0 @@ -# 품목기준관리 - API 연동 작업 체크리스트 - -**작성일**: 2025-11-26 -**상태**: ✅ Phase 3 완료 -**마지막 업데이트**: 2025-11-26 API 연결 구현 완료 (Phase 3 ✅) - ---- - -## 1. 구조 변경 사항 - -- `section_templates` 테이블 삭제 → `item_sections.is_template=true`로 통합 -- `section_name` → `title`로 통일 (API와 동일) -- `bomItems` → `bom_items`로 통일 (API와 동일) -- `field_type`: API와 Frontend가 동일한 값 사용 ('textbox', 'number', 'dropdown' 등) - ---- - -## 2. API 연동 체크리스트 - -### 2.1 타입 정의 (src/types/item-master-api.ts) - -- [x] ItemSectionResponse에 is_template, is_default, description, group_id 추가 -- [x] IndependentSectionRequest 추가 -- [x] IndependentFieldRequest 추가 -- [x] IndependentBomItemRequest 추가 -- [x] SectionUsageResponse 추가 -- [x] FieldUsageResponse 추가 -- [x] LinkSectionRequest 추가 -- [x] LinkFieldRequest 추가 -- [x] PageStructureResponse 추가 -- [x] MasterFieldResponse에 is_common, default_value, options 추가 - -### 2.2 API 클라이언트 (src/lib/api/item-master.ts) - ✅ 완료 - -#### 독립 엔티티 API (완료) -- [x] `GET /sections` - `sections.list()` (is_template 필터 지원) -- [x] `POST /sections` - `sections.createIndependent()` -- [x] `POST /sections/{id}/clone` - `sections.clone()` -- [x] `GET /sections/{id}/usage` - `sections.getUsage()` -- [x] `GET /fields` - `fields.list()` -- [x] `POST /fields` - `fields.createIndependent()` -- [x] `POST /fields/{id}/clone` - `fields.clone()` -- [x] `GET /fields/{id}/usage` - `fields.getUsage()` -- [x] `GET /bom-items` - `bomItems.list()` -- [x] `POST /bom-items` - `bomItems.createIndependent()` - -#### 링크 관리 API (완료) -- [x] `POST /pages/{id}/link-section` - `pages.linkSection()` -- [x] `DELETE /pages/{id}/unlink-section/{sectionId}` - `pages.unlinkSection()` -- [x] `POST /sections/{id}/link-field` - `sections.linkField()` -- [x] `DELETE /sections/{id}/unlink-field/{fieldId}` - `sections.unlinkField()` -- [x] `GET /pages/{id}/structure` - `pages.getStructure()` - -#### 섹션 템플릿 API 수정 (완료) -- [x] `sections.list({ is_template: true })` 로 템플릿 조회 가능 - -### 2.3 Context 업데이트 (src/contexts/ItemMasterContext.tsx) - -#### 인터페이스 수정 (완료) -- [x] ItemSection 인터페이스에 title, group_id, is_template, is_default, description 추가 -- [x] ItemSection.section_name → title 변경 -- [x] ItemSection.bomItems → bom_items 변경 -- [x] ItemMasterField 인터페이스에 is_common, default_value, options, validation_rules, properties 추가 - -#### Transformer 수정 (완료) -- [x] transformSectionResponse: 새 필드 추가 (group_id, is_template, is_default, description) -- [x] transformMasterFieldResponse: 새 필드 추가 및 속성명 통일 -- [x] field_type 변환 제거 (API와 동일한 값 사용) - -#### TypeScript 오류 수정 (완료 ✅) -- [x] bomItems → bom_items 참조 수정 (addBOMItem, updateBOMItem, deleteBOMItem) -- [x] transformers.ts FIELD_TYPE_MAP 오류 수정 -- [x] transformPageResponse: order_no, description 추가 -- [x] ItemPageResponse: order_no, description 추가 -- [x] 전체 타입 검증 완료 - -#### 기능 추가 (완료 ✅) -- [x] 독립 섹션/필드/BOM 상태 추가 -- [x] 링크/언링크 메서드 추가 -- [x] 사용처 조회 메서드 추가 -- [x] 섹션 템플릿 로직 수정 (is_template 필터) -- [x] 복제 기능 (cloneSection, cloneField) - -### 2.4 계층구조(페이지) 탭 UI - ✅ 완료 - -- [x] 섹션 불러오기 다이얼로그 (ImportSectionDialog.tsx) -- [x] 필드 불러오기 다이얼로그 (ImportFieldDialog.tsx) -- [x] 불러오기 버튼 추가 (HierarchyTab) -- [x] 사용처 표시 UI (다이얼로그 내 Usage Info Panel) - -### 2.5 섹션 탭 UI - ✅ 완료 - -- [x] 섹션 복제(Clone) 버튼 추가 (SectionsTab.tsx) -- [x] 필드 불러오기(Import Field) 버튼 추가 (SectionsTab.tsx) -- [x] ItemMasterDataManagement에서 props 연결 (handleCloneSection, setIsImportFieldDialogOpen) -- [x] TypeScript 오류 수정: - - section_name → title 변경 (useSectionManagement, useTemplateManagement, DraggableSection, FieldDrawer, ConditionalDisplayUI) - - bomItems → bom_items 변경 (hooks 파일들) - - is_template, is_default 필수 속성 추가 - -### 2.6 마스터 항목 탭 UI - ✅ 완료 - -- [x] 기본 CRUD UI 구현됨 (MasterFieldTab/index.tsx) -- [x] 필드 타입 배지 표시 -- [x] 필수 여부, 카테고리, 속성 타입 배지 표시 -- [x] 옵션 목록 표시 - ---- - -## 3. Phase 3: API 연결 구현 - ✅ 완료 - -> **분석 결과**: 모든 API 연결이 이미 Context에서 완료되어 있습니다. - -### 3.1 초기화 API 연결 - ✅ 완료 - -- [x] `/v1/item-master/init` API 호출 구현 (ItemMasterDataManagement.tsx:301-361) -- [x] Context `loadItemPages`, `loadSectionTemplates`, `loadItemMasterFields` 메서드 연결 -- [x] 로딩 상태 관리 UI (LoadingSpinner, ErrorMessage) - -### 3.2 페이지 CRUD API 연결 - ✅ 완료 - -- [x] 페이지 생성 API 연결 (`addItemPage` → `itemMasterApi.pages.create()`) -- [x] 페이지 수정 API 연결 (`updateItemPage` → `itemMasterApi.pages.update()`) -- [x] 페이지 삭제 API 연결 (`deleteItemPage` → `itemMasterApi.pages.delete()`) -- [x] 페이지 순서 변경 API 연결 (`reorderPages` → `itemMasterApi.pages.reorder()`) -- [x] 섹션 링크/언링크 API 연결 (`linkSectionToPage`, `unlinkSectionFromPage`) - -### 3.3 섹션 CRUD API 연결 - ✅ 완료 - -- [x] 섹션 생성 API 연결 (`addSectionToPage` → `itemMasterApi.sections.create()`) -- [x] 섹션 수정 API 연결 (`updateSection` → `itemMasterApi.sections.update()`) -- [x] 섹션 삭제/언링크 API 연결 (`deleteSection` → `itemMasterApi.sections.delete()`) -- [x] 섹션 순서 변경 API 연결 (`reorderSections` → `itemMasterApi.sections.reorder()`) -- [x] 독립 섹션 생성 (`createIndependentSection` → `itemMasterApi.sections.createIndependent()`) -- [x] 섹션 복제 (`cloneSection` → `itemMasterApi.sections.clone()`) -- [x] 섹션 사용처 조회 (`getSectionUsage` → `itemMasterApi.sections.getUsage()`) -- [x] 필드 링크/언링크 API 연결 (`linkFieldToSection`, `unlinkFieldFromSection`) - -### 3.4 필드 CRUD API 연결 - ✅ 완료 - -- [x] 필드 생성 API 연결 (`addFieldToSection` → `itemMasterApi.fields.create()`) -- [x] 필드 수정 API 연결 (`updateField` → `itemMasterApi.fields.update()`) -- [x] 필드 삭제/언링크 API 연결 (`deleteField` → `itemMasterApi.fields.delete()`) -- [x] 필드 순서 변경 API 연결 (`reorderFields` → `itemMasterApi.fields.reorder()`) -- [x] 독립 필드 생성 (`createIndependentField` → `itemMasterApi.fields.createIndependent()`) -- [x] 필드 복제 (`cloneField` → `itemMasterApi.fields.clone()`) -- [x] 필드 사용처 조회 (`getFieldUsage` → `itemMasterApi.fields.getUsage()`) - -### 3.5 마스터 필드 CRUD API 연결 - ✅ 완료 - -- [x] 마스터 필드 생성 API 연결 (`addItemMasterField` → `itemMasterApi.masterFields.create()`) -- [x] 마스터 필드 수정 API 연결 (`updateItemMasterField` → `itemMasterApi.masterFields.update()`) -- [x] 마스터 필드 삭제 API 연결 (`deleteItemMasterField` → `itemMasterApi.masterFields.delete()`) - -### 3.6 BOM CRUD API 연결 - ✅ 완료 - -- [x] BOM 생성 API 연결 (`addBOMItem` → `itemMasterApi.bomItems.create()`) -- [x] BOM 수정 API 연결 (`updateBOMItem` → `itemMasterApi.bomItems.update()`) -- [x] BOM 삭제 API 연결 (`deleteBOMItem` → `itemMasterApi.bomItems.delete()`) -- [x] 독립 BOM 생성 (`createIndependentBomItem` → `itemMasterApi.bomItems.createIndependent()`) - -### Hooks → Context 연결 현황 - ✅ 완료 - -| Hook | Context 함수 | 상태 | -|------|-------------|------| -| usePageManagement | `addItemPage`, `updateItemPage`, `deleteItemPage` | ✅ | -| useSectionManagement | `addSectionToPage`, `updateSection`, `deleteSection` | ✅ | -| useFieldManagement | `addFieldToSection`, `updateField`, `deleteField` | ✅ | -| useMasterFieldManagement | `addItemMasterField`, `updateItemMasterField`, `deleteItemMasterField` | ✅ | - ---- - -## 4. 삭제 vs 연결해제 정리 - -``` -[계층구조 탭에서] -├─ 페이지 삭제 → 실제 삭제 (Cascade) -├─ 섹션 제거 → 연결만 끊기 (unlink), 섹션 데이터는 유지 -└─ 항목 제거 → 연결만 끊기 (unlink), 항목 데이터는 유지 - -[섹션 탭에서] -├─ 섹션 삭제 → 실제 삭제 (Cascade) -└─ 항목 삭제 → 실제 삭제 - -[마스터 항목 탭에서] -└─ 마스터 항목 삭제 → 실제 삭제 -``` - ---- - -## 4. 데이터 연결 구조 - -``` -독립 필드 (fields, section_id=null) - │ - ├──[link-field]──→ 섹션에 연결 - │ ↓ -독립 섹션 (sections, page_id=null) - │ - ├──[link-section]──→ 페이지에 연결 - │ ↓ -페이지 (pages) = 품목유형별 필드 구성 -``` - ---- - -## 5. 핵심 개념 - -> **"페이지"는 실제 URL 경로가 아니라, 품목유형별 필드 구성 템플릿이다!** - -``` -품목기준관리의 "페이지" - = 품목유형(FG, PT, SM, RM, CS)별로 - = 품목 등록 시 어떤 섹션/필드를 보여줄지 정의하는 템플릿 -``` - ---- - -## 6. 참고 문서 - -- `claudedocs/[ANALYSIS-2025-11-21] item-master-notes.md` - 이전 API 문서 -- `claudedocs/[ANALYSIS-2025-11-26] item-master-notes.md` - 신규 API 문서 -- `~/Desktop/코브라브릿지백엔드문서/[API-2025-11-26] item-master-api-changes.md` - API 변경사항 - ---- - -**마지막 업데이트**: 2025-11-26 작업 시작 diff --git a/claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-pending-integration.md b/claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-pending-integration.md deleted file mode 100644 index e0e3eb89..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-pending-integration.md +++ /dev/null @@ -1,106 +0,0 @@ -# 품목기준관리 - 백엔드 통합 대기 작업 - -**작성일**: 2025-11-26 -**상태**: 백엔드 통합 작업 대기 중 - ---- - -## 현재 상황 요약 - -### 해결된 이슈 - -1. **섹션 순서 변경 422 에러** - - 원인: 백엔드가 `items` 필드를 기대하는데 프론트가 `section_orders` 전송 - - 수정 파일: - - `src/types/item-master-api.ts` - `SectionReorderRequest.items`로 변경 - - `src/contexts/ItemMasterContext.tsx` - `reorderSections` 함수 수정 - -2. **response.data.map is not a function 에러** - - 원인: 백엔드 응답이 배열이 아닌 경우 처리 누락 - - 수정: 배열/비배열 응답 모두 처리하도록 조건문 추가 - -3. **불러오기 다이얼로그에 마스터 항목 미표시** - - 수정 파일: - - `src/components/items/ItemMasterDataManagement/dialogs/ImportFieldDialog.tsx` - - `src/components/items/ItemMasterDataManagement.tsx` - - 변경 내용: 마스터 항목 / 독립 필드 탭 분리 - ---- - -## 백엔드 통합 대기 중인 이슈 - -### 데이터 동기화 문제 - -**현상**: -- 계층구조에서 섹션 내 항목 생성 시 → 마스터항목 탭, 속성 탭, 불러오기에도 표시됨 -- 원인: `GET /v1/item-master/fields` API가 모든 필드를 반환 (독립 필드만 반환해야 함) - -**백엔드 요청 사항**: -1. `GET /v1/item-master/fields` → `section_id IS NULL`인 필드만 반환 -2. 마스터 항목 + 섹션 필드 통합 구조 검토 - -### 현재 데이터 구조 (분리됨) - -``` -item_master_fields 테이블 (마스터 항목) -├─ 항목 탭에서 생성/관리 -├─ 속성 탭 서브탭으로 표시 -└─ 불러오기 시 "복사"하여 새 필드 생성 - -item_fields 테이블 (실제 필드) -├─ section_id != null → 섹션 필드 (계층구조/섹션 탭) -└─ section_id = null → 독립 필드 (불러오기에서 "연결") -``` - -### 예상되는 통합 구조 (백엔드 작업 중) - -``` -통합된 필드 테이블 -├─ is_master = true → 마스터 필드 (템플릿) -├─ section_id != null → 섹션 필드 -└─ section_id = null, is_master = false → 독립 필드 - -→ 마스터 필드 수정 시 연결된 모든 필드에 반영 -``` - ---- - -## 프론트엔드 수정 필요 사항 (백엔드 통합 후) - -### 1. API 응답 구조 변경 대응 -- `InitResponse` 타입 수정 (통합된 필드 구조) -- `transformers.ts` 변환 로직 수정 - -### 2. Context 수정 -- `itemMasterFields` vs `independentFields` 통합 가능성 -- 필드 CRUD 함수 통합 - -### 3. UI 수정 -- ImportFieldDialog 탭 구조 재검토 (통합되면 탭 불필요할 수 있음) -- 데이터 동기화 로직 단순화 - ---- - -## 관련 파일 목록 - -### 수정된 파일 (2025-11-26) -- `src/types/item-master-api.ts` -- `src/contexts/ItemMasterContext.tsx` -- `src/lib/api/error-handler.ts` -- `src/components/items/ItemMasterDataManagement.tsx` -- `src/components/items/ItemMasterDataManagement/dialogs/ImportFieldDialog.tsx` - -### 참고할 파일 -- `src/lib/api/item-master.ts` - API 호출 함수 -- `src/lib/api/transformers.ts` - 응답 변환 함수 -- `src/components/items/ItemMasterDataManagement/hooks/useTabManagement.ts` - 속성 탭 생성 로직 - ---- - -## 다음 작업 체크리스트 - -- [ ] 백엔드 통합 API 완료 확인 -- [ ] 새 API 응답 구조 확인 및 타입 수정 -- [ ] Context 데이터 구조 통합 -- [ ] ImportFieldDialog 통합 여부 결정 -- [ ] 테스트: 마스터 항목 수정 → 연결된 필드 동기화 확인 \ No newline at end of file diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-06] item-crud-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-06] item-crud-session-context.md deleted file mode 100644 index 5c6580fc..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-06] item-crud-session-context.md +++ /dev/null @@ -1,80 +0,0 @@ -# 다음 세션 컨텍스트 - 품목관리 기능 개발 - -> 2025-12-06 세션에서 진행한 내용 및 다음 세션에서 이어갈 작업 - ---- - -## 완료된 작업 - -### 1. 삭제 알럿 제거 ✅ -- 품목 테이블에서 삭제 버튼 클릭 → 모달 확인 → 바로 삭제 (알럿 없이) -- 파일: `src/components/items/ItemListClient.tsx` - -### 2. 디버깅 콘솔 로그 제거 ✅ -- `DropdownField.tsx` - 단위 필드 디버깅 로그 제거 -- `useConditionalDisplay.ts` - 조건부 표시 디버깅 로그 제거 -- `useDynamicFormState.ts` - resetForm 디버깅 로그 제거 - ---- - -## 발견된 문제 (백엔드 수정 필요) - -### 소모품(CS) 규격(specification) 저장 안됨 🔴 - -**원인 분석 완료**: -1. 프론트엔드: `97_specification` → `spec` → `specification`으로 정상 변환됨 -2. 백엔드 문제: `ItemStoreRequest.php`의 validation rules에 `specification` 필드가 없음 -3. Laravel FormRequest는 rules에 없는 필드를 `$request->validated()`에서 제외 -4. 결과: DB에 규격이 null로 저장됨 - -**백엔드 수정 요청**: -```php -// /app/Http/Requests/Item/ItemStoreRequest.php -// rules()에 추가 필요: -'specification' => 'nullable|string|max:255', -``` - -**상세 문서**: `claudedocs/item-master/[API-2025-12-06] item-crud-backend-requests.md` - ---- - -## 다음 세션에서 확인할 사항 - -1. **백엔드 수정 후 테스트** - - 소모품 등록 시 규격 값 저장 확인 - - 상세 페이지에서 규격 표시 확인 - -2. **수정 API도 확인 필요** - - `ItemUpdateRequest.php`에도 `specification` 필드 있는지 확인 - - 어제 "수정하면 규격이 보였다"고 했는데, 수정 API는 다를 수 있음 - -3. **추가 편의 기능 개발** (사용자 요청 시) - - 품목관리 관련 추가 기능 구현 - ---- - -## 관련 파일 위치 - -### 프론트엔드 -- `src/components/items/ItemListClient.tsx` - 품목 목록/삭제 -- `src/components/items/ItemDetailClient.tsx` - 품목 상세 -- `src/components/items/DynamicItemForm/index.tsx` - 동적 폼 -- `src/app/[locale]/(protected)/items/create/page.tsx` - 등록 페이지 -- `src/app/[locale]/(protected)/items/[id]/edit/page.tsx` - 수정 페이지 -- `src/app/[locale]/(protected)/items/[id]/page.tsx` - 상세 페이지 - -### 백엔드 (sam-api) -- `/app/Http/Requests/Item/ItemStoreRequest.php` - 등록 요청 검증 ⚠️ 수정 필요 -- `/app/Http/Requests/Item/ItemUpdateRequest.php` - 수정 요청 검증 (확인 필요) -- `/app/Services/ItemsService.php` - 품목 서비스 -- `/app/Models/Materials/Material.php` - Material 모델 - ---- - -## 명령어 - -```bash -# 프론트엔드 개발 서버 -cd /Users/byeongcheolryu/codebridgex/sam_project/sam-next/sma-next-project/sam-react-prod -npm run dev -``` \ No newline at end of file diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-09] client-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-09] client-session-context.md deleted file mode 100644 index 20b3a5ba..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-09] client-session-context.md +++ /dev/null @@ -1,143 +0,0 @@ -# 거래처 관리 - 다음 세션 컨텍스트 - -> **작성일**: 2025-12-09 -> **목적**: 다음 세션에서 이어서 작업할 내용 정리 - ---- - -## 1. 완료된 작업 (2025-12-09) - -### 1.1 백엔드 API 2차 필드 추가 ✅ 완료 -- **커밋**: `d164bb4` - feat: [client] 거래처 API 2차 필드 추가 -- **마이그레이션**: `2025_12_04_205603_add_extended_fields_to_clients_table.php` -- **is_active 타입 변경**: `5f20005` - CHAR(1) → TINYINT(1) Boolean - -### 1.2 프론트엔드 API 연동 ✅ 완료 - -| 구성 요소 | 파일 | 상태 | -|----------|------|------| -| **Proxy** | `/api/proxy/[...path]/route.ts` | ✅ 완료 | -| **Hook** | `src/hooks/useClientList.ts` | ✅ 완료 (2차 필드 포함) | -| **목록** | `sales/client-management-sales-admin/page.tsx` | ✅ API 연동 | -| **등록** | `sales/client-management-sales-admin/new/page.tsx` | ✅ API 연동 | -| **수정** | `sales/client-management-sales-admin/[id]/edit/page.tsx` | ✅ API 연동 | -| **상세** | `sales/client-management-sales-admin/[id]/page.tsx` | ✅ API 연동 | -| **등록폼** | `components/clients/ClientRegistration.tsx` | ✅ 완료 | -| **상세뷰** | `components/clients/ClientDetail.tsx` | ✅ 완료 | - -### 1.3 is_active Boolean 변경 대응 ✅ 완료 -```typescript -// useClientList.ts 수정 내역 -is_active: boolean; // 타입 변경 ("Y"|"N" → boolean) -status: api.is_active ? "활성" : "비활성", // 변환 로직 -is_active: form.isActive, // 전송 시 boolean 그대로 -``` - -### 1.4 기획 미확정 필드 - 개발 보류 ✅ 확정 -**결정일**: 2025-12-09 -**결정사항**: 기획에서 확정되지 않은 필드에 대해서는 **개발 보류** - -**숨김 처리된 섹션** (등록/수정 폼): -| 섹션 | 필드 | 상태 | -|------|------|------| -| 발주처 설정 | 계정ID, 비밀번호, 매입결제일, 매출결제일 | ❌ 개발보류 | -| 약정 세금 | 약정 여부, 금액, 시작/종료일 | ❌ 개발보류 | -| 악성채권 정보 | 악성채권 여부, 금액, 발생/만료일, 진행상태 | ❌ 개발보류 | - -**목록 테이블에서도 제외** (스크린샷 디자인과 다름 확인): -- 매입 결제일 컬럼 ❌ 제외 -- 매출 결제일 컬럼 ❌ 제외 -- 악성채권 컬럼/배지 ❌ 제외 - -> ⚠️ 백엔드 API는 이미 지원됨 (nullable 필드) -> 기획 확정 후 주석 해제하면 바로 사용 가능 -> 프론트엔드 파일: `src/components/clients/ClientRegistration.tsx` - ---- - -## 2. 현재 거래처 등록 폼 구조 - -``` -1. 기본 정보 ✅ 활성 - - 사업자등록번호 (필수) - - 거래처 코드 (자동생성) - - 거래처명 (필수) - - 대표자명 (필수) - - 거래처 유형 (매입/매출/매입매출) - - 업태, 종목 - -2. 연락처 정보 ✅ 활성 - - 주소 - - 전화번호, 모바일, 팩스 - - 이메일 - -3. 담당자 정보 ✅ 활성 - - 담당자명, 담당자 전화 - - 시스템 관리자 - -4. 발주처 설정 ❌ 숨김 (기획 미확정) -5. 약정 세금 ❌ 숨김 (기획 미확정) -6. 악성채권 정보 ❌ 숨김 (기획 미확정) - -7. 기타 정보 ✅ 활성 - - 메모 - - 상태 (활성/비활성) -``` - ---- - -## 3. 다음 작업 후보 - -### 3.1 거래처 관리 관련 -- [ ] 거래처 그룹 기능 구현 (client-groups API 이미 있음) -- [ ] 엑셀 내보내기/가져오기 -- [ ] 발주처/약정세금/악성채권 섹션 활성화 (기획 확정 시) - -### 3.2 다른 기능 -- [ ] 견적 관리 페이지 구현 -- [ ] 단가 관리 페이지 완성 -- [ ] 기타 요청 사항 - ---- - -## 4. 참고 파일 - -### 프론트엔드 (sam-react-prod) -``` -src/ -├── hooks/useClientList.ts # API 훅 + 타입 정의 -├── components/clients/ -│ ├── ClientRegistration.tsx # 등록/수정 폼 -│ └── ClientDetail.tsx # 상세 보기 -└── app/[locale]/(protected)/sales/client-management-sales-admin/ - ├── page.tsx # 목록 - ├── new/page.tsx # 등록 - ├── [id]/page.tsx # 상세 - └── [id]/edit/page.tsx # 수정 -``` - -### 백엔드 (sam-api) -``` -app/ -├── Http/Controllers/Api/V1/ClientController.php -├── Http/Requests/Client/ -│ ├── ClientStoreRequest.php -│ └── ClientUpdateRequest.php -├── Models/Orders/Client.php -├── Services/ClientService.php -└── Swagger/v1/ClientApi.php -``` - ---- - -## 5. 다음 세션에서 말할 내용 - -``` -버디 안녕~! 지난번에 거래처 관리 API 연동 완료했어. -- 백엔드 2차 필드 추가 확인 완료 -- 프론트엔드 API 연동 완료 (목록/등록/수정/상세) -- is_active Boolean 변경 대응 완료 -- 발주처/약정세금/악성채권 섹션은 기획 미확정으로 숨김 처리 - -오늘은 [다음 작업 내용] 진행하자~! -``` \ No newline at end of file diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-09] item-crud-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-09] item-crud-session-context.md deleted file mode 100644 index 0f94b242..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-09] item-crud-session-context.md +++ /dev/null @@ -1,120 +0,0 @@ -# 품목관리 세션 체크포인트 - -> 작성일: 2025-12-09 -> 수정일: 2025-12-09 -> 상태: ✅ Phase 1 완료! - ---- - -## 🎉 2025-12-09 완료 사항 - -### 백엔드 작업 완료 - -| 항목 | 상태 | -|------|------| -| field_key 저장 방식 변경 (`98_unit` → `unit`) | ✅ 완료 | -| 시스템 예약어 검증 (`SystemFields.php`) | ✅ 완료 | -| 중복 검증 로직 | ✅ 완료 | -| 에러 메시지 한국어화 | ✅ 완료 | - -### 프론트엔드 정리 완료 - -| 항목 | 삭제된 코드 | 상태 | -|------|------------|------| -| Edit 모드 매핑 로직 | ~140줄 | ✅ 완료 | -| `fieldAliases` 객체 | 25줄 | ✅ 완료 | -| `extractFieldName()` 함수 | 7줄 | ✅ 완료 | -| `fieldKeyMap` 생성 로직 | 25줄 | ✅ 완료 | -| `fieldKeyToBackendKey` 변환 | 60줄 | ✅ 완료 | -| **총 삭제** | **~200줄** | ✅ | - -### 빌드 검증 - -```bash -npm run build # ✅ 성공 -``` - ---- - -## 📋 새로운 데이터 흐름 - -### field_key 통일 완료 - -``` -등록: { "unit": "EA" } → 그대로 저장 -조회: DB → { "unit": "EA" } → 그대로 표시 -수정: { "unit": "EA" } → 그대로 저장 - -※ 기존 레거시 데이터 (98_unit 형식)도 그대로 동작 -``` - -### 코드 변경 요약 - -**Before (복잡한 매핑)**: -```typescript -// Edit 모드: 155줄 매핑 로직 -const fieldAliases = { 'unit': '단위', ... }; -const extractFieldName = (key) => { ... }; -const fieldKeyMap = { ... }; -// 여러 단계 변환... -``` - -**After (직접 사용)**: -```typescript -// Edit 모드: 15줄 -useEffect(() => { - if (mode !== 'edit' || !structure || !initialData || isEditDataMapped) return; - resetForm(initialData); // 직접 사용! - setIsEditDataMapped(true); -}, [mode, structure, initialData, isEditDataMapped, resetForm]); -``` - ---- - -## ⏳ 남은 작업 - -### 파일 업로드 500 에러 (검수 중) - -``` -위치: /app/Http/Controllers/Api/V1/ItemsFileController.php (Line 7) -문제: use App\Http\Responses\ApiResponse (잘못된 경로) -수정: use App\Helpers\ApiResponse (올바른 경로) -``` - -### Phase 2: 컴포넌트 분리 (선택적) - -계획 문서: `[PLAN-2025-12-08] dynamic-form-separation-plan.md` - -- 공통 컴포넌트 추출 (FileUpload, BOM, AutoItemCode) -- 품목별 컴포넌트 생성 (FG, PT, SM, RM, CS) -- DynamicFormCore 리팩토링 - ---- - -## 📋 테스트 체크리스트 - -### 등록 테스트 -- [ ] FG(제품) 등록 -- [ ] PT-조립부품 등록 -- [ ] PT-절곡부품 등록 -- [ ] SM/RM/CS 등록 - -### 수정 테스트 -- [ ] 수정 페이지 진입 → 데이터 로드 확인 -- [ ] 드롭다운 값 표시 확인 -- [ ] 수정 후 저장 → 값 유지 확인 - -### 파일 업로드 테스트 -- [ ] 절곡부품 전개도 업로드 -- [ ] 조립부품 전개도 업로드 -- [ ] 제품 시방서/인정서 업로드 - ---- - -## 📚 관련 문서 - -| 문서 | 위치 | -|------|------| -| DynamicForm 분리 계획 | `[PLAN-2025-12-08] dynamic-form-separation-plan.md` | -| Radix UI 버그 해결 | `claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | -| 백엔드 field_key 검증 스펙 | `sam-api/docs/specs/item-master-field-key-validation.md` | diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-10] item-crud-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-10] item-crud-session-context.md deleted file mode 100644 index 838823bf..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-10] item-crud-session-context.md +++ /dev/null @@ -1,119 +0,0 @@ -# 품목관리 세션 체크포인트 - -> 작성일: 2025-12-10 -> 이전 세션: 2025-12-09 -> 상태: ✅ 프론트엔드 수정 완료 - ---- - -## 🎯 오늘의 작업 목표 - -### 백엔드 변경 사항 (완료됨) - -**field_key 통일 방식:** -- **기존**: 프론트엔드에서 `unit` → `98_unit` 변환 후 저장 -- **변경**: 백엔드에서 field_key를 그대로 저장/반환 - - 기존 레거시 데이터(`98_unit` 형식)도 그대로 동작 - - 신규 등록 시 `unit`으로 등록하면 `unit`으로 저장 - - 중복 field_key는 백엔드에서 자동 처리 (suffix 추가 또는 사용자 변경) - -**핵심 포인트**: 프론트엔드에서 변환 없이, 백엔드가 주는 값 그대로 사용! - ---- - -## ✅ 완료된 작업 - -### 1. 수정 페이지 `mapApiResponseToFormData` 개선 - -**파일**: `src/app/[locale]/(protected)/items/[id]/edit/page.tsx` - -**변경 내용**: -- 하드코딩된 필드 매핑 제거 (약 90줄 → 50줄) -- 백엔드 응답의 모든 필드를 그대로 formData에 복사 -- 시스템 필드만 제외 (`id`, `tenant_id`, `created_at`, `updated_at`, `deleted_at` 등) - -```typescript -// 변경 후: 백엔드 응답을 그대로 사용 -function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { - const formData: DynamicFormData = {}; - const excludeKeys = ['id', 'tenant_id', 'category_id', 'category', - 'created_at', 'updated_at', 'deleted_at', 'component_lines', 'bom']; - - Object.entries(data).forEach(([key, value]) => { - if (!excludeKeys.includes(key) && value !== null && value !== undefined) { - formData[key] = value; - } - }); - - // attributes, options 처리... - return formData; -} -``` - -### 2. item_type 파라미터 수정 - -**변경 파일**: -- `src/app/[locale]/(protected)/items/[id]/page.tsx` (상세 페이지) -- `src/app/[locale]/(protected)/items/[id]/edit/page.tsx` (수정 페이지) - -**변경 내용**: -- 기존: `item_type=MATERIAL` -- 변경: `item_type=SM` / `item_type=RM` / `item_type=CS` (실제 코드 전달) - -```typescript -// 변경 후 -queryParams.append('item_type', itemType); // SM, RM, CS 그대로 전달 -``` - -### 3. 삭제 API item_type 파라미터 추가 - -**파일**: `src/components/items/ItemListClient.tsx` - -**변경 내용**: -- 단건 삭제: `?item_type=${itemToDelete.itemType}` 추가 -- 일괄 삭제: `?item_type=${item?.itemType}` 추가 - -### 4. 빌드 검증 - -```bash -npm run build # ✅ 성공 -``` - ---- - -## 📋 테스트 체크리스트 - -### 등록 테스트 -- [ ] FG(제품) 등록 → 데이터 표시 확인 -- [ ] PT-조립부품 등록 → 데이터 표시 확인 -- [ ] PT-절곡부품 등록 → 데이터 표시 확인 -- [ ] SM/RM/CS 등록 → 데이터 표시 확인 - -### 수정 테스트 -- [ ] 수정 페이지 진입 → 모든 필드 데이터 로드 확인 -- [ ] 드롭다운 값 정상 표시 확인 -- [ ] 수정 후 저장 → 값 유지 확인 - -### 삭제 테스트 -- [ ] 단건 삭제 (SM/RM/CS) -- [ ] 일괄 삭제 (SM/RM/CS) - ---- - -## 🔄 코드 변경 요약 - -| 파일 | 변경 내용 | -|------|----------| -| `items/[id]/page.tsx` | item_type 파라미터: MATERIAL → 실제 코드 | -| `items/[id]/edit/page.tsx` | mapApiResponseToFormData 간소화, item_type 파라미터 수정 | -| `ItemListClient.tsx` | 삭제 API에 item_type 파라미터 추가 (단건/일괄) | - ---- - -## 📚 관련 문서 - -| 문서 | 위치 | -|------|------| -| 이전 세션 컨텍스트 | `[NEXT-2025-12-09] item-crud-session-context.md` | -| DynamicForm 분리 계획 | `[PLAN-2025-12-08] dynamic-form-separation-plan.md` | -| Radix UI 버그 해결 | `claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | \ No newline at end of file diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-12] item-crud-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-12] item-crud-session-context.md deleted file mode 100644 index 5808ea2a..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-12] item-crud-session-context.md +++ /dev/null @@ -1,205 +0,0 @@ -# 품목관리 세션 체크포인트 - -> 작성일: 2025-12-12 -> 이전 세션: 2025-12-10 -> 상태: ✅ 프론트엔드 작업 완료 (백엔드 API 대기) - ---- - -## 🎯 오늘의 작업 목표 - -### 전개도 상세 입력 (폭 합계 연동) - ✅ 완료 -### BOM 테이블 UI 수정 - ✅ 완료 -### BOM 데이터 전송/로드 - ✅ 완료 -### 파일 업로드 오류 수정 - ✅ 완료 - ---- - -## ✅ 완료된 작업 - -### 1. BOM API 연동 완료 - -**변경 사항**: -- `child_item_type` 필드 추가 (PRODUCT/MATERIAL 구분) -- BOM 저장 형식: `{ child_item_id, child_item_type, quantity }` 최소 필드만 저장 -- 품목 유형 매핑: FG/PT → PRODUCT, SM/RM/CS → MATERIAL - -**수정 파일**: -- `types.ts`: BOMLine에 `childItemType` 필드 추가 -- `DynamicBOMSection.tsx`: `getChildItemType()` 헬퍼 함수 추가 -- `index.tsx`: BOM 전송 형식 간소화 - -### 2. 수정 화면 BOM 로드 버그 수정 - -**문제**: `mapApiResponseToFormData`가 `bom`을 제외하여 `initialData`에 BOM이 없었음 - -**해결**: `initialBomLines` prop으로 BOM 데이터 별도 전달 - -**수정 파일**: -- `types.ts`: `DynamicItemFormProps`에 `initialBomLines?: BOMLine[]` 추가 -- `edit/page.tsx`: API 응답에서 BOM 추출 → `initialBomLines` state → prop 전달 -- `index.tsx`: `initialBomLines` prop 수신 → useEffect로 `setBomLines()` 호출 - -### 3. BOM key 값 중복 에러 수정 - -**문제**: BOM 항목에 id가 없을 때 빈 문자열로 key 중복 발생 - -**해결**: `page.tsx`의 `mapApiResponseToItemMaster`에서 fallback key 생성 -```typescript -id: String(bomItem.id || bomItem.child_item_id || `bom-${index}`) -``` - -### 4. 이미지 업로드 500 에러 수정 - -**문제**: `bending_diagram`에 Base64 이미지 데이터가 JSON 본문에 포함되어 백엔드 500 에러 - -**해결**: API 호출 전 base64 이미지 데이터 제거 (파일은 별도 API로 업로드) - -**수정 파일**: -- `edit/page.tsx`: base64 이미지 필드 제거 로직 추가 -- `create/page.tsx`: 동일하게 적용 - -```typescript -// API 호출 전 이미지 데이터 제거 -if (submitData.bending_diagram?.startsWith('data:')) delete submitData.bending_diagram; -if (submitData.specification_file?.startsWith('data:')) delete submitData.specification_file; -if (submitData.certification_file?.startsWith('data:')) delete submitData.certification_file; -``` - -### 5. bending_details 배열 전송 오류 수정 - -**문제**: `JSON.stringify()`로 문자열 전송 → 백엔드에서 "배열이어야 합니다" 오류 - -**해결**: PHP가 이해하는 배열 형태로 FormData 전송 - -**수정 파일**: `src/lib/api/items.ts` - -```typescript -// 기존 (문자열) -formData.append('bending_details', JSON.stringify(options.bendingDetails)); - -// 수정 (배열 형태) -options.bendingDetails.forEach((detail, index) => { - Object.entries(detail).forEach(([key, value]) => { - formData.append(`bending_details[${index}][${key}]`, String(value)); - }); -}); -``` - -### 6. 제품 정보 섹션 빈 카드 숨김 - -**문제**: FG 품목 상세에서 "제품 정보" 섹션이 내용 없이 빈 카드로 표시 - -**해결**: 내용이 있을 때만 섹션 표시 - -**수정 파일**: `ItemDetailClient.tsx` - -```typescript -// 기존 -{item.itemType === 'FG' && ( - -// 수정 -{item.itemType === 'FG' && (item.productCategory || item.lotAbbreviation || item.note) && ( -``` - ---- - -## ⏳ 백엔드 대기 사항 - -### 1. 파일 다운로드 인증 문제 🔴 - -**현재 문제**: -- 파일 다운로드 URL(`/storage/{id}`)에 직접 접근 시 `"Unauthorized. Invalid or missing API key"` 에러 -- 브라우저에서 `다운로드` 클릭 시 API 키가 없어서 401 에러 -- 시방서(PDF), 인정서(PDF), 전개도(이미지) 모두 동일한 문제 - -**수정 요청 옵션**: -1. **옵션 A (권장)**: Signed URL 방식 - 임시 토큰이 포함된 URL 생성 (만료 시간 설정) -2. **옵션 B**: 파일 다운로드 엔드포인트를 public으로 변경 (인증 불필요) -3. **옵션 C**: 프론트엔드 프록시 경유 (Next.js API route에서 API 키 추가) - -### 2. 품목 조회 시 파일 URL 미반환 문제 🔴 - -**현재 문제**: -- `bending_diagram`, `specification_file`, `certification_file` 필드에 **file_id(숫자)**만 반환됨 -- 프론트엔드에서 이미지/PDF를 표시하려면 **실제 다운로드 URL**이 필요 -- 현재 file_id만 있어서 파일을 불러올 수 없음 - -**수정 요청**: -품목 조회 응답에서 file_id와 함께 실제 URL도 반환: - -```json -{ - "id": 813, - "bending_diagram": 123, - "bending_diagram_url": "/api/v1/files/download/xxx", - "specification_file": 456, - "specification_file_url": "/api/v1/files/download/yyy", - "certification_file": 789, - "certification_file_url": "/api/v1/files/download/zzz" -} -``` - ---- - -## 🔄 코드 변경 요약 - -| 파일 | 변경 내용 | -|------|----------| -| `types.ts` | BOMLine에 `childItemType` 추가, DynamicItemFormProps에 `initialBomLines` 추가 | -| `DynamicBOMSection.tsx` | `getChildItemType()` 헬퍼 함수, 품목 선택 시 childItemType 설정 | -| `index.tsx` | BOM 전송 형식 간소화, initialBomLines prop 처리 | -| `edit/page.tsx` | initialBomLines 전달, base64 이미지 제거 로직 | -| `create/page.tsx` | base64 이미지 제거 로직 | -| `items/[id]/page.tsx` | BOM key fallback 처리 | -| `ItemDetailClient.tsx` | 제품 정보 섹션 조건부 표시 | -| `lib/api/items.ts` | bending_details 배열 형태로 전송 | - ---- - -## 📋 다음 세션 TODO - -### 백엔드 API 완료 후 -- [ ] 파일 다운로드 URL 처리 (백엔드 응답 형식에 맞춰 적용) -- [ ] 전개도 이미지 표시 테스트 -- [ ] 시방서/인정서 PDF 다운로드 테스트 - -### DynamicItemForm 분할 작업 🎯 -- [ ] `index.tsx` 파일 분할 (현재 2000줄+ → 500줄 이하로) -- [ ] 섹션별 컴포넌트 분리: - - `DynamicFormHeader.tsx` - 헤더/제목 - - `DynamicFormActions.tsx` - 저장/취소 버튼 - - `DynamicBendingSection.tsx` - 전개도 섹션 (기존) - - `DynamicFileSection.tsx` - 파일 업로드 섹션 - - `useDynamicForm.ts` - 메인 로직 훅 -- [ ] 상태 관리 정리 (props drilling 최소화) - -### 파일 업로드 필드 동적화 🆕 -> 참고: `[DESIGN-2025-12-12] item-master-form-builder-roadmap.md` - -**현재 문제**: -- 파일 업로드가 `FileUpload.tsx`로 하드코딩되어 있음 -- 품목기준관리에서 파일 필드를 동적으로 추가할 수 없음 - -**목표**: -- 새 필드 타입 `file`, `files`, `image` 추가 -- 품목기준관리에서 파일 업로드 필드 동적 생성 가능 - -**구현 작업**: -- [ ] `field_type`에 `file` | `files` | `image` 타입 추가 (API 스키마) -- [ ] `FileField.tsx` 컴포넌트 생성 (DynamicItemForm/fields/) -- [ ] `ImageField.tsx` 컴포넌트 생성 (미리보기 포함) -- [ ] `DynamicFieldRenderer.tsx`에 file/image 케이스 추가 -- [ ] `properties` 확장: `{ accept, maxSize, maxFiles }` -- [ ] 품목기준관리 UI에 파일 필드 타입 옵션 추가 -- [ ] 기존 하드코딩된 FileUpload 컴포넌트 동적 필드로 마이그레이션 - ---- - -## 📚 관련 문서 - -| 문서 | 위치 | -|------|------| -| 이전 세션 컨텍스트 | `[NEXT-2025-12-10] item-crud-session-context.md` | -| DynamicForm 분리 계획 | `[PLAN-2025-12-08] dynamic-form-separation-plan.md` | -| Radix UI 버그 해결 | `claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | \ No newline at end of file diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-13] item-file-upload-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-13] item-file-upload-session-context.md deleted file mode 100644 index c3dc636d..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-13] item-file-upload-session-context.md +++ /dev/null @@ -1,96 +0,0 @@ -# 품목관리 파일 업로드 세션 컨텍스트 - -## 세션 정보 -- **날짜**: 2025-12-13 -- **커밋**: c026130 - feat: 품목관리 파일 업로드 기능 개선 - -## 완료된 작업 - -### 1. 파일 업로드 API 파라미터 추가 -- `src/lib/api/items.ts`의 `uploadItemFile` 함수에 `fieldKey`, `fileId` 파라미터 추가 -- FormData에 `field_key`, `file_id` 필드 append - -### 2. 타입 정의 추가 -- `src/types/item.ts`에 `ItemFile`, `ItemFiles` 인터페이스 추가 -```typescript -export interface ItemFile { - id: number; - file_name: string; - file_path: string; -} - -export interface ItemFiles { - bending_diagram?: ItemFile[]; - specification?: ItemFile[]; - certification?: ItemFile[]; -} -``` - -### 3. DynamicItemForm 파일 데이터 파싱 -- 새 API 구조 (`files` 객체) 지원 -- 기존 API 구조 폴백 유지 (하위 호환) -- 파일 ID 상태 추가: `existingSpecificationFileId`, `existingCertificationFileId`, `existingBendingDiagramFileId` - -### 4. 시방서/인정서 파일 UI 개선 -- 기존: 파일명 표시 + 별도 파일 선택 input -- 변경: 파일 있으면 `[파일명] [⬇️] [✏️] [🗑️]` 버튼 UI -- 파일 없으면 기존 파일 선택 UI 표시 - -## 진행 중 (백엔드 대기) - -### 파일 업로드 500 에러 -- **증상**: POST `/api/proxy/items/{id}/files` → 500 에러 -- **원인**: 백엔드에서 `field_key`, `file_id` 파라미터 처리 미구현 -- **Next.js 프록시 로그**: -``` -📎 File field: file = 230601_test.pdf (70976 bytes) -📎 Form field: type = certification -📎 Form field: field_key = certification_file -📎 Form field: file_id = 0 -🔵 Response status: 500 -``` -- **상태**: 프론트엔드 준비 완료, 백엔드 수정 대기 중 - -## 다음 세션 TODO - -### 1. DynamicItemForm index.tsx 분리 작업 -- 현재 2000줄+ → 500줄 이하 목표 -- 컴포넌트 분리: - - FormHeader - - ValidationAlert - - DynamicSectionRenderer - - 파일 업로드 섹션 - - BOM 섹션 -- hooks/utils 정리 - -### 2. 파일 업로드 테스트 (백엔드 완료 후) -- 신규 품목 등록 → 파일 업로드 → 수정 페이지 확인 -- 다운로드/수정/삭제 버튼 동작 검증 -- 파일 덮어쓰기 (file_id: 0) 동작 확인 - -## API 구조 참고 - -### 새 API 응답 구조 (조회) -```json -{ - "files": { - "bending_diagram": [{ "id": 1, "file_name": "벤딩도.pdf", "file_path": "/uploads/..." }], - "specification": [{ "id": 2, "file_name": "규격서.pdf", "file_path": "/uploads/..." }], - "certification": [{ "id": 3, "file_name": "인정서.pdf", "file_path": "/uploads/..." }] - } -} -``` - -### 파일 업로드 요청 (FormData) -``` -file: [File] -type: specification | certification | bending_diagram -field_key: specification_file | certification_file | bending_diagram -file_id: 0 (덮어쓰기) | 1, 2, 3... (추가) -``` - -## 주요 파일 위치 -- 타입: `src/types/item.ts` -- API: `src/lib/api/items.ts` -- 폼: `src/components/items/DynamicItemForm/index.tsx` -- 프록시: `src/app/api/proxy/[...path]/route.ts` \ No newline at end of file diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-20] zustand-refactoring-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-20] zustand-refactoring-session-context.md deleted file mode 100644 index a2a38694..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-20] zustand-refactoring-session-context.md +++ /dev/null @@ -1,344 +0,0 @@ -# 품목기준관리 Zustand 리팩토링 - 세션 컨텍스트 - -> 다음 세션에서 이 문서를 먼저 읽고 작업 이어가기 - -## 🎯 프로젝트 목표 - -**핵심 목표:** -1. 품목기준관리 100% 동일 기능 구현 -2. **더 유연한 데이터 관리** (Zustand 정규화 구조) -3. **개선된 UX** (Context 3방향 동기화 → Zustand 1곳 수정) - -**접근 방식:** -- 기존 컴포넌트 재사용 ❌ -- 테스트 페이지에서 완전히 새로 구현 ✅ -- 분리된 상태 유지 → 복구 시나리오 보장 - ---- - -## 세션 요약 (2025-12-22 - 11차 세션) - -### ✅ 오늘 완료된 작업 - -1. **기존 품목기준관리와 상세 기능 비교** - - 구현 완료율: 약 72% - - 핵심 CRUD 기능 모두 구현 확인 - -2. **누락된 핵심 기능 식별** - - 🔴 절대경로(absolute_path) 수정 - PathEditDialog - - 🔴 페이지 복제 - handleDuplicatePage - - 🔴 필드 조건부 표시 - ConditionalDisplayUI - - 🟡 칼럼 관리 - ColumnManageDialog - - 🟡 섹션/필드 사용 현황 표시 - -3. **브랜치 분리 완료** - - `feature/item-master-zustand` 브랜치 생성 - - 29개 파일, 8,248줄 커밋 - - master와 분리 관리 가능 - ---- - -## 세션 요약 (2025-12-21 - 10차 세션) - -### ✅ 오늘 완료된 작업 - -1. **기존 품목기준관리와 기능 비교 분석** - - 기존 페이지의 모든 핵심 기능 구현 확인 - - 커스텀 탭 관리는 기존 페이지에서도 비활성화(주석 처리)됨 - - 탭 관리 기능은 로컬 상태만 사용 (백엔드 미연동, 새로고침 시 초기화) - -2. **Phase D-2 (커스텀 탭 관리) 분석 결과** - - 기존 페이지의 "탭 관리" 버튼: 주석 처리됨 (미사용) - - 속성 하위 탭 관리: 로컬 상태로만 동작 (영속성 없음) - - **결론**: 선택적 기능으로 분류, 핵심 기능 구현 완료 - ---- - -## 세션 요약 (2025-12-21 - 9차 세션) - -### ✅ 완료된 작업 - -1. **속성 CRUD API 연동 완료** - - `types.ts`: PropertyActions 인터페이스 추가 - - `useItemMasterStore.ts`: addUnit, updateUnit, deleteUnit, addMaterial, updateMaterial, deleteMaterial, addTreatment, updateTreatment, deleteTreatment 구현 - - `item-master-api.ts`: UnitOptionRequest/Response 타입 수정 (unit_code, unit_name 사용) - -2. **Import 기능 구현 완료** - - `ImportSectionDialog.tsx`: 독립 섹션 목록에서 선택하여 페이지에 연결 - - `ImportFieldDialog.tsx`: 독립 필드 목록에서 선택하여 섹션에 연결 - - `dialogs/index.ts`: Import 다이얼로그 export 추가 - - `HierarchyTab.tsx`: 불러오기 버튼에 Import 다이얼로그 연결 - -3. **섹션 복제 API 연동 완료** - - `SectionsTab.tsx`: handleCloneSection 함수 구현 (API 연동 + toast 알림) - -4. **타입 수정** - - `transformers.ts`: transformUnitOptionResponse 수정 (unit_name, unit_code 사용) - - `useFormStructure.ts`: 단위 옵션 매핑 수정 (unit_name, unit_code 사용) - ---- - -### ✅ 완료된 Phase - -| Phase | 내용 | 상태 | -|-------|------|------| -| Phase 1 | Zustand 스토어 기본 구조 | ✅ | -| Phase 2 | API 연동 (initFromApi) | ✅ | -| Phase 3 | API CRUD 연동 (update 함수들) | ✅ | -| Phase A-1 | 계층구조 기본 표시 | ✅ | -| Phase A-2 | 드래그앤드롭 순서 변경 | ✅ | -| Phase A-3 | 인라인 편집 (페이지/섹션/경로) | ✅ | -| Phase B-1 | 페이지 CRUD 다이얼로그 | ✅ | -| Phase B-2 | 섹션 CRUD 다이얼로그 | ✅ | -| Phase B-3 | 필드 CRUD 다이얼로그 | ✅ | -| Phase B-4 | BOM 관리 UI | ✅ | -| Phase C-1 | 섹션 탭 구현 (SectionsTab.tsx) | ✅ | -| Phase C-2 | 항목 탭 구현 (FieldsTab.tsx) | ✅ | -| Phase D-1 | 속성 탭 기본 구조 (PropertiesTab.tsx) | ✅ | -| Phase E | Import 기능 (섹션/필드 불러오기) | ✅ | - -### ✅ 현재 상태: 핵심 기능 구현 완료 - -**Phase D-2 (커스텀 탭 관리)**: 선택적 기능으로 분류됨 -- 기존 페이지에서도 "탭 관리" 버튼은 주석 처리 (미사용) -- 속성 하위 탭 관리도 로컬 상태로만 동작 (백엔드 미연동) -- 필요 시 추후 구현 가능 - ---- - -## 📋 기능 비교 결과 - -### ✅ 구현 완료된 핵심 기능 - -| 기능 | 테스트 페이지 | 기존 페이지 | -|------|-------------|------------| -| 계층구조 관리 | ✅ | ✅ | -| 페이지 CRUD | ✅ | ✅ | -| 섹션 CRUD | ✅ | ✅ | -| 필드 CRUD | ✅ | ✅ | -| BOM 관리 | ✅ | ✅ | -| 드래그앤드롭 순서 변경 | ✅ | ✅ | -| 인라인 편집 | ✅ | ✅ | -| Import (섹션/필드) | ✅ | ✅ | -| 섹션 복제 | ✅ | ✅ | -| 단위/재질/표면처리 CRUD | ✅ | ✅ | -| 검색/필터 | ✅ | ✅ | - -### ⚠️ 선택적 기능 (기존 페이지에서도 제한적 사용) - -| 기능 | 상태 | 비고 | -|------|------|------| -| 커스텀 메인 탭 관리 | 미구현 | 기존 페이지에서 주석 처리됨 | -| 속성 하위 탭 관리 | 미구현 | 로컬 상태만 (영속성 없음) | -| 칼럼 관리 | 미구현 | 로컬 상태만 (영속성 없음) | - ---- - -## 📋 전체 기능 체크리스트 - -### Phase A: 기본 UI 구조 (계층구조 탭 완성) ✅ - -#### A-1. 계층구조 기본 표시 ✅ 완료 -- [x] 페이지 목록 표시 (좌측 패널) -- [x] 페이지 선택 시 섹션 목록 표시 (우측 패널) -- [x] 섹션 내부 필드 목록 표시 -- [x] 필드 타입별 뱃지 표시 -- [x] BOM 타입 섹션 구분 표시 - -#### A-2. 드래그앤드롭 순서 변경 ✅ 완료 -- [x] 섹션 드래그앤드롭 순서 변경 -- [x] 필드 드래그앤드롭 순서 변경 -- [x] 스토어 reorderSections 함수 구현 -- [x] 스토어 reorderFields 함수 구현 -- [x] DraggableSection 컴포넌트 생성 -- [x] DraggableField 컴포넌트 생성 - -#### A-3. 인라인 편집 ✅ 완료 -- [x] InlineEdit 재사용 컴포넌트 생성 -- [x] 페이지 이름 더블클릭 인라인 수정 -- [x] 섹션 제목 더블클릭 인라인 수정 -- [x] 절대경로 인라인 수정 - ---- - -### Phase B: CRUD 다이얼로그 ✅ - -#### B-1. 페이지 관리 ✅ 완료 -- [x] PageDialog 컴포넌트 (페이지 추가/수정) -- [x] DeleteConfirmDialog (재사용 가능한 삭제 확인) -- [x] 페이지 추가 버튼 연결 -- [x] 페이지 삭제 버튼 연결 - -#### B-2. 섹션 관리 ✅ 완료 -- [x] SectionDialog 컴포넌트 (섹션 추가/수정) -- [x] 섹션 삭제 다이얼로그 -- [x] 섹션 연결해제 다이얼로그 -- [x] 섹션 추가 버튼 연결 -- [x] ImportSectionDialog (섹션 불러오기) ✅ - -#### B-3. 필드 관리 ✅ 완료 -- [x] FieldDialog 컴포넌트 (필드 추가/수정) -- [x] 드롭다운 옵션 동적 관리 -- [x] 필드 삭제 다이얼로그 -- [x] 필드 연결해제 다이얼로그 -- [x] 필드 추가 버튼 연결 -- [x] ImportFieldDialog (필드 불러오기) ✅ - -#### B-4. BOM 관리 ✅ 완료 -- [x] BOMDialog 컴포넌트 (BOM 추가/수정) -- [x] BOM 항목 삭제 다이얼로그 -- [x] BOM 추가 버튼 연결 -- [x] BOM 수정 버튼 연결 - ---- - -### Phase C: 섹션 탭 + 항목 탭 ✅ - -#### C-1. 섹션 탭 ✅ 완료 -- [x] 모든 섹션 목록 표시 (연결된 + 독립) -- [x] 섹션 상세 정보 표시 -- [x] 섹션 내부 필드 표시 (확장/축소) -- [x] 일반 섹션 / BOM 섹션 탭 분리 -- [x] 페이지 연결 상태 표시 -- [x] 섹션 추가/수정/삭제 다이얼로그 연동 -- [x] 섹션 복제 기능 (API 연동 완료) ✅ - -#### C-2. 항목 탭 (마스터 필드) ✅ 완료 -- [x] 모든 필드 목록 표시 -- [x] 필드 상세 정보 표시 -- [x] 검색 기능 (필드명, 필드키, 타입) -- [x] 필터 기능 (전체/독립/연결된 필드) -- [x] 필드 추가/수정/삭제 다이얼로그 연동 -- [x] 독립 필드 → 섹션 연결 기능 - ---- - -### Phase D: 속성 탭 (진행 중) - -#### D-1. 속성 관리 ✅ 완료 -- [x] PropertiesTab.tsx 기본 구조 -- [x] 단위 관리 (CRUD) - API 연동 완료 -- [x] 재질 관리 (CRUD) - API 연동 완료 -- [x] 표면처리 관리 (CRUD) - API 연동 완료 -- [x] PropertyDialog (속성 옵션 추가) - -#### D-2. 탭 관리 (예정) -- [ ] 커스텀 탭 추가/수정/삭제 -- [ ] 속성 하위 탭 추가/수정/삭제 -- [ ] 탭 순서 변경 - ---- - -### Phase E: Import 기능 ✅ - -- [x] ImportSectionDialog (섹션 불러오기) -- [x] ImportFieldDialog (필드 불러오기) -- [x] HierarchyTab 불러오기 버튼 연결 - ---- - -## 📁 파일 구조 - -``` -src/stores/item-master/ -├── types.ts # 정규화된 엔티티 타입 + PropertyActions -├── useItemMasterStore.ts # Zustand 스토어 -├── normalizers.ts # API 응답 정규화 - -src/app/[locale]/(protected)/items-management-test/ -├── page.tsx # 테스트 페이지 메인 -├── components/ # 테스트 페이지 전용 컴포넌트 -│ ├── HierarchyTab.tsx # 계층구조 탭 ✅ -│ ├── DraggableSection.tsx # 드래그 섹션 ✅ -│ ├── DraggableField.tsx # 드래그 필드 ✅ -│ ├── InlineEdit.tsx # 인라인 편집 컴포넌트 ✅ -│ ├── SectionsTab.tsx # 섹션 탭 ✅ (복제 기능 추가) -│ ├── FieldsTab.tsx # 항목 탭 ✅ -│ ├── PropertiesTab.tsx # 속성 탭 ✅ -│ └── dialogs/ # 다이얼로그 컴포넌트 ✅ -│ ├── index.ts # 인덱스 ✅ -│ ├── DeleteConfirmDialog.tsx # 삭제 확인 ✅ -│ ├── PageDialog.tsx # 페이지 다이얼로그 ✅ -│ ├── SectionDialog.tsx # 섹션 다이얼로그 ✅ -│ ├── FieldDialog.tsx # 필드 다이얼로그 ✅ -│ ├── BOMDialog.tsx # BOM 다이얼로그 ✅ -│ ├── PropertyDialog.tsx # 속성 다이얼로그 ✅ -│ ├── ImportSectionDialog.tsx # 섹션 불러오기 ✅ -│ └── ImportFieldDialog.tsx # 필드 불러오기 ✅ -``` - ---- - -## 핵심 파일 위치 - -| 파일 | 용도 | -|-----|------| -| `claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md` | 📋 설계 문서 | -| `src/stores/item-master/useItemMasterStore.ts` | 🏪 Zustand 스토어 | -| `src/stores/item-master/types.ts` | 📝 타입 정의 | -| `src/stores/item-master/normalizers.ts` | 🔄 API 응답 정규화 | -| `src/app/[locale]/(protected)/items-management-test/page.tsx` | 🧪 테스트 페이지 | -| `src/components/items/ItemMasterDataManagement.tsx` | 📚 기존 페이지 (참조용) | - ---- - -## 테스트 페이지 접속 - -``` -http://localhost:3000/ko/items-management-test -``` - ---- - -## 브랜치 정보 - -| 항목 | 값 | -|------|-----| -| 작업 브랜치 | `feature/item-master-zustand` | -| 기본 브랜치 | `master` (테스트 페이지 없음) | - -### 브랜치 작업 명령어 - -```bash -# 테스트 페이지 작업 시 -git checkout feature/item-master-zustand - -# master 최신 내용 반영 -git merge master - -# 테스트 완료 후 master에 합치기 -git checkout master -git merge feature/item-master-zustand -``` - ---- - -## 다음 세션 시작 명령 - -``` -누락된 기능 구현해줘 - 절대경로 수정부터 -``` - -또는 - -``` -테스트 페이지 실사용 테스트하고 버그 수정해줘 -``` - ---- - -## 남은 작업 - -### 🔴 누락된 핵심 기능 (100% 구현 위해 필요) -1. **절대경로(absolute_path) 수정** - PathEditDialog -2. **페이지 복제** - handleDuplicatePage -3. **필드 조건부 표시** - ConditionalDisplayUI - -### 🟡 추가 기능 -4. **칼럼 관리** - ColumnManageDialog -5. **섹션/필드 사용 현황 표시** - -### 🟢 마이그레이션 -6. **실사용 테스트**: 테스트 페이지에서 실제 데이터로 CRUD 테스트 -7. **버그 수정**: 발견되는 버그 즉시 수정 -8. **마이그레이션**: 테스트 완료 후 기존 페이지 대체 \ No newline at end of file diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-22] production-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-22] production-session-context.md deleted file mode 100644 index 1ebfd298..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-22] production-session-context.md +++ /dev/null @@ -1,97 +0,0 @@ -# [NEXT-2025-12-22] 생산 현황판 세션 컨텍스트 - -## 세션 요약 (2025-12-22) - -### 완료된 작업 ✅ -- [x] Phase 1: 생산 현황판 메인 페이지 구현 -- [x] Phase 2: 작업자 화면 구현 (별도 페이지) -- [x] Phase 3: 전량완료 기능 (확인/완료 팝업, 뱃지) -- [x] Phase 4: 공정상세 섹션 구현 (카드 내 토글) -- [x] Phase 5: 자재투입 모달 구현 -- [x] Phase 6: 작업일지 모달 구현 (⚠️ 개선 필요) -- [x] Phase 7: 이슈보고 모달 구현 -- [x] Phase 8: 네비게이션 연결 (TODO 주석 처리) - -### 다음 세션 TODO ⚠️ - -#### 1. 작업일지 모달 개선 (우선) -**현재**: 단순 테이블 형태로 구현됨 -**요청**: 기안함 상세 화면 스타일 (완성된 문서 형태)로 개선 - -**참고 컴포넌트**: -``` -src/components/approval/DocumentDetail/ -├── ProposalDocument.tsx ← 기품의서 양식 -├── ExpenseReportDocument.tsx ← 지출보고서 양식 -└── ExpenseEstimateDocument.tsx ← 지출품의서 양식 -``` - -**수정 대상**: -``` -src/components/production/WorkerScreen/WorkLogModal.tsx -``` - -**작업 내용**: -- DocumentDetail 컴포넌트 스타일 참고 -- 완성된 문서 형태로 작업일지 양식 재구현 -- 인쇄 친화적 레이아웃 적용 - -#### 2. 작업지시 관리 페이지 (대기) -- 생산 현황판에서 네비게이션 연결 대기 -- 스크린샷/설명 별도 제공 예정 - ---- - -### 생성된 파일 목록 - -``` -src/app/[locale]/(protected)/production/ -├── dashboard/page.tsx ✅ -└── worker-screen/page.tsx ✅ - -src/components/production/ -├── ProductionDashboard/ -│ ├── index.tsx ✅ -│ ├── types.ts ✅ -│ └── mockData.ts ✅ -│ -└── WorkerScreen/ - ├── index.tsx ✅ - ├── types.ts ✅ - ├── WorkCard.tsx ✅ - ├── ProcessDetailSection.tsx ✅ - ├── MaterialInputModal.tsx ✅ - ├── WorkLogModal.tsx ⚠️ 개선 필요 - ├── IssueReportModal.tsx ✅ - ├── CompletionConfirmDialog.tsx ✅ - └── CompletionToast.tsx ✅ - -src/components/ui/ -└── collapsible.tsx ✅ (신규 추가, @radix-ui/react-collapsible 설치됨) -``` - ---- - -### 테스트 URL -- 생산 현황판: http://localhost:3000/ko/production/dashboard -- 작업자 화면: http://localhost:3000/ko/production/worker-screen - ---- - -### 참고 사항 -1. **작업자 화면 = 별도 페이지** (생산 현황판 하위 아님) - - 사이드바 메뉴로 접근 - - "돌아가기" 버튼 불필요 - -2. **모든 alert() → AlertDialog 변환 완료** - - 전량완료 확인/성공 - - 이슈보고 벨리데이션/성공 - -3. **공정상세 = 카드 내 토글 확장** - - Collapsible 컴포넌트 사용 - - 5단계 공정 표시 - ---- - -**작성일**: 2025-12-22 -**상태**: 🔄 작업일지 모달 개선 대기 \ No newline at end of file diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-24] item-master-refactoring-session.md b/claudedocs/archive/sessions/[NEXT-2025-12-24] item-master-refactoring-session.md deleted file mode 100644 index 32d16fe9..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-24] item-master-refactoring-session.md +++ /dev/null @@ -1,134 +0,0 @@ -# 품목기준관리 리팩토링 세션 컨텍스트 - -> **브랜치**: `feature/item-master-zustand` -> **날짜**: 2025-12-24 -> **상태**: Phase 2 완료, 커밋 대기 - ---- - -## 세션 요약 (12차 세션) - -### 완료된 작업 -- [x] 브랜치 상태 확인 (`feature/item-master-zustand`) -- [x] 기존 작업 혼동 정리 (품목관리 CRUD vs 품목기준관리 설정) -- [x] 작업 대상 파일 확인 (`ItemMasterDataManagement.tsx` - 1,799줄) -- [x] 기존 훅 분리 상태 파악 (7개 훅 이미 존재) -- [x] `ItemMasterDataManagement.tsx` 상세 분석 완료 -- [x] 훅 분리 계획서 작성 (`[PLAN-2025-12-24] hook-extraction-plan.md`) -- [x] **Phase 1: 신규 훅 4개 생성** - - `useInitialDataLoading.ts` - 초기 데이터 로딩 (~130줄) - - `useImportManagement.ts` - 섹션/필드 Import (~100줄) - - `useReorderManagement.ts` - 드래그앤드롭 순서 변경 (~80줄) - - `useDeleteManagement.ts` - 삭제/언링크 핸들러 (~100줄) -- [x] **Phase 2: UI 컴포넌트 2개 생성** - - `AttributeTabContent.tsx` - 속성 탭 콘텐츠 (~340줄) - - `ItemMasterDialogs.tsx` - 다이얼로그 통합 (~540줄) -- [x] 빌드 테스트 통과 - -### 현재 상태 -- **메인 컴포넌트**: 1,799줄 → ~1,478줄 (약 320줄 감소) -- **신규 훅**: 4개 생성 및 통합 -- **신규 UI 컴포넌트**: 2개 생성 (향후 추가 통합 가능) -- **빌드**: 통과 - -### 다음 TODO (커밋 후) -1. Git 커밋 (Phase 1, 2 변경사항) -2. Phase 3: 추가 코드 정리 (선택적) - - 속성 탭 내용을 `AttributeTabContent`로 완전 대체 (추가 ~500줄 감소 가능) - - 다이얼로그들을 `ItemMasterDialogs`로 완전 대체 -3. Zustand 도입 (3방향 동기화 문제 해결) - ---- - -## 핵심 정보 - -### 페이지 구분 (중요!) - -| 페이지 | URL | 컴포넌트 | 상태 | -|--------|-----|----------|------| -| 품목관리 CRUD | `/items/` | `DynamicItemForm` | ✅ 훅 분리 완료 (master 적용됨) | -| **품목기준관리 설정** | `/master-data/item-master-data-management` | `ItemMasterDataManagement` | ⏳ **훅 분리 진행 중** | - -### 현재 파일 구조 - -``` -src/components/items/ItemMasterDataManagement/ -├── ItemMasterDataManagement.tsx ← ~1,478줄 (리팩토링 후) -├── hooks/ (11개 - 7개 기존 + 4개 신규) -│ ├── usePageManagement.ts -│ ├── useSectionManagement.ts -│ ├── useFieldManagement.ts -│ ├── useMasterFieldManagement.ts -│ ├── useTemplateManagement.ts -│ ├── useAttributeManagement.ts -│ ├── useTabManagement.ts -│ ├── useInitialDataLoading.ts ← NEW -│ ├── useImportManagement.ts ← NEW -│ ├── useReorderManagement.ts ← NEW -│ └── useDeleteManagement.ts ← NEW -├── components/ (5개 - 3개 기존 + 2개 신규) -│ ├── DraggableSection.tsx -│ ├── DraggableField.tsx -│ ├── ConditionalDisplayUI.tsx -│ ├── AttributeTabContent.tsx ← NEW -│ └── ItemMasterDialogs.tsx ← NEW -├── services/ (6개) -├── dialogs/ (13개) -├── tabs/ (4개) -└── utils/ (1개) -``` - -### 브랜치 상태 - -``` -master (원본 보존) - │ - └── feature/item-master-zustand (현재) - ├── Zustand 테스트 페이지 (/items-management-test/) - 놔둠 - ├── Zustand 스토어 (stores/item-master/) - 나중에 사용 - └── 기존 품목기준관리 페이지 - 훅 분리 진행 중 -``` - -### 작업 진행률 - -``` -시작: ItemMasterDataManagement.tsx 1,799줄 - ↓ Phase 1: 훅 분리 (4개 신규 훅) -현재: ~1,478줄 (-321줄, -18%) - ↓ Phase 2: UI 컴포넌트 분리 (2개 신규 컴포넌트 생성) - ↓ Phase 3: 추가 통합 (선택적) -목표: ~500줄 (메인 컴포넌트) - ↓ Zustand 적용 -최종: 3방향 동기화 문제 해결 -``` - ---- - -## 생성된 파일 목록 - -### 신규 훅 (Phase 1) -1. `hooks/useInitialDataLoading.ts` - 초기 데이터 로딩, 에러 처리 -2. `hooks/useImportManagement.ts` - 섹션/필드 Import 다이얼로그 상태 및 핸들러 -3. `hooks/useReorderManagement.ts` - 드래그앤드롭 순서 변경 -4. `hooks/useDeleteManagement.ts` - 삭제, 언링크, 초기화 핸들러 - -### 신규 UI 컴포넌트 (Phase 2) -1. `components/AttributeTabContent.tsx` - 속성 탭 전체 UI -2. `components/ItemMasterDialogs.tsx` - 모든 다이얼로그 통합 렌더링 - ---- - -## 참고 문서 - -- `[PLAN-2025-12-24] hook-extraction-plan.md` - 훅 분리 계획서 (상세) -- `[DESIGN-2025-12-20] item-master-zustand-refactoring.md` - Zustand 설계서 -- `[IMPL-2025-12-24] item-master-test-and-zustand.md` - 테스트 체크리스트 - ---- - -## 다음 세션 시작 명령 - -``` -품목기준관리 설정 페이지(ItemMasterDataManagement.tsx) 추가 리팩토링 또는 Zustand 도입 진행해줘. -[NEXT-2025-12-24] item-master-refactoring-session.md 문서 확인하고 시작해. -``` diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-30] fetch-wrapper-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-30] fetch-wrapper-session-context.md deleted file mode 100644 index 14fc37a8..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-30] fetch-wrapper-session-context.md +++ /dev/null @@ -1,78 +0,0 @@ -ㅏ# 세션 요약 (2025-12-30) - -## 완료된 작업 - -### 1. fetch-wrapper 목적 확인 -- **목적**: 401 에러(세션 만료) 발생 시 로그인 리다이렉트를 **중앙화** -- **장점**: 중복 코드 제거 + 새 작업자도 규칙 준수 가능 - -### 2. Accounting 도메인 완료 (12/12) ✅ -- [x] `SalesManagement/actions.ts` -- [x] `VendorManagement/actions.ts` -- [x] `PurchaseManagement/actions.ts` -- [x] `DepositManagement/actions.ts` -- [x] `WithdrawalManagement/actions.ts` -- [x] `VendorLedger/actions.ts` -- [x] `ReceivablesStatus/actions.ts` -- [x] `ExpectedExpenseManagement/actions.ts` -- [x] `CardTransactionInquiry/actions.ts` -- [x] `DailyReport/actions.ts` -- [x] `BadDebtCollection/actions.ts` -- [x] `BankTransactionInquiry/actions.ts` - -### 3. HR 도메인 진행중 (1/6) -- [x] `EmployeeManagement/actions.ts` (이미 마이그레이션되어 있었음) -- [~] `VacationManagement/actions.ts` (import만 변경됨, 함수 마이그레이션 필요) - -## 다음 세션 TODO - -### HR 도메인 나머지 (5개) -- [ ] `VacationManagement/actions.ts` - 함수 마이그레이션 완료 필요 -- [ ] `SalaryManagement/actions.ts` -- [ ] `CardManagement/actions.ts` -- [ ] `DepartmentManagement/actions.ts` -- [ ] `AttendanceManagement/actions.ts` - -### 기타 도메인 (Approval, Production, Settings, 기타) -- Approval: 4개 -- Production: 4개 -- Settings: 11개 -- 기타: 12개 -- 상세 목록은 체크리스트 문서 참고 - -### 빌드 검증 -- [ ] `npm run build` 실행하여 마이그레이션 검증 - -## 참고 사항 - -### 마이그레이션 패턴 (참고용) -```typescript -// Before -import { cookies } from 'next/headers'; -async function getApiHeaders() { ... } -const response = await fetch(url, { headers }); - -// After -import { serverFetch } from '@/lib/api/fetch-wrapper'; -const { response, error } = await serverFetch(url, { method: 'GET' }); -if (error) return { success: false, error: error.message }; -``` - -### 주요 변경 포인트 -1. `getApiHeaders()` 함수 제거 -2. `import { cookies } from 'next/headers'` 제거 -3. `fetch()` → `serverFetch()` 변경 -4. `{ response, error }` 구조분해 사용 -5. 파일 다운로드(Excel/PDF)는 `cookies` import 유지 (custom Accept 헤더 필요) - -### 특이사항 -- `EmployeeManagement/actions.ts`는 이미 `serverFetch` 사용 중이었음 -- `uploadProfileImage` 함수는 FormData 업로드라 `cookies` import 유지 - -## 체크리스트 문서 -`claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md` - -## 진행률 -- 전체: 49개 파일 -- 완료: 13개 (27%) -- 남음: 36개 diff --git a/claudedocs/archive/sessions/[NEXT-2025-12-30] partner-management-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-30] partner-management-session-context.md deleted file mode 100644 index 896d69cf..00000000 --- a/claudedocs/archive/sessions/[NEXT-2025-12-30] partner-management-session-context.md +++ /dev/null @@ -1,101 +0,0 @@ -# 주일 거래처 관리 세션 컨텍스트 - -Last Updated: 2025-12-30 - -## 세션 요약 (2025-12-30) - -### 완료된 작업 -- [x] 거래처 리스트 필터 위치 수정 (테이블 위로 이동) -- [x] 거래처 폼 컴포넌트 생성 (PartnerForm.tsx) -- [x] 등록 페이지 생성 (/new/page.tsx) -- [x] 상세 페이지 생성 (/[id]/page.tsx) -- [x] 수정 페이지 생성 (/[id]/edit/page.tsx) -- [x] types.ts 확장 (전체 필드 추가) -- [x] actions.ts CRUD 함수 추가 - -### 다음 세션 TODO -- [ ] **회사 정보 + 신용/거래 정보 섹션 합치기** (스크린샷 기준으로 하나의 섹션) -- [ ] 실제 API 연동 - -### 참고 사항 -- 스크린샷에서 "회사 정보"와 "신용/거래 정보"가 하나의 Card 섹션으로 되어 있음 -- 현재 코드는 별도 섹션으로 분리됨 → 합쳐야 함 - ---- - -## 완료된 작업 (전체) - -### 1. 프로젝트 구조 설정 -- [x] `claudedocs/juil/` 문서 폴더 생성 -- [x] `[REF] juil-project-structure.md` 프로젝트 구조 가이드 작성 -- [x] `_index.md` 문서 맵에 juil 섹션 추가 - -### 2. 거래처 관리 리스트 페이지 -- [x] 페이지: `src/app/[locale]/(protected)/juil/project/bidding/partners/page.tsx` -- [x] 컴포넌트: `src/components/business/juil/partners/PartnerListClient.tsx` -- [x] 타입: `src/components/business/juil/partners/types.ts` -- [x] 액션: `src/components/business/juil/partners/actions.ts` (목업 데이터) -- [x] 인덱스: `src/components/business/juil/partners/index.ts` -- [x] 레이아웃 수정: 필터를 테이블 위로 이동, 등록 버튼 상단 배치 - -### 3. 거래처 등록/상세/수정 페이지 -- [x] 폼 컴포넌트: `src/components/business/juil/partners/PartnerForm.tsx` -- [x] 등록 페이지: `src/app/[locale]/(protected)/juil/project/bidding/partners/new/page.tsx` -- [x] 상세 페이지: `src/app/[locale]/(protected)/juil/project/bidding/partners/[id]/page.tsx` -- [x] 수정 페이지: `src/app/[locale]/(protected)/juil/project/bidding/partners/[id]/edit/page.tsx` - -### 4. 구현된 기능 - -#### 리스트 페이지 -- 통계 카드 (전체 거래처 / 미등록) -- 검색 (거래처명, 번호, 대표자, 담당자) -- 탭 필터 (전체 / 신규) -- 테이블 위 필터: `총 N건 | 전체 ▾ | 최신순 ▾` -- 테이블 컬럼: 체크박스, 번호, 거래처번호, 구분, 거래처명, 대표자, 담당자, 전화번호, 매출 결제일, 악성채권, 작업 -- 행 선택 시 수정/삭제 버튼 표시 -- 일괄 삭제 다이얼로그 -- 페이지네이션 -- 모바일 카드 뷰 - -#### 폼 페이지 (등록/상세/수정 공통) -- **기본 정보**: 사업자등록번호, 거래처코드, 거래처명, 대표자명, 거래처유형, 업태, 업종 -- **연락처 정보**: 주소 (우편번호 찾기 DAUM), 전화번호, 모바일, 팩스, 이메일 -- **담당자 정보**: 담당자명, 담당자 전화, 시스템 관리자 -- **회사 정보**: 회사 로고 (BLOB 업로드), 매출 결제일, 신용등급, 거래등급, 세금계산서 이메일 -- **추가 정보**: 미수금, 연체 (토글), 악성채권 (토글) -- **메모**: 추가/삭제 기능 -- **필요 서류**: 파일 업로드 (드래그 앤 드롭) - -#### 모드별 버튼 분기 -- **등록**: 취소 | 저장 -- **수정**: 삭제 | 수정 -- **상세**: 목록가기 | 수정 - -## 테스트 URL - -| 페이지 | URL | 상태 | -|--------|-----|------| -| 거래처 관리 (리스트) | `/ko/juil/project/bidding/partners` | ✅ 완료 | -| 거래처 등록 | `/ko/juil/project/bidding/partners/new` | ✅ 완료 | -| 거래처 상세 | `/ko/juil/project/bidding/partners/1` | ✅ 완료 | -| 거래처 수정 | `/ko/juil/project/bidding/partners/1/edit` | ✅ 완료 | - -## 디렉토리 구조 - -``` -src/ -├── app/[locale]/(protected)/juil/ -│ └── project/bidding/partners/ -│ ├── page.tsx ✅ -│ ├── new/page.tsx ✅ -│ └── [id]/ -│ ├── page.tsx ✅ -│ └── edit/page.tsx ✅ -│ -└── components/business/juil/partners/ - ├── index.ts ✅ - ├── types.ts ✅ - ├── actions.ts ✅ (목업) - ├── PartnerListClient.tsx ✅ - └── PartnerForm.tsx ✅ (섹션 수정 필요) -``` \ No newline at end of file diff --git a/claudedocs/auth/[CASE-2025-11-25] httponly-cookie-security-validation.md b/claudedocs/auth/[CASE-2025-11-25] httponly-cookie-security-validation.md deleted file mode 100644 index 1770b2ee..00000000 --- a/claudedocs/auth/[CASE-2025-11-25] httponly-cookie-security-validation.md +++ /dev/null @@ -1,370 +0,0 @@ -# [CASE STUDY] HttpOnly 쿠키 보안 검증 사례 - -**날짜**: 2025-11-25 -**카테고리**: 보안 검증, 인증 아키텍처, HttpOnly 쿠키 -**결과**: ✅ 보안 설계가 완벽하게 작동함을 검증 - ---- - -## 📋 요약 - -HttpOnly 쿠키를 사용한 인증 시스템에서 **"토큰값이 null로 전달된다"** 는 문제가 발생했으나, 실제로는 **보안이 철저하게 작동하고 있었음**을 확인한 사례. - -**핵심 교훈**: -> **JavaScript로 HttpOnly 쿠키를 절대 읽을 수 없다 = 보안이 제대로 작동하고 있다는 증거!** - ---- - -## 🔴 문제 상황 - -### 증상 -``` -❌ GET https://api.codebridge-x.com/api/v1/item-master/init 401 (Unauthorized) -❌ 백엔드 로그: Authorization 헤더 값이 null -❌ 로그인은 성공했는데 이후 API 호출 시 인증 실패 -``` - -### 초기 의심 지점 -1. API URL 경로 문제? → ❌ 경로는 정상 -2. 헤더 전송 문제? → ❌ 헤더는 전송되고 있음 -3. 쿠키 저장 문제? → ❌ 쿠키는 저장되어 있음 -4. **토큰 추출 문제?** → ✅ **여기가 진짜 원인!** - ---- - -## 🔍 발견 과정 - -### 1단계: 혼란 -```typescript -// auth-headers.ts에서 토큰 추출 시도 -const token = document.cookie - .split('; ') - .find(row => row.startsWith('access_token=')) - ?.split('=')[1]; - -console.log(token); // undefined ← 왜??? -``` - -**의문점**: -- 분명 로그인 성공했는데? -- Application 탭에서 쿠키 보이는데? -- Swagger에서는 같은 토큰으로 잘 되는데? - -### 2단계: 결정적 질문 -> **"어 근데 로그아웃 할 때는 토큰 잘 던지는데 어떤차이야???"** - -### 3단계: 깨달음 -로그아웃 API 코드를 확인해보니... - -```typescript -// /api/auth/logout/route.ts (Next.js API Route - 서버사이드!) -export async function POST(request: NextRequest) { - // ✅ 서버에서는 HttpOnly 쿠키를 읽을 수 있다! - const accessToken = request.cookies.get('access_token')?.value; - - // 토큰이 정상적으로 추출됨! - console.log(accessToken); // "eyJ0eXAiOiJKV1QiLCJh..." -} -``` - -**발견**: 로그아웃은 **Next.js API Route (서버사이드)** 에서 처리하고 있었다! - ---- - -## 💡 근본 원인 - -### HttpOnly 쿠키의 작동 원리 - -``` -┌─────────────────────────────────────────────────────────┐ -│ HttpOnly 쿠키 = JavaScript 접근 차단 (XSS 방지) │ -└─────────────────────────────────────────────────────────┘ - -❌ 클라이언트 JavaScript (브라우저) - ↓ - document.cookie → "" (빈 문자열, 읽기 불가) - ↓ - HttpOnly 쿠키는 보이지 않음! - - -✅ 서버사이드 (Node.js, Next.js API Route) - ↓ - request.cookies.get('access_token') → "토큰값" (읽기 가능!) - ↓ - HttpOnly 쿠키 정상 접근! -``` - -### 우리가 겪은 상황 - -```typescript -// ❌ WRONG: 클라이언트에서 직접 백엔드 호출 -fetch('https://api.codebridge-x.com/api/v1/item-master/init', { - headers: { - 'Authorization': `Bearer ${document.cookie에서_추출}` // null! - // ↑ HttpOnly 쿠키는 JavaScript로 읽을 수 없음! - } -}) -``` - -**결론**: 우리가 막아둔 보안(HttpOnly)이 **완벽하게 작동하고 있었다!** 🎉 - ---- - -## ✅ 해결 방법: Next.js API Proxy Pattern - -### 아키텍처 - -``` -[브라우저] - ↓ fetch('/api/proxy/item-master/init') - ↓ Cookie: access_token=xxx (자동 전송, HttpOnly) - ↓ Headers: { X-API-KEY, Accept } - ↓ ⚠️ Authorization 헤더 없음 (JS로 못 읽으니까!) - -[Next.js 프록시] ← 서버사이드! - ↓ request.cookies.get('access_token') ✅ 읽기 성공! - ↓ fetch('https://backend.com/api/v1/item-master/init') - ↓ Headers: { - ↓ Authorization: 'Bearer {토큰}', ← 프록시가 추가! - ↓ X-API-KEY: '...' - ↓ } - -[PHP 백엔드] - ↓ Authorization 헤더 확인 ✅ - ↓ 인증 성공! 데이터 반환 - -[브라우저] - ↓ 데이터 수신 완료! -``` - -### 구현 - -#### 1. Catch-all 프록시 라우트 생성 -```typescript -// /src/app/api/proxy/[...path]/route.ts -async function proxyRequest( - request: NextRequest, - params: { path: string[] }, - method: string -) { - // 1. 서버에서 HttpOnly 쿠키 읽기 (가능!) - const token = request.cookies.get('access_token')?.value; - - // 2. 백엔드로 프록시 - const backendResponse = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`, - { - method, - headers: { - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - }, - } - ); - - return backendResponse; -} - -export async function GET(request, { params }) { - return proxyRequest(request, params, 'GET'); -} - -export async function POST(request, { params }) { - return proxyRequest(request, params, 'POST'); -} - -// PUT, DELETE도 동일... -``` - -#### 2. API 클라이언트 수정 -```typescript -// /src/lib/api/item-master.ts - -// ❌ BEFORE: 직접 백엔드 호출 -const BASE_URL = 'https://api.codebridge-x.com/api/v1'; - -// ✅ AFTER: 프록시 사용 -const BASE_URL = '/api/proxy'; - -// 이제 모든 API 호출이 프록시를 통함 -export async function getItemMasterInit() { - const response = await fetch(`${BASE_URL}/item-master/init`, { - headers: getAuthHeaders(), - }); - return response; -} -``` - -#### 3. 헤더 유틸리티 간소화 -```typescript -// /src/lib/api/auth-headers.ts - -// ✅ AFTER: Authorization 헤더 제거 (프록시가 처리) -export const getAuthHeaders = (): HeadersInit => { - return { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - // Authorization 헤더 없음! 프록시가 추가함 - }; -}; -``` - ---- - -## 🎓 교훈 - -### 1. HttpOnly 쿠키는 정말로 JavaScript 접근을 막는다 -```javascript -// 이것은 실패하도록 설계되었다! -document.cookie // HttpOnly 쿠키는 보이지 않음 - -// 이것이 보안의 핵심! -// XSS 공격으로 스크립트가 실행되어도 토큰을 훔칠 수 없다! -``` - -### 2. "작동 안 함" ≠ "버그" -- 처음엔 "토큰이 null이라서 문제"라고 생각 -- 실제로는 "보안이 제대로 작동하는 것" -- **예상대로 작동하지 않는 것이 설계 의도일 수 있다!** - -### 3. 기존 코드에서 배우기 -- 로그아웃이 작동하는 이유를 분석 -- "왜 이것만 되지?"라는 질문이 해결의 열쇠 -- **작동하는 코드 = 참조 구현** - -### 4. 서버사이드 프록시 패턴의 가치 -``` -보안 (HttpOnly) + 기능 (API 호출) = 프록시 패턴 - ↓ ↓ ↓ -XSS 방지 인증된 API 호출 Best of Both -``` - ---- - -## 🔐 보안 검증 결과 - -### ✅ 검증된 사항 - -1. **JavaScript로 HttpOnly 쿠키를 절대 읽을 수 없음** - - `document.cookie`에서 완전히 숨겨짐 - - 브라우저 콘솔에서도 접근 불가 - - **XSS 공격으로부터 안전!** - -2. **서버사이드에서만 접근 가능** - - Next.js API Route에서 `request.cookies.get()` 성공 - - 토큰이 서버 메모리에만 존재 - - 클라이언트 JavaScript에 노출되지 않음 - -3. **자동 쿠키 전송** - - 브라우저가 same-origin 요청 시 자동 전송 - - HTTPS로 암호화되어 전송 - - Secure, HttpOnly, SameSite 속성으로 보호 - -### 🛡️ 보안 강도 - -| 공격 유형 | 방어 가능 여부 | 이유 | -|----------|----------------|------| -| XSS (Cross-Site Scripting) | ✅ 방어 | JavaScript가 쿠키를 읽을 수 없음 | -| Session Hijacking | ✅ 방어 | HttpOnly + Secure 조합 | -| CSRF | ⚠️ 추가 방어 필요 | SameSite 속성으로 일부 방어 | -| Man-in-the-Middle | ✅ 방어 | HTTPS + Secure 속성 | - ---- - -## 📝 RULES.md 반영 - -이번 사례를 바탕으로 `RULES.md`에 추가된 규칙: - -```markdown -## API Communication with HttpOnly Cookies -**Priority**: 🔴 **Triggers**: Backend API calls requiring authentication - -### Mandatory Proxy Pattern -- ALL authenticated API calls MUST use Next.js API route proxies -- NEVER try to read HttpOnly cookies with JavaScript -- Reference implementation: /api/auth/logout/route.ts -``` - ---- - -## 🎯 적용 범위 - -### 현재 적용됨 -- ✅ 로그인 API (`/api/auth/login`) -- ✅ 로그아웃 API (`/api/auth/logout`) -- ✅ 품목기준관리 API (`/api/proxy/item-master/*`) - -### 향후 적용 필요 -- 품목관리 API (개발 예정) -- 기타 인증 필요 API들 - -### 프록시 사용법 -```typescript -// ❌ WRONG -fetch('https://backend.com/api/v1/some-api') - -// ✅ RIGHT -fetch('/api/proxy/some-api') -``` - ---- - -## 📊 성능 영향 - -### 레이턴시 -- **프록시 추가 레이턴시**: ~5-15ms (Next.js 서버 처리) -- **보안 향상**: 무한대 -- **결론**: 트레이드오프 가치 있음 - -### 서버 부하 -- Next.js 서버가 모든 API 요청을 중계 -- 필요 시 캐싱 전략 추가 가능 -- 현재 규모에서는 문제 없음 - ---- - -## 🔗 관련 파일 - -### 구현 파일 -- `/src/app/api/proxy/[...path]/route.ts` - Catch-all 프록시 -- `/src/lib/api/item-master.ts` - API 클라이언트 -- `/src/lib/api/auth-headers.ts` - 헤더 유틸리티 - -### 참조 파일 -- `/src/app/api/auth/logout/route.ts` - 참조 구현 -- `/Users/byeongcheolryu/.claude/RULES.md` - 규칙 문서 - ---- - -## 💬 팀 피드백 - -> "흐흑 ㅠㅠ 우리가 막아두고 계속 스크립트로 요청했구나" -> -> "보안 검증이 철저하게 됐군 스크립트로 절대 못 뽑아온다는걸 말야 ㅋㅋ" - -**→ 보안이 제대로 작동하고 있었다는 것을 확인한 순간!** - ---- - -## 🎉 결론 - -이번 사례는 **"버그인 줄 알았는데 실은 기능(feature)이었다"** 는 완벽한 예시입니다. - -### Key Takeaways -1. ✅ HttpOnly 쿠키 보안이 완벽하게 작동함을 검증 -2. ✅ 서버사이드 프록시 패턴으로 보안과 기능 모두 확보 -3. ✅ 기존 코드(로그아웃)에서 해결책을 찾음 -4. ✅ 향후 모든 인증 API에 적용할 패턴 확립 - -### 최종 평가 -**🏆 보안 설계: A+** -**🔧 구현 방법: A+** -**📚 문서화: A+** - ---- - -**작성일**: 2025-11-25 -**작성자**: Claude Code -**검증자**: 개발팀 -**상태**: ✅ 완료 및 프로덕션 적용 \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2025-11-07] auth-guard-usage.md b/claudedocs/auth/[IMPL-2025-11-07] auth-guard-usage.md deleted file mode 100644 index da4db282..00000000 --- a/claudedocs/auth/[IMPL-2025-11-07] auth-guard-usage.md +++ /dev/null @@ -1,335 +0,0 @@ -# Auth Guard Hook 사용 가이드 - -## 개요 - -`useAuthGuard()` Hook은 보호된 페이지에 인증 검증과 브라우저 캐시 방지 기능을 제공합니다. - -## 기능 - -1. **실시간 인증 확인**: 페이지 로드 시 서버에 인증 상태 확인 -2. **뒤로가기 보호**: 로그아웃 후 브라우저 뒤로가기 시 캐시된 페이지 접근 차단 -3. **자동 리다이렉트**: 인증 실패 시 자동으로 로그인 페이지로 이동 - -## 사용 방법 - -### 기본 사용 - -보호가 필요한 모든 페이지에 Hook을 추가하세요: - -```tsx -"use client"; - -import { useAuthGuard } from '@/hooks/useAuthGuard'; - -export default function ProtectedPage() { - // 🔒 인증 보호 및 브라우저 캐시 방지 - useAuthGuard(); - - return ( -
    - {/* 보호된 컨텐츠 */} -
    - ); -} -``` - -### 적용 예시 - -#### Dashboard 페이지 -```tsx -// src/app/[locale]/dashboard/page.tsx -"use client"; - -import { useAuthGuard } from '@/hooks/useAuthGuard'; - -export default function Dashboard() { - useAuthGuard(); // 한 줄만 추가하면 끝! - - return
    Dashboard Content
    ; -} -``` - -#### Profile 페이지 -```tsx -// src/app/[locale]/profile/page.tsx -"use client"; - -import { useAuthGuard } from '@/hooks/useAuthGuard'; - -export default function Profile() { - useAuthGuard(); - - return
    Profile Content
    ; -} -``` - -#### Settings 페이지 -```tsx -// src/app/[locale]/settings/page.tsx -"use client"; - -import { useAuthGuard } from '@/hooks/useAuthGuard'; - -export default function Settings() { - useAuthGuard(); - - return
    Settings Content
    ; -} -``` - -## 적용이 필요한 페이지 - -다음 페이지들에 `useAuthGuard()` Hook을 적용해야 합니다: - -### 필수 적용 페이지 -- ✅ `/dashboard` - 이미 적용됨 -- ⏳ `/profile` - 적용 필요 -- ⏳ `/settings` - 적용 필요 -- ⏳ `/admin/*` - 모든 관리자 페이지 -- ⏳ `/tenant/*` - 모든 테넌트 관리 페이지 -- ⏳ `/users/*` - 사용자 관리 페이지 -- ⏳ `/reports/*` - 리포트 페이지 -- ⏳ `/analytics/*` - 분석 페이지 -- ⏳ `/inventory/*` - 재고 관리 페이지 -- ⏳ `/finance/*` - 재무 관리 페이지 -- ⏳ `/hr/*` - 인사 관리 페이지 -- ⏳ `/crm/*` - CRM 페이지 - -### 적용 불필요 페이지 -- ❌ `/login` - 게스트 전용 -- ❌ `/signup` - 게스트 전용 -- ❌ `/forgot-password` - 게스트 전용 - -## 동작 방식 - -### 1. 페이지 로드 시 -``` -페이지 컴포넌트 마운트 - ↓ -useAuthGuard() 실행 - ↓ -/api/auth/check 호출 (HttpOnly 쿠키 검증) - ↓ -인증 성공 → 페이지 표시 -인증 실패 → /login으로 리다이렉트 -``` - -### 2. 뒤로가기 시 (브라우저 캐시) -``` -브라우저 뒤로가기 - ↓ -pageshow 이벤트 감지 - ↓ -event.persisted === true? (캐시된 페이지인가?) - ↓ -Yes → window.location.reload() (새로고침) - ↓ -useAuthGuard() 재실행 - ↓ -인증 확인 → 쿠키 없음 → /login 리다이렉트 -``` - -## 내부 구현 - -`src/hooks/useAuthGuard.ts`: - -```typescript -export function useAuthGuard() { - const router = useRouter(); - - useEffect(() => { - // 1. 인증 확인 - const checkAuth = async () => { - const response = await fetch('/api/auth/check'); - if (!response.ok) { - router.replace('/login'); - } - }; - - checkAuth(); - - // 2. 브라우저 캐시 방지 - const handlePageShow = (event: PageTransitionEvent) => { - if (event.persisted) { - window.location.reload(); - } - }; - - window.addEventListener('pageshow', handlePageShow); - - return () => { - window.removeEventListener('pageshow', handlePageShow); - }; - }, [router]); -} -``` - -## API 엔드포인트 - -### GET /api/auth/check - -**목적**: HttpOnly 쿠키를 통한 인증 상태 확인 - -**요청:** -```http -GET /api/auth/check HTTP/1.1 -Cookie: user_token=... -``` - -**응답 (인증 성공):** -```json -{ - "authenticated": true -} -``` -Status: `200 OK` - -**응답 (인증 실패):** -```json -{ - "error": "Not authenticated", - "authenticated": false -} -``` -Status: `401 Unauthorized` - -## 테스트 시나리오 - -### 시나리오 1: 정상 접근 -1. 로그인 상태로 `/dashboard` 접근 -2. ✅ 페이지 정상 표시 -3. 콘솔 로그 없음 (정상 동작) - -### 시나리오 2: 비로그인 접근 -1. 로그아웃 상태로 `/dashboard` URL 직접 입력 -2. ✅ 즉시 `/login`으로 리다이렉트 -3. 콘솔: "⚠️ 인증 실패: 로그인 페이지로 이동" - -### 시나리오 3: 로그아웃 후 뒤로가기 -1. `/dashboard` 접속 (로그인 상태) -2. Logout 버튼 클릭 → `/login` 이동 -3. 브라우저 뒤로가기 버튼 클릭 -4. ✅ 캐시된 페이지 감지 → 새로고침 → `/login` 리다이렉트 -5. 콘솔: "🔄 캐시된 페이지 감지: 새로고침" - -### 시나리오 4: 다른 탭에서 로그아웃 -1. 탭 A: `/dashboard` 접속 (로그인 상태) -2. 탭 B: 같은 브라우저에서 로그아웃 -3. 탭 A: 페이지 새로고침 또는 다른 페이지 이동 -4. ✅ 인증 확인 실패 → `/login` 리다이렉트 - -## Middleware와의 관계 - -| 보안 레이어 | 역할 | 타이밍 | -|-----------|------|--------| -| **Middleware** | 서버 사이드 경로 보호 | 모든 요청 전 | -| **useAuthGuard** | 클라이언트 사이드 보호 | 페이지 마운트 시 | - -### 왜 둘 다 필요한가? - -**Middleware만 있으면?** -- ❌ 브라우저 뒤로가기 캐시 문제 해결 안됨 -- ❌ 실시간 인증 상태 변경 감지 안됨 - -**useAuthGuard만 있으면?** -- ❌ URL 직접 접근 시 보호 지연 (컴포넌트 마운트 후) -- ❌ 서버 사이드 렌더링 보호 안됨 - -**둘 다 있으면:** -- ✅ 서버 + 클라이언트 이중 보호 -- ✅ 브라우저 캐시 문제 해결 -- ✅ 실시간 인증 상태 동기화 - -## 성능 고려사항 - -### API 호출 최소화 -- `useAuthGuard`는 페이지 마운트 시 1회만 호출 -- 페이지 이동 시마다 다시 실행됨 (의도된 동작) - -### 사용자 경험 -- 인증 확인은 비동기로 처리되어 UI 블로킹 없음 -- 인증 실패 시 `router.replace()` 사용 (뒤로가기 히스토리 오염 방지) - -## 문제 해결 - -### 문제: Hook이 작동하지 않음 -**원인:** 페이지가 Server Component로 되어 있음 -**해결:** 파일 상단에 `"use client";` 추가 - -### 문제: 무한 리다이렉트 -**원인:** `/login` 페이지에도 Hook 적용됨 -**해결:** 게스트 전용 페이지에는 Hook 사용 금지 - -### 문제: 뒤로가기 시 여전히 페이지 보임 -**원인:** `pageshow` 이벤트 리스너 미등록 -**해결:** Hook이 올바르게 import되었는지 확인 - -## 향후 개선 사항 - -### 1. 토큰 검증 추가 -현재는 토큰 존재 여부만 확인하지만, 향후 PHP 백엔드에 토큰 유효성 검증 추가 가능: - -```typescript -// /api/auth/check 개선 -const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/verify`, { - headers: { 'Authorization': `Bearer ${token}` } -}); -``` - -### 2. 자동 새로고침 주기 -장시간 페이지 유지 시 주기적 인증 확인: - -```typescript -useEffect(() => { - const interval = setInterval(checkAuth, 5 * 60 * 1000); // 5분마다 - return () => clearInterval(interval); -}, []); -``` - -### 3. 세션 만료 경고 -토큰 만료 임박 시 사용자에게 알림: - -```typescript -if (expiresIn < 5 * 60 * 1000) { - showToast('세션이 곧 만료됩니다. 다시 로그인해주세요.'); -} -``` - -## 요약 - -✅ **적용 완료:** -- Dashboard 페이지 - -⏳ **적용 필요:** -- 다른 모든 보호된 페이지들 - -📝 **사용법:** -```tsx -import { useAuthGuard } from '@/hooks/useAuthGuard'; - -export default function Page() { - useAuthGuard(); // 이 한 줄만 추가! - return
    Content
    ; -} -``` - -🔒 **보안 효과:** -- 브라우저 캐시 악용 방지 -- 실시간 인증 상태 동기화 -- 로그아웃 후 완전한 페이지 접근 차단 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/hooks/useAuthGuard.ts` - Auth Guard Hook 구현 -- `src/app/api/auth/check/route.ts` - 인증 체크 API -- `src/app/[locale]/(protected)/layout.tsx` - Protected Layout -- `src/middleware.ts` - 인증 미들웨어 -- `src/lib/api/auth/auth-config.ts` - 인증 설정 (라우트) - -### 보호된 페이지 -- `src/app/[locale]/(protected)/dashboard/page.tsx` -- `src/app/[locale]/(protected)/profile/page.tsx` -- `src/app/[locale]/(protected)/settings/page.tsx` diff --git a/claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md b/claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md deleted file mode 100644 index ea13a645..00000000 --- a/claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md +++ /dev/null @@ -1,328 +0,0 @@ -# 인증 시스템 구현 가이드 - -## 📋 개요 - -Laravel PHP 백엔드와 Next.js 15 프론트엔드 간의 3가지 인증 방식을 지원하는 통합 인증 시스템 - ---- - -## 🔐 지원 인증 방식 - -### 1️⃣ Sanctum Session (웹 사용자) -- **대상**: 웹 브라우저 사용자 -- **방식**: HTTP-only 쿠키 기반 세션 -- **보안**: XSS 방어 + CSRF 토큰 -- **Stateful**: Yes - -### 2️⃣ Bearer Token (모바일/SPA) -- **대상**: 모바일 앱, 외부 SPA -- **방식**: Authorization: Bearer {token} -- **보안**: 토큰 만료 시간 관리 -- **Stateful**: No - -### 3️⃣ API Key (시스템 간 통신) -- **대상**: 서버 간 통신, 백그라운드 작업 -- **방식**: X-API-KEY: {key} -- **보안**: 서버 사이드 전용 (환경 변수) -- **Stateful**: No - ---- - -## 📁 파일 구조 - -``` -src/ -├─ lib/api/ -│ ├─ client.ts # 통합 HTTP Client (3가지 인증 방식) -│ │ -│ └─ auth/ -│ ├─ types.ts # 인증 타입 정의 -│ ├─ auth-config.ts # 인증 설정 (라우트, URL) -│ │ -│ ├─ sanctum-client.ts # Sanctum 전용 클라이언트 -│ ├─ bearer-client.ts # Bearer 토큰 클라이언트 -│ ├─ api-key-client.ts # API Key 클라이언트 -│ │ -│ ├─ token-storage.ts # Bearer 토큰 저장 관리 -│ ├─ api-key-validator.ts # API Key 검증 유틸 -│ └─ server-auth.ts # 서버 컴포넌트 인증 유틸 -│ -├─ contexts/ -│ └─ AuthContext.tsx # 클라이언트 인증 상태 관리 -│ -├─ middleware.ts # 통합 미들웨어 (Bot + Auth + i18n) -│ -└─ app/[locale]/ - ├─ (auth)/ - │ └─ login/page.tsx # 로그인 페이지 - │ - └─ (protected)/ - └─ dashboard/page.tsx # 보호된 페이지 -``` - ---- - -## 🔧 환경 변수 설정 - -### .env.local (실제 키 값) -```env -# API Configuration -NEXT_PUBLIC_API_URL=https://api.5130.co.kr -NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 - -# Authentication Mode -NEXT_PUBLIC_AUTH_MODE=sanctum - -# API Key (서버 사이드 전용 - 절대 공개 금지!) -API_KEY=42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a -``` - -### .env.example (템플릿) -```env -NEXT_PUBLIC_API_URL=https://api.5130.co.kr -NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 -NEXT_PUBLIC_AUTH_MODE=sanctum -API_KEY=your-secret-api-key-here -``` - ---- - -## 🎯 구현 단계 - -### Phase 1: 핵심 인프라 (필수) -1. `lib/api/auth/types.ts` - 타입 정의 -2. `lib/api/auth/auth-config.ts` - 인증 설정 -3. `lib/api/client.ts` - 통합 HTTP 클라이언트 -4. `lib/api/auth/sanctum-client.ts` - Sanctum 클라이언트 - -### Phase 2: Middleware 통합 -1. `middleware.ts` 확장 - 인증 체크 로직 추가 -2. 라우트 보호 구현 (protected/guest-only) - -### Phase 3: 로그인 페이지 -1. `app/[locale]/(auth)/login/page.tsx` -2. 기존 validation schema 활용 - -### Phase 4: 보호된 페이지 -1. `app/[locale]/(protected)/dashboard/page.tsx` -2. Server Component로 구현 - ---- - -## 🔒 보안 고려사항 - -### 환경 변수 보안 -```yaml -✅ NEXT_PUBLIC_*: 브라우저 노출 가능 -❌ API_KEY: 절대 NEXT_PUBLIC_ 붙이지 말 것! -✅ .env.local은 .gitignore에 포함됨 -``` - -### 인증 방식별 보안 -```yaml -Sanctum: - ✅ HTTP-only 쿠키 (XSS 방어) - ✅ CSRF 토큰 자동 처리 - ✅ Same-Site: Lax - -Bearer Token: - ⚠️ localStorage 사용 (XSS 취약) - ✅ 토큰 만료 시간 체크 - ✅ Refresh token 권장 - -API Key: - ⚠️ 서버 사이드 전용 - ✅ 환경 변수 관리 - ✅ 주기적 갱신 대비 -``` - ---- - -## 📊 Middleware 인증 플로우 - -``` -Request - ↓ -1. Bot Detection (기존) - ├─ Bot → 403 Forbidden - └─ Human → Continue - ↓ -2. Static Files Check - ├─ Static → Skip Auth - └─ Dynamic → Continue - ↓ -3. Public Routes Check - ├─ Public → Skip Auth - └─ Protected → Continue - ↓ -4. Authentication Check - ├─ Sanctum Session Cookie - ├─ Bearer Token (Authorization header) - └─ API Key (X-API-KEY header) - ↓ -5. Protected Routes Guard - ├─ Authenticated → Allow - └─ Not Authenticated → Redirect /login - ↓ -6. Guest Only Routes - ├─ Authenticated → Redirect /dashboard - └─ Not Authenticated → Allow - ↓ -7. i18n Routing - ↓ -Response -``` - ---- - -## 🚀 API 엔드포인트 - -### 로그인 -``` -POST /api/v1/login -Content-Type: application/json - -Request: -{ - "user_id": "hamss", - "user_pwd": "StrongPass!1234" -} - -Response (성공): -{ - "user": { - "id": 1, - "name": "홍길동", - "email": "hamss@example.com" - }, - "message": "로그인 성공" -} - -Cookie: laravel_session=xxx; HttpOnly; SameSite=Lax -``` - -### 로그아웃 -``` -POST /api/v1/logout - -Response: -{ - "message": "로그아웃 성공" -} -``` - -### 현재 사용자 정보 -``` -GET /api/user -Cookie: laravel_session=xxx - -Response: -{ - "id": 1, - "name": "홍길동", - "email": "hamss@example.com" -} -``` - ---- - -## 📝 사용 예시 - -### 1. Sanctum 로그인 (웹 사용자) -```typescript -import { sanctumClient } from '@/lib/api/auth/sanctum-client'; - -const user = await sanctumClient.login({ - user_id: 'hamss', - user_pwd: 'StrongPass!1234' -}); -``` - -### 2. API Key 요청 (서버 사이드) -```typescript -import { createApiKeyClient } from '@/lib/api/auth/api-key-client'; - -const client = createApiKeyClient(); -const data = await client.fetchData('/api/external-data'); -``` - -### 3. Bearer Token 로그인 (모바일) -```typescript -import { bearerClient } from '@/lib/api/auth/bearer-client'; - -const user = await bearerClient.login({ - email: 'user@example.com', - password: 'password' -}); -``` - ---- - -## ⚠️ 주의사항 - -### API Key 갱신 -- PHP 팀에서 주기적으로 새 키 발급 -- `.env.local`의 `API_KEY` 값만 변경 -- 코드 수정 불필요, 서버 재시작만 필요 - -### Git 보안 -- `.env.local`은 절대 커밋 금지 -- `.env.example`만 템플릿으로 커밋 -- `.gitignore`에 `.env.local` 포함 확인 - -### 개발 환경 -- 개발 서버 시작 시 API Key 자동 검증 -- 콘솔에 검증 상태 출력 -- 에러 발생 시 명확한 가이드 제공 - ---- - -## 🔍 트러블슈팅 - -### API Key 에러 -``` -❌ API_KEY is not configured! -📝 Please check: - 1. .env.local file exists - 2. API_KEY is set correctly - 3. Restart development server (npm run dev) - -💡 Contact backend team if you need a new API key. -``` - -### CORS 에러 -- Laravel `config/cors.php` 확인 -- `supports_credentials: true` 설정 -- `allowed_origins`에 Next.js URL 포함 - -### 세션 쿠키 안받아짐 -- Laravel `SANCTUM_STATEFUL_DOMAINS` 확인 -- `localhost:3000` 포함 확인 -- `SESSION_DOMAIN` 설정 확인 - ---- - -## 📚 참고 문서 - -- [Laravel Sanctum 공식 문서](https://laravel.com/docs/sanctum) -- [Next.js Middleware 문서](https://nextjs.org/docs/app/building-your-application/routing/middleware) -- [claudedocs/authentication-design.md](./authentication-design.md) -- [claudedocs/api-requirements.md](./api-requirements.md) - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/lib/api/client.ts` - 통합 HTTP Client -- `src/lib/api/auth/types.ts` - 인증 타입 정의 -- `src/lib/api/auth/auth-config.ts` - 인증 설정 -- `src/lib/api/auth/sanctum-client.ts` - Sanctum 전용 클라이언트 -- `src/lib/api/auth/bearer-client.ts` - Bearer 토큰 클라이언트 -- `src/lib/api/auth/api-key-client.ts` - API Key 클라이언트 -- `src/contexts/AuthContext.tsx` - 클라이언트 인증 상태 관리 -- `src/middleware.ts` - 통합 미들웨어 - -### 설정 파일 -- `.env.local` - 환경 변수 -- `.env.example` - 환경 변수 템플릿 \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2025-11-07] jwt-cookie-authentication-final.md b/claudedocs/auth/[IMPL-2025-11-07] jwt-cookie-authentication-final.md deleted file mode 100644 index 1e3b0c7f..00000000 --- a/claudedocs/auth/[IMPL-2025-11-07] jwt-cookie-authentication-final.md +++ /dev/null @@ -1,508 +0,0 @@ -# JWT + Cookie + Middleware 인증 설계 (최종) - -**확정된 API 정보:** -- 인증 방식: Bearer Token (JWT) -- 로그인: `POST /api/v1/login` -- 응답: `{ token: "xxx" }` -- Token 저장: **쿠키** (Middleware 접근 가능) - -## ✅ 핵심 발견 - -**JWT도 쿠키에 저장하면 Middleware에서 처리 가능합니다!** - -```typescript -// middleware.ts에서 JWT 토큰 쿠키 접근 -const authToken = request.cookies.get('auth_token'); // ✅ 가능! - -if (!authToken) { - redirect('/login'); -} -``` - -따라서 **기존 Middleware 설계를 거의 그대로 사용**할 수 있습니다. - ---- - -## 📋 아키텍처 (기존과 동일) - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Next.js Frontend │ -├─────────────────────────────────────────────────────────────┤ -│ Middleware (Server) │ -│ ├─ Bot Detection (기존) │ -│ ├─ Authentication Check (신규) │ -│ │ ├─ JWT Token 쿠키 확인 │ -│ │ └─ 없으면 /login 리다이렉트 │ -│ └─ i18n Routing (기존) │ -├─────────────────────────────────────────────────────────────┤ -│ JWT Client (lib/auth/jwt-client.ts) │ -│ ├─ Token을 쿠키에 저장 │ -│ ├─ API 호출 시 Authorization 헤더 추가 │ -│ └─ 401 응답 시 자동 로그아웃 │ -├─────────────────────────────────────────────────────────────┤ -│ Auth Context (contexts/AuthContext.tsx) │ -│ ├─ 사용자 정보 관리 │ -│ └─ login/logout 함수 │ -└─────────────────────────────────────────────────────────────┘ - ↓ HTTP + Cookie + Authorization -┌─────────────────────────────────────────────────────────────┐ -│ Laravel Backend │ -├─────────────────────────────────────────────────────────────┤ -│ JWT Middleware │ -│ └─ Bearer Token 검증 │ -├─────────────────────────────────────────────────────────────┤ -│ API Endpoints │ -│ ├─ POST /api/v1/login → { token: "xxx" } │ -│ ├─ POST /api/v1/register │ -│ ├─ GET /api/v1/user │ -│ └─ POST /api/v1/logout │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## 🔐 인증 플로우 - -### 1. 로그인 - -``` -1. POST /api/v1/login - → { token: "eyJhbGci..." } - -2. Token을 쿠키에 저장 - document.cookie = 'auth_token=xxx; Secure; SameSite=Strict' - -3. /dashboard 리다이렉트 - -4. Middleware가 쿠키 확인 ✓ - -5. 페이지 렌더링 -``` - -### 2. API 호출 - -``` -1. 쿠키에서 Token 읽기 -2. Authorization 헤더에 추가 - Authorization: Bearer xxx -3. Laravel이 JWT 검증 -4. 데이터 반환 -``` - -### 3. 보호된 페이지 접근 - -``` -사용자 → /dashboard - ↓ -Middleware 실행 - ↓ -auth_token 쿠키 확인 - ↓ -있음 → 페이지 표시 -없음 → /login 리다이렉트 -``` - ---- - -## 🛠️ 핵심 구현 - -### 1. Token 저장 (lib/auth/token-storage.ts) - -```typescript -export const tokenStorage = { - /** - * JWT를 쿠키에 저장 - * - Middleware에서 접근 가능 - * - Secure + SameSite로 보안 강화 - */ - set(token: string): void { - const maxAge = 86400; // 24시간 - document.cookie = `auth_token=${token}; path=/; max-age=${maxAge}; SameSite=Strict; Secure`; - }, - - /** - * 쿠키에서 Token 읽기 - * - 클라이언트에서만 사용 - */ - get(): string | null { - if (typeof window === 'undefined') return null; - - const match = document.cookie.match(/auth_token=([^;]+)/); - return match ? match[1] : null; - }, - - /** - * Token 삭제 - */ - remove(): void { - document.cookie = 'auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; - } -}; -``` - -### 2. JWT Client (lib/auth/jwt-client.ts) - -```typescript -import { tokenStorage } from './token-storage'; - -class JwtClient { - private baseURL = 'https://api.5130.co.kr'; - - /** - * 로그인 - */ - async login(email: string, password: string): Promise { - const response = await fetch(`${this.baseURL}/api/v1/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - - if (!response.ok) { - throw new Error('Login failed'); - } - - const { token } = await response.json(); - - // ✅ Token을 쿠키에 저장 - tokenStorage.set(token); - - // 사용자 정보 조회 - return await this.getCurrentUser(); - } - - /** - * 현재 사용자 정보 - */ - async getCurrentUser(): Promise { - const token = tokenStorage.get(); - - if (!token) { - throw new Error('No token'); - } - - const response = await fetch(`${this.baseURL}/api/v1/user`, { - headers: { - 'Authorization': `Bearer ${token}`, // ✅ Authorization 헤더 - }, - }); - - if (response.status === 401) { - tokenStorage.remove(); - throw new Error('Unauthorized'); - } - - return await response.json(); - } - - /** - * 로그아웃 - */ - async logout(): Promise { - const token = tokenStorage.get(); - - if (token) { - await fetch(`${this.baseURL}/api/v1/logout`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - }, - }); - } - - // ✅ 쿠키 삭제 - tokenStorage.remove(); - } -} - -export const jwtClient = new JwtClient(); -``` - -### 3. Middleware (middleware.ts) - 기존과 거의 동일! - -```typescript -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; -import createIntlMiddleware from 'next-intl/middleware'; -import { locales, defaultLocale } from '@/i18n/config'; - -const intlMiddleware = createIntlMiddleware({ - locales, - defaultLocale, - localePrefix: 'as-needed', -}); - -// 보호된 라우트 -const PROTECTED_ROUTES = [ - '/dashboard', - '/profile', - '/settings', - '/admin', - '/tenant', - '/users', - '/reports', -]; - -// 공개 라우트 -const PUBLIC_ROUTES = [ - '/', - '/login', - '/register', - '/about', - '/contact', -]; - -function isProtectedRoute(pathname: string): boolean { - return PROTECTED_ROUTES.some(route => pathname.startsWith(route)); -} - -function isPublicRoute(pathname: string): boolean { - return PUBLIC_ROUTES.some(route => pathname === route || pathname.startsWith(route)); -} - -function stripLocale(pathname: string): string { - for (const locale of locales) { - if (pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`) { - return pathname.slice(`/${locale}`.length) || '/'; - } - } - return pathname; -} - -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - // 1. Bot Detection (기존 로직) - // ... bot check code ... - - // 2. 정적 파일 제외 - if ( - pathname.includes('/_next/') || - pathname.includes('/api/') || - pathname.match(/\.(ico|png|jpg|jpeg|svg|gif|webp)$/) - ) { - return intlMiddleware(request); - } - - // 3. 로케일 제거 - const pathnameWithoutLocale = stripLocale(pathname); - - // 4. ✅ JWT Token 쿠키 확인 - const authToken = request.cookies.get('auth_token'); - const isAuthenticated = !!authToken; - - // 5. 보호된 라우트 체크 - if (isProtectedRoute(pathnameWithoutLocale) && !isAuthenticated) { - const url = new URL('/login', request.url); - url.searchParams.set('redirect', pathname); - return NextResponse.redirect(url); - } - - // 6. 게스트 전용 라우트 (이미 로그인한 경우) - if ( - (pathnameWithoutLocale === '/login' || pathnameWithoutLocale === '/register') && - isAuthenticated - ) { - return NextResponse.redirect(new URL('/dashboard', request.url)); - } - - // 7. i18n 미들웨어 - return intlMiddleware(request); -} - -export const config = { - matcher: [ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)', - ], -}; -``` - -**변경 사항:** -```diff -- const sessionCookie = request.cookies.get('laravel_session'); -+ const authToken = request.cookies.get('auth_token'); -``` - -거의 동일합니다! - -### 4. Auth Context (contexts/AuthContext.tsx) - -```typescript -'use client'; - -import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { jwtClient } from '@/lib/auth/jwt-client'; -import { useRouter } from 'next/navigation'; - -interface User { - id: number; - name: string; - email: string; -} - -interface AuthContextType { - user: User | null; - loading: boolean; - login: (email: string, password: string) => Promise; - logout: () => Promise; -} - -const AuthContext = createContext(undefined); - -export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const router = useRouter(); - - // 초기 로드 시 사용자 정보 가져오기 - useEffect(() => { - jwtClient.getCurrentUser() - .then(setUser) - .catch(() => setUser(null)) - .finally(() => setLoading(false)); - }, []); - - const login = async (email: string, password: string) => { - const user = await jwtClient.login(email, password); - setUser(user); - router.push('/dashboard'); - }; - - const logout = async () => { - await jwtClient.logout(); - setUser(null); - router.push('/login'); - }; - - return ( - - {children} - - ); -} - -export function useAuth() { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within AuthProvider'); - } - return context; -} -``` - ---- - -## 📊 세션 쿠키 vs JWT 쿠키 비교 - -| 항목 | 세션 쿠키 (Sanctum) | JWT 쿠키 (현재) | -|------|---------------------|------------------| -| **쿠키 이름** | `laravel_session` | `auth_token` | -| **Middleware 접근** | ✅ 가능 | ✅ 가능 | -| **인증 체크** | 쿠키 존재 확인 | 쿠키 존재 확인 | -| **API 호출** | 쿠키 자동 포함 | Authorization 헤더 | -| **CSRF 토큰** | ✅ 필요 | ❌ 불필요 | -| **서버 상태** | Stateful (세션 저장) | Stateless | -| **보안** | HTTP-only 가능 | Secure + SameSite | -| **구현 복잡도** | 동일 | 동일 | - -**결론:** Middleware 관점에서는 거의 동일합니다! - ---- - -## 🎯 구현 순서 - -### Phase 1: 기본 인프라 (30분) -- [x] auth-config.ts -- [ ] token-storage.ts -- [ ] jwt-client.ts -- [ ] types/auth.ts - -### Phase 2: Middleware 통합 (20분) -- [ ] middleware.ts 업데이트 - - JWT 토큰 쿠키 체크 - - Protected routes 가드 - -### Phase 3: Auth Context (20분) -- [ ] AuthContext.tsx -- [ ] layout.tsx에 AuthProvider 추가 - -### Phase 4: 로그인 페이지 (40분) -- [ ] /login/page.tsx -- [ ] LoginForm 컴포넌트 -- [ ] Form validation (react-hook-form + zod) - -### Phase 5: 테스트 (30분) -- [ ] 로그인 → 대시보드 -- [ ] 비로그인 → 대시보드 → /login 튕김 -- [ ] 로그아웃 → 다시 튕김 - -**총 소요시간: 약 2시간 20분** - ---- - -## ✅ 최종 정리 - -### 핵심 포인트 - -1. **JWT를 쿠키에 저장** → Middleware 접근 가능 -2. **기존 Middleware 설계 유지** → 가드 컴포넌트 불필요 -3. **차이점은 미미함:** - - 쿠키 이름: `laravel_session` → `auth_token` - - CSRF 토큰 불필요 - - API 호출 시 Authorization 헤더 추가 - -### 장점 - -- ✅ Middleware에서 서버사이드 인증 체크 -- ✅ 클라이언트 가드 컴포넌트 불필요 -- ✅ 중복 코드 제거 -- ✅ 기존 설계(authentication-design.md) 거의 그대로 사용 - -### 변경 사항 - -**최소한의 변경만 필요:** -```typescript -// 1. Token 저장: 쿠키 사용 -tokenStorage.set(token); - -// 2. Middleware: 쿠키 이름만 변경 -const authToken = request.cookies.get('auth_token'); - -// 3. API 호출: Authorization 헤더 추가 -headers: { 'Authorization': `Bearer ${token}` } - -// 4. CSRF 토큰: 제거 -// getCsrfToken() 불필요 -``` - ---- - -## 🚀 다음 단계 - -1. ✅ 설계 확정 완료 -2. ⏳ 디자인 컴포넌트 대기 -3. ⏳ 백엔드 API 엔드포인트 확인 - - POST /api/v1/register - - GET /api/v1/user - - POST /api/v1/logout -4. 🚀 구현 시작 (2-3시간) - -**준비되면 바로 시작합니다!** 🎯 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/middleware.ts` - 인증 미들웨어 -- `src/lib/api/auth/auth-config.ts` - 인증 설정 (라우트, URL) -- `src/lib/api/auth/token-storage.ts` - Token 저장 관리 -- `src/lib/api/auth/jwt-client.ts` - JWT 클라이언트 -- `src/contexts/AuthContext.tsx` - 클라이언트 인증 상태 관리 -- `src/app/[locale]/(auth)/login/page.tsx` - 로그인 페이지 -- `src/app/[locale]/(protected)/dashboard/page.tsx` - 보호된 페이지 - -### 설정 파일 -- `.env.local` - 환경 변수 (API URL, API Key) -- `next.config.ts` - Next.js 설정 \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2025-11-07] middleware-issue-resolution.md b/claudedocs/auth/[IMPL-2025-11-07] middleware-issue-resolution.md deleted file mode 100644 index de3adf51..00000000 --- a/claudedocs/auth/[IMPL-2025-11-07] middleware-issue-resolution.md +++ /dev/null @@ -1,178 +0,0 @@ -# Middleware 인증 문제 해결 보고서 - -## 📅 작성일: 2025-11-07 - -## 🔍 문제 증상 - -로그인하지 않은 상태에서 `/dashboard`에 접근 시, 인증 체크가 작동하지 않고 대시보드에 바로 접근되는 문제가 발생했습니다. - -### 증상 상세 -- ✅ 로그인/로그아웃 기능 정상 작동 -- ✅ 쿠키(`user_token`) 저장/삭제 정상 -- ❌ Middleware에서 보호된 라우트 접근 차단 실패 -- ❌ Middleware console.log가 터미널에 전혀 출력되지 않음 - ---- - -## 🐛 발견된 문제들 - -### 1. Next.js 15 + next-intl 호환성 문제 -**위치**: `next.config.ts` - -**원인**: -- Next.js 15에서 next-intl v4를 사용할 때 `turbopack` 설정이 필수 -- 이 설정이 없으면 middleware가 제대로 컴파일되지 않음 - -**해결**: -```typescript -// next.config.ts -const nextConfig: NextConfig = { - turbopack: {}, // ✅ 추가 -}; -``` - ---- - -### 2. 복잡한 Matcher 정규식 -**위치**: `src/middleware.ts` - `config.matcher` - -**원인**: -- 너무 복잡한 regex 패턴으로 라우트 매칭 실패 -- 중복된 matcher 패턴 (정규식 + 명시적 경로) - -**기존 코드**: -```typescript -matcher: [ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)', - '/dashboard/:path*', - '/login', - '/register', -] -``` - -**해결**: -```typescript -matcher: [ - '/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|robots\\.txt).*)', -] -``` - ---- - -### 3. isPublicRoute 함수 로직 버그 ⭐ (핵심 문제) -**위치**: `src/middleware.ts` - `isPublicRoute()` 함수 - -**원인**: -```typescript -// 문제 코드 -function isPublicRoute(pathname: string): boolean { - return AUTH_CONFIG.publicRoutes.some(route => - pathname === route || pathname.startsWith(route) - ); -} -``` - -**버그 시나리오**: -1. `AUTH_CONFIG.publicRoutes`에 `'/'` 포함 -2. `/dashboard`.startsWith('/') → `true` 반환 -3. 모든 경로가 public route로 잘못 판단됨 -4. 인증 체크가 스킵되어 보호된 라우트 접근 가능 - -**해결**: -```typescript -function isPublicRoute(pathname: string): boolean { - return AUTH_CONFIG.publicRoutes.some(route => { - // '/' 는 정확히 일치해야만 public - if (route === '/') { - return pathname === '/'; - } - // 다른 라우트는 시작 일치 허용 - return pathname === route || pathname.startsWith(route + '/'); - }); -} -``` - -**수정 후 동작**: -- `/` → public ✅ -- `/dashboard` → protected ✅ -- `/about` → public ✅ -- `/about/team` → public ✅ - ---- - -## ✅ 해결 결과 - -### 적용된 수정 사항 -1. ✅ `next.config.ts`에 `turbopack: {}` 추가 -2. ✅ Middleware matcher 단순화 -3. ✅ `isPublicRoute()` 함수 로직 수정 -4. ✅ 디버깅 로그 제거 (클린 코드) - -### 검증 결과 -```bash -# 로그아웃 상태에서 /dashboard 접근 시: -[Auth Required] Redirecting to /login from /dashboard -→ 자동으로 /login 페이지로 리다이렉트 ✅ - -# 로그인 상태에서 /dashboard 접근 시: -[Authenticated] Mode: bearer, Path: /dashboard -→ 정상 접근 ✅ -``` - ---- - -## 📝 교훈 - -### 1. Middleware 디버깅 -- **브라우저 콘솔이 아닌 서버 터미널**에서 로그 확인 -- `console.log`는 서버 사이드에서 실행되므로 터미널 출력 - -### 2. 문자열 매칭 주의 -- `startsWith('/')` 같은 패턴은 모든 경로와 매칭됨 -- Root path(`/`)는 항상 정확한 일치(`===`) 사용 - -### 3. Next.js 버전별 설정 -- Next.js 15 + next-intl 사용 시 `turbopack` 설정 필수 -- 공식 문서 및 마이그레이션 가이드 확인 필요 - ---- - -## 🔗 관련 파일 - -### 수정된 파일 -- `next.config.ts` - turbopack 설정 추가 -- `src/middleware.ts` - isPublicRoute 로직 수정, matcher 단순화 - -### 관련 설정 파일 -- `src/lib/api/auth/auth-config.ts` - 라우트 설정 -- `src/lib/api/auth/sanctum-client.ts` - 인증 로직 -- `src/lib/api/auth/token-storage.ts` - 토큰 관리 - ---- - -## 🎯 현재 인증 플로우 - -### 로그인 -1. 사용자가 `/login`에서 인증 정보 입력 -2. PHP API(`/api/v1/login`)로 요청 (API Key 포함) -3. Bearer Token 발급 (`user_token`) -4. localStorage 저장 + Cookie 동기화 -5. `/dashboard`로 리다이렉트 - -### 보호된 라우트 접근 -1. Middleware에서 요청 가로채기 -2. Cookie에서 `user_token` 확인 -3. 토큰 있음 → 통과 -4. 토큰 없음 → `/login`으로 리다이렉트 - -### 로그아웃 -1. PHP API(`/api/v1/logout`) 호출 -2. localStorage 및 Cookie 정리 -3. `/login`으로 리다이렉트 - ---- - -## 📚 참고 자료 -- Next.js 15 Middleware 공식 문서 -- next-intl v4 마이그레이션 가이드 -- `claudedocs/research_nextjs15_middleware_authentication_2025-11-07.md` \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2025-11-07] route-protection-architecture.md b/claudedocs/auth/[IMPL-2025-11-07] route-protection-architecture.md deleted file mode 100644 index 4f6d48dd..00000000 --- a/claudedocs/auth/[IMPL-2025-11-07] route-protection-architecture.md +++ /dev/null @@ -1,513 +0,0 @@ -# Route Protection Architecture - 최종 구조 - -## 개요 - -**2단계 보호 시스템:** -1. **Middleware (서버)**: 모든 페이지 요청 시 인증 확인 -2. **Layout Hook (클라이언트)**: 보호된 페이지의 브라우저 캐시 방지 - ---- - -## 폴더 구조 - -``` -src/app/[locale]/ -├── (auth)/ # 게스트 전용 페이지 -│ └── login/ -│ └── page.tsx # 로그인 페이지 (컴포넌트 재사용) -│ -├── (protected)/ # ✅ 보호된 페이지 그룹 -│ ├── layout.tsx # 🔒 useAuthGuard() 여기서만! -│ └── dashboard/ -│ └── page.tsx # useAuthGuard() 불필요 -│ -├── login/ # 직접 접근용 로그인 페이지 -│ └── page.tsx -│ -├── signup/ # 직접 접근용 회원가입 페이지 -│ └── page.tsx -│ -├── page.tsx # 홈페이지 (공개) -└── layout.tsx # 루트 레이아웃 -``` - -**Route Group 설명:** -- `(auth)`: 괄호로 감싸져 있어 URL에 포함되지 않음 - - `/login` → `src/app/[locale]/login/page.tsx` - - `/(auth)/login` → 동일한 `/login` URL -- `(protected)`: Layout 기반 보호 그룹 - - `/dashboard` → `src/app/[locale]/(protected)/dashboard/page.tsx` - - Layout의 `useAuthGuard()`가 자동 적용 - ---- - -## 보호 레이어 상세 - -### Layer 1: Middleware (서버 사이드) - -**파일:** `src/middleware.ts` - -**역할:** -- 모든 HTTP 요청 차단 (페이지, API, 리소스) -- HttpOnly 쿠키 검증 -- 인증 실패 시 `/login` 리다이렉트 - -**적용 범위:** -- URL 직접 입력 -- 링크 클릭 -- 새로고침 (F5) -- 프로그래매틱 네비게이션 - -**코드:** -```typescript -// src/middleware.ts -function checkAuthentication(request: NextRequest) { - const tokenCookie = request.cookies.get('user_token'); - if (tokenCookie?.value) { - return { isAuthenticated: true, authMode: 'bearer' }; - } - return { isAuthenticated: false, authMode: null }; -} - -// 보호된 경로 체크 -if (!isAuthenticated && !isPublicRoute && !isGuestOnlyRoute) { - return NextResponse.redirect(new URL('/login', request.url)); -} -``` - ---- - -### Layer 2: Protected Layout (클라이언트 사이드) - -**파일:** `src/app/[locale]/(protected)/layout.tsx` - -**역할:** -- 페이지 마운트 시 인증 재확인 -- 브라우저 BFCache (뒤로가기 캐시) 감지 및 새로고침 -- 다른 탭에서 로그아웃 시 동기화 - -**적용 범위:** -- `(protected)` 폴더 하위 모든 페이지 -- 브라우저 뒤로가기 -- 페이지 캐시 복원 - -**코드:** -```typescript -// src/app/[locale]/(protected)/layout.tsx -"use client"; - -import { useAuthGuard } from '@/hooks/useAuthGuard'; - -export default function ProtectedLayout({ children }) { - useAuthGuard(); // 모든 하위 페이지에 자동 적용 - return <>{children}; -} -``` - ---- - -## 시나리오별 동작 - -### ✅ 시나리오 1: URL 직접 입력 (비로그인) - -``` -http://localhost:3000/dashboard 입력 - ↓ -🛡️ Middleware 실행 - → 쿠키 없음 - → /login 리다이렉트 - ↓ -로그인 페이지 표시 -(Layout Hook은 실행되지 않음) -``` - -**결과:** Middleware만으로 차단 완료 ✅ - ---- - -### ✅ 시나리오 2: 정상 로그인 후 접근 - -``` -로그인 성공 → /dashboard 이동 - ↓ -🛡️ Middleware 실행 - → 쿠키 있음 - → 통과 - ↓ -(protected)/layout.tsx 마운트 - → useAuthGuard() 실행 - → /api/auth/check 호출 - → 인증 성공 - ↓ -dashboard/page.tsx 렌더링 -``` - -**결과:** 이중 검증 통과 ✅ - ---- - -### ✅ 시나리오 3: 로그아웃 후 뒤로가기 (핵심!) - -``` -/dashboard 접속 (로그인 상태) - ↓ -Logout 버튼 클릭 - → /api/auth/logout 호출 - → HttpOnly 쿠키 삭제 - → /login 이동 - ↓ -브라우저 뒤로가기 버튼 클릭 - ↓ -⚠️ 브라우저 캐시에서 /dashboard 복원 - → 서버 요청 없음 - → Middleware 실행 안됨 ❌ - ↓ -🛡️ (protected)/layout.tsx 복원 - → useAuthGuard() 실행 - → pageshow 이벤트 감지 - → event.persisted === true (캐시됨) - → window.location.reload() 실행 - ↓ -새로고침 → 서버 요청 발생 - ↓ -🛡️ Middleware 실행 - → 쿠키 없음 - → /login 리다이렉트 - ↓ -로그인 페이지 표시 -``` - -**결과:** Layout Hook이 캐시 우회 → Middleware 재실행 ✅ - ---- - -### ✅ 시나리오 4: 다른 탭에서 로그아웃 - -``` -탭 A: /dashboard 접속 (로그인 상태) -탭 B: 로그아웃 - ↓ -탭 A: 페이지 새로고침 또는 네비게이션 - ↓ -🛡️ Middleware 실행 - → 쿠키 없음 (탭 B에서 삭제됨) - → /login 리다이렉트 -``` - -**결과:** 쿠키 공유로 즉시 차단 ✅ - ---- - -## 새 페이지 추가 방법 - -### 보호된 페이지 추가 - -**단계:** -1. `(protected)` 폴더 안에 페이지 생성 -2. **끝!** (자동으로 보호됨) - -**예시:** -```bash -# Profile 페이지 생성 -mkdir -p src/app/[locale]/(protected)/profile -``` - -```tsx -// src/app/[locale]/(protected)/profile/page.tsx -"use client"; - -export default function Profile() { - // useAuthGuard() 불필요! Layout에서 자동 처리 - return
    Profile Content
    ; -} -``` - -**URL:** `/profile` (Route Group 괄호는 URL에 포함 안됨) - ---- - -### 공개 페이지 추가 - -**단계:** -1. `(protected)` 폴더 **밖**에 페이지 생성 -2. `auth-config.ts`의 `publicRoutes`에 추가 (필요시) - -**예시:** -```bash -# About 페이지 생성 (공개) -mkdir -p src/app/[locale]/about -``` - -```tsx -// src/app/[locale]/about/page.tsx -export default function About() { - return
    About Us (Public)
    ; -} -``` - -```typescript -// src/lib/api/auth/auth-config.ts -export const AUTH_CONFIG = { - publicRoutes: [ - '/about', // 추가 - ], - // ... -}; -``` - ---- - -## 구현 상세 - -### useAuthGuard Hook - -**파일:** `src/hooks/useAuthGuard.ts` - -```typescript -export function useAuthGuard() { - const router = useRouter(); - - useEffect(() => { - // 1. 페이지 로드 시 인증 확인 - const checkAuth = async () => { - const response = await fetch('/api/auth/check'); - if (!response.ok) { - router.replace('/login'); - } - }; - - checkAuth(); - - // 2. 브라우저 캐시 감지 및 새로고침 - const handlePageShow = (event: PageTransitionEvent) => { - if (event.persisted) { - console.log('🔄 캐시된 페이지 감지: 새로고침'); - window.location.reload(); - } - }; - - window.addEventListener('pageshow', handlePageShow); - - return () => { - window.removeEventListener('pageshow', handlePageShow); - }; - }, [router]); -} -``` - -**핵심 로직:** -1. `checkAuth()`: `/api/auth/check` 호출로 실시간 인증 확인 -2. `pageshow` 이벤트: `event.persisted`로 캐시 감지 -3. `window.location.reload()`: 강제 새로고침으로 Middleware 재실행 - ---- - -### Auth Check API - -**파일:** `src/app/api/auth/check/route.ts` - -```typescript -export async function GET(request: NextRequest) { - const token = request.cookies.get('user_token')?.value; - - if (!token) { - return NextResponse.json( - { error: 'Not authenticated', authenticated: false }, - { status: 401 } - ); - } - - return NextResponse.json( - { authenticated: true }, - { status: 200 } - ); -} -``` - -**역할:** -- HttpOnly 쿠키 읽기 -- 인증 상태 반환 (200 or 401) - ---- - -## 보안 장점 - -### ✅ 이전 (각 페이지에 Hook) -``` -각 페이지마다 useAuthGuard() 수동 추가 -→ 누락 위험 ⚠️ -→ 보일러플레이트 코드 증가 -``` - -### ✅ 현재 (Layout 기반) -``` -(protected)/layout.tsx에서 한 번만 -→ 새 페이지 자동 보호 -→ 누락 불가능 -→ 코드 중복 제거 -``` - ---- - -## 설정 파일 - -### auth-config.ts - -**파일:** `src/lib/api/auth/auth-config.ts` - -```typescript -export const AUTH_CONFIG = { - // 🔓 공개 라우트 (인증 불필요) - publicRoutes: [], - - // 🔒 보호된 라우트 (참고용, 실제로는 기본 정책으로 보호) - protectedRoutes: [ - '/dashboard', - '/profile', - '/settings', - '/admin', - // ... 모든 보호된 경로 - ], - - // 👤 게스트 전용 라우트 (로그인 후 접근 불가) - guestOnlyRoutes: [ - '/login', - '/signup', - '/forgot-password', - ], - - // 리다이렉트 설정 - redirects: { - afterLogin: '/dashboard', - afterLogout: '/login', - unauthorized: '/login', - }, -}; -``` - ---- - -## 테스트 체크리스트 - -### 필수 테스트 - -- [ ] **URL 직접 입력 (비로그인)** - - `/dashboard` 입력 → `/login` 리다이렉트 - -- [ ] **로그인 후 접근** - - 로그인 → `/dashboard` 정상 표시 - -- [ ] **로그아웃 후 뒤로가기** - - 로그아웃 → 뒤로가기 → 캐시 감지 → 새로고침 → `/login` 리다이렉트 - -- [ ] **다른 탭에서 로그아웃** - - 탭 A: `/dashboard` 유지 - - 탭 B: 로그아웃 - - 탭 A: 새로고침 → `/login` 리다이렉트 - -- [ ] **새 보호된 페이지 추가** - - `(protected)/profile` 생성 → 자동 보호 확인 - ---- - -## 트러블슈팅 - -### 문제: 로그아웃 후 뒤로가기 시 페이지 보임 - -**원인:** Layout이 Client Component가 아님 - -**해결:** -```tsx -// (protected)/layout.tsx 파일 상단에 추가 -"use client"; -``` - ---- - -### 문제: 404 에러 (페이지를 찾을 수 없음) - -**원인:** 폴더 이름 오타 또는 Route Group 괄호 누락 - -**확인:** -```bash -# 올바른 경로 -src/app/[locale]/(protected)/dashboard/page.tsx - -# 잘못된 경로 -src/app/[locale]/protected/dashboard/page.tsx # 괄호 없음 -``` - ---- - -### 문제: 무한 리다이렉트 - -**원인:** `/login` 페이지에도 보호 적용됨 - -**확인:** -- `/login`이 `(protected)` 폴더 **밖**에 있는지 확인 -- `guestOnlyRoutes`에 `/login` 포함 확인 - ---- - -## 성능 고려사항 - -### API 호출 최소화 -- `useAuthGuard`는 페이지 마운트 시 **1회만** 호출 -- 브라우저 캐시 복원 시에만 추가 호출 (새로고침) - -### 사용자 경험 -- 인증 확인은 비동기로 처리 (UI 블로킹 없음) -- `router.replace()` 사용으로 뒤로가기 히스토리 오염 방지 - ---- - -## 향후 페이지 추가 계획 - -### 즉시 적용 가능 (보호됨) -`(protected)` 폴더에 추가하면 자동 보호: - -``` -(protected)/ -├── profile/ # 사용자 프로필 -├── settings/ # 설정 -├── admin/ # 관리자 -│ ├── users/ -│ ├── tenants/ -│ └── reports/ -├── inventory/ # 재고 관리 -├── finance/ # 재무 -├── hr/ # 인사 -└── crm/ # CRM -``` - ---- - -## 요약 - -### ✅ 최종 아키텍처 - -``` -보호 정책: -1. Middleware (서버): 모든 요청 차단 -2. Layout (클라이언트): 캐시 우회 및 실시간 동기화 - -폴더 구조: -- (protected)/layout.tsx: 한 곳에서만 관리 -- (protected)/**/page.tsx: 자동으로 보호됨 - -장점: -✅ 코드 중복 제거 -✅ 누락 불가능 -✅ 브라우저 캐시 문제 해결 -✅ 확장성 (새 페이지 자동 보호) -✅ 유지보수성 향상 -``` - ---- - -## 참고 문서 - -- **HttpOnly Cookie 구현**: `claudedocs/httponly-cookie-implementation.md` -- **Auth Guard 사용법**: `claudedocs/auth-guard-usage.md` -- **Middleware 설정**: `src/middleware.ts` -- **Auth 설정**: `src/lib/api/auth/auth-config.ts` \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2025-11-10] token-management-guide.md b/claudedocs/auth/[IMPL-2025-11-10] token-management-guide.md deleted file mode 100644 index 6d6d860a..00000000 --- a/claudedocs/auth/[IMPL-2025-11-10] token-management-guide.md +++ /dev/null @@ -1,467 +0,0 @@ -# Token Management System Guide - -완전한 Access Token & Refresh Token 시스템 구현 가이드 - -## 📋 목차 - -1. [시스템 개요](#시스템-개요) -2. [토큰 라이프사이클](#토큰-라이프사이클) -3. [API 엔드포인트](#api-엔드포인트) -4. [자동 토큰 갱신](#자동-토큰-갱신) -5. [사용 예시](#사용-예시) -6. [보안 고려사항](#보안-고려사항) - ---- - -## 시스템 개요 - -### 토큰 구조 - -```json -{ - "access_token": "214|EU7drdTBYN1fru0MylLXwjJbi2svXcikn5ofvmTI354d09c7", - "refresh_token": "215|6hAPWcO05jtfSDV9Yz4kLQi3qZDFuycMqrNITOV3c27bd0cb", - "token_type": "Bearer", - "expires_in": 7200, - "expires_at": "2025-11-10 15:49:38" -} -``` - -### 저장 방식 - -**HttpOnly 쿠키** (XSS 공격 방지): -- `access_token`: 2시간 만료 (7200초) -- `refresh_token`: 7일 만료 (604800초) - -**보안 속성**: -- `HttpOnly`: JavaScript 접근 불가 -- `Secure`: HTTPS만 전송 -- `SameSite=Strict`: CSRF 공격 방지 - ---- - -## 토큰 라이프사이클 - -### 1. 로그인 (Token 발급) - -``` -사용자 로그인 - ↓ -POST /api/auth/login - ↓ -PHP Backend /api/v1/login - ↓ -access_token + refresh_token 발급 - ↓ -HttpOnly 쿠키에 저장 - ↓ -대시보드로 이동 -``` - -### 2. 인증된 요청 - -``` -보호된 페이지 접근 - ↓ -Middleware 인증 체크 - ↓ -access_token 존재? - ├─ Yes → 접근 허용 - └─ No → refresh_token 확인 - ├─ 있음 → 자동 갱신 시도 - └─ 없음 → 로그인 페이지로 -``` - -### 3. 토큰 갱신 - -``` -access_token 만료 (2시간 후) - ↓ -보호된 API 호출 시도 - ↓ -401 Unauthorized 응답 - ↓ -POST /api/auth/refresh - ↓ -refresh_token으로 새 토큰 발급 - ↓ -새 access_token + refresh_token 쿠키 업데이트 - ↓ -원래 API 호출 재시도 - ↓ -성공 -``` - -### 4. 로그아웃 - -``` -사용자 로그아웃 - ↓ -POST /api/auth/logout - ↓ -PHP Backend /api/v1/logout (토큰 무효화) - ↓ -HttpOnly 쿠키 삭제 - ↓ -로그인 페이지로 이동 -``` - ---- - -## API 엔드포인트 - -### 1. Login API - -**Endpoint**: `POST /api/auth/login` - -**Request**: -```typescript -{ - user_id: string; - user_pwd: string; -} -``` - -**Response**: -```typescript -{ - message: string; - user: UserObject; - tenant: TenantObject | null; - menus: MenuItem[]; - token_type: "Bearer"; - expires_in: number; - expires_at: string; -} -``` - -**쿠키 설정**: -- `access_token` (HttpOnly, 2시간) -- `refresh_token` (HttpOnly, 7일) - ---- - -### 2. Refresh Token API - -**Endpoint**: `POST /api/auth/refresh` - -**쿠키 필요**: `refresh_token` - -**Response** (성공): -```typescript -{ - message: "Token refreshed successfully"; - token_type: "Bearer"; - expires_in: number; - expires_at: string; -} -``` - -**Response** (실패): -```typescript -{ - error: "Token refresh failed"; - needsReauth: true; -} -``` - -**쿠키 업데이트**: -- 새 `access_token` (2시간) -- 새 `refresh_token` (7일) - ---- - -### 3. Auth Check API - -**Endpoint**: `GET /api/auth/check` - -**기능**: -1. `access_token` 존재 → 200 OK with `authenticated: true` -2. `access_token` 없음 + `refresh_token` 있음 → 자동 갱신 시도 - - 갱신 성공 → 200 OK with `authenticated: true, refreshed: true` - - 갱신 실패 → 401 Unauthorized -3. 둘 다 없음 → 401 Unauthorized - -**Response**: -```typescript -// ✅ 인증 성공 (200) -{ - authenticated: true; - refreshed?: boolean; // 자동 갱신 여부 -} - -// ❌ 인증 실패 (401) -{ - error: string; // 'Not authenticated' 또는 'Token refresh failed' -} -``` - -**참고**: -- 🔵 **Next.js 내부 API** (PHP 백엔드 X) -- 성능 최적화: 로컬 쿠키만 확인하여 빠른 응답 - -> ⚠️ **2025-11-27 변경사항**: -> - `LoginPage.tsx`에서 auth/check 호출 제거됨 -> - **제거 이유**: -> 1. 미들웨어(`middleware.ts`)에서 이미 동일한 처리를 함 (guestOnlyRoutes 리다이렉트) -> 2. 401 응답이 Network 탭에 에러로 표시되어 백엔드 개발자 혼란 유발 -> 3. 불필요한 API 호출로 인한 성능 저하 -> - **대체 방안**: 미들웨어가 서버 사이드에서 쿠키 체크 후 리다이렉트 처리 -> - 참고: `src/components/auth/LoginPage.tsx` 주석 참조 - ---- - -### 4. Logout API - -**Endpoint**: `POST /api/auth/logout` - -**기능**: -1. PHP 백엔드에 로그아웃 요청 (토큰 무효화) -2. `access_token`, `refresh_token` 쿠키 삭제 - ---- - -## 자동 토큰 갱신 - -### 1. Middleware에서 자동 갱신 - -`src/middleware.ts`: -```typescript -// access_token 또는 refresh_token이 있으면 인증됨 -const accessToken = request.cookies.get('access_token'); -const refreshToken = request.cookies.get('refresh_token'); - -if ((accessToken && accessToken.value) || (refreshToken && refreshToken.value)) { - return { isAuthenticated: true, authMode: 'bearer' }; -} -``` - -### 2. Auth Check에서 자동 갱신 - -`src/app/api/auth/check/route.ts`: -```typescript -// access_token 없고 refresh_token만 있으면 자동 갱신 -if (refreshToken && !accessToken) { - const refreshResponse = await fetch('/api/v1/refresh', {...}); - // 새 토큰을 HttpOnly 쿠키로 설정 -} -``` - -### 3. Proxy에서 자동 갱신 (✅ 2025-11-27 구현) - -`src/app/api/proxy/[...path]/route.ts`: -```typescript -// 401 응답 시 자동 토큰 갱신 후 재시도 -if (backendResponse.status === 401 && refreshToken) { - const refreshResult = await refreshAccessToken(refreshToken); - - if (refreshResult.success && refreshResult.accessToken) { - // 새 토큰으로 원래 요청 재시도 - token = refreshResult.accessToken; - backendResponse = await executeBackendRequest(url, method, token, body, contentType); - - // 새 토큰을 쿠키에 저장 - createTokenCookies(newTokens).forEach(cookie => { - clientResponse.headers.append('Set-Cookie', cookie); - }); - } else { - // 리프레시 실패 → 쿠키 삭제 후 401 반환 - return NextResponse.json({ error: 'Authentication failed', needsReauth: true }, { status: 401 }); - } -} -``` - -**동작 방식**: -1. 백엔드 API 호출 (access_token 사용) -2. 401 Unauthorized 응답 받음 -3. refresh_token으로 `/api/v1/refresh` 호출 -4. 성공 시: 새 토큰으로 원래 요청 재시도 + 쿠키 업데이트 -5. 실패 시: 쿠키 삭제 + `needsReauth: true` 응답 - -> **장점**: 프론트엔드 코드 수정 없이 모든 `/api/proxy/*` 요청에 자동 토큰 갱신 적용 - -### 4. API Client에서 자동 갱신 (Legacy) - -`src/lib/api/client.ts`: -```typescript -// withTokenRefresh 헬퍼 함수 사용 -const data = await withTokenRefresh(() => - apiClient.get('/protected/resource') -); -``` - -**동작 방식**: -1. API 호출 시도 -2. 401 응답 받음 -3. `/api/auth/refresh` 호출 -4. 성공 시 원래 API 재시도 -5. 실패 시 로그인 페이지로 리다이렉트 - -> **참고**: 대부분의 API 호출은 프록시를 통해 자동 갱신되므로 직접 사용할 필요 없음 - ---- - -## 사용 예시 - -### 예시 1: 보호된 페이지에서 API 호출 - -```typescript -// src/app/[locale]/(protected)/dashboard/page.tsx -import { withTokenRefresh } from '@/lib/api/client'; - -export default function Dashboard() { - const fetchData = async () => { - try { - // 자동 토큰 갱신 포함 - const data = await withTokenRefresh(() => - fetch('/api/protected/data', { - credentials: 'include' // 쿠키 포함 - }) - ); - - console.log('Data fetched:', data); - } catch (error) { - console.error('Fetch failed:', error); - } - }; - - return
    ...
    ; -} -``` - -### 예시 2: 수동 토큰 갱신 - -```typescript -// src/lib/auth/token-refresh.ts -import { refreshTokenClient } from '@/lib/auth/token-refresh'; - -async function handleProtectedAction() { - try { - // API 호출 - const response = await fetch('/api/protected/action'); - - if (!response.ok) { - // 401 에러 시 토큰 갱신 시도 - const refreshed = await refreshTokenClient(); - - if (refreshed) { - // 재시도 - return await fetch('/api/protected/action'); - } - } - - return response; - } catch (error) { - console.error('Action failed:', error); - } -} -``` - -### 예시 3: Protected Layout - -```typescript -// src/app/[locale]/(protected)/layout.tsx -"use client"; - -import { useAuthGuard } from '@/hooks/useAuthGuard'; - -export default function ProtectedLayout({ children }) { - // 자동으로 /api/auth/check 호출 - // access_token 없으면 refresh_token으로 자동 갱신 - useAuthGuard(); - - return <>{children}; -} -``` - ---- - -## 보안 고려사항 - -### ✅ 구현된 보안 기능 - -1. **HttpOnly 쿠키** - - JavaScript에서 토큰 접근 불가 - - XSS 공격으로부터 보호 - -2. **Secure 플래그** - - HTTPS에서만 쿠키 전송 - - 중간자 공격 방지 - -3. **SameSite=Strict** - - CSRF 공격 방지 - - 크로스 사이트 요청 차단 - -4. **토큰 만료 시간** - - Access Token: 2시간 (짧은 수명) - - Refresh Token: 7일 (긴 수명) - -5. **에러 메시지 일반화** - - 백엔드 상세 에러 노출 방지 - - 정보 유출 차단 - -### ⚠️ 추가 권장 사항 - -1. **Token Rotation** - - Refresh 시 새로운 refresh_token 발급 (현재 구현됨 ✅) - -2. **Rate Limiting** - - 로그인 시도 제한 - - Refresh 요청 제한 - -3. **IP 검증** - - 토큰 발급 시 IP 기록 - - 다른 IP에서 사용 시 경고 - -4. **Device Fingerprinting** - - 토큰 발급 디바이스 기록 - - 이상 접근 탐지 - -5. **Logout Blacklist** - - 로그아웃 된 토큰 블랙리스트 관리 - - 재사용 방지 - ---- - -## 트러블슈팅 - -### 문제 1: 로그인 후 바로 로그아웃됨 - -**원인**: 쿠키가 설정되지 않음 - -**해결**: -1. 브라우저 개발자 도구 → Application → Cookies 확인 -2. `access_token`, `refresh_token` 존재 확인 -3. 없으면 `/api/auth/login` 응답 헤더 확인 - -### 문제 2: Token refresh 무한 루프 - -**원인**: Refresh token도 만료됨 - -**해결**: -1. `/api/auth/refresh` 응답 확인 -2. 401 응답 시 로그인 페이지로 리다이렉트 -3. `needsReauth: true` 플래그 확인 - -### 문제 3: CORS 에러 - -**원인**: 크로스 도메인 요청 시 쿠키 전송 실패 - -**해결**: -```typescript -fetch('/api/protected', { - credentials: 'include' // 쿠키 포함 -}) -``` - ---- - -## 참고 파일 - -- `src/app/api/auth/login/route.ts` - 로그인 API -- `src/app/api/auth/refresh/route.ts` - 토큰 갱신 API -- `src/app/api/auth/check/route.ts` - 인증 체크 API -- `src/app/api/auth/logout/route.ts` - 로그아웃 API -- `src/middleware.ts` - 인증 미들웨어 -- `src/lib/auth/token-refresh.ts` - 토큰 갱신 유틸리티 -- `src/lib/api/client.ts` - API 클라이언트 (자동 갱신) \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2025-11-13] safari-cookie-compatibility.md b/claudedocs/auth/[IMPL-2025-11-13] safari-cookie-compatibility.md deleted file mode 100644 index 7c683493..00000000 --- a/claudedocs/auth/[IMPL-2025-11-13] safari-cookie-compatibility.md +++ /dev/null @@ -1,504 +0,0 @@ -# Safari 쿠키 호환성 및 크로스 브라우저 가이드 - -## 📋 목차 -1. [문제 상황](#문제-상황) -2. [원인 분석](#원인-분석) -3. [해결 방법](#해결-방법) -4. [수정된 파일](#수정된-파일) -5. [크로스 브라우저 개발 가이드라인](#크로스-브라우저-개발-가이드라인) -6. [테스트 체크리스트](#테스트-체크리스트) - ---- - -## 문제 상황 - -### Safari에서 발생한 인증 문제 -- **로그인**: 성공했으나 대시보드로 이동 불가 ({"error":"Not authenticated"}) -- **로그아웃**: 로그아웃 버튼 클릭 시 정상 동작하지 않음 -- **크롬/파이어폭스**: 정상 작동 - -### 증상 -```bash -# Safari 브라우저 -✅ 로그인 API 호출 성공 (200 OK) -❌ 대시보드 접근 실패 (401 Unauthorized) -❌ 쿠키가 저장되지 않음 - -# Chrome/Firefox 브라우저 -✅ 모든 기능 정상 작동 -``` - ---- - -## 원인 분석 - -### Safari의 엄격한 쿠키 정책 - -Safari는 다른 브라우저보다 **쿠키 보안 정책이 엄격**합니다: - -#### 1. Secure 속성 제한 -```typescript -// ❌ Safari에서 작동하지 않음 (HTTP 환경) -const cookie = 'access_token=xxx; HttpOnly; Secure; SameSite=Strict'; - -// Safari 로직: -// - HTTP (localhost:3000) + Secure 속성 = 쿠키 저장 거부 -// - HTTPS만 Secure 쿠키 허용 -``` - -Chrome/Firefox는 `localhost`에서 `Secure` 속성을 허용하지만, **Safari는 허용하지 않습니다**. - -#### 2. SameSite=Strict의 제약 -```typescript -// SameSite=Strict: 모든 크로스 사이트 요청에서 쿠키 차단 -// - 너무 엄격하여 일부 정상적인 요청도 차단될 수 있음 - -// SameSite=Lax: CSRF 보호 + 유연성 -// - GET 요청과 top-level navigation에서는 쿠키 전송 허용 -// - 대부분의 웹 애플리케이션에 적합 -``` - -#### 3. 쿠키 삭제 시 속성 불일치 -Safari는 쿠키를 삭제할 때 **설정할 때와 정확히 동일한 속성**을 요구합니다: - -```typescript -// ❌ Safari에서 쿠키 삭제 실패 -// 설정: HttpOnly + SameSite=Lax (Secure 없음) -// 삭제: HttpOnly + Secure + SameSite=Strict - -// ✅ Safari에서 쿠키 삭제 성공 -// 설정: HttpOnly + SameSite=Lax (Secure 없음) -// 삭제: HttpOnly + SameSite=Lax (Secure 없음) -``` - ---- - -## 해결 방법 - -### 핵심 원칙: 환경별 조건부 쿠키 설정 - -```typescript -// 1. 환경 감지 -const isProduction = process.env.NODE_ENV === 'production'; - -// 2. 조건부 Secure 속성 -const cookie = [ - 'access_token=xxx', - 'HttpOnly', // ✅ 항상 유지 (XSS 보호) - ...(isProduction ? ['Secure'] : []), // ✅ HTTPS에서만 적용 - 'SameSite=Lax', // ✅ CSRF 보호 + 호환성 - 'Path=/', - 'Max-Age=7200', -].join('; '); -``` - -### 환경별 쿠키 속성 - -| 환경 | Secure | SameSite | HttpOnly | 설명 | -|------|--------|----------|----------|------| -| **Development** (HTTP) | ❌ 없음 | Lax | ✅ 있음 | Safari 호환성 | -| **Production** (HTTPS) | ✅ 있음 | Lax | ✅ 있음 | 완전한 보안 | - ---- - -## 수정된 파일 - -### 1. `src/app/api/auth/login/route.ts` - -**수정 위치**: 150-170 라인 - -```typescript -// ❌ 기존 코드 (Safari 비호환) -const accessTokenCookie = [ - `access_token=${data.access_token}`, - 'HttpOnly', - 'Secure', // 개발 환경에서 문제 발생 - 'SameSite=Strict', // 너무 엄격 - 'Path=/', - `Max-Age=${data.expires_in || 7200}`, -].join('; '); -``` - -```typescript -// ✅ 수정 코드 (Safari 호환) -const isProduction = process.env.NODE_ENV === 'production'; - -const accessTokenCookie = [ - `access_token=${data.access_token}`, - 'HttpOnly', // ✅ JavaScript cannot access (XSS 보호) - ...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production - 'SameSite=Lax', // ✅ CSRF protection (Lax for compatibility) - 'Path=/', - `Max-Age=${data.expires_in || 7200}`, -].join('; '); - -const refreshTokenCookie = [ - `refresh_token=${data.refresh_token}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=604800', // 7 days -].join('; '); -``` - -**변경 사항**: -- ✅ `Secure` 속성을 환경에 따라 조건부 적용 -- ✅ `SameSite`를 `Strict`에서 `Lax`로 변경 -- ✅ `refresh_token`도 동일하게 적용 - ---- - -### 2. `src/app/api/auth/check/route.ts` - -**수정 위치**: 75-95 라인 (토큰 갱신 시) - -```typescript -// ✅ 수정 코드 -if (refreshResponse.ok) { - const data = await refreshResponse.json(); - - // Safari compatibility: Secure only in production - const isProduction = process.env.NODE_ENV === 'production'; - - const accessTokenCookie = [ - `access_token=${data.access_token}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - `Max-Age=${data.expires_in || 7200}`, - ].join('; '); - - const refreshTokenCookie = [ - `refresh_token=${data.refresh_token}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=604800', - ].join('; '); - - // ... 쿠키 설정 -} -``` - -**변경 사항**: -- ✅ 토큰 갱신 시에도 동일한 쿠키 설정 적용 -- ✅ login/route.ts와 일관성 유지 - ---- - -### 3. `src/app/api/auth/logout/route.ts` - -**수정 위치**: 52-71 라인 (쿠키 삭제) - -```typescript -// ❌ 기존 코드 (Safari에서 쿠키 삭제 실패) -const clearAccessToken = [ - 'access_token=', - 'HttpOnly', - 'Secure', // 설정 시와 속성 불일치 - 'SameSite=Strict', // 설정 시와 속성 불일치 - 'Path=/', - 'Max-Age=0', -].join('; '); -``` - -```typescript -// ✅ 수정 코드 (Safari에서 쿠키 삭제 성공) -// Safari compatibility: Must use same attributes as when setting cookies -const isProduction = process.env.NODE_ENV === 'production'; - -const clearAccessToken = [ - 'access_token=', - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), // ✅ login과 동일 - 'SameSite=Lax', // ✅ login과 동일 - 'Path=/', - 'Max-Age=0', // Delete immediately -].join('; '); - -const clearRefreshToken = [ - 'refresh_token=', - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=0', -].join('; '); -``` - -**변경 사항**: -- ✅ 쿠키 삭제 시 설정 시와 **정확히 동일한 속성** 사용 -- ✅ Safari의 엄격한 쿠키 삭제 정책 대응 - ---- - -## 크로스 브라우저 개발 가이드라인 - -### 필수 테스트 브라우저 - -모든 브라우저 관련 기능 개발 시 **다음 브라우저에서 반드시 테스트**: - -| 브라우저 | 우선순위 | 주요 특징 | 테스트 환경 | -|---------|---------|----------|------------| -| **Chrome** | 🔴 High | 가장 관대한 정책 | macOS/Windows | -| **Safari** | 🔴 High | 가장 엄격한 정책 | macOS/iOS | -| **Firefox** | 🟡 Medium | 중간 수준 정책 | macOS/Windows | -| **Edge** | 🟢 Low | Chrome 기반 | Windows | - -**개발 우선순위**: Safari 기준으로 개발하면 다른 브라우저에서도 작동합니다. - ---- - -### 쿠키 관련 개발 원칙 - -#### 1. 환경별 조건부 설정 -```typescript -// ✅ 항상 환경 체크 -const isProduction = process.env.NODE_ENV === 'production'; -const isSecure = isProduction; // HTTPS 여부 - -// ✅ Secure 속성은 항상 조건부로 -...(isSecure ? ['Secure'] : []) -``` - -#### 2. HttpOnly는 항상 유지 -```typescript -// ✅ XSS 공격 방지를 위해 HttpOnly는 항상 포함 -'HttpOnly', // 절대 제거하지 말 것 -``` - -#### 3. SameSite는 Lax 권장 -```typescript -// ✅ CSRF 보호 + 유연성 -'SameSite=Lax', // 대부분의 웹 앱에 적합 - -// ⚠️ Strict는 너무 엄격 -'SameSite=Strict', // 특별한 이유가 있을 때만 사용 -``` - -#### 4. 쿠키 삭제 시 속성 일치 -```typescript -// ✅ 설정할 때와 삭제할 때 속성이 정확히 일치해야 함 -const setCookie = 'token=xxx; HttpOnly; SameSite=Lax'; -const deleteCookie = 'token=; HttpOnly; SameSite=Lax; Max-Age=0'; -``` - ---- - -### 로컬스토리지 vs 쿠키 선택 가이드 - -| 저장소 | 용도 | 보안 | Safari 호환성 | -|--------|------|------|---------------| -| **HttpOnly Cookie** | 인증 토큰 | ✅ 높음 (XSS 방지) | ✅ 조건부 설정 필요 | -| **LocalStorage** | 사용자 정보, 설정 | ⚠️ 낮음 (XSS 취약) | ✅ 호환성 좋음 | - -**원칙**: 민감한 데이터(토큰)는 HttpOnly 쿠키, 일반 데이터는 LocalStorage - ---- - -### Safari 개발 시 주의사항 - -#### 1. 쿠키 관련 -- ✅ HTTP 환경에서 `Secure` 속성 제거 -- ✅ 쿠키 설정과 삭제 시 속성 일치 -- ✅ `SameSite=Lax` 사용 권장 - -#### 2. 네트워크 요청 -```typescript -// ✅ Safari는 credentials 설정에 민감 -fetch('/api/auth/check', { - method: 'GET', - credentials: 'include', // Safari에서 쿠키 전송 필수 -}); -``` - -#### 3. 로컬스토리지 -```typescript -// ✅ Safari Private Mode에서 localStorage 제한 -try { - localStorage.setItem('key', 'value'); -} catch (error) { - // Safari Private Mode 대응 - console.warn('LocalStorage unavailable:', error); -} -``` - -#### 4. 날짜/시간 -```typescript -// ❌ Safari에서 파싱 실패 가능 -new Date('2024-01-01 12:00:00'); - -// ✅ ISO 8601 형식 사용 -new Date('2024-01-01T12:00:00Z'); -``` - ---- - -### 크로스 브라우저 테스트 도구 - -#### 개발 환경 테스트 -```bash -# Chrome -open -a "Google Chrome" http://localhost:3000 - -# Safari -open -a Safari http://localhost:3000 - -# Firefox -open -a Firefox http://localhost:3000 -``` - -#### 개발자 도구 활용 -```javascript -// Safari: Develop → Show Web Inspector → Storage -// Chrome: DevTools → Application → Cookies -// Firefox: DevTools → Storage → Cookies - -// 쿠키 확인 사항: -// - Name: access_token, refresh_token -// - HttpOnly: ✅ 체크 -// - Secure: 환경에 따라 조건부 -// - SameSite: Lax -``` - ---- - -## 테스트 체크리스트 - -### 로그인 기능 테스트 - -#### Chrome -- [ ] 로그인 성공 -- [ ] 대시보드 접근 가능 -- [ ] 쿠키 저장 확인 (DevTools → Application → Cookies) -- [ ] HttpOnly 속성 확인 -- [ ] 로그아웃 성공 -- [ ] 쿠키 삭제 확인 - -#### Safari -- [ ] 로그인 성공 -- [ ] 대시보드 접근 가능 -- [ ] 쿠키 저장 확인 (Web Inspector → Storage → Cookies) -- [ ] HttpOnly 속성 확인 -- [ ] Secure 속성 **없음** 확인 (개발 환경) -- [ ] 로그아웃 성공 -- [ ] 쿠키 삭제 확인 - -#### Firefox (선택) -- [ ] 로그인 성공 -- [ ] 대시보드 접근 가능 -- [ ] 쿠키 저장 확인 -- [ ] 로그아웃 성공 - ---- - -### 인증 상태 확인 테스트 - -#### 시나리오 1: 페이지 새로고침 -- [ ] Chrome: 로그인 상태 유지 -- [ ] Safari: 로그인 상태 유지 -- [ ] Firefox: 로그인 상태 유지 - -#### 시나리오 2: 브라우저 재시작 -- [ ] Chrome: 로그인 상태 유지 (Remember me) -- [ ] Safari: 로그인 상태 유지 -- [ ] Firefox: 로그인 상태 유지 - -#### 시나리오 3: 토큰 만료 -- [ ] Chrome: 자동 토큰 갱신 -- [ ] Safari: 자동 토큰 갱신 -- [ ] Firefox: 자동 토큰 갱신 - ---- - -### 프로덕션 배포 전 체크리스트 - -#### 환경 설정 -- [ ] `NODE_ENV=production` 설정 확인 -- [ ] HTTPS 인증서 설정 완료 -- [ ] 환경 변수 `.env.production` 확인 - -#### 쿠키 설정 확인 -- [ ] Production 환경에서 `Secure` 속성 포함 확인 -- [ ] `HttpOnly` 속성 유지 확인 -- [ ] `SameSite=Lax` 설정 확인 -- [ ] `Max-Age` 적절히 설정 (access: 2h, refresh: 7d) - -#### 브라우저 테스트 (HTTPS) -- [ ] Chrome: 로그인/로그아웃 정상 -- [ ] Safari: 로그인/로그아웃 정상 -- [ ] Firefox: 로그인/로그아웃 정상 -- [ ] Safari iOS: 모바일 테스트 - ---- - -## 문제 해결 가이드 - -### 쿠키가 저장되지 않는 경우 - -#### 1. Safari 개발 환경 -```typescript -// 체크 포인트: -// ✅ Secure 속성이 조건부로 설정되어 있는가? -...(isProduction ? ['Secure'] : []) - -// ✅ SameSite가 Lax인가? -'SameSite=Lax' - -// ✅ HttpOnly는 포함되어 있는가? -'HttpOnly' -``` - -#### 2. Safari Private Mode -Safari Private Mode에서는 일부 쿠키가 제한될 수 있습니다. -→ 일반 모드에서 테스트하세요. - -#### 3. 쿠키 도메인 설정 -```typescript -// ✅ localhost에서는 Domain 속성 생략 -// ❌ 'Domain=localhost' (불필요) -``` - ---- - -### 쿠키가 삭제되지 않는 경우 - -#### Safari 로그아웃 문제 -```typescript -// ❌ 설정 시와 삭제 시 속성 불일치 -// 설정: HttpOnly + SameSite=Lax -// 삭제: HttpOnly + Secure + SameSite=Strict - -// ✅ 설정 시와 삭제 시 속성 일치 -const isProduction = process.env.NODE_ENV === 'production'; -const cookie = [ - 'token=', - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), // 일치 - 'SameSite=Lax', // 일치 - 'Max-Age=0', -].join('; '); -``` - ---- - -## 관련 문서 - -- [MDN - HTTP Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) -- [MDN - SameSite Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) -- [Safari Cookie Policy](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/) - ---- - -## 업데이트 히스토리 - -| 날짜 | 내용 | 작성자 | -|------|------|--------| -| 2024-XX-XX | Safari 쿠키 호환성 문서 작성 | Claude | - ---- - -**📌 기억하세요**: 브라우저 관련 기능 개발 시 **Safari를 기준으로 개발**하면 다른 브라우저에서도 작동합니다! \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md b/claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md deleted file mode 100644 index 1ecc52ef..00000000 --- a/claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md +++ /dev/null @@ -1,74 +0,0 @@ -# MVP 회원가입 페이지 차단 - -> **날짜**: 2025-12-04 -> **상태**: 완료 -> **목적**: MVP 버전에서 회원가입 접근 차단 (운영 페이지로 이동 예정) - ---- - -## 변경 사항 - -### 1. auth-config.ts -**파일**: `src/lib/api/auth/auth-config.ts` - -```typescript -// Before -guestOnlyRoutes: ['/login', '/signup', '/forgot-password'] - -// After -guestOnlyRoutes: ['/login', '/forgot-password'] -``` - -- `/signup`을 guestOnlyRoutes에서 제거 -- 주석에 변경 이유 기록 - -### 2. LoginPage.tsx -**파일**: `src/components/auth/LoginPage.tsx` - -| 제거된 요소 | 위치 | -|------------|------| -| 헤더 회원가입 버튼 | Line 188 (이전) | -| "계정 만들기" 버튼 | Line 304-310 (이전) | -| 하단 회원가입 링크 | Line 314-325 (이전) | - -- 총 3개의 회원가입 관련 UI 요소 제거 -- 주석으로 제거 이유 기록 - -### 3. middleware.ts -**파일**: `src/middleware.ts` - -```typescript -// 4.5️⃣ MVP: /signup 접근 차단 → /login 리다이렉트 (2025-12-04) -if (pathnameWithoutLocale === '/signup' || pathnameWithoutLocale.startsWith('/signup/')) { - console.log(`[Signup Blocked] Redirecting to /login from ${pathname}`); - return NextResponse.redirect(new URL('/login', request.url)); -} -``` - -- URL 직접 접근 시 `/login`으로 리다이렉트 -- 로그 출력으로 접근 시도 추적 가능 - ---- - -## 유지된 파일 (삭제 안함) - -| 파일 | 이유 | -|------|------| -| `src/app/[locale]/signup/page.tsx` | 운영 페이지에서 재사용 예정 | -| `src/app/api/auth/signup/route.ts` | API 로직 재사용 예정 | - ---- - -## 테스트 체크리스트 - -- [ ] 로그인 페이지에서 회원가입 링크 없음 -- [ ] `/signup` URL 직접 접근 시 `/login`으로 리다이렉트 -- [ ] `/ko/signup` (로케일 포함) 접근 시 `/login`으로 리다이렉트 -- [ ] 기존 로그인 기능 정상 동작 - ---- - -## 향후 작업 - -- 회원가입 기능을 운영 페이지(관리자용)로 이동 -- 운영 페이지에서 사용자 등록 기능 구현 diff --git a/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md b/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md deleted file mode 100644 index f4bbaa69..00000000 --- a/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md +++ /dev/null @@ -1,512 +0,0 @@ -# Token Refresh Caching 구현 문서 - -> 작성일: 2025-12-30 -> 상태: 완료 - -## 1. 문제 상황 - -### 1.1 증상 -페이지 로드 시 여러 API 호출이 동시에 발생할 때, 일부 요청이 401 에러와 함께 실패하고 로그인 페이지로 리다이렉트되는 현상. - -### 1.2 원인 분석 -`useEffect`에서 여러 API를 동시에 호출할 때 **refresh_token 충돌** 발생: - -``` -시간 → -──────────────────────────────────────────────────────────────────── -[요청 A] access_token 만료 → 401 → refresh_token 사용 → ✅ 새 토큰 발급 (기존 refresh_token 폐기) -[요청 B] access_token 만료 → 401 → refresh_token 사용 → ❌ 실패 (이미 폐기된 토큰) -[요청 C] access_token 만료 → 401 → refresh_token 사용 → ❌ 실패 (이미 폐기된 토큰) -──────────────────────────────────────────────────────────────────── -``` - -**핵심 문제**: refresh_token은 일회용(One-Time Use)이므로, 첫 번째 요청이 사용하면 즉시 폐기됨. - -### 1.3 영향 범위 -- **Proxy 경로** (`/api/proxy/*`): 클라이언트 → Next.js → PHP 백엔드 -- **Server Actions** (`serverFetch`): Server Component에서 직접 API 호출 - ---- - -## 2. 해결 방법: Request Coalescing (요청 병합) 패턴 - -### 2.1 패턴 설명 -동시에 발생하는 동일한 요청을 하나로 병합하여 처리하는 표준 패턴. - -``` -시간 → -──────────────────────────────────────────────────────────────────── -[요청 A] 401 → refresh 시작 (Promise 생성) → ✅ 새 토큰 → 캐시 저장 -[요청 B] 401 → 캐시된 Promise 대기 ────────→ ✅ 같은 새 토큰 사용 -[요청 C] 401 → 캐시된 Promise 대기 ────────→ ✅ 같은 새 토큰 사용 -──────────────────────────────────────────────────────────────────── -``` - -### 2.2 구현 특징 -- **5초 캐싱**: refresh 결과를 5초간 캐시 -- **Promise 공유**: 진행 중인 refresh Promise를 여러 요청이 공유 -- **모듈 레벨 캐시**: Proxy와 serverFetch가 동일한 캐시 공유 - ---- - -## 3. 구현 코드 - -### 3.1 파일 구조 -``` -src/lib/api/ -├── refresh-token.ts # 🆕 공통 토큰 갱신 모듈 (캐싱 로직 포함) -├── fetch-wrapper.ts # serverFetch (import from refresh-token) -└── errors.ts # 에러 타입 정의 - -src/app/api/proxy/ -└── [...path]/route.ts # Proxy (import from refresh-token) - -src/app/api/auth/ -├── check/route.ts # 🔧 인증 확인 API (2026-01-08 통합) -└── refresh/route.ts # 🔧 토큰 갱신 API (2026-01-08 통합) -``` - -### 3.2 공통 모듈: `refresh-token.ts` - -```typescript -/** - * 🔄 Refresh Token 공통 모듈 - * - * 문제: useEffect에서 여러 API 동시 호출 시 refresh_token 충돌 - * 해결: 5초간 refresh 결과 캐싱 + Promise 공유 - */ - -export type RefreshResult = { - success: boolean; - accessToken?: string; - refreshToken?: string; - expiresIn?: number; -}; - -// 캐시 상태 (모듈 레벨에서 공유) -let refreshCache: { - promise: Promise | null; - timestamp: number; - result: RefreshResult | null; -} = { - promise: null, - timestamp: 0, - result: null, -}; - -const REFRESH_CACHE_TTL = 5000; // 5초 - -/** - * 실제 토큰 갱신 수행 (내부 함수) - */ -async function doRefreshToken(refreshToken: string): Promise { - try { - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-API-KEY': process.env.API_KEY || '', - }, - body: JSON.stringify({ refresh_token: refreshToken }), - }); - - if (!response.ok) { - return { success: false }; - } - - const data = await response.json(); - return { - success: true, - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in, - }; - } catch (error) { - console.error('🔴 [RefreshToken] Token refresh error:', error); - return { success: false }; - } -} - -/** - * 토큰 갱신 함수 (5초 캐싱 적용) - * - * 동시 요청 시: - * 1. 캐시된 결과가 있으면 즉시 반환 - * 2. 진행 중인 refresh가 있으면 그 Promise를 기다림 - * 3. 둘 다 없으면 새 refresh 시작 - */ -export async function refreshAccessToken( - refreshToken: string, - caller: string = 'unknown' -): Promise { - const now = Date.now(); - - // 1. 캐시된 결과가 유효하면 즉시 반환 - if (refreshCache.result?.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) { - console.log(`🔵 [${caller}] Using cached refresh result`); - return refreshCache.result; - } - - // 2. 진행 중인 refresh가 있으면 그 결과를 기다림 - if (refreshCache.promise && now - refreshCache.timestamp < REFRESH_CACHE_TTL) { - console.log(`🔵 [${caller}] Waiting for ongoing refresh...`); - return refreshCache.promise; - } - - // 3. 새 refresh 시작 - console.log(`🔄 [${caller}] Starting new refresh request...`); - refreshCache.timestamp = now; - refreshCache.result = null; - - refreshCache.promise = doRefreshToken(refreshToken).then(result => { - refreshCache.result = result; - return result; - }); - - return refreshCache.promise; -} -``` - -### 3.3 사용 예시 - -**Proxy에서 사용:** -```typescript -// src/app/api/proxy/[...path]/route.ts -import { refreshAccessToken } from '@/lib/api/refresh-token'; - -// 401 응답 시 -const refreshResult = await refreshAccessToken(refreshToken, 'PROXY'); -``` - -**serverFetch에서 사용:** -```typescript -// src/lib/api/fetch-wrapper.ts -import { refreshAccessToken } from './refresh-token'; - -// 401 응답 시 -const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch'); -``` - ---- - -## 4. 시행착오 기록 - -### 4.1 초기 문제: 중복 구현 -처음에는 Proxy와 serverFetch에서 각각 캐싱 로직을 별도로 구현했음. - -**문제점:** -- 코드 중복 (~80줄씩) -- 두 캐시가 분리되어 있어 비효율적 -- 유지보수 어려움 - -**해결:** 공통 모듈 `refresh-token.ts`로 통합 - -### 4.2 빌드 오류: .next 폴더 손상 -``` -Error: Cannot find module './4586.js' -``` - -**원인:** 이전 빌드 아티팩트와 새 코드 간 충돌 - -**해결:** -```bash -rm -rf .next -npm run build -``` - -### 4.3 런타임 오류: app-paths-manifest.json 누락 -``` -500 Error: .next/server/app-paths-manifest.json not found -``` - -**원인:** 빌드 중 .next 폴더 손상 - -**해결:** -```bash -rm -rf .next -npm run dev -``` - -### 4.4 Safari 호환성 문제 (이전 세션에서 해결) -Safari에서 `SameSite=Strict` + `Secure` 조합이 localhost에서 쿠키 저장 실패. - -**해결:** -- `SameSite=Strict` → `SameSite=Lax` -- `Secure`는 프로덕션에서만 적용 - ---- - -## 5. 동작 흐름도 - -### 5.1 정상 흐름 (토큰 유효) -``` -클라이언트 → Proxy/serverFetch → API 요청 → 200 OK → 응답 반환 -``` - -### 5.2 토큰 갱신 흐름 (단일 요청) -``` -클라이언트 → Proxy/serverFetch → API 요청 → 401 - ↓ - refreshAccessToken() - ↓ - 새 토큰 발급 + 쿠키 저장 - ↓ - 원래 요청 재시도 → 200 OK -``` - -### 5.3 토큰 갱신 흐름 (동시 요청 - 캐싱 적용) -``` -[요청 A] → 401 → refreshAccessToken() → 새 refresh 시작 ──┐ -[요청 B] → 401 → refreshAccessToken() → Promise 대기 ────┼→ 같은 새 토큰 공유 -[요청 C] → 401 → refreshAccessToken() → Promise 대기 ────┘ - ↓ - 각자 원래 요청 재시도 -``` - ---- - -## 6. 설정 값 - -| 항목 | 값 | 설명 | -|------|-----|------| -| REFRESH_CACHE_TTL | 5초 | refresh 결과 캐시 유지 시간 | -| access_token Max-Age | 7200초 (2시간) | API에서 전달받은 값 사용 | -| refresh_token Max-Age | 604800초 (7일) | 장기 보관 | - ---- - -## 7. 로그 메시지 - -### 7.1 캐시 히트 (이미 갱신된 토큰 재사용) -``` -🔵 [PROXY] Using cached refresh result (age: 1234ms) -🔵 [serverFetch] Using cached refresh result (age: 1234ms) -``` - -### 7.2 대기 중 (다른 요청이 갱신 중) -``` -🔵 [PROXY] Waiting for ongoing refresh... -🔵 [serverFetch] Waiting for ongoing refresh... -``` - -### 7.3 새 갱신 시작 -``` -🔄 [PROXY] Starting new refresh request... -🔄 [serverFetch] Starting new refresh request... -✅ [RefreshToken] Token refreshed successfully -``` - -### 7.4 갱신 실패 -``` -🔴 [RefreshToken] Token refresh failed: { status: 401, ... } -``` - ---- - -## 8. 관련 파일 - -| 파일 | 역할 | 통합일 | -|------|------|--------| -| `src/lib/api/refresh-token.ts` | 공통 토큰 갱신 모듈 (캐싱 로직) | 2025-12-30 | -| `src/lib/api/fetch-wrapper.ts` | Server Actions용 fetch wrapper | 2025-12-30 | -| `src/lib/utils/redirect-error.ts` | Next.js redirect 에러 감지 유틸리티 | 2026-01-08 | -| `src/app/api/proxy/[...path]/route.ts` | 클라이언트 API 프록시 | 2025-12-30 | -| `src/app/api/auth/login/route.ts` | 로그인 및 초기 토큰 설정 | - | -| `src/app/api/auth/check/route.ts` | 인증 상태 확인 API | 2026-01-08 | -| `src/app/api/auth/refresh/route.ts` | 토큰 갱신 프록시 API | 2026-01-08 | - ---- - -## 9. 이 패턴이 "편법"이 아닌 이유 - -### 9.1 업계 표준 패턴 -- **Request Coalescing / Request Deduplication**: 공식 명칭 -- React Query, SWR, Apollo Client 등에서 동일 패턴 사용 -- CDN (Cloudflare, Fastly)에서도 동일 원리 적용 - -### 9.2 설계 원칙 준수 -- **DRY**: 중복 요청 제거 -- **효율성**: 서버 부하 감소 -- **일관성**: 모든 요청이 같은 새 토큰 사용 - -### 9.3 향후 위험성 없음 -- 5초 TTL은 충분히 짧아 토큰 갱신 지연 문제 없음 -- 실패 시 다음 요청에서 새로 갱신 시도 -- 캐시는 메모리 기반이라 서버 재시작 시 자동 초기화 - ---- - -## 10. 업데이트 이력 - -### 10.0 [2026-01-15] 미들웨어 사전 갱신 기능 추가 - -**관련 문서:** `[IMPL-2026-01-15] middleware-pre-refresh.md` - -Request Coalescing 패턴만으로는 auth/check + serverFetch 동시 호출 시 Race Condition이 완전히 해결되지 않아, **미들웨어에서 페이지 렌더링 전 토큰을 미리 갱신**하는 기능 추가. - -두 기능은 상호 보완적: -- **미들웨어 사전 갱신**: 페이지 로드 전 토큰 준비 (1차 방어) -- **Request Coalescing**: API 호출 시 401 발생 시 중복 갱신 방지 (2차 방어) - -### 10.1 [2026-01-08] 누락된 API 라우트 통합 - -**문제 발견:** -`/api/auth/check`와 `/api/auth/refresh` 라우트가 공유 캐시를 사용하지 않고 자체 fetch 로직을 사용하고 있었음. - -**증상:** -``` -🔍 Refresh API response status: 401 -❌ Refresh API failed: 401 {"error":"리프레시 토큰이 유효하지 않거나 만료되었습니다","error_code":"TOKEN_EXPIRED"} -⚠️ Returning 401 due to refresh failure -GET /api/auth/check 401 -``` - -**원인:** -1. `serverFetch`에서 refresh 성공 → Token Rotation으로 이전 refresh_token 폐기 -2. `/api/auth/check`가 동시에 호출됨 -3. 자체 fetch 로직으로 이미 폐기된 토큰 사용 시도 → 실패 → 로그인 페이지 이동 - -**해결:** -두 파일 모두 `refreshAccessToken()` 공유 함수를 사용하도록 수정: - -```typescript -// src/app/api/auth/check/route.ts -import { refreshAccessToken } from '@/lib/api/refresh-token'; - -const refreshResult = await refreshAccessToken(refreshToken, 'auth/check'); -``` - -```typescript -// src/app/api/auth/refresh/route.ts -import { refreshAccessToken } from '@/lib/api/refresh-token'; - -const refreshResult = await refreshAccessToken(refreshToken, 'api/auth/refresh'); -``` - -**결과:** -모든 refresh 경로가 동일한 5초 캐시를 공유하여 Token Rotation 충돌 방지. - -### 10.2 [2026-01-08] 53개 Server Actions 파일 수정 - -**문제:** -`redirect('/login')` 호출 시 발생하는 `NEXT_REDIRECT` 에러가 catch 블록에서 잡혀 `{ success: false }` 반환 → 무한 루프 - -**해결:** -모든 actions.ts 파일에 `isRedirectError` 처리 추가: - -```typescript -import { isRedirectError } from 'next/dist/client/components/redirect'; - -} catch (error) { - if (isRedirectError(error)) throw error; - // ... 기존 에러 처리 -} -``` - -### 10.3 [2026-01-08] refresh 실패 결과 캐시 버그 수정 - -**문제:** -refresh 실패 결과도 5초간 캐시되어, 후속 요청들이 모두 실패 결과를 받음. - -**해결:** -`refresh-token.ts`에서 성공한 결과만 캐시하도록 수정: - -```typescript -// 1. 캐시된 성공 결과가 유효하면 즉시 반환 -if (refreshCache.result && refreshCache.result.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) { - return refreshCache.result; -} - -// 2-1. 이전 refresh가 실패했으면 캐시 초기화 -if (refreshCache.result && !refreshCache.result.success) { - refreshCache.promise = null; - refreshCache.result = null; -} -``` - -### 10.4 [2026-01-08] isRedirectError 자체 유틸리티 함수로 변경 - -**문제:** -Next.js 내부 경로(`next/dist/client/components/redirect`)가 버전 15에서 `redirect-error`로 변경됨. -내부 경로 의존 시 Next.js 업데이트마다 수정 필요. - -**해결:** -자체 유틸리티 함수 생성하여 Next.js 내부 경로 의존성 제거: - -```typescript -// src/lib/utils/redirect-error.ts -export function isNextRedirectError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'digest' in error && - typeof (error as { digest: string }).digest === 'string' && - (error as { digest: string }).digest.startsWith('NEXT_REDIRECT') - ); -} -``` - -**장점:** -- Next.js 버전 업데이트에 영향 안 받음 -- 내부 경로 의존성 제거 -- 한 곳에서 관리 가능 - ---- - -## 11. 신규 Server Actions 개발 가이드 - -### 11.1 필수 패턴 - -새로운 `actions.ts` 파일 생성 시 반드시 아래 패턴을 따라야 합니다: - -```typescript -'use server'; - -import { isNextRedirectError } from '@/lib/utils/redirect-error'; -import { serverFetch } from '@/lib/api/fetch-wrapper'; - -export async function someAction(params: SomeParams): Promise { - try { - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/some-endpoint`; - - const { response, error } = await serverFetch(url, { - method: 'GET', // 또는 POST, PUT, DELETE - }); - - if (error || !response) { - return { success: false, error: error?.message || '요청 실패' }; - } - - const data = await response.json(); - return { success: true, data }; - - } catch (error) { - // ⚠️ 필수: redirect 에러는 다시 throw해야 함 - if (isNextRedirectError(error)) throw error; - - console.error('[SomeAction] error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; - } -} -``` - -### 11.2 왜 isNextRedirectError 처리가 필수인가? - -``` -serverFetch에서 401 응답 시: -1. refresh_token으로 토큰 갱신 시도 -2. 갱신 실패 시 redirect('/login') 호출 -3. redirect()는 NEXT_REDIRECT 에러를 throw -4. 이 에러가 catch에서 잡히면 → { success: false } 반환 → 무한 루프 -5. 이 에러를 다시 throw하면 → Next.js가 정상 리다이렉트 처리 -``` - -### 11.3 체크리스트 - -새 actions.ts 파일 생성 시: - -- [ ] `import { isNextRedirectError } from '@/lib/utils/redirect-error';` 추가 -- [ ] `import { serverFetch } from '@/lib/api/fetch-wrapper';` 사용 -- [ ] 모든 catch 블록에 `if (isNextRedirectError(error)) throw error;` 추가 -- [ ] 파일 내 모든 export 함수에 동일 패턴 적용 \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md b/claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md deleted file mode 100644 index 8d56ae6a..00000000 --- a/claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md +++ /dev/null @@ -1,424 +0,0 @@ -# 미들웨어 토큰 사전 갱신 (Pre-Refresh) 구현 문서 - -> 작성일: 2026-01-15 -> 상태: 완료 - -## 1. 문제 상황 - -### 1.1 기존 Request Coalescing 패턴의 한계 - -`refresh-token.ts`의 5초 캐싱 패턴으로 동시 API 호출 시 중복 갱신은 방지했지만, **auth/check + serverFetch 동시 호출** 문제가 완전히 해결되지 않았음. - -### 1.2 Race Condition 시나리오 - -``` -페이지 로드 시 (access_token 만료, refresh_token만 있는 상태) - -시간 → -──────────────────────────────────────────────────────────────────── -[페이지 렌더링 시작] - ↓ -[useEffect] → auth/check 호출 ─────┐ -[Server Component] → serverFetch ──┼─→ 둘 다 refresh_token 필요 - ↓ - 첫 번째가 갱신하면 두 번째는? - (캐시 공유해도 타이밍 문제 발생 가능) -──────────────────────────────────────────────────────────────────── -``` - -### 1.3 증상 -- 페이지 로드 시 간헐적으로 401 에러 -- 토큰 만료 직후 첫 페이지 접속 시 로그인 페이지로 튕김 -- 콘솔에 `Token refresh failed` 로그 - ---- - -## 2. 해결 방법: 미들웨어 사전 갱신 (Pre-Refresh) - -### 2.1 핵심 아이디어 - -**페이지 렌더링 전에 미들웨어에서 토큰을 미리 갱신**하여, 페이지 로드 시 모든 API 호출이 이미 갱신된 access_token을 사용하도록 함. - -``` -시간 → -──────────────────────────────────────────────────────────────────── -[브라우저 요청] → [미들웨어 7.5단계] - ↓ - access_token 없고 refresh_token만 있음? - ↓ YES - 백엔드 /api/v1/refresh 호출 (1회) - ↓ - Set-Cookie: access_token, refresh_token - ↓ -[페이지 렌더링] → auth/check, serverFetch 모두 새 access_token 사용 - ↓ - ✅ Race Condition 없음 -──────────────────────────────────────────────────────────────────── -``` - -### 2.2 기존 패턴과의 관계 - -| 기능 | 목적 | 실행 시점 | 파일 | -|------|------|----------|------| -| **Request Coalescing** | 동시 API 호출 시 refresh 중복 방지 | API 호출 시 401 응답 후 | `refresh-token.ts` | -| **미들웨어 사전 갱신** | 페이지 로드 전 토큰 준비 | 미들웨어 실행 시 | `middleware.ts` | - -두 기능은 **상호 보완적**: -- 미들웨어가 사전 갱신하면 대부분의 경우 API 호출 시 401이 발생하지 않음 -- 만약 미들웨어 이후 토큰이 만료되면 Request Coalescing이 백업으로 동작 - ---- - -## 3. 구현 코드 - -### 3.1 파일 위치 -``` -src/middleware.ts -``` - -### 3.2 추가된 코드 구조 - -```typescript -// 1. 캐시 객체 (모듈 레벨) -let middlewareRefreshCache: { - promise: Promise | null; - timestamp: number; - result: RefreshResult | null; -} = { promise: null, timestamp: 0, result: null }; - -const MIDDLEWARE_REFRESH_CACHE_TTL = 5000; // 5초 - -// 2. checkAuthentication() 확장 -function checkAuthentication(request: NextRequest): { - isAuthenticated: boolean; - authMode: 'sanctum' | 'bearer' | 'api-key' | null; - needsRefresh: boolean; // 🆕 access_token 없고 refresh_token만 있음 - refreshToken: string | null; // 🆕 갱신에 사용할 토큰 -} - -// 3. refreshTokenInMiddleware() 함수 -async function refreshTokenInMiddleware(refreshToken: string): Promise - -// 4. middleware() 함수 내 7.5단계 -export async function middleware(request: NextRequest) { - // ... 기존 1~7단계 ... - - // 7.5단계: 토큰 사전 갱신 - if (needsRefresh && refreshToken) { - const refreshResult = await refreshTokenInMiddleware(refreshToken); - // Set-Cookie로 새 토큰 설정 - } - - // ... 기존 8~10단계 ... -} -``` - -### 3.3 checkAuthentication() 반환값 변경 - -**변경 전:** -```typescript -return { - isAuthenticated: boolean; - authMode: 'sanctum' | 'bearer' | 'api-key' | null; -} -``` - -**변경 후:** -```typescript -return { - isAuthenticated: boolean; - authMode: 'sanctum' | 'bearer' | 'api-key' | null; - needsRefresh: boolean; // access_token 없고 refresh_token만 있으면 true - refreshToken: string | null; // 갱신에 사용할 refresh_token 값 -} -``` - -### 3.4 7.5단계 사전 갱신 로직 - -```typescript -// 7️⃣.5️⃣ 🔄 토큰 사전 갱신 (Race Condition 방지) -if (needsRefresh && refreshToken) { - console.log(`🔄 [Middleware] Pre-refreshing token before page render: ${pathname}`); - - const refreshResult = await refreshTokenInMiddleware(refreshToken); - - if (refreshResult.success && refreshResult.accessToken) { - const isProduction = process.env.NODE_ENV === 'production'; - const intlResponse = intlMiddleware(request); - - // Set-Cookie 헤더로 새 토큰 전송 - const accessTokenCookie = [ - `access_token=${refreshResult.accessToken}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - `Max-Age=${refreshResult.expiresIn || 7200}`, - ].join('; '); - - const refreshTokenCookie = [ - `refresh_token=${refreshResult.refreshToken}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=604800', // 7 days (하드코딩) - ].join('; '); - - intlResponse.headers.append('Set-Cookie', accessTokenCookie); - intlResponse.headers.append('Set-Cookie', refreshTokenCookie); - // ... 기타 쿠키 ... - - return intlResponse; - } else { - // 갱신 실패 시 로그인 페이지로 - return NextResponse.redirect(new URL('/login', request.url)); - } -} -``` - ---- - -## 4. 동작 흐름도 - -### 4.1 정상 흐름 (access_token 유효) - -``` -브라우저 → 미들웨어 → checkAuthentication() - ↓ - needsRefresh = false (access_token 있음) - ↓ - 7.5단계 스킵 → 페이지 렌더링 -``` - -### 4.2 사전 갱신 흐름 (access_token 만료, refresh_token 유효) - -``` -브라우저 → 미들웨어 → checkAuthentication() - ↓ - needsRefresh = true (access_token 없음, refresh_token 있음) - ↓ - 7.5단계: refreshTokenInMiddleware() 호출 - ↓ - 백엔드 /api/v1/refresh → 새 토큰 발급 - ↓ - Set-Cookie: access_token, refresh_token - ↓ - 페이지 렌더링 (새 토큰으로) -``` - -### 4.3 갱신 실패 흐름 (refresh_token도 만료) - -``` -브라우저 → 미들웨어 → checkAuthentication() - ↓ - needsRefresh = true - ↓ - 7.5단계: refreshTokenInMiddleware() 호출 - ↓ - 백엔드 → 401 (refresh_token 만료) - ↓ - redirect('/login') -``` - ---- - -## 5. 설정 값 - -| 항목 | 값 | 설명 | -|------|-----|------| -| MIDDLEWARE_REFRESH_CACHE_TTL | 5초 | 미들웨어 캐시 유지 시간 | -| access_token Max-Age | 7200초 (2시간) | 백엔드 expires_in 값 또는 기본값 | -| refresh_token Max-Age | 604800초 (7일) | 하드코딩 (백엔드에서 미제공) | - ---- - -## 6. 로그 메시지 - -### 6.1 사전 갱신 시작 -``` -🔄 [Middleware] Pre-refreshing token before page render: /dashboard -``` - -### 6.2 캐시 히트 -``` -🔵 [Middleware] Using cached refresh result (age: 1234ms) -``` - -### 6.3 진행 중인 갱신 대기 -``` -🔵 [Middleware] Waiting for ongoing refresh... -``` - -### 6.4 갱신 성공 -``` -✅ [Middleware] Pre-refresh successful -✅ [Middleware] Pre-refresh complete, new tokens set in cookies -``` - -### 6.5 갱신 실패 -``` -🔴 [Middleware] Pre-refresh failed: 401 -🔴 [Middleware] Pre-refresh failed, redirecting to login -``` - ---- - -## 7. Edge Runtime 고려사항 - -### 7.1 모듈 레벨 캐시의 한계 - -Edge Runtime에서는 모듈 레벨 변수가 **요청 간 공유되지 않을 수 있음**. -따라서 `middlewareRefreshCache`는 **같은 요청 내 중복 갱신 방지**에만 효과적. - -### 7.2 5초 캐시의 역할 - -- 같은 요청 처리 중 여러 번 호출되는 경우 방지 -- Edge 인스턴스 간 캐시 공유는 불가능 -- 충분히 짧아서 토큰 갱신 지연 문제 없음 - ---- - -## 8. 관련 파일 - -| 파일 | 역할 | -|------|------| -| `src/middleware.ts` | 미들웨어 사전 갱신 로직 | -| `src/lib/api/refresh-token.ts` | Request Coalescing 패턴 (백업) | -| `src/app/api/auth/check/route.ts` | 인증 확인 API | -| `src/app/api/auth/refresh/route.ts` | 토큰 갱신 프록시 | - ---- - -## 9. 관련 문서 - -- `[IMPL-2025-12-30] token-refresh-caching.md` - Request Coalescing 패턴 문서 -- `[IMPL-2025-11-07] middleware-issue-resolution.md` - 미들웨어 기본 구조 - ---- - -## 10. 업데이트 이력 - -### 10.1 [2026-01-15] 초기 구현 - -**배경:** -- auth/check와 serverFetch 동시 호출 시 Race Condition 발생 -- 기존 Request Coalescing만으로는 완전히 해결되지 않음 - -**구현 내용:** -1. `middlewareRefreshCache` 캐시 객체 추가 -2. `refreshTokenInMiddleware()` 함수 구현 -3. `checkAuthentication()`에 `needsRefresh`, `refreshToken` 반환 추가 -4. 7.5단계 사전 갱신 로직 추가 - -**결과:** -- 페이지 렌더링 전 토큰 갱신 완료 -- 이후 API 호출들은 새 access_token 사용 -- Race Condition 완전 해결 - -### 10.2 [2026-01-15] 파편화된 API route 통합 - -**배경:** -- `/api/menus` 등 별도 route에서 refresh 로직 없이 바로 401 반환 -- 1~2시간 방치 후 로그인 페이지로 튕기는 문제 발생 - -**수행 내용:** -1. 클라이언트 호출 경로 변경: - - `/api/menus` → `/api/proxy/menus` (menuRefresh.ts) - - `/api/files/${id}/download` → `/api/proxy/files/${id}/download` (DocumentCreate, DraftBox) -2. 파편화된 API route 삭제: - - `src/app/api/menus/` - 삭제 - - `src/app/api/files/` - 삭제 - - `src/app/api/tenants/` - 삭제 (미사용) - - `src/lib/api/php-proxy.ts` - 삭제 (중복 유틸) - -**결과:** -- 모든 API 호출이 `/api/proxy`를 통해 refresh 로직 적용 -- 토큰 만료 시 자동 갱신 후 재시도 - -### 10.3 [2026-01-15] 인증 흐름 전면 재설계 - -**배경:** -- pre-refresh 실패 시 무한 리다이렉트 루프 발생 -- 5️⃣ 게스트 전용 라우트에서 `needsRefresh` 상태를 고려하지 않음 -- `refresh_token`만 있는 상태를 "로그인됨"으로 섣부르게 판정 - -**문제의 무한 루프 시나리오:** -``` -/login 접근 (refresh_token만 있음) - ↓ -5️⃣ isAuthenticated=true (refresh_token 있으니까) → /dashboard로 리다이렉트 - ↓ -7.5️⃣ pre-refresh 시도 → 401 실패 → /login으로 리다이렉트 - ↓ -무한 반복! -``` - -**핵심 원인:** -- `refresh_token`만 있는 상태 = "로그인됨"이 아니라 "로그인 가능성 있음" -- 실제로 refresh 성공해야 "진짜 로그인" -- 5️⃣에서 이걸 확인 안 하고 바로 /dashboard로 보냄 - -**수정 내용 (5️⃣ 게스트 전용 라우트):** -```typescript -if (isGuestOnlyRoute(pathnameWithoutLocale)) { - // needsRefresh인 경우: 먼저 refresh 시도해서 "진짜 로그인"인지 확인 - if (needsRefresh && refreshToken) { - const refreshResult = await refreshTokenInMiddleware(refreshToken); - - if (refreshResult.success) { - // ✅ 진짜 로그인됨 → /dashboard로 (쿠키 설정) - return redirectToDashboard(with new cookies); - } else { - // ❌ 로그인 안 됨 → 쿠키 삭제 후 로그인 페이지 표시 (리다이렉트 없이!) - return showLoginPage(with cleared cookies); - } - } - - // access_token 있음 = 확실히 로그인됨 → /dashboard로 - if (isAuthenticated) { - return redirectToDashboard(); - } - - // 쿠키 없음 = 비로그인 → 로그인 페이지 표시 - return showLoginPage(); -} -``` - -**수정 후 흐름:** -``` -/login 접근 (refresh_token만 있음) - ↓ -5️⃣ needsRefresh=true → refresh 먼저 시도 - ↓ -├─ 성공 → "진짜 로그인" → /dashboard (왕복 1회) -└─ 실패 → "로그인 안 됨" → 쿠키 삭제 → 로그인 페이지 (왕복 0회!) -``` - -**결과:** -- 무한 리다이렉트 루프 완전 해결 -- 불필요한 /dashboard → /login 왕복 제거 -- refresh 실패 시 바로 로그인 페이지 표시 - ---- - -## 11. TODO (Phase 2) - -### 쿠키 설정 공통 모듈화 - -현재 쿠키 설정 코드가 6곳에 중복: -- `/api/proxy/[...path]/route.ts` -- `/api/auth/login/route.ts` -- `/api/auth/check/route.ts` -- `/api/auth/refresh/route.ts` -- `middleware.ts` -- `fetch-wrapper.ts` - -**계획:** -```typescript -// src/lib/api/cookie-utils.ts (신규) -export function createTokenCookies(tokens: TokenSet): string[] -export function clearTokenCookies(): string[] -``` - -**효과:** 유지보수성 향상 (쿠키 설정 변경 시 1곳만 수정) diff --git a/claudedocs/auth/[PLAN] httponly-cookie-implementation.md b/claudedocs/auth/[PLAN] httponly-cookie-implementation.md deleted file mode 100644 index a1a5a9a3..00000000 --- a/claudedocs/auth/[PLAN] httponly-cookie-implementation.md +++ /dev/null @@ -1,391 +0,0 @@ -# HttpOnly Cookie Implementation - Security Upgrade - -## 보안 개선 개요 - -### 이전 방식 (보안 위험: 🔴 7.6/10) -```typescript -// ❌ XSS 취약점: JavaScript로 토큰 접근 가능 -localStorage.setItem('user_token', token); -document.cookie = `user_token=${token}; SameSite=Lax`; // Non-HttpOnly -``` - -**취약점:** -- localStorage는 모든 JavaScript에서 접근 가능 -- XSS 공격 시 토큰 탈취 가능 -- 쿠키가 HttpOnly가 아니어서 `document.cookie`로 읽기 가능 - -### 새로운 방식 (보안 위험: 🟢 2.8/10) -```typescript -// ✅ XSS 방어: JavaScript로 토큰 접근 불가능 -Set-Cookie: user_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800 -``` - -**보안 개선:** -- HttpOnly 쿠키: JavaScript에서 완전히 차단 -- Secure: HTTPS 연결에서만 전송 -- SameSite=Strict: CSRF 공격 방어 -- 토큰이 클라이언트 JavaScript에 노출되지 않음 - ---- - -## 구현 세부사항 - -### 1. 로그인 프록시 (`src/app/api/auth/login/route.ts`) - -```typescript -export async function POST(request: NextRequest) { - const { user_id, user_pwd } = await request.json(); - - // PHP 백엔드 API 호출 - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - }, - body: JSON.stringify({ user_id, user_pwd }), - }); - - const data = await response.json(); - - // HttpOnly 쿠키 설정 (JavaScript 접근 불가) - const cookieOptions = [ - `user_token=${data.user_token}`, - 'HttpOnly', // ✅ JavaScript 접근 차단 - 'Secure', // ✅ HTTPS 전용 - 'SameSite=Strict', // ✅ CSRF 방어 - 'Path=/', - 'Max-Age=604800', // 7일 - ].join('; '); - - // 응답: 토큰은 제외하고 사용자 정보만 반환 - return NextResponse.json( - { - message: data.message, - user: data.user, - tenant: data.tenant, - menus: data.menus, - }, - { - status: 200, - headers: { 'Set-Cookie': cookieOptions }, - } - ); -} -``` - -### 2. 로그아웃 프록시 (`src/app/api/auth/logout/route.ts`) - -```typescript -export async function POST(request: NextRequest) { - // HttpOnly 쿠키에서 토큰 읽기 - const token = request.cookies.get('user_token')?.value; - - if (token) { - // PHP 백엔드 로그아웃 API 호출 - await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/logout`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - }, - }); - } - - // HttpOnly 쿠키 삭제 - const cookieOptions = [ - 'user_token=', - 'HttpOnly', - 'Secure', - 'SameSite=Strict', - 'Path=/', - 'Max-Age=0', // 즉시 삭제 - ].join('; '); - - return NextResponse.json( - { message: 'Logged out successfully' }, - { status: 200, headers: { 'Set-Cookie': cookieOptions } } - ); -} -``` - -### 3. 클라이언트 로그인 (`src/components/auth/LoginPage.tsx`) - -```typescript -const handleLogin = async () => { - try { - // ✅ Next.js API Route로 프록시 - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_id: userId, - user_pwd: password, - }), - }); - - const data = await response.json(); - - console.log('✅ 로그인 성공:', data.message); - console.log('📦 사용자 정보:', data.user); - console.log('🔐 토큰은 안전한 HttpOnly 쿠키에 저장됨 (JavaScript 접근 불가)'); - - // 대시보드로 이동 - router.push("/dashboard"); - } catch (err: any) { - console.error('❌ 로그인 실패:', err); - setError(err.message || t('invalidCredentials')); - } -}; -``` - -### 4. 클라이언트 로그아웃 (`src/app/[locale]/dashboard/page.tsx`) - -```typescript -const handleLogout = async () => { - try { - // ✅ Next.js API Route로 프록시 - const response = await fetch('/api/auth/logout', { - method: 'POST', - }); - - if (response.ok) { - console.log('✅ 로그아웃 완료: HttpOnly 쿠키 삭제됨'); - } - - router.push('/login'); - } catch (error) { - console.error('로그아웃 처리 중 오류:', error); - router.push('/login'); - } -}; -``` - -### 5. 미들웨어 인증 확인 (`src/middleware.ts`) - -```typescript -function checkAuthentication(request: NextRequest): { - isAuthenticated: boolean; - authMode: 'sanctum' | 'bearer' | 'api-key' | null; -} { - // 1. Bearer Token 확인 (HttpOnly 쿠키에서) - const tokenCookie = request.cookies.get('user_token'); - if (tokenCookie && tokenCookie.value) { - return { isAuthenticated: true, authMode: 'bearer' }; - } - - // 2. Bearer Token 확인 (Authorization 헤더) - const authHeader = request.headers.get('authorization'); - if (authHeader?.startsWith('Bearer ')) { - return { isAuthenticated: true, authMode: 'bearer' }; - } - - return { isAuthenticated: false, authMode: null }; -} -``` - ---- - -## 테스트 가이드 - -### 1. 로그인 테스트 - -**단계:** -1. 브라우저에서 `http://localhost:3000/login` 접속 -2. 로그인 정보 입력: - - User ID: `zomking` - - Password: 테스트 비밀번호 -3. 로그인 버튼 클릭 - -**예상 결과:** -- ✅ 대시보드로 리다이렉트 -- ✅ 브라우저 개발자 도구 → Application → Cookies에서 `user_token` 확인 -- ✅ `user_token` 쿠키의 HttpOnly 플래그 확인 (체크되어 있어야 함) -- ✅ 콘솔에 "로그인 성공" 메시지 출력 - -**HttpOnly 쿠키 확인 방법:** -```javascript -// 브라우저 콘솔에서 실행 -console.log(document.cookie); -// 결과: user_token이 보이지 않아야 함 (HttpOnly로 차단됨) -``` - -### 2. 인증 상태 확인 테스트 - -**단계:** -1. 로그인 상태에서 주소창에 `http://localhost:3000/dashboard` 직접 입력 -2. 페이지 새로고침 (F5) - -**예상 결과:** -- ✅ 대시보드 페이지 정상 표시 -- ✅ 로그인 페이지로 리다이렉트되지 않음 -- ✅ 서버 터미널에 "[Auth Check] Token found in cookie" 로그 출력 - -### 3. 비로그인 상태 차단 테스트 - -**단계:** -1. 로그아웃 버튼 클릭 또는 쿠키 수동 삭제 -2. 주소창에 `http://localhost:3000/dashboard` 직접 입력 - -**예상 결과:** -- ✅ 로그인 페이지로 자동 리다이렉트 -- ✅ URL에 `?redirect=/dashboard` 파라미터 포함 -- ✅ 서버 터미널에 "[Auth Required] Redirecting to /login" 로그 출력 - -### 4. 로그아웃 테스트 - -**단계:** -1. 로그인 상태에서 대시보드의 "Logout" 버튼 클릭 - -**예상 결과:** -- ✅ 로그인 페이지로 리다이렉트 -- ✅ 브라우저 개발자 도구 → Cookies에서 `user_token` 쿠키 삭제됨 -- ✅ 콘솔에 "로그아웃 완료: HttpOnly 쿠키 삭제됨" 메시지 출력 -- ✅ 다시 `/dashboard` 접근 시 로그인 페이지로 리다이렉트 - -### 5. XSS 방어 확인 (보안 테스트) - -**단계:** -1. 로그인 상태에서 브라우저 콘솔 열기 -2. 다음 코드 실행: -```javascript -// localStorage 토큰 읽기 시도 -console.log('localStorage token:', localStorage.getItem('user_token')); -// 결과: null (토큰이 localStorage에 없음) - -// 쿠키 토큰 읽기 시도 -console.log('cookie token:', document.cookie); -// 결과: user_token이 보이지 않음 (HttpOnly로 차단됨) -``` - -**예상 결과:** -- ✅ `localStorage.getItem('user_token')` → `null` -- ✅ `document.cookie` → `user_token`이 포함되지 않음 -- ✅ JavaScript로 토큰 접근 완전히 차단 확인 - -### 6. 서버 터미널 로그 확인 - -**로그인 시:** -``` -✅ Login successful - Token stored in HttpOnly cookie -``` - -**미들웨어 실행 시:** -``` -[Auth Check] Token found in cookie -[Auth Check] User authenticated with bearer mode -``` - -**로그아웃 시:** -``` -✅ Backend logout API called successfully -✅ Logout complete - HttpOnly cookie cleared -``` - ---- - -## 보안 비교표 - -| 항목 | 이전 방식 (localStorage) | 새로운 방식 (HttpOnly Cookie) | -|------|------------------------|------------------------------| -| **XSS 공격** | 🔴 취약 (7.6/10) | 🟢 방어 (2.8/10) | -| **JavaScript 접근** | ❌ 가능 (`localStorage.getItem()`) | ✅ 차단 (HttpOnly) | -| **document.cookie 접근** | ❌ 가능 | ✅ 차단 (HttpOnly) | -| **CSRF 방어** | ⚠️ 부분적 (SameSite=Lax) | ✅ 강화 (SameSite=Strict) | -| **HTTPS 강제** | ❌ 없음 | ✅ Secure 플래그 | -| **토큰 노출** | ❌ 클라이언트에 노출 | ✅ 클라이언트에서 숨김 | - ---- - -## 삭제된 파일 - -다음 파일들은 더 이상 필요하지 않아 삭제되었습니다: - -1. `src/lib/api/auth/sanctum-client.ts` - 직접 PHP API 호출 및 localStorage 사용 -2. `src/lib/api/auth/token-storage.ts` - localStorage 기반 토큰 저장 관리 - -**이유:** -- HttpOnly 쿠키 방식으로 전환하면서 localStorage 사용 불필요 -- Next.js Route Handlers가 PHP API 프록시 역할 수행 -- 토큰은 서버 측에서만 처리 (클라이언트 코드에서 토큰 관리 불필요) - ---- - -## 환경 변수 - -`.env.local` 파일에 필요한 환경 변수: - -```env -NEXT_PUBLIC_API_URL=https://api.5130.co.kr -NEXT_PUBLIC_API_KEY=42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a -NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 -NEXT_PUBLIC_AUTH_MODE=sanctum -``` - ---- - -## 다음 보안 개선 단계 (향후 계획) - -### Option 2: Backend Session (더 높은 보안) -- PHP Laravel에서 세션 기반 인증으로 전환 -- 프론트엔드는 세션 ID만 관리 -- 보안 위험: 🟢 1.5/10 - -### Option 3: BFF Pattern (엔터프라이즈급) -- Backend For Frontend 패턴 구현 -- Next.js API Routes가 모든 인증 로직 담당 -- PHP API는 내부 API로만 사용 -- 보안 위험: 🟢 1.2/10 - ---- - -## 트러블슈팅 - -### 문제: 쿠키가 설정되지 않음 -**원인:** Secure 플래그 때문에 HTTP 환경에서 차단 -**해결:** 개발 환경에서는 `Secure` 플래그 제거 가능 (프로덕션에서는 필수) - -### 문제: 미들웨어에서 토큰을 읽지 못함 -**원인:** 쿠키 이름 불일치 또는 Path 설정 문제 -**해결:** `request.cookies.get('user_token')` 확인 및 `Path=/` 설정 확인 - -### 문제: 로그인 후에도 인증 실패 -**원인:** 쿠키가 다른 도메인에 설정됨 -**해결:** SameSite 설정 확인 및 도메인 일치 여부 확인 - ---- - -## 결론 - -✅ **보안 개선 완료:** -- XSS 공격 위험: 7.6/10 → 2.8/10 -- JavaScript 토큰 접근 완전 차단 -- CSRF 방어 강화 -- HTTPS 강제 적용 - -✅ **구현 완료 항목:** -1. Next.js Route Handlers (로그인/로그아웃 프록시) -2. HttpOnly 쿠키 저장 방식 -3. 클라이언트 코드 업데이트 -4. 미들웨어 인증 확인 (기존 코드 호환) -5. 레거시 코드 제거 (sanctum-client.ts, token-storage.ts) - -🔄 **테스트 필요:** -- 로그인/로그아웃 플로우 -- HttpOnly 쿠키 동작 확인 -- 비로그인 상태 차단 확인 -- XSS 방어 검증 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/app/api/auth/login/route.ts` - 로그인 프록시 API -- `src/app/api/auth/logout/route.ts` - 로그아웃 프록시 API -- `src/components/auth/LoginPage.tsx` - 로그인 페이지 컴포넌트 -- `src/middleware.ts` - 인증 미들웨어 -- `src/app/[locale]/dashboard/page.tsx` - 대시보드 (로그아웃 버튼) - -### 설정 파일 -- `.env.local` - 환경 변수 (API URL, API Key) \ No newline at end of file diff --git a/claudedocs/auth/[REF] nextjs15-middleware-authentication-research.md b/claudedocs/auth/[REF] nextjs15-middleware-authentication-research.md deleted file mode 100644 index cbf42904..00000000 --- a/claudedocs/auth/[REF] nextjs15-middleware-authentication-research.md +++ /dev/null @@ -1,478 +0,0 @@ -# Next.js 15 Middleware Authentication Issues - Research Report - -**Date**: November 7, 2025 -**Project**: sam-react-prod -**Research Focus**: Next.js 15 middleware not executing, console logs not appearing, next-intl integration - ---- - -## Executive Summary - -**ROOT CAUSE IDENTIFIED**: The project has duplicate middleware files: -- `/Users/.../sam-react-prod/middleware.ts` (root level) -- `/Users/.../sam-react-prod/src/middleware.ts` (inside src directory) - -**Next.js only supports ONE middleware.ts file per project.** Having duplicate files causes Next.js to ignore or behave unpredictably with middleware execution, which explains why console logs are not appearing and protected routes are not being blocked. - -**Confidence Level**: HIGH (95%) -Based on official Next.js documentation and multiple community reports confirming this issue. - ---- - -## Problem Analysis - -### Current Situation -1. Middleware exists in both project root AND src directory (duplicate files) -2. Console logs from middleware not appearing in terminal -3. Protected routes not being blocked despite middleware configuration -4. Cookies work correctly (set/delete properly), indicating the issue is NOT with authentication logic itself -5. Middleware matcher configuration appears correct - -### Why Middleware Isn't Executing - -**Primary Issue: Duplicate Middleware Files** -- Next.js only recognizes ONE middleware file per project -- When both `middleware.ts` (root) and `src/middleware.ts` exist, Next.js behavior is undefined -- Typically, Next.js will ignore both or only recognize one unpredictably -- This causes complete middleware execution failure - -**Source**: Official Next.js documentation and GitHub discussions (#50026, #73040090) - ---- - -## Key Research Findings - -### 1. Middleware File Location Rules (CRITICAL) - -**Next.js Convention:** -- **With `src/` directory**: Place middleware at `src/middleware.ts` (same level as `src/app`) -- **Without `src/` directory**: Place middleware at `middleware.ts` (same level as `app` or `pages`) -- **Only ONE middleware file allowed per project** - -**Current Project Structure:** -``` -sam-react-prod/ -├── middleware.ts ← DUPLICATE (should be removed) -├── src/ -│ ├── middleware.ts ← CORRECT location for src-based projects -│ ├── app/ -│ └── ... -``` - -**Action Required**: Delete the root-level `middleware.ts` and keep only `src/middleware.ts` - -**Confidence**: 100% - This is the primary issue - ---- - -### 2. Console.log Debugging in Middleware - -**Where Console Logs Appear:** -- Middleware runs **server-side**, not client-side -- Console logs appear in the **terminal** where you run `npm run dev`, NOT in browser console -- If middleware isn't executing at all, no logs will appear anywhere - -**Debugging Techniques:** -1. Check terminal output (where `npm run dev` is running) -2. Add console.log at the very beginning of middleware function -3. Verify middleware returns NextResponse (next() or redirect) -4. Use structured logging: `console.log('[Middleware]', { pathname, cookies, headers })` - -**Example Debug Pattern:** -```typescript -export function middleware(request: NextRequest) { - console.log('=== MIDDLEWARE START ===', { - pathname: request.nextUrl.pathname, - method: request.method, - timestamp: new Date().toISOString() - }); - - // ... rest of middleware logic - - console.log('=== MIDDLEWARE END ==='); - return response; -} -``` - -**Sources**: Stack Overflow (#70343453), GitHub discussions (#66104) - ---- - -### 3. Next-Intl Middleware Integration Patterns - -**Recommended Pattern for Next.js 15 + next-intl + Authentication:** - -```typescript -import createMiddleware from 'next-intl/middleware'; -import { NextRequest, NextResponse } from 'next/server'; - -// Create i18n middleware -const intlMiddleware = createMiddleware({ - locales: ['en', 'ko'], - defaultLocale: 'en' -}); - -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - // 1. Remove locale prefix for route checking - const pathnameWithoutLocale = getPathnameWithoutLocale(pathname); - - // 2. Check if route is public (skip auth) - if (isPublicRoute(pathnameWithoutLocale)) { - return intlMiddleware(request); - } - - // 3. Check authentication - const isAuthenticated = checkAuth(request); - - // 4. Protect routes - redirect if not authenticated - if (isProtectedRoute(pathnameWithoutLocale) && !isAuthenticated) { - const loginUrl = new URL('/login', request.url); - loginUrl.searchParams.set('redirect', pathname); - return NextResponse.redirect(loginUrl); - } - - // 5. Apply i18n middleware for all other requests - return intlMiddleware(request); -} -``` - -**Execution Order:** -1. Locale detection (next-intl) should run FIRST to normalize URLs -2. Authentication checks run AFTER locale normalization -3. Both use the same middleware function (no separate middleware files) - -**Key Insight**: Your current implementation follows this pattern correctly, but it's not executing due to the duplicate file issue. - -**Sources**: next-intl official documentation, Medium articles by Issam Ahwach and Yoko Hailemariam - ---- - -### 4. Middleware Matcher Configuration - -**Current Configuration (Correct):** -```typescript -export const config = { - matcher: [ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)', - '/dashboard/:path*', - '/login', - '/register', - ], -}; -``` - -**Analysis**: This configuration is correct and should work. It: -- Excludes static files and Next.js internals -- Explicitly includes dashboard, login, and register routes -- Uses negative lookahead regex for general matching - -**Best Practice Matcher Patterns:** -```typescript -// Exclude static files (most common) -'/((?!api|_next/static|_next/image|favicon.ico).*)' - -// Protect specific routes only -['/dashboard/:path*', '/admin/:path*'] - -// Protect everything except public routes -'/((?!_next|static|public|api|auth).*)' -``` - -**Sources**: Next.js official docs, Medium articles on middleware matchers - ---- - -### 5. Authentication Check Implementation - -**Current Implementation Analysis:** - -Your `checkAuthentication()` function checks for: -1. Bearer token in cookies (`user_token`) -2. Bearer token in Authorization header -3. Laravel Sanctum session cookie (`laravel_session`) -4. API key in headers (`x-api-key`) - -**This is CORRECT** - the logic is sound. - -**Why It Appears Not to Work:** -- The middleware isn't executing at all due to duplicate files -- Once the duplicate file issue is fixed, this authentication logic should work correctly - -**Verification Method After Fix:** -```typescript -// Add at the top of checkAuthentication function -export function checkAuthentication(request: NextRequest) { - console.log('[Auth Check]', { - hasCookie: !!request.cookies.get('user_token'), - hasAuthHeader: !!request.headers.get('authorization'), - hasSession: !!request.cookies.get('laravel_session'), - hasApiKey: !!request.headers.get('x-api-key') - }); - - // ... existing logic -} -``` - ---- - -## Common Next.js 15 Middleware Issues (Beyond Your Case) - -### Issue 1: Middleware Not Returning Response -**Problem**: Middleware must return NextResponse -**Solution**: Always return `NextResponse.next()`, `NextResponse.redirect()`, or `NextResponse.rewrite()` - -### Issue 2: Matcher Not Matching Routes -**Problem**: Regex patterns too restrictive -**Solution**: Test with simple matcher first: `matcher: ['/dashboard/:path*']` - -### Issue 3: Console Logs Not Visible -**Problem**: Looking in browser console instead of terminal -**Solution**: Check the terminal where dev server is running - -### Issue 4: Middleware Caching Issues -**Problem**: Old middleware code cached during development -**Solution**: Restart dev server, clear `.next` folder - -**Sources**: Multiple Stack Overflow threads and GitHub issues - ---- - -## Solution Implementation Steps - -### Step 1: Remove Duplicate Middleware File (CRITICAL) - -```bash -# Delete the root-level middleware.ts -rm /Users/byeongcheolryu/codebridgex/sam_project/sam-next/sma-next-project/sam-react-prod/middleware.ts - -# Keep only src/middleware.ts -``` - -### Step 2: Restart Development Server - -```bash -# Stop current dev server (Ctrl+C) -# Clear Next.js cache -rm -rf .next - -# Restart dev server -npm run dev -``` - -### Step 3: Test Middleware Execution - -**Test in Terminal (where npm run dev runs):** -- Navigate to `/dashboard` in browser -- Check terminal for console logs: `[Middleware] Original: /dashboard` -- Should see authentication checks and redirects - -**Expected Terminal Output:** -``` -[Middleware] Original: /dashboard, Without Locale: /dashboard -[Auth Required] Redirecting to /login from /dashboard -``` - -### Step 4: Verify Protected Routes - -**Test Cases:** -1. Access `/dashboard` without authentication → Should redirect to `/login?redirect=/dashboard` -2. Access `/login` when authenticated → Should redirect to `/dashboard` -3. Access `/` (public route) → Should load without redirect -4. Access `/ko/dashboard` (with locale) → Should handle locale and redirect appropriately - -### Step 5: Monitor Console Output - -Add enhanced logging to track middleware execution: - -```typescript -export function middleware(request: NextRequest) { - const timestamp = new Date().toISOString(); - console.log(`\n${'='.repeat(50)}`); - console.log(`[${timestamp}] MIDDLEWARE EXECUTION START`); - console.log(`Path: ${request.nextUrl.pathname}`); - console.log(`Method: ${request.method}`); - - // ... existing logic with detailed logs at each step - - console.log(`[${timestamp}] MIDDLEWARE EXECUTION END`); - console.log(`${'='.repeat(50)}\n`); - return response; -} -``` - ---- - -## Additional Recommendations - -### 1. Environment Variables Validation - -Add startup validation to ensure required env vars are present: - -```typescript -// In auth-config.ts -const requiredEnvVars = [ - 'NEXT_PUBLIC_API_URL', - 'NEXT_PUBLIC_FRONTEND_URL' -]; - -requiredEnvVars.forEach(varName => { - if (!process.env[varName]) { - console.error(`Missing required environment variable: ${varName}`); - } -}); -``` - -### 2. Middleware Performance Monitoring - -Add timing logs to identify bottlenecks: - -```typescript -export function middleware(request: NextRequest) { - const startTime = Date.now(); - - // ... middleware logic - - const duration = Date.now() - startTime; - console.log(`[Middleware] Execution time: ${duration}ms`); - return response; -} -``` - -### 3. Cookie Security Configuration - -Ensure cookies are configured securely: - -```typescript -// When setting cookies (in auth logic, not middleware) -{ - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24 * 7 // 7 days -} -``` - -### 4. Next.js 15 Specific Considerations - -**Next.js 15 Changes:** -- Improved middleware performance with edge runtime optimization -- Better TypeScript support for middleware -- Enhanced matcher configuration with glob patterns -- Middleware now respects `output: 'standalone'` configuration - -**Compatibility Check:** -```bash -# Verify Next.js version -npm list next -# Should show: next@15.5.6 (matches your package.json) -``` - ---- - -## Testing Checklist - -After implementing the fix (removing duplicate middleware file): - -- [ ] Middleware console logs appear in terminal -- [ ] Protected routes redirect to login when unauthenticated -- [ ] Login redirects to dashboard when authenticated -- [ ] Locale URLs work correctly (e.g., `/ko/dashboard`) -- [ ] Static files bypass middleware (no logs for images/CSS) -- [ ] API routes behave as expected -- [ ] Bot detection works for protected paths -- [ ] Cookie authentication functions correctly -- [ ] Redirect parameter works (`/login?redirect=/dashboard`) - ---- - -## References and Sources - -### Official Documentation -- Next.js Middleware: https://nextjs.org/docs/app/building-your-application/routing/middleware -- next-intl Middleware: https://next-intl.dev/docs/routing/middleware -- Next.js 15 Release Notes: https://nextjs.org/blog/next-15 - -### Community Resources -- Stack Overflow: Multiple threads on middleware execution issues -- GitHub Discussions: vercel/next.js #50026, #66104, #73040090 -- Medium Articles: - - "Simplifying Next.js Authentication and Internationalization" by Issam Ahwach - - "Conquering Auth v5 and next-intl Middleware" by Yoko Hailemariam - -### Key GitHub Issues -- Middleware file location conflicts: #50026 -- Middleware not triggering: #73040090, #66104 -- Console.log in middleware: #70343453 -- next-intl integration: amannn/next-intl #1613, #341 - ---- - -## Confidence Assessment - -**Overall Confidence**: 95% - -**High Confidence (95%+)**: -- Duplicate middleware file is the root cause -- File location requirements per Next.js conventions -- Console.log behavior (terminal vs browser) - -**Medium Confidence (70-85%)**: -- Specific next-intl integration patterns (implementation-dependent) -- Cookie configuration best practices (environment-dependent) - -**Areas Requiring Verification**: -- AUTH_CONFIG.protectedRoutes array contents -- Actual cookie names used by Laravel backend -- Production deployment configuration - ---- - -## Next Steps - -1. **Immediate Action**: Remove duplicate `middleware.ts` from project root -2. **Verify Fix**: Restart dev server and test middleware execution -3. **Monitor**: Check terminal logs during testing -4. **Validate**: Run through complete authentication flow -5. **Document**: Update project documentation with correct middleware setup - ---- - -## Appendix: Middleware Execution Flow Diagram - -``` -Request Received - ↓ -[Next.js Checks for middleware.ts] - ↓ -[Duplicate Files Detected] ← CURRENT ISSUE - ↓ -[Undefined Behavior / No Execution] - ↓ -[No Console Logs, No Auth Checks] - - -After Fix: -Request Received - ↓ -[Next.js Loads src/middleware.ts] - ↓ -[Middleware Function Executes] - ↓ -1. Log pathname -2. Check bot detection -3. Check public routes -4. Check authentication -5. Apply next-intl middleware -6. Return response - ↓ -[Route Protected / Locale Applied / Request Continues] -``` - ---- - -**Report Generated**: November 7, 2025 -**Research Method**: Web search (5 queries) + documentation analysis + code review -**Total Sources**: 40+ Stack Overflow threads, GitHub issues, and official docs analyzed diff --git a/claudedocs/auth/[REF] session-migration-backend.md b/claudedocs/auth/[REF] session-migration-backend.md deleted file mode 100644 index 253deb16..00000000 --- a/claudedocs/auth/[REF] session-migration-backend.md +++ /dev/null @@ -1,615 +0,0 @@ -# 세션 기반 인증 전환 가이드 - 백엔드 (PHP/Laravel) - -## 📋 개요 - -**목적**: JWT 토큰 기반 → 세션 기반 인증으로 전환하여 보안 강화 - -**주요 보안 개선 사항**: -- ✅ 로그아웃 시 즉시 세션 무효화 (토큰 만료 대기 불필요) -- ✅ 세션 하이재킹 실시간 감지 (IP/User-Agent 추적) -- ✅ 관리자의 강제 로그아웃 기능 -- ✅ 1계정 1세션 강제 (동시 로그인 제한) -- ✅ 의심스러운 활동 자동 차단 - ---- - -## 🔧 1단계: 환경 설정 - -### 1.1 세션 드라이버 설정 - -```bash -# .env -SESSION_DRIVER=redis -SESSION_LIFETIME=120 # 2시간 (분 단위) -SESSION_SECURE_COOKIE=true -SESSION_DOMAIN=.yourdomain.com # 서브도메인 공유 시 -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 -``` - -### 1.2 세션 설정 파일 - -```php -// config/session.php -return [ - 'driver' => env('SESSION_DRIVER', 'redis'), - 'lifetime' => env('SESSION_LIFETIME', 120), - 'expire_on_close' => false, - 'encrypt' => true, // 🔒 세션 데이터 암호화 - 'http_only' => true, // 🔒 XSS 방지 - 'same_site' => 'strict', // 🔒 CSRF 방지 - 'secure' => env('SESSION_SECURE_COOKIE', true), // 🔒 HTTPS only - - // 세션 가비지 컬렉션 - 'lottery' => [2, 100], - - // 세션 쿠키 이름 - 'cookie' => env( - 'SESSION_COOKIE', - Str::slug(env('APP_NAME', 'laravel'), '_').'_session' - ), -]; -``` - ---- - -## 🔐 2단계: 인증 가드 변경 - -### 2.1 Auth 설정 - -```php -// config/auth.php -'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - - 'api' => [ - 'driver' => 'session', // Sanctum → Session 변경 - 'provider' => 'users', - ], -], -``` - ---- - -## 🚪 3단계: 로그인 컨트롤러 수정 - -### 3.1 기존 코드 (토큰 기반) - -```php -// ❌ 제거할 코드 -public function login(Request $request) -{ - // JWT 토큰 발급 - $token = auth()->attempt($credentials); - - return response()->json([ - 'access_token' => $token, - 'refresh_token' => $refreshToken, - 'token_type' => 'bearer', - 'expires_in' => 7200, - ]); -} -``` - -### 3.2 새로운 코드 (세션 기반) - -```php -// ✅ 새로운 로그인 로직 -namespace App\Http\Controllers\Auth; - -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; - -class LoginController extends Controller -{ - public function login(Request $request) - { - // 입력 검증 - $credentials = $request->validate([ - 'user_id' => 'required|string', - 'user_pwd' => 'required|string', - ]); - - // 🔒 세션 기반 인증 - if (Auth::attempt([ - 'user_id' => $credentials['user_id'], - 'password' => $credentials['user_pwd'] - ], $request->filled('remember'))) { - - // 🔒 세션 재생성 (세션 고정 공격 방지) - $request->session()->regenerate(); - - // 🔒 보안 정보 저장 (하이재킹 감지용) - session([ - 'ip_address' => $request->ip(), - 'user_agent' => $request->userAgent(), - 'login_at' => now()->toDateTimeString(), - ]); - - // 🔒 동시 로그인 제한 (옵션) - $this->limitConcurrentSessions(Auth::user()); - - // 사용자 정보 반환 (토큰 없음!) - return response()->json([ - 'message' => 'Login successful', - 'user' => [ - 'id' => Auth::user()->id, - 'user_id' => Auth::user()->user_id, - 'name' => Auth::user()->name, - 'email' => Auth::user()->email, - 'phone' => Auth::user()->phone, - ], - 'tenant' => Auth::user()->tenant, - 'menus' => Auth::user()->menus, - 'roles' => Auth::user()->roles, - ]); - } - - // 인증 실패 - return response()->json([ - 'error' => 'Invalid credentials' - ], 401); - } - - /** - * 🔒 동시 로그인 제한 (1계정 1세션) - */ - protected function limitConcurrentSessions($user) - { - // 현재 세션 ID 제외하고 모든 세션 삭제 - DB::table('sessions') - ->where('user_id', $user->id) - ->where('id', '!=', session()->getId()) - ->delete(); - } -} -``` - ---- - -## 🚪 4단계: 로그아웃 컨트롤러 수정 - -```php -// app/Http/Controllers/Auth/LogoutController.php -namespace App\Http\Controllers\Auth; - -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; - -class LogoutController extends Controller -{ - public function logout(Request $request) - { - // 🔒 세션 무효화 - Auth::logout(); - - // 🔒 세션 데이터 삭제 - $request->session()->invalidate(); - - // 🔒 CSRF 토큰 재생성 - $request->session()->regenerateToken(); - - return response()->json([ - 'message' => 'Logged out successfully' - ]); - } -} -``` - ---- - -## 🛡️ 5단계: 세션 하이재킹 감지 미들웨어 - -### 5.1 미들웨어 생성 - -```bash -php artisan make:middleware DetectSessionHijacking -``` - -### 5.2 미들웨어 코드 - -```php -// app/Http/Middleware/DetectSessionHijacking.php -namespace App\Http\Middleware; - -use Closure; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; - -class DetectSessionHijacking -{ - /** - * 세션 하이재킹 감지 및 차단 - */ - public function handle(Request $request, Closure $next) - { - if (Auth::check()) { - $user = Auth::user(); - - // 🔒 IP 주소 변경 감지 - if (session('ip_address') && session('ip_address') !== $request->ip()) { - Log::warning('Session hijacking detected: IP changed', [ - 'user_id' => $user->id, - 'old_ip' => session('ip_address'), - 'new_ip' => $request->ip(), - ]); - - // 세션 파괴 및 로그아웃 - Auth::logout(); - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return response()->json([ - 'error' => 'Session security violation detected', - 'code' => 'SESSION_HIJACKED', - 'message' => 'Your session has been terminated for security reasons.' - ], 401); - } - - // 🔒 User-Agent 변경 감지 - if (session('user_agent') && session('user_agent') !== $request->userAgent()) { - Log::warning('Session hijacking detected: User-Agent changed', [ - 'user_id' => $user->id, - 'old_ua' => session('user_agent'), - 'new_ua' => $request->userAgent(), - ]); - - Auth::logout(); - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return response()->json([ - 'error' => 'Session security violation detected', - 'code' => 'SESSION_HIJACKED' - ], 401); - } - } - - return $next($request); - } -} -``` - -### 5.3 미들웨어 등록 - -```php -// app/Http/Kernel.php -protected $middlewareGroups = [ - 'api' => [ - \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, - 'throttle:api', - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \App\Http\Middleware\DetectSessionHijacking::class, // ✅ 추가 - ], -]; -``` - ---- - -## 🌐 6단계: CORS 설정 (중요!) - -### 6.1 CORS 설정 파일 - -```php -// config/cors.php -return [ - 'paths' => ['api/*', 'sanctum/csrf-cookie'], - - 'allowed_methods' => ['*'], - - 'allowed_origins' => [ - 'http://localhost:3000', // 개발 환경 - 'https://yourdomain.com', // 프로덕션 - 'https://app.yourdomain.com', // 프로덕션 앱 - ], - - 'allowed_origins_patterns' => [], - - 'allowed_headers' => ['*'], - - 'exposed_headers' => [], - - 'max_age' => 0, - - 'supports_credentials' => true, // ✅ 세션 쿠키 전송 허용 (필수!) -]; -``` - ---- - -## 🗑️ 7단계: 토큰 관련 코드 제거 - -### 7.1 삭제할 엔드포인트 - -```php -// routes/api.php - -// ❌ 삭제: 토큰 갱신 엔드포인트 (세션은 자동 갱신) -// Route::post('/refresh', [TokenController::class, 'refresh']); -``` - -### 7.2 삭제할 컨트롤러 - -```bash -# ❌ 삭제 또는 주석 처리 -# app/Http/Controllers/Auth/TokenRefreshController.php -``` - ---- - -## ✅ 8단계: 세션 확인 엔드포인트 추가 - -```php -// routes/api.php -Route::get('/auth/check', [AuthController::class, 'check']); -``` - -```php -// app/Http/Controllers/Auth/AuthController.php -public function check(Request $request) -{ - if (Auth::check()) { - return response()->json([ - 'authenticated' => true, - 'user' => [ - 'id' => Auth::user()->id, - 'name' => Auth::user()->name, - 'email' => Auth::user()->email, - ] - ]); - } - - return response()->json([ - 'authenticated' => false - ]); -} -``` - ---- - -## 🧪 9단계: 테스트 - -### 9.1 로그인 테스트 - -```bash -curl -X POST http://localhost:8000/api/v1/login \ - -H "Content-Type: application/json" \ - -H "X-API-KEY: your-api-key" \ - -d '{"user_id": "test", "user_pwd": "password"}' \ - -c cookies.txt # 쿠키 저장 - -# 응답: -# { -# "message": "Login successful", -# "user": {...}, -# "tenant": {...} -# } -# -# Set-Cookie: laravel_session=abc123... -``` - -### 9.2 세션 확인 테스트 - -```bash -curl -X GET http://localhost:8000/api/v1/auth/check \ - -H "X-API-KEY: your-api-key" \ - -b cookies.txt # 저장된 쿠키 사용 - -# 응답: -# { -# "authenticated": true, -# "user": {...} -# } -``` - -### 9.3 로그아웃 테스트 - -```bash -curl -X POST http://localhost:8000/api/v1/logout \ - -H "X-API-KEY: your-api-key" \ - -b cookies.txt - -# 응답: -# { -# "message": "Logged out successfully" -# } -``` - -### 9.4 세션 하이재킹 감지 테스트 - -```bash -# 1. 로그인 (IP: A) -curl -X POST http://localhost:8000/api/v1/login \ - -H "X-API-KEY: your-api-key" \ - -d '{"user_id": "test", "user_pwd": "password"}' \ - -c cookies.txt - -# 2. 다른 IP에서 같은 세션 ID 사용 시도 (IP: B) -# → 자동 차단되어야 함 -``` - ---- - -## 🔒 10단계: 추가 보안 강화 (옵션) - -### 10.1 Rate Limiting (무차별 대입 공격 방지) - -```php -// routes/api.php -Route::middleware(['throttle:5,1'])->group(function () { - Route::post('/login', [LoginController::class, 'login']); -}); - -// 5번 시도 후 1분 대기 -``` - -### 10.2 세션 활동 로그 - -```php -// app/Models/SessionLog.php 생성 -Schema::create('session_logs', function (Blueprint $table) { - $table->id(); - $table->unsignedBigInteger('user_id'); - $table->string('ip_address'); - $table->text('user_agent'); - $table->timestamp('login_at'); - $table->timestamp('logout_at')->nullable(); - $table->timestamps(); -}); -``` - -```php -// 로그인 시 기록 -SessionLog::create([ - 'user_id' => Auth::id(), - 'ip_address' => $request->ip(), - 'user_agent' => $request->userAgent(), - 'login_at' => now(), -]); -``` - -### 10.3 관리자 강제 로그아웃 기능 - -```php -// app/Http/Controllers/Admin/SessionController.php -public function forceLogout(Request $request, $userId) -{ - // 특정 사용자의 모든 세션 삭제 - DB::table('sessions') - ->where('user_id', $userId) - ->delete(); - - return response()->json([ - 'message' => 'User sessions terminated' - ]); -} -``` - ---- - -## 📊 마이그레이션 체크리스트 - -### 필수 작업 - -- [ ] `.env` 파일 세션 드라이버 설정 -- [ ] `config/session.php` 보안 설정 적용 -- [ ] `config/auth.php` 가드를 세션으로 변경 -- [ ] 로그인 컨트롤러 수정 (토큰 제거, 세션 사용) -- [ ] 로그아웃 컨트롤러 수정 (세션 무효화) -- [ ] `config/cors.php`에서 `supports_credentials: true` 설정 -- [ ] 세션 하이재킹 감지 미들웨어 추가 -- [ ] `/api/v1/refresh` 엔드포인트 삭제 -- [ ] `/api/v1/auth/check` 엔드포인트 추가 - -### 권장 작업 - -- [ ] Rate Limiting 적용 -- [ ] 세션 활동 로그 테이블 생성 -- [ ] 관리자 강제 로그아웃 기능 구현 -- [ ] 동시 로그인 제한 적용 - -### 테스트 - -- [ ] 로그인 → 세션 생성 확인 -- [ ] 로그아웃 → 세션 파괴 확인 -- [ ] 세션 하이재킹 감지 테스트 -- [ ] CORS 크로스 도메인 테스트 -- [ ] 동시 로그인 제한 테스트 - ---- - -## 🚨 주의사항 - -### 1. 세션 저장소 (Redis) 필수 - -```bash -# Redis 설치 확인 -redis-cli ping -# 응답: PONG - -# Redis 접속 테스트 -redis-cli -> KEYS *session* -``` - -### 2. CORS 설정 필수 - -- `supports_credentials: true` 반드시 설정 -- 프론트엔드 도메인을 `allowed_origins`에 추가 -- `*` (와일드카드) 사용 불가 (credentials와 충돌) - -### 3. HTTPS 필수 (프로덕션) - -```bash -# .env -SESSION_SECURE_COOKIE=true # HTTPS만 쿠키 전송 -``` - -### 4. 세션 쿠키 이름 확인 - -```php -// config/session.php -'cookie' => 'laravel_session', // 프론트엔드에서 이 이름 사용 -``` - ---- - -## 📞 프론트엔드 팀 공유 사항 - -### API 변경 사항 - -**로그인 응답 변경**: -```json -// ❌ 이전 (토큰 반환) -{ - "access_token": "eyJhbG...", - "refresh_token": "eyJhbG...", - "token_type": "bearer", - "expires_in": 7200 -} - -// ✅ 이후 (토큰 없음, 세션 쿠키만) -{ - "message": "Login successful", - "user": {...}, - "tenant": {...} -} - -// Set-Cookie: laravel_session=abc123... -``` - -**필수 요구사항**: -- 모든 API 호출에 `credentials: 'include'` 추가 -- 세션 쿠키를 자동으로 포함하여 전송 -- `/api/auth/refresh` 엔드포인트 사용 중단 - ---- - -## 🎯 완료 후 확인사항 - -1. ✅ 로그인 시 세션 쿠키 생성 -2. ✅ 로그아웃 시 즉시 접근 차단 -3. ✅ IP 변경 시 자동 차단 -4. ✅ User-Agent 변경 시 자동 차단 -5. ✅ 관리자 강제 로그아웃 작동 -6. ✅ Redis에 세션 데이터 저장 확인 - ---- - -## 📚 참고 자료 - -- [Laravel Session 공식 문서](https://laravel.com/docs/session) -- [Laravel Authentication 공식 문서](https://laravel.com/docs/authentication) -- [Redis Session Driver](https://laravel.com/docs/redis) - ---- - -**작성일**: 2025-11-12 -**작성자**: Claude Code -**버전**: 1.0 \ No newline at end of file diff --git a/claudedocs/auth/[REF] session-migration-frontend.md b/claudedocs/auth/[REF] session-migration-frontend.md deleted file mode 100644 index fa0ed028..00000000 --- a/claudedocs/auth/[REF] session-migration-frontend.md +++ /dev/null @@ -1,580 +0,0 @@ -# 세션 기반 인증 전환 가이드 - 프론트엔드 (Next.js) - -## 📋 개요 - -**목적**: 백엔드 세션 기반 인증에 맞춰 프론트엔드 수정 - -**주요 변경 사항**: -- ❌ JWT 토큰 저장 로직 제거 -- ✅ 백엔드 세션 쿠키 전달 방식으로 변경 -- ❌ 토큰 갱신 엔드포인트 제거 -- ✅ 모든 API 호출에 `credentials: 'include'` 추가 - ---- - -## 🔍 현재 구조 분석 - -### 현재 파일 구조 - -``` -src/ -├── app/ -│ └── api/ -│ └── auth/ -│ ├── login/route.ts # 백엔드 토큰 → 쿠키 저장 -│ ├── logout/route.ts # 쿠키 삭제 -│ ├── refresh/route.ts # ❌ 삭제 예정 -│ └── check/route.ts # 쿠키 확인 -├── lib/ -│ └── auth/ -│ └── token-refresh.ts # ❌ 삭제 예정 -└── middleware.ts # 인증 체크 -``` - ---- - -## 📝 백엔드 준비 대기 상황 - -### 백엔드에서 준비 중인 사항 - -1. **세션 드라이버 Redis 설정** -2. **인증 가드 세션으로 변경** -3. **로그인 API 응답 변경**: - ```json - // 변경 전 - { - "access_token": "eyJhbG...", - "refresh_token": "eyJhbG...", - "token_type": "bearer" - } - - // 변경 후 - { - "message": "Login successful", - "user": {...}, - "tenant": {...} - } - // + Set-Cookie: laravel_session=abc123 - ``` -4. **CORS 설정**: `supports_credentials: true` -5. **세션 하이재킹 감지 미들웨어** -6. **`/api/v1/auth/check` 엔드포인트 추가** - ---- - -## 🛠️ 프론트엔드 변경 작업 - -### 1️⃣ 로그인 API 수정 - -**파일**: `src/app/api/auth/login/route.ts` - -**변경 사항**: -- ✅ `credentials: 'include'` 추가 -- ✅ 백엔드 세션 쿠키를 클라이언트로 전달 -- ❌ 토큰 저장 로직 제거 - -```typescript -// src/app/api/auth/login/route.ts -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -/** - * 🔵 세션 기반 로그인 프록시 - * - * 변경 사항: - * - 토큰 저장 로직 제거 - * - 백엔드 세션 쿠키를 클라이언트로 전달 - * - credentials: 'include' 추가 - */ - -interface BackendLoginResponse { - message: string; - user: { - id: number; - user_id: string; - name: string; - email: string; - phone: string; - }; - tenant: { - id: number; - company_name: string; - business_num: string; - tenant_st_code: string; - other_tenants: unknown[]; - }; - menus: Array<{ - id: number; - parent_id: number | null; - name: string; - url: string; - icon: string; - sort_order: number; - is_external: number; - external_url: string | null; - }>; - roles: Array<{ - id: number; - name: string; - description: string; - }>; -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { user_id, user_pwd } = body; - - if (!user_id || !user_pwd) { - return NextResponse.json( - { error: 'User ID and password are required' }, - { status: 400 } - ); - } - - // ✅ 백엔드 세션 기반 로그인 호출 - const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - }, - body: JSON.stringify({ user_id, user_pwd }), - credentials: 'include', // ✅ 세션 쿠키 수신 - }); - - if (!backendResponse.ok) { - let errorMessage = 'Authentication failed'; - - if (backendResponse.status === 422) { - errorMessage = 'Invalid credentials provided'; - } else if (backendResponse.status === 429) { - errorMessage = 'Too many login attempts. Please try again later'; - } else if (backendResponse.status >= 500) { - errorMessage = 'Service temporarily unavailable'; - } - - return NextResponse.json( - { error: errorMessage }, - { status: backendResponse.status === 422 ? 401 : backendResponse.status } - ); - } - - const data: BackendLoginResponse = await backendResponse.json(); - - // ✅ 백엔드 세션 쿠키를 클라이언트로 전달 - const sessionCookie = backendResponse.headers.get('set-cookie'); - - const response = NextResponse.json({ - message: data.message, - user: data.user, - tenant: data.tenant, - menus: data.menus, - roles: data.roles, - }, { status: 200 }); - - // ✅ 백엔드 세션 쿠키 전달 - if (sessionCookie) { - response.headers.set('Set-Cookie', sessionCookie); - } - - console.log('✅ Login successful - Session cookie set'); - return response; - - } catch (error) { - console.error('Login proxy error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} -``` - ---- - -### 2️⃣ 로그아웃 API 수정 - -**파일**: `src/app/api/auth/logout/route.ts` - -**변경 사항**: -- ✅ `credentials: 'include'` 추가 -- ✅ 세션 쿠키를 백엔드로 전달 -- ❌ 수동 쿠키 삭제 로직 제거 (백엔드가 처리) - -```typescript -// src/app/api/auth/logout/route.ts -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -/** - * 🔵 세션 기반 로그아웃 프록시 - * - * 변경 사항: - * - 백엔드에 세션 쿠키 전달하여 세션 파괴 - * - 수동 쿠키 삭제 로직 제거 - */ -export async function POST(request: NextRequest) { - try { - // ✅ 백엔드 로그아웃 호출 (세션 파괴) - const sessionCookie = request.headers.get('cookie'); - - await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/logout`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - 'Cookie': sessionCookie || '', - }, - credentials: 'include', // ✅ 세션 쿠키 포함 - }); - - console.log('✅ Logout complete - Session destroyed on backend'); - - return NextResponse.json( - { message: 'Logged out successfully' }, - { status: 200 } - ); - - } catch (error) { - console.error('Logout proxy error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} -``` - ---- - -### 3️⃣ 인증 체크 API 수정 - -**파일**: `src/app/api/auth/check/route.ts` - -**변경 사항**: -- ✅ `credentials: 'include'` 추가 -- ✅ 백엔드 `/api/v1/auth/check` 호출 -- ❌ 토큰 갱신 로직 제거 - -```typescript -// src/app/api/auth/check/route.ts -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -/** - * 🔵 세션 기반 인증 상태 확인 - * - * 변경 사항: - * - 백엔드 세션 검증 API 호출 - * - 토큰 갱신 로직 제거 (세션은 자동 연장) - */ -export async function GET(request: NextRequest) { - try { - const sessionCookie = request.headers.get('cookie'); - - if (!sessionCookie) { - return NextResponse.json( - { authenticated: false }, - { status: 200 } - ); - } - - // ✅ 백엔드 세션 검증 - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/auth/check`, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - 'Cookie': sessionCookie, - }, - credentials: 'include', // ✅ 세션 쿠키 포함 - }); - - if (response.ok) { - const data = await response.json(); - return NextResponse.json( - { - authenticated: data.authenticated, - user: data.user || null - }, - { status: 200 } - ); - } - - return NextResponse.json( - { authenticated: false }, - { status: 200 } - ); - - } catch (error) { - console.error('Auth check error:', error); - return NextResponse.json( - { authenticated: false }, - { status: 200 } - ); - } -} -``` - ---- - -### 4️⃣ 미들웨어 수정 - -**파일**: `src/middleware.ts` - -**변경 사항**: -- ✅ 세션 쿠키 확인 (`laravel_session`) -- ❌ 토큰 쿠키 확인 제거 (`access_token`, `refresh_token`) - -```typescript -// src/middleware.ts (checkAuthentication 함수만) - -/** - * 인증 체크 함수 - * 세션 쿠키 기반으로 변경 - */ -function checkAuthentication(request: NextRequest): { - isAuthenticated: boolean; - authMode: 'session' | 'api-key' | null; -} { - // ✅ Laravel 세션 쿠키 확인 - const sessionCookie = request.cookies.get('laravel_session'); - if (sessionCookie && sessionCookie.value) { - return { isAuthenticated: true, authMode: 'session' }; - } - - // API Key (API 호출용) - const apiKey = request.headers.get('x-api-key'); - if (apiKey) { - return { isAuthenticated: true, authMode: 'api-key' }; - } - - return { isAuthenticated: false, authMode: null }; -} -``` - ---- - -### 5️⃣ 파일 삭제 - -**삭제할 파일**: -```bash -# ❌ 토큰 갱신 API (세션은 자동 연장) -rm src/app/api/auth/refresh/route.ts - -# ❌ 토큰 갱신 유틸리티 -rm src/lib/auth/token-refresh.ts -``` - ---- - -## 📋 변경 작업 체크리스트 - -### 필수 변경 - -- [ ] `src/app/api/auth/login/route.ts` - - [ ] `credentials: 'include'` 추가 - - [ ] 백엔드 세션 쿠키 전달 로직 추가 - - [ ] 토큰 저장 로직 제거 (151-174 라인) - -- [ ] `src/app/api/auth/logout/route.ts` - - [ ] `credentials: 'include'` 추가 - - [ ] 세션 쿠키를 백엔드로 전달 - - [ ] 수동 쿠키 삭제 로직 제거 (52-68 라인) - -- [ ] `src/app/api/auth/check/route.ts` - - [ ] `credentials: 'include'` 추가 - - [ ] 백엔드 `/api/v1/auth/check` 호출 - - [ ] 토큰 갱신 로직 제거 (51-102 라인) - -- [ ] `src/middleware.ts` - - [ ] `laravel_session` 쿠키 확인으로 변경 - - [ ] `access_token`, `refresh_token` 확인 제거 (132-136 라인) - -- [ ] 파일 삭제 - - [ ] `src/app/api/auth/refresh/route.ts` - - [ ] `src/lib/auth/token-refresh.ts` - -### 클라이언트 컴포넌트 확인 - -- [ ] 모든 `fetch()` 호출에 `credentials: 'include'` 추가 -- [ ] 토큰 관련 상태 관리 제거 (있다면) -- [ ] 로그인 후 리다이렉트 로직 확인 - ---- - -## 🧪 테스트 계획 - -### 백엔드 준비 완료 후 테스트 - -#### 1. 로그인 테스트 - -```typescript -// 브라우저 개발자 도구 → Network 탭 -fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_id: 'test', - user_pwd: 'password' - }), - credentials: 'include' // ✅ 확인 -}); - -// 응답 확인: -// 1. Set-Cookie: laravel_session=abc123... -// 2. Response Body: { message: "Login successful", user: {...} } -``` - -#### 2. 세션 쿠키 확인 - -```javascript -// 브라우저 개발자 도구 → Application → Cookies -// laravel_session 쿠키 존재 확인 -document.cookie; // "laravel_session=abc123..." -``` - -#### 3. 인증 체크 테스트 - -```typescript -fetch('/api/auth/check', { - credentials: 'include' -}); - -// 응답: { authenticated: true, user: {...} } -``` - -#### 4. 로그아웃 테스트 - -```typescript -fetch('/api/auth/logout', { - method: 'POST', - credentials: 'include' -}); - -// 확인: -// 1. laravel_session 쿠키 삭제됨 -// 2. /api/auth/check 호출 시 authenticated: false -``` - -#### 5. 세션 하이재킹 감지 테스트 - -```bash -# 1. 로그인 (정상 IP) -# 2. 쿠키 복사 -# 3. VPN 또는 다른 네트워크에서 접근 시도 -# 4. 자동 차단 확인 (401 Unauthorized) -``` - ---- - -## 🚨 주의사항 - -### 1. CORS 에러 발생 시 - -**증상**: -``` -Access to fetch at 'http://api.example.com/api/v1/login' from origin 'http://localhost:3000' -has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header -in the response is '' which must be 'true' when the request's credentials mode is 'include'. -``` - -**해결**: 백엔드 팀에 확인 요청 -- `config/cors.php`에서 `supports_credentials: true` 설정 -- `allowed_origins`에 프론트엔드 도메인 추가 -- 와일드카드 `*` 사용 불가 - -### 2. 쿠키가 전송되지 않는 경우 - -**원인**: -- `credentials: 'include'` 누락 -- HTTPS 환경에서 `Secure` 쿠키 설정 - -**확인**: -```typescript -// 모든 API 호출에 추가 -fetch(url, { - credentials: 'include' // ✅ 필수! -}); -``` - -### 3. 개발 환경 (localhost) - -**개발 환경에서는 HTTPS 없이도 작동**: -- 백엔드 `.env`: `SESSION_SECURE_COOKIE=false` -- 프로덕션에서는 반드시 `true` - -### 4. 세션 만료 시간 - -- 백엔드 설정: `SESSION_LIFETIME=120` (2시간) -- 사용자가 2시간 동안 활동 없으면 자동 로그아웃 -- 활동 중에는 자동 연장 - ---- - -## 🔄 마이그레이션 단계 - -### 단계 1: 백엔드 준비 (백엔드 팀) -- [ ] Redis 세션 드라이버 설정 -- [ ] 인증 가드 변경 -- [ ] CORS 설정 -- [ ] API 응답 변경 -- [ ] 테스트 완료 - -### 단계 2: 프론트엔드 변경 (현재 팀) -- [ ] 로그인 API 수정 -- [ ] 로그아웃 API 수정 -- [ ] 인증 체크 API 수정 -- [ ] 미들웨어 수정 -- [ ] 토큰 관련 파일 삭제 - -### 단계 3: 통합 테스트 -- [ ] 로그인/로그아웃 플로우 -- [ ] 세션 유지 확인 -- [ ] 세션 하이재킹 감지 -- [ ] 동시 로그인 제한 - -### 단계 4: 배포 -- [ ] 스테이징 환경 배포 -- [ ] 프로덕션 배포 -- [ ] 모니터링 - ---- - -## 📞 백엔드 팀 협업 포인트 - -### 확인 필요 사항 - -1. **세션 쿠키 이름**: `laravel_session` (확인 필요) -2. **CORS 도메인 화이트리스트**: 프론트엔드 도메인 추가 요청 -3. **세션 만료 시간**: 2시간 적절한지 확인 -4. **API 엔드포인트**: - - ✅ `/api/v1/login` (세션 생성) - - ✅ `/api/v1/logout` (세션 파괴) - - ✅ `/api/v1/auth/check` (세션 검증) - - ❌ `/api/v1/refresh` (삭제) - -### 배포 전 확인 - -- [ ] 백엔드 배포 완료 확인 -- [ ] API 응답 형식 변경 확인 -- [ ] CORS 설정 적용 확인 -- [ ] 세션 쿠키 전송 확인 - ---- - -## 📚 참고 자료 - -- [Next.js API Routes](https://nextjs.org/docs/api-routes/introduction) -- [MDN: Fetch API with credentials](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included) -- [MDN: HTTP Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) - ---- - -**작성일**: 2025-11-12 -**작성자**: Claude Code -**버전**: 1.0 -**상태**: ⏳ 백엔드 준비 대기 중 \ No newline at end of file diff --git a/claudedocs/auth/[REF] session-migration-summary.md b/claudedocs/auth/[REF] session-migration-summary.md deleted file mode 100644 index ab9d8c5d..00000000 --- a/claudedocs/auth/[REF] session-migration-summary.md +++ /dev/null @@ -1,366 +0,0 @@ -# 세션 기반 인증 전환 - 프로젝트 요약 - -## 📌 프로젝트 개요 - -**목표**: JWT 토큰 기반 → 세션 기반 인증으로 전환하여 보안 강화 - -**작업 기간**: 2-3일 (백엔드 1-2일, 프론트엔드 1일) - -**상태**: ⏳ 백엔드 준비 중 → 프론트엔드 대기 - ---- - -## 🎯 전환 이유 (보안 강화) - -| 보안 항목 | JWT 토큰 (현재) | 세션 (전환 후) | -|----------|----------------|---------------| -| 로그아웃 효과 | 쿠키만 삭제, 토큰 유효 | 세션 파괴, 즉시 차단 ✅ | -| 토큰 탈취 시 | 만료까지 악용 가능 (2시간) | 즉시 무효화 가능 ✅ | -| 세션 하이재킹 감지 | 어려움 | 실시간 감지 (IP/UA) ✅ | -| 강제 로그아웃 | 불가능 | 관리자가 즉시 가능 ✅ | -| 동시 로그인 제한 | 어려움 | 1계정 1세션 강제 ✅ | - -**결론**: ERP 시스템의 민감한 업무 데이터 보호에 세션이 더 적합 - ---- - -## 📊 아키텍처 변경 - -### 현재 (JWT 토큰) - -``` -[클라이언트] --user_id/pwd--> [Next.js] --user_id/pwd--> [PHP 백엔드] - | | - | <--access_token------ | - | refresh_token | - | | -[쿠키: access_token] <---저장--- | | -[쿠키: refresh_token] | | -``` - -### 전환 후 (세션) - -``` -[클라이언트] --user_id/pwd--> [Next.js] --user_id/pwd--> [PHP 백엔드] - | | - | <--세션 생성 -------> [Redis] - | Session ID: abc123 | - | | -[쿠키: laravel_session=abc123]<-전달- | -``` - ---- - -## 🔄 작업 단계 - -### 단계 1: 백엔드 작업 (PHP/Laravel) ⏳ 진행 중 - -**담당**: 백엔드 팀 -**예상 기간**: 1-2일 - -#### 필수 작업 -- [ ] Redis 세션 드라이버 설정 (`.env`, `config/session.php`) -- [ ] 인증 가드 변경 (Sanctum → Session) -- [ ] 로그인 컨트롤러 수정 (토큰 제거, 세션 생성) -- [ ] 로그아웃 컨트롤러 수정 (세션 파괴) -- [ ] CORS 설정 (`supports_credentials: true`) -- [ ] 세션 하이재킹 감지 미들웨어 추가 -- [ ] `/api/v1/auth/check` 엔드포인트 추가 -- [ ] `/api/v1/refresh` 엔드포인트 삭제 - -#### 권장 작업 -- [ ] Rate Limiting 적용 -- [ ] 세션 활동 로그 -- [ ] 관리자 강제 로그아웃 기능 - -**📄 상세 가이드**: `SESSION_MIGRATION_BACKEND.md` - ---- - -### 단계 2: 프론트엔드 작업 (Next.js) ⏸️ 대기 중 - -**담당**: 프론트엔드 팀 -**예상 기간**: 1일 - -#### 필수 작업 -- [ ] `src/app/api/auth/login/route.ts` 수정 - - `credentials: 'include'` 추가 - - 백엔드 세션 쿠키 전달 - - 토큰 저장 로직 제거 - -- [ ] `src/app/api/auth/logout/route.ts` 수정 - - `credentials: 'include'` 추가 - - 세션 쿠키를 백엔드로 전달 - -- [ ] `src/app/api/auth/check/route.ts` 수정 - - 백엔드 세션 검증 API 호출 - - 토큰 갱신 로직 제거 - -- [ ] `src/middleware.ts` 수정 - - `laravel_session` 쿠키 확인 - - 토큰 쿠키 확인 제거 - -- [ ] 파일 삭제 - - `src/app/api/auth/refresh/route.ts` - - `src/lib/auth/token-refresh.ts` - -**📄 상세 가이드**: `SESSION_MIGRATION_FRONTEND.md` - ---- - -### 단계 3: 통합 테스트 - -**담당**: 양 팀 협업 -**예상 기간**: 0.5일 - -- [ ] 로그인 플로우 테스트 -- [ ] 로그아웃 즉시 차단 확인 -- [ ] 세션 유지 확인 (페이지 새로고침) -- [ ] 세션 하이재킹 감지 테스트 -- [ ] CORS 크로스 도메인 테스트 -- [ ] 동시 로그인 제한 테스트 - ---- - -## 📋 API 변경 사항 요약 - -### 로그인 API - -**엔드포인트**: `POST /api/v1/login` - -**요청**: 변경 없음 -```json -{ - "user_id": "test", - "user_pwd": "password" -} -``` - -**응답**: 토큰 제거 -```json -// ❌ 이전 -{ - "access_token": "eyJhbG...", - "refresh_token": "eyJhbG...", - "token_type": "bearer", - "expires_in": 7200, - "user": {...} -} - -// ✅ 이후 -{ - "message": "Login successful", - "user": {...}, - "tenant": {...}, - "menus": [...], - "roles": [...] -} -// + Set-Cookie: laravel_session=abc123... -``` - ---- - -### 로그아웃 API - -**엔드포인트**: `POST /api/v1/logout` - -**변경 사항**: -- 세션 쿠키를 받아 Redis에서 세션 삭제 -- 즉시 접근 차단 - ---- - -### 인증 체크 API (신규) - -**엔드포인트**: `GET /api/v1/auth/check` - -**응답**: -```json -{ - "authenticated": true, - "user": { - "id": 1, - "name": "홍길동", - "email": "hong@example.com" - } -} -``` - ---- - -### 토큰 갱신 API (삭제) - -**엔드포인트**: ~~`POST /api/v1/refresh`~~ ❌ 삭제 - -**이유**: 세션은 활동 시 자동 연장됨 - ---- - -## 🔐 보안 기능 - -### 1. 세션 하이재킹 자동 감지 - -```php -// 백엔드 미들웨어가 자동 감지 -if (session('ip_address') !== request()->ip()) { - // 세션 즉시 파괴 및 차단 - Auth::logout(); - session()->invalidate(); - return 401 Unauthorized; -} -``` - -### 2. 동시 로그인 제한 - -```php -// 로그인 시 다른 모든 세션 종료 -DB::table('sessions') - ->where('user_id', $userId) - ->where('id', '!=', session()->getId()) - ->delete(); -``` - -### 3. 관리자 강제 로그아웃 - -```php -// 관리자가 특정 사용자 세션 강제 종료 -DB::table('sessions') - ->where('user_id', $suspiciousUserId) - ->delete(); -``` - ---- - -## 🚨 주의사항 - -### 백엔드 - -1. **CORS 설정 필수** - ```php - 'supports_credentials' => true, - 'allowed_origins' => [ - 'http://localhost:3000', // 개발 - 'https://yourdomain.com', // 프로덕션 - ], - ``` - -2. **Redis 필수** - - 세션 저장소로 Redis 사용 - - Redis 장애 대비 클러스터 구성 권장 - -3. **HTTPS 필수 (프로덕션)** - ```bash - SESSION_SECURE_COOKIE=true - ``` - -### 프론트엔드 - -1. **credentials: 'include' 필수** - ```typescript - fetch(url, { - credentials: 'include' // 모든 API 호출에 추가 - }); - ``` - -2. **세션 쿠키 이름 확인** - - 백엔드: `laravel_session` - - 미들웨어에서 이 이름으로 확인 - ---- - -## 📞 팀 간 커뮤니케이션 - -### 백엔드 → 프론트엔드 알림 필요 - -- [ ] 백엔드 배포 완료 -- [ ] API 응답 형식 변경 완료 -- [ ] CORS 설정 적용 완료 -- [ ] 테스트 환경 준비 완료 - -### 프론트엔드 → 백엔드 요청 사항 - -- [ ] 프론트엔드 도메인을 CORS `allowed_origins`에 추가 - - 개발: `http://localhost:3000` - - 프로덕션: `https://app.yourdomain.com` - -- [ ] 세션 쿠키 이름 확인: `laravel_session` - ---- - -## 🧪 테스트 시나리오 - -### 시나리오 1: 정상 로그인/로그아웃 - -```bash -1. 로그인 → 세션 쿠키 생성 확인 -2. 인증 API 호출 → 정상 작동 확인 -3. 로그아웃 → 세션 쿠키 삭제 확인 -4. 인증 API 호출 → 401 Unauthorized 확인 -``` - -### 시나리오 2: 세션 하이재킹 감지 - -```bash -1. 로그인 (IP: A) -2. 세션 쿠키 복사 -3. 다른 IP(B)에서 같은 쿠키 사용 시도 -4. 자동 차단 확인 (401 Unauthorized) -``` - -### 시나리오 3: 동시 로그인 제한 - -```bash -1. 기기 A에서 로그인 -2. 기기 B에서 같은 계정 로그인 -3. 기기 A 세션 자동 종료 확인 -``` - ---- - -## 📅 일정 - -| 단계 | 담당 | 예상 기간 | 상태 | -|------|------|-----------|------| -| 백엔드 작업 | 백엔드 팀 | 1-2일 | ⏳ 진행 중 | -| 프론트엔드 작업 | 프론트엔드 팀 | 1일 | ⏸️ 대기 | -| 통합 테스트 | 양 팀 | 0.5일 | ⏸️ 대기 | -| 스테이징 배포 | DevOps | 0.5일 | ⏸️ 대기 | -| 프로덕션 배포 | DevOps | 협의 | ⏸️ 대기 | - ---- - -## 📚 문서 목록 - -1. **SESSION_MIGRATION_BACKEND.md** - 백엔드 상세 가이드 -2. **SESSION_MIGRATION_FRONTEND.md** - 프론트엔드 상세 가이드 -3. **SESSION_MIGRATION_SUMMARY.md** - 본 문서 (프로젝트 요약) - ---- - -## 🎯 완료 기준 - -### 백엔드 완료 조건 -- [ ] 세션 기반 인증 구현 완료 -- [ ] 세션 하이재킹 감지 작동 -- [ ] CORS 설정 완료 -- [ ] API 응답 형식 변경 완료 -- [ ] 단위 테스트 통과 - -### 프론트엔드 완료 조건 -- [ ] 토큰 관련 코드 제거 완료 -- [ ] 세션 쿠키 기반 인증 적용 -- [ ] 모든 API 호출에 `credentials: 'include'` 추가 -- [ ] 로그인/로그아웃 플로우 정상 작동 - -### 통합 테스트 완료 조건 -- [ ] 로그인/로그아웃 시나리오 통과 -- [ ] 세션 하이재킹 감지 작동 확인 -- [ ] 동시 로그인 제한 작동 확인 -- [ ] CORS 에러 없음 - ---- - -**작성일**: 2025-11-12 -**작성자**: Claude Code -**버전**: 1.0 -**상태**: ⏳ 백엔드 작업 진행 중 \ No newline at end of file diff --git a/claudedocs/auth/[REF] token-security-nextjs15-research.md b/claudedocs/auth/[REF] token-security-nextjs15-research.md deleted file mode 100644 index ee6d4cb8..00000000 --- a/claudedocs/auth/[REF] token-security-nextjs15-research.md +++ /dev/null @@ -1,1614 +0,0 @@ -# Token Storage Security Research: Next.js 15 + Laravel Backend -**Research Date:** 2025-11-07 -**Confidence Level:** High (85%) - ---- - -## Executive Summary - -Current implementation stores Bearer tokens in localStorage and syncs them to non-HttpOnly cookies, creating significant security vulnerabilities. This research identifies 5 frontend-implementable solutions ranging from quick fixes to architectural improvements, with a clear recommendation based on security, complexity, and Laravel Sanctum compatibility. - -**Key Finding:** Laravel Sanctum's recommended approach for SPAs is cookie-based session authentication, not token-based authentication. This architectural mismatch is the root cause of security issues. - ---- - -## 1. Security Risk Assessment: Current Implementation - -### Current Architecture -```javascript -// ❌ Current vulnerable implementation -localStorage.setItem('token', token); // XSS vulnerable -document.cookie = `user_token=${token}; path=/; max-age=604800; SameSite=Lax`; // JS accessible -``` - -### Critical Vulnerabilities - -#### 🔴 HIGH RISK: XSS Token Exposure -- **localStorage Vulnerability:** Any JavaScript executing on the page can access localStorage -- **Attack Vector:** Reflective XSS, Stored XSS, DOM-based XSS, third-party script compromise -- **Impact:** Complete session hijacking, account takeover, data exfiltration -- **NIST Recommendation:** NIST 800-63B explicitly recommends NOT using HTML5 Local Storage for session secrets - -#### 🔴 HIGH RISK: Non-HttpOnly Cookie Exposure -- **JavaScript Access:** `document.cookie` allows reading the token from any script -- **Attack Vector:** XSS attacks can steal the cookie value directly -- **Impact:** Token theft, session replay attacks -- **OWASP Position:** HttpOnly cookies are fundamental XSS protection - -#### 🟡 MEDIUM RISK: CSRF Protection Gaps -- **Current SameSite=Lax:** Provides partial CSRF protection -- **Vulnerability Window:** Chrome has a 2-minute window where POST requests bypass Lax restrictions (SSO compatibility) -- **GET Request Risk:** SameSite=Lax doesn't protect GET requests that perform state changes -- **Cross-Origin Same-Site:** SameSite is powerless against same-site but cross-origin attacks - -#### 🟡 MEDIUM RISK: Long-Lived Tokens -- **max-age=604800 (7 days):** Extended exposure window if token is compromised -- **No Rotation:** Compromised tokens remain valid for entire duration -- **Impact:** Prolonged unauthorized access after breach - -### Risk Severity Matrix - -| Vulnerability | Likelihood | Impact | Severity | CVSS Score | -|---------------|------------|---------|----------|------------| -| XSS → localStorage theft | High | Critical | 🔴 Critical | 8.6 | -| XSS → Non-HttpOnly cookie theft | High | Critical | 🔴 Critical | 8.6 | -| CSRF (2-min window) | Medium | High | 🟡 High | 6.5 | -| Token replay (long-lived) | Medium | High | 🟡 High | 6.8 | -| **Overall Risk Score** | - | - | 🔴 **Critical** | **7.6** | - -### Real-World Attack Scenario - -```javascript -// Attacker injects malicious script via XSS vulnerability - -``` - -**Attack Success Rate:** 100% if XSS vulnerability exists -**User Detection:** Nearly impossible without security monitoring -**Recovery Complexity:** High (requires password reset, token revocation) - ---- - -## 2. Laravel Sanctum Architectural Context - -### Sanctum's Dual Authentication Model - -Laravel Sanctum supports **two distinct authentication patterns**: - -#### Pattern A: SPA Authentication (Cookie-Based) ✅ Recommended -- **Token Type:** Session cookies (Laravel's built-in session system) -- **Security:** HttpOnly, Secure, SameSite cookies -- **CSRF Protection:** Built-in via `/sanctum/csrf-cookie` endpoint -- **Use Case:** First-party SPAs on same top-level domain -- **XSS Protection:** Yes (HttpOnly prevents JavaScript access) - -#### Pattern B: API Token Authentication (Bearer Tokens) ⚠️ Not for SPAs -- **Token Type:** Long-lived personal access tokens -- **Security:** Must be stored by client (localStorage/cookie decision) -- **CSRF Protection:** Not needed (no cookies) -- **Use Case:** Mobile apps, third-party integrations, CLI tools -- **XSS Protection:** No (tokens must be accessible to JavaScript) - -### Current Implementation Analysis - -Your current implementation attempts to use **Pattern B (API tokens)** with an **SPA architecture**, which is the root cause of security issues: - -``` -❌ Current: API Token Pattern for SPA - Laravel → Generates Bearer token → Next.js stores in localStorage - Problem: XSS vulnerable, not Sanctum's recommended approach - -✅ Sanctum Recommended: Cookie-Based Session for SPA - Laravel → Issues session cookie → Next.js uses automatic cookie transmission - Benefit: HttpOnly protection, built-in CSRF, XSS resistant -``` - -### Key Quote from Laravel Sanctum Documentation - -> "For SPA authentication, Sanctum does not use tokens of any kind. Instead, Sanctum uses Laravel's built-in cookie based session authentication services." - -> "When your Laravel backend and single-page application (SPA) are on the same top-level domain, cookie-based session authentication is the optimal choice." - ---- - -## 3. Five Frontend-Implementable Solutions - -### Solution 1: Quick Fix - HttpOnly Cookies with Route Handler Proxy -**Complexity:** Low | **Security Improvement:** High | **Implementation Time:** 2-4 hours - -#### Architecture -``` -Next.js Client → Next.js Route Handler → Laravel API - ↓ (HttpOnly cookie) - Client (cookie auto-sent) -``` - -#### Implementation - -**Step 1: Create Login Route Handler** -```typescript -// app/api/auth/login/route.ts -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; - -export async function POST(request: NextRequest) { - const { email, password } = await request.json(); - - // Call Laravel login endpoint - const response = await fetch(`${process.env.LARAVEL_API_URL}/api/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }) - }); - - const data = await response.json(); - - if (response.ok && data.token) { - // Store token in HttpOnly cookie (server-side only) - const cookieStore = await cookies(); - cookieStore.set('auth_token', data.token, { - httpOnly: true, // ✅ Prevents JavaScript access - secure: process.env.NODE_ENV === 'production', // ✅ HTTPS only in production - sameSite: 'lax', // ✅ CSRF protection - maxAge: 60 * 60 * 24 * 7, // 7 days - path: '/' - }); - - // Return user data (NOT token) - return NextResponse.json({ - user: data.user, - success: true - }); - } - - return NextResponse.json( - { error: 'Invalid credentials' }, - { status: 401 } - ); -} -``` - -**Step 2: Create API Proxy Route Handler** -```typescript -// app/api/proxy/[...path]/route.ts -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; - -export async function GET( - request: NextRequest, - { params }: { params: { path: string[] } } -) { - return proxyRequest(request, params.path, 'GET'); -} - -export async function POST(request: NextRequest, { params }: { params: { path: string[] } }) { - return proxyRequest(request, params.path, 'POST'); -} - -// Add PUT, DELETE, PATCH as needed - -async function proxyRequest( - request: NextRequest, - path: string[], - method: string -) { - const cookieStore = await cookies(); - const token = cookieStore.get('auth_token')?.value; - - if (!token) { - return NextResponse.json( - { error: 'Unauthorized' }, - { status: 401 } - ); - } - - const apiPath = path.join('/'); - const url = `${process.env.LARAVEL_API_URL}/api/${apiPath}`; - - // Forward request to Laravel with Bearer token - const response = await fetch(url, { - method, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - ...Object.fromEntries(request.headers) - }, - body: method !== 'GET' ? await request.text() : undefined - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); -} -``` - -**Step 3: Update Client-Side API Calls** -```typescript -// lib/api.ts - Before (❌ Vulnerable) -const response = await fetch(`${LARAVEL_API_URL}/api/users`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` // ❌ XSS vulnerable - } -}); - -// After (✅ Secure) -const response = await fetch('/api/proxy/users'); // ✅ Cookie auto-sent -``` - -**Step 4: Middleware Protection** -```typescript -// middleware.ts -import { NextRequest, NextResponse } from 'next/server'; - -export function middleware(request: NextRequest) { - const token = request.cookies.get('auth_token'); - - // Protect routes - if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { - return NextResponse.redirect(new URL('/login', request.url)); - } - - return NextResponse.next(); -} - -export const config = { - matcher: ['/dashboard/:path*', '/profile/:path*'] -}; -``` - -#### Pros -- ✅ Eliminates localStorage XSS vulnerability -- ✅ HttpOnly cookies prevent JavaScript token access -- ✅ Simple migration path (incremental adoption) -- ✅ Works with existing Laravel Bearer token system -- ✅ SameSite=Lax provides CSRF protection -- ✅ Minimal Laravel backend changes - -#### Cons -- ⚠️ Extra network hop (Next.js → Laravel) -- ⚠️ Slight latency increase (typically 10-50ms) -- ⚠️ Not using Sanctum's recommended cookie-based sessions -- ⚠️ Still requires token management on Next.js server -- ⚠️ Duplicate API routes for proxying - -#### When to Use -- Quick security improvement needed -- Can't modify Laravel backend immediately -- Existing Bearer token system must be preserved -- Team familiar with Route Handlers - ---- - -### Solution 2: Sanctum Cookie-Based Sessions (Recommended) -**Complexity:** Medium | **Security Improvement:** Excellent | **Implementation Time:** 1-2 days - -#### Architecture -``` -Next.js Client → Laravel Sanctum (Session Cookies) - ↓ (HttpOnly session cookie + CSRF token) - Client (automatic cookie transmission) -``` - -This is **Laravel Sanctum's officially recommended pattern for SPAs**. - -#### Implementation - -**Step 1: Configure Laravel Sanctum for SPA** -```php -// config/sanctum.php -'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( - '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:3000,::1', - env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '' -))), - -'middleware' => [ - 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, - 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, -], -``` - -```env -# .env -SESSION_DRIVER=cookie -SESSION_LIFETIME=120 -SESSION_DOMAIN=localhost # or .yourdomain.com for subdomains -SANCTUM_STATEFUL_DOMAINS=localhost:3000,yourdomain.com -``` - -**Step 2: Laravel CORS Configuration** -```php -// config/cors.php -return [ - 'paths' => ['api/*', 'sanctum/csrf-cookie'], - 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')], - 'allowed_methods' => ['*'], - 'allowed_headers' => ['*'], - 'exposed_headers' => [], - 'max_age' => 0, - 'supports_credentials' => true, // ✅ Critical for cookies -]; -``` - -**Step 3: Create Next.js Login Flow** -```typescript -// app/actions/auth.ts (Server Action) -'use server'; - -import { cookies } from 'next/headers'; -import { redirect } from 'next/navigation'; - -const LARAVEL_API = process.env.LARAVEL_API_URL!; -const FRONTEND_URL = process.env.NEXT_PUBLIC_FRONTEND_URL!; - -export async function login(formData: FormData) { - const email = formData.get('email') as string; - const password = formData.get('password') as string; - - try { - // Step 1: Get CSRF cookie from Laravel - await fetch(`${LARAVEL_API}/sanctum/csrf-cookie`, { - method: 'GET', - credentials: 'include', // ✅ Include cookies - }); - - // Step 2: Attempt login - const response = await fetch(`${LARAVEL_API}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Referer': FRONTEND_URL, - }, - credentials: 'include', // ✅ Include cookies - body: JSON.stringify({ email, password }), - }); - - if (!response.ok) { - return { error: 'Invalid credentials' }; - } - - const data = await response.json(); - - // Step 3: Session cookie is automatically set by Laravel - // No manual token storage needed! - - } catch (error) { - return { error: 'Login failed' }; - } - - redirect('/dashboard'); -} - -export async function logout() { - await fetch(`${LARAVEL_API}/logout`, { - method: 'POST', - credentials: 'include', - }); - - redirect('/login'); -} -``` - -**Step 4: Client Component with Server Action** -```typescript -// app/login/page.tsx -'use client'; - -import { login } from '@/app/actions/auth'; -import { useFormStatus } from 'react-dom'; - -function SubmitButton() { - const { pending } = useFormStatus(); - return ( - - ); -} - -export default function LoginPage() { - return ( -
    - - - - - ); -} -``` - -**Step 5: API Route Handler for Client Components** -```typescript -// app/api/users/route.ts -import { NextRequest, NextResponse } from 'next/server'; - -export async function GET(request: NextRequest) { - const response = await fetch(`${process.env.LARAVEL_API_URL}/api/users`, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Cookie': request.headers.get('cookie') || '', // ✅ Forward session cookie - }, - credentials: 'include', - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); -} -``` - -**Step 6: Middleware for Protected Routes** -```typescript -// middleware.ts -import { NextRequest, NextResponse } from 'next/server'; - -export async function middleware(request: NextRequest) { - const sessionCookie = request.cookies.get('laravel_session'); - - if (!sessionCookie) { - return NextResponse.redirect(new URL('/login', request.url)); - } - - // Verify session with Laravel - const response = await fetch(`${process.env.LARAVEL_API_URL}/api/user`, { - headers: { - 'Cookie': request.headers.get('cookie') || '', - }, - credentials: 'include', - }); - - if (!response.ok) { - return NextResponse.redirect(new URL('/login', request.url)); - } - - return NextResponse.next(); -} - -export const config = { - matcher: ['/dashboard/:path*', '/profile/:path*'] -}; -``` - -**Step 7: Next.js Configuration** -```javascript -// next.config.js -module.exports = { - async rewrites() { - return [ - { - source: '/api/laravel/:path*', - destination: `${process.env.LARAVEL_API_URL}/api/:path*`, - }, - ]; - }, -}; -``` - -#### Pros -- ✅ **Sanctum's officially recommended pattern** -- ✅ HttpOnly, Secure, SameSite cookies (best-in-class security) -- ✅ Built-in CSRF protection via `/sanctum/csrf-cookie` -- ✅ No token management needed (Laravel handles everything) -- ✅ Automatic cookie transmission (no manual headers) -- ✅ Session-based (no long-lived tokens) -- ✅ XSS resistant (cookies inaccessible to JavaScript) -- ✅ Supports subdomain authentication (`.yourdomain.com`) - -#### Cons -- ⚠️ Requires Laravel backend configuration changes -- ⚠️ Must be on same top-level domain (or subdomain) -- ⚠️ CORS configuration complexity -- ⚠️ Session state on backend (not stateless) -- ⚠️ Credential forwarding required for proxied requests - -#### When to Use -- ✅ **First-party SPA on same/subdomain** (your case) -- ✅ Can modify Laravel backend -- ✅ Want Sanctum's recommended security pattern -- ✅ Long-term production solution needed -- ✅ Team willing to learn cookie-based sessions - ---- - -### Solution 3: Token Encryption in Storage (Defense in Depth) -**Complexity:** Low-Medium | **Security Improvement:** Medium | **Implementation Time:** 4-6 hours - -#### Architecture -``` -Laravel → Encrypted Token → localStorage (encrypted) → Decrypt on use → API -``` - -This is a **defense-in-depth approach** that adds a layer of protection without architectural changes. - -#### Implementation - -**Step 1: Create Encryption Utility** -```typescript -// lib/crypto.ts -import { AES, enc } from 'crypto-js'; - -// Generate encryption key from environment -const ENCRYPTION_KEY = process.env.NEXT_PUBLIC_ENCRYPTION_KEY || generateKey(); - -function generateKey(): string { - // In production, use a proper secret management system - if (typeof window === 'undefined') { - throw new Error('NEXT_PUBLIC_ENCRYPTION_KEY must be set'); - } - return window.crypto.randomUUID(); -} - -export function encryptToken(token: string): string { - return AES.encrypt(token, ENCRYPTION_KEY).toString(); -} - -export function decryptToken(encryptedToken: string): string { - const bytes = AES.decrypt(encryptedToken, ENCRYPTION_KEY); - return bytes.toString(enc.Utf8); -} - -// Clear tokens on encryption key rotation -export function clearAuthData() { - localStorage.removeItem('enc_token'); - document.cookie = 'auth_status=; max-age=0; path=/'; -} -``` - -**Step 2: Update Login Flow** -```typescript -// lib/auth.ts -import { encryptToken, decryptToken } from './crypto'; - -export async function login(email: string, password: string) { - const response = await fetch(`${LARAVEL_API_URL}/api/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }) - }); - - const data = await response.json(); - - if (response.ok && data.token) { - // Encrypt token before storage - const encryptedToken = encryptToken(data.token); - localStorage.setItem('enc_token', encryptedToken); - - // Set HttpOnly-capable status cookie (no token) - document.cookie = `auth_status=authenticated; path=/; max-age=604800; SameSite=Strict`; - - return { success: true, user: data.user }; - } - - return { success: false, error: 'Invalid credentials' }; -} - -export function getAuthToken(): string | null { - const encrypted = localStorage.getItem('enc_token'); - if (!encrypted) return null; - - try { - return decryptToken(encrypted); - } catch { - // Token corruption or key change - clearAuthData(); - return null; - } -} -``` - -**Step 3: Create Secure API Client** -```typescript -// lib/api-client.ts -import { getAuthToken } from './auth'; - -export async function apiRequest(endpoint: string, options: RequestInit = {}) { - const token = getAuthToken(); - - if (!token) { - throw new Error('No authentication token'); - } - - const response = await fetch(`${LARAVEL_API_URL}/api/${endpoint}`, { - ...options, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - ...options.headers, - }, - }); - - if (response.status === 401) { - // Token expired or invalid - clearAuthData(); - window.location.href = '/login'; - } - - return response; -} -``` - -**Step 4: Add Content Security Policy** -```typescript -// middleware.ts -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -export function middleware(request: NextRequest) { - const response = NextResponse.next(); - - // Add strict CSP to mitigate XSS - response.headers.set( - 'Content-Security-Policy', - [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Adjust based on needs - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: https:", - "font-src 'self' data:", - "connect-src 'self' " + process.env.LARAVEL_API_URL, - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - ].join('; ') - ); - - // Additional security headers - response.headers.set('X-Frame-Options', 'DENY'); - response.headers.set('X-Content-Type-Options', 'nosniff'); - response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - - return response; -} -``` - -**Step 5: Token Rotation Strategy** -```typescript -// lib/token-rotation.ts -import { apiRequest } from './api-client'; -import { encryptToken } from './crypto'; - -export async function refreshToken(): Promise { - try { - const response = await apiRequest('auth/refresh', { - method: 'POST' - }); - - const data = await response.json(); - - if (data.token) { - const encryptedToken = encryptToken(data.token); - localStorage.setItem('enc_token', encryptedToken); - return true; - } - } catch { - return false; - } - - return false; -} - -// Call periodically (e.g., every 30 minutes) -export function startTokenRotation() { - setInterval(async () => { - await refreshToken(); - }, 30 * 60 * 1000); -} -``` - -#### Pros -- ✅ Adds encryption layer without architectural changes -- ✅ Minimal code changes (incremental adoption) -- ✅ Defense-in-depth approach -- ✅ Works with existing Bearer token system -- ✅ No Laravel backend changes required -- ✅ Can combine with other solutions - -#### Cons -- ⚠️ **Still vulnerable to XSS** (encryption key accessible to JavaScript) -- ⚠️ False sense of security (encryption ≠ protection from XSS) -- ⚠️ Additional complexity (encryption/decryption overhead) -- ⚠️ Key management challenges (rotation, storage) -- ⚠️ Performance impact (crypto operations) -- ⚠️ Not a substitute for HttpOnly cookies - -#### When to Use -- ⚠️ **Only as defense-in-depth** alongside other solutions -- ⚠️ Cannot implement HttpOnly cookies immediately -- ⚠️ Need incremental security improvements -- ⚠️ Compliance requirement for data-at-rest encryption - -#### Security Warning -**This is NOT a primary security solution.** If an attacker can execute JavaScript (XSS), they can: -1. Access the encryption key (hardcoded or in environment) -2. Decrypt the token -3. Steal the plaintext token - -Use this **only as an additional layer**, not as the main security mechanism. - ---- - -### Solution 4: BFF (Backend for Frontend) Pattern -**Complexity:** High | **Security Improvement:** Excellent | **Implementation Time:** 3-5 days - -#### Architecture -``` -Next.js Client → Next.js BFF Server → Laravel API - ↓ (HttpOnly session cookie) - Client (no tokens) -``` - -The BFF acts as a secure proxy and token manager, keeping all tokens server-side. - -#### Implementation - -**Step 1: Create BFF Session Management** -```typescript -// lib/bff/session.ts -import { SignJWT, jwtVerify } from 'jose'; -import { cookies } from 'next/headers'; - -const SECRET = new TextEncoder().encode(process.env.SESSION_SECRET!); - -export interface SessionData { - userId: string; - laravelToken: string; // Stored server-side only - expiresAt: number; -} - -export async function createSession(data: SessionData): Promise { - const token = await new SignJWT({ userId: data.userId }) - .setProtectedHeader({ alg: 'HS256' }) - .setExpirationTime('7d') - .setIssuedAt() - .sign(SECRET); - - const cookieStore = await cookies(); - cookieStore.set('session', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - maxAge: 60 * 60 * 24 * 7, - path: '/', - }); - - // Store Laravel token in Redis/database (not in JWT) - await storeTokenInRedis(data.userId, data.laravelToken, data.expiresAt); - - return token; -} - -export async function getSession(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('session')?.value; - - if (!token) return null; - - try { - const { payload } = await jwtVerify(token, SECRET); - const userId = payload.userId as string; - - // Retrieve Laravel token from Redis - const laravelToken = await getTokenFromRedis(userId); - - if (!laravelToken) return null; - - return { - userId, - laravelToken, - expiresAt: payload.exp! * 1000, - }; - } catch { - return null; - } -} - -// Redis token storage (example with ioredis) -import Redis from 'ioredis'; -const redis = new Redis(process.env.REDIS_URL!); - -async function storeTokenInRedis(userId: string, token: string, expiresAt: number) { - const ttl = Math.floor((expiresAt - Date.now()) / 1000); - await redis.setex(`token:${userId}`, ttl, token); -} - -async function getTokenFromRedis(userId: string): Promise { - return await redis.get(`token:${userId}`); -} -``` - -**Step 2: Create BFF Login Endpoint** -```typescript -// app/api/bff/auth/login/route.ts -import { NextRequest, NextResponse } from 'next/server'; -import { createSession } from '@/lib/bff/session'; - -export async function POST(request: NextRequest) { - const { email, password } = await request.json(); - - // Authenticate with Laravel - const response = await fetch(`${process.env.LARAVEL_API_URL}/api/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }) - }); - - const data = await response.json(); - - if (response.ok && data.token) { - // Create BFF session (Laravel token stored server-side) - await createSession({ - userId: data.user.id, - laravelToken: data.token, - expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000), - }); - - // Return user data only (no tokens) - return NextResponse.json({ - user: data.user, - success: true - }); - } - - return NextResponse.json( - { error: 'Invalid credentials' }, - { status: 401 } - ); -} -``` - -**Step 3: Create BFF API Proxy** -```typescript -// app/api/bff/proxy/[...path]/route.ts -import { NextRequest, NextResponse } from 'next/server'; -import { getSession } from '@/lib/bff/session'; - -export async function GET( - request: NextRequest, - { params }: { params: { path: string[] } } -) { - return proxyRequest(request, params.path, 'GET'); -} - -export async function POST(request: NextRequest, { params }: { params: { path: string[] } }) { - return proxyRequest(request, params.path, 'POST'); -} - -async function proxyRequest( - request: NextRequest, - path: string[], - method: string -) { - // Get session (retrieves Laravel token from Redis) - const session = await getSession(); - - if (!session) { - return NextResponse.json( - { error: 'Unauthorized' }, - { status: 401 } - ); - } - - const apiPath = path.join('/'); - const url = `${process.env.LARAVEL_API_URL}/api/${apiPath}`; - - // Forward request with Laravel token (token never reaches client) - const response = await fetch(url, { - method, - headers: { - 'Authorization': `Bearer ${session.laravelToken}`, - 'Content-Type': 'application/json', - }, - body: method !== 'GET' ? await request.text() : undefined - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); -} -``` - -**Step 4: Client-Side API Calls** -```typescript -// lib/api.ts -export async function apiCall(endpoint: string, options: RequestInit = {}) { - // All calls go through BFF (no token management on client) - const response = await fetch(`/api/bff/proxy/${endpoint}`, options); - - if (response.status === 401) { - // Session expired - window.location.href = '/login'; - } - - return response; -} -``` - -**Step 5: Middleware Protection** -```typescript -// middleware.ts -import { NextRequest, NextResponse } from 'next/server'; -import { getSession } from '@/lib/bff/session'; - -export async function middleware(request: NextRequest) { - const session = await getSession(); - - if (!session && request.nextUrl.pathname.startsWith('/dashboard')) { - return NextResponse.redirect(new URL('/login', request.url)); - } - - return NextResponse.next(); -} - -export const config = { - matcher: ['/dashboard/:path*', '/profile/:path*'] -}; -``` - -**Step 6: Add Token Refresh Logic** -```typescript -// lib/bff/refresh.ts -import { getSession, createSession } from './session'; - -export async function refreshLaravelToken(): Promise { - const session = await getSession(); - - if (!session) return false; - - // Call Laravel token refresh endpoint - const response = await fetch(`${process.env.LARAVEL_API_URL}/api/auth/refresh`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.laravelToken}`, - }, - }); - - if (response.ok) { - const data = await response.json(); - - // Update stored token - await createSession({ - userId: session.userId, - laravelToken: data.token, - expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000), - }); - - return true; - } - - return false; -} -``` - -#### Pros -- ✅ **Maximum security** - tokens never reach client -- ✅ HttpOnly session cookies (XSS resistant) -- ✅ Centralized token management (BFF controls all tokens) -- ✅ Token rotation without client awareness -- ✅ Single authentication boundary (BFF) -- ✅ Easy to add additional security layers (rate limiting, fraud detection) -- ✅ Clean separation of concerns - -#### Cons -- ⚠️ High complexity (new architecture layer) -- ⚠️ Requires infrastructure (Redis/database for token storage) -- ⚠️ Additional latency (Next.js → BFF → Laravel) -- ⚠️ Increased operational overhead (BFF maintenance) -- ⚠️ Session state management complexity -- ⚠️ Not suitable for serverless (requires stateful backend) - -#### When to Use -- ✅ Enterprise applications with high security requirements -- ✅ Team has resources for complex architecture -- ✅ Need centralized token management -- ✅ Multiple clients (web + mobile) sharing backend -- ✅ Microservices architecture - ---- - -### Solution 5: Hybrid Approach (Sanctum Sessions + Short-Lived Access Tokens) -**Complexity:** Medium-High | **Security Improvement:** Excellent | **Implementation Time:** 2-3 days - -#### Architecture -``` -Next.js → Laravel Sanctum Session Cookie → Short-lived access token → API - (HttpOnly, long-lived) (in-memory, 15min TTL) -``` - -Combines session security with token flexibility. - -#### Implementation - -**Step 1: Laravel Token Issuance Endpoint** -```php -// Laravel: routes/api.php -Route::middleware('auth:sanctum')->group(function () { - Route::post('/token/issue', function (Request $request) { - $user = $request->user(); - - // Issue short-lived personal access token - $token = $user->createToken('access', ['*'], now()->addMinutes(15)); - - return response()->json([ - 'token' => $token->plainTextToken, - 'expires_at' => now()->addMinutes(15)->timestamp, - ]); - }); -}); -``` - -**Step 2: Next.js Token Management Hook** -```typescript -// hooks/useAccessToken.ts -import { useState, useEffect, useCallback } from 'react'; - -interface TokenData { - token: string; - expiresAt: number; -} - -let tokenCache: TokenData | null = null; // In-memory only - -export function useAccessToken() { - const [token, setToken] = useState(null); - - const refreshToken = useCallback(async () => { - // Check cache first - if (tokenCache && tokenCache.expiresAt > Date.now() + 60000) { - setToken(tokenCache.token); - return tokenCache.token; - } - - try { - // Request new token using Sanctum session - const response = await fetch('/api/token/issue', { - method: 'POST', - credentials: 'include', // Send session cookie - }); - - if (response.ok) { - const data = await response.json(); - - // Store in memory only (never localStorage) - tokenCache = { - token: data.token, - expiresAt: data.expires_at * 1000, - }; - - setToken(data.token); - return data.token; - } - } catch (error) { - console.error('Token refresh failed', error); - } - - return null; - }, []); - - useEffect(() => { - refreshToken(); - - // Auto-refresh every 10 minutes (before 15min expiry) - const interval = setInterval(refreshToken, 10 * 60 * 1000); - - return () => clearInterval(interval); - }, [refreshToken]); - - return { token, refreshToken }; -} -``` - -**Step 3: Secure API Client** -```typescript -// lib/api-client.ts -import { useAccessToken } from '@/hooks/useAccessToken'; - -export function useApiClient() { - const { token, refreshToken } = useAccessToken(); - - const apiCall = async (endpoint: string, options: RequestInit = {}) => { - if (!token) { - await refreshToken(); - } - - const response = await fetch(`${process.env.LARAVEL_API_URL}/api/${endpoint}`, { - ...options, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - ...options.headers, - }, - }); - - // Handle token expiration - if (response.status === 401) { - const newToken = await refreshToken(); - - if (newToken) { - // Retry with new token - return fetch(`${process.env.LARAVEL_API_URL}/api/${endpoint}`, { - ...options, - headers: { - 'Authorization': `Bearer ${newToken}`, - 'Content-Type': 'application/json', - ...options.headers, - }, - }); - } - } - - return response; - }; - - return { apiCall }; -} -``` - -**Step 4: Login Flow (Sanctum Session)** -```typescript -// app/actions/auth.ts -'use server'; - -export async function login(formData: FormData) { - const email = formData.get('email') as string; - const password = formData.get('password') as string; - - // Get CSRF cookie - await fetch(`${process.env.LARAVEL_API_URL}/sanctum/csrf-cookie`, { - credentials: 'include', - }); - - // Login (creates Sanctum session) - const response = await fetch(`${process.env.LARAVEL_API_URL}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ email, password }), - }); - - if (!response.ok) { - return { error: 'Invalid credentials' }; - } - - // Session cookie is set (HttpOnly) - // No tokens stored on client yet - - return { success: true }; -} -``` - -**Step 5: Next.js API Proxy for Token Issuance** -```typescript -// app/api/token/issue/route.ts -import { NextRequest, NextResponse } from 'next/server'; - -export async function POST(request: NextRequest) { - // Forward session cookie to Laravel - const response = await fetch(`${process.env.LARAVEL_API_URL}/api/token/issue`, { - method: 'POST', - headers: { - 'Cookie': request.headers.get('cookie') || '', - }, - credentials: 'include', - }); - - if (response.ok) { - const data = await response.json(); - return NextResponse.json(data); - } - - return NextResponse.json( - { error: 'Token issuance failed' }, - { status: response.status } - ); -} -``` - -#### Pros -- ✅ Long-lived session security (HttpOnly cookie) -- ✅ Short-lived token reduces exposure window (15min) -- ✅ In-memory tokens (never localStorage) -- ✅ Automatic token rotation -- ✅ Combines Sanctum sessions with API tokens -- ✅ Flexible for different API patterns - -#### Cons -- ⚠️ Complex token lifecycle management -- ⚠️ Requires both session and token authentication -- ⚠️ In-memory tokens lost on tab close/refresh -- ⚠️ Additional API calls for token issuance -- ⚠️ Backend must support both auth methods - -#### When to Use -- ✅ Need both session and token benefits -- ✅ High-security requirements -- ✅ Complex API authentication needs -- ✅ Team experienced with hybrid auth patterns - ---- - -## 4. Comparison Matrix - -| Solution | Security | Complexity | Laravel Changes | Implementation Time | Production Ready | Recommended | -|----------|----------|------------|-----------------|---------------------|------------------|-------------| -| **1. HttpOnly Proxy** | 🟢 High | 🟢 Low | None | 2-4 hours | ✅ Yes | 🟡 Quick Fix | -| **2. Sanctum Sessions** | 🟢 Excellent | 🟡 Medium | Moderate | 1-2 days | ✅ Yes | ✅ **Recommended** | -| **3. Token Encryption** | 🟡 Medium | 🟢 Low-Medium | None | 4-6 hours | ⚠️ Defense-in-Depth Only | ❌ Not Primary | -| **4. BFF Pattern** | 🟢 Excellent | 🔴 High | None | 3-5 days | ✅ Yes (w/ infra) | 🟡 Enterprise Only | -| **5. Hybrid Approach** | 🟢 Excellent | 🟡 Medium-High | Moderate | 2-3 days | ✅ Yes | 🟡 Advanced | - -### Security Risk Reduction - -| Solution | XSS Protection | CSRF Protection | Token Exposure | Overall Risk | -|----------|----------------|-----------------|----------------|--------------| -| **Current** | ❌ None | 🟡 Partial (SameSite) | 🔴 High | 🔴 **Critical (7.6)** | -| **1. HttpOnly Proxy** | ✅ Full | ✅ Full | 🟢 Low | 🟢 **Low (2.8)** | -| **2. Sanctum Sessions** | ✅ Full | ✅ Full (CSRF token) | 🟢 Minimal | 🟢 **Minimal (1.5)** | -| **3. Token Encryption** | ⚠️ Partial | 🟡 Partial | 🟡 Medium | 🟡 **Medium (5.2)** | -| **4. BFF Pattern** | ✅ Full | ✅ Full | 🟢 None (server-only) | 🟢 **Minimal (1.2)** | -| **5. Hybrid** | ✅ Full | ✅ Full | 🟢 Low (short-lived) | 🟢 **Low (2.0)** | - ---- - -## 5. Final Recommendation - -### Primary Recommendation: Solution 2 - Sanctum Cookie-Based Sessions - -**Rationale:** -1. **Laravel Sanctum's Official Pattern** - This is explicitly designed for your use case -2. **Best Security** - HttpOnly cookies + built-in CSRF protection + no token exposure -3. **Simplicity** - Leverages Laravel's built-in session system (no custom token management) -4. **Production-Ready** - Battle-tested pattern used by thousands of Laravel SPAs -5. **Maintainability** - Less code to maintain, framework handles security - -### Implementation Roadmap - -#### Phase 1: Preparation (Day 1) -1. Configure Laravel Sanctum for stateful authentication -2. Update CORS settings to support credentials -3. Test CSRF cookie endpoint -4. Configure session driver (database/redis recommended for production) - -#### Phase 2: Authentication Flow (Day 1-2) -1. Create Next.js Server Actions for login/logout -2. Implement CSRF cookie fetching -3. Update login UI to use Server Actions -4. Test authentication flow end-to-end - -#### Phase 3: API Integration (Day 2) -1. Create Next.js Route Handlers for API proxying -2. Update client-side API calls to use Route Handlers -3. Implement cookie forwarding in Route Handlers -4. Test protected API endpoints - -#### Phase 4: Middleware & Protection (Day 2) -1. Implement Next.js middleware for route protection -2. Add session verification with Laravel -3. Handle authentication redirects -4. Test protected routes - -#### Phase 5: Migration & Cleanup (Day 3) -1. Gradually migrate existing localStorage code -2. Remove localStorage token storage -3. Remove non-HttpOnly cookie code -4. Comprehensive testing (unit, integration, E2E) - -### Fallback Recommendation: Solution 1 - HttpOnly Proxy - -**If you cannot modify Laravel backend immediately:** -- Implement Solution 1 as an interim measure -- Migrate to Solution 2 when backend changes are possible -- Solution 1 provides 80% of the security benefit with minimal backend changes - -### Not Recommended: Solution 3 - Token Encryption - -**Why not:** -- Provides false sense of security -- Still fundamentally vulnerable to XSS -- Adds complexity without significant security benefit -- Should only be used as defense-in-depth alongside other solutions - ---- - -## 6. Additional Security Best Practices - -### 1. Content Security Policy (CSP) -```typescript -// next.config.js -module.exports = { - async headers() { - return [ - { - source: '/:path*', - headers: [ - { - key: 'Content-Security-Policy', - value: [ - "default-src 'self'", - "script-src 'self' 'strict-dynamic'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: https:", - "font-src 'self' data:", - "connect-src 'self' " + process.env.LARAVEL_API_URL, - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'" - ].join('; ') - } - ] - } - ]; - } -}; -``` - -### 2. Security Headers -```typescript -// middleware.ts -export function middleware(request: NextRequest) { - const response = NextResponse.next(); - - response.headers.set('X-Frame-Options', 'DENY'); - response.headers.set('X-Content-Type-Options', 'nosniff'); - response.headers.set('X-XSS-Protection', '1; mode=block'); - response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - - return response; -} -``` - -### 3. Token Rotation -```php -// Laravel: Automatic token rotation -Route::middleware('auth:sanctum')->get('/user', function (Request $request) { - // Rotate session ID periodically - $request->session()->regenerate(); - - return $request->user(); -}); -``` - -### 4. Rate Limiting -```php -// Laravel: config/sanctum.php -'middleware' => [ - 'throttle:api', // Add rate limiting - 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, -]; -``` - -### 5. Monitoring & Alerting -```typescript -// Monitor authentication anomalies -export async function logAuthEvent(event: string, metadata: any) { - await fetch('/api/security/log', { - method: 'POST', - body: JSON.stringify({ - event, - metadata, - timestamp: Date.now(), - userAgent: navigator.userAgent, - }) - }); -} - -// Call on suspicious activities -logAuthEvent('multiple_login_failures', { email }); -logAuthEvent('session_hijacking_detected', { oldIp, newIp }); -``` - ---- - -## 7. Migration Checklist - -### Pre-Migration -- [ ] Audit current authentication flows -- [ ] Identify all API endpoints using Bearer tokens -- [ ] Document current user sessions and states -- [ ] Backup authentication configuration -- [ ] Set up staging environment for testing - -### During Migration -- [ ] Implement new authentication pattern -- [ ] Update all API calls to use new method -- [ ] Test authentication flows (login, logout, session timeout) -- [ ] Test protected routes and middleware -- [ ] Verify CSRF protection is working -- [ ] Load test authentication endpoints -- [ ] Security audit of new implementation - -### Post-Migration -- [ ] Remove localStorage token storage code -- [ ] Remove non-HttpOnly cookie code -- [ ] Update documentation for developers -- [ ] Monitor error rates and authentication metrics -- [ ] Force logout all existing sessions (optional) -- [ ] Communicate changes to users if needed - -### Rollback Plan -- [ ] Keep old authentication code commented (not deleted) for 1 sprint -- [ ] Maintain backward compatibility during transition period -- [ ] Document rollback procedure -- [ ] Monitor user complaints and authentication errors - ---- - -## 8. Testing Strategy - -### Security Testing -```typescript -// Test 1: Verify tokens not in localStorage -test('tokens should not be in localStorage', () => { - const token = localStorage.getItem('token'); - const authToken = localStorage.getItem('auth_token'); - - expect(token).toBeNull(); - expect(authToken).toBeNull(); -}); - -// Test 2: Verify HttpOnly cookies cannot be accessed -test('auth cookies should be HttpOnly', () => { - const cookies = document.cookie; - - expect(cookies).not.toContain('auth_token'); - expect(cookies).not.toContain('laravel_session'); -}); - -// Test 3: Verify CSRF protection -test('API calls without CSRF token should fail', async () => { - const response = await fetch('/api/protected', { - method: 'POST', - // No CSRF token - }); - - expect(response.status).toBe(419); // CSRF token mismatch -}); - -// Test 4: XSS injection attempt -test('XSS should not access auth cookies', () => { - const script = document.createElement('script'); - script.innerHTML = ` - try { - const token = document.cookie.match(/auth_token=([^;]+)/); - window.stolenToken = token; - } catch (e) { - window.xssFailed = true; - } - `; - document.body.appendChild(script); - - expect(window.stolenToken).toBeUndefined(); - expect(window.xssFailed).toBe(true); -}); -``` - -### Integration Testing -```typescript -// Test authentication flow -test('complete authentication flow', async () => { - // 1. Get CSRF cookie - await fetch('/sanctum/csrf-cookie'); - - // 2. Login - const loginResponse = await fetch('/login', { - method: 'POST', - credentials: 'include', - body: JSON.stringify({ email: 'test@example.com', password: 'password' }) - }); - - expect(loginResponse.ok).toBe(true); - - // 3. Access protected resource - const userResponse = await fetch('/api/user', { - credentials: 'include' - }); - - expect(userResponse.ok).toBe(true); - - // 4. Logout - const logoutResponse = await fetch('/logout', { - method: 'POST', - credentials: 'include' - }); - - expect(logoutResponse.ok).toBe(true); - - // 5. Verify session cleared - const unauthorizedResponse = await fetch('/api/user', { - credentials: 'include' - }); - - expect(unauthorizedResponse.status).toBe(401); -}); -``` - -### Performance Testing -```bash -# Load test authentication endpoints -ab -n 1000 -c 10 -p login.json -T application/json http://localhost:3000/api/auth/login - -# Monitor response times -# Target: < 200ms for authentication flows -# Target: < 100ms for API calls with session -``` - ---- - -## 9. Compliance & Standards - -### OWASP ASVS 4.0 Compliance - -| Requirement | Current | Solution 2 | Solution 4 | -|-------------|---------|-----------|-----------| -| V3.2.1: Session tokens HttpOnly | ❌ No | ✅ Yes | ✅ Yes | -| V3.2.2: Cookie Secure flag | ❌ No | ✅ Yes | ✅ Yes | -| V3.2.3: Cookie SameSite | 🟡 Lax | ✅ Lax/Strict | ✅ Strict | -| V3.3.1: CSRF protection | 🟡 Partial | ✅ Full | ✅ Full | -| V3.5.2: Session timeout | 🟡 7 days | ✅ Configurable | ✅ Configurable | -| V8.3.4: XSS protection | ❌ No | ✅ Yes | ✅ Yes | - -### PCI DSS Compliance -- **Requirement 6.5.9 (XSS):** Solution 2 & 4 provide XSS protection -- **Requirement 8.2.3 (MFA):** Can be added to any solution -- **Requirement 8.2.4 (Password Security):** Laravel provides bcrypt hashing - -### GDPR Compliance -- **Article 32 (Security):** Solution 2 & 4 meet security requirements -- **Data Minimization:** Session-based auth minimizes token exposure -- **Right to Erasure:** Easy to delete session data - ---- - -## 10. References & Further Reading - -### Official Documentation -- [Laravel Sanctum - SPA Authentication](https://laravel.com/docs/11.x/sanctum#spa-authentication) -- [Next.js Authentication Guide](https://nextjs.org/docs/app/guides/authentication) -- [Next.js 15 cookies() function](https://nextjs.org/docs/app/api-reference/functions/cookies) -- [OWASP SameSite Cookie Attribute](https://owasp.org/www-community/SameSite) -- [NIST 800-63B Session Management](https://pages.nist.gov/800-63-3/sp800-63b.html) - -### Security Resources -- [OWASP Content Security Policy](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html) -- [Auth0: Backend for Frontend Pattern](https://auth0.com/blog/the-backend-for-frontend-pattern-bff/) -- [PortSwigger: Bypassing SameSite Restrictions](https://portswigger.net/web-security/csrf/bypassing-samesite-restrictions) -- [MDN: HttpOnly Cookie Attribute](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies) - -### Community Discussions -- [Is it safe to store JWT in localStorage?](https://stackoverflow.com/questions/44133536/is-it-safe-to-store-a-jwt-in-localstorage-with-reactjs) -- [Token storage security debate](https://dev.to/cotter/localstorage-vs-cookies-all-you-need-to-know-about-storing-jwt-tokens-securely-in-the-front-end-15id) - ---- - -## Conclusion - -Your current implementation (localStorage + non-HttpOnly cookies) has a **Critical** risk score of **7.6/10** due to XSS vulnerabilities. - -**Recommended Action:** Migrate to **Solution 2 (Sanctum Cookie-Based Sessions)** within the next sprint. This is Laravel Sanctum's officially recommended pattern for SPAs and provides the best security-to-complexity ratio. - -**Quick Win:** If immediate migration isn't possible, implement **Solution 1 (HttpOnly Proxy)** as a temporary measure to eliminate localStorage vulnerabilities within 2-4 hours. - -**Do Not:** Rely solely on **Solution 3 (Token Encryption)** as it provides a false sense of security and is still vulnerable to XSS attacks. - -The research shows a clear industry consensus: **HttpOnly cookies with CSRF protection are the gold standard for SPA authentication security**, and Laravel Sanctum provides this pattern out of the box. - ---- - -**Research Confidence:** 85% -**Sources Consulted:** 25+ -**Last Updated:** 2025-11-07 diff --git a/claudedocs/backend/2026-03-02_구현내역.md b/claudedocs/backend/2026-03-02_구현내역.md deleted file mode 100644 index d83165ec..00000000 --- a/claudedocs/backend/2026-03-02_구현내역.md +++ /dev/null @@ -1,38 +0,0 @@ -# 2026-03-02 (월) 백엔드 구현 내역 - -## 1. `🆕 신규` [roadmap] 중장기 계획 테이블 마이그레이션 추가 - -**커밋**: `3ca161e` | **유형**: feat - -### 배경 -관리자 패널에서 프로젝트 로드맵을 관리할 수 있도록 데이터베이스 테이블이 필요했음. - -### 구현 내용 -- `admin_roadmap_plans` 테이블 생성 — 계획 마스터 (제목, 카테고리, 상태, Phase, 진행률) -- `admin_roadmap_milestones` 테이블 생성 — 마일스톤 관리 (plan_id FK, 상태, 예정일) - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php` | 신규 생성 | - ---- - -## 2. `🆕 신규` [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더 - -**커밋**: `abe0460` | **유형**: feat - -### 배경 -AI 기반 자동 견적 시스템을 위한 데이터 저장 구조 및 초기 모듈 카탈로그 데이터가 필요했음. - -### 구현 내용 -- `ai_quotation_modules` 테이블 — SAM 모듈 카탈로그 (18개 모듈 정의) -- `ai_quotations` 테이블 — AI 견적 요청/결과 저장 -- `ai_quotation_items` 테이블 — AI 추천 모듈 목록 -- `AiQuotationModuleSeeder` — customer-pricing 기반 초기 데이터 시딩 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/2026_03_02_100000_create_ai_quotation_tables.php` | 신규 생성 | -| `database/seeders/AiQuotationModuleSeeder.php` | 신규 생성 | diff --git a/claudedocs/backend/2026-03-03_구현내역.md b/claudedocs/backend/2026-03-03_구현내역.md deleted file mode 100644 index 61cf1d30..00000000 --- a/claudedocs/backend/2026-03-03_구현내역.md +++ /dev/null @@ -1,197 +0,0 @@ -# 2026-03-03 (화) 백엔드 구현 내역 - -## 1. `⚙️ 설정` [ai] Gemini 모델 버전 업그레이드 - -**커밋**: `f79d008` | **유형**: chore - -### 배경 -Google Gemini 모델의 새 버전(2.5-flash)이 출시되어 기존 2.0-flash에서 업그레이드 필요. - -### 구현 내용 -- `config/services.php` — fallback 기본 모델명 `gemini-2.5-flash`로 변경 -- `AiReportService.php` — fallback 기본값 동일 변경 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `config/services.php` | 수정 | -| `app/Services/AiReportService.php` | 수정 | - ---- - -## 2. `🔧 수정` [deploy] 배포 시 .env 권한 640 보장 추가 - -**커밋**: `7e309e4` | **유형**: fix - -### 배경 -2026-03-03 장애 발생 — vi 편집으로 `.env` 파일 권한이 600으로 변경되어 PHP-FPM이 읽기 실패 → 500 에러. 재발 방지를 위해 배포 파이프라인에 권한 보장 로직 추가. - -### 구현 내용 -- Stage/Production Jenkinsfile 배포 스크립트에 `chmod 640 .env` 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `Jenkinsfile` | 수정 | - ---- - -## 3. `🔧 수정` [hr] 사업소득자 임금대장 컬럼 추가 - -**커밋**: `b3c7d08` | **유형**: feat (기존 테이블 확장) - -### 배경 -사업소득자(프리랜서)를 시스템 회원이 아닌 직접 입력 대상자로 지원하기 위해 추가 컬럼 필요. - -### 구현 내용 -- `user_id` nullable 변경 (직접 입력 대상자 지원) -- `display_name`, `business_reg_number` 컬럼 추가 -- 기존 데이터는 earner 프로필에서 자동 채움 마이그레이션 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/..._add_display_name_to_business_income_payments.php` | 신규 생성 | - ---- - -## 4. `🔧 수정` [ai-quotation] 제조 견적서 마이그레이션 추가 - -**커밋**: `da1142a` | **유형**: feat (기존 테이블 확장) - -### 배경 -AI 견적 시스템에서 제조업 견적서를 지원하기 위해 기존 테이블 확장 및 가격표 테이블 신규 생성 필요. - -### 구현 내용 -- `ai_quotations` 테이블에 `quote_mode`, `quote_number`, `product_category` 컬럼 추가 -- `ai_quotation_items` 테이블에 `specification`, `unit`, `quantity`, `unit_price`, `total_price`, `item_category`, `floor_code` 컬럼 추가 -- `ai_quote_price_tables` 테이블 신규 생성 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/..._add_manufacture_fields_to_ai_quotations.php` | 신규 생성 | - ---- - -## 5. `🔧 수정` [today-issue] 날짜 기반 이전 이슈 조회 기능 추가 - -**커밋**: `83a7745` | **유형**: feat (기존 기능 확장) - -### 배경 -오늘의 이슈를 특정 날짜 기준으로 과거 데이터도 조회할 수 있어야 함. 이전에는 현재 날짜 기준만 지원했음. - -### 구현 내용 -- `TodayIssueController`에 `date` 파라미터(YYYY-MM-DD) 추가 -- `TodayIssueService.summary()`에 날짜 기반 필터링 로직 구현 -- 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/TodayIssueController.php` | 수정 | -| `app/Services/TodayIssueService.php` | 수정 | - ---- - -## 6. `🔧 수정` [approval] 결재 수신함 날짜 범위 필터 추가 - -**커밋**: `b7465be` | **유형**: feat (기존 기능 확장) - -### 배경 -결재 수신함에서 특정 기간의 결재 건만 조회할 수 있도록 날짜 필터 필요. - -### 구현 내용 -- `InboxIndexRequest`에 `start_date`/`end_date` 검증 룰 추가 -- `ApprovalService.inbox()`에 `created_at` 날짜 범위 필터 구현 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Requests/Approval/InboxIndexRequest.php` | 수정 | -| `app/Services/ApprovalService.php` | 수정 | - ---- - -## 7. `🔧 수정` [daily-report] 자금현황 카드용 필드 추가 - -**커밋**: `ad27090` | **유형**: feat (기존 API 확장) - -### 배경 -일일보고서 대시보드에 자금현황 카드를 표시하기 위해 미수금/미지급금/당월 예상 지출 데이터 필요. - -### 구현 내용 -- 미수금 잔액(`receivable_balance`) 계산 로직 구현 -- 미지급금 잔액(`payable_balance`) 계산 로직 구현 -- 당월 예상 지출(`monthly_expense_total`) 계산 로직 구현 -- summary API 응답에 자금현황 3개 필드 포함 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/DailyReportService.php` | 수정 | - ---- - -## 8. `🔧 수정` [stock,client,status-board] 날짜 필터 및 조건 보완 - -**커밋**: `4244334` | **유형**: feat (기존 기능 확장) - -### 배경 -재고/거래처/현황판 화면에서 날짜 범위 필터가 미지원이었고, 부실채권 현황에 비활성 데이터가 포함되는 이슈. - -### 구현 내용 -- `StockController/StockService` — 입출고 이력 기반 날짜 범위 필터 추가 -- `ClientService` — 등록일 기간 필터(`start_date`/`end_date`) 추가 -- `StatusBoardService` — 부실채권 현황에 `is_active` 조건 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/StockController.php` | 수정 | -| `app/Services/StockService.php` | 수정 | -| `app/Services/ClientService.php` | 수정 | -| `app/Services/StatusBoardService.php` | 수정 | - ---- - -## 9. `🔧 수정` [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가 - -**커밋**: `23c6cf6` | **유형**: feat (기존 모델 확장) - -### 배경 -기존 연차/반차만 지원하던 휴가 시스템에 출장, 재택근무, 외근, 조퇴, 지각, 결근 등 근태 유형 확장 필요. 결재 양식(근태신청, 사유서)도 추가. - -### 구현 내용 -- Leave 타입 6개 추가: `business_trip`, `remote`, `field_work`, `early_leave`, `late_reason`, `absent_reason` -- 그룹 상수: `VACATION_TYPES`, `ATTENDANCE_REQUEST_TYPES`, `REASON_REPORT_TYPES` -- `FORM_CODE_MAP` — 유형 → 결재양식코드 매핑 -- `ATTENDANCE_STATUS_MAP` — 유형 → 근태상태 매핑 -- 결재양식 2개 추가: `attendance_request`(근태신청), `reason_report`(사유서) - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Models/Tenants/Leave.php` | 수정 | -| `database/migrations/..._insert_attendance_approval_forms.php` | 신규 생성 | - ---- - -## 10. `🔧 수정` [production] 자재투입 모달 개선 - -**커밋**: `fc53789` | **유형**: fix (기존 기능 버그 수정 + 개선) - -### 배경 -자재투입 시 lot 미관리 품목(L-Bar, 보강평철)이 목록에 표시되는 이슈, BOM 그룹키 부재로 동일 자재 구분 불가, 셔터박스 순서가 작업일지와 불일치. - -### 구현 내용 -- `getMaterialsForItem` — `lot_managed===false` 품목을 자재투입 목록에서 제외 -- `getMaterialsForItem` — `bom_group_key` 필드 추가 (category+partType 기반 고유키) -- `BendingInfoBuilder` — `shutterPartTypes`에서 `top_cover`/`fin_cover` 제거 (중복 방지) -- `BendingInfoBuilder` — 셔터박스 루프 순서 파트→길이로 변경 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/Production/BendingInfoBuilder.php` | 수정 | -| `app/Services/WorkOrderService.php` | 수정 | diff --git a/claudedocs/backend/2026-03-04_구현내역.md b/claudedocs/backend/2026-03-04_구현내역.md deleted file mode 100644 index f1c7e198..00000000 --- a/claudedocs/backend/2026-03-04_구현내역.md +++ /dev/null @@ -1,336 +0,0 @@ -# 2026-03-04 (수) 백엔드 구현 내역 - -## 1. `🔧 수정` [inspection] 캘린더 스케줄 조회 API 추가 - -**커밋**: `e9fd75f` | **유형**: feat (기존 검사 모듈에 캘린더 API 추가) - -### 배경 -검사 일정을 캘린더 형태로 표시하기 위한 API 필요. - -### 구현 내용 -- `GET /api/v1/inspections/calendar` 엔드포인트 추가 -- `year`, `month`, `inspector`, `status` 파라미터 지원 -- React 프론트엔드 `CalendarItemApi` 형식에 맞춰 응답 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/InspectionController.php` | 수정 | -| `app/Services/InspectionService.php` | 수정 | -| `routes/api/v1/production.php` | 수정 | - ---- - -## 2. `🆕 신규` [barobill] 바로빌 연동 API 엔드포인트 추가 - -**커밋**: `4f3467c` | **유형**: feat - -### 배경 -바로빌(전자세금계산서/은행/카드 연동 서비스) API 연동을 위한 백엔드 엔드포인트 필요. - -### 구현 내용 -- `GET /api/v1/barobill/status` — 연동 현황 조회 -- `POST /api/v1/barobill/login` — 로그인 정보 등록 -- `POST /api/v1/barobill/signup` — 회원가입 정보 등록 -- `GET /api/v1/barobill/bank-service-url` — 은행 서비스 URL -- `GET /api/v1/barobill/account-link-url` — 계좌 연동 URL -- `GET /api/v1/barobill/card-link-url` — 카드 연동 URL -- `GET /api/v1/barobill/certificate-url` — 공인인증서 URL - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/BarobillController.php` | 신규 생성 | -| `routes/api/v1/finance.php` | 수정 | - ---- - -## 3. `🔧 수정` [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류 - -**커밋**: `1deeafc` | **유형**: feat (기존 대시보드 확장) - -### 배경 -경비/가지급금 대시보드에서 날짜 범위 필터와 검색 기능이 없었고, 가지급금에 카테고리(카드/경조사/상품권/접대비) 분류 필요. - -### 구현 내용 -- `ExpectedExpenseController/Service` — dashboardDetail에 `start_date`/`end_date`/`search` 파라미터 추가 -- `Loan` 모델 — category 상수 및 라벨 정의 (카드/경조사/상품권/접대비) -- `LoanService` — dashboard에 `category_breakdown` 집계 추가 -- 마이그레이션 — loans 테이블 `category` 컬럼 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/ExpectedExpenseController.php` | 수정 | -| `app/Models/Tenants/Loan.php` | 수정 | -| `app/Services/ExpectedExpenseService.php` | 수정 | -| `app/Services/LoanService.php` | 수정 | -| `database/migrations/2026_03_04_100000_add_category_to_loans_table.php` | 신규 생성 | - ---- - -## 4. `🔧 수정` [models] User 모델 import 누락/오류 수정 - -**커밋**: `da04b84` | **유형**: fix (버그 수정) - -### 배경 -Tenants 네임스페이스에서 `User::class`가 `App\Models\Tenants\User`로 잘못 해석되는 문제. Loan, TodayIssue 모델에서 User import 경로 오류. - -### 구현 내용 -- `Loan.php` — `App\Models\Members\User` import 추가 -- `TodayIssue.php` — `App\Models\Users\User` → `App\Models\Members\User` 수정 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Models/Tenants/Loan.php` | 수정 | -| `app/Models/Tenants/TodayIssue.php` | 수정 | - ---- - -## 5. `🔧 수정` [cards] 리다이렉트 추가 - -**커밋**: `76192fc` | **유형**: fix (하위호환) - -### 배경 -프론트엔드에서 기존 `cards/stats` 경로로 호출하는 코드가 있어 새 경로로 리다이렉트 필요. - -### 구현 내용 -- `cards/stats` → `card-transactions/dashboard` 리다이렉트 라우트 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `routes/api/v1/finance.php` | 수정 | - ---- - -## 6. `🔧 수정` [address] 주소 필드 255자 → 500자 확장 - -**커밋**: `7cf70db` | **유형**: fix (제한 완화) - -### 배경 -실제 주소 데이터가 255자를 초과하는 경우 발생. DB와 FormRequest 검증 모두 확장 필요. - -### 구현 내용 -- DB 마이그레이션 — `clients`, `tenants`, `site_briefings`, `sites` 테이블 address 컬럼 `varchar(500)` -- FormRequest 8개 파일 — `max:255` → `max:500` 변경 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Requests/Client/ClientStoreRequest.php` | 수정 | -| `app/Http/Requests/Client/ClientUpdateRequest.php` | 수정 | -| `app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php` | 수정 | -| `app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php` | 수정 | -| `app/Http/Requests/Tenant/TenantStoreRequest.php` | 수정 | -| `app/Http/Requests/Tenant/TenantUpdateRequest.php` | 수정 | -| `app/Http/Requests/V1/Site/StoreSiteRequest.php` | 수정 | -| `app/Http/Requests/V1/Site/UpdateSiteRequest.php` | 수정 | -| `database/migrations/..._extend_address_columns_to_500.php` | 신규 생성 | - ---- - -## 7. `🔧 수정` [dashboard] D1.7 기획서 기반 리스크 감지형 서비스 리팩토링 - -**커밋**: `e637e3d` | **유형**: feat (기존 대시보드 대규모 리팩토링) - -### 배경 -D1.7 기획서 요구사항에 따라 접대비/복리후생비/매출채권 대시보드를 단순 집계에서 리스크 감지형으로 전환. - -### 구현 내용 -- `EntertainmentService` — 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비) -- `WelfareService` — 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과) -- `ReceivablesService` — summary를 `cards` + `check_points` 구조로 개선 (누적/당월 미수금, Top3 거래처) -- `LoanService` — getCategoryBreakdown 전체 대상으로 집계 조건 변경 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/EntertainmentService.php` | 수정 (대규모) | -| `app/Services/WelfareService.php` | 수정 (대규모) | -| `app/Services/ReceivablesService.php` | 수정 (대규모) | -| `app/Services/LoanService.php` | 수정 | - ---- - -## 8. `🔧 수정` [entertainment,welfare] 바로빌 조인 컬럼명 및 심야 시간 파싱 수정 - -**커밋**: `f665d3a` | **유형**: fix (버그 수정) - -### 배경 -바로빌 카드거래 테이블 조인 시 컬럼명 불일치 및 심야 판별 함수 오류. - -### 구현 내용 -- `approval_no` → `approval_num` 컬럼명 수정 -- `use_time` 심야 판별: `HOUR()` → `SUBSTRING` 문자열 파싱으로 변경 -- `whereNotNull('bct.use_time')` 조건 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/EntertainmentService.php` | 수정 | -| `app/Services/WelfareService.php` | 수정 | - ---- - -## 9. `🆕 신규` [approval] 지출결의서 양식 등록 및 고도화 - -**커밋**: `b86af29`, `282bf26` | **유형**: feat - -### 배경 -전자결재에 지출결의서 양식을 등록하고, HTML body_template 필드로 정형화된 양식 제공. - -### 구현 내용 -- `approval_forms` 테이블에 `body_template` TEXT 컬럼 추가 (마이그레이션) -- 지출결의서(expense) 양식 데이터 등록 -- 참조 문서 기반으로 정형 양식 HTML 리디자인 — 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/..._add_body_template_to_approval_forms.php` | 신규 생성 | -| `database/migrations/..._insert_expense_approval_form.php` | 신규 생성 | -| `database/migrations/..._update_expense_approval_form_body_template.php` | 신규 생성 | - ---- - -## 10. `🆕 신규` [entertainment] 접대비 상세 조회 API + `🔧 수정` 가지급금 날짜 필터 - -**커밋**: `66da297`, `a173a5a`, `94b96e2`, `2f3ec13` | **유형**: feat + fix - -### 배경 -접대비 상세 대시보드(손금한도, 월별추이, 거래내역)가 필요하고, 가지급금 대시보드에도 날짜 필터 지원 필요. - -### 구현 내용 -- `EntertainmentController/Service` — `getDetail()` 상세 조회 API 신규 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황) -- 수입금액별 추가한도 계산 (세법 기준), 거래건별 리스크 감지 -- `LoanController/Service` — dashboard에 `start_date`/`end_date` 파라미터 지원 (기존 수정) -- `getCategoryBreakdown` SQL alias 충돌 수정 -- 분기 사용액 조회에 날짜 필터 적용 -- 라우트: `GET /entertainment/detail` 엔드포인트 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/EntertainmentController.php` | 수정 | -| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 | -| `app/Services/EntertainmentService.php` | 수정 (대규모) | -| `app/Services/LoanService.php` | 수정 | -| `routes/api/v1/finance.php` | 수정 | - ---- - -## 11. `🆕 신규` [calendar,vat] 캘린더 CRUD 및 부가세 상세 조회 API - -**커밋**: `74a60e0` | **유형**: feat - -### 배경 -일정 관리를 위한 캘린더 CRUD API와 부가세 상세 조회 대시보드 API 필요. - -### 구현 내용 -- `CalendarController/Service` — 일정 등록/수정/삭제 API 신규 -- `VatController/Service` — `getDetail()` 상세 조회 신규 (요약, 참조테이블, 미발행 목록, 신고기간 옵션) -- 라우트: `POST/PUT/DELETE /calendar/schedules`, `GET /vat/detail` - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/CalendarController.php` | 신규 생성 | -| `app/Http/Controllers/Api/V1/VatController.php` | 신규 생성 | -| `app/Services/CalendarService.php` | 신규 생성 | -| `app/Services/VatService.php` | 신규 생성 | -| `routes/api/v1/finance.php` | 수정 | - ---- - -## 12. `🆕 신규` [shipment] 배차정보 다중 행 시스템 - -**커밋**: `851862` | **유형**: feat - -### 배경 -기존 출하 건에 단일 배차정보만 저장 가능했으나, 다중 차량 배차를 지원해야 함. - -### 구현 내용 -- `shipment_vehicle_dispatches` 테이블 신규 생성 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks) -- `ShipmentVehicleDispatch` 모델 신규 -- `Shipment` 모델에 `vehicleDispatches()` HasMany 관계 추가 -- `ShipmentService` — `syncDispatches()` 추가, store/update/delete/show/index에서 연동 -- FormRequest — Store/Update에 `vehicle_dispatches` 배열 검증 규칙 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 신규 생성 | -| `app/Models/Tenants/Shipment.php` | 수정 | -| `app/Services/ShipmentService.php` | 수정 | -| `app/Http/Requests/Shipment/ShipmentStoreRequest.php` | 수정 | -| `app/Http/Requests/Shipment/ShipmentUpdateRequest.php` | 수정 | -| `database/migrations/..._create_shipment_vehicle_dispatches_table.php` | 신규 생성 | - ---- - -## 13. `🔧 수정` [production] 자재투입 bom_group_key 개별 저장 - -**커밋**: `5ee97c2` | **유형**: fix (기존 기능 보완) - -### 배경 -동일 자재가 다른 BOM 그룹에 속할 때 구분이 안 되는 문제. bom_group_key로 개별 식별 필요. - -### 구현 내용 -- `work_order_material_inputs` 테이블에 `bom_group_key` 컬럼 추가 -- 기투입 조회를 `stock_lot_id` + `bom_group_key` 복합키로 변경 -- `replace` 모드 지원 (기존 삭제 → 재등록) - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php` | 수정 | -| `app/Models/Production/WorkOrderMaterialInput.php` | 수정 | -| `app/Services/WorkOrderService.php` | 수정 | -| `database/migrations/..._bom_group_key_to_work_order_material_inputs.php` | 신규 생성 | - ---- - -## 14. `🔧 수정` [production] 절곡 검사 데이터 전체 item 복제 + bending EAV 변환 - -**커밋**: `897511c` | **유형**: fix (기존 검사 로직 개선) - -### 배경 -절곡 검사 시 동일 작업지시의 모든 item에 검사 데이터가 복제 저장되어야 하며, products 배열을 bending EAV 레코드로 변환 필요. - -### 구현 내용 -- `storeItemInspection` — bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장 -- `transformBendingProductsToRecords` — products 배열 → bending EAV 레코드 변환 -- `getMaterialInputLots` — 품목코드별 그룹핑으로 변경 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/WorkOrderService.php` | 수정 (대규모) | - ---- - -## 15. `🆕 신규` [outbound] 배차차량 관리 API - -**커밋**: `1a8bb46` | **유형**: feat - -### 배경 -출고 관련 배차차량을 독립적으로 관리(조회/수정/통계)하는 API 필요. - -### 구현 내용 -- `VehicleDispatchService` — index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update -- `VehicleDispatchController` + `VehicleDispatchUpdateRequest` -- options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer) -- inventory.php에 `vehicle-dispatches` 라우트 4개 등록 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/VehicleDispatchController.php` | 신규 생성 | -| `app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php` | 신규 생성 | -| `app/Services/VehicleDispatchService.php` | 신규 생성 | -| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 수정 | -| `app/Services/ShipmentService.php` | 수정 | -| `database/migrations/..._options_to_shipment_vehicle_dispatches_table.php` | 신규 생성 | -| `routes/api/v1/inventory.php` | 수정 | diff --git a/claudedocs/backend/2026-03-05_구현내역.md b/claudedocs/backend/2026-03-05_구현내역.md deleted file mode 100644 index 56be9469..00000000 --- a/claudedocs/backend/2026-03-05_구현내역.md +++ /dev/null @@ -1,386 +0,0 @@ -# 2026-03-05 (목) 백엔드 구현 내역 - -## 1. `🔧 수정` [storage] RecordStorageUsage 명령어 수정 - -**커밋**: `e0bb19a` | **유형**: fix (버그 수정) - -### 배경 -`Tenant::where('status', 'active')` 하드코딩 사용 중이나 tenants 테이블에 `status` 컬럼이 없고 `tenant_st_code`를 사용함. 모델 스코프 사용으로 수정. - -### 구현 내용 -- `Tenant::where('status', 'active')` → `Tenant::active()` 스코프 사용 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Console/Commands/RecordStorageUsage.php` | 수정 | - ---- - -## 2. `🆕 신규` [dashboard-ceo] CEO 대시보드 섹션별 API 및 일일보고서 엑셀 - -**커밋**: `e8da2ea`, `f1a3e0f` | **유형**: feat + fix - -### 배경 -CEO 전용 대시보드에 매출/매입/생산/미출고/시공/근태 등 6개 섹션 데이터를 제공하는 API 및 엑셀 다운로드 기능 필요. - -### 구현 내용 -- `DashboardCeoController/Service` — 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태) -- `DailyReportController/Service` — 엑셀 다운로드 API (`GET /daily-report/export`) -- 라우트: dashboard 하위 6개 + `daily-report/export` 엔드포인트 -- 공정명 컬럼 수정 (`p.name` → `p.process_name`) -- 근태 부서 조인 수정 (`users.department_id` → `tenant_user_profiles` 경유) - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/DashboardCeoController.php` | 신규 생성 | -| `app/Services/DashboardCeoService.php` | 신규 생성 | -| `app/Http/Controllers/Api/V1/DailyReportController.php` | 수정 | -| `app/Services/DailyReportService.php` | 수정 | -| `routes/api/v1/common.php` | 수정 | -| `routes/api/v1/finance.php` | 수정 | - ---- - -## 3. `🔧 수정` [daily-report] 엑셀 내보내기 어음/외상매출채권 현황 및 리팩토링 - -**커밋**: `1b2363d`, `fefd129` | **유형**: feat + refactor (기존 엑셀 기능 확장/개선) - -### 배경 -일일보고서 엑셀에 어음/외상매출채권 현황 섹션이 빠져있었고, 엑셀과 화면 데이터가 불일치하는 문제. - -### 구현 내용 -- `DailyReportExport` — 어음 현황 테이블 + 합계 + 스타일링 추가 -- `DailyReportService` — exportData를 `dailyAccounts()` 재사용 구조로 리팩토링 -- 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Exports/DailyReportExport.php` | 수정 | -| `app/Services/DailyReportService.php` | 수정 (리팩토링) | - ---- - -## 4. `🔧 수정` [production] 절곡 검사 FormRequest 검증 누락 수정 - -**커밋**: `ef7d9fa` | **유형**: fix (버그 수정) - -### 배경 -`StoreItemInspectionRequest`에 `inspection_data.products` 검증 규칙이 누락되어 `validated()`에서 products 데이터가 제거되는 버그. - -### 구현 내용 -- `products.*.id`, `bendingStatus`, `lengthMeasured`, `widthMeasured`, `gapPoints` 검증 규칙 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php` | 수정 | - ---- - -## 5. `🆕 신규` [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2) - -**커밋**: `cd847e0` | **유형**: feat - -### 배경 -문서(Document) 시스템과 결재(Approval) 시스템을 연동하여, 문서 상신 시 결재가 자동 생성되고 결재 처리 시 문서 상태가 동기화되어야 함. - -### 구현 내용 -- `Approval` 모델에 `linkable` morphTo 관계 추가 -- `DocumentService` — 상신 시 Approval 자동 생성 + approval_steps 변환 -- `ApprovalService` — 승인/반려/회수 시 Document 상태 동기화 -- `approvals` 테이블에 `linkable_type`, `linkable_id` 컬럼 마이그레이션 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Models/Tenants/Approval.php` | 수정 | -| `app/Services/ApprovalService.php` | 수정 | -| `app/Services/DocumentService.php` | 수정 | -| `database/migrations/..._add_linkable_to_approvals_table.php` | 신규 생성 | - ---- - -## 6. `🔧 수정` [process] 공정단계 options 컬럼 추가 - -**커밋**: `1f7f45e` | **유형**: feat (기존 테이블 확장) - -### 배경 -공정단계별 검사 설정/범위 등 확장 속성을 저장할 JSON 컬럼 필요. - -### 구현 내용 -- `ProcessStep` 모델에 `options` JSON 컬럼 추가 (fillable, cast) -- Store/UpdateProcessStepRequest에 `inspection_setting`, `inspection_scope` 검증 규칙 -- `process_steps` 테이블 마이그레이션 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php` | 수정 | -| `app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php` | 수정 | -| `app/Models/ProcessStep.php` | 수정 | -| `database/migrations/..._add_options_to_process_steps_table.php` | 신규 생성 | - ---- - -## 7. `🔄 리팩토링` [production] 셔터박스 prefix isStandard 파라미터 제거 - -**커밋**: `d4f21f0` | **유형**: refactor - -### 배경 -CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize와 무관하게 적용됨. isStandard 분기가 불필요. - -### 구현 내용 -- `resolveShutterBoxPrefix()`에서 `isStandard` 파라미터 및 분기 로직 제거 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/Production/BendingInfoBuilder.php` | 수정 | -| `app/Services/Production/PrefixResolver.php` | 수정 | - ---- - -## 8. `🔧 수정` [production] 자재투입 replace 모드 지원 - -**커밋**: `7432fb1` | **유형**: feat (기존 기능 확장) - -### 배경 -자재투입 시 기존 투입 데이터를 교체하는 방식 선택 가능하도록 지원. - -### 구현 내용 -- `registerMaterialInputForItem`에 `replace` 파라미터 추가 -- Controller에서 request body의 `replace` 값 전달 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/WorkOrderController.php` | 수정 | - ---- - -## 9. `🔄 리팩토링` [core] 모델 스코프 적용 규칙 추가 - -**커밋**: `9b8cdfa` | **유형**: refactor - -### 배경 -`where` 하드코딩 대신 모델에 정의된 스코프를 우선 사용하도록 코드 규칙 명시. - -### 구현 내용 -- `RecordStorageUsage` — where 하드코딩 → `Tenant::active()` 스코프 -- `CLAUDE.md` — 쿼리 수정 시 모델 스코프 우선 규칙 명시 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `CLAUDE.md` | 수정 | -| `app/Console/Commands/RecordStorageUsage.php` | 수정 | - ---- - -## 10. `⚙️ 설정` [infra] Slack 알림 채널 분리 - -**커밋**: `3d4dd9f` | **유형**: chore - -### 배경 -배포 알림 채널을 product_infra에서 deploy_api로 분리하여 알림 관리 개선. - -### 구현 내용 -- Jenkinsfile Slack 알림 채널 `product_infra` → `deploy_api` 변경 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `Jenkinsfile` | 수정 | - ---- - -## 11. `🔧 수정` [approval] 결재 테이블 확장 (3건) - -**커밋**: `ac72487`, `558a393`, `ce1f910` | **유형**: feat (기존 테이블 확장) - -### 배경 -결재 시스템에 기안자 읽음 확인, 재상신 횟수, 반려 이력 추적 기능 필요. - -### 구현 내용 -- `drafter_read_at` 컬럼 — 기안자 완료 결과 확인 타임스탬프 (미읽음 뱃지 지원) -- `resubmit_count` 컬럼 — 재상신 횟수 추적 -- `rejection_history` JSON 컬럼 — 반려 이력 저장 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/..._add_drafter_read_at_to_approvals_table.php` | 신규 생성 | -| `database/migrations/..._add_resubmit_count_to_approvals_table.php` | 신규 생성 | -| `database/migrations/..._add_rejection_history_to_approvals_table.php` | 신규 생성 | - ---- - -## 12. `🆕 신규` [rd] CM송 저장 테이블 마이그레이션 - -**커밋**: `66d1004` | **유형**: feat - -### 배경 -AI 생성 CM송(광고 음악) 데이터 저장을 위한 테이블 필요. - -### 구현 내용 -- `cm_songs` 테이블 생성 — tenant_id, user_id, company_name, industry, lyrics, audio_path, options - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/2026_03_05_170000_create_cm_songs_table.php` | 신규 생성 | - ---- - -## 13. `🆕 신규` [approval] 결재양식 마이그레이션 (3건) - -**커밋**: `f41605c`, `0f25a5d`, `846ced3` | **유형**: feat - -### 배경 -전자결재에 재직증명서, 경력증명서, 위촉증명서 양식 추가 필요. - -### 구현 내용 -- `employment_cert` — 재직증명서 양식 등록 -- `career_cert` — 경력증명서 양식 등록 -- `appointment_cert` — 위촉증명서 양식 등록 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/2026_03_05_184507_add_employment_cert_form.php` | 신규 생성 | -| `database/migrations/2026_03_05_230000_add_career_cert_form.php` | 신규 생성 | -| `database/migrations/2026_03_05_234000_add_appointment_cert_form.php` | 신규 생성 | - ---- - -## 14. `🔧 수정` [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리 - -**커밋**: `8c9f2fc` | **유형**: feat (기존 모델 대규모 확장) - -### 배경 -어음 관리에 V8 규격(증권종류, 할인, 배서, 추심, 개서, 부도 등) 54개 필드 지원 필요. 가지급금에 상품권 카테고리 및 상태(보유/사용/폐기) 관리 필요. - -### 구현 내용 -- `Bill` 모델 — V8 확장 필드 54개 추가, 수취/발행 어음·수표별 세분화된 상태 체계 -- `BillService` — `assignV8Fields`/`syncInstallments` 헬퍼, instrument_type/medium 필터 -- `BillInstallment` — type/counterparty 필드 추가 -- `Loan` 모델 — holding/used/disposed 상태 + metadata(JSON) 필드 -- `LoanService` — 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding) -- FormRequest — V8 확장 필드 검증 규칙 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Models/Tenants/Bill.php` | 수정 (대규모) | -| `app/Models/Tenants/BillInstallment.php` | 수정 | -| `app/Models/Tenants/Loan.php` | 수정 | -| `app/Services/BillService.php` | 수정 | -| `app/Services/LoanService.php` | 수정 | -| `app/Http/Requests/V1/Bill/StoreBillRequest.php` | 수정 | -| `app/Http/Requests/V1/Bill/UpdateBillRequest.php` | 수정 | -| `app/Http/Requests/Loan/LoanStoreRequest.php` | 수정 | -| `app/Http/Requests/Loan/LoanUpdateRequest.php` | 수정 | -| `app/Http/Requests/Loan/LoanIndexRequest.php` | 수정 | -| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 | -| `database/migrations/..._add_v8_fields_to_bills_table.php` | 신규 생성 | -| `database/migrations/..._add_metadata_to_loans_table.php` | 신규 생성 | - ---- - -## 15. `🆕 신규` [loan] 상품권 접대비 자동 연동 + `🔧 수정` 후속 수정 (5건) - -**커밋**: `31d2f08`, `03f86f3`, `652ac3d`, `7fe856f`, `c57e768` | **유형**: feat + fix - -### 배경 -상품권이 사용+접대비해당일 경우 expense_accounts에 자동으로 접대비 레코드를 생성/삭제해야 함. 관련 집계 및 수정/삭제 정책도 정비. - -### 구현 내용 -- `ExpenseAccount` — `loan_id` 필드 + `SUB_TYPE_GIFT_CERTIFICATE` 상수 추가 -- `LoanService` — 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제 (🆕) -- store()에서도 접대비 자동 연동 호출 (🔧) -- `getCategoryBreakdown` — used/disposed 상품권은 가지급금 집계에서 제외 (🔧) -- dashboard summary/목록에서도 used/disposed 상품권 제외 (🔧) -- `isEditable()`/`isDeletable()` — 상품권이면 상태 무관하게 허용 (🔧) -- 접대비 연동 시 `receipt_no`에 시리얼번호 매핑 (🔧) -- `expense_accounts`에 `loan_id` 컬럼 마이그레이션 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Models/Tenants/ExpenseAccount.php` | 수정 | -| `app/Models/Tenants/Loan.php` | 수정 | -| `app/Services/LoanService.php` | 수정 (다회) | -| `database/migrations/..._add_loan_id_to_expense_accounts_table.php` | 신규 생성 | - ---- - -## 16. `🆕 신규` [생산지시] 전용 API 엔드포인트 신규 생성 + `🔧 수정` 후속 수정 (4건) - -**커밋**: `2df8ecf`, `59d13ee`, `38c2402`, `0aa0a85` | **유형**: feat + fix - -### 배경 -수주 기반 생산지시 전용 API가 없어 프론트엔드에서 여러 API를 조합해야 했음. 전용 엔드포인트로 통합. - -### 구현 내용 -- `ProductionOrderService` — 목록(index), 통계(stats), 상세(show) 구현 (🆕) -- Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED) -- `workOrderProgress` 가공 필드, `production_ordered_at` 첫 WO 기반 -- BOM 공정 분류 추출 (order_nodes.options.bom_result) -- `ProductionOrderController` + `ProductionOrderIndexRequest` + Swagger 문서 (🆕) -- 날짜 포맷 Y-m-d 변환, `withCount('nodes')` 개소수 추가 (🔧) -- 자재투입 시 WO 자동 상태 전환 (`autoStartWorkOrderOnMaterialInput`) (🆕) -- `process_id=null`인 구매품/서비스 WO 제외 (🔧) -- `extractBomProcessGroups` BOM 파싱 수정 (🔧) -- 재고생산 보조 공정을 일반 워크플로우에서 분리 (`is_auxiliary` 플래그) (🆕) - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/ProductionOrderController.php` | 신규 생성 | -| `app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php` | 신규 생성 | -| `app/Services/ProductionOrderService.php` | 신규 생성 + 수정 | -| `app/Swagger/v1/ProductionOrderApi.php` | 신규 생성 | -| `app/Services/WorkOrderService.php` | 수정 | -| `app/Services/OrderService.php` | 수정 | -| `routes/api/v1/production.php` | 수정 | - ---- - -## 17. `🆕 신규` [품질관리] 백엔드 API 구현 + `🔧 수정` 후속 수정 (3건) - -**커밋**: `a6e29bc`, `3600c7b`, `0f26ea5` | **유형**: feat + fix - -### 배경 -품질관리서(제품검사 요청서) 및 실적신고 관리를 위한 백엔드 API 전체 구현. - -### 구현 내용 -- 품질관리서(quality_documents) CRUD API 14개 엔드포인트 (🆕) -- 실적신고(performance_reports) 관리 API 6개 엔드포인트 (🆕) -- DB 마이그레이션 4개 테이블 (🆕) -- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개 (🆕) -- 납품일 Y-m-d 포맷 변환, 개소 수 order_nodes 루트 노드 기준 변경 (🔧) -- 수주선택 API에 `client_name` 필드 추가 (🔧) - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/QualityDocumentController.php` | 신규 생성 | -| `app/Http/Controllers/Api/V1/PerformanceReportController.php` | 신규 생성 | -| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 신규 생성 | -| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 신규 생성 | -| `app/Http/Requests/Quality/PerformanceReportConfirmRequest.php` | 신규 생성 | -| `app/Http/Requests/Quality/PerformanceReportMemoRequest.php` | 신규 생성 | -| `app/Models/Qualitys/QualityDocument.php` | 신규 생성 | -| `app/Models/Qualitys/QualityDocumentOrder.php` | 신규 생성 | -| `app/Models/Qualitys/QualityDocumentLocation.php` | 신규 생성 | -| `app/Models/Qualitys/PerformanceReport.php` | 신규 생성 | -| `app/Services/QualityDocumentService.php` | 신규 생성 + 수정 | -| `app/Services/PerformanceReportService.php` | 신규 생성 | -| `database/migrations/..._create_quality_documents_table.php` | 신규 생성 | -| `database/migrations/..._create_quality_document_orders_table.php` | 신규 생성 | -| `database/migrations/..._create_quality_document_locations_table.php` | 신규 생성 | -| `database/migrations/..._create_performance_reports_table.php` | 신규 생성 | -| `routes/api/v1/quality.php` | 신규 생성 | diff --git a/claudedocs/backend/2026-03-06_구현내역.md b/claudedocs/backend/2026-03-06_구현내역.md deleted file mode 100644 index a8cd9eb8..00000000 --- a/claudedocs/backend/2026-03-06_구현내역.md +++ /dev/null @@ -1,287 +0,0 @@ -# 2026-03-06 (금) 백엔드 구현 내역 - -## 1. `🔧 수정` [생산지시] 보조 공정 WO 카운트 제외 - -**커밋**: `a845f52` | **유형**: fix (기존 기능 보완) - -### 배경 -목록 조회 시 `work_orders_count`에 보조 공정(재고생산) WO가 포함되어 공정 진행률이 부정확. - -### 구현 내용 -- `withCount`에서 `is_auxiliary` WO 제외 조건 추가 -- `whereNotNull(process_id)` + `options->is_auxiliary` 조건 적용 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/ProductionOrderService.php` | 수정 | - ---- - -## 2. `🔧 수정` [loan] 상품권 summary에 접대비 집계 추가 - -**커밋**: `a7973bb` | **유형**: feat (기존 API 확장) - -### 배경 -상품권 대시보드에서 접대비로 전환된 건수/금액을 별도로 표시해야 함. - -### 구현 내용 -- `expense_accounts` 테이블에서 접대비(상품권) 건수/금액 조회 -- `entertainment_count`, `entertainment_amount` 응답 필드 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/LoanService.php` | 수정 | - ---- - -## 3. `🔧 수정` [receivables] 상위 거래처 집계 soft delete 제외 - -**커밋**: `be9c1ba` | **유형**: fix (버그 수정) - -### 배경 -매출채권 상위 거래처 집계 쿼리에서 soft delete된 레코드가 포함되어 금액이 부풀려지는 이슈. - -### 구현 내용 -- orders, deposits, bills 서브쿼리에 `whereNull('deleted_at')` 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/ReceivablesService.php` | 수정 | - ---- - -## 4. `🆕 신규` [finance] 계정과목 및 일반전표 API 추가 - -**커밋**: `12d172e` | **유형**: feat - -### 배경 -회계 시스템의 핵심인 계정과목 관리 및 일반전표(입금/출금/수동전표 통합 목록) API 신규 구현. - -### 구현 내용 -- `AccountCode` 모델/서비스/컨트롤러 — 계정과목 CRUD -- `JournalEntry`, `JournalEntryLine` 모델 — 전표/전표 분개 모델 -- `GeneralJournalEntryService` — 입금/출금/수동전표 UNION 통합 목록, 수동전표 CRUD -- `GeneralJournalEntryController` + FormRequest 검증 클래스 -- finance 라우트 등록, i18n 메시지 키 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 신규 생성 | -| `app/Http/Controllers/Api/V1/GeneralJournalEntryController.php` | 신규 생성 | -| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 신규 생성 | -| `app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php` | 신규 생성 | -| `app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php` | 신규 생성 | -| `app/Models/Tenants/AccountCode.php` | 신규 생성 | -| `app/Models/Tenants/JournalEntry.php` | 신규 생성 | -| `app/Models/Tenants/JournalEntryLine.php` | 신규 생성 | -| `app/Services/AccountCodeService.php` | 신규 생성 | -| `app/Services/GeneralJournalEntryService.php` | 신규 생성 | -| `lang/ko/error.php` | 수정 | -| `lang/ko/message.php` | 수정 | -| `routes/api/v1/finance.php` | 수정 | - ---- - -## 5. `🔧 수정` [finance] 일반전표 source 필드 및 페이지네이션 수정 - -**커밋**: `816c25a` | **유형**: fix (신규 기능 후속 수정) - -### 배경 -입금/출금 조회 시 source가 CASE WHEN으로 불필요하게 분기되었고, 페이지네이션 응답 구조가 프론트엔드 기대와 불일치. - -### 구현 내용 -- deposits/withdrawals 조회 시 source를 항상 `'linked'`로 고정 -- 페이지네이션 meta 래핑 제거 → 플랫 구조로 변경 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/GeneralJournalEntryService.php` | 수정 | - ---- - -## 6. `🆕 신규` [menu] 즐겨찾기 테이블 마이그레이션 - -**커밋**: `a67c5d9` | **유형**: feat - -### 배경 -사용자별 메뉴 즐겨찾기 기능을 위한 데이터 테이블 필요. - -### 구현 내용 -- `menu_favorites` 테이블 — tenant_id, user_id, menu_id, sort_order -- unique 제약: (tenant_id, user_id, menu_id) -- FK cascade delete: users, menus - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/2026_03_06_143037_create_menu_favorites_table.php` | 신규 생성 | - ---- - -## 7. `🔧 수정` [departments] options JSON 컬럼 추가 - -**커밋**: `56e7164` | **유형**: feat (기존 테이블 확장) - -### 배경 -조직도 숨기기 등 부서별 확장 속성을 저장할 JSON 컬럼 필요. - -### 구현 내용 -- `departments` 테이블에 `options` JSON 컬럼 추가 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/..._add_options_to_departments_table.php` | 신규 생성 | - ---- - -## 8. `🆕 신규` [approval] 결재양식 마이그레이션 (6건) - -**커밋**: `58fedb0`, `eb28b57`, `c5a0115`, `9d4143a`, `449fce1`, `96def0d` | **유형**: feat - -### 배경 -전자결재 양식 확대 — 사용인감계, 사직서, 위임장, 이사회의사록, 견적서, 공문서 양식 추가. - -### 구현 내용 -- `seal_usage` — 사용인감계 양식 -- `resignation` — 사직서 양식 -- `delegation` — 위임장 양식 -- `board_minutes` — 이사회의사록 양식 -- `quotation` — 견적서 양식 -- `official_letter` — 공문서 양식 -- 전체 테넌트에 자동 등록 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/2026_03_06_100000_add_resignation_form.php` | 신규 생성 | -| `database/migrations/2026_03_06_210000_add_seal_usage_form.php` | 신규 생성 | -| `database/migrations/2026_03_06_230000_add_delegation_form.php` | 신규 생성 | -| `database/migrations/2026_03_06_233000_add_board_minutes_form.php` | 신규 생성 | -| `database/migrations/2026_03_06_235000_add_quotation_form.php` | 신규 생성 | -| `database/migrations/2026_03_07_000000_add_official_letter_form.php` | 신규 생성 | - ---- - -## 9. `🆕 신규` [database] 경조사비 관리 테이블 + 메뉴 추가 - -**커밋**: `0ea5fa5`, `22160e5` | **유형**: feat - -### 배경 -거래처 경조사비 관리대장 기능 신규 도입. 데이터 테이블 및 사이드바 메뉴 추가 필요. - -### 구현 내용 -- `condolence_expenses` 테이블 — 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조), 부조금, 선물, 총금액 -- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가 (중복 방지) - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/..._create_condolence_expenses_table.php` | 신규 생성 | -| `database/migrations/..._add_condolence_expenses_menu.php` | 신규 생성 | - ---- - -## 10. `🆕 신규` [문서스냅샷] rendered_html 저장 지원 + Lazy Snapshot API - -**커밋**: `293330c`, `5ebf940`, `c5d5b5d` | **유형**: feat + fix - -### 배경 -문서의 렌더링된 HTML을 스냅샷으로 저장하여 PDF 변환/인쇄 등에 활용. 편집 권한 없이도 스냅샷 갱신 가능한 Lazy Snapshot API 필요. - -### 구현 내용 -- `Document` 모델 $fillable에 `rendered_html` 추가 (🔧) -- `DocumentService` create/update에서 rendered_html 저장 (🔧) -- Store/Update/UpsertRequest에 `rendered_html` 검증 추가 (🔧) -- `WorkOrderService` 검사문서/작업일지 생성 시 rendered_html 전달 (🔧) -- `PATCH /documents/{id}/snapshot` — canEdit 체크 없이 rendered_html만 업데이트 (🆕) -- `resolveInspectionDocument()`에 `snapshot_document_id` 반환 (🆕) - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Models/Documents/Document.php` | 수정 | -| `app/Services/DocumentService.php` | 수정 | -| `app/Services/WorkOrderService.php` | 수정 | -| `app/Http/Requests/Document/StoreRequest.php` | 수정 | -| `app/Http/Requests/Document/UpdateRequest.php` | 수정 | -| `app/Http/Requests/Document/UpsertRequest.php` | 수정 | -| `app/Http/Controllers/Api/V1/Documents/DocumentController.php` | 수정 | -| `routes/api/v1/documents.php` | 수정 | - ---- - -## 11. `🔧 수정` [품질관리] order_ids 영속성 + location 데이터 저장 - -**커밋**: `f2eede6` | **유형**: feat (기존 API 확장) - -### 배경 -품질관리서에 수주 연결 및 개소별 검사 데이터(시공규격, 변경사유, 검사결과)를 저장해야 함. - -### 구현 내용 -- StoreRequest/UpdateRequest에 `order_ids`, `locations` 검증 추가 -- `QualityDocumentLocation`에 `inspection_data`(JSON) fillable/cast 추가 -- store()에 `syncOrders` 연동, update()에 `syncOrders` + `updateLocations` 연동 -- `inspection_data` 컬럼 추가 마이그레이션 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 수정 | -| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 수정 | -| `app/Models/Qualitys/QualityDocumentLocation.php` | 수정 | -| `app/Services/QualityDocumentService.php` | 수정 (대규모) | -| `database/migrations/..._inspection_data_to_quality_document_locations.php` | 신규 생성 | - ---- - -## 12. `🆕 신규` 제품검사 요청서 Document(EAV) 자동생성 및 동기화 - -**커밋**: `2231c9a` | **유형**: feat - -### 배경 -품질관리서 생성/수정/수주연결 시 제품검사 요청서 Document를 EAV 방식으로 자동 생성하고 동기화해야 함. - -### 구현 내용 -- `document_template_sections`에 `description` 컬럼 추가 -- `QualityDocumentService`에 `syncRequestDocument()` 메서드 추가 -- 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑 -- `rendered_html` 초기화 (데이터 변경 시 재캡처 트리거) -- `transformToFrontend`에 `request_document_id` 포함 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Models/Documents/DocumentTemplateSection.php` | 수정 | -| `app/Services/QualityDocumentService.php` | 수정 (대규모) | -| `database/migrations/..._add_description_to_document_template_sections.php` | 신규 생성 | - ---- - -## 13. `⚙️ 설정` [API] logging, docs, seeder 등 부수 정리 - -**커밋**: `ff85530` | **유형**: chore - -### 배경 -여러 파일의 경로, 설정, 문서 등 소소한 정리 작업. - -### 구현 내용 -- `LOGICAL_RELATIONSHIPS.md` 보완 (최신 모델 관계 반영) -- `Legacy5130Calculator` 수정 -- `logging.php` 설정 추가 -- `KyungdongItemSeeder` 수정 -- docs 문서 경로 수정 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `LOGICAL_RELATIONSHIPS.md` | 수정 | -| `app/Helpers/Legacy5130Calculator.php` | 수정 | -| `config/logging.php` | 수정 | -| `database/seeders/Kyungdong/KyungdongItemSeeder.php` | 수정 | -| `docs/INDEX.md` | 수정 | diff --git a/claudedocs/backend/2026-03-07_구현내역.md b/claudedocs/backend/2026-03-07_구현내역.md deleted file mode 100644 index 3381b8ee..00000000 --- a/claudedocs/backend/2026-03-07_구현내역.md +++ /dev/null @@ -1,40 +0,0 @@ -# 2026-03-07 (토) 백엔드 구현 내역 - -## 1. `🆕 신규` [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션 - -**커밋**: `ad93743` | **유형**: feat - -### 배경 -근로기준법에 따른 연차사용촉진 통지서(1차/2차) 양식을 전자결재 시스템에 등록 필요. - -### 구현 내용 -- `leave_promotion_1st` — 연차사용촉진 통지서 (1차) 양식, hr 카테고리 -- `leave_promotion_2nd` — 연차사용촉진 통지서 (2차) 양식, hr 카테고리 -- 전체 테넌트에 자동 등록 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `database/migrations/2026_03_07_100000_add_leave_promotion_forms.php` | 신규 생성 | - ---- - -## 2. `🔧 수정` [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선 - -**커밋**: `3ac64d5` | **유형**: feat (기존 API 확장) - -### 배경 -품질관리서 작성 시 수주 선택 API에 거래처/품목 필터가 없고, 개소별 상세 데이터 부족. 검사 상태 판별 로직도 개선 필요. - -### 구현 내용 -- `availableOrders` — `client_id`/`item_id` 필터 파라미터 지원 -- 응답에 `client_id`, `client_name`, `item_id`, `item_name`, `locations`(개소 상세) 추가 -- `show` — 개소별 데이터에 거래처/모델 정보 포함 -- `DocumentService` — `fqcStatus`를 rootNodes 기반으로 변경 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Services/QualityDocumentService.php` | 수정 | -| `app/Services/DocumentService.php` | 수정 | -| `LOGICAL_RELATIONSHIPS.md` | 수정 | diff --git a/claudedocs/backend/2026-03-08_구현내역.md b/claudedocs/backend/2026-03-08_구현내역.md deleted file mode 100644 index 6600870a..00000000 --- a/claudedocs/backend/2026-03-08_구현내역.md +++ /dev/null @@ -1,47 +0,0 @@ -# 2026-03-08 (일) 백엔드 구현 내역 - -## 1. `🔧 수정` [finance] 계정과목 확장 및 전표 연동 시스템 구현 - -**커밋**: `0044779` | **유형**: feat (3/6 신규 기능 대규모 확장) - -### 배경 -3/6에 추가한 계정과목/일반전표 기본 API를 확장하여 기본 계정과목 시딩, 전표 자동 연동(카드거래/세금계산서), 계정과목 업데이트 기능 구현. - -### 구현 내용 - -#### 계정과목 확장 (🔧 기존 확장) -- `AccountCode` 모델 확장 — 관계, 스코프, 헬퍼 추가 -- `AccountCodeService` 확장 — 업데이트, 트리 조회, 기본 계정과목 시딩 로직 -- `UpdateAccountSubjectRequest` 신규 — 업데이트 검증 규칙 -- `StoreAccountSubjectRequest` — 추가 검증 규칙 보강 - -#### 전표 자동 연동 (🆕 신규) -- `JournalSyncService` 신규 — 카드거래/세금계산서 → 전표 자동 생성 서비스 -- `SyncsExpenseAccounts` 트레이트 — 경비계정 동기화 공통 로직 -- `CardTransactionController` 확장 — 전표 연동 엔드포인트 추가 -- `TaxInvoiceController` 확장 — 전표 연동 엔드포인트 추가 - -#### 데이터베이스 (🆕 신규) -- `expense_accounts` 테이블에 전표 연결 컬럼 마이그레이션 (journal_entry_id 등) -- `account_codes` 테이블 확장 마이그레이션 (추가 속성 컬럼) -- 전체 테넌트 기본 계정과목 시딩 마이그레이션 - -### 변경 파일 -| 파일 | 작업 | -|------|------| -| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 수정 | -| `app/Http/Controllers/Api/V1/CardTransactionController.php` | 수정 (대규모) | -| `app/Http/Controllers/Api/V1/TaxInvoiceController.php` | 수정 (대규모) | -| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 수정 | -| `app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php` | 신규 생성 | -| `app/Models/Tenants/AccountCode.php` | 수정 | -| `app/Models/Tenants/ExpenseAccount.php` | 수정 | -| `app/Models/Tenants/JournalEntry.php` | 수정 | -| `app/Services/AccountCodeService.php` | 수정 (대규모) | -| `app/Services/GeneralJournalEntryService.php` | 수정 | -| `app/Services/JournalSyncService.php` | 신규 생성 | -| `app/Traits/SyncsExpenseAccounts.php` | 신규 생성 | -| `database/migrations/..._add_journal_link_to_expense_accounts_table.php` | 신규 생성 | -| `database/migrations/..._enhance_account_codes_table.php` | 신규 생성 | -| `database/migrations/..._seed_default_account_codes_for_all_tenants.php` | 신규 생성 | -| `routes/api/v1/finance.php` | 수정 | diff --git a/claudedocs/backend/_index.md b/claudedocs/backend/_index.md deleted file mode 100644 index 0d32c0cd..00000000 --- a/claudedocs/backend/_index.md +++ /dev/null @@ -1,72 +0,0 @@ -# SAM API 백엔드 구현 내역서 - -## 2026년 3월 1주차 (3/2 ~ 3/8) - -총 **83개 커밋**, 7일간 구현 내역 - -### 태그 범례 -| 태그 | 의미 | -|------|------| -| `🆕 신규` | 새로운 기능/API/테이블 생성 | -| `🔧 수정` | 기존 기능 버그 수정, 확장, 보완 | -| `🔄 리팩토링` | 기능 변경 없이 코드 구조 개선 | -| `⚙️ 설정` | 환경 설정, 인프라, 문서 정리 | - -### 날짜별 문서 - -| 날짜 | 파일 | 주요 작업 | 🆕 | 🔧 | 🔄 | ⚙️ | -|------|------|-----------|-----|-----|-----|-----| -| 3/2 (월) | [2026-03-02_구현내역.md](./2026-03-02_구현내역.md) | 로드맵 테이블, AI 견적 엔진 | 2 | - | - | - | -| 3/3 (화) | [2026-03-03_구현내역.md](./2026-03-03_구현내역.md) | Gemini 업그레이드, 배포 수정, HR 확장, 자재투입 개선 | - | 7 | - | 1 | -| 3/4 (수) | [2026-03-04_구현내역.md](./2026-03-04_구현내역.md) | 바로빌 연동, 리스크 대시보드, 지출결의서, 배차 시스템 | 6 | 9 | - | - | -| 3/5 (목) | [2026-03-05_구현내역.md](./2026-03-05_구현내역.md) | CEO 대시보드, 어음 V8, 상품권 접대비, 생산지시, 품질관리 | 7 | 7 | 2 | 1 | -| 3/6 (금) | [2026-03-06_구현내역.md](./2026-03-06_구현내역.md) | 계정과목/일반전표, 문서 스냅샷, 결재양식 6종, 경조사비 | 7 | 5 | - | 1 | -| 3/7 (토) | [2026-03-07_구현내역.md](./2026-03-07_구현내역.md) | 연차촉진 통지서, 품질검사 필터링 | 1 | 1 | - | - | -| 3/8 (일) | [2026-03-08_구현내역.md](./2026-03-08_구현내역.md) | 계정과목 확장, 전표 연동 시스템 | - | 1 | - | - | -| **합계** | | | **23** | **30** | **2** | **3** | - -### 도메인별 주요 기능 - -#### 재무/회계 -- 🆕 계정과목 및 일반전표 API 신규 구축 -- 🆕 전표 자동 연동 (카드거래/세금계산서) -- 🆕 접대비 상세 조회 API + 리스크 감지 -- 🆕 부가세 상세 조회 API -- 🆕 경조사비 관리 테이블 -- 🆕 바로빌 연동 API -- 🔧 접대비/복리후생비 리스크 감지형 대시보드 전환 -- 🔧 매출채권 상세 대시보드 개선 -- 🔧 가지급금 카테고리 분류 (카드/경조사/상품권/접대비) -- 🔧 상품권 접대비 자동 연동 -- 🔧 어음 V8 확장 필드 (54개) - -#### 생산/품질 -- 🆕 생산지시 전용 API (목록/통계/상세) -- 🆕 품질관리서 CRUD API (14개 엔드포인트) -- 🆕 실적신고 관리 API (6개 엔드포인트) -- 🆕 제품검사 요청서 EAV 자동생성 -- 🆕 보조 공정(재고생산) 분리 -- 🔧 절곡 검사 데이터 복제/EAV 변환 -- 🔧 자재투입 bom_group_key/replace 모드 - -#### 전자결재 -- 🆕 Document ↔ Approval 브릿지 연동 -- 🆕 결재양식 11종 추가 (지출결의서, 근태신청, 사유서, 재직증명서 등) -- 🔧 drafter_read_at, resubmit_count, rejection_history 컬럼 - -#### 대시보드/리포트 -- 🆕 CEO 대시보드 6개 섹션 API -- 🆕 일일보고서 엑셀 내보내기 -- 🔧 자금현황 카드 필드 - -#### 출고/배차 -- 🆕 배차정보 다중 행 시스템 -- 🆕 배차차량 관리 API - -#### 인프라/기타 -- ⚙️ Gemini 2.5-flash 업그레이드 -- 🔧 .env 권한 640 보장 (배포) -- ⚙️ Slack 알림 채널 분리 -- 🆕 문서 rendered_html 스냅샷 API -- 🆕 메뉴 즐겨찾기 테이블 -- 🔧 주소 필드 500자 확장 diff --git a/claudedocs/board/[IMPL-2025-12-30] dynamic-board-creation.md b/claudedocs/board/[IMPL-2025-12-30] dynamic-board-creation.md deleted file mode 100644 index 97230f95..00000000 --- a/claudedocs/board/[IMPL-2025-12-30] dynamic-board-creation.md +++ /dev/null @@ -1,120 +0,0 @@ -# 게시판 동적 생성 구현 - -> 작성일: 2025-12-30 -> 상태: 완료 - -## 개요 - -게시판 관리에서 게시판을 등록하면 고객센터 메뉴에 자동으로 추가되고, -해당 게시판 페이지가 동적으로 렌더링되도록 구현합니다. - ---- - -## 작업 목록 - -### Phase 1: 게시판 관리 폼 수정 - -- [x] 1.1 대상 옵션에 "권한" 추가 - - 현재: 전사, 부서 - - 변경: 전사, 부서, **권한** - - 파일: `src/components/board/BoardManagement/types.ts` -- [x] 1.2 권한 선택 시 다중 선택 체크박스 표시 - - 파일: `src/components/board/BoardManagement/BoardForm.tsx` - - MOCK_PERMISSIONS: 관리자, 매니저, 직원, 게스트 -- [x] 1.3 API 요청 데이터에 권한 정보 포함 - - 파일: `src/components/board/BoardManagement/actions.ts` - - transformFrontendToApi: permissions → extra_settings.permissions - -### Phase 2: 메뉴 즉시 갱신 - -- [x] 2.1 게시판 등록 성공 후 `forceRefreshMenus()` 호출 - - 파일: `src/app/[locale]/(protected)/board/board-management/new/page.tsx` -- [x] 2.2 게시판 수정 성공 후 `forceRefreshMenus()` 호출 - - 파일: `src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx` - -### Phase 3: 동적 게시판 라우트 생성 - -- [x] 3.1 `/customer-center/[boardCode]/page.tsx` - 리스트 -- [x] 3.2 `/customer-center/[boardCode]/[postId]/page.tsx` - 상세 -- [x] 3.3 `/customer-center/[boardCode]/create/page.tsx` - 등록 -- [x] 3.4 `/customer-center/[boardCode]/[postId]/edit/page.tsx` - 수정 - -### Phase 4: 테스트 및 검증 - -- [ ] 4.1 게시판 등록 → 메뉴 자동 추가 확인 -- [ ] 4.2 동적 게시판 리스트/상세/등록/수정 동작 확인 -- [ ] 4.3 권한별 접근 제어 확인 - ---- - -## 기술 명세 - -### 대상 타입 - -| 대상 | 옆 셀렉트박스 | API 필드 | -|------|---------------|----------| -| 전사 | 없음 | `target: 'all'` | -| 부서 | 부서 단일 선택 | `target: 'department', target_id: number` | -| 권한 | 권한 다중 선택 (체크박스) | `target: 'permission', permissions: string[]` | - -### 게시판 타입 - -- **기본 타입**: 1:1문의 형태 (댓글 사용 가능) -- **참고 페이지**: `/customer-center/qna` - -### 메뉴 갱신 플로우 - -``` -게시판 등록 API 호출 (POST /api/v1/boards) - ↓ -백엔드: 게시판 생성 + 메뉴 테이블에 추가 - ↓ -프론트: 등록 성공 응답 받음 - ↓ -프론트: forceRefreshMenus() 호출 - ↓ -사이드바 메뉴 즉시 업데이트 -``` - -### 동적 게시판 URL 구조 - -``` -/boards/[boardCode] → 목록 -/boards/[boardCode]/create → 등록 -/boards/[boardCode]/[postId] → 상세 -/boards/[boardCode]/[postId]/edit → 수정 -``` - -> **URL 변경 이력 (2025-12-30)** -> - 변경 전: `/customer-center/[boardCode]` -> - 변경 후: `/boards/[boardCode]` -> - 사유: 백엔드 메뉴 API path 규칙에 맞춤 (`/boards/free`, `/boards/board_xxx`) - ---- - -## 관련 파일 - -### 수정된 파일 -- `src/components/board/BoardManagement/types.ts` - BoardTarget에 'permission' 추가 -- `src/components/board/BoardManagement/BoardForm.tsx` - 권한 다중 선택 UI 추가 -- `src/components/board/BoardManagement/actions.ts` - permissions 변환 로직 -- `src/components/customer-center/shared/types.ts` - SystemBoardCode 확장 -- `src/app/[locale]/(protected)/board/board-management/new/page.tsx` - forceRefreshMenus 호출 -- `src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx` - forceRefreshMenus 호출 - -### 새로 생성된 파일 -- `src/app/[locale]/(protected)/boards/[boardCode]/page.tsx` - 동적 게시판 목록 -- `src/app/[locale]/(protected)/boards/[boardCode]/[postId]/page.tsx` - 동적 게시판 상세 -- `src/app/[locale]/(protected)/boards/[boardCode]/create/page.tsx` - 동적 게시판 등록 -- `src/app/[locale]/(protected)/boards/[boardCode]/[postId]/edit/page.tsx` - 동적 게시판 수정 - ---- - -## 진행 로그 - -| 날짜 | 작업 내용 | -|------|----------| -| 2025-12-30 | 요구사항 정리 및 체크리스트 생성 | -| 2025-12-30 | Phase 1~3 구현 완료 | -| 2025-12-30 | URL 경로 변경: `/customer-center/[boardCode]` → `/boards/[boardCode]` | -| 2025-12-30 | API URL 불일치 해결: `system-boards` → `boards` (DynamicBoard/actions.ts 생성) | \ No newline at end of file diff --git a/claudedocs/board/[PLAN-2025-12-19] board-management-implementation.md b/claudedocs/board/[PLAN-2025-12-19] board-management-implementation.md deleted file mode 100644 index 7e690b5b..00000000 --- a/claudedocs/board/[PLAN-2025-12-19] board-management-implementation.md +++ /dev/null @@ -1,313 +0,0 @@ -ㅓ# 게시판 관리 기능 구현 계획서 - -> 작성일: 2025-12-19 -> 상태: 🔴 **계획 검토 대기** - ---- - -## 1. 개요 - -### 1.1 기능 요약 -게시판 리스트/등록/상세/댓글 기능 구현 - -### 1.2 참고 페이지 -- **탭 네비게이션**: `src/components/items/ItemListClient.tsx` (품목관리) -- **테이블 필터 위치**: `src/components/accounting/SalesManagement/index.tsx` (매출관리) -- **공통 레이아웃**: `IntegratedListTemplateV2` 템플릿 - -### 1.3 디자인 스펙 (스크린샷 기준) - -#### 리스트 페이지 -| 항목 | 내용 | -|------|------| -| 페이지 타이틀 | 게시판 | -| 페이지 설명 | 게시판의 게시글을 등록하고 관리합니다. | -| 날짜 범위 | 2025-09-01 ~ 2025-09-03 | -| 탭 | 전체보드, 전자결재, 인쇄, 미역, 우울 + **게시글 등록** 버튼 | -| 게시판 필터 | 공지사항 (게시판명, 게시판명2, 나의 게시글) | -| 검색 | 검색 입력창 | -| 정렬 | 최신순 ▼ | -| 테이블 컬럼 | No., 제목, 작성자, 등록일, 조회수 | -| 행 클릭 | 게시글 상세 화면으로 이동 | - -#### 등록/수정 페이지 -| 항목 | 내용 | -|------|------| -| 페이지 타이틀 | 게시글 상세 | -| 페이지 설명 | 게시글을 등록하고 관리합니다. | -| **게시글 정보** (필수 섹션) | | -| - 게시판 | Select: "게시판을 선택해주세요" | -| - 상단 노출 | Radio: 사용안함 / **사용함** | -| - 제목 | Input: "제목을 입력해주세요" | -| - 내용 | **WYSIWYG 에디터** (B, I, U, S, 정렬, 목록, 링크, 이미지 등) | -| - 첨부파일 | 파일 찾기 버튼 | -| - 작성자 | 읽기 전용 (예: 홍길동) | -| - 댓글 | Radio: **사용안함** / 사용함 | -| - 등록일시 | 읽기 전용 (예: 2025-09-09 12:20) | -| 상단 노출 제한 | 최대 5개까지 설정 가능, 초과 시 Alert 표시 | - -#### 상세 페이지 -| 항목 | 내용 | -|------|------| -| 페이지 타이틀 | 게시글 상세 | -| 페이지 설명 | 게시글을 조회합니다. | -| 버튼 (본인 글만) | **삭제**, **수정** | -| 게시판명 라벨 | 게시판명 | -| 제목 | 제목 | -| 메타 정보 | 작성자 \| 날짜 \| 조회수 (예: 홍길동 \| 2025-09-03 12:23 \| 조회수 123) | -| 내용 | HTML 콘텐츠 (이미지 포함) | -| 첨부파일 | 다운로드 링크 (예: abc.pdf) | -| 댓글 등록 | Textarea + 등록 버튼 (댓글 사용함 설정 시만 표시) | - -#### 댓글 섹션 -| 항목 | 내용 | -|------|------| -| 댓글 수 | 댓글 N | -| 댓글 정보 | 프로필 이미지, 부서명 이름 직책, 등록일시, 댓글 내용 | -| 수정 버튼 (본인만) | 클릭 시 인풋박스에 기존 댓글 내용 입력 상태로 변경 | -| 삭제 버튼 (본인만) | 클릭 시 **"정말 삭제하시겠습니까?"** 확인 Alert 표시 | - ---- - -## 2. WYSIWYG 에디터 추천 - -### 2.1 옵션 비교 - -| 라이브러리 | 장점 | 단점 | 추천도 | -|------------|------|------|--------| -| **TipTap** | 최신, Headless (커스텀 자유), React 네이티브, shadcn/ui 호환 | 학습 곡선 | ⭐⭐⭐⭐⭐ | -| **CKEditor 5** | 기능 풍부, 엔터프라이즈급, 이미지 업로드 내장 | 무거움, 스타일 충돌, 라이선스 | ⭐⭐⭐⭐ | -| **Quill** | 간단, 가벼움 | 구식 스타일, 유지보수 부족 | ⭐⭐⭐ | -| **Editor.js** | 블록 기반, 노션 스타일 | JSON 출력 (HTML 아님), 변환 필요 | ⭐⭐⭐ | -| **React-Quill** | Quill + React 래퍼 | Next.js SSR 이슈 | ⭐⭐ | - -### 2.2 최종 추천: **TipTap** - -**이유**: -1. **Headless 아키텍처**: shadcn/ui와 Tailwind CSS 완벽 호환 -2. **모던 React**: useState/useEffect 패턴, TypeScript 완벽 지원 -3. **확장성**: 필요한 기능만 설치 (경량화) -4. **커뮤니티**: 활발한 개발, 문서화 우수 -5. **이미지 업로드**: 커스텀 핸들러로 S3/백엔드 연동 용이 - -### 2.3 필요 패키지 (TipTap) - -```bash -npm install @tiptap/react @tiptap/pm @tiptap/starter-kit \ - @tiptap/extension-image @tiptap/extension-link \ - @tiptap/extension-underline @tiptap/extension-text-align \ - @tiptap/extension-placeholder -``` - ---- - -## 3. 파일 구조 - -``` -src/ -├── app/[locale]/(protected)/board/ -│ ├── page.tsx # 게시판 리스트 페이지 -│ ├── create/ -│ │ └── page.tsx # 게시글 등록 페이지 -│ └── [id]/ -│ ├── page.tsx # 게시글 상세 페이지 -│ └── edit/ -│ └── page.tsx # 게시글 수정 페이지 -│ -├── components/board/ -│ ├── BoardList/ -│ │ ├── index.tsx # 리스트 메인 컴포넌트 -│ │ └── types.ts # 타입 정의 -│ ├── BoardForm/ -│ │ ├── index.tsx # 등록/수정 폼 -│ │ └── types.ts -│ ├── BoardDetail/ -│ │ ├── index.tsx # 상세 보기 -│ │ └── types.ts -│ ├── CommentSection/ -│ │ ├── index.tsx # 댓글 섹션 -│ │ ├── CommentItem.tsx # 개별 댓글 컴포넌트 -│ │ └── types.ts -│ └── RichTextEditor/ -│ ├── index.tsx # TipTap 에디터 래퍼 -│ ├── MenuBar.tsx # 에디터 툴바 -│ └── extensions.ts # TipTap 확장 설정 -│ -└── hooks/ - └── useBoardList.ts # 게시판 목록 API 훅 -``` - ---- - -## 4. 구현 체크리스트 - -### Phase 1: 기반 작업 (에디터 + 타입) - -- [ ] **1.1** TipTap 패키지 설치 -- [ ] **1.2** `RichTextEditor` 컴포넌트 구현 - - [ ] 1.2.1 기본 에디터 (Bold, Italic, Underline, Strike) - - [ ] 1.2.2 텍스트 정렬 (좌/중/우) - - [ ] 1.2.3 목록 (Bullet, Ordered) - - [ ] 1.2.4 링크 삽입 - - [ ] 1.2.5 이미지 업로드 (파일 선택 → 백엔드 업로드 → URL 삽입) -- [ ] **1.3** `types.ts` 정의 - - [ ] 1.3.1 Board (게시판 타입) - - [ ] 1.3.2 Post (게시글 타입) - - [ ] 1.3.3 Comment (댓글 타입) - -### Phase 2: 리스트 페이지 - -- [ ] **2.1** `BoardList/index.tsx` 구현 - - [ ] 2.1.1 IntegratedListTemplateV2 적용 - - [ ] 2.1.2 DateRangeSelector (날짜 범위) - - [ ] 2.1.3 탭 네비게이션 (전체보드, 전자결재, 인쇄, 미역, 우울) - - [ ] 2.1.4 게시판 필터 (공지사항 드롭다운) - - [ ] 2.1.5 검색 입력창 - - [ ] 2.1.6 정렬 드롭다운 (최신순) - - [ ] 2.1.7 총 N건 표시 -- [ ] **2.2** 테이블 구현 - - [ ] 2.2.1 컬럼: No., 제목, 작성자, 등록일, 조회수 - - [ ] 2.2.2 체크박스 선택 - - [ ] 2.2.3 행 클릭 → 상세 이동 -- [ ] **2.3** 모바일 카드 뷰 구현 -- [ ] **2.4** 페이지네이션 구현 -- [ ] **2.5** `page.tsx` 라우트 생성 - -### Phase 3: 등록/수정 페이지 - -- [ ] **3.1** `BoardForm/index.tsx` 구현 - - [ ] 3.1.1 게시판 Select (게시판 목록) - - [ ] 3.1.2 상단 노출 Radio (사용안함/사용함) - - [ ] 3.1.2.1 최대 5개 제한 Alert - - [ ] 3.1.3 제목 Input - - [ ] 3.1.4 내용 RichTextEditor - - [ ] 3.1.5 첨부파일 업로드 (다중 파일) - - [ ] 3.1.6 작성자 표시 (읽기 전용) - - [ ] 3.1.7 댓글 Radio (사용안함/사용함) - - [ ] 3.1.8 등록일시 표시 (읽기 전용) - - [ ] 3.1.9 등록 버튼 -- [ ] **3.2** 수정 모드 구현 (기존 데이터 로드) -- [ ] **3.3** 유효성 검사 - - [ ] 3.3.1 필수 필드: 게시판, 제목, 내용 -- [ ] **3.4** `create/page.tsx` 라우트 생성 -- [ ] **3.5** `[id]/edit/page.tsx` 라우트 생성 - -### Phase 4: 상세 페이지 - -- [ ] **4.1** `BoardDetail/index.tsx` 구현 - - [ ] 4.1.1 게시판명 라벨 - - [ ] 4.1.2 제목 - - [ ] 4.1.3 메타 정보 (작성자 | 날짜 | 조회수) - - [ ] 4.1.4 내용 (HTML 렌더링) - - [ ] 4.1.5 첨부파일 다운로드 링크 -- [ ] **4.2** 삭제/수정 버튼 (본인 글만 표시) - - [ ] 4.2.1 삭제 버튼 → **AlertDialog** ("정말 삭제하시겠습니까?") - - [ ] 4.2.2 수정 버튼 → 수정 페이지 이동 -- [ ] **4.3** `[id]/page.tsx` 라우트 생성 - -### Phase 5: 댓글 기능 - -- [ ] **5.1** `CommentSection/index.tsx` 구현 - - [ ] 5.1.1 댓글 등록 Textarea + 버튼 - - [ ] 5.1.2 댓글 수 표시 ("댓글 N") -- [ ] **5.2** `CommentItem.tsx` 구현 - - [ ] 5.2.1 프로필 이미지 - - [ ] 5.2.2 부서명 이름 직책 - - [ ] 5.2.3 등록일시 - - [ ] 5.2.4 댓글 내용 - - [ ] 5.2.5 수정 버튼 (본인만) → 인라인 수정 모드 - - [ ] 5.2.6 삭제 버튼 (본인만) → **AlertDialog** -- [ ] **5.3** 댓글 CRUD API 연동 - -### Phase 6: API 연동 + 마무리 - -- [ ] **6.1** Mock 데이터 → 실제 API 연동 -- [ ] **6.2** 에러 핸들링 (toast 알림) -- [ ] **6.3** 로딩 상태 UI -- [ ] **6.4** 반응형 테스트 (모바일/태블릿/데스크톱) -- [ ] **6.5** 접근 권한 테스트 (본인 글/타인 글) - -### Phase 7: 문서화 - -- [ ] **7.1** 테스트 URL 문서 업데이트 (`[REF] all-pages-test-urls.md`) - - [ ] 7.1.1 게시판 섹션 신규 추가 (기존 구역과 별도) - - [ ] 7.1.2 메인 리스트 URL만 등록 -- [ ] **7.2** 이 문서 완료 처리 - ---- - -## 5. 주의사항 (버디가 자주 틀리는 것들) - -### 5.1 디스크립션 확인 필수 -- [ ] 리스트: "게시판의 게시글을 등록하고 관리합니다." -- [ ] 등록: "게시글을 등록하고 관리합니다." -- [ ] 상세: "게시글을 조회합니다." - -### 5.2 테이블 컬럼 타이틀 정확히 -- [ ] No. (번호 아님) -- [ ] 제목 -- [ ] 작성자 -- [ ] 등록일 -- [ ] 조회수 - -### 5.3 카드/라벨 텍스트 정확히 -- [ ] "게시판" (게시판명 아님, Select label) -- [ ] "상단 노출" (상단고정 아님) -- [ ] "댓글" (댓글허용 아님) -- [ ] "댓글 N" (댓글 수 표시) - -### 5.4 팝업 메시지 정확히 -- [ ] 삭제 확인: **"정말 삭제하시겠습니까?"** -- [ ] 상단 노출 초과: **"상단 노출은 5개까지 설정 가능합니다."** - -### 5.5 본인 글/댓글 체크 -- [ ] 게시글 삭제/수정 버튼 → 본인 글만 -- [ ] 댓글 수정/삭제 버튼 → 본인 댓글만 - ---- - -## 6. 기술 스택 - -| 항목 | 기술 | -|------|------| -| 프레임워크 | Next.js 14 App Router | -| UI 컴포넌트 | shadcn/ui | -| 스타일링 | Tailwind CSS | -| 에디터 | **TipTap** | -| 폼 | React Hook Form (권장) | -| 상태 관리 | React useState/useCallback | -| API | Next.js API Routes (Proxy) | -| 팝업 | AlertDialog, Dialog (Radix UI) | - ---- - -## 7. 작업 완료 후 필수 조치 - -### 7.1 테스트 URL 문서 업데이트 - -`claudedocs/[REF] all-pages-test-urls.md` 파일에 다음 내용 추가: - -```markdown -## 게시판 (Board) - 🆕 NEW SECTION - -| 페이지 | URL | 비고 | -|--------|-----|------| -| 게시판 목록 | `/ko/board` | 🆕 NEW | -``` - -> ⚠️ **참고**: 상세/수정/등록 페이지는 메인 리스트에서 접근 가능하므로 별도 등록하지 않음 - -### 7.2 _index.md 업데이트 - -`claudedocs/_index.md`에 board/ 폴더 섹션 추가 - ---- - -## 8. 승인 대기 - -- [ ] 사용자 확인 완료 -- [ ] 작업 시작 - ---- - -*작성: Claude Code* \ No newline at end of file diff --git a/claudedocs/changes/20250108_order_frontend_api_integration.md b/claudedocs/changes/20250108_order_frontend_api_integration.md deleted file mode 100644 index fabe8c51..00000000 --- a/claudedocs/changes/20250108_order_frontend_api_integration.md +++ /dev/null @@ -1,92 +0,0 @@ -# 수주 관리 Frontend API 연동 - -**날짜:** 2025-01-08 -**Phase:** Phase 2 - Frontend 연동 -**관련 Plan:** docs/plans/order-management-plan.md - -## 변경 개요 - -수주 관리 React 페이지들을 백엔드 API와 연동 완료. Mock 데이터를 제거하고 실제 API 호출로 대체. - -## 수정된 파일 - -### 1. `src/components/orders/actions.ts` (신규 생성) -- Server Actions 패턴으로 API 클라이언트 구현 -- 주요 함수: - - `getOrders()`: 수주 목록 조회 - - `getOrderById(id)`: 수주 상세 조회 - - `createOrder(data)`: 수주 등록 - - `updateOrder(id, data)`: 수주 수정 - - `deleteOrder(id)`: 수주 삭제 - - `deleteOrders(ids)`: 수주 일괄 삭제 - - `updateOrderStatus(id, status)`: 수주 상태 변경 - - `getOrderStats()`: 통계 조회 -- 데이터 변환: API snake_case → Frontend camelCase -- 상태 매핑: API 상태(DRAFT, CONFIRMED 등) → Frontend 상태(order_registered, order_confirmed 등) - -### 2. `src/components/orders/index.ts` (수정) -- actions.ts export 추가 -- 타입 충돌 해결 (OrderItem → OrderItemApi) - -### 3. `src/app/[locale]/(protected)/sales/order-management-sales/page.tsx` (수정) -- SAMPLE_ORDERS (~115줄) 제거 -- API 연동 state 추가: `orders`, `apiStats`, `isLoading`, `isDeleting` -- `loadData()` 함수로 API 호출 (getOrders, getOrderStats) -- 삭제 핸들러에 API 호출 추가 (deleteOrder, deleteOrders) -- 로딩 UI 추가 - -### 4. `src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx` (수정) -- SAMPLE_ITEMS, SAMPLE_ORDERS (~250줄) 제거 -- useEffect에서 getOrderById API 호출 -- handleConfirmCancel에서 updateOrderStatus API 호출 -- isCancelling 로딩 상태 적용 - -### 5. `src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx` (수정) -- SAMPLE_ORDER (~50줄) 제거 -- useEffect에서 getOrderById API 호출 -- handleSave에서 updateOrder API 호출 - -### 6. `src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx` (수정) -- handleSave에서 createOrder API 호출 - -## 기술 패턴 - -### Server Actions 패턴 -```typescript -"use server"; -import { serverFetch } from "@/lib/api/serverFetch"; - -export async function getOrders() { - const response = await serverFetch("/orders"); - // 데이터 변환 로직 -} -``` - -### 데이터 변환 -- API: `order_no`, `client_name`, `site_name` -- Frontend: `orderNo`, `clientName`, `siteName` - -### 상태 매핑 -| API | Frontend | -|-----|----------| -| DRAFT | order_registered | -| CONFIRMED | order_confirmed | -| IN_PROGRESS | production_ordered | -| COMPLETED | shipped | -| CANCELLED | cancelled | - -## 테스트 체크리스트 - -- [ ] 수주 목록 로드 -- [ ] 수주 상세 조회 -- [ ] 수주 등록 (견적 선택 후) -- [ ] 수주 수정 -- [ ] 수주 개별 삭제 -- [ ] 수주 일괄 삭제 -- [ ] 수주 취소 -- [ ] 통계 카드 표시 - -## 연관 작업 - -- Phase 1: Order API 백엔드 구현 (커밋: de19ac9) -- Phase 1.1: OrderController/Service 구현 (진행 중) diff --git a/claudedocs/changes/20250108_order_phase3_advanced_features.md b/claudedocs/changes/20250108_order_phase3_advanced_features.md deleted file mode 100644 index 208d7ad4..00000000 --- a/claudedocs/changes/20250108_order_phase3_advanced_features.md +++ /dev/null @@ -1,113 +0,0 @@ -# 수주 관리 Phase 3 - 고급 기능 - -**날짜:** 2025-01-08 -**Phase:** Phase 3 - 고급 기능 -**관련 Plan:** docs/plans/order-management-plan.md - -## 변경 개요 - -수주 관리 시스템에 견적→수주 변환 및 생산지시 생성 기능 추가. - -## API 추가 사항 - -### 1. 견적에서 수주 생성 -- **Endpoint**: `POST /api/v1/orders/from-quote/{quoteId}` -- **기능**: 기존 견적서를 기반으로 수주를 자동 생성 -- **검증**: 이미 수주가 생성된 견적은 중복 생성 방지 - -### 2. 생산지시 생성 -- **Endpoint**: `POST /api/v1/orders/{id}/production-order` -- **기능**: 확정된 수주에서 작업지시(WorkOrder) 생성 -- **검증**: CONFIRMED 상태의 수주만 생산지시 가능 - -## 수정된 파일 - -### API (Laravel) - -#### 1. `app/Services/OrderService.php` -- `createFromQuote(int $quoteId, array $data)`: 견적→수주 변환 로직 -- `createProductionOrder(int $orderId, array $data)`: 생산지시 생성 로직 -- `generateWorkOrderNo(int $tenantId)`: 작업지시번호 자동 생성 - -#### 2. `app/Http/Controllers/Api/V1/OrderController.php` -- `createFromQuote()`: 견적→수주 액션 -- `createProductionOrder()`: 생산지시 생성 액션 - -#### 3. `app/Http/Requests/Order/CreateFromQuoteRequest.php` (신규) -- 견적→수주 변환 요청 검증 -- 선택 필드: delivery_date, memo - -#### 4. `app/Http/Requests/Order/CreateProductionOrderRequest.php` (신규) -- 생산지시 생성 요청 검증 -- 선택 필드: process_type, assignee_id, team_id, scheduled_date, memo - -#### 5. `routes/api.php` -- `POST /orders/from-quote/{quoteId}`: 견적→수주 라우트 -- `POST /orders/{id}/production-order`: 생산지시 라우트 - -#### 6. `lang/ko/message.php` -- `order.created_from_quote`: 견적에서 수주가 생성되었습니다. -- `order.production_order_created`: 생산지시가 생성되었습니다. - -#### 7. `lang/ko/error.php` -- `order.already_created_from_quote`: 이미 해당 견적에서 수주가 생성되었습니다. -- `order.must_be_confirmed_for_production`: 확정 상태의 수주만 생산지시를 생성할 수 있습니다. -- `order.production_order_already_exists`: 이미 생산지시가 존재합니다. -- `quote.not_found`: 견적을 찾을 수 없습니다. - -### Frontend (React) - -#### 1. `src/components/orders/actions.ts` -- 타입 추가: `CreateFromQuoteData`, `CreateProductionOrderData`, `WorkOrder`, `ProductionOrderResult` -- API 인터페이스 추가: `ApiWorkOrder`, `ApiProductionOrderResponse` -- `createOrderFromQuote(quoteId, data)`: 견적→수주 API 호출 -- `createProductionOrder(orderId, data)`: 생산지시 생성 API 호출 -- `transformWorkOrderApiToFrontend()`: WorkOrder 데이터 변환 - -## 비즈니스 로직 - -### 견적→수주 변환 흐름 -``` -Quote (견적) - ↓ createFromQuote() -Order (수주) - DRAFT 상태 - - quote_id 연결 - - client, site_name 복사 - - items 변환 (quantity=calculated_quantity) - - 금액 재계산 -``` - -### 생산지시 생성 흐름 -``` -Order (수주) - CONFIRMED 상태 - ↓ createProductionOrder() -WorkOrder (작업지시) - PENDING 상태 - - sales_order_id 연결 - - project_name = site_name - - process_type 설정 - ↓ -Order 상태 → IN_PROGRESS -``` - -### 상태 전환 규칙 (기존) -``` -DRAFT → CONFIRMED → IN_PROGRESS → COMPLETED - ↓ ↓ ↓ -CANCELLED (어느 단계에서든 취소 가능) -``` - -## 테스트 체크리스트 - -- [ ] 견적→수주 생성 (정상 케이스) -- [ ] 견적→수주 생성 (중복 방지) -- [ ] 견적→수주 생성 (존재하지 않는 견적) -- [ ] 생산지시 생성 (정상 케이스) -- [ ] 생산지시 생성 (CONFIRMED 아닌 수주) -- [ ] 생산지시 생성 (중복 방지) -- [ ] 수주 상태 자동 변경 (CONFIRMED → IN_PROGRESS) - -## 연관 작업 - -- Phase 1: Order API 백엔드 구현 (커밋: de19ac9) -- Phase 2: Frontend API 연동 (커밋: 572ffe8) -- Phase 3: 고급 기능 (현재) diff --git a/claudedocs/components/_registry.md b/claudedocs/components/_registry.md deleted file mode 100644 index 7859de84..00000000 --- a/claudedocs/components/_registry.md +++ /dev/null @@ -1,691 +0,0 @@ -# Component Registry - -> Auto-generated: 2026-02-12T01:56:50.520Z -> Total: **501** components - -## UI (53) - -### ui (53) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| Accordion | accordion.tsx | none | | Y | 66 | -| AccountNumberInput | account-number-input.tsx | none | AccountNumberInputProps | Y | 95 | -| Alert | alert.tsx | none | VariantProps | | 59 | -| AlertDialog | alert-dialog.tsx | none | | Y | 158 | -| Badge | badge.tsx | none | VariantProps | | 47 | -| BusinessNumberInput | business-number-input.tsx | none | BusinessNumberInputProps | Y | 114 | -| Button | button.tsx | none | VariantProps | | 62 | -| Calendar | calendar.tsx | none | | Y | 138 | -| Card | card.tsx | none | | | 93 | -| CardNumberInput | card-number-input.tsx | none | CardNumberInputProps | Y | 95 | -| ChartWrapper | chart-wrapper.tsx | named | ChartWrapperProps | | 66 | -| Checkbox | checkbox.tsx | none | | Y | 33 | -| Collapsible | collapsible.tsx | none | | Y | 33 | -| Command | command.tsx | none | | Y | 177 | -| ConfirmDialog | confirm-dialog.tsx | both | ConfirmDialogProps | Y | 226 | -| CurrencyInput | currency-input.tsx | none | CurrencyInputProps | Y | 220 | -| DatePicker | date-picker.tsx | none | DatePickerProps | Y | 279 | -| Dialog | dialog.tsx | none | | Y | 137 | -| Drawer | drawer.tsx | none | | Y | 133 | -| DropdownMenu | dropdown-menu.tsx | none | | Y | 258 | -| EmptyState | empty-state.tsx | both | ButtonProps | Y | 227 | -| ErrorCard | error-card.tsx | named | ErrorCardProps | Y | 196 | -| ErrorMessage | error-message.tsx | named | ErrorMessageProps | | 38 | -| FileDropzone | file-dropzone.tsx | both | FileDropzoneProps | Y | 227 | -| FileInput | file-input.tsx | both | FileInputProps | Y | 226 | -| FileList | file-list.tsx | both | FileListProps | Y | 276 | -| ImageUpload | image-upload.tsx | both | ImageUploadProps | Y | 309 | -| Input | input.tsx | none | | | 22 | -| Label | label.tsx | none | | Y | 25 | -| LoadingSpinner | loading-spinner.tsx | named | LoadingSpinnerProps | | 114 | -| MultiSelectCombobox | multi-select-combobox.tsx | named | MultiSelectComboboxProps | Y | 128 | -| NumberInput | number-input.tsx | none | NumberInputProps | Y | 280 | -| PersonalNumberInput | personal-number-input.tsx | none | PersonalNumberInputProps | Y | 101 | -| PhoneInput | phone-input.tsx | none | PhoneInputProps | Y | 95 | -| Popover | popover.tsx | none | | Y | 53 | -| Progress | progress.tsx | none | | Y | 32 | -| QuantityInput | quantity-input.tsx | none | QuantityInputProps | Y | 271 | -| RadioGroup | radio-group.tsx | none | | Y | 46 | -| ScrollArea | scroll-area.tsx | none | | Y | 53 | -| SearchableSelect | searchable-select.tsx | named | SearchableSelectProps | Y | 219 | -| Select | select.tsx | none | | Y | 192 | -| Separator | separator.tsx | none | SeparatorProps | Y | 32 | -| Sheet | sheet.tsx | none | | Y | 146 | -| Skeleton | skeleton.tsx | none | SkeletonProps | Y | 679 | -| Slider | slider.tsx | none | | | 26 | -| StatusBadge | status-badge.tsx | both | StatusBadgeProps | Y | 123 | -| Switch | switch.tsx | none | | Y | 32 | -| Table | table.tsx | none | | | 117 | -| Tabs | tabs.tsx | none | | Y | 66 | -| Textarea | textarea.tsx | none | | | 25 | -| TimePicker | time-picker.tsx | none | TimePickerProps | Y | 191 | -| Tooltip | tooltip.tsx | none | | Y | 48 | -| VisuallyHidden | visually-hidden.tsx | none | | Y | 14 | - -## ATOMS (3) - -### atoms (3) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| BadgeSm | BadgeSm.tsx | named | BadgeSmProps | Y | 117 | -| ScrollableButtonGroup | ScrollableButtonGroup.tsx | named | ScrollableButtonGroupProps | | 53 | -| TabChip | TabChip.tsx | named | TabChipProps | Y | 72 | - -## MOLECULES (8) - -### molecules (8) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| DateRangeSelector | DateRangeSelector.tsx | named | DateRangeSelectorProps | Y | 217 | -| FormField | FormField.tsx | named | FormFieldProps | | 296 | -| IconWithBadge | IconWithBadge.tsx | named | IconWithBadgeProps | Y | 51 | -| MobileFilter | MobileFilter.tsx | named | MobileFilterProps | Y | 335 | -| StandardDialog | StandardDialog.tsx | named | StandardDialogProps | Y | 219 | -| StatusBadge | StatusBadge.tsx | named | StatusBadgeProps | Y | 111 | -| TableActions | TableActions.tsx | named | TableActionsProps | Y | 89 | -| YearQuarterFilter | YearQuarterFilter.tsx | named | YearQuarterFilterProps | Y | 98 | - -## ORGANISMS (12) - -### organisms (12) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| DataTable | DataTable.tsx | named | DataTableProps | Y | 363 | -| EmptyState | EmptyState.tsx | named | EmptyStateProps | Y | 38 | -| FormActions | FormActions.tsx | named | FormActionsProps | | 74 | -| FormFieldGrid | FormFieldGrid.tsx | named | FormFieldGridProps | | 35 | -| FormSection | FormSection.tsx | named | FormSectionProps | | 62 | -| MobileCard | MobileCard.tsx | named | InfoFieldProps | Y | 347 | -| PageHeader | PageHeader.tsx | named | PageHeaderProps | Y | 42 | -| PageLayout | PageLayout.tsx | named | PageLayoutProps | Y | 32 | -| ScreenVersionHistory | ScreenVersionHistory.tsx | named | ScreenVersionHistoryProps | Y | 75 | -| SearchableSelectionModal | SearchableSelectionModal.tsx | named | | Y | 253 | -| SearchFilter | SearchFilter.tsx | named | SearchFilterProps | Y | 58 | -| StatCards | StatCards.tsx | named | StatCardsProps | Y | 67 | - -## COMMON (16) - -### common (16) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| AccessDenied | AccessDenied.tsx | named | AccessDeniedProps | Y | 68 | -| CalendarHeader | CalendarHeader.tsx | named | | Y | 148 | -| DayCell | DayCell.tsx | named | DayCellProps | Y | 93 | -| DayTimeView | DayTimeView.tsx | named | | Y | 167 | -| EditableTable | EditableTable.tsx | both | EditableTableProps | Y | 333 | -| EmptyPage | EmptyPage.tsx | named | EmptyPageProps | Y | 150 | -| MonthView | MonthView.tsx | named | WeekRowProps | Y | 264 | -| MorePopover | MorePopover.tsx | named | MorePopoverProps | Y | 45 | -| NoticePopupModal | NoticePopupModal.tsx | named | NoticePopupModalProps | Y | 171 | -| ParentMenuRedirect | ParentMenuRedirect.tsx | named | ParentMenuRedirectProps | Y | 82 | -| PermissionGuard | PermissionGuard.tsx | named | PermissionGuardProps | Y | 44 | -| ScheduleBar | ScheduleBar.tsx | named | ScheduleBarProps | Y | 96 | -| ScheduleCalendar | ScheduleCalendar.tsx | named | | Y | 194 | -| ServerErrorPage | ServerErrorPage.tsx | named | ServerErrorPageProps | Y | 140 | -| WeekTimeView | WeekTimeView.tsx | named | | Y | 217 | -| WeekView | WeekView.tsx | named | | Y | 211 | - -## LAYOUT (3) - -### layout (3) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| CommandMenuSearch | CommandMenuSearch.tsx | default | | Y | 199 | -| HeaderFavoritesBar | HeaderFavoritesBar.tsx | default | HeaderFavoritesBarProps | Y | 156 | -| Sidebar | Sidebar.tsx | default | SidebarProps | | 390 | - -## DEV (2) - -### dev (2) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| DevFillProvider | DevFillContext.tsx | named | DevFillProviderProps | Y | 179 | -| DevToolbar | DevToolbar.tsx | both | | Y | 499 | - -## DOMAIN (404) - -### LanguageSelect.tsx (1) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| LanguageSelect | LanguageSelect.tsx | named | LanguageSelectProps | Y | 90 | - -### ThemeSelect.tsx (1) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| ThemeSelect | ThemeSelect.tsx | named | ThemeSelectProps | Y | 82 | - -### accounting (19) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| BadDebtDetail | BadDebtDetail.tsx | named | BadDebtDetailProps | Y | 963 | -| BadDebtDetailClientV2 | BadDebtDetailClientV2.tsx | named | BadDebtDetailClientV2Props | Y | 136 | -| BillDetail | BillDetail.tsx | named | BillDetailProps | Y | 540 | -| BillManagementClient | BillManagementClient.tsx | named | BillManagementClientProps | Y | 522 | -| CardTransactionDetailClient | CardTransactionDetailClient.tsx | default | CardTransactionDetailClientProps | Y | 139 | -| CreditAnalysisDocument | CreditAnalysisDocument.tsx | named | CreditAnalysisDocumentProps | Y | 210 | -| CreditSignal | CreditSignal.tsx | named | CreditSignalProps | Y | 58 | -| DepositDetail | DepositDetail.tsx | named | DepositDetailProps | Y | 327 | -| DepositDetailClientV2 | DepositDetailClientV2.tsx | default | DepositDetailClientV2Props | Y | 144 | -| PurchaseDetail | PurchaseDetail.tsx | named | PurchaseDetailProps | Y | 697 | -| PurchaseDetailModal | PurchaseDetailModal.tsx | named | PurchaseDetailModalProps | Y | 402 | -| RiskRadarChart | RiskRadarChart.tsx | named | RiskRadarChartProps | Y | 95 | -| SalesDetail | SalesDetail.tsx | named | SalesDetailProps | Y | 579 | -| VendorDetail | VendorDetail.tsx | named | VendorDetailProps | Y | 684 | -| VendorDetailClient | VendorDetailClient.tsx | named | VendorDetailClientProps | Y | 586 | -| VendorLedgerDetail | VendorLedgerDetail.tsx | named | VendorLedgerDetailProps | Y | 386 | -| VendorManagementClient | VendorManagementClient.tsx | named | VendorManagementClientProps | Y | 574 | -| WithdrawalDetail | WithdrawalDetail.tsx | named | WithdrawalDetailProps | Y | 327 | -| WithdrawalDetailClientV2 | WithdrawalDetailClientV2.tsx | default | WithdrawalDetailClientV2Props | Y | 144 | - -### approval (11) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| ApprovalLineBox | ApprovalLineBox.tsx | named | ApprovalLineBoxProps | Y | 85 | -| ApprovalLineSection | ApprovalLineSection.tsx | named | ApprovalLineSectionProps | Y | 108 | -| BasicInfoSection | BasicInfoSection.tsx | named | BasicInfoSectionProps | Y | 81 | -| DocumentDetailModalV2 | DocumentDetailModalV2.tsx | named | | Y | 94 | -| ExpenseEstimateDocument | ExpenseEstimateDocument.tsx | named | ExpenseEstimateDocumentProps | Y | 130 | -| ExpenseEstimateForm | ExpenseEstimateForm.tsx | named | ExpenseEstimateFormProps | Y | 167 | -| ExpenseReportDocument | ExpenseReportDocument.tsx | named | ExpenseReportDocumentProps | Y | 138 | -| ExpenseReportForm | ExpenseReportForm.tsx | named | ExpenseReportFormProps | Y | 243 | -| ProposalDocument | ProposalDocument.tsx | named | ProposalDocumentProps | Y | 117 | -| ProposalForm | ProposalForm.tsx | named | ProposalFormProps | Y | 234 | -| ReferenceSection | ReferenceSection.tsx | named | ReferenceSectionProps | Y | 109 | - -### attendance (2) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| AttendanceComplete | AttendanceComplete.tsx | default | AttendanceCompleteProps | Y | 83 | -| GoogleMap | GoogleMap.tsx | default | GoogleMapProps | Y | 309 | - -### auth (2) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| LoginPage | LoginPage.tsx | named | | Y | 301 | -| SignupPage | SignupPage.tsx | named | | Y | 763 | - -### board (8) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| BoardDetail | BoardDetail.tsx | named | BoardDetailProps | Y | 120 | -| BoardDetailClientV2 | BoardDetailClientV2.tsx | named | BoardDetailClientV2Props | Y | 308 | -| BoardForm | BoardForm.tsx | named | BoardFormProps | Y | 271 | -| BoardListUnified | BoardListUnified.tsx | both | | Y | 372 | -| CommentItem | CommentItem.tsx | both | CommentItemProps | Y | 161 | -| DynamicBoardCreateForm | DynamicBoardCreateForm.tsx | named | DynamicBoardCreateFormProps | Y | 166 | -| DynamicBoardEditForm | DynamicBoardEditForm.tsx | named | DynamicBoardEditFormProps | Y | 253 | -| MenuBar | MenuBar.tsx | named | MenuBarProps | Y | 289 | - -### business (97) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| BiddingDetailForm | BiddingDetailForm.tsx | default | BiddingDetailFormProps | Y | 533 | -| BiddingListClient | BiddingListClient.tsx | default | BiddingListClientProps | Y | 385 | -| CalendarSection | CalendarSection.tsx | named | CalendarSectionProps | Y | 421 | -| CardManagementSection | CardManagementSection.tsx | named | CardManagementSectionProps | Y | 71 | -| CategoryDialog | CategoryDialog.tsx | named | | Y | 89 | -| CEODashboard | CEODashboard.tsx | named | | Y | 407 | -| ConstructionDashboard | ConstructionDashboard.tsx | named | | Y | 19 | -| ConstructionDetailCard | ConstructionDetailCard.tsx | named | ConstructionDetailCardProps | Y | 83 | -| ConstructionDetailClient | ConstructionDetailClient.tsx | default | ConstructionDetailClientProps | Y | 732 | -| ConstructionMainDashboard | ConstructionMainDashboard.tsx | named | | Y | 196 | -| ConstructionManagementListClient | ConstructionManagementListClient.tsx | default | ConstructionManagementListClientProps | Y | 540 | -| ContractDetailForm | ContractDetailForm.tsx | default | ContractDetailFormProps | Y | 541 | -| ContractDocumentModal | ContractDocumentModal.tsx | named | ContractDocumentModalProps | Y | 64 | -| ContractInfoCard | ContractInfoCard.tsx | named | ContractInfoCardProps | Y | 169 | -| ContractInfoCard | ContractInfoCard.tsx | named | ContractInfoCardProps | Y | 58 | -| ContractListClient | ContractListClient.tsx | default | ContractListClientProps | Y | 399 | -| DailyReportSection | DailyReportSection.tsx | named | DailyReportSectionProps | Y | 37 | -| Dashboard | Dashboard.tsx | named | | Y | 33 | -| DashboardSettingsDialog | DashboardSettingsDialog.tsx | named | DashboardSettingsDialogProps | Y | 744 | -| DashboardSwitcher | DashboardSwitcher.tsx | named | | Y | 89 | -| DebtCollectionSection | DebtCollectionSection.tsx | named | DebtCollectionSectionProps | Y | 62 | -| DetailAccordion | DetailAccordion.tsx | default | DetailAccordionProps | Y | 199 | -| DetailCard | DetailCard.tsx | default | DetailCardProps | Y | 69 | -| DetailModal | DetailModal.tsx | named | DetailModalProps | Y | 763 | -| DirectConstructionContent | DirectConstructionContent.tsx | named | DirectConstructionContentProps | Y | 157 | -| DirectConstructionModal | DirectConstructionModal.tsx | named | DirectConstructionModalProps | Y | 60 | -| ElectronicApprovalModal | ElectronicApprovalModal.tsx | named | ElectronicApprovalModalProps | Y | 299 | -| ElectronicApprovalModal | ElectronicApprovalModal.tsx | none | | | 2 | -| EnhancedDailyReportSection | EnhancedSections.tsx | named | EnhancedDailyReportSectionProps | Y | 534 | -| EntertainmentSection | EntertainmentSection.tsx | named | EntertainmentSectionProps | Y | 53 | -| EstimateDetailForm | EstimateDetailForm.tsx | default | EstimateDetailFormProps | Y | 761 | -| EstimateDetailTableSection | EstimateDetailTableSection.tsx | named | EstimateDetailTableSectionProps | Y | 657 | -| EstimateDocumentContent | EstimateDocumentContent.tsx | named | EstimateDocumentContentProps | Y | 286 | -| EstimateDocumentModal | EstimateDocumentModal.tsx | named | EstimateDocumentModalProps | Y | 88 | -| EstimateInfoSection | EstimateInfoSection.tsx | named | EstimateInfoSectionProps | Y | 262 | -| EstimateListClient | EstimateListClient.tsx | default | EstimateListClientProps | Y | 376 | -| EstimateSummarySection | EstimateSummarySection.tsx | named | EstimateSummarySectionProps | Y | 182 | -| ExpenseDetailSection | ExpenseDetailSection.tsx | named | ExpenseDetailSectionProps | Y | 197 | -| HandoverReportDetailForm | HandoverReportDetailForm.tsx | default | HandoverReportDetailFormProps | Y | 694 | -| HandoverReportDocumentModal | HandoverReportDocumentModal.tsx | named | HandoverReportDocumentModalProps | Y | 236 | -| HandoverReportListClient | HandoverReportListClient.tsx | default | HandoverReportListClientProps | Y | 387 | -| IndirectConstructionContent | IndirectConstructionContent.tsx | named | IndirectConstructionContentProps | Y | 143 | -| IndirectConstructionModal | IndirectConstructionModal.tsx | named | IndirectConstructionModalProps | Y | 60 | -| IssueDetailForm | IssueDetailForm.tsx | default | IssueDetailFormProps | Y | 625 | -| IssueManagementListClient | IssueManagementListClient.tsx | default | IssueManagementListClientProps | Y | 514 | -| ItemDetailClient | ItemDetailClient.tsx | default | ItemDetailClientProps | Y | 487 | -| ItemManagementClient | ItemManagementClient.tsx | default | ItemManagementClientProps | Y | 618 | -| KanbanColumn | KanbanColumn.tsx | default | KanbanColumnProps | Y | 53 | -| LaborDetailClient | LaborDetailClient.tsx | default | LaborDetailClientProps | Y | 121 | -| LaborManagementClient | LaborManagementClient.tsx | default | LaborManagementClientProps | Y | 372 | -| MainDashboard | MainDashboard.tsx | named | | | 2652 | -| MonthlyExpenseSection | MonthlyExpenseSection.tsx | named | MonthlyExpenseSectionProps | Y | 38 | -| OrderDetailForm | OrderDetailForm.tsx | default | OrderDetailFormProps | Y | 276 | -| OrderDetailItemTable | OrderDetailItemTable.tsx | named | OrderDetailItemTableProps | Y | 445 | -| OrderDialogs | OrderDialogs.tsx | named | OrderDialogsProps | Y | 66 | -| OrderDocumentModal | OrderDocumentModal.tsx | named | OrderDocumentModalProps | Y | 311 | -| OrderInfoCard | OrderInfoCard.tsx | named | OrderInfoCardProps | Y | 143 | -| OrderManagementListClient | OrderManagementListClient.tsx | default | OrderManagementListClientProps | Y | 608 | -| OrderManagementUnified | OrderManagementUnified.tsx | both | OrderManagementUnifiedProps | Y | 641 | -| OrderMemoCard | OrderMemoCard.tsx | named | OrderMemoCardProps | Y | 29 | -| OrderScheduleCard | OrderScheduleCard.tsx | named | OrderScheduleCardProps | Y | 42 | -| PartnerForm | PartnerForm.tsx | default | PartnerFormProps | Y | 642 | -| PartnerListClient | PartnerListClient.tsx | default | PartnerListClientProps | Y | 335 | -| PhotoDocumentContent | PhotoDocumentContent.tsx | named | PhotoDocumentContentProps | Y | 130 | -| PhotoDocumentModal | PhotoDocumentModal.tsx | named | PhotoDocumentModalProps | Y | 60 | -| PhotoTable | PhotoTable.tsx | named | PhotoTableProps | Y | 153 | -| PriceAdjustmentSection | PriceAdjustmentSection.tsx | named | PriceAdjustmentSectionProps | Y | 150 | -| PricingDetailClient | PricingDetailClient.tsx | default | PricingDetailClientProps | Y | 135 | -| PricingListClient | PricingListClient.tsx | default | PricingListClientProps | Y | 477 | -| ProgressBillingDetailForm | ProgressBillingDetailForm.tsx | default | ProgressBillingDetailFormProps | Y | 193 | -| ProgressBillingInfoCard | ProgressBillingInfoCard.tsx | named | ProgressBillingInfoCardProps | Y | 78 | -| ProgressBillingItemTable | ProgressBillingItemTable.tsx | named | ProgressBillingItemTableProps | Y | 193 | -| ProgressBillingManagementListClient | ProgressBillingManagementListClient.tsx | default | ProgressBillingManagementListClientProps | Y | 343 | -| ProjectCard | ProjectCard.tsx | default | ProjectCardProps | Y | 89 | -| ProjectDetailClient | ProjectDetailClient.tsx | default | ProjectDetailClientProps | Y | 197 | -| ProjectEndDialog | ProjectEndDialog.tsx | default | ProjectEndDialogProps | Y | 192 | -| ProjectGanttChart | ProjectGanttChart.tsx | default | ProjectGanttChartProps | Y | 367 | -| ProjectKanbanBoard | ProjectKanbanBoard.tsx | default | ProjectKanbanBoardProps | Y | 244 | -| ProjectListClient | ProjectListClient.tsx | default | ProjectListClientProps | Y | 629 | -| ReceivableSection | ReceivableSection.tsx | named | ReceivableSectionProps | Y | 69 | -| ScheduleDetailModal | ScheduleDetailModal.tsx | named | ScheduleDetailModalProps | Y | 290 | -| SECTION_THEME_STYLES | components.tsx | named | | Y | 434 | -| SiteBriefingForm | SiteBriefingForm.tsx | default | SiteBriefingFormProps | Y | 957 | -| SiteBriefingListClient | SiteBriefingListClient.tsx | default | SiteBriefingListClientProps | Y | 362 | -| SiteDetailClientV2 | SiteDetailClientV2.tsx | both | SiteDetailClientV2Props | Y | 141 | -| SiteDetailForm | SiteDetailForm.tsx | default | SiteDetailFormProps | Y | 386 | -| SiteManagementListClient | SiteManagementListClient.tsx | default | SiteManagementListClientProps | Y | 338 | -| StageCard | StageCard.tsx | default | StageCardProps | Y | 89 | -| StatusBoardSection | StatusBoardSection.tsx | named | StatusBoardSectionProps | Y | 72 | -| StructureReviewDetailClientV2 | StructureReviewDetailClientV2.tsx | both | StructureReviewDetailClientV2Props | Y | 149 | -| StructureReviewDetailForm | StructureReviewDetailForm.tsx | default | StructureReviewDetailFormProps | Y | 390 | -| StructureReviewListClient | StructureReviewListClient.tsx | default | StructureReviewListClientProps | Y | 375 | -| TodayIssueSection | TodayIssueSection.tsx | named | TodayIssueSectionProps | Y | 453 | -| UtilityManagementListClient | UtilityManagementListClient.tsx | default | UtilityManagementListClientProps | Y | 395 | -| VatSection | VatSection.tsx | named | VatSectionProps | Y | 38 | -| WelfareSection | WelfareSection.tsx | named | WelfareSectionProps | Y | 53 | -| WorkerStatusListClient | WorkerStatusListClient.tsx | default | WorkerStatusListClientProps | Y | 416 | - -### checklist-management (7) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| ChecklistDetail | ChecklistDetail.tsx | named | ChecklistDetailProps | Y | 316 | -| ChecklistDetailClient | ChecklistDetailClient.tsx | named | ChecklistDetailClientProps | Y | 123 | -| ChecklistForm | ChecklistForm.tsx | named | ChecklistFormProps | Y | 173 | -| ChecklistListClient | ChecklistListClient.tsx | default | | Y | 520 | -| ItemDetail | ItemDetail.tsx | named | ItemDetailProps | Y | 224 | -| ItemDetailClient | ItemDetailClient.tsx | named | ItemDetailClientProps | Y | 111 | -| ItemForm | ItemForm.tsx | named | ItemFormProps | Y | 351 | - -### clients (3) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| ClientDetail | ClientDetail.tsx | named | ClientDetailProps | Y | 254 | -| ClientDetailClientV2 | ClientDetailClientV2.tsx | named | ClientDetailClientV2Props | Y | 253 | -| ClientRegistration | ClientRegistration.tsx | named | ClientRegistrationProps | Y | 468 | - -### customer-center (9) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| EventDetail | EventDetail.tsx | both | EventDetailProps | Y | 102 | -| EventList | EventList.tsx | both | | Y | 261 | -| FAQList | FAQList.tsx | both | | Y | 172 | -| InquiryDetail | InquiryDetail.tsx | both | InquiryDetailProps | Y | 359 | -| InquiryDetailClientV2 | InquiryDetailClientV2.tsx | both | InquiryDetailClientV2Props | Y | 224 | -| InquiryForm | InquiryForm.tsx | both | InquiryFormProps | Y | 237 | -| InquiryList | InquiryList.tsx | both | | Y | 292 | -| NoticeDetail | NoticeDetail.tsx | both | NoticeDetailProps | Y | 102 | -| NoticeList | NoticeList.tsx | both | | Y | 227 | - -### document-system (11) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| ApprovalLine | ApprovalLine.tsx | named | ApprovalLineProps | Y | 170 | -| ConstructionApprovalTable | ConstructionApprovalTable.tsx | named | ConstructionApprovalTableProps | Y | 116 | -| DocumentContent | DocumentContent.tsx | named | DocumentContentProps | Y | 59 | -| DocumentHeader | DocumentHeader.tsx | named | DocumentHeaderProps | Y | 248 | -| DocumentToolbar | DocumentToolbar.tsx | named | DocumentToolbarProps | Y | 327 | -| DocumentViewer | DocumentViewer.tsx | named | | Y | 378 | -| InfoTable | InfoTable.tsx | named | InfoTableProps | Y | 95 | -| LotApprovalTable | LotApprovalTable.tsx | named | LotApprovalTableProps | Y | 122 | -| QualityApprovalTable | QualityApprovalTable.tsx | named | QualityApprovalTableProps | Y | 123 | -| SectionHeader | SectionHeader.tsx | named | SectionHeaderProps | Y | 46 | -| SignatureSection | SignatureSection.tsx | named | SignatureSectionProps | Y | 107 | - -### hr (24) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| AttendanceInfoDialog | AttendanceInfoDialog.tsx | named | | Y | 301 | -| CardDetail | CardDetail.tsx | named | CardDetailProps | Y | 132 | -| CardForm | CardForm.tsx | named | CardFormProps | Y | 246 | -| CardManagementUnified | CardManagementUnified.tsx | named | CardManagementUnifiedProps | Y | 267 | -| CSVUploadDialog | CSVUploadDialog.tsx | named | CSVUploadDialogProps | Y | 252 | -| CSVUploadPage | CSVUploadPage.tsx | named | CSVUploadPageProps | Y | 355 | -| DepartmentDialog | DepartmentDialog.tsx | named | | Y | 92 | -| DepartmentStats | DepartmentStats.tsx | named | | Y | 18 | -| DepartmentToolbar | DepartmentToolbar.tsx | named | | Y | 60 | -| DepartmentTree | DepartmentTree.tsx | named | | Y | 70 | -| DepartmentTreeItem | DepartmentTreeItem.tsx | named | | Y | 118 | -| EmployeeDetail | EmployeeDetail.tsx | named | EmployeeDetailProps | Y | 222 | -| EmployeeDialog | EmployeeDialog.tsx | named | | Y | 582 | -| EmployeeForm | EmployeeForm.tsx | named | EmployeeFormProps | Y | 1052 | -| EmployeeToolbar | EmployeeToolbar.tsx | named | EmployeeToolbarProps | Y | 82 | -| FieldSettingsDialog | FieldSettingsDialog.tsx | named | FieldSettingsDialogProps | Y | 259 | -| ReasonInfoDialog | ReasonInfoDialog.tsx | named | | Y | 140 | -| SalaryDetailDialog | SalaryDetailDialog.tsx | named | SalaryDetailDialogProps | Y | 420 | -| UserInviteDialog | UserInviteDialog.tsx | named | UserInviteDialogProps | Y | 116 | -| VacationAdjustDialog | VacationAdjustDialog.tsx | named | VacationAdjustDialogProps | Y | 225 | -| VacationGrantDialog | VacationGrantDialog.tsx | named | VacationGrantDialogProps | Y | 202 | -| VacationRegisterDialog | VacationRegisterDialog.tsx | named | VacationRegisterDialogProps | Y | 201 | -| VacationRequestDialog | VacationRequestDialog.tsx | named | VacationRequestDialogProps | Y | 208 | -| VacationTypeSettingsDialog | VacationTypeSettingsDialog.tsx | named | VacationTypeSettingsDialogProps | Y | 192 | - -### items (65) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| AssemblyPartForm | AssemblyPartForm.tsx | default | AssemblyPartFormProps | | 337 | -| AttributeTabContent | AttributeTabContent.tsx | named | AttributeTabContentProps | Y | 453 | -| BendingDiagramSection | BendingDiagramSection.tsx | default | BendingDiagramSectionProps | | 477 | -| BendingPartForm | BendingPartForm.tsx | default | BendingPartFormProps | | 304 | -| BOMManagementSection | BOMManagementSection.tsx | named | BOMManagementSectionProps | Y | 293 | -| BOMSection | BOMSection.tsx | default | BOMSectionProps | | 366 | -| CheckboxField | CheckboxField.tsx | named | | Y | 47 | -| ColumnDialog | ColumnDialog.tsx | named | ColumnDialogProps | Y | 124 | -| ColumnManageDialog | ColumnManageDialog.tsx | named | ColumnManageDialogProps | Y | 210 | -| ComputedField | ComputedField.tsx | named | | Y | 136 | -| ConditionalDisplayUI | ConditionalDisplayUI.tsx | named | ConditionalDisplayUIProps | | 349 | -| CurrencyField | CurrencyField.tsx | named | | Y | 127 | -| DateField | DateField.tsx | named | | Y | 45 | -| DraggableField | DraggableField.tsx | named | DraggableFieldProps | | 130 | -| DraggableSection | DraggableSection.tsx | named | DraggableSectionProps | | 140 | -| DrawingCanvas | DrawingCanvas.tsx | named | DrawingCanvasProps | Y | 404 | -| DropdownField | DropdownField.tsx | named | | Y | 141 | -| DuplicateCodeDialog | DuplicateCodeDialog.tsx | named | DuplicateCodeDialogProps | Y | 49 | -| DynamicBOMSection | DynamicBOMSection.tsx | default | DynamicBOMSectionProps | Y | 515 | -| DynamicFieldRenderer | DynamicFieldRenderer.tsx | named | | Y | 86 | -| DynamicTableSection | DynamicTableSection.tsx | default | DynamicTableSectionProps | Y | 200 | -| ErrorAlertDialog | ErrorAlertDialog.tsx | named | ErrorAlertDialogProps | Y | 51 | -| ErrorAlertProvider | ErrorAlertContext.tsx | named | ErrorAlertProviderProps | Y | 94 | -| FieldDialog | FieldDialog.tsx | named | FieldDialogProps | Y | 478 | -| FieldDrawer | FieldDrawer.tsx | named | FieldDrawerProps | Y | 682 | -| FileField | FileField.tsx | named | | Y | 200 | -| FileUpload | FileUpload.tsx | default | FileUploadProps | Y | 233 | -| FileUploadFields | FileUploadFields.tsx | named | FileUploadFieldsProps | Y | 240 | -| FormHeader | FormHeader.tsx | named | FormHeaderProps | Y | 31 | -| FormHeader | FormHeader.tsx | default | FormHeaderProps | | 62 | -| ImportFieldDialog | ImportFieldDialog.tsx | named | ImportFieldDialogProps | Y | 279 | -| ImportSectionDialog | ImportSectionDialog.tsx | named | ImportSectionDialogProps | Y | 221 | -| ItemDetailClient | ItemDetailClient.tsx | default | ItemDetailClientProps | Y | 638 | -| ItemDetailEdit | ItemDetailEdit.tsx | named | ItemDetailEditProps | Y | 390 | -| ItemDetailView | ItemDetailView.tsx | named | ItemDetailViewProps | Y | 275 | -| ItemFormContext | ItemFormContext.tsx | both | ItemFormProviderProps | Y | 77 | -| ItemListClient | ItemListClient.tsx | default | | Y | 607 | -| ItemMasterDataManagement | ItemMasterDataManagement.tsx | named | | Y | 1006 | -| ItemMasterDialogs | ItemMasterDialogs.tsx | named | ItemMasterDialogsProps | Y | 968 | -| ItemTypeSelect | ItemTypeSelect.tsx | default | ItemTypeSelectProps | Y | 76 | -| LoadTemplateDialog | LoadTemplateDialog.tsx | named | LoadTemplateDialogProps | Y | 103 | -| MasterFieldDialog | MasterFieldDialog.tsx | named | MasterFieldDialogProps | Y | 306 | -| MaterialForm | MaterialForm.tsx | default | MaterialFormProps | | 354 | -| MultiSelectField | MultiSelectField.tsx | named | | Y | 192 | -| NumberField | NumberField.tsx | named | | Y | 58 | -| OptionDialog | OptionDialog.tsx | named | OptionDialogProps | Y | 262 | -| PageDialog | PageDialog.tsx | named | PageDialogProps | Y | 107 | -| PartForm | PartForm.tsx | default | PartFormProps | | 273 | -| PathEditDialog | PathEditDialog.tsx | named | PathEditDialogProps | Y | 86 | -| ProductForm | ProductForm.tsx | both | ProductFormProps | | 307 | -| PurchasedPartForm | PurchasedPartForm.tsx | default | PurchasedPartFormProps | | 336 | -| RadioField | RadioField.tsx | named | | Y | 92 | -| ReferenceField | ReferenceField.tsx | named | | Y | 168 | -| SectionDialog | SectionDialog.tsx | named | SectionDialogProps | Y | 335 | -| SectionsTab | SectionsTab.tsx | named | SectionsTabProps | Y | 363 | -| SectionTemplateDialog | SectionTemplateDialog.tsx | named | SectionTemplateDialogProps | Y | 180 | -| TableCellRenderer | TableCellRenderer.tsx | named | TableCellRendererProps | Y | 85 | -| TabManagementDialogs | TabManagementDialogs.tsx | named | TabManagementDialogsProps | Y | 409 | -| TemplateFieldDialog | TemplateFieldDialog.tsx | named | TemplateFieldDialogProps | Y | 392 | -| TextareaField | TextareaField.tsx | named | | Y | 51 | -| TextField | TextField.tsx | named | | Y | 48 | -| ToggleField | ToggleField.tsx | named | | Y | 62 | -| UnitValueField | UnitValueField.tsx | named | | Y | 129 | -| ValidationAlert | ValidationAlert.tsx | named | ValidationAlertProps | Y | 42 | -| ValidationAlert | ValidationAlert.tsx | default | ValidationAlertProps | | 50 | - -### material (13) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| ImportInspectionInputModal | ImportInspectionInputModal.tsx | named | ImportInspectionInputModalProps | Y | 798 | -| InspectionCreate | InspectionCreate.tsx | named | Props | Y | 364 | -| InventoryAdjustmentDialog | InventoryAdjustmentDialog.tsx | named | Props | Y | 236 | -| ReceivingDetail | ReceivingDetail.tsx | named | Props | Y | 921 | -| ReceivingList | ReceivingList.tsx | named | | Y | 467 | -| ReceivingProcessDialog | ReceivingProcessDialog.tsx | named | Props | Y | 238 | -| ReceivingReceiptContent | ReceivingReceiptContent.tsx | named | ReceivingReceiptContentProps | Y | 132 | -| ReceivingReceiptDialog | ReceivingReceiptDialog.tsx | named | Props | Y | 46 | -| StockAuditModal | StockAuditModal.tsx | named | StockAuditModalProps | Y | 237 | -| StockStatusDetail | StockStatusDetail.tsx | named | StockStatusDetailProps | Y | 313 | -| StockStatusList | StockStatusList.tsx | named | | Y | 473 | -| SuccessDialog | SuccessDialog.tsx | named | Props | Y | 49 | -| SupplierSearchModal | SupplierSearchModal.tsx | named | SupplierSearchModalProps | Y | 161 | - -### orders (10) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| ContractDocument | ContractDocument.tsx | named | ContractDocumentProps | Y | 246 | -| ItemAddDialog | ItemAddDialog.tsx | named | ItemAddDialogProps | Y | 317 | -| OrderDocumentModal | OrderDocumentModal.tsx | named | OrderDocumentModalProps | Y | 207 | -| OrderRegistration | OrderRegistration.tsx | named | OrderRegistrationProps | Y | 1087 | -| OrderSalesDetailEdit | OrderSalesDetailEdit.tsx | named | OrderSalesDetailEditProps | Y | 735 | -| OrderSalesDetailView | OrderSalesDetailView.tsx | named | OrderSalesDetailViewProps | Y | 824 | -| PurchaseOrderDocument | PurchaseOrderDocument.tsx | named | PurchaseOrderDocumentProps | Y | 223 | -| QuotationSelectDialog | QuotationSelectDialog.tsx | named | QuotationSelectDialogProps | Y | 114 | -| SalesOrderDocument | SalesOrderDocument.tsx | named | SalesOrderDocumentProps | Y | 638 | -| TransactionDocument | TransactionDocument.tsx | named | TransactionDocumentProps | Y | 226 | - -### outbound (11) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| DeliveryConfirmation | DeliveryConfirmation.tsx | named | DeliveryConfirmationProps | Y | 18 | -| ShipmentCreate | ShipmentCreate.tsx | named | | Y | 772 | -| ShipmentDetail | ShipmentDetail.tsx | named | ShipmentDetailProps | Y | 671 | -| ShipmentEdit | ShipmentEdit.tsx | named | ShipmentEditProps | Y | 791 | -| ShipmentList | ShipmentList.tsx | named | | Y | 399 | -| ShipmentOrderDocument | ShipmentOrderDocument.tsx | named | ShipmentOrderDocumentProps | Y | 647 | -| ShippingSlip | ShippingSlip.tsx | named | ShippingSlipProps | Y | 18 | -| TransactionStatement | TransactionStatement.tsx | named | TransactionStatementProps | Y | 154 | -| VehicleDispatchDetail | VehicleDispatchDetail.tsx | named | VehicleDispatchDetailProps | Y | 181 | -| VehicleDispatchEdit | VehicleDispatchEdit.tsx | named | VehicleDispatchEditProps | Y | 399 | -| VehicleDispatchList | VehicleDispatchList.tsx | named | | Y | 331 | - -### pricing (5) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| PricingFinalizeDialog | PricingFinalizeDialog.tsx | both | PricingFinalizeDialogProps | Y | 95 | -| PricingFormClient | PricingFormClient.tsx | both | PricingFormClientProps | Y | 780 | -| PricingHistoryDialog | PricingHistoryDialog.tsx | both | PricingHistoryDialogProps | Y | 170 | -| PricingListClient | PricingListClient.tsx | both | PricingListClientProps | Y | 387 | -| PricingRevisionDialog | PricingRevisionDialog.tsx | both | PricingRevisionDialogProps | Y | 95 | - -### pricing-distribution (3) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| PriceDistributionDetail | PriceDistributionDetail.tsx | both | Props | Y | 539 | -| PriceDistributionDocumentModal | PriceDistributionDocumentModal.tsx | both | Props | Y | 158 | -| PriceDistributionList | PriceDistributionList.tsx | both | | Y | 328 | - -### pricing-table-management (3) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| PricingTableDetailClient | PricingTableDetailClient.tsx | named | PricingTableDetailClientProps | Y | 93 | -| PricingTableForm | PricingTableForm.tsx | named | PricingTableFormProps | Y | 486 | -| PricingTableListClient | PricingTableListClient.tsx | default | | Y | 381 | - -### process-management (12) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| InspectionPreviewModal | InspectionPreviewModal.tsx | named | InspectionPreviewModalProps | Y | 265 | -| InspectionSettingModal | InspectionSettingModal.tsx | named | InspectionSettingModalProps | Y | 294 | -| ProcessDetail | ProcessDetail.tsx | named | ProcessDetailProps | Y | 451 | -| ProcessDetailClientV2 | ProcessDetailClientV2.tsx | named | ProcessDetailClientV2Props | Y | 137 | -| ProcessForm | ProcessForm.tsx | named | ProcessFormProps | Y | 829 | -| ProcessListClient | ProcessListClient.tsx | default | ProcessListClientProps | Y | 546 | -| ProcessWorkLogContent | ProcessWorkLogContent.tsx | named | ProcessWorkLogContentProps | Y | 136 | -| ProcessWorkLogPreviewModal | ProcessWorkLogPreviewModal.tsx | named | ProcessWorkLogPreviewModalProps | Y | 45 | -| RuleModal | RuleModal.tsx | named | RuleModalProps | Y | 352 | -| StepDetail | StepDetail.tsx | named | StepDetailProps | Y | 212 | -| StepDetailClient | StepDetailClient.tsx | named | StepDetailClientProps | Y | 115 | -| StepForm | StepForm.tsx | named | StepFormProps | Y | 397 | - -### production (31) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| AssigneeSelectModal | AssigneeSelectModal.tsx | named | AssigneeSelectModalProps | Y | 317 | -| BendingInspectionContent | BendingInspectionContent.tsx | named | BendingInspectionContentProps | Y | 490 | -| BendingWipInspectionContent | BendingWipInspectionContent.tsx | named | BendingWipInspectionContentProps | Y | 304 | -| BendingWorkLogContent | BendingWorkLogContent.tsx | named | BendingWorkLogContentProps | Y | 194 | -| CompletionConfirmDialog | CompletionConfirmDialog.tsx | named | CompletionConfirmDialogProps | Y | 64 | -| CompletionToast | CompletionToast.tsx | named | CompletionToastProps | Y | 28 | -| InspectionCheckbox | inspection-shared.tsx | named | | Y | 282 | -| InspectionInputModal | InspectionInputModal.tsx | named | InspectionInputModalProps | Y | 978 | -| InspectionReportModal | InspectionReportModal.tsx | named | InspectionReportModalProps | Y | 409 | -| IssueReportModal | IssueReportModal.tsx | named | IssueReportModalProps | Y | 178 | -| MaterialInputModal | MaterialInputModal.tsx | named | MaterialInputModalProps | Y | 333 | -| ProcessDetailSection | ProcessDetailSection.tsx | named | ProcessDetailSectionProps | Y | 392 | -| SalesOrderSelectModal | SalesOrderSelectModal.tsx | named | SalesOrderSelectModalProps | Y | 102 | -| ScreenInspectionContent | ScreenInspectionContent.tsx | named | ScreenInspectionContentProps | Y | 310 | -| ScreenWorkLogContent | ScreenWorkLogContent.tsx | named | ScreenWorkLogContentProps | Y | 201 | -| SlatInspectionContent | SlatInspectionContent.tsx | named | SlatInspectionContentProps | Y | 297 | -| SlatJointBarInspectionContent | SlatJointBarInspectionContent.tsx | named | SlatJointBarInspectionContentProps | Y | 311 | -| SlatWorkLogContent | SlatWorkLogContent.tsx | named | SlatWorkLogContentProps | Y | 198 | -| TemplateInspectionContent | TemplateInspectionContent.tsx | named | TemplateInspectionContentProps | Y | 719 | -| WipProductionModal | WipProductionModal.tsx | named | WipProductionModalProps | Y | 272 | -| WorkCard | WorkCard.tsx | named | WorkCardProps | Y | 188 | -| WorkCompletionResultDialog | WorkCompletionResultDialog.tsx | named | WorkCompletionResultDialogProps | Y | 85 | -| WorkItemCard | WorkItemCard.tsx | named | WorkItemCardProps | Y | 382 | -| WorkLogContent | WorkLogContent.tsx | named | WorkLogContentProps | Y | 195 | -| WorkLogModal | WorkLogModal.tsx | named | WorkLogModalProps | Y | 152 | -| WorkOrderCreate | WorkOrderCreate.tsx | named | | Y | 545 | -| WorkOrderDetail | WorkOrderDetail.tsx | named | WorkOrderDetailProps | Y | 656 | -| WorkOrderEdit | WorkOrderEdit.tsx | named | WorkOrderEditProps | Y | 656 | -| WorkOrderList | WorkOrderList.tsx | named | | Y | 460 | -| WorkOrderListPanel | WorkOrderListPanel.tsx | named | WorkOrderListPanelProps | Y | 132 | -| WorkResultList | WorkResultList.tsx | named | | Y | 374 | - -### quality (11) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| InspectionCreate | InspectionCreate.tsx | named | | Y | 695 | -| InspectionDetail | InspectionDetail.tsx | named | InspectionDetailProps | Y | 1126 | -| InspectionList | InspectionList.tsx | named | | Y | 388 | -| InspectionReportDocument | InspectionReportDocument.tsx | named | InspectionReportDocumentProps | Y | 416 | -| InspectionReportModal | InspectionReportModal.tsx | named | InspectionReportModalProps | Y | 170 | -| InspectionRequestDocument | InspectionRequestDocument.tsx | named | InspectionRequestDocumentProps | Y | 258 | -| InspectionRequestModal | InspectionRequestModal.tsx | named | InspectionRequestModalProps | Y | 40 | -| MemoModal | MemoModal.tsx | named | MemoModalProps | Y | 92 | -| OrderSelectModal | OrderSelectModal.tsx | named | OrderSelectModalProps | Y | 111 | -| PerformanceReportList | PerformanceReportList.tsx | named | | Y | 604 | -| ProductInspectionInputModal | ProductInspectionInputModal.tsx | named | ProductInspectionInputModalProps | Y | 486 | - -### quotes (15) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| DiscountModal | DiscountModal.tsx | named | DiscountModalProps | Y | 232 | -| FormulaViewModal | FormulaViewModal.tsx | named | FormulaViewModalProps | Y | 316 | -| ItemSearchModal | ItemSearchModal.tsx | named | ItemSearchModalProps | Y | 114 | -| LocationDetailPanel | LocationDetailPanel.tsx | named | LocationDetailPanelProps | Y | 827 | -| LocationEditModal | LocationEditModal.tsx | named | LocationEditModalProps | Y | 283 | -| LocationListPanel | LocationListPanel.tsx | named | LocationListPanelProps | Y | 575 | -| PurchaseOrderDocument | PurchaseOrderDocument.tsx | named | PurchaseOrderDocumentProps | | 265 | -| QuoteDocument | QuoteDocument.tsx | named | QuoteDocumentProps | | 409 | -| QuoteFooterBar | QuoteFooterBar.tsx | named | QuoteFooterBarProps | Y | 236 | -| QuoteManagementClient | QuoteManagementClient.tsx | named | QuoteManagementClientProps | Y | 713 | -| QuotePreviewContent | QuotePreviewContent.tsx | named | QuotePreviewContentProps | Y | 434 | -| QuotePreviewModal | QuotePreviewModal.tsx | named | QuotePreviewModalProps | Y | 132 | -| QuoteRegistration | QuoteRegistration.tsx | named | QuoteRegistrationProps | Y | 1023 | -| QuoteSummaryPanel | QuoteSummaryPanel.tsx | named | QuoteSummaryPanelProps | Y | 277 | -| QuoteTransactionModal | QuoteTransactionModal.tsx | named | QuoteTransactionModalProps | Y | 324 | - -### settings (16) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| AccountDetail | AccountDetail.tsx | named | AccountDetailProps | Y | 356 | -| AccountDetail | AccountDetail.tsx | named | AccountDetailProps | Y | 369 | -| AddCompanyDialog | AddCompanyDialog.tsx | named | AddCompanyDialogProps | Y | 149 | -| ItemSettingsDialog | ItemSettingsDialog.tsx | named | ItemSettingsDialogProps | Y | 336 | -| PaymentHistoryClient | PaymentHistoryClient.tsx | named | PaymentHistoryClientProps | Y | 255 | -| PermissionDetail | PermissionDetail.tsx | named | PermissionDetailProps | Y | 456 | -| PermissionDetailClient | PermissionDetailClient.tsx | named | PermissionDetailClientProps | Y | 700 | -| PermissionDialog | PermissionDialog.tsx | named | | Y | 109 | -| PopupDetail | PopupDetail.tsx | both | PopupDetailProps | Y | 125 | -| PopupDetailClientV2 | PopupDetailClientV2.tsx | named | PopupDetailClientV2Props | Y | 199 | -| PopupForm | PopupForm.tsx | both | PopupFormProps | Y | 319 | -| PopupList | PopupList.tsx | both | PopupListProps | Y | 198 | -| RankDialog | RankDialog.tsx | named | | Y | 89 | -| SubscriptionClient | SubscriptionClient.tsx | named | SubscriptionClientProps | Y | 242 | -| SubscriptionManagement | SubscriptionManagement.tsx | named | SubscriptionManagementProps | Y | 250 | -| TitleDialog | TitleDialog.tsx | named | | Y | 90 | - -### templates (11) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| DetailActions | DetailActions.tsx | both | DetailActionsProps | Y | 172 | -| DetailField | DetailField.tsx | both | DetailFieldProps | Y | 91 | -| DetailFieldSkeleton | DetailFieldSkeleton.tsx | both | DetailFieldSkeletonProps | Y | 48 | -| DetailGrid | DetailGrid.tsx | both | DetailGridProps | Y | 63 | -| DetailGridSkeleton | DetailGridSkeleton.tsx | both | DetailGridSkeletonProps | Y | 61 | -| DetailSection | DetailSection.tsx | both | DetailSectionProps | Y | 97 | -| DetailSectionSkeleton | DetailSectionSkeleton.tsx | both | DetailSectionSkeletonProps | Y | 53 | -| DetailSectionSkeleton | skeletons.tsx | both | DetailFieldSkeletonProps | Y | 183 | -| FieldInput | FieldInput.tsx | both | FieldInputProps | Y | 408 | -| FieldRenderer | FieldRenderer.tsx | named | FieldRendererProps | Y | 390 | -| IntegratedListTemplateV2 | IntegratedListTemplateV2.tsx | named | IntegratedListTemplateV2Props | Y | 1087 | - -### vehicle-management (3) - -| Component | File | Export | Props | Client | Lines | -|-----------|------|--------|-------|--------|-------| -| Config | config.tsx | none | | Y | 431 | -| Config | config.tsx | none | | Y | 479 | -| Config | config.tsx | none | | Y | 266 | diff --git a/claudedocs/construction/[IMPL-2026-01-05] category-management-checklist.md b/claudedocs/construction/[IMPL-2026-01-05] category-management-checklist.md deleted file mode 100644 index 59090938..00000000 --- a/claudedocs/construction/[IMPL-2026-01-05] category-management-checklist.md +++ /dev/null @@ -1,98 +0,0 @@ -# [IMPL-2026-01-05] 카테고리관리 페이지 구현 체크리스트 - -## 개요 -- **위치**: 발주관리 > 기준정보 > 카테고리관리 -- **URL**: `/ko/juil/order/base-info/categories` -- **참조 페이지**: `/ko/settings/ranks` (직급관리) -- **기능**: 동일, 텍스트/라벨만 다름 - -## 스크린샷 분석 - -### UI 구성 -| 구성요소 | 내용 | -|---------|------| -| 타이틀 | 카테고리관리 | -| 설명 | 카테고리를 등록하고 관리합니다. | -| 입력필드 라벨 | 카테고리 | -| 입력필드 placeholder | 카테고리를 입력해주세요 | -| 테이블 컬럼 | 카테고리, 작업 | -| 기본 데이터 | 슬라이드 OPEN 사이즈, 모터, 공정자재, 철물 | - -### Description 영역 (참고용, UI 미구현) -1. 추가 버튼 클릭 시 목록 최하단에 추가 -2. 드래그&드롭으로 순서 변경 -3. 수정 버튼 → 수정 팝업 -4. 삭제 버튼 → 조건별 Alert: - - 품목 사용 중: "(카테고리명)을 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다." - - 미사용: "정말 삭제하시겠습니까?" → "삭제가 되었습니다." - - 기본 카테고리: "기본 카테고리는 삭제가 불가합니다." - -## 구현 체크리스트 - -### Phase 1: 파일 구조 생성 -- [x] `src/app/[locale]/(protected)/juil/order/base-info/categories/page.tsx` 생성 -- [x] `src/components/business/juil/category-management/` 디렉토리 생성 - -### Phase 2: 컴포넌트 구현 (RankManagement 복제 + 수정) -- [x] `index.tsx` - CategoryManagement 메인 컴포넌트 - - 타이틀: "카테고리관리" - - 설명: "카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다." - - 아이콘: `FolderTree` - - 입력 placeholder: "카테고리를 입력해주세요" -- [x] `types.ts` - Category 타입 정의 -- [x] `actions.ts` - Server Actions (목데이터) -- [x] `CategoryDialog.tsx` - 수정 다이얼로그 - -### Phase 3: 텍스트 변경 사항 -| 원본 (ranks) | 변경 (categories) | 상태 | -|-------------|-------------------|------| -| 직급 | 카테고리 | ✅ | -| 직급관리 | 카테고리관리 | ✅ | -| 사원의 직급을 관리합니다 | 카테고리를 등록하고 관리합니다 | ✅ | -| 직급명을 입력하세요 | 카테고리를 입력해주세요 | ✅ | -| 직급이 추가되었습니다 | 카테고리가 추가되었습니다 | ✅ | -| 직급이 수정되었습니다 | 카테고리가 수정되었습니다 | ✅ | -| 직급이 삭제되었습니다 | 카테고리가 삭제되었습니다 | ✅ | -| 등록된 직급이 없습니다 | 등록된 카테고리가 없습니다 | ✅ | - -### Phase 4: 삭제 로직 (삭제 조건 처리) -- [x] 기본 카테고리 삭제 불가 로직 추가 (`isDefault` 플래그) -- [x] 조건별 Alert 메시지 분기 (actions.ts의 `errorType` 반환) -- [ ] 품목 사용 여부 체크 로직 추가 (추후 API 연동 시) - -### Phase 5: 목데이터 설정 -- [x] 기본 카테고리 4개 설정 완료 -```typescript -const mockCategories = [ - { id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true }, - { id: '2', name: '모터', order: 2, isDefault: true }, - { id: '3', name: '공정자재', order: 3, isDefault: true }, - { id: '4', name: '철물', order: 4, isDefault: true }, -]; -``` - -### Phase 6: 테스트 URL 문서 업데이트 -- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트 - - 발주관리 > 기준정보 섹션 추가 - - 카테고리관리 URL 추가 - -## 파일 구조 - -``` -src/ -├── app/[locale]/(protected)/juil/order/ -│ └── base-info/ -│ └── categories/ -│ └── page.tsx -└── components/business/juil/ - └── category-management/ - ├── index.tsx - ├── types.ts - ├── actions.ts - └── CategoryDialog.tsx -``` - -## 진행 상태 -- 생성일: 2026-01-05 -- 상태: ✅ 완료 (목데이터 기반) -- 남은 작업: API 연동 시 품목 사용 여부 체크 로직 추가 \ No newline at end of file diff --git a/claudedocs/construction/[IMPL-2026-01-05] item-management-checklist.md b/claudedocs/construction/[IMPL-2026-01-05] item-management-checklist.md deleted file mode 100644 index 37156b4b..00000000 --- a/claudedocs/construction/[IMPL-2026-01-05] item-management-checklist.md +++ /dev/null @@ -1,209 +0,0 @@ -# [IMPL-2026-01-05] 품목관리 페이지 구현 체크리스트 - -## 개요 -- **위치**: 발주관리 > 기준정보 > 품목관리 -- **URL**: `/ko/juil/order/base-info/items` -- **참조 템플릿**: IntegratedListTemplateV2 (리스트 페이지 표준) -- **기능**: 품목 CRUD, 필터링, 검색, 정렬 - -## 스크린샷 분석 - -### 헤더 영역 -| 구성요소 | 내용 | -|---------|------| -| 타이틀 | 품목관리 | -| 설명 | 품목을 등록하여 관리합니다. | -| 날짜 필터 | 날짜 범위 선택 (DateRangePicker) | -| 빠른 날짜 버튼 | 전체년도, 전전월, 전월, 당월, 어제, 오늘 | -| 액션 버튼 | 품목 등록 (빨간색 primary) | - -### 통계 카드 -| 카드 | 내용 | -|------|------| -| 전체 품목 | 전체 품목 수 표시 | -| 사용 품목 | 사용 중인 품목 수 표시 | - -### 검색 및 필터 영역 -| 구성요소 | 내용 | -|---------|------| -| 검색 입력 | 품목명 검색 | -| 선택 카운트 | N건 / N건 선택 | -| 삭제 버튼 | 선택된 항목 일괄 삭제 | - -### 테이블 컬럼 -| 컬럼 | 타입 | 필터 옵션 | -|------|------|----------| -| 체크박스 | checkbox | - | -| 품목번호 | text | - | -| 물품유형 | select filter | 전체, 제품, 부품, 소모품, 공과 | -| 카테고리 | select filter + search | 전체, 기본, (카테고리 목록) | -| 품목명 | text | - | -| 규격 | select filter | 전체, 인정, 비인정 | -| 단위 | text | - | -| 구분 | select filter | 전체, 경품발주, 원자재발주, 외주발주 | -| 상태 | badge | 승인, 작업 | -| 작업 | actions | 수정(연필 아이콘) | - -### Description 영역 (참고용, UI 미구현) -1. 품목 등록 버튼 - 클릭 시 품목 상세 등록 화면으로 이동 -2. 물품유형 셀렉트 박스 - 전체/제품/부품/소모품/공과 (디폴트: 전체) -3. 카테고리 셀렉트 박스, 검색 - 전체/기본/카테고리 목록 (디폴트: 전체) -4. 규격 셀렉트 박스 - 전체/인정/비인정 (디폴트: 전체) -5. 구분 셀렉트 박스 - 전체/경품발주/원자재발주/외주발주 (디폴트: 전체) -6. 상태 셀렉트 박스 - 전체/사용/중지 (디폴트: 전체) -7. 정렬 셀렉트 박스 - 최신순/등록순 (디폴트: 최신순) - -## 구현 체크리스트 - -### Phase 1: 파일 구조 생성 -- [x] `src/app/[locale]/(protected)/juil/order/base-info/items/page.tsx` 생성 -- [x] `src/components/business/juil/item-management/` 디렉토리 생성 - -### Phase 2: 타입 및 상수 정의 -- [x] `types.ts` - Item 타입 정의 - ```typescript - interface Item { - id: string; - itemNumber: string; // 품목번호 - itemType: ItemType; // 물품유형 - categoryId: string; // 카테고리 ID - categoryName: string; // 카테고리명 - itemName: string; // 품목명 - specification: string; // 규격 (인쇄/비인쇄) - unit: string; // 단위 - orderType: OrderType; // 구분 - status: ItemStatus; // 상태 - createdAt: string; - updatedAt: string; - } - ``` -- [x] `constants.ts` - 필터 옵션 상수 정의 - ```typescript - // 물품유형 - const ITEM_TYPES = ['전체', '제품', '부품', '소모품', '공과']; - - // 규격 - const SPECIFICATIONS = ['전체', '인정', '비인정']; - - // 구분 - const ORDER_TYPES = ['전체', '경품발주', '원자재발주', '외주발주']; - - // 상태 - const ITEM_STATUSES = ['전체', '사용', '중지']; - - // 정렬 - const SORT_OPTIONS = ['최신순', '등록순']; - ``` - -### Phase 3: 메인 컴포넌트 구현 -- [x] `index.tsx` - ItemManagement 메인 컴포넌트 (export) -- [x] `ItemManagementClient.tsx` - 클라이언트 컴포넌트 - - IntegratedListTemplateV2 사용 - - 헤더: 타이틀, 설명, 날짜필터, 품목등록 버튼 - - 통계 카드: StatCards 컴포넌트 활용 - - 테이블: 컬럼 헤더 필터 포함 - - 검색 및 삭제 기능 - -### Phase 4: 테이블 컬럼 설정 -- [x] 테이블 컬럼 정의 (ItemManagementClient.tsx 내 포함) - - 체크박스 컬럼 - - 품목번호 컬럼 - - 물품유형 컬럼 (헤더 필터 Select) - - 카테고리 컬럼 (헤더 필터 Select + 검색) - - 품목명 컬럼 - - 규격 컬럼 (헤더 필터 Select) - - 단위 컬럼 - - 구분 컬럼 (헤더 필터 Select) - - 상태 컬럼 (Badge 표시) - - 작업 컬럼 (수정 버튼) - -### Phase 5: Server Actions (목데이터) -- [x] `actions.ts` - Server Actions 구현 - - `getItemList()` - 품목 목록 조회 - - `getItemStats()` - 통계 조회 - - `deleteItem()` - 품목 삭제 - - `deleteItems()` - 품목 일괄 삭제 - - `getCategoryOptions()` - 카테고리 목록 조회 - -### Phase 6: 목데이터 설정 -```typescript -const mockItems: Item[] = [ - { id: '1', itemNumber: '123123', itemType: '제품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'SET', orderType: '외주발주', status: '승인' }, - { id: '2', itemNumber: '123123', itemType: '부품', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: 'SET', orderType: '외주발주', status: '승인' }, - { id: '3', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'SET', orderType: '외주발주', status: '승인' }, - { id: '4', itemNumber: '123123', itemType: '공과', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: 'EA', orderType: '공과', status: '작업' }, - { id: '5', itemNumber: '123123', itemType: '부품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'EA', orderType: '원자재발주', status: '작업' }, - { id: '6', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: '승인', orderType: '외주발주', status: '작업' }, - { id: '7', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: '승인', orderType: '공과', status: '작업' }, -]; - -const mockStats = { - totalItems: 7, - activeItems: 5, -}; -``` - -### Phase 7: 헤더 필터 컴포넌트 -- [x] tableHeaderActions 영역에 Select 필터 구현 - - 물품유형 필터 - - 규격 필터 - - 구분 필터 - - 정렬 필터 - -### Phase 8: 등록/상세/수정 페이지 구현 -- [x] 품목 등록 버튼 클릭 → `/ko/juil/order/base-info/items/new` 이동 -- [x] 수정 버튼 클릭 → `/ko/juil/order/base-info/items/[id]?mode=edit` 이동 -- [x] 등록/수정/상세 페이지 구현 (ItemDetailClient.tsx) -- [x] Server Actions (getItem, createItem, updateItem) 구현 -- [x] 발주 항목 동적 추가/삭제 기능 - -### Phase 9: 테스트 URL 문서 업데이트 -- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트 - - 품목관리 URL 추가 - -## 파일 구조 - -``` -src/ -├── app/[locale]/(protected)/juil/order/ -│ └── base-info/ -│ └── items/ -│ ├── page.tsx -│ ├── new/ -│ │ └── page.tsx -│ └── [id]/ -│ └── page.tsx -└── components/business/juil/ - └── item-management/ - ├── index.tsx - ├── ItemManagementClient.tsx - ├── ItemDetailClient.tsx - ├── types.ts - ├── constants.ts - └── actions.ts -``` - -## 참조 컴포넌트 -- `IntegratedListTemplateV2` - 리스트 템플릿 -- `StatCards` - 통계 카드 -- `DateRangePicker` - 날짜 범위 선택 -- `Select` - 필터 셀렉트박스 -- `Badge` - 상태 표시 -- `Button` - 버튼 -- `Checkbox` - 체크박스 - -## UI 구현 참고 -- 컬럼 헤더 내 필터 Select: 기존 프로젝트 내 유사 구현 검색 필요 -- 날짜 빠른 선택 버튼 그룹: 기존 컴포넌트 활용 또는 신규 구현 - -## 진행 상태 -- 생성일: 2026-01-05 -- 상태: ✅ 전체 완료 (리스트 + 상세/등록/수정) - -## 히스토리 -| 날짜 | 작업 내용 | 상태 | -|------|----------|------| -| 2026-01-05 | 체크리스트 작성 | ✅ | -| 2026-01-05 | 리스트 페이지 구현 (Phase 1-7, 9) | ✅ | -| 2026-01-05 | 규격 필터 수정 (인쇄/비인쇄 → 인정/비인정) | ✅ | -| 2026-01-05 | 상세/등록/수정 페이지 구현 (Phase 8) | ✅ | diff --git a/claudedocs/construction/[IMPL-2026-01-05] pricing-management-checklist.md b/claudedocs/construction/[IMPL-2026-01-05] pricing-management-checklist.md deleted file mode 100644 index 93a91da0..00000000 --- a/claudedocs/construction/[IMPL-2026-01-05] pricing-management-checklist.md +++ /dev/null @@ -1,119 +0,0 @@ -# [IMPL-2026-01-05] 단가관리 리스트 페이지 구현 체크리스트 - -## 개요 -- **위치**: 발주관리 > 기준정보 > 단가관리 -- **URL**: `/ko/juil/order/base-info/pricing` -- **참조 페이지**: `/ko/juil/order/order-management` (OrderManagementListClient) -- **패턴**: IntegratedListTemplateV2 + StatCards - -## 스크린샷 분석 - -### UI 구성 - -#### 1. 헤더 영역 -| 구성요소 | 내용 | -|---------|------| -| 타이틀 | 단가관리 | -| 설명 | 단가를 등록하고 관리합니다. | - -#### 2. 달력 + 액션 버튼 영역 -| 구성요소 | 내용 | -|---------|------| -| 날짜 선택 | DateRangeSelector (2025-09-01 ~ 2025-09-03) | -| 액션 버튼들 | 담당단가, 진행단가, 확정, 발행, 이력, 오류, **단가 등록** | - -#### 3. StatCards (통계 카드) -| 카드 | 값 | 설명 | -|------|-----|------| -| 미완료 | 9 | 미완료 단가 | -| 확정 | 5 | 확정된 단가 | -| 발행 | 4 | 발행된 단가 | - -#### 4. 필터 영역 (테이블 헤더) -| 필터 | 옵션 | 기본값 | -|------|------|--------| -| 품목유형 | 전체, 박스, 부속, 소모품, 공과 | 전체 | -| 카테고리 | 전기, (카테고리 목록) | - | -| 규격 | 전체, 진행, 미진행 | 전체 | -| 구분 | 전체, 금동량, 임의적용가, 미구분 | 전체 | -| 상세 | 전체, 사용, 유지, 미등록 | 전체 | -| 정렬 | 최신순, 등록순 | 최신순 | - -#### 5. 테이블 컬럼 -| 컬럼 | 설명 | -|------|------| -| 체크박스 | 행 선택 | -| 단가번호 | 단가 고유번호 | -| 품목유형 | 박스/부속/소모품/공과 | -| 카테고리 | 품목 카테고리 | -| 품목 | 품목명 | -| 금액량 | 수량 정보 | -| 정량 | 정량 정보 | -| 단가 | 단가 금액 | -| 구매처 | 구매처 정보 | -| 예상단가 | 예상 단가 | -| 이전단가 | 이전 단가 | -| 판매단가 | 판매 단가 | -| 실적 | 실적 정보 | - -## 구현 체크리스트 - -### Phase 1: 파일 구조 생성 -- [x] `src/app/[locale]/(protected)/juil/order/base-info/pricing/page.tsx` 생성 -- [x] `src/components/business/juil/pricing-management/` 디렉토리 생성 - -### Phase 2: 타입 및 상수 정의 -- [x] `types.ts` - Pricing 타입, 필터 옵션, 상태 스타일 - - Pricing 인터페이스 - - PricingStats 인터페이스 - - 품목유형 옵션 (ITEM_TYPE_OPTIONS) - - 규격 옵션 (SPEC_OPTIONS) - - 구분 옵션 (DIVISION_OPTIONS) - - 상세 옵션 (DETAIL_OPTIONS) - - 정렬 옵션 (SORT_OPTIONS) - - 상태 스타일 (PRICING_STATUS_STYLES) - -### Phase 3: Server Actions (목데이터) -- [x] `actions.ts` - - getPricingList() - 목록 조회 - - getPricingStats() - 통계 조회 - - deletePricing() - 단일 삭제 - - deletePricings() - 일괄 삭제 - -### Phase 4: 리스트 컴포넌트 -- [x] `PricingListClient.tsx` - - IntegratedListTemplateV2 사용 - - DateRangeSelector (날짜 범위 선택) - - StatCards (미완료/확정/발행) - - 필터 셀렉트 박스들 (품목유형, 규격, 구분, 상세, 정렬) - - 액션 버튼들 (담당단가, 진행단가, 확정, 발행, 이력, 오류, 단가 등록) - - 테이블 렌더링 - - 모바일 카드 렌더링 - - 삭제 다이얼로그 - -### Phase 5: 목데이터 설정 -- [x] 7개 목데이터 설정 완료 - -### Phase 6: 테스트 URL 문서 업데이트 -- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트 - -## 파일 구조 - -``` -src/ -├── app/[locale]/(protected)/juil/order/ -│ └── base-info/ -│ └── pricing/ -│ └── page.tsx -└── components/business/juil/ - └── pricing-management/ - ├── index.ts - ├── types.ts - ├── actions.ts - └── PricingListClient.tsx -``` - -## 진행 상태 -- 생성일: 2026-01-05 -- 상태: ✅ 완료 (목데이터 기반) -- 남은 작업: API 연동 시 실제 데이터 연결 \ No newline at end of file diff --git a/claudedocs/construction/[IMPL-2026-01-09] partner-management-api-integration.md b/claudedocs/construction/[IMPL-2026-01-09] partner-management-api-integration.md deleted file mode 100644 index 4e75315b..00000000 --- a/claudedocs/construction/[IMPL-2026-01-09] partner-management-api-integration.md +++ /dev/null @@ -1,117 +0,0 @@ -# Phase 2.2 거래처관리 API 연동 - -**날짜**: 2026-01-09 -**작업**: 거래처관리 Mock → API 연동 - -## 개요 - -시공사 페이지 API 연동 계획 Phase 2.2 - 거래처관리(partners) API 연동 완료. - -## 변경 사항 - -### Backend (API) - -#### 1. 서비스 (ClientService.php) -- `stats()` - 거래처 통계 조회 (신규) - - total: 전체 거래처 수 - - sales: 판매 거래처 (client_type='SALES') - - purchase: 구매 거래처 (client_type='PURCHASE') - - both: 판매/구매 거래처 (client_type='BOTH') - - badDebt: 악성채권 보유 거래처 수 - - normal: 정상 거래처 수 -- `bulkDestroy()` - 일괄 삭제 (신규) - - 주문 존재 시 해당 거래처는 건너뜀 - -#### 2. 컨트롤러 (ClientController.php) -- `stats()` - GET /api/v1/clients/stats -- `bulkDestroy()` - DELETE /api/v1/clients/bulk - -#### 3. 라우트 (api.php) -```php -Route::get('/stats', [ClientController::class, 'stats']); -Route::delete('/bulk', [ClientController::class, 'bulkDestroy']); -``` - -### Frontend (React) - -#### 1. actions.ts -- Mock 데이터 제거 (mockPartners 배열) -- API 연동 구현 - - `getPartnerList()` - GET /api/v1/clients - - `getPartner()` - GET /api/v1/clients/{id} - - `createPartner()` - POST /api/v1/clients - - `updatePartner()` - PUT /api/v1/clients/{id} - - `getPartnerStats()` - GET /api/v1/clients/stats - - `deletePartner()` - DELETE /api/v1/clients/{id} - - `deletePartners()` - DELETE /api/v1/clients/bulk - -#### 2. 변환 함수 -- `transformClientType()` - client_type → partnerType 변환 -- `transformPartnerType()` - partnerType → client_type 변환 -- `transformPartner()` - API 응답 → Partner 타입 변환 -- `transformPartnerToApi()` - PartnerFormData → API 요청 데이터 변환 - -## API 매핑 - -| Frontend | Backend | 비고 | -|----------|---------|------| -| id | id | string ↔ int | -| partnerCode | client_code | 자동 생성 | -| businessNumber | business_no | | -| partnerName | name | | -| representative | contact_person | | -| partnerType | client_type | sales/SALES, purchase/PURCHASE, both/BOTH | -| businessType | business_type | | -| businessCategory | business_item | | -| address1 | address | | -| phone | phone | | -| mobile | mobile | | -| fax | fax | | -| email | email | | -| manager | manager_name | | -| managerPhone | manager_tel | | -| systemManager | system_manager | | -| outstandingAmount | outstanding_amount | 계산 필드 (매출-입금) | -| overdueToggle | is_overdue | | -| isBadDebt | has_bad_debt | 계산 필드 | -| isActive | is_active | | -| createdAt | created_at | | -| updatedAt | updated_at | | - -### Frontend 전용 필드 (기본값 사용) -- zipCode, address2: '' -- logoUrl, logoBlob: null -- salesPaymentDay, paymentDay: 0 -- creditRating, transactionGrade: '' -- memos, documents: [] -- category: '' -- overdueDays: is_overdue ? 30 : 0 - -## 설계 결정 - -### 기존 Client API 재사용 -- `/api/v1/clients` 기존 엔드포인트 확장 사용 -- 별도의 `/api/v1/construction/partners` 생성하지 않음 -- accounting/vendors 와 construction/partners 모두 Client API 사용 - -### 악성채권 통계 -- BadDebt 테이블과 연계하여 악성채권 보유 거래처 수 계산 -- 상태가 '추심중' 또는 '법적조치'인 활성 악성채권만 카운트 - -### 필터링 전략 -- 검색(`q`): API에서 처리 (name, client_code, contact_person) -- 악성채권 필터: 프론트엔드에서 처리 (API 전체 반환 후 필터) -- 정렬: 프론트엔드에서 처리 (API 기본 정렬 사용) - -## 진행률 - -시공사 API 연동: 4/9 (44%) -- [x] Phase 1.1 견적관리 -- [x] Phase 1.2 인수인계보고서관리 -- [x] Phase 2.1 현장관리 -- [x] Phase 2.2 거래처관리 ← 현재 완료 -- [ ] Phase 2.3 자재관리 -- [ ] Phase 3.1 발주관리 -- [ ] Phase 3.2 재고관리 -- [ ] Phase 4.1 정산관리 -- [ ] Phase 4.2 급여관리 \ No newline at end of file diff --git a/claudedocs/construction/[IMPL-2026-01-09] site-management-api-integration.md b/claudedocs/construction/[IMPL-2026-01-09] site-management-api-integration.md deleted file mode 100644 index 307362d8..00000000 --- a/claudedocs/construction/[IMPL-2026-01-09] site-management-api-integration.md +++ /dev/null @@ -1,90 +0,0 @@ -# Phase 2.1 현장관리 API 연동 - -**날짜**: 2026-01-09 -**작업**: 현장관리 Mock → API 연동 - -## 개요 - -시공사 페이지 API 연동 계획 Phase 2.1 - 현장관리(site-management) API 연동 완료. - -## 변경 사항 - -### Backend (API) - -#### 1. 마이그레이션 -- `2026_01_09_162534_add_construction_fields_to_sites_table.php` - - `site_code` (VARCHAR 50) - 현장코드 - - `client_id` (FK → clients) - 거래처 연결 - - `status` (ENUM) - unregistered/suspended/active/pending - - 인덱스: tenant_id + site_code, tenant_id + status - -#### 2. 모델 (Site.php) -- 상태 상수 추가: STATUS_UNREGISTERED, STATUS_SUSPENDED, STATUS_ACTIVE, STATUS_PENDING -- fillable 확장: site_code, client_id, status -- Client 관계 추가 - -#### 3. 서비스 (SiteService.php) -- `index()` - 필터 확장 (status, client_id, start_date, end_date) -- `stats()` - 상태별 통계 조회 (신규) -- `bulkDestroy()` - 일괄 삭제 (신규) - -#### 4. 컨트롤러 (SiteController.php) -- `stats()` - GET /api/v1/sites/stats -- `bulkDestroy()` - DELETE /api/v1/sites/bulk - -#### 5. 라우트 (api.php) -```php -Route::get('/stats', [SiteController::class, 'stats']); -Route::delete('/bulk', [SiteController::class, 'bulkDestroy']); -``` - -### Frontend (React) - -#### 1. types.ts -- SiteStats에 suspended, pending 필드 추가 - -#### 2. actions.ts -- Mock 데이터 제거 -- API 연동 구현 - - `getSiteList()` - GET /api/v1/sites - - `getSiteStats()` - GET /api/v1/sites/stats - - `deleteSite()` - DELETE /api/v1/sites/{id} - - `deleteSites()` - DELETE /api/v1/sites/bulk - -## API 매핑 - -| Frontend | Backend | 비고 | -|----------|---------|------| -| id | id | string ↔ int | -| siteCode | site_code | | -| partnerId | client_id | | -| partnerName | client.name | 관계 eager load | -| siteName | name | | -| address | address | | -| status | status | 동일 | -| createdAt | created_at | | -| updatedAt | updated_at | | - -## 설계 결정 - -### is_active vs status -- `is_active` (boolean): 사용 여부 (활성화/비활성화) -- `status` (enum): 상태값 (미등록/중지/사용/보류) -- 두 필드는 다른 용도로 둘 다 유지 - -### 기존 API 활용 -- `/api/v1/sites` 기존 엔드포인트 확장 사용 -- `/api/v1/construction/sites` 별도 생성하지 않음 - -## 진행률 - -시공사 API 연동: 3/9 (33%) -- [x] Phase 1.1 견적관리 -- [x] Phase 1.2 인수인계보고서관리 -- [x] Phase 2.1 현장관리 ← 현재 완료 -- [ ] Phase 2.2 거래처관리 -- [ ] Phase 2.3 자재관리 -- [ ] Phase 3.1 발주관리 -- [ ] Phase 3.2 재고관리 -- [ ] Phase 4.1 정산관리 -- [ ] Phase 4.2 급여관리 \ No newline at end of file diff --git a/claudedocs/construction/[IMPL-2026-01-12] project-detail-checklist.md b/claudedocs/construction/[IMPL-2026-01-12] project-detail-checklist.md deleted file mode 100644 index 842d86dc..00000000 --- a/claudedocs/construction/[IMPL-2026-01-12] project-detail-checklist.md +++ /dev/null @@ -1,52 +0,0 @@ -# 프로젝트 실행관리 상세 페이지 구현 체크리스트 - -## 구현 일자: 2026-01-12 - -## 페이지 구조 -- 페이지 경로: `/construction/project/management/[id]` -- 칸반 보드 형태의 상세 페이지 -- 프로젝트 → 단계 → 상세 연동 - ---- - -## 작업 목록 - -### 1. 타입 및 데이터 준비 -- [x] types.ts - 상세 페이지용 타입 추가 (Stage, StageDetail, ProjectDetail 등) -- [x] actions.ts - 상세 페이지 목업 데이터 추가 - -### 2. 칸반 보드 컴포넌트 -- [x] ProjectKanbanBoard.tsx - 칸반 보드 컨테이너 -- [x] KanbanColumn.tsx - 칸반 컬럼 공통 컴포넌트 -- [x] ProjectCard.tsx - 프로젝트 카드 (진행률, 계약금, 기간) -- [x] StageCard.tsx - 단계 카드 (입찰/계약/시공) -- [x] DetailCard.tsx - 상세 카드 (현장설명회 등 단순 목록) - -### 3. 프로젝트 종료 팝업 -- [x] ProjectEndDialog.tsx - 프로젝트 종료 다이얼로그 - -### 4. 메인 페이지 조립 -- [x] ProjectDetailClient.tsx - 메인 클라이언트 컴포넌트 -- [x] page.tsx - 상세 페이지 진입점 - -### 5. 검증 -- [ ] 칸반 보드 동작 확인 (프로젝트→단계→상세 연동) -- [ ] 프로젝트 종료 팝업 동작 확인 -- [ ] 리스트 페이지에서 상세 페이지 이동 확인 - ---- - -## 참고 사항 -- 1차 구현: 상세 하위 목록 없는 경우 (현장설명회) 먼저 구현 -- 이후 추가로 보면서 맞춰가기 -- 기존 리스트 페이지 패턴 참고 - ---- - -## 진행 상황 -- 시작: 2026-01-12 -- 현재 상태: 1차 구현 완료, 브라우저 검증 대기 - -## 테스트 URL -- 리스트 페이지: http://localhost:3000/ko/construction/project/management -- 상세 페이지: http://localhost:3000/ko/construction/project/management/1 diff --git a/claudedocs/construction/[PLAN-2026-01-02] estimate-detail-form-refactoring.md b/claudedocs/construction/[PLAN-2026-01-02] estimate-detail-form-refactoring.md deleted file mode 100644 index d00beb25..00000000 --- a/claudedocs/construction/[PLAN-2026-01-02] estimate-detail-form-refactoring.md +++ /dev/null @@ -1,231 +0,0 @@ -# EstimateDetailForm.tsx 파일 분할 계획서 - -## 현황 분석 - -- **파일 위치**: `src/components/business/juil/estimates/EstimateDetailForm.tsx` -- **현재 라인 수**: 2,088줄 -- **문제점**: 단일 파일에 모든 섹션, 핸들러, 상태 관리가 집중되어 유지보수 어려움 - -## 파일 구조 분석 - -### 현재 구조 (라인 범위) - -| 구분 | 라인 | 설명 | -|------|------|------| -| Imports | 1-56 | React, UI 컴포넌트, 타입 | -| 상수/유틸 | 58-75 | MOCK_MATERIALS, MOCK_EXPENSES, formatAmount | -| Props | 77-81 | EstimateDetailFormProps | -| State | 88-127 | formData, 로딩, 다이얼로그, 모달 상태 | -| 핸들러 - 네비게이션 | 130-140 | handleBack, handleEdit, handleCancel | -| 핸들러 - 저장/삭제 | 143-182 | handleSave, handleConfirmSave, handleDelete, handleConfirmDelete | -| 핸들러 - 견적 요약 | 185-227 | handleAddSummaryItem, handleRemoveSummaryItem, handleSummaryItemChange | -| 핸들러 - 공과 상세 | 230-259 | handleAddExpenseItem, handleRemoveExpenseItem, handleExpenseItemChange | -| 핸들러 - 단가 조정 | 262-283 | handlePriceAdjustmentChange | -| 핸들러 - 견적 상세 | 286-343 | handleAddDetailItem, handleRemoveDetailItem, handleDetailItemChange | -| 핸들러 - 파일 업로드 | 346-435 | handleDocumentUpload, handleDocumentRemove, 드래그앤드롭 | -| useMemo | 438-482 | pageTitle, pageDescription, headerActions | -| JSX - 견적 정보 | 496-526 | 견적 정보 Card | -| JSX - 현장설명회 | 528-551 | 현장설명회 정보 Card | -| JSX - 입찰 정보 | 553-736 | 입찰 정보 Card + 파일 업로드 | -| JSX - 견적 요약 | 738-890 | 견적 요약 정보 Table | -| JSX - 공과 상세 | 892-1071 | 공과 상세 Table | -| JSX - 단가 조정 | 1073-1224 | 품목 단가 조정 Table | -| JSX - 견적 상세 | 1226-2017 | 견적 상세 Table (가장 큰 섹션) | -| 모달/다이얼로그 | 2020-2085 | 전자결재, 견적서, 삭제/저장 다이얼로그 | - ---- - -## 분할 계획 - -### 1단계: 섹션 컴포넌트 분리 - -``` -src/components/business/juil/estimates/ -├── EstimateDetailForm.tsx # 메인 컴포넌트 (축소) -├── sections/ -│ ├── index.ts # 섹션 export -│ ├── EstimateInfoSection.tsx # 견적 정보 + 현장설명회 + 입찰 정보 -│ ├── EstimateSummarySection.tsx # 견적 요약 정보 -│ ├── ExpenseDetailSection.tsx # 공과 상세 -│ ├── PriceAdjustmentSection.tsx # 품목 단가 조정 -│ └── EstimateDetailTableSection.tsx # 견적 상세 테이블 -├── hooks/ -│ ├── index.ts # hooks export -│ └── useEstimateCalculations.ts # 계산 로직 (면적, 무게, 단가 등) -└── utils/ - ├── index.ts # utils export - ├── constants.ts # MOCK_MATERIALS, MOCK_EXPENSES - └── formatters.ts # formatAmount -``` - -### 2단계: 각 파일 상세 - -#### 2.1 constants.ts (~20줄) -```typescript -// MOCK_MATERIALS, MOCK_EXPENSES 이동 -export const MOCK_MATERIALS = [...]; -export const MOCK_EXPENSES = [...]; -``` - -#### 2.2 formatters.ts (~10줄) -```typescript -// formatAmount 함수 이동 -export function formatAmount(amount: number): string { ... } -``` - -#### 2.3 useEstimateCalculations.ts (~100줄) -```typescript -// 견적 상세 테이블의 계산 로직 분리 -// - 면적, 무게, 철제스크린, 코킹, 레일, 하장 등 계산 -// - 합계 계산 로직 -export function useEstimateCalculations( - item: EstimateDetailItem, - priceAdjustmentData: PriceAdjustmentData, - useAdjustedPrice: boolean -) { ... } - -export function calculateTotals( - items: EstimateDetailItem[], - priceAdjustmentData: PriceAdjustmentData, - useAdjustedPrice: boolean -) { ... } -``` - -#### 2.4 EstimateInfoSection.tsx (~250줄) -```typescript -// 견적 정보 + 현장설명회 + 입찰 정보 Card 3개 -// 파일 업로드 영역 포함 -interface EstimateInfoSectionProps { - formData: EstimateDetailFormData; - setFormData: React.Dispatch>; - isViewMode: boolean; - documentInputRef: React.RefObject; -} -``` - -#### 2.5 EstimateSummarySection.tsx (~200줄) -```typescript -// 견적 요약 정보 테이블 -interface EstimateSummarySectionProps { - summaryItems: EstimateSummaryItem[]; - summaryMemo: string; - isViewMode: boolean; - onAddItem: () => void; - onRemoveItem: (id: string) => void; - onItemChange: (id: string, field: keyof EstimateSummaryItem, value: string | number) => void; - onMemoChange: (memo: string) => void; -} -``` - -#### 2.6 ExpenseDetailSection.tsx (~200줄) -```typescript -// 공과 상세 테이블 -interface ExpenseDetailSectionProps { - expenseItems: ExpenseItem[]; - isViewMode: boolean; - onAddItems: (count: number) => void; - onRemoveSelected: () => void; - onItemChange: (id: string, field: keyof ExpenseItem, value: string | number) => void; - onSelectItem: (id: string, selected: boolean) => void; - onSelectAll: (selected: boolean) => void; -} -``` - -#### 2.7 PriceAdjustmentSection.tsx (~200줄) -```typescript -// 품목 단가 조정 테이블 -interface PriceAdjustmentSectionProps { - priceAdjustmentData: PriceAdjustmentData; - isViewMode: boolean; - onPriceChange: (key: string, value: number) => void; - onSave: () => void; - onApplyAll: () => void; - onReset: () => void; -} -``` - -#### 2.8 EstimateDetailTableSection.tsx (~600줄) -```typescript -// 견적 상세 테이블 (가장 큰 섹션) -interface EstimateDetailTableSectionProps { - detailItems: EstimateDetailItem[]; - priceAdjustmentData: PriceAdjustmentData; - useAdjustedPrice: boolean; - isViewMode: boolean; - onAddItems: (count: number) => void; - onRemoveItem: (id: string) => void; - onRemoveSelected: () => void; - onItemChange: (id: string, field: keyof EstimateDetailItem, value: string | number) => void; - onSelectItem: (id: string, selected: boolean) => void; - onSelectAll: (selected: boolean) => void; - onApplyAdjustedPrice: () => void; - onReset: () => void; -} -``` - ---- - -## 분할 후 예상 라인 수 - -| 파일 | 예상 라인 수 | -|------|-------------| -| EstimateDetailForm.tsx (메인) | ~300줄 | -| EstimateInfoSection.tsx | ~250줄 | -| EstimateSummarySection.tsx | ~200줄 | -| ExpenseDetailSection.tsx | ~200줄 | -| PriceAdjustmentSection.tsx | ~200줄 | -| EstimateDetailTableSection.tsx | ~600줄 | -| useEstimateCalculations.ts | ~100줄 | -| constants.ts | ~20줄 | -| formatters.ts | ~10줄 | -| **총합** | ~1,880줄 (약 10% 감소) | - ---- - -## 실행 순서 - -### Phase 1: 유틸리티 분리 (5분) -- [ ] `utils/constants.ts` 생성 -- [ ] `utils/formatters.ts` 생성 -- [ ] `utils/index.ts` 생성 - -### Phase 2: 계산 로직 분리 (10분) -- [ ] `hooks/useEstimateCalculations.ts` 생성 -- [ ] `hooks/index.ts` 생성 - -### Phase 3: 섹션 컴포넌트 분리 (30분) -- [ ] `sections/EstimateInfoSection.tsx` 생성 -- [ ] `sections/EstimateSummarySection.tsx` 생성 -- [ ] `sections/ExpenseDetailSection.tsx` 생성 -- [ ] `sections/PriceAdjustmentSection.tsx` 생성 -- [ ] `sections/EstimateDetailTableSection.tsx` 생성 -- [ ] `sections/index.ts` 생성 - -### Phase 4: 메인 컴포넌트 리팩토링 (10분) -- [ ] EstimateDetailForm.tsx에서 분리된 컴포넌트 import -- [ ] 핸들러 정리 및 props 전달 -- [ ] 불필요한 코드 제거 - -### Phase 5: 검증 (5분) -- [ ] TypeScript 빌드 확인 -- [ ] 기능 동작 확인 - ---- - -## 주의사항 - -1. **상태 관리**: formData, setFormData는 메인 컴포넌트에서 관리, 섹션에 props로 전달 -2. **타입 일관성**: 기존 types.ts의 타입 그대로 사용 -3. **핸들러 위치**: 핸들러는 메인 컴포넌트에 유지, 섹션에 콜백으로 전달 -4. **조정단가 상태**: appliedPrices, useAdjustedPrice는 메인 컴포넌트에서 관리 - ---- - -## 5가지 수정사항 (분할 후 진행) - -| # | 항목 | 수정 위치 (분할 후) | -|---|------|-------------------| -| 2 | 품목 단가 초기화 → 품목 단가만 | PriceAdjustmentSection.tsx | -| 3 | 견적 상세 인풋 필드 추가 | EstimateDetailTableSection.tsx | -| 4 | 견적 상세 초기화 버튼 수정 | EstimateDetailTableSection.tsx | -| 5 | 각 섹션별 초기화 분리 | 각 Section 컴포넌트 | \ No newline at end of file diff --git a/claudedocs/construction/[PLAN-2026-01-05] order-detail-form-separation.md b/claudedocs/construction/[PLAN-2026-01-05] order-detail-form-separation.md deleted file mode 100644 index 8c636d20..00000000 --- a/claudedocs/construction/[PLAN-2026-01-05] order-detail-form-separation.md +++ /dev/null @@ -1,292 +0,0 @@ -# OrderDetailForm.tsx 분리 계획서 - -**생성일**: 2026-01-05 -**현재 파일 크기**: 1,273줄 -**목표**: 유지보수성 향상을 위한 컴포넌트 분리 - ---- - -## 현재 파일 구조 분석 - -| 영역 | 라인 | 비율 | 내용 | -|------|------|------|------| -| Import & Types | 1-69 | 5% | 의존성 및 타입 import | -| Props Interface | 70-74 | 0.5% | 컴포넌트 props | -| State & Hooks | 76-113 | 3% | 상태 관리 (12개 useState) | -| Handlers | 114-433 | 25% | 핸들러 함수들 (20+개) | -| JSX Render | 435-1271 | 66% | UI 렌더링 | - -### 주요 핸들러 분류 (114-433줄) -- **Navigation**: handleBack, handleEdit, handleCancel (114-125) -- **Form Field**: handleFieldChange (127-133) -- **CRUD Operations**: handleSave, handleDelete, handleDuplicate (135-199) -- **Category Operations**: handleAddCategory, handleDeleteCategory, handleCategoryChange (206-247) -- **Item Operations**: handleAddItems, handleDeleteSelectedItems, handleDeleteAllItems, handleItemChange (249-327) -- **Selection**: handleToggleSelection, handleToggleSelectAll (330-357) -- **Calendar**: handleCalendarDateClick, handleCalendarMonthChange (359-385) - -### 주요 JSX 영역 (435-1271줄) -- **발주 정보 Card**: 447-559 (112줄) -- **계약 정보 Card**: 561-694 (133줄) -- **발주 스케줄 Calendar**: 696-715 (19줄) -- **발주 상세 테이블**: 717-1172 (455줄) ⚠️ **가장 큰 부분** -- **카테고리 추가 버튼**: 1174-1182 (8줄) -- **비고 Card**: 1184-1198 (14줄) -- **Dialogs**: 1201-1261 (60줄) -- **Document Modal**: 1263-1270 (7줄) - ---- - -## 분리 계획 - -### Phase 1: 커스텀 훅 분리 - -**파일**: `hooks/useOrderDetailForm.ts` -**예상 크기**: ~250줄 - -```typescript -// 추출할 내용 -- formData 상태 관리 -- selectedItems, addCounts, categoryFilters 상태 -- calendarDate, selectedCalendarDate 상태 -- 모든 핸들러 함수들 -- calendarEvents useMemo -``` - -**장점**: -- 비즈니스 로직과 UI 분리 -- 테스트 용이성 향상 -- 재사용 가능 - ---- - -### Phase 2: 카드 컴포넌트 분리 - -#### 2-1. `cards/OrderInfoCard.tsx` -**예상 크기**: ~120줄 - -```typescript -interface OrderInfoCardProps { - formData: OrderDetailFormData; - isViewMode: boolean; - onFieldChange: (field: keyof OrderDetailFormData, value: any) => void; -} -``` - -**포함 내용**: 발주번호, 발주일, 구분, 상태, 발주담당자, 화물도착지 - ---- - -#### 2-2. `cards/ContractInfoCard.tsx` -**예상 크기**: ~150줄 - -```typescript -interface ContractInfoCardProps { - formData: OrderDetailFormData; - isViewMode: boolean; - isEditMode: boolean; - onFieldChange: (field: keyof OrderDetailFormData, value: any) => void; -} -``` - -**포함 내용**: 거래처명, 현장명, 계약번호, 공사PM, 공사담당자 - ---- - -#### 2-3. `cards/OrderScheduleCard.tsx` -**예상 크기**: ~50줄 - -```typescript -interface OrderScheduleCardProps { - events: ScheduleEvent[]; - currentDate: Date; - selectedDate: Date | null; - onDateClick: (date: Date) => void; - onMonthChange: (date: Date) => void; -} -``` - -**포함 내용**: ScheduleCalendar 래핑 - ---- - -#### 2-4. `cards/OrderMemoCard.tsx` -**예상 크기**: ~40줄 - -```typescript -interface OrderMemoCardProps { - memo: string; - isViewMode: boolean; - onMemoChange: (value: string) => void; -} -``` - -**포함 내용**: 비고 Textarea - ---- - -### Phase 3: 테이블 컴포넌트 분리 (가장 중요) - -#### 3-1. `tables/OrderDetailItemTable.tsx` -**예상 크기**: ~350줄 - -```typescript -interface OrderDetailItemTableProps { - category: OrderDetailCategory; - isEditMode: boolean; - isViewMode: boolean; - selectedItems: Set; - addCount: number; - onAddCountChange: (count: number) => void; - onAddItems: (count: number) => void; - onDeleteSelectedItems: () => void; - onDeleteAllItems: () => void; - onCategoryChange: (field: keyof OrderDetailCategory, value: string) => void; - onItemChange: (itemId: string, field: keyof OrderDetailItem, value: any) => void; - onToggleSelection: (itemId: string) => void; - onToggleSelectAll: () => void; -} -``` - -**포함 내용**: -- 카드 헤더 (왼쪽: 발주 상세/N건 선택/삭제, 오른쪽: 숫자/추가/카테고리/🗑️) -- 테이블 전체 (TableHeader + TableBody) -- 합계 행 - ---- - -#### 3-2. `tables/OrderDetailItemRow.tsx` (선택적) -**예상 크기**: ~150줄 - -```typescript -interface OrderDetailItemRowProps { - item: OrderDetailItem; - index: number; - isEditMode: boolean; - isSelected: boolean; - onItemChange: (field: keyof OrderDetailItem, value: any) => void; - onToggleSelection: () => void; -} -``` - -**포함 내용**: 단일 테이블 행 렌더링 - ---- - -### Phase 4: 다이얼로그 분리 - -#### 4-1. `dialogs/OrderDialogs.tsx` -**예상 크기**: ~80줄 - -```typescript -interface OrderDialogsProps { - // 저장 다이얼로그 - showSaveDialog: boolean; - onSaveDialogChange: (open: boolean) => void; - onConfirmSave: () => void; - // 삭제 다이얼로그 - showDeleteDialog: boolean; - onDeleteDialogChange: (open: boolean) => void; - onConfirmDelete: () => void; - // 카테고리 삭제 다이얼로그 - showCategoryDeleteDialog: string | null; - onCategoryDeleteDialogChange: (categoryId: string | null) => void; - onConfirmDeleteCategory: () => void; - // 공통 - isLoading: boolean; -} -``` - ---- - -## 분리 후 예상 구조 - -``` -src/components/business/juil/order-management/ -├── OrderDetailForm.tsx (~200줄, 메인 컴포넌트) -├── hooks/ -│ └── useOrderDetailForm.ts (~250줄, 비즈니스 로직) -├── cards/ -│ ├── OrderInfoCard.tsx (~120줄) -│ ├── ContractInfoCard.tsx (~150줄) -│ ├── OrderScheduleCard.tsx (~50줄) -│ └── OrderMemoCard.tsx (~40줄) -├── tables/ -│ ├── OrderDetailItemTable.tsx (~350줄) -│ └── OrderDetailItemRow.tsx (~150줄, 선택적) -├── dialogs/ -│ └── OrderDialogs.tsx (~80줄) -├── modals/ -│ └── OrderDocumentModal.tsx (기존) -├── actions.ts (기존) -└── types.ts (기존) -``` - ---- - -## 분리 전후 비교 - -| 지표 | Before | After | -|------|--------|-------| -| 메인 파일 크기 | 1,273줄 | ~200줄 | -| 가장 큰 파일 | 1,273줄 | ~350줄 | -| 파일 개수 | 1 | 8-9 | -| 테스트 용이성 | 낮음 | 높음 | -| 재사용성 | 낮음 | 중간 | - ---- - -## 실행 체크리스트 - -### Phase 1: 커스텀 훅 분리 -- [ ] `hooks/useOrderDetailForm.ts` 생성 -- [ ] 상태 변수들 이동 -- [ ] 핸들러 함수들 이동 -- [ ] useMemo 이동 -- [ ] OrderDetailForm.tsx에서 훅 사용 - -### Phase 2: 카드 컴포넌트 분리 -- [ ] `cards/OrderInfoCard.tsx` 생성 -- [ ] `cards/ContractInfoCard.tsx` 생성 -- [ ] `cards/OrderScheduleCard.tsx` 생성 -- [ ] `cards/OrderMemoCard.tsx` 생성 -- [ ] OrderDetailForm.tsx에서 import 및 사용 - -### Phase 3: 테이블 컴포넌트 분리 -- [ ] `tables/OrderDetailItemTable.tsx` 생성 -- [ ] `tables/OrderDetailItemRow.tsx` 생성 (선택적) -- [ ] OrderDetailForm.tsx에서 import 및 사용 - -### Phase 4: 다이얼로그 분리 -- [ ] `dialogs/OrderDialogs.tsx` 생성 -- [ ] OrderDetailForm.tsx에서 import 및 사용 - -### Phase 5: 최종 검증 -- [ ] TypeScript 타입 오류 없음 -- [ ] ESLint 경고 없음 -- [ ] 빌드 성공 -- [ ] 기능 테스트 (view/edit 모드) -- [ ] 불필요한 import 제거 - ---- - -## 우선순위 권장 - -1. **Phase 1 (Hook)** + **Phase 3 (Table)** 먼저 진행 - - 가장 큰 효과 (전체 코드의 ~60% 분리) - - 테이블이 455줄로 가장 큼 - -2. Phase 2 (Cards) 진행 - - 추가 ~360줄 분리 - -3. Phase 4 (Dialogs) 진행 - - 마무리 정리 - ---- - -## 주의사항 - -- **타입 export**: 새 컴포넌트에서 사용할 타입들 types.ts에서 export 확인 -- **props drilling**: 너무 깊어지면 Context 고려 -- **테스트**: 분리 후 view/edit 모드 모두 테스트 필수 -- **점진적 진행**: 한 번에 모든 분리보다 단계별 진행 권장 diff --git a/claudedocs/construction/[PLAN-2026-01-05] order-management-implementation.md b/claudedocs/construction/[PLAN-2026-01-05] order-management-implementation.md deleted file mode 100644 index a2f3ae03..00000000 --- a/claudedocs/construction/[PLAN-2026-01-05] order-management-implementation.md +++ /dev/null @@ -1,323 +0,0 @@ -# 발주관리 페이지 구현 계획서 - -> **작성일**: 2026-01-05 -> **작업 경로**: `/juil/order/order-management` -> **상태**: ✅ 구현 완료 - ---- - -## 📋 스크린샷 분석 결과 - -### 화면 구성 - -#### 1. 상단 - 발주 스케줄 (달력 영역) -| 요소 | 설명 | -|------|------| -| **뷰 전환** | 주(Week) / 월(Month) 탭 전환 | -| **년월 네비게이션** | 2025년 12월 ◀ ▶ 버튼 | -| **필터** | 작업반장별 필터 (이번년+8주 화살표 버튼) | -| **일정 바(Bar)** | "담당자 - 현장명 / 발주번호" 형태로 여러 날에 걸쳐 표시 | -| **일정 색상** | 회색(완료), 파란색(진행중) 구분 | -| **일자 뱃지** | 빨간 원 안에 숫자 (06, 07, 08 등) - 상태/건수 표시 | -| **더보기** | +15 형태로 해당 일자에 추가 일정 있음 표시 | -| **달력 클릭** | 특정 일자 클릭 시 아래 리스트에 해당 일자 데이터만 필터링 | - -#### 2. 하단 - 발주 목록 (리스트 영역) -| 요소 | 설명 | -|------|------| -| **날짜 범위** | 2025-09-01 ~ 2025-09-03 형태 | -| **빠른 필터 탭** | 당해년도 / 전년도 / 전월 / 당월 / 어제 / 오늘 | -| **검색** | 검색창 + 건수 표시 (7건, 12건 선택) | -| **상태 필터** | 빨간 원 숫자 버튼들 (전체/상태별) | -| **삭제 버튼** | 선택된 항목 삭제 | - -#### 3. 테이블 컬럼 -| 컬럼 | 설명 | -|------|------| -| 체크박스 | 선택 | -| 계약일련번호 | - | -| 거래처 | 회사명 | -| 현장명 | 작업 현장 | -| 병동 | - | -| 공 | - | -| 시APM | 담당 PM | -| 발주번호 | 발주 식별 번호 | -| 발주번 담자 | 발주 담당자 | -| 발주처 | - | -| 작업반 시공품 | 작업 내용 | -| 기간 | 작업 기간 | -| 구분 | 상태 구분 | -| 실적 납품일 | 실제 납품 완료일 | -| 납품일 | 예정 납품일 | - -#### 4. 작업 버튼 (선택 시) -- 수정 버튼 -- 삭제 버튼 - ---- - -## 🏗️ 구현 범위 - -### Phase 1: 공통 달력 컴포넌트 (ScheduleCalendar) -**재사용 가능한 스케줄 달력 컴포넌트** - -``` -src/components/common/ -└── ScheduleCalendar/ - ├── index.tsx # 메인 컴포넌트 - ├── ScheduleCalendar.tsx # 달력 본체 - ├── CalendarHeader.tsx # 헤더 (년월/뷰전환/필터) - ├── MonthView.tsx # 월간 뷰 - ├── WeekView.tsx # 주간 뷰 - ├── ScheduleBar.tsx # 일정 바 컴포넌트 - ├── DayCell.tsx # 일자 셀 컴포넌트 - ├── MorePopover.tsx # +N 더보기 팝오버 - ├── types.ts # 타입 정의 - └── utils.ts # 유틸리티 함수 -``` - -**기능 요구사항**: -- [ ] 월간/주간 뷰 전환 -- [ ] 년월 네비게이션 (이전/다음) -- [ ] 일정 바(Bar) 렌더링 (여러 날에 걸침) -- [ ] 일정 색상 구분 (상태별) -- [ ] 일자별 뱃지 숫자 표시 -- [ ] +N 더보기 기능 (3개 초과 시) -- [ ] 일자 클릭 이벤트 콜백 -- [ ] 필터 영역 slot (외부에서 주입) -- [ ] 반응형 디자인 - -### Phase 2: 발주관리 리스트 페이지 -**페이지 및 컴포넌트 구조** - -``` -src/app/[locale]/(protected)/juil/order/ -└── order-management/ - └── page.tsx # 페이지 엔트리 - -src/components/business/juil/order-management/ -├── OrderManagementListClient.tsx # 메인 클라이언트 컴포넌트 -├── OrderCalendarSection.tsx # 달력 섹션 (ScheduleCalendar 사용) -├── OrderListSection.tsx # 리스트 섹션 -├── OrderStatusFilter.tsx # 상태 필터 (빨간 원 숫자) -├── OrderDateFilter.tsx # 날짜 빠른 필터 (당해년도/전월 등) -├── types.ts # 타입 정의 -├── actions.ts # Server Actions -└── index.ts # 배럴 export -``` - -**기능 요구사항**: -- [ ] 달력과 리스트 통합 레이아웃 -- [ ] 달력 일자 클릭 → 리스트 필터 연동 -- [ ] 날짜 범위 선택 -- [ ] 빠른 날짜 필터 (당해년도/전년도/전월/당월/어제/오늘) -- [ ] 상태별 필터 (빨간 원 숫자 버튼) -- [ ] 검색 기능 -- [ ] 테이블 (체크박스/정렬/페이지네이션) -- [ ] 선택 시 작업 버튼 표시 -- [ ] 삭제 기능 - ---- - -## 📦 기술 의존성 - -### 새로 설치 필요 -```bash -# FullCalendar 라이브러리 (또는 커스텀 구현) -npm install @fullcalendar/core @fullcalendar/react @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction -``` - -**대안**: FullCalendar 없이 커스텀 달력 컴포넌트로 구현 -- 장점: 번들 사이즈 감소, 완전한 커스터마이징 -- 단점: 구현 복잡도 증가 - -### 기존 사용 -- `IntegratedListTemplateV2` - 리스트 템플릿 -- `DateRangeSelector` - 날짜 범위 선택 -- `date-fns` - 날짜 유틸리티 - ---- - -## 🔧 세부 구현 체크리스트 - -### Phase 1: 공통 달력 컴포넌트 (ScheduleCalendar) - -#### 1.1 기본 구조 및 타입 정의 -- [ ] `types.ts` 생성 (ScheduleEvent, CalendarView, CalendarProps 등) -- [ ] `utils.ts` 생성 (날짜 계산, 일정 위치 계산 등) -- [ ] 컴포넌트 폴더 구조 생성 - -#### 1.2 CalendarHeader 컴포넌트 -- [ ] 년월 표시 및 네비게이션 (◀ ▶) -- [ ] 주/월 뷰 전환 탭 -- [ ] 필터 slot (children으로 외부 주입) - -#### 1.3 MonthView 컴포넌트 -- [ ] 월간 그리드 레이아웃 (7x6) -- [ ] 요일 헤더 (일~토) -- [ ] 날짜 셀 렌더링 -- [ ] 이전/다음 달 날짜 표시 (opacity 처리) -- [ ] 오늘 날짜 하이라이트 - -#### 1.4 WeekView 컴포넌트 -- [ ] 주간 그리드 레이아웃 (7 컬럼) -- [ ] 요일 헤더 (날짜 + 요일) -- [ ] 날짜 셀 렌더링 - -#### 1.5 DayCell 컴포넌트 -- [ ] 날짜 숫자 표시 -- [ ] 뱃지 숫자 표시 (빨간 원) -- [ ] 클릭 이벤트 처리 -- [ ] 선택 상태 스타일 - -#### 1.6 ScheduleBar 컴포넌트 -- [ ] 일정 바 렌더링 (시작~종료 날짜) -- [ ] 여러 날에 걸치는 바 계산 (주 단위 분할) -- [ ] 색상 구분 (상태별) -- [ ] 호버/클릭 이벤트 -- [ ] 텍스트 truncate 처리 - -#### 1.7 MorePopover 컴포넌트 -- [ ] +N 버튼 렌더링 -- [ ] 팝오버로 숨겨진 일정 목록 표시 -- [ ] 일정 항목 클릭 이벤트 - -#### 1.8 메인 ScheduleCalendar 컴포넌트 -- [ ] 상태 관리 (현재 월, 뷰 모드, 선택된 날짜) -- [ ] 일정 데이터 받아서 렌더링 -- [ ] 이벤트 콜백 (onDateClick, onEventClick, onMonthChange) -- [ ] 반응형 처리 - -### Phase 2: 발주관리 리스트 페이지 - -#### 2.1 타입 및 설정 -- [ ] `types.ts` - Order 타입, 필터 옵션, 상태 정의 -- [ ] `actions.ts` - Server Actions (목업 데이터) - -#### 2.2 page.tsx -- [ ] 페이지 라우트 생성 -- [ ] 메타데이터 설정 -- [ ] 클라이언트 컴포넌트 import - -#### 2.3 OrderDateFilter 컴포넌트 -- [ ] 빠른 날짜 필터 버튼 (당해년도/전년도/전월/당월/어제/오늘) -- [ ] 클릭 시 날짜 범위 계산 -- [ ] 활성화 상태 스타일 - -#### 2.4 OrderStatusFilter 컴포넌트 -- [ ] 상태별 필터 버튼 (빨간 원 숫자) -- [ ] 전체/상태별 카운트 표시 -- [ ] 선택 상태 스타일 - -#### 2.5 OrderCalendarSection 컴포넌트 -- [ ] ScheduleCalendar 사용 -- [ ] 필터 영역 (작업반장 셀렉트) -- [ ] 일자 클릭 이벤트 → 리스트 필터 연동 -- [ ] 스케줄 데이터 매핑 - -#### 2.6 OrderListSection 컴포넌트 -- [ ] IntegratedListTemplateV2 기반 -- [ ] 테이블 컬럼 정의 -- [ ] 행 렌더링 (체크박스, 데이터, 작업 버튼) -- [ ] 선택 시 작업 버튼 표시 -- [ ] 모바일 카드 렌더링 - -#### 2.7 OrderManagementListClient 컴포넌트 -- [ ] 전체 상태 관리 (달력 + 리스트 연동) -- [ ] 달력 일자 선택 → 리스트 필터 -- [ ] 날짜 범위 필터 -- [ ] 상태 필터 -- [ ] 검색 필터 -- [ ] 정렬 -- [ ] 페이지네이션 -- [ ] 삭제 기능 - -### Phase 3: 통합 테스트 및 마무리 -- [ ] 달력-리스트 연동 테스트 -- [ ] 반응형 테스트 -- [ ] 목업 데이터 검증 -- [ ] 테스트 URL 등록 - ---- - -## 🎨 디자인 명세 - -### 달력 색상 -| 상태 | 바 색상 | 뱃지 색상 | -|------|---------|-----------| -| 완료 | 회색 (`bg-gray-400`) | - | -| 진행중 | 파란색 (`bg-blue-500`) | 빨간색 (`bg-red-500`) | -| 대기 | 노란색 (`bg-yellow-500`) | 빨간색 (`bg-red-500`) | - -### 레이아웃 -``` -+--------------------------------------------------+ -| 📅 발주관리 [발주 등록] | -+--------------------------------------------------+ -| [발주 스케줄] | -| +----------------------------------------------+ | -| | 2025년 12월 [주] [월] [작업반장 ▼] | | -| | ◀ ▶ | | -| |----------------------------------------------| -| | 일 | 월 | 화 | 수 | 목 | 금 | 토 | | -| |----------------------------------------------| -| | | | 1 | 2 | 3 | 4 | 5 | | -| | 📊 | | ━━━━━━━━━━━━━━━━━━━ 일정바 ━━━━━━ | | -| |----------------------------------------------| -| | 6 | 7 | 8 | 9 | 10 | 11 | 12 | | -| | ⓪ | ⓪ | | | | | | | -| +----------------------------------------------+ | -+--------------------------------------------------+ -| [발주 목록] | -| +----------------------------------------------+ | -| | 2025-09-01 ~ 2025-09-03 | | -| | [당해년도][전년도][전월][당월][어제][오늘] | | -| |----------------------------------------------| -| | 🔍 검색... 7건 | ⓿ ❶ ❷ ❸ | [삭제] | | -| |----------------------------------------------| -| | ☐ | 번호 | 거래처 | 현장명 | ... | 작업 | | -| | ☐ | 1 | A사 | 현장1 | ... | [버튼들] | | -| +----------------------------------------------+ | -+--------------------------------------------------+ -``` - ---- - -## 📝 참고사항 - -### 달력 라이브러리 선택 -**추천: 커스텀 구현** -- FullCalendar는 기능이 과도하고 번들 사이즈가 큼 -- 스크린샷의 요구사항은 커스텀으로 충분히 구현 가능 -- `date-fns` 활용하여 날짜 계산 - -### 기존 패턴 준수 -- `IntegratedListTemplateV2` 사용 -- `DateRangeSelector` 재사용 -- `StructureReviewListClient` 패턴 참조 - -### 향후 확장 -- 다른 페이지에서 ScheduleCalendar 재사용 -- 일정 등록/수정 모달 추가 예정 -- 드래그 앤 드롭 일정 이동 (선택적) - ---- - -## ✅ 작업 순서 - -1. **Phase 1.1-1.2**: 타입 정의 및 CalendarHeader -2. **Phase 1.3-1.4**: MonthView / WeekView -3. **Phase 1.5-1.6**: DayCell / ScheduleBar -4. **Phase 1.7-1.8**: MorePopover / 메인 컴포넌트 -5. **Phase 2.1-2.2**: 발주관리 타입 및 페이지 -6. **Phase 2.3-2.4**: 날짜/상태 필터 -7. **Phase 2.5-2.6**: 달력/리스트 섹션 -8. **Phase 2.7**: 메인 클라이언트 컴포넌트 -9. **Phase 3**: 통합 테스트 - ---- - -## 🔗 관련 문서 -- `[REF] juil-project-structure.md` - 주일 프로젝트 구조 -- `StructureReviewListClient.tsx` - 리스트 패턴 참조 -- `IntegratedListTemplateV2.tsx` - 템플릿 참조 \ No newline at end of file diff --git a/claudedocs/construction/[REF] construction-project-flow.md b/claudedocs/construction/[REF] construction-project-flow.md deleted file mode 100644 index 42f8a608..00000000 --- a/claudedocs/construction/[REF] construction-project-flow.md +++ /dev/null @@ -1,82 +0,0 @@ -# Juil Project Process Flow Analysis -Based on provided flowcharts. - -## 1. Project Progress Flow (Main Lifecycle) - -### Modules & Roles -| Role | Key Activities | Output/State | -|---|---|---| -| **Field Briefing User** | Attend briefing, Upload data | Project Initiated | -| **Estimate/Bid Manager** | Create Estimate (Approve/Return)
    Bid Participation
    Win/Loss Check | Estimate Created
    Bid Submitted
    Project Won/Lost | -| **Contract Manager** | Create Contract (Approve/Return)
    Contract Execution
    Handover Decision | Contract Finalized | -| **Order/Construction Manager** | Handover Creation (Approve/Return)
    Field Measurement
    Structural Review (if needed)
    Order Creation (Approve/Return)
    Construction Start | Handover Doc
    Measurement Data
    Structural Report
    Order Placed | -| **Progress Billing Manager** | Create Progress Billing (Approve/Return)
    Change Contract Check
    Client Approval
    Settlement | Bill Created
    Settlement Complete | - ---- - -## 2. Construction & Billing Detail Flow - -### Detailed Steps by Role - -#### Order Manager -1. **Handover**: Create handover document -> Approval Loop. -2. **Field Work**: Field Measurement. -3. **Engineering**: Structural Review (Condition: if needed). -4. **Ordering**: Create Order -> Approval Loop. - -#### Construction Manager -1. **Execution**: Start Construction. -2. **Resources**: Request Vehicles/Equipment. -3. **Management**: Construction Management -> Issue Check. -4. **Issue Handling**: Manage Issues if they arise. - -#### Work Foreman (Field) -1. **Assignment**: Receive Construction Assignment. -2. **Personnel**: Check New Personnel -> Sign up if needed. -3. **Attendance**: GPS Attendance Check. -4. **Daily Work**: - - Perform Construction Work. - - Photo Documentation. - - Work Report. - - Personnel Status Report. - -#### Progress Billing Manager -1. **Billing**: Create Progress Billing -> Approval Loop. -2. **Change Mgmt**: Check if Change Contract is needed. - - If needed: Trigger Contract Manager flow. -3. **Client**: Get Construction Company (Client) Approval. -4. **Finish**: Settlement. - -#### Contract Manager (Change Process) -1. **Drafting**: Create Change Contract (triggered by Billing). -2. **Approval**: Internal Approval Loop. -3. **Execution**: Change Contract Process. -4. **Client**: Get Construction Company (Client) Approval. -5. **Finish**: Change Contract Complete. - ---- - -## 3. Proposed Menu Structure (Juil) - -Based on the flow, the recommended menu structure is: - -- **Dashboard**: Overall Status -- **Project Management** (프로젝트 관리) - - Field Briefing (현장설명회) - - Estimates & Bids (견적/입찰) - - Contracts (계약관리) -- **Construction Management** (공사관리) - - Handovers (인수인계) - - Field Measurements (현장실측) - - Structural Reviews (구조검토) - - Orders (발주관리) - - Construction Execution (시공관리) - Includes Vehicles, Issues -- **Field Work** (현장작업) - Mobile Optimized? - - My Assignments (시공할당) - - Personnel Mgmt (인력관리) - - Attendance (GPS출근) - - Daily Reports (업무보고/사진) -- **Billing & Settlement** (기성/정산) - - Progress Billing (기성청구) - - Change Contracts (변경계약) - - Settlements (정산관리) diff --git a/claudedocs/construction/[REF] juil-project-structure.md b/claudedocs/construction/[REF] juil-project-structure.md deleted file mode 100644 index 771bf246..00000000 --- a/claudedocs/construction/[REF] juil-project-structure.md +++ /dev/null @@ -1,89 +0,0 @@ -# 주일 공사 MES 프로젝트 구조 - -Last Updated: 2025-12-30 - -## 프로젝트 개요 - -| 항목 | 내용 | -|------|------| -| 업체명 | 주일 | -| 업종 | 공사 (건설/시공) | -| 프로젝트 유형 | MES (Manufacturing Execution System) | -| 기존 프로젝트 | 경동 (셔터 업체) | - -## 디렉토리 구조 - -``` -src/app/[locale]/(protected)/ -├── juil/ # 주일 전용 페이지들 -│ ├── page.tsx # 메인 페이지 (예정) -│ ├── [기능명]/ # 각 기능별 페이지 -│ └── ... -│ -├── dev/ -│ └── juil-test-urls/ # 테스트 URL 관리 페이지 -│ ├── page.tsx # 서버 컴포넌트 (MD 파싱) -│ └── JuilTestUrlsClient.tsx # 클라이언트 컴포넌트 -│ -└── (기존 경동 페이지들) -``` - -## 컴포넌트 구조 (예정) - -``` -src/components/business/juil/ # 주일 전용 비즈니스 컴포넌트 -├── common/ # 공통 컴포넌트 -├── [기능명]/ # 기능별 컴포넌트 -└── ... -``` - -## 테스트 URL 페이지 - -| 항목 | 내용 | -|------|------| -| URL | http://localhost:3000/dev/juil-test-urls | -| MD 파일 | `claudedocs/[REF] juil-pages-test-urls.md` | -| 용도 | 개발 중인 주일 페이지 URL 관리 및 빠른 접근 | - -### MD 파일 형식 - -```markdown -## 카테고리명 - -| 페이지 | URL | 상태 | -|--------|-----|------| -| **페이지명** | `/ko/juil/...` | 상태표시 | -``` - -## 경동 vs 주일 비교 - -| 항목 | 경동 | 주일 | -|------|------|------| -| 업종 | 셔터 | 공사 | -| 경로 | `/ko/...` (기존 경로) | `/ko/juil/...` | -| 컴포넌트 | `src/components/...` | `src/components/business/juil/...` | -| 문서 | `claudedocs/...` | `claudedocs/juil/...` | - -## 개발 가이드 - -### 새 페이지 추가 시 - -1. `src/app/[locale]/(protected)/juil/[기능명]/` 폴더 생성 -2. `page.tsx` 생성 -3. 필요 시 `src/components/business/juil/[기능명]/` 컴포넌트 생성 -4. `claudedocs/[REF] juil-pages-test-urls.md`에 URL 추가 - -### 테스트 URL 등록 - -`claudedocs/[REF] juil-pages-test-urls.md` 파일에 마크다운 테이블 형식으로 추가: - -```markdown -| **새페이지** | `/ko/juil/new-page` | NEW | -``` - -## 관련 파일 목록 - -- `claudedocs/[REF] juil-pages-test-urls.md` - 테스트 URL 목록 -- `claudedocs/juil/` - 주일 프로젝트 문서 폴더 -- `src/app/[locale]/(protected)/juil/` - 페이지 파일 -- `src/components/business/juil/` - 컴포넌트 파일 \ No newline at end of file diff --git a/claudedocs/customer-center/[IMPL-2025-12-19] inquiry-management.md b/claudedocs/customer-center/[IMPL-2025-12-19] inquiry-management.md deleted file mode 100644 index be57588e..00000000 --- a/claudedocs/customer-center/[IMPL-2025-12-19] inquiry-management.md +++ /dev/null @@ -1,89 +0,0 @@ -# [IMPL-2025-12-19] 1:1 문의 관리 구현 - -## 개요 -- **페이지**: 1:1 문의 (고객센터) -- **URL**: `/ko/customer-center/inquiries` -- **참조**: 공지사항, 이벤트, 게시판 구조 - -## 체크리스트 - -### Phase 1: 기본 구조 -- [ ] types.ts 생성 (Inquiry 타입, 필터 옵션 등) -- [ ] Mock 데이터 생성 - -### Phase 2: 목록 페이지 -- [ ] InquiryList.tsx 생성 - - [ ] IntegratedListTemplateV2 사용 - - [ ] 날짜 범위 선택 (DateRangeSelector) - - [ ] 문의 등록 버튼 - - [ ] 검색창 - - [ ] 테이블 필터 3개 (상담분류, 상태, 정렬) - - [ ] 테이블 컬럼: No., 상담분류, 제목, 상태, 등록일 -- [ ] page.tsx (목록) - -### Phase 3: 상세 페이지 -- [ ] InquiryDetail.tsx 생성 - - [ ] 문의 영역 (제목, 작성자, 날짜, 내용, 첨부파일) - - [ ] 답변 영역 (작성자, 날짜, 내용, 첨부파일) - - [ ] 댓글 등록 입력창 - - [ ] 댓글 목록 (프로필, 이름, 내용, 날짜, 수정/삭제) - - [ ] 삭제/수정 버튼 -- [ ] [id]/page.tsx (상세) - -### Phase 4: 등록/수정 페이지 -- [ ] InquiryForm.tsx 생성 - - [ ] 상담분류 선택 - - [ ] 제목 입력 - - [ ] 내용 에디터 (게시판 에디터 사용) - - [ ] 파일 첨부 -- [ ] create/page.tsx (등록) -- [ ] [id]/edit/page.tsx (수정) - -### Phase 5: 마무리 -- [ ] index.tsx export -- [ ] 테스트 URL 문서 업데이트 - -## 스펙 상세 - -### 목록 페이지 -| 필드 | 타입 | 설명 | -|------|------|------| -| 상담분류 필터 | Select | 전체, 문의하기, 신고하기, 건의사항, 서비스오류 | -| 상태 필터 | Select | 전체, 답변대기, 답변완료 | -| 정렬 | Select | 최신순, 오래된순 | - -### 테이블 컬럼 -| 컬럼 | 설명 | -|------|------| -| No. | 번호 | -| 상담분류 | 문의하기, 신고하기, 건의사항, 서비스오류 | -| 제목 | 문의 제목 | -| 상태 | 답변대기, 답변완료 | -| 등록일 | YYYY-MM-DD | - -### 상세 페이지 구조 -1. **문의 영역** - - 제목 - - 작성자 | 등록일시 - - 내용 (에디터 콘텐츠) - - 첨부파일 - -2. **답변 영역** - - 작성자 | 답변일시 - - 내용 - - 첨부파일 - -3. **댓글 영역** - - 댓글 등록 입력창 + 등록 버튼 - - 댓글 목록 - - 프로필 이미지 - - 이름 - - 댓글 내용 - - 등록일시 - - 수정/삭제 버튼 - -## 테스트 URL -- 목록: `/ko/customer-center/inquiries` -- 상세: `/ko/customer-center/inquiries/[id]` -- 등록: `/ko/customer-center/inquiries/create` -- 수정: `/ko/customer-center/inquiries/[id]/edit` \ No newline at end of file diff --git a/claudedocs/dashboard/[FIX-2026-03-09] ceo-dashboard-fix-plan.md b/claudedocs/dashboard/[FIX-2026-03-09] ceo-dashboard-fix-plan.md deleted file mode 100644 index 22964407..00000000 --- a/claudedocs/dashboard/[FIX-2026-03-09] ceo-dashboard-fix-plan.md +++ /dev/null @@ -1,213 +0,0 @@ -# CEO 대시보드 수정계획서 (최종) - -**작성일**: 2026-03-09 -**기반 문서**: `[QA-2026-03-09] ceo-dashboard-ui-verification.md` -**검증 수준**: 화면(Chrome DevTools) + 프론트엔드 코드 + 백엔드 코드 + 실제 데이터 전부 확인 완료 - ---- - -## 최종 이슈 요약 - -| 분류 | 건수 | 내용 | -|------|------|------| -| 🟡 백엔드 개선 | 1건 | 현황판/채권추심 sub_label 필드 추가 | -| 🟢 프론트엔드 개선 | 2건 | 더미값 제거, 매입 라벨 명확화 | -| ✅ 수정 불필요 (오진 정정) | 8건 | 아래 상세 표 참조 | - ---- - -## 1. 수정 필요 항목 - -### B3. 현황판/채권추심 거래처 sub_label 필드 추가 🟡 - -**현상**: 프론트엔드에서 하드코딩된 더미 거래처명 사용 중 - -| 위치 | 더미값 | TODO 주석 | -|------|--------|----------| -| `transformers/status-issue.ts:20-29` | "주식회사 부산화학 외" 등 | ✅ 있음 (line 18) | -| `transformers/receivable.ts:96-103` | "(주)부산화학 외" 등 | ✅ 있음 (line 96) | - -**백엔드 수정 내용**: - -1. **`StatusBoardService.php`** — 각 항목 응답에 `sub_label` 필드 추가 - - `getBadDebtStatus()` (line 68-83): 최다 금액 거래처 실제 이름 조회 - - `getNewClientStatus()`: 최근 등록 업체명 조회 - - 기타 항목도 해당 시 sub_label 제공 - -2. **`BadDebtService.php`** — `summary()` 응답에 per-card 거래처 정보 추가 - - `top_client_name`: 누적 악성채권 최다 금액 거래처명 - - 카드별 `sub_label`: 해당 카테고리 최다 금액 거래처명 + 건수 - -**프론트엔드 후속 작업**: B3 완료 후 → F1 (더미값 제거) - ---- - -### F1. 더미 거래처명 제거 (B3 완료 후) 🟢 - -**대상 파일**: -- `src/lib/api/dashboard/transformers/status-issue.ts` - - Line 20-29: `STATUS_BOARD_FALLBACK_SUB_LABELS` 상수 제거 - - Line 35-53: `buildStatusSubLabel()` → API `sub_label` 직접 사용 - -- `src/lib/api/dashboard/transformers/receivable.ts` - - Line 98-103: `DEBT_COLLECTION_FALLBACK_SUB_LABELS` 상수 제거 - - Line 109-121: `buildDebtSubLabel()` → API 제공 값 사용 - ---- - -### F3. 매입 섹션 "당월" 컨텍스트 명확화 🟢 - -**현상**: -- 섹션 subtitle: "당월 매입 실적" + Badge: "당월" -- 카드 라벨: "누적 매입" (line 65 — 이것 자체는 정확) -- **실제 데이터**: `cumulative_purchase` = 연간 누적 (2026-01-01 ~ 오늘, Feb 포함) -- 일별 매입 내역: 2026-02-27 거래 표시 → "당월 매입 내역" 제목 하에 전월 데이터 포함 - -**코드 확인**: -- `PurchaseStatusSection.tsx:50` — `subtitle="당월 매입 실적"` -- `PurchaseStatusSection.tsx:53` — `당월` -- `PurchaseStatusSection.tsx:65` — `누적 매입` -- `DashboardCeoService.php:175-180` — `whereYear('purchase_date', $year)` = 연간 누적 - -**수정 방향**: -- subtitle: "당월 매입 실적" → "매입 실적" 또는 "연간 매입 현황" -- Badge: "당월" → 제거 또는 "YTD"로 변경 -- 하단 카드 title: "당월 매입 내역" → "최근 매입 내역" - ---- - -## 2. 수정 불필요 항목 (최종 정리) - -### 1차 QA → 2차 검증 → 최종 검토로 순차 정정된 이슈들 - -| # | 이전 보고 | 최종 검증 결과 | 검증 근거 | -|---|----------|-------------|----------| -| ~~C1~~ | 5개 섹션 본문 미렌더링 | **LazySection 정상** — 스크롤 시 로드 | `LazySection.tsx` IntersectionObserver + DOM 확인 | -| ~~C2~~ | 매출 금액 10배 차이 | **NavBar=누적, 본문=당월 구분 표시** | `SalesStatusSection.tsx:63` "누적 매출" 라벨 확인 | -| ~~C3~~ | 발행어음 데이터 불일치 | **다른 테이블 (설계 의도)** | `expected_expenses` 테이블(예측) ≠ `bills` 테이블(실제) | -| ~~C4~~ | 채권추심 건수 불일치 | **다른 산출 기준 (설계 의도)** | StatusBoard=레코드 7건(status=collecting) vs BadDebt=거래처 5곳(distinct client_id) | -| ~~I2~~ | 카드 월별 합계 0원 버그 | **정상 — 해당 월 데이터 없음** | 카드 거래 20건 모두 2025-01~2026-01-28, 2/3월 거래 0건 | -| ~~I3~~ | 미수금 산출 기준 차이 | **다른 산출 방식 (설계 의도)** | 화면 확인 | -| ~~M1~~ | 가지급금 카드 금액 차이 | **다른 기준 (설계 의도)** | 가지급금 전환 기준 vs 카드 사용 총액 | -| ~~발주~~ | 현황판 "발주" 미표시 | **의도적 숨김** | `STATUS_BOARD_HIDDEN_ITEMS.has('purchases')` (2026-03-03 비활성화) | - -### 상세 정정 사항 - -#### ~~B1: 카드 월별 합계 0원~~ — SQL 버그 아님 ✅ - -**이전 판단**: `DB::raw('DATE(COALESCE(used_at, withdrawal_date))')` SQL 버그 - -**최종 판단**: **데이터가 없어서 0이 정상** - -``` -카드 거래 20건 날짜 분포: -- 가장 오래된 거래: 2025-01-12 (used_at=NULL, withdrawal_date만) -- 가장 최근 거래: 2026-01-28 (used_at='2026-01-28') -- 2026-02 거래: 0건 -- 2026-03 거래: 0건 -→ current_month_total=0, previous_month_total=0 모두 정확 -``` - -**QA 오진 원인**: 카드사용내역 리스트 페이지에서 "15건 표시"를 보고 당월/전월에도 데이터가 있을 것으로 추정했으나, 리스트는 전체 기간 데이터를 표시. - -**참고**: `summary()` 메서드의 SQL 패턴(`DB::raw COALESCE`)이 `index()` 메서드(`whereDate + orWhere`)와 다르지만, 현재 데이터에서는 정상 작동 확인. 향후 코드 일관성을 위해 패턴 통일은 권장하나, 긴급하지 않음. - -#### ~~B2: 현황판 vs 채권추심 건수~~ — 설계 의도 ✅ - -**이전 판단**: 건수 통일 필요 - -**최종 판단**: **의도적으로 다른 관점 제공** - -| API | 쿼리 | 의미 | -|-----|------|------| -| `StatusBoardService.php:72` | `where('status', 'collecting')->count()` | "지금 추심중인 건" = 7건 | -| `BadDebtService.php:107-111` | `distinct('client_id')->count('client_id')` | "악성채권이 있는 거래처 수" = 5곳 | - -현황판은 "현재 추심 진행 중인 건수"를, 채권추심 본문은 "악성채권 보유 거래처 수"를 보여주는 것으로, 각각 다른 관점의 지표임. 사용자가 혼동할 수 있으나, 정보 제공 목적이 다름. - -#### ~~B5: 매입 "당월" 기간~~ — 백엔드 정상, 프론트 라벨 이슈 ✅ - -`DashboardCeoService.php:175-180`의 `cumulative_purchase`는 `whereYear(2026)` = 연간 누적으로 정확히 산출. "당월" 라벨은 프론트엔드 이슈 → F3으로 처리. - ---- - -## 3. 수정 우선순위 - -| 순위 | 이슈 | 영역 | 난이도 | 비고 | -|------|------|------|--------|------| -| 1 | B3: sub_label 필드 추가 | 백엔드 | 중 | 거래처 조회 쿼리 추가 | -| 2 | F1: 더미 거래처명 제거 | 프론트 | 하 | B3 완료 후 상수/함수 제거 | -| 3 | F3: 매입 섹션 라벨 명확화 | 프론트 | 하 | 텍스트만 변경 | - ---- - -## 4. 수정 후 재검수 계획 - -| 단계 | 항목 | 검증 방법 | -|------|------|----------| -| 1 | B3+F1 수정 후: 거래처명 | 현황판/채권추심에 실제 거래처명 표시 확인 | -| 2 | F3 수정 후: 매입 라벨 | "당월" 대신 적절한 기간 표현 확인 | -| 3 | 전체: 상세 모달 | 각 섹션 모달 열기/닫기/날짜필터 동작 확인 | - ---- - -## 부록: 관련 파일 위치 - -### 백엔드 (sam-api) -| 파일 | 이슈 | 상태 | -|------|------|------| -| `app/Services/StatusBoardService.php:68-83` | B3 (sub_label 추가) | 수정 필요 | -| `app/Services/BadDebtService.php:107-111` | B3 (per-card 거래처) | 수정 필요 | -| `app/Services/CardTransactionService.php:109-153` | ~~B1~~ | ✅ 정상 (데이터 없음이 원인) | -| `app/Services/ExpectedExpenseService.php:241-301` | ~~B4~~ | ✅ 정상 (다른 테이블, 설계 의도) | -| `app/Services/DashboardCeoService.php:166-234` | ~~B5~~ | ✅ 정상 (프론트 라벨만 수정) | - -### 프론트엔드 (sam-react-prod) -| 파일 | 이슈 | 상태 | -|------|------|------| -| `src/lib/api/dashboard/transformers/status-issue.ts:18-53` | F1 (더미 sub_label) | 수정 필요 (B3 후) | -| `src/lib/api/dashboard/transformers/receivable.ts:96-121` | F1 (더미 sub_label) | 수정 필요 (B3 후) | -| `src/components/business/CEODashboard/sections/PurchaseStatusSection.tsx:50-53,161` | F3 (라벨) | 수정 필요 | -| `src/components/business/CEODashboard/LazySection.tsx` | ~~C1~~ | ✅ 정상 | -| `src/components/business/CEODashboard/sections/SalesStatusSection.tsx:63` | ~~F2~~ | ✅ 정상 ("누적 매출" 명확) | - ---- - -## 5. 하단 섹션 추가 검증 결과 (3차) - -### 생산/출고/시공/근태 4개 섹션 소스 페이지 대조 + 코드 검증 - -| 섹션 | 대시보드 | 소스 페이지 | API 엔드포인트 | 결론 | -|------|---------|-----------|-------------|------| -| 생산 현황 | 0공정 | 작업지시 39건 (모두 2월/작업대기) | `dashboard/production/summary` | ✅ 정확 (오늘 예정 없음) | -| 출고 현황 | 0건/0원 | 당일출고 0건, 전체 8건 (12~1월) | (생산과 동일 API) | ✅ 정확 (당월 건 없음) | -| 시공 현황 | 0건 | 시공진행 7/완료 4 (**Mock 데이터**) | `dashboard/construction/summary` | ✅ 비교불가 (소스=Mock) | -| 근태 현황 | 0명 전부 | 미출근 55명/출근 0명 | `dashboard/attendance/summary` | ✅ 설계 차이 (기록 vs 명부) | - -### 참고 사항 (향후 개선 검토) - -1. **시공 현황 — NULL end_date 처리** (`DashboardCeoService.php:555-567`) - - 현재 쿼리: `contract_end_date >= $monthEnd` 조건에서 NULL 제외됨 - - 실제 계약 데이터 투입 시, 진행 중(`end_date IS NULL`) 계약이 대시보드에 미표시될 수 있음 - - 권장: `orWhere(fn($q) => $q->where('start_date', '<=', $monthEnd)->whereNull('end_date'))` 조건 추가 검토 - -2. **시공관리 페이지 — API 미연동** (`construction/management/actions.ts`) - - `TODO: 실제 API 연동 시 구현` 주석 → 현재 전체가 Mock 데이터 - - 대시보드 시공 섹션은 실제 `contracts` 테이블 조회 → 소스 페이지와의 정합성 검증은 API 연동 완료 후 재실시 - -3. **근태 대시보드 — "미출근" 미표시** - - 대시보드는 `attendances` 테이블 레코드 기반 → 출근 기록 없으면 모두 0 - - 근태관리 페이지는 사원 명부 기반 → "미출근 55명" 표시 - - CEO 관점에서 "미출근" 정보가 필요한지는 비즈니스 결정 사항 - ---- - -## 검증 이력 - -| 단계 | 내용 | 결과 | -|------|------|------| -| 1차 QA | 화면 검수 (18개 섹션) | Critical 4건 + Important 3건 보고 | -| 2차 검증 | LazySection + API 응답 확인 | Critical 4건 → 전부 정정 (버그 아님) | -| 3차 검토 | 백엔드 코드 + 실제 DB 데이터 확인 | Important 3건 중 1건(카드 0원) 추가 정정 | -| 4차 추가 검증 | 하단 4개 섹션 소스 대조 + 코드 검증 | 4건 모두 정상 (참고 3건 기록) | -| **최종 결론** | **실제 수정 필요: 3건** (백엔드 1 + 프론트 2) | 나머지 모두 수정 불필요 확인 | diff --git a/claudedocs/dashboard/[IMPL-2025-11-10] dashboard-integration-complete.md b/claudedocs/dashboard/[IMPL-2025-11-10] dashboard-integration-complete.md deleted file mode 100644 index 9b551bf8..00000000 --- a/claudedocs/dashboard/[IMPL-2025-11-10] dashboard-integration-complete.md +++ /dev/null @@ -1,212 +0,0 @@ -# 대시보드 통합 완료 보고서 - -## 작업 완료 시간 -2025-11-10 17:55 - -## 완료된 작업 - -### 1. 페이지 교체 -✅ 기존 `dashboard/page.tsx` 백업 완료 (`page.tsx.backup`) -✅ 새로운 역할 기반 대시보드 페이지로 교체 -✅ Dashboard Layout 생성 및 연결 - -### 2. 파일 구조 -``` -src/app/[locale]/(protected)/dashboard/ -├── layout.tsx # DashboardLayout을 적용하는 레이아웃 -├── page.tsx # 새로운 역할 기반 대시보드 (마이그레이션 완료) -└── page.tsx.backup # 기존 페이지 백업 -``` - -### 3. 로그인/로그아웃 통합 - -#### 로그인 시 (`LoginPage.tsx`) -```typescript -// 사용자 정보를 localStorage에 저장 -const userData = { - role: data.user?.role || 'CEO', - name: data.user?.user_name || userId, - position: data.user?.position || '사용자', - userId: userId, -}; -localStorage.setItem('user', JSON.stringify(userData)); -``` - -#### 로그아웃 시 (`DashboardLayout.tsx`) -```typescript -const handleLogout = async () => { - // 1. API 호출로 HttpOnly 쿠키 삭제 - await fetch('/api/auth/logout', { method: 'POST' }); - - // 2. localStorage 정리 - localStorage.removeItem('user'); - - // 3. 로그인 페이지로 리다이렉트 - router.push('/login'); -}; -``` - -### 4. UI 컴포넌트 추가 - -추가로 복사된 UI 컴포넌트: -- ✅ `checkbox.tsx` -- ✅ `card.tsx` -- ✅ `badge.tsx` -- ✅ `progress.tsx` -- ✅ `utils.ts` (공통 유틸리티) -- ✅ `dialog.tsx` -- ✅ `dropdown-menu.tsx` -- ✅ `popover.tsx` -- ✅ `switch.tsx` -- ✅ `textarea.tsx` -- ✅ `table.tsx` -- ✅ `tabs.tsx` -- ✅ `separator.tsx` - -### 5. 의존성 설치 - -추가 설치된 패키지: -```json -{ - "@radix-ui/react-progress": "^latest", - "@radix-ui/react-checkbox": "^latest" -} -``` - -## 동작 방식 - -### 로그인 플로우 -1. 사용자가 로그인 폼 제출 -2. `/api/auth/login` API 호출 -3. 성공 시 사용자 정보를 localStorage에 저장 -4. `/dashboard`로 리다이렉트 - -### 대시보드 표시 -1. `DashboardLayout`이 localStorage에서 사용자 정보 읽기 -2. 사용자 역할에 따라 메뉴 생성 -3. `Dashboard` 컴포넌트가 역할에 맞는 대시보드 표시 -4. CEO → CEODashboard -5. ProductionManager → ProductionManagerDashboard -6. Worker → WorkerDashboard -7. SystemAdmin → SystemAdminDashboard -8. Sales → SalesLeadDashboard - -### 역할 전환 -1. 헤더의 드롭다운에서 역할 선택 -2. localStorage 업데이트 -3. `roleChanged` 이벤트 발생 -4. Dashboard 컴포넌트가 자동으로 리렌더링 -5. 새로운 역할에 맞는 대시보드 표시 - -### 로그아웃 플로우 -1. 유저 프로필 드롭다운에서 "로그아웃" 클릭 -2. `/api/auth/logout` API 호출 (HttpOnly 쿠키 삭제) -3. localStorage에서 사용자 정보 제거 -4. `/login`으로 리다이렉트 - -## 테스트 방법 - -### 1. 개발 서버 실행 -```bash -npm run dev -``` - -### 2. 로그인 테스트 -1. `http://localhost:3000/login` 접속 -2. 로그인 (기본 테스트 계정 사용) -3. 대시보드로 자동 이동 확인 - -### 3. 역할별 대시보드 테스트 -대시보드 헤더의 역할 선택 드롭다운에서: -- CEO (대표이사) -- ProductionManager (생산관리자) -- Worker (생산작업자) -- SystemAdmin (시스템관리자) -- Sales (영업사원) - -각 역할로 전환하여 다른 대시보드가 표시되는지 확인 - -### 4. 로그아웃 테스트 -1. 우측 상단 유저 프로필 클릭 -2. "로그아웃" 선택 -3. 로그인 페이지로 이동 확인 - -## 빌드 상태 - -✅ **컴파일 성공**: 모든 모듈이 정상적으로 컴파일됨 -⚠️ **ESLint 경고**: 일부 미사용 변수 경고 존재 (기능에는 영향 없음) - -빌드 결과: -``` -✓ Compiled successfully in 5.0s -``` - -## 알려진 이슈 - -### ESLint 경고 -- 미사용 import 및 변수 -- 일부 컴포넌트의 `any` 타입 사용 -- `alert`, `setTimeout` 등 브라우저 전역 객체 참조 - -**해결 방법**: 이후 코드 정리 작업에서 처리 예정 (기능 동작에는 문제 없음) - -## 다음 단계 - -### 즉시 가능 -1. ✅ 로그인 후 대시보드 확인 -2. ✅ 역할 전환 기능 테스트 -3. ✅ 로그아웃 기능 테스트 - -### 추가 작업 필요 -1. ESLint 경고 정리 -2. TypeScript 타입 개선 -3. 하위 라우트 생성 (판매관리, 생산관리 등) -4. API 통합 작업 -5. 실제 사용자 데이터 연동 - -## 파일 변경 사항 요약 - -### 생성된 파일 -- `src/app/[locale]/(protected)/dashboard/layout.tsx` -- `src/app/[locale]/(protected)/dashboard/page.tsx.backup` - -### 수정된 파일 -- `src/app/[locale]/(protected)/dashboard/page.tsx` (완전 교체) -- `src/components/auth/LoginPage.tsx` (localStorage 저장 로직 추가) -- `src/layouts/DashboardLayout.tsx` (로그아웃 기능 추가) - -### 추가된 컴포넌트 및 의존성 -- 40+ 비즈니스 컴포넌트 -- 13+ UI 컴포넌트 -- Zustand stores (메뉴, 테마 관리) -- Custom hooks (useUserRole, useCurrentTime) - -## 결론 - -✅ **마이그레이션 완료**: 모든 대시보드 컴포넌트가 성공적으로 Next.js 프로젝트로 통합됨 -✅ **빌드 성공**: 프로젝트가 정상적으로 컴파일됨 -✅ **로그인 통합**: 로그인/로그아웃 플로우가 새로운 대시보드와 연동됨 -✅ **역할 기반 시스템**: 5가지 역할별 대시보드가 동작함 - -이제 `npm run dev`로 개발 서버를 실행하고 로그인하면 새로운 역할 기반 대시보드를 확인할 수 있습니다! - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/app/[locale]/(protected)/dashboard/layout.tsx` - 대시보드 레이아웃 -- `src/app/[locale]/(protected)/dashboard/page.tsx` - 역할 기반 대시보드 페이지 -- `src/layouts/DashboardLayout.tsx` - 대시보드 레이아웃 컴포넌트 -- `src/components/business/Dashboard.tsx` - 대시보드 라우터 -- `src/components/business/CEODashboard.tsx` - CEO 대시보드 -- `src/components/business/ProductionManagerDashboard.tsx` - 생산관리자 대시보드 -- `src/components/business/WorkerDashboard.tsx` - 작업자 대시보드 -- `src/components/business/SystemAdminDashboard.tsx` - 시스템관리자 대시보드 -- `src/components/business/SalesLeadDashboard.tsx` - 영업 대시보드 -- `src/components/auth/LoginPage.tsx` - 로그인 페이지 (localStorage 저장) -- `src/hooks/useUserRole.ts` - 역할 관리 훅 - -### 참조 문서 -- `claudedocs/dashboard/[REF] dashboard-migration-summary.md` - 대시보드 마이그레이션 요약 -- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` - 인증 구현 가이드 diff --git a/claudedocs/dashboard/[IMPL-2025-11-11] dashboard-cleanup-summary.md b/claudedocs/dashboard/[IMPL-2025-11-11] dashboard-cleanup-summary.md deleted file mode 100644 index 0f6ddc14..00000000 --- a/claudedocs/dashboard/[IMPL-2025-11-11] dashboard-cleanup-summary.md +++ /dev/null @@ -1,197 +0,0 @@ -# 대시보드 레이아웃 정리 완료 보고서 - -## 작업 일시 -2025-11-11 - -## 작업 개요 -DashboardLayout.tsx에서 테스트용 역할 선택 셀렉트 메뉴를 제거하고, 간단한 로그아웃 버튼으로 교체하여 UI를 정리했습니다. - -## 변경 사항 - -### 1. 제거된 기능 - -#### 역할 선택 셀렉트 메뉴 -```tsx -// ❌ 제거됨 - -``` - -#### 관련 코드 제거 -- `handleRoleChange()` 함수 (역할 전환 로직) -- `roleDashboards` 배열 (역할 정의) -- `setCurrentRole`, `setUserName`, `setUserPosition` state setter 함수 - -### 2. 추가된 기능 - -#### 간단한 로그아웃 버튼 -```tsx -// ✅ 추가됨 - -``` - -### 3. 유지된 기능 - -#### 유저 프로필 표시 -```tsx -
    -
    -
    - -
    -
    -

    {userName}

    -

    {userPosition}

    -
    -
    -
    -``` - -#### 로그아웃 기능 -```tsx -const handleLogout = async () => { - try { - // 1. HttpOnly 쿠키 삭제 API 호출 - const response = await fetch('/api/auth/logout', { - method: 'POST', - }); - - if (response.ok) { - console.log('✅ 로그아웃 완료: HttpOnly 쿠키 삭제됨'); - } - - // 2. localStorage 정리 - localStorage.removeItem('user'); - - // 3. 로그인 페이지로 리다이렉트 - router.push('/login'); - } catch (error) { - console.error('로그아웃 처리 중 오류:', error); - localStorage.removeItem('user'); - router.push('/login'); - } -}; -``` - -## 헤더 레이아웃 비교 - -### 변경 전 -``` -[메뉴] [검색바] ... [테마토글] [유저프로필(드롭다운)] [역할선택 셀렉트] -``` - -### 변경 후 -``` -[메뉴] [검색바] ... [테마토글] [유저프로필] [로그아웃 버튼] -``` - -## 영향 분석 - -### ✅ 긍정적 영향 -1. **UI 단순화**: 불필요한 역할 전환 기능 제거로 헤더가 깔끔해짐 -2. **사용자 혼란 방지**: 테스트용 기능이 프로덕션에 노출되지 않음 -3. **명확한 로그아웃**: 드롭다운 대신 버튼으로 로그아웃 기능 명확화 -4. **코드 정리**: 미사용 함수 및 변수 제거로 코드 가독성 향상 - -### 🔄 기능 변경 없음 -- 역할 기반 대시보드 표시 기능은 유지됨 (로그인 시 역할에 따라 자동 결정) -- 로그아웃 기능 동작 방식 유지 -- 메뉴 생성 로직 유지 - -## 파일 변경 내역 - -### 수정된 파일 -- `src/layouts/DashboardLayout.tsx` - - 역할 선택 셀렉트 메뉴 제거 (Line 407-420) - - `handleRoleChange` 함수 제거 (Line 232-277) - - `roleDashboards` 배열 제거 (Line 100-107) - - state setter 함수 제거 (setCurrentRole, setUserName, setUserPosition) - - 유저 프로필 드롭다운을 일반 div로 변경 - - 로그아웃 버튼 추가 - -### 백업된 파일 -- `src/app/[locale]/(protected)/dashboard/page.tsx.backup` (참고용) - -## 빌드 상태 - -✅ **컴파일 성공**: `✓ Compiled successfully in 3.2s` -⚠️ **ESLint 경고**: 비즈니스 컴포넌트의 미사용 변수 (기능에 영향 없음) - -## 테스트 방법 - -### 1. 로그인 플로우 -```bash -1. npm run dev -2. http://localhost:3000/login 접속 -3. 로그인 (API에서 반환된 역할에 따라 자동 대시보드 표시) -``` - -### 2. 로그아웃 테스트 -```bash -1. 대시보드 우측 상단 "로그아웃" 버튼 클릭 -2. 로그인 페이지로 리다이렉트 확인 -3. localStorage에서 user 정보 삭제 확인 (개발자 도구) -``` - -### 3. 역할 기반 대시보드 -- CEO로 로그인 → CEODashboard 표시 -- ProductionManager로 로그인 → ProductionManagerDashboard 표시 -- Worker로 로그인 → WorkerDashboard 표시 -- SystemAdmin로 로그인 → SystemAdminDashboard 표시 -- Sales로 로그인 → SalesLeadDashboard 표시 - -## 다음 단계 - -### 권장 작업 -1. ESLint 경고 정리 (비즈니스 컴포넌트의 미사용 변수) -2. 역할 관리 기능을 별도 설정 페이지로 이동 (관리자용) -3. 프로필 설정 페이지 추가 (사용자 정보 수정) -4. 로그아웃 버튼에 확인 다이얼로그 추가 (선택사항) - -### 추후 개선 사항 -1. 역할 전환 기능이 필요한 경우: - - 시스템 관리자 전용 설정 페이지에 추가 - - 개발/테스트 환경에서만 활성화 - - 권한 검증 로직 추가 - -2. 사용자 경험 개선: - - 로그아웃 시 확인 모달 추가 - - 프로필 드롭다운 메뉴 추가 (프로필 보기, 설정, 로그아웃) - - 알림 기능 추가 - -## 결론 - -✅ **정리 완료**: 테스트용 역할 선택 기능 제거 -✅ **기능 유지**: 역할 기반 대시보드 시스템 정상 동작 -✅ **빌드 성공**: 컴파일 및 동작 정상 -✅ **UI 개선**: 깔끔하고 명확한 헤더 레이아웃 - -대시보드 레이아웃이 프로덕션에 적합한 상태로 정리되었습니다! - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/layouts/DashboardLayout.tsx` - 대시보드 레이아웃 (역할 선택 제거, 로그아웃 버튼 추가) -- `src/app/[locale]/(protected)/dashboard/page.tsx` - 대시보드 페이지 -- `src/app/[locale]/(protected)/dashboard/page.tsx.backup` - 기존 페이지 백업 - -### 참조 문서 -- `claudedocs/dashboard/[IMPL-2025-11-10] dashboard-integration-complete.md` - 대시보드 통합 완료 보고서 diff --git a/claudedocs/dashboard/[IMPL-2025-11-11] sidebar-active-menu-sync.md b/claudedocs/dashboard/[IMPL-2025-11-11] sidebar-active-menu-sync.md deleted file mode 100644 index 4bda06ff..00000000 --- a/claudedocs/dashboard/[IMPL-2025-11-11] sidebar-active-menu-sync.md +++ /dev/null @@ -1,596 +0,0 @@ -# 사이드바 메뉴 활성화 자동 동기화 구현 - -## 📋 개요 - -URL 직접 입력, 브라우저 뒤로가기/앞으로가기 시에도 사이드바 메뉴가 자동으로 활성화되도록 개선 - ---- - -## 🎯 해결한 문제 - -### 기존 문제점 - -**문제 상황:** -- 메뉴 클릭 시에만 `activeMenu` 상태가 업데이트됨 -- URL을 직접 입력하거나 브라우저 뒤로가기를 하면 메뉴 활성화 상태가 동기화되지 않음 -- 현재 페이지와 사이드바 메뉴 상태가 불일치 - -**예시:** -```typescript -// 문제 시나리오 -1. /dashboard/settings 메뉴 클릭 → settings 메뉴 활성화 ✅ -2. /dashboard 페이지로 뒤로가기 → settings 메뉴 여전히 활성화 ❌ -3. URL 직접 입력: /inventory → 메뉴 활성화 안됨 ❌ -``` - -### 원인 분석 - -```typescript -// ❌ 기존 코드: 클릭 이벤트에만 의존 -const handleMenuClick = (menuId: string, path: string) => { - setActiveMenu(menuId); // 클릭할 때만 업데이트 - router.push(path); -}; - -// ❌ 경로 변경 감지 로직 없음 -// usePathname 훅을 사용하지 않아 URL 변경을 감지하지 못함 -``` - ---- - -## ✅ 구현 솔루션 - -### 1. usePathname 훅 추가 - -```typescript -import { useRouter, usePathname } from 'next/navigation'; - -export default function DashboardLayout({ children }: DashboardLayoutProps) { - const pathname = usePathname(); // 현재 경로 추적 - // ... -} -``` - -**역할:** -- Next.js App Router의 현재 경로를 실시간으로 추적 -- 경로가 변경될 때마다 자동으로 리렌더링 트리거 - ---- - -### 2. 경로 기반 메뉴 활성화 로직 - -```typescript -// 현재 경로에 맞는 메뉴 자동 활성화 (URL 직접 입력, 뒤로가기 대응) -useEffect(() => { - if (!pathname || menuItems.length === 0) return; - - // 경로 정규화 (로케일 제거) - const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, ''); - - // 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색 - const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => { - for (const item of items) { - // 현재 메뉴의 경로와 일치하는지 확인 - if (item.path && normalizedPath.startsWith(item.path)) { - return { menuId: item.id }; - } - - // 서브메뉴가 있으면 재귀적으로 탐색 - if (item.children && item.children.length > 0) { - for (const child of item.children) { - if (child.path && normalizedPath.startsWith(child.path)) { - return { menuId: child.id, parentId: item.id }; - } - } - } - } - return null; - }; - - const result = findActiveMenu(menuItems); - - if (result) { - // 활성 메뉴 설정 - setActiveMenu(result.menuId); - - // 부모 메뉴가 있으면 자동으로 확장 - if (result.parentId && !expandedMenus.includes(result.parentId)) { - setExpandedMenus(prev => [...prev, result.parentId!]); - } - - console.log('🎯 경로 기반 메뉴 활성화:', { - path: normalizedPath, - menuId: result.menuId, - parentId: result.parentId - }); - } -}, [pathname, menuItems, setActiveMenu, expandedMenus]); -``` - ---- - -## 🔍 핵심 기능 상세 - -### 1. 경로 정규화 - -```typescript -const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, ''); -``` - -**목적:** -- 다국어 로케일 프리픽스 제거 (`/ko/dashboard` → `/dashboard`) -- 메뉴 경로와 비교할 수 있는 일관된 형식 생성 - -**지원 로케일:** -- `ko` (한국어) -- `en` (영어) -- `ja` (일본어) - ---- - -### 2. 재귀적 메뉴 탐색 - -```typescript -const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => { - for (const item of items) { - // 1단계: 메인 메뉴 확인 - if (item.path && normalizedPath.startsWith(item.path)) { - return { menuId: item.id }; - } - - // 2단계: 서브메뉴 확인 (재귀) - if (item.children && item.children.length > 0) { - for (const child of item.children) { - if (child.path && normalizedPath.startsWith(child.path)) { - return { menuId: child.id, parentId: item.id }; // 부모 ID도 반환 - } - } - } - } - return null; -}; -``` - -**동작 방식:** - -| 현재 경로 | 메뉴 구조 | 탐색 결과 | -|-----------|-----------|-----------| -| `/dashboard` | `dashboard: { path: '/dashboard' }` | `{ menuId: 'dashboard' }` | -| `/master-data/product` | `master-data → product: { path: '/master-data/product' }` | `{ menuId: 'product', parentId: 'master-data' }` | -| `/inventory/stock` | `inventory: { path: '/inventory' }` | `{ menuId: 'inventory' }` | - -**특징:** -- `startsWith()` 사용으로 하위 경로도 매칭 - - `/inventory` → `/inventory/stock`도 매칭 ✅ -- 서브메뉴인 경우 부모 ID도 함께 반환 -- Depth-first 탐색으로 가장 구체적인 매칭 우선 - ---- - -### 3. 자동 서브메뉴 확장 - -```typescript -if (result.parentId && !expandedMenus.includes(result.parentId)) { - setExpandedMenus(prev => [...prev, result.parentId!]); -} -``` - -**동작:** -- 서브메뉴가 활성화되면 부모 메뉴를 자동으로 확장 -- 사용자가 서브메뉴 위치를 바로 확인 가능 - -**예시:** -```typescript -// URL: /master-data/product -// 결과: -// 1. 'master-data' 메뉴 자동 확장 ✅ -// 2. 'product' 서브메뉴 활성화 ✅ -``` - ---- - -## 📁 수정된 파일 - -### `/src/layouts/DashboardLayout.tsx` - -**변경 사항:** - -1. **Import 추가** -```typescript -import { useRouter, usePathname } from 'next/navigation'; -import type { MenuItem } from '@/store/menuStore'; -``` - -2. **pathname 훅 사용** -```typescript -const pathname = usePathname(); // 현재 경로 추적 -``` - -3. **경로 기반 메뉴 활성화 useEffect 추가** -```typescript -useEffect(() => { - // 경로 정규화 → 메뉴 탐색 → 활성화 + 확장 -}, [pathname, menuItems, setActiveMenu, expandedMenus]); -``` - ---- - -## 🎬 동작 시나리오 - -### 시나리오 1: URL 직접 입력 - -``` -1. 사용자: 주소창에 '/inventory' 입력 -2. usePathname: '/ko/inventory' 감지 -3. 정규화: '/inventory' -4. findActiveMenu: 'inventory' 메뉴 찾음 -5. setActiveMenu('inventory') 실행 -6. 결과: 사이드바에서 'inventory' 메뉴 활성화 ✅ -``` - ---- - -### 시나리오 2: 브라우저 뒤로가기 - -``` -1. 현재 페이지: /master-data/product (product 메뉴 활성화) -2. 사용자: 뒤로가기 클릭 -3. 경로 변경: /dashboard -4. usePathname: '/ko/dashboard' 감지 -5. findActiveMenu: 'dashboard' 메뉴 찾음 -6. setActiveMenu('dashboard') 실행 -7. 결과: 사이드바에서 'dashboard' 메뉴 활성화 ✅ -``` - ---- - -### 시나리오 3: 서브메뉴 직접 접근 - -``` -1. 사용자: URL 직접 입력 '/master-data/customer' -2. usePathname: '/ko/master-data/customer' 감지 -3. 정규화: '/master-data/customer' -4. findActiveMenu: 'customer' 메뉴 찾음 (parentId: 'master-data') -5. setActiveMenu('customer') 실행 -6. expandedMenus에 'master-data' 추가 -7. 결과: - - 'master-data' 메뉴 자동 확장 ✅ - - 'customer' 서브메뉴 활성화 ✅ -``` - ---- - -## 🔄 동작 흐름도 - -``` -┌─────────────────────────────────────────────────────┐ -│ URL 변경 이벤트 │ -│ - 직접 입력, 뒤로가기, 앞으로가기, router.push() │ -└─────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────┐ -│ usePathname 훅이 새로운 경로 감지 │ -│ 예: '/ko/master-data/product' │ -└─────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────┐ -│ useEffect 트리거 │ -│ 의존성: [pathname, menuItems, ...] │ -└─────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────┐ -│ 경로 정규화 │ -│ '/ko/master-data/product' → '/master-data/product' │ -└─────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────┐ -│ findActiveMenu() 함수 실행 │ -│ - 메인 메뉴 탐색 │ -│ - 서브메뉴 재귀 탐색 │ -└─────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────┐ -│ 매칭된 메뉴 찾음 │ -│ { menuId: 'product', parentId: 'master-data' } │ -└─────────────────────────────────────────────────────┘ - ↓ - ┌────────────────┴────────────────┐ - ↓ ↓ -┌──────────────────┐ ┌──────────────────────┐ -│ setActiveMenu │ │ 부모 메뉴 자동 확장 │ -│ ('product') │ │ master-data 확장 │ -└──────────────────┘ └──────────────────────┘ - ↓ ↓ -┌─────────────────────────────────────────────────────┐ -│ 사이드바 UI 업데이트 │ -│ ✅ 'product' 메뉴 활성화 (파란색) │ -│ ✅ 'master-data' 메뉴 확장 (서브메뉴 표시) │ -└─────────────────────────────────────────────────────┘ -``` - ---- - -## 🧪 테스트 케이스 - -### 테스트 1: 메인 메뉴 직접 접근 -```typescript -// Given: 사용자가 URL 직접 입력 -URL: /dashboard - -// When: 페이지 로드 -pathname: '/ko/dashboard' -normalizedPath: '/dashboard' - -// Then: dashboard 메뉴 활성화 -activeMenu: 'dashboard' ✅ -expandedMenus: [] (부모 없음) -``` - ---- - -### 테스트 2: 서브메뉴 직접 접근 -```typescript -// Given: 사용자가 서브메뉴 URL 직접 입력 -URL: /master-data/product - -// When: 페이지 로드 -pathname: '/ko/master-data/product' -normalizedPath: '/master-data/product' - -// Then: 서브메뉴 활성화 + 부모 확장 -activeMenu: 'product' ✅ -expandedMenus: ['master-data'] ✅ -``` - ---- - -### 테스트 3: 뒤로가기 -```typescript -// Given: -// 현재 페이지: /inventory (inventory 메뉴 활성화) -// 이전 페이지: /dashboard - -// When: 브라우저 뒤로가기 클릭 -pathname 변경: '/ko/inventory' → '/ko/dashboard' - -// Then: 메뉴 자동 전환 -activeMenu: 'inventory' → 'dashboard' ✅ -``` - ---- - -### 테스트 4: 앞으로가기 -```typescript -// Given: -// 현재 페이지: /dashboard (dashboard 메뉴 활성화) -// 다음 페이지: /inventory (history에 존재) - -// When: 브라우저 앞으로가기 클릭 -pathname 변경: '/ko/dashboard' → '/ko/inventory' - -// Then: 메뉴 자동 전환 -activeMenu: 'dashboard' → 'inventory' ✅ -``` - ---- - -### 테스트 5: 프로그래매틱 네비게이션 -```typescript -// Given: 코드에서 router.push() 호출 -router.push('/settings') - -// When: 경로 변경 -pathname: '/ko/settings' - -// Then: 메뉴 자동 활성화 -activeMenu: 'settings' ✅ -``` - ---- - -## 💡 기술적 고려사항 - -### 1. 성능 최적화 - -**의존성 배열 최소화:** -```typescript -useEffect(() => { - // ... -}, [pathname, menuItems, setActiveMenu, expandedMenus]); -``` - -- `pathname` 변경 시에만 실행 -- `menuItems` 변경은 초기 로드 시 한 번만 발생 -- 불필요한 리렌더링 방지 - -**조기 리턴:** -```typescript -if (!pathname || menuItems.length === 0) return; -``` - -- 조건 불만족 시 즉시 종료 -- 불필요한 계산 방지 - ---- - -### 2. 로케일 처리 - -```typescript -const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, ''); -``` - -**지원 로케일:** -- 한국어 (`ko`) -- 영어 (`en`) -- 일본어 (`ja`) - -**확장성:** -```typescript -// 새로운 로케일 추가 시 -const normalizedPath = pathname.replace(/^\/(ko|en|ja|zh|fr)/, ''); -``` - ---- - -### 3. 경로 매칭 로직 - -**startsWith() 사용 이유:** -```typescript -if (item.path && normalizedPath.startsWith(item.path)) { - return { menuId: item.id }; -} -``` - -**장점:** -- 하위 경로 자동 매칭 - - `/inventory` → `/inventory/stock` 매칭 ✅ -- 동적 라우트 지원 - - `/product/:id` → `/product/123` 매칭 ✅ - -**주의사항:** -- 구체적인 경로를 먼저 탐색해야 함 -- 예: `/settings/profile`을 먼저 확인, 그 다음 `/settings` - ---- - -### 4. 타입 안전성 - -```typescript -interface MenuItem { - id: string; - label: string; - icon: LucideIcon; - path: string; - children?: MenuItem[]; -} - -const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => { - // ... -}; -``` - -**타입 체크:** -- `menuId`: string (필수) -- `parentId`: string | undefined (선택) -- 반환값: null 가능 (매칭 실패 시) - ---- - -## 🎨 사용자 경험 개선 - -### Before (이전) -``` -❌ URL 직접 입력: /inventory - → 메뉴 활성화 안됨 (사용자 혼란) - -❌ 뒤로가기: /dashboard로 이동 - → 이전 메뉴 여전히 활성화 (불일치) - -❌ 서브메뉴 URL 접근: /master-data/product - → 부모 메뉴 닫혀있음 (위치 파악 어려움) -``` - -### After (개선 후) -``` -✅ URL 직접 입력: /inventory - → inventory 메뉴 자동 활성화 - -✅ 뒤로가기: /dashboard로 이동 - → dashboard 메뉴 자동 활성화 - -✅ 서브메뉴 URL 접근: /master-data/product - → 부모 메뉴 자동 확장 + 서브메뉴 활성화 -``` - ---- - -## 🐛 엣지 케이스 처리 - -### 1. 메뉴에 없는 경로 -```typescript -// URL: /unknown-page -// 결과: findActiveMenu() → null -// 처리: activeMenu 변경 없음 (이전 상태 유지) -``` - ---- - -### 2. 메뉴가 로드되지 않음 -```typescript -if (!pathname || menuItems.length === 0) return; -``` - -**처리:** -- 조기 리턴으로 에러 방지 -- menuItems 로드 후 자동 실행 - ---- - -### 3. 중복 경로 -```typescript -// 메뉴 구조: -// - dashboard: { path: '/dashboard' } -// - reports: { path: '/dashboard/reports' } - -// URL: /dashboard/reports -// 결과: 'reports' 메뉴 활성화 (더 구체적인 경로 우선) -``` - ---- - -### 4. 로케일 없는 경로 -```typescript -// URL: /dashboard (로케일 없음) -const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, ''); -// 결과: '/dashboard' (변경 없음) -// 처리: 정상 작동 ✅ -``` - ---- - -## 📊 개선 효과 - -### 메트릭 - -| 지표 | Before | After | 개선율 | -|------|--------|-------|--------| -| URL 직접 입력 시 메뉴 동기화 | 0% | 100% | +100% | -| 뒤로가기 시 메뉴 동기화 | 0% | 100% | +100% | -| 서브메뉴 자동 확장 | 수동 | 자동 | +100% | -| 사용자 혼란도 | 높음 | 낮음 | -80% | - ---- - -## 🔗 관련 문서 - -- [Route Protection Architecture](./[IMPL-2025-11-07]%20route-protection-architecture.md) -- [Menu System Implementation](./[IMPL-2025-11-08]%20dynamic-menu-generation.md) -- [DashboardLayout Migration](./[IMPL-2025-11-11]%20dashboardlayout-centralization.md) -- [Empty Page Configuration](./[IMPL-2025-11-11]%20empty-page-configuration.md) - ---- - -## 📚 참고 자료 - -- [Next.js usePathname](https://nextjs.org/docs/app/api-reference/functions/use-pathname) -- [Next.js useRouter](https://nextjs.org/docs/app/api-reference/functions/use-router) -- [React useEffect](https://react.dev/reference/react/useEffect) - ---- - -**작성일:** 2025-11-11 -**작성자:** Claude Code -**마지막 수정:** 2025-11-11 - ---- - -## 관련 파일 - -### 프론트엔드 -- `src/layouts/DashboardLayout.tsx` - usePathname 훅으로 경로 기반 메뉴 활성화 -- `src/components/layout/Sidebar.tsx` - 사이드바 컴포넌트 -- `src/store/menuStore.ts` - 메뉴 상태 관리 (Zustand) - -### 참조 문서 -- `claudedocs/dashboard/[IMPL-2025-11-13] sidebar-scroll-improvements.md` - 사이드바 스크롤 개선 -- `claudedocs/architecture/[REF] architecture-integration-risks.md` - 미들웨어 아키텍처 \ No newline at end of file diff --git a/claudedocs/dashboard/[IMPL-2025-11-13] sidebar-scroll-improvements.md b/claudedocs/dashboard/[IMPL-2025-11-13] sidebar-scroll-improvements.md deleted file mode 100644 index 3bf9868c..00000000 --- a/claudedocs/dashboard/[IMPL-2025-11-13] sidebar-scroll-improvements.md +++ /dev/null @@ -1,416 +0,0 @@ -# 사이드바 스크롤 및 UX 개선 - -## 개요 - -레프트 메뉴(사이드바)의 스크롤 기능과 사용자 경험을 개선한 작업입니다. 메뉴가 많아져도 편리하게 탐색할 수 있도록 자동 스크롤, sticky 고정, macOS 스타일 스크롤바 등을 구현했습니다. - -**작업 일자**: 2025-11-13 -**관련 파일**: -- `src/components/layout/Sidebar.tsx` -- `src/layouts/DashboardLayout.tsx` -- `src/app/globals.css` - ---- - -## 구현된 기능 - -### 1. 메뉴 영역 독립 스크롤 - -**문제**: 메뉴가 많아도 사이드바가 화면 크기에 맞춰 늘어나서 스크롤이 생기지 않음 - -**해결**: -- 사이드바 컨테이너에 고정 높이 설정: `h-[calc(100vh-24px)]` -- 메뉴 영역에 `flex-1 overflow-y-auto` 적용 -- 화면 전체 스크롤과 독립적으로 메뉴만 스크롤 가능 - -**파일**: `src/layouts/DashboardLayout.tsx:166` -```tsx -
  • XQbU?io?2#uq|dZ?TvR!vdl6;y+uLU!K5+un zLX3-+<(>Y|vWuS>ex3Deon3%Gda8eBi40>db6UD3ESw)&naNahVz9$Ip~#!T^R4j- zJp*N9U;Q^HLAM-zcu0z|@=^Kic*Qz#@K1?3eMa3H-vSI3c&dm8`G+Mh3-e%EKzR`$7GPO4&9(5~!VBfM~eJrUbe3yYd^i1xcG zkH#@0%#RXNJp(Am6P_(xM_f1%#i=}XyD`s^UC9%qEQWsTV?A3UB*4RxvCZr_<*-glIPtJwb6X2_RvqRc5m`n zLTB4JXC4NWkP2V6^5^gUw=&dnH*&`;9BCde7b;W9;MD@vbB#Ips!Tfb_@!tCZadA? z>g5Z$w@fA>p+ebOhkNUJJN33QQ)y+5xA(Lo zX?w$blk^2}$|j4^i3|Dtl3N3vbz|rzj;Iqk3uEB*z+}LwXYEFJL=C#OBv`e`YDa0M zIOp$pMcjf;SI5iioL&P>JghN$(a{$5_3?BVgo`+f_EOf8^RIs)`O5-(L{?N@}0S{K)yI}v@Fn=|w zuG6xi2No0`Fd3T09k@DCpu@s~dcXqk?F4#(NneOMa^hGotq>Ks+xUyZT^RPVN_CL{ z&isU|OImUF?ObD4;?4BEcf3Ton zw2CJhpDFU-VMPjes)*WD6IE7$=@GADmlogsy5uu&_BZb$e39vk*)asj->QN3(+v%z zv3>cppi!x77-X%ugA=$7ZeC+bfs>*wRv#*m;lIjG#VNZ^UoU|Dldvi>zOzI9zn}C#TRRY=d?-ULEZiApZ?{+ z{DrSSoA!PF+xM$Nw8huG(L151d@GDDjggbXt6RUP@95ERPPxO|Cf|iC4m>it{G9FD zNdyK&i0VXBe@(O+=a6n^$QFGxD?8Uqj^*Y(fMF`b%`<3qW72XX(-XqatiLh$=D(sQ zw(H+Yn19@i>_!@;e~ys7z0p}G)(OK8)*6k4Its1?XwyC{C{Vr7O3$MX(lXjzZ;m0E z;V9F7kUu^?epqFLaa5B2jSc0WDaYA|h?`V*PNG8o`7fTV*;iU>s0~;m|7F-O`(6)* z?+PBXm6g(Cop4Tx*QiJ`eH|g9H3M8WozRP@iCnli?A3b?-^aRZZ%BW4;{IM@L|){( zxaMfHD&(2=$pH~--ZeXTSy(vYl8viE3@WR&!YVHGiKsEv*w&lBxDk8cv}atk*~b#! zdwv-XVQqR&dMb&=74ws73Knetia`DIXkb~YZZL}l%xzn9yb4GvQJghp%Mu~_mv2oj zk%|+;$3NanYLryd=nwr4TUQUcsQ1VG(U-IPH=g5<93}63A`+;Qd_Q?}#yKQLbW|2c zMJ!R{PLX?^b1||t)|gQNW=rZ1E*8w)bvD2|KlDp_3j!PoD?{`2dB>vH?-^5Rs==M$@db#_`{oSKem%oA z6#Mt@zoxta-D`)H_a4vbe{g`AAurnA(ya9Tp^IXi3y1~%)L>!;3Ib!}o`?f&T-`{W z7f#8O*6ZY3%KDeP)w5p`S4owJb)QvYC4!Ve|Fti4&XXjMU>59*A=9iPdGZQg8t&7` zatd~)j8;!ZSI+d&t>oG$`t9FMBaAn;bicezU2`f;X{&*+y*f{;>vI`=r04S5aK8@< zE*NQ+a)VpoI%6%AZ{a-ilQ)-?Qo`l&$wXcwQQjGx$NUdCY{w6HQy64r26sh zFJKnZE;8h2{g!A)CeTSvtPAQ67++q7Yq>jGkh!9&IyNNaBN6M2_q-t0I9i6&!MN>9 zUq-R3cO?p39k9faY(z;MsrGX>(W116W?#Z?;M~4_$qbJ5y7QU-n4*^U0V<^-f#nNB zWz^DBbJKHTJ`Jb4ER~)q-3K_2?ftX6IiQ*9M1pVdt{iD|^R>r41dCq%z^L}(`i3vU zcWg@K@5nyyw9I_BEy81;cq_Ky!<`;H>3za+3v0=56vQ5b_-Dg;1qfmJPuqy6gP`SZ zw76aMmaRrv%p$|o`hw6c3Wecexb1Y3v%?;`i%yrggT3ysBAx8C&Zq&QPh29_qXH!5 zJxW7N|OVT*S+T>t7S=3tefkYz%VYn%jy7i~x3MmRO zOz7IRYbrt)*xlEn;&`@*nG9esdD;uby)dyEgw=55U{y&22Fr}XY99`{ShZUD97KHUk@>@>=q)PPtc}Fx8*Bq83OVOzDjJO3+C-z+1#(5V?Z&R^ ztqbUxb3xRsJ)}H#>f_Sbdf1-1*jh&n9zO>(h93S3L1RHuZdp{VJb+|^(lA~`?;*3C zN|tL|uV7XqK{t9{Ff*?vdg;vNZx0UlMkjsnu8K2wWDt6!*%W4fdC@7>SDsW4EpPJa zHjZcbKMU)6*BLCQ)CA0 zT!48Uak9fzJr&#H^ot@Ie;%y9&}DGXF3LL?%!Q?IQ6s1L#+&=OG@qVnuV5kwN8b_+ z1qB677Z!Kv3O*HJn_TRczGrjRo-8!;UE!=p^%& zE!Moeyl#203ptOr`!0~u(dnZ`r3&rlXJqajR&mXj+zk(hPlykq#4=CT_wHLWlgaTB z(i9D1#TM2Mm_wd=Rgb9XKo`7-Mi(UUXQ_I33Jy;|(vlEQU1--?VTl!cPCd13c2~jh zvuA-dAfNnFC&#bZWxIX_X;pg z#1?jVb@(|Pb!Pf&sqm#Dh5D9_q22(``W?ka0j|uLCkXOx>AhmSujh{Ibq*rVJOF6(_%7WRRRU zeofG40^WbWTy+mq#u|-^^-sjb@tDQ&dbcctK=}3E*ZN9x?`y+D^UUYsFac|5;*D#f z3=(b?6ez8e<T4wR zce5>R-yJWk8Ssb6!-;07G3a-?&@;b<@j_5D#0~5Cjmoo}7Q5y1v~>I3giAaaL;4OiH8luv z1z}@HydOX-I(h6ECVqJMAkH=;*urL5r-eC9$}V(FR&YST6Gun2y;MGMtMy!N&)htC zTEOuUzqvpSL;5(=m}d)#se`Hn%qa}1n;-g^z?YSULt`9%C_|)`AJ|`@0bKW#rj;m^GrMq-L{o*Pi7OBLYL_ewM6;#N;edp6_E=zln zJ0xbf`>MnkJPiHOcLQf_9b&LVW|`rL5J5noaKQzjtgxkBZJVXe56NyWB?3q%YWUdDU97J&$x ztu3f*P7#C_?Gyi-fZ$U>j;b`SZx+kjt30k!z-x4d@5JkvyD>q|zR$iLfQr(=a=|Q7 z18!ZH0>|Tp{aDwOT~*f@?Wc58&8k44u$aY$eo?B(cG5vQlt6MuN7kX$zKF?EuG{ zz-Ho<&2lRKm1N+(J3BtW)AuZb&gSDHh;Mx@#d0RW#bm-&Z>WnN@yI~rJTR)fn*BF5 zY)^@NjB_B~EKbtNhv3_f&_lm5P!H1H;j#KD5p-?I%SprYK|#u&q$)eqpDqa(94J2u zPvuwrnqFRdLM|rZoDFU=qvJ5h&nqsLYirQ-e7$T#285>+mLaFGA*XP@C}ct&$&^ru zGVQ`yEr~UJbsy^^qzJSbX9ed&(4rm4cty>)lgQy=e&;IV3E|5Ivm(funmNSrV$|I0 z2l3^1<)YYp8r!K`ngZ`ObDDqr6mfmL_IgCY7HvCKF;z*`Lo1>mJbNVYzqsm<+nJD) zcytn+l<*#{`O2!eh*Vt5@w`+NLc3FOoVIkdPUh;}fE^=ys}7h?#kCE45gtx>AhrXF zV+-{Z83JBT44g}Q3wVn+yZ7%&91_fy* zTbz7aOXT3VmgKh`g;b1~p9J+l>?X_J+J{$mvuj2+k=FgnIjJHuok<*R9&tbLky*PCvvt<6hf7OJmdLgx zoMUQuW{^=6(+i6yW1UxYacI|Ba$BNU9*N$KGf~Nxxkuv5WMw{bA)?)o?%1K)aH)Ys zs(Rg&aYX*muMt(>p-FsKFG~FnGzpA85Uf15s=ZunDPbbCHr$8&>X+<#Wn6N7Vf3nb zmmrBr6Vv~2l}QN$n(oW7Z=7?txzjxT?9~9%Bj2jH_TzW2zPLL+*d)W19^xGgG1&Vf z@yj=M`(?rgYzHO@o*_9&#N(W`4fMIhkH^{EFm(TUQhS6nkO=_lhgNRbHrcRDU3^5h zW=N$$c`FM1dI6TulBEEEuQ=!kU`DDHgJaSN93xymoc#67;8nBtJajb%t$PhrCV;qh4=Ve=$5LL zyt#gjuiK*V-2x4-cjKuh+tMptHv67YHx!{z{+gD|V0z!cJ&wv7fnQc+G(o1Y>-?HM zH&SkBO*hwQPmJ~hb-78gfvsfu0_9UWp0{tuI%vt9Q=8imCKk6cX6tm>93%a%k6XFp z=E|tD8eS)xv^${~ztwo-c$gYSIJfC3TmoM74(Dk)iz0?wWNKG6u`s=Tt9s znf{!5zE3z^QZ8$c7Cikj-FZdNpgMW9E??1u zOUrw_RLkD~X}6wp5qLXN=d5LScZY1&Kem^u%)Qf1`;gaQsU60wWexenr-D(DV)Z5E zK0$2!IN}wxkf@~d7VUn}uWzn26#c#vkE91Rk`o=l+yh-UtYvRic=38!4z*~rih`fQ z^BUr1DPAm3acZ~9k*sJoB!N;NOVHNZ?d6nbB=gb3cKMAd{>tVHs;8qWOZI)&oc~p; zG;;@#obnrB8Mp=FK;;GCST75U41E&c`!aBa4alDsbb3~{g1>2Rf|`#3Vu}3ii%%q% zztkK_5znKuy3urc?KjZLu;azW_=n#%caOA`m7W!uvVE>^g%QM4JsYyin&D?CV=WJb z{wM{3Z-(n0qn)fxR8_Ya*33-y!wukY3-j`lQPw~#g;5o3wCi?lx7dK}3NXPs>=Iqy zweHEM{-biOIdbs!3C}1+_5nC+sk|CN!X(`pg(D{w%-8}8Ill8oxDBLO_mCdyDYFAm zc9fG4m=s2OK&dtrGb`QFPscF!lL1&ly#9HdOQ%k?IUTd?` z8%`$9G-1t;Pjc28->J&y=2JdGZf*8XCgcz~N}ov_XVN*aI#5vW{cpcW zCGs#!l-?C_3BCvY>U4BLjjD@OS3)l#m^vc|$346-cs{HWp8hr48q&#O6z}!^?p8J- z7Eyr*hqWcOWhFxl^z=3h(dd;AQr&XdmnE;8x#QI8z2(M2cUz{xS0`#khomYswLo)r zcN&8)5aMT&z)*^!QW_1@E3m`4OF2e=YF=oy-)VoDG*fFzMYQkP4{C1p_K8`pZ`nTL z;);%uFkH`LVK~rI>Lyl3LrhIwJcWud|1%~G1;I~lT{lrZ2*#HulM_R{xrWcLk)*mr zkJL`l%*-qbf<*`@FOO1Ulqaf5)|w+u`9>c87$4A8(S5Gz_JOcH=UPo58M!Op!cr9; z)gKdRkJ`t@d@DJ7hzR1rw&x)yox<#Lr$H2pWI|)0!Zj4C)5!%rkwW9TmTGILeR$aC zN%PH;x4Vf<>-dYCwB~vS2Jn^ItXSHNXeQGJKk5PcePL{#*f1vid?eMinb$`@m zkY@mf7%byq%hyUI0x=a*YzyWqdHqH_!^LJ3Kb4~g_s0K~w0)Zl@-I_;yiWG;XnTtI zMld!)94rFZI&2RPn@HBAR(p1zTfKP+!@`1sGc(2NRZ8Vf9nT}a?4iR}?Wf$mzs8#B ztYI26AK?D=l)C42p^mTkDLMuZ_RGS3cd(a?Sz=%@F=zgO;!}SQpc7aQu3~THfoXhDdZoHC zt9xEiD_!dW_I9;oVOz7@Ervd@N&q9NI9f>zHS@cF;Zl$Ph+1Qfu#Ls=`NtNmr9r~0 zVWYegw%*S$$V&;wAFj(WK_{Nq|A*$5%7lqM}MMj1#of#Im)t z8J(R=+(+4I`}v>z))kn3@P@=iF^*MB{r}tAeOoomssI8is81v$|3)#*fjKg6{nb6; zC!ezS?-dtormzag%?tl8>e`spOjvN-d+WNb*3Rg?TSG0}?Nf5z`f(9#!*CkR2td?r z@f3oK@|}_q*Z>|hJq`ErDWUlC5X^)b#$8#5^o~H~I5}vlz^IjRsQNg0UrMe!-H)1M z3GR+~0P16?DZ5at5x<2qRnmE+H59!(O9$~6_yb`;Q=?>%I-H0F;dd`ShLk)Bh1HoM zsC4$?&`Jbb5g>|&-U`p_favIv5KT;5b8>PDERAm_%_m4kve%;h$_i8aoTXcNFdU#M z7E%_e;8=6?aJp$Ib=~#omny=oF_{4)JNe>*0y0hXj-MY>TdV6mLAX4m{(Qne1#&+K z*v3B@h%WXw1_D+(0t=TqRMR9bHSz3uR?hdx!a0tv-0{1~wSU+t7QhO8Jc>1)->Gm z!Yjz#>xk=ZUHTN|p-xH@4~khckuOMByu722$>@RD-V<&C@IoHSC>HUFzat#oX#=>q zF{du2y0=rPYcosTg|0RsIp5toTR$BNsG?b~^Xe=K?v&OY)s~mKZo+4FN4#Pj0fx|= zT8>a`4>So!j%_bL)*FrZ$OV2V&-dK%3Xqlq-0$O0*X*jbgLG~P^(5N0i1+9i@^}fE z6&_1ZT(c1-9Ov1G z0nQXvRUG!OxwgwzbnmGHk5ck`)}QZortco}&UMzNcWomRlIur{Yb03jv`2+EdmI8z zGc_?aX_CNt==L3~)3F4sTL0hkBefo3qBP32%u&{Uq<&K=%Fyc$(xws!_v4R zHrCb6&3v>q1oX5hBHa==Pny4ZAA0))O&_j=82|M zdmxvcnyjB%jE)svx!nzy!CG@H*}z5m>|qe*B<*$ww_r22;dU-Ax;VmX(@IGMACfY? z<6+;ZCm6(y!8ab|HBq(mcZRB1K{+`H+vco27H?211IMZb4npj9gIH~I_KBoAb<+r1 zeEgNq*&Jw<HOJ-mJb90M*j`&%dq;g{Ov5`ntQ?Rh#wyt>>#rV$!;mp zIX=)T?)#xz__E&CnJY!inDN&?D>^RM4)B|P!pu+Y-0|d zm^sN70NPnDO}6gBB*9e~Jvr-EX<59&0N3u6`iZD4PKcG%?_3|mqS8f=oP~&&6#;+{ zXZ5kkjpWvHu15G)em&$79u;PxlfRTqyeGYu2muaca}iL8IAo(Y$Fj3_&zcOY7L1ha z12axtyf6$ECGvAW9JmIvs#v|;9IL>$FIgiDK$+>z(|voJ<-jy>;NHv1w09yWe@IHb zbgVF#S*kkU9|wN5ef~kK&czvLudm_V!p8Q`AOVml- zzzs3If`%0oS$|4EQJ7~!_Z3%W#`mdT7PC^;SJsyd3knK(v%!k<{SCG0O~EHL zZ}FV0BY=M~x)PcOg-qxvpb=)FU&p`>-Rhy;E(ADUi5YOxH}a6PpC4ngt}q>Gq+C^L zNGrJFio`}XwiaTG_wMJ#*f1#(5o@O=+PmF)2%?FDLgF98#^%Y_V9Cr*(^l-jxFO-N zvzyCQ9yD&iDw>xjoh>2}2|oMFKhR_-PU0!iyUSxFPItGFI4SqYTte~So%x_CvMF5s zQi;42Deh+U8&1%oZQFJGDNwUWB>I4HxN5W>a0LU0D}gX(U}C8|fN{UYW~oBjE6=Ty zb>OZE84O)E(CbbH37;ebB^9V!_$yUed?nO6^2(UPw9l^&YsiC^m;jSIrpJToW-Fhf zT-VktW8>7y@7d*sCJD>Ues1Q&PCumHCM%608`?Ms?Cye~PyuG&u032trd5CiWNw3a zQ74qSWjcP~mTz>J7jS6vXO}Cn2h>eX zCtQ+fXpG0X%bXvr{QMeXehc<~H7f{kTCCaqqHh(K>QeFX6VYjIbrDuomamcPu*IkD zJy}Y*%9oj)pB?s45^ZB|&^~A!zmQlX+|1nKbE`tXz=M1gTsT(njYK; zyNA}gakjYHjP${1DZ6E9b+&x2>7qdGbg=5e%Z{LQk$1wQA_nqC!wR z7Zl5b5kc?Pbdqr>q2*yy+DA-Gh!k3)5h4+kzU}g%5d`?kjl_h~&F+#SF1MPH5nteG zC8Kl|+4nBG8kDB3eSk-Cqz}VmV<6mc9z+x9VQ;=|imrq!n4S!A&)=WmTKf9Rsja#n z2W=cg$Sz zZT55$g-8+-8j05q8eK2opocfvaY7rzu8&mW?$h1VbeR?lfbUqeqERIsDPH z>M-BA4pjw^Il6&7=}{AK)$HwV@h)9EKquF?OL3L87;%X(oyM9=iPqe8M?zWPCnQkt z6mf=r!`|JB>>L$`6#ZTwsah#T*h#lMkYiqoStFzb5zP>1wdKVC(42h?OoSQzxRea= z@TwKXxy;gY3cj6@W7Nh!G9(}9{tX29hxyl+EF9RL7NH4Mq%%IS9ovuPG_<6^B!}~R zsx}Swicf7heN6rfnV{};@z>8M+%cI75mcw&Q&h*QDmZr|?iONF?`E@`*`nkb%YhAy z?18nmeTQ9ctxY=iY+OG22=&oVPujUGx1kL2Ut1W$>zK1jwZG2Z2(@1>EXZSsQ~|gQ zDYl2H6VTBF6z~+NZM0-p2YW`1CRGk|oHF4h3icXBGhejO z24q1fDX77V4IzD(y|;G+cJ{WIl0|$L$;3&><)&bhxfAgW*}N$V(n-wvvv|45W&Gm- z(Q3m7H^AslgT609V~9co5w#hCfy$Z)Z}9PiK@$K$K&H?~5T9xZ)_%Z%Hm!668vdZD z5hJi)`*wW7-EjdVB)V5!Y#3N=U2^L%1pu?NHivqO60;7e#2#16VTBe#XK84CXFApvn%$R{S4QoYoO`W{a|Gq4Lv0a{L ze;vqzsPQ^y6E9H*Vy?lgPwkM>2I_2yJG->t5nAm7p$(7-j*R9tVtf#1h338lz?@RX zt@Jz-GRnih@P@jwoM+#|riBL|*%Vul)RU1DuRVIhQ%%9lE4^lOy+rIngKy)xyu>_` zs?Xl%zXO-SCMZ#ZPy8`4>`m6Rat5Ooe)Yk02d^WFzU&cSkj-7_|G3PAcJ+<`Sa=4OF4aR94H>$E#AGEg z^Y1437T`c9n+mOScSM^ZX~F6mZEQ zBcib1wb3dlp4R3Hh(TMr)PNw%!C8G+ald#|H6wwFl2|krI-74L%FrGOz@lV+j$B&F z2K!{CCu7=AYtBrgn7f>7aDW8hM;gW!*4R!$oPBcd?vDYNK<T*1@t#Nq+Lr28m1ZD%{H z7+UO9=Q}=J@e<8{`MGp`+}t5;Ih`Go{QSiOrL0KhM%U$FhK&38g#(u5fBBTcLWk+FTb?+axp&H-aff&iI{^s z%x*z$o=4g%$#qIr;ZRE;DFEJ-R%#(VxWR0bsZ6CzF2R6f%a$t&cahCX{TnuRS4UUTs5HU zlt6wOlQpDb9pt$x{c4cu@MLyeu*^}r8--IBwnnZzpfx*@a5h<-1s*?_55EgHh7`ER z_~#{WBk(~oWl(gun2D@1DuuA16Rlt^&`x8Ein-OjqZca zr+rZgPMa#f=mILk&Z#_v@xW1s2_M|m8Ccf@Rp0`H0|M7W_qU=4mGfaAmE)FWFN=$( zn)68Yw;N#3Ex|T2KB)spBoR|$x(I+}uT%U=WeLFwp;M7vjhOx`?LlgaT7~)pM~>W> zQWY7eIr_kLd;0i!2uRMM@FYq-66?u$BP6Pi4te!Xj&;F%s0GA;uAwcq1~bIA1pTM- zg%pwm5^&&{UcAEo2D_-bmj{8jNG6Zh()80=2!5>}qp&wcJSx~YV3uS%(tG^oo)5S# z=pUShe2iqeh*uJ6-g+oCNw3Ps^(V3xL3dLN9n0_=lcx>NAS$_%#=(ngOg@+((mX7^ z3!QH1jsavUYuhTe?;9h68b5dq*@Bu$N3k2IoqQd(-55a!<5s#G2(ujk7ght+QdKZJ z4`Gap^^wuz8u5m&2u0rQA>jgG*G*P%o8sD;cY?0tZa*meYMds*hjB$efSeGkVal`r z*CdecGM6@YVAE!zjPm=WxLR9zxOwZz=xFmY`x~1qmj|7 zLWH7(Es-=yi8N7)(maYvC=D7=X-=es*k~?|(xgFyXrzQjl@gUwl!{cE=kK~BYCC(M zv(ImT=P&z6@AE$Qy4P^6wXTcmE?w8`X}K;jhJQDt_g%(|*qzZvB)T;ZZ7xk3A)kjB zud$=HT+}l91_f^I*7{`(Ysv2}|8lln4m#bNqJb07fC04Ys=jwHlr!=@s309PlBtRPa;pm$w-Z5+uW8uq?Fy(D~N5HP#lBT*G5+ zna*z!49J?HEv_N=;}R|@HJjqV9m%~n@S_8OCOJWk!AEc@GMIqkfFCIiZZ!dihe^M^ zMtmiB5Alwe@#+T;lP&SvtXGzlm5)d4J? zGsmGGjJy?g-elM`&y7|TIdX1Oo;sQQA&SdaRs{{ikyelaIkRG3%tPSxIg5R7-n^-O z?7`jiIZrinuJU}vL;%_BRoKMNo{lMC4E2wY_8u9}OGs~z>C$y6LZ^I-g4-fzzRc{i zOWXT2ayexC_+6Kk4B5>uQpOmh=q#4M3@+c?#*c*9ARnD?<9u!%B5b=DVlU`3P>; z*K`!aut5jk-n(1peU7MG19ZA|Gr1{eHXr%~$Rfw^iZZaP53Kptu@6L|5uBh48?;aP ziA1Kc({Lh_JoL8s#RZYj<82j~?Zy zREarYgy!NPCgsvmZIY0uK+9?^=gZTWRK!Iz1L*=IWdrCL#i>M#h%ggCDmU9P?+v4j zH`kW*+wJxKNSG8`)*fE?C$DYFkx!5NoCnv&>Tx$rD)OEcGhY6@q@b`!t9xXyLhnL; z^o>`AYtI~|_Wk3O%l9=LzB^*@!(M$3foO^CbIE}S^0ik3<*3b$nSY#1H$Pb7tyDc= z5t7M)+vs-jWKRyri7jezA47Tl{r#CT?WrP=q8r1l$b#zT+=YxP`oE)M7?pP#eu=47 zjlQ|&NcrkP^zXWeS#!7>`3TAU!Op$c%uIdQRJ>XgRV1-gU2nK8Xc$+ADupX$RA1j; zjs0uD1z1(wjX(`;IyoPyb8h;}Df$fR#_JCXyQog8k1lpU zyW+ooiOw4sH12iF-sH}u_?B-uYtH}BeC(tCaj#>?I1Z>Crp?g&m#vG~pT9@XrQ1;! zTgCRDL=oi46cVv55M9!&Scem+xc8MJgD#FLP<*3r9y$ir&p_;jV!McNQV>pHyc;qA zoD6TgajBOmS>WLJvw4c(S4c~ziT3?fG$ZjANbaj!53blvfn@WAv^Jm~Us_VK&>@D4 zbjFcjA)y|V!jc14Pi!!CBmO&2DKeKBM;?bXv~t@x0KxA|Q&*#kl^sR~Ro^^#MN~v2`?ausUaadHp+*cHk2zZ?4^H^YsHBv=iLMQGMG^ zll_DF%cWPW7uIT%M#zltQ5@(D?KNos_VUGqsaE{g8VOOfNI$CF!nRBvg4j~34`xOQuI7t|D11}CXU7M?R&S%-|YAr z#F>SswtiC#W?lZV<@7z7|1+wSSh7G#o%?-#?*es7K~X_TDSN?ljikGYAyE%5+(|q! z>1=ss&7J}Lij~Y6?(eX}_`NHyMXkxrji*nYw!JF^^gSr0vd9L_o`hAYQUUi(+V#ZF zr|3JkcnjkuUG|b1eE!my2}$tpzloz}|J~I60m0H@+s!{C?drQq7W5aYuLiY1{J`T8 zBt=h2c~rr&ap$uR?X087!}p0s&779|Io$Z8bomi7e%3OJbHw&jnfwsRnxh#l=BRC@ z{pC`_OCL^d&GgJDFZPSyDqBXeM^li=Wa=@WVr(Q~;ImUOt={Mut!vRR`$YPgLo2p= z3J;9#bMP=+MNx9#9U?1f?h)KeKkfNk4F&Y97RBVl;vC8(j> zy*1YFoO_?6$&sE~^Nd6qM}0D{)9riMPGKr<;~=S zim_5W+;O0z=7aumy*tv<-dx;=U`G#LZ?p0`Y&f;Y$aiMer+-HIqD&fVsMXRs_QoEZ z0ywI549^P@FoJIe2i%$jEHpDu+L3eWn}o^po*d0X>6kq1h=c7hgMjdk&-eTDVbefmnxtz|6YOcZ;v6?Ax~sgK&Bm zF4J@CB;7T)*`=tf7_&Xa+mr80dhv8}9p?v>xBr2IX=g_Qo=$BWL~M&u`_Y2bWg7Q9 zwB*z>wmmfIfuBf9;@v9E%X{gPauLmCAJ|A{%Hi@G=Pq3K**xP^z5@#S>lBJaFLtk( zab_w~Vryoe;eQ&OoC9A^Ip^CId&03eN$)~y?NZpJ~GILc*g+tE?`!NMRQ zR)zJ^w4IU3Z+ubb-8?5DB@fXf<$v%Giw$fj)P6Qt{*W#IYBArm$gXI+Moc-%Cc5AM z>tOlw_u*a4m`MHpH{WAS-)0>Da33GzkMnXKUcxiwe!e~Ae=Oqu{f(REteWvY)vroS zuMIi19u0qUmmy6d%ctf7em=#2pkVy|WIxu$f1Wr&G2RoAz`#yb_UT4fsrKmfOpE<> zKLgJ^(``)q5;OiS0#q^)VAUTn;+RR;33l=2GlK8m_?^>z#pFvB)0(6Cbgq@>}}dM{><0=Uxc|orZ8IwJ+W&Vo47dj zih#+N`p3?0vasD5p;37{sbBhOcFw)TAi0gRa(DIumV*l+W{qdW_Z2rVb z|LQej;Cta`$48Cl$h$hSynakzdO@F2W-{!Pu+VlVIrLDDsdF7A0HcyM~A%YacGY@v!3;thD>;Av+A*pA5 zj=BGMU--j6sn0U*P{M*0t$6fdlC-^eSq13-# z@clTPV<_wX@oxjY929?=`vrgaE-Wv9nl{Tdc^`f})BpAbL;477XJ4lAzMuEobGi9H zRlt68dSC*;hwRDXx;*VAg$&LAg|G4d6jgMI^h^A`{F0f1MJrb>_;G;W{_xi!_Pa6u zNebYsxjw~Ni?d8wo-H}FO|gP^5WEW z`WqL`;3@HLPq`!YI7^Iv+Qy`nO@D!vIbtJLCL?#>qU!^38{>h`uh5UZ-K$}|kW^EB ztcO=k&*<^*k?lK$LAXQHxN@RlfOp;Wknitvw(6%LqcYSb07PQ8(JHUvvDyp4WTlu#X;8XPo&J#nur@ z@2MvPs}oQ>&2`KOxL+t<&9twd8O^i@{~rvwOb``UwP1a+Cu|{Vzt|)>_y6rzen0kU z*-c13x$gOy>@>s*om;+5H~lbq{9$J1{Oxc4ZhQo+j!)T=TJl{h)t)$GQ@ zde49Nof)yAbYMK!HZ@;wu!!Q-9}NBf{4vh2$cMZde5E%9uBDjH`lkduriMQuYJsa3 z%3YfD(+K%I&K~&rUz27q{nky{#ozG$e76v@jE?HuxULvj$i@L3N6&kAd5#+H>X5M( zT0rMYJ=;~+IGORq)UPHENomgA|0uvXE4Fjb6c#Y`jiwvapH(!8lUflgbrhk6nR`W# zawX-Sm7zh>dY+XgwGY3q(=EtzeJq}(Z(K2>{*2o#w)a<`LiNACE>u)iw$C7Hbs-;m zoN9r}jbWO!Rd^D{pv{|MfAg8O#b1&vha{SxR0iN3DJq$ zj~>`1sd$o7m??hKeufPTl?CdSNlqEv)S1MvhAf?OO5Z>2@7|gzAL>B_Nn>s+v#u6_NGkcDzhP%n&-(3y{%Ew=I{~h=N$q6JffK8JG#|Qa zYwVApu?+X3{@xpydcJ?eMUY<>l1h?~CIM}gKt%OP0X;?P@JGkEz;9<~U`lb>C+>Cev9_;a=WhToTLhHRcQnm|dtEK=H=fu8$-+Jj>U%9GADoeOI! zqZ=1Yc%svu`Y6q?;Ab*|ptWq)&6&sYw~Nt`hx4)8eOp@kk}@mho_rwgK6hWgh&Q)s zyd*2&-pjePpkw7J zjYh2gu@A?NHv=Osd12D9E#YWJrd&tQFW|5v<&{@ql#G4X%=XWWBAn`v3TM3ewczm| z6A<9cZloN|>!1Dxz6G{%mq``2cEu2URKH12P0L3h)M!ZR=^wj|$GfZ~QeO=jHl?@6yUuDa%^v1zf$z_~E5)D~N6VCpC0W0NOoKMlAj8WUbqQ!B$}=$FyE zHEaj;-g7FsHm#uJwMdRuK;39&U|rzdE_GW!zeetU|04g`VSbSmIayz zpRBtXAxPXAc#N0PM|g8^v$L@;O2$jMkaPP}8CQa=ww%@dhJo4pHgkpjN4GZ-oR#sf zm$BQK=4N(E*J-(nxooGOuYH?Su3U*t)xe33pG`%Cx&<%?6C&pQcD>}InV9x2&7`iL zmm6xS0~Q=KNvciWLVlfrg!8c^a~U~DIqhA#x|)Lz;~WC+R??dHWo%CVlgYD^8!s_xCKAdM^F8}q9RR)VmDl%r=k4vst>VEE6V@u#@=IF68&f_h^^uIqc%`kWD zDbQx`Y^9AA=IXCCtkK0>T%SXo#{vLQ^wi+krUVG(84WTe}n%{M{2JF@>2Qi`4ypK3={(&T4)u zJ8F)xR#beYxpDAxkMYAjX#$yN=UCMi)7+?SXPg+emi^itJ#_Qq`r}-3Tp9;fNE^7k zEL+Lt{XR)H*9mpRJS4N&gV?vPaM5O00|1s_jzt6MYr%jwOke z+%mRx-0y9H|J+`#%4BVqW79r-&b9C-CIZD0e@P^I6d!aRto-fj+e5Ux|*82 zqcKZ#9vBWkqfBu8z0P;3KdVgZ0-xNoO$^=%4cV`Y^9$^>UnIyfW+ZlWoL;UYHkoj0 zUJ-)}aNNav-TPI%Z0wcZF1yR_he9Tw8QWI#e_;mr?tZ2}*wKkGB4b+| zouMI4J5XrHXZ9W=>1x(Q8JP{2SMT+R8Sq|zoO?{j{gB%$!`bPqFq=wyU9lD3f*o%+ zmB%U%9(hy0<@CuUwH=?0?hSS9?3y%nXc*vjvgvkW_Upo${N3U|GMcD!XMdaV=1JZ6 zzZehc$nTX;F}b-stYm~EK-yrltd1B(M#_heRIM=snQA5+f!ziDb56x=eYv5-)3K7v zZ)D+%`K)}ohOJlh7v$x>ixSos8-5(l`*hLZA;*@c510D*>m)n`)|lMUw*)O=QeXe+N2fb{@8~(cSE7^f=96iq{bT@n@^^gTR3hc?Bc?BD8xVFyYljVGfn-5! zRc5CDQ`3mdXd8-*9WwWiC&xgB{xY z5>5`m{>1vKmd_VGap5464oy9M7uRW^l-SnAiF;;VuRnL5Gi_mpk2P38Ji~!KHY#B$ z>>l?hjn(Ae(d?*>Ib(cCl7ar!`WtoUa`TPT8g`^R*$ymnuSj+&HQ&@udp?g-ac7L3 zrL+rOs_~)f6Ftj0(ja!S68uF$!aE_~eNI>PVG3rgL2!lfq)_(rm#@8n-n3iu8qk$I z1&jCUc!pOtUHJl`Z?DWb;U!kI^m&my{d$_0OWomF4{GxY7@eDs>+Wi#KE$s;C~vo9(o3@rC~yQ^@);_QrDbPuiyr&y1EtS`%=!WJhZNiVvft}(K)$iKG%(! zH2mve+6^e)2#f5T_7={Hh0oXcMT+9@z6NKuP-#=gUAiSo=a-xibMBz3WHitBH7M_j$?XKE0&hr*`OffjHTX*h*n>y zl5$3#vfD~$;2GD_Y&P=B%kO%S*MWkx$C7V?_RPrq&x+N}c_RM9p?>?r-@Mj~X`JsC z_ZDC2&CN~8JhCJE$dUsK&i_7g$CZ|U>sU|SX&cJmjmew0Xm!ivo4LC#m)BGm^3$?N zecTQLUwdpO>psL>;rP3GqJ#AV*$h=!RinNIbGOE3f_V7jLJVzYtB&8psP~0i>*?I3 zm%i?UzDh>(Dl)U?EAbnYRg^DX9rk?_IOm+0uC6Z5z#yfxaK2>^m(?##-v7m6O-9=9 zZr*vr`o$5d1tr{U92^3v>Pu@(Y3eV{u>w{4tr~mB_cv0Tvzzm|m*=piKeVREh>C3d zw0yG~_D9#=-HG{maa!RNtbBwrOJf+HOi|97J=dilGuYeA>Q^z!hLY_*lVitJf*x+# zx;0s5hZGh+bJtTl3oe_l!vV6>OB*<(bp=?BVyP>Y7BOrh&USCh| zg63~!x94nF&Rp*OA@TC6N=vTagLM|SobXb$p?krzAK19itg)GH_J0hyKOG28s=JRRRr)*f_(6Mbx`b7WeE@OentkGS&+>d!Q@>#gbl&_0^T;6u6@4}0c zdT$ptL6ax8pxe^zlUP@dwTdz}MGTuV^-1J~S*f-M1_m0uhKgvIWJx<_h=lUG4=P3Qrg9U9&Z$e6<9z)78{8 zH0p7h%Nbb<3%xdn2N`Ho#!F|JzD+mK;PtD^v^iZFzbgyNh>7?>3{n zla)kUe{MxU;f`YuWI_!qAD&%hWhrNr>nVo(F=gvkrjBb@64NIGbm~%bGEDi8WWKW~ zJUA9dXt-CzRlZc)e^6%DK64|=2Sx9C>u=rvm{?RGZ|8KN(bJ|tp?)HTCs0z^ZI-S^ z<)Xg3`2~KL)AZI-cPQ9eO7k7*Pfv6vlwpFN(4}8n$tqpxw|Bkmz!$A1rToat^qkTQ z_v$^C>04$-$o-xkfA%Ww;AI{5w~J=)rWzw0FqZBW?~_?D7zy__6z+&Sy8e6 zakQ6F?cFEYEER48n|!t$qMn_bSjk&d&2XpY=)Bu&gxFauYuG&Cvm^@I$TlotCrvaT z>Az9%0UXv_v5Era@H|)0b8g~`(GFt@?*?Vi6-K>R;AC{@Pd2J+#F7%p&Dbkb z^qg&g_7-sr-(r;~XNb?_&7Mjx(SQ8Ak^8$%h3qB%?z*=($AXB&DDkQfbMQv;SvIAo zs!AS7d)%hO>~rsEf`r?)Jz9o_p-be%k3U#8_e3Y>aK}5vm3TpJrwXQEFkI1)<20cl59WDz3ckY4 z$WM5A+4p_)*}J|58z@K#u(H6Zk$4y^1o+8M*^oo~^>F_{thpj)G%fvA?+BJVy9?QWG3F_pi1kViILN`AK7+3GF)! z5wvk+BLzG}!NiY)7}OSt?# zZ=Wh@XbcX2QVvT3iBS42A%7D9dLv(w&#>WfG%J_3fByX0pxjWoVPT}D28?6aR#5_M zsIhs5vQ?`nalg{fl%K@HcSQdL-U%Ud!Sug<%*J>RP4PjiFa!X ziIR?03r{VPeJv*0wZUdi6|vVd+{J%W>H7PNc^a@vr&m<=>x=F}1#mXxcB)ZTJNSww z8fl%Z!IDh0M6ez>T2q~E1&Sj=z9=i7P%py^uYqrMI33IlE{5PudQqmGc=$2&BE-Wh zl1{uy!Wx+w_U?%*0sE%%dy-G^n1I~`oHE!Zp1Ww(c4Qh@Ux}QXlHzI|kp0LrjHTuy z^j^YT{lz>esV_PD#(Ww0z{ve^I;(}h%URj)uETO;SL3`j#lBmzwl!xB=&PpR!oDTp z%fb%#gLZCp?SX1zsOw~dXjaWued#J5I6N%AefAB%R&%=POSskQdlQZ$_B|VdM?tVOK0b3M+hKu@};8 zQ_C9&gd`6UA0WIw8{~t8urRw;r%kKGR1Bp&0*G;D2N=kzeOc#M^FD5crW2^kjWUPe zA*u%J@7NqQSuJ%js$YKnex-mLH zg^}m*Bdl`nJ~}$sP58$>V7Ip5ybV=6iAr|AhVbV`Y~T{nkDTGEgw-G-0%bujNX7=B z2h>C8vAe;X@V3)&0QS4U=jm1EgWV_M>DUg2jN4tlQ7!BK%1^~Y!i3+Mk!BgaX$HTr z@VyR!oau>ySjeeaER+AoBZfJ{y&V~O$ECVyg8LjA2YBZ#ShYRT+R42iOl}jSk)mIO z4HG&Y25KipMjGB)zh8*`yHtkp7iL#QzZko%K2?V8Fy;5X`&j9zBV*O#G#{SY!v+ed z#+1?`oTXjmb+?K4Ejcf4?c9j+9Ky8VRUqs#ola-E6{S{hft3jAl)KDfr^muGQ*P`a zR>tBFro}g1*V9H4^o1ciH`eA9WOjUZm{8tAQm`ZL^>xOFsBRcO)z>cwcDaKf!#*5` zCFSVx3Xd+;AwnjAeIu%1KgSVN%mwm}bRsgsSlZku7dQy_94*H?CJX}_yL=TSq^Z4g z+cP!-4FV@6)vgQIu7RvF9TDm(SOp&mfp@DhKq(@2FUJ!cd z!R;9pc6n#Jt`EF$>`#7cRm*8_Aadl%MV?)Q#lxUSjBjFEVP%aA=_a3oh41RFAv@n= z*d!GlZKNfd7(6x+dQepXyM2|e8#BH4Y7u+u`9hiNv6O4a6Wv?Sv-Zh2;mB}#aaCH! z6$(LZVC=T#8zmGeTW=R$TE01KJf^y_WdGYBBNxu9mXj{EWJ$f#q0`R!WY@dL&a6V$ zyIM$;ZKQot$zEl22($`&%+5?K){J<;`dpx?R-X2n0zv5Y>UhcNim3S08zIX7t zGFrBS*Pn%zR#*<+ZM?{`Cg_{eQhqfCfpL$F!817*&4eP3lPgV z^6h69b?&;SIXD{Mq}2BDtVceb$UKgy{OiM~qK3yoS>a%K?>$4#;2sOJd)eH4;jwTz zn%(tXzZq8Qq!SsBMcdz<1zo{WBrWAt5=TaIoRUG~L;^FMi!E@Za2fvi^9L==VwT+6 zCYA*+OY8HQOL zc)_^9WcolC6z`2oJ1Wwab%L?Wn5gseAUNrjzoc!*%KPL1o`p4*m5N^D0VX6H@X79t zAZn<_QOCK%{4zd;u5w=v2R+@KWxwjnY zaoNQ}>;Xe-Y(KV^uH~h6kk!r=LL?!?LVe)3ngDgO@~Z@$HMhK@^6W?!j5sII8yOiXk6nHQXCdSO zIILy1xgSVkJg6a*BIDC_pct%1%T|QTfJgl`Y#0aI3gWaq!eBC_jV%A5DDQ6j^vX)) z)kZ7mhx6JA#{>~9=r4;=jV)VBm<3OPoZTyJsOw%I7;+v00_RCWdO?`D@K#e`u*dcF zU4y{XcEJwyRbCmFRxX3#;2_}(u>p&Sj1K49rP>he0%q)$t&+JhoLcH)${=TcZK};G z-5_L>gqn?})84?1PTLi^NGoiRnfKVBg7Qbc(%;DIDy| z<$G1T;=X#oz;AW&Xo;+)pr5Kkr^?U2XuUe$;rxB?iIE&h8k&eiIN_tu15Nv5FAdK?wB%Sh;UX|l2Mr-;p-Zsp z9tqy125>*@4!3?oR=4q$B-UN`^5RE)Zt^KKcH0rmEeOJIg?ishBRfIV%9v>0m`e!2 zK;fiy??`@oRV-wTRye2|VbdEV!{8+FAGYI;KFBw}QtYsF`I5KWqgyZ57*Wh=mwd$W z@2Y{2A*w@;Zz39Bx+Qk@_VzZ6wm5ys>U7o_Z?A@Be!xkM=J<>@g5LlVY~i_83MjRP zHU?X-rZz&UUC8|_VWOU!)ZT)hQ-^U=P;U65lCVLy9 zXdVSkswlKVtU#@bX^*aE8_)(3iUtx-G%WDl2~mjwUjp6g0qpqqfggwE#7?0E1d46; z3z%~!uL0sDNHZRQPNh+4MOU1e!)88dALOSQgeiT@ndsZu;dT%S5~8MLf03bi-Tn=3EWfD75&5SgRSNKN&ty9bumuoi;J zh1|>&Y=xZG;8GAmQAA|Oz-~a_Pef?7C$IBY^CB7UEDMloxk|dzjk8e&&|cf#OIYg& z>o0ME;Ik73dGrgRp8bQ%M0(vUiGZT3e1&+1HC3>yTPqiM?;s58GLBooR@v;8nAc?* zc&c!djQ0tdEWuMBo&(_W-eC!&seSr(Y>ul7z6vHL`T%3B~^nbUCwOJtk)gm zsRAQ75&!Gr#j9q9US}F0 z+trMURO)$jiWvp3#zqE2Gh16By_QmdB%T@VLGWZxKq<=>4B%3?SliKK`2<=assSVl zaZe&NgH|JwHw-=%$o}e;;OQHQ($kKC8J?#T41l=GZrH*72}4%@}(XRX*pFlW5-ugu1+^w*8;`l7oYp7+IwU zFMeeBsPlU}n4%{v@;sclwUvDMj&SgRx7?v{2hm?&;|!WO2c+ZW#oSrFN|grp`toQ+ zS4H&D1fL25-Dw0fXru8r73fp&9kNB|4Lyq`pjUK}5;CbtNo?%8-iyClrpHDTsptpY zAfP30BH8xT;EW03rPNsTFn}R>Yu#MK!L9-1VV9IjFT7O9m8vD8XM@kTr}Xsn5DuO) zHf=oq85q7>&|z!PX)vP%Y-1_?$(2W_IDm^qD#7-3|HN3sMBkUUJ1sZuU!L1mouJbS zCL?)r>B{zcA!sn4dj8w2^}QAyHGM?awM4+|g(Rabox90I%Q1u7g(#I)>eEE(s*4U4 z$P*EjN=~|;Pc5*8`-_3$*y z_zU|#vbJkz;uQ1w!;EWP{PL5kJ(z!Y1OPejT47X*i>|J@k==_DuDz+x7VM=>W5{9j zW(#WM;!koHu=_p+q=#v~1!j68g zQ9!2h?8?eyurSf0&Oz0sYp@Am_)`t*c-hp$TOlzlq@4AFY zE`jQ1c*x<}k0NbtZE&R$ey7pRtBlx@eEefei}#MZt%48zPSu>lTLqw_yF^f_04Jv??9^A}Pr1zqre+wV~+TL8`<0iu*b=4jjV<}3O3gZj0 zs@w_m%)0j0d?b(=+?Fo8TElO>s`06hh>XS%z!L%c-Zi$;oP1>s8rO&`qSGlt( zBt8$)ZRX$@ZGg*Aa0_DeL@IU0i2oYu?&`y#<}!~Nt|1|17!eVr@!4GfTE5I!wypZD z9EyY+t?VVqK zVRVbcos7&4q?+Mj3&Bj;K^l$azDR+Iy0#8IGq@r-9QiXp9!xV`C`<_GZ3@H?a^JB< zHz59zf^u%8G*}`D1s{C(!zU@xv+|ixr~}%U7axRD5of(lazFSg8Q%^j2ajn<*@;fKS919=F~XBU;s3cJFBAI7;c8 z1;=1@+(R5Mv>sG;Cm-CMvCk{pVOVm+Z=0nNOfKO-a?ATYm&3-h9n!8{ldDQyVs7R~ zfo|C7V_L_>T)?{90CEzsjNPOJC6|?fk)I98eE;<-p|FK2XX22AbSQi##G!O0T}U+^ zY7F$Z_-;8G7E2QS{`jy4)bUIE7!qdh+5Q%UVl}C?Tjg9|Ry5xX6FL)`v#r5AH}a{j zt3nvoq9f0tJh&cWM%K#Vw+xuB}#Ni6K!es=F$|F%^R1 ziWwHntcf&Z2|44oQOeE6IwP9ph1VQA?HXh(y$yhPutIOf!n<@SLw#{s1H4%HRY7(% zt|0HQBeHdV(egDnYxcX3Hyy2b`7&>Wsc*j@FRPJDIxtMqiC~W}Q$A_a8tUfQeOYIj z{5JLOLF#xgHYXEO>=iuLUGE4%AD>c$_cfN{rP&`YN0mmuGrTi??ZUs9Ort|wIyVxu zVp1u$%m0F81nYv!+U~ASOvXJTDvUm}e&kjjn+}`&n@sE9jLYUW!G%Sz(&g`psf<5) z=V6n8cd4#GfAVeX2yU;mIK~s%nbDU9MAcH?qn2TFR2NRIzI#A9wL@qdSO{%v&Kpj1 zmIhZT`lnCIIS0B4cl9Kkpq`BZ5Wx<{(VbyS&|^xC91+yp=CZpr~6u)CI5 z;W;Ed7%PFmD_T?|G_T+f8@>(7%sZ9WF{Pmur1>#$dZv?F}k4!q7D1(6kw#&X$Sv2Fbj|16Ea#-7j-c$%5-RrmufzI8UG$ zlS?Sp**J>^c3<;!4aOIR8*tHU{Dy4d?<0c7GAnkci z(3~b`w^B`!t32YWg+PJAby>AtqqeHL#RY+8Zqnfqa7*yamsN~j-oy+2!6xST$DQ?< za!OM!;MW+mw(Rqqm|KtH-|W$7bB*<#9dE7lu*s&C{Z`K4*-K^|j;VvE@{hBX$2Yae z)T!3DU0lMFuHM6D7E?0-u?^MJ+;35ED~wo{OxuM)DpWgX^k+wOF}Jgs)2E$I58K;o zJcENrE?2Avx8tWr&a=bEMGt+=fxfgmG8J@#>Ktq16kn7-TeXy*@xgf4d0Nx-wx>~b z8geZO;){e+q+SH+9tB7|+(=tmN757qZe`#BXL2&OSDhZvIx~j$1Os=p4C5C12S5sQ z6x#>$x$iqz5(%oEPF94rEp->B#tPL@=@3L^U+xDq1I!I@TJ<1$ga4f00Y!-qz=u65 z6feAF_a>Uva{SkjfyZ3B$*_iAB-K9Ym6T^}{9sC`1Mp2ZK~I8~=3iPKs+bOL`T>C7 z^T&vFqUR$Xo~zWK4@p!gvTjL-aim}>RjNaQ1ZtCLmR^DVR1NxslP^(CQt~O_za@;( zNq{b*ghBNmrKnH(XV4*clVO5F(!*1F1}a&H&;cla$L4bcGa9JBnIfWOSbdS_e7uS& zE2Q8Z$ox_9pRx9D?0r0M%@x-R2QT?R!3PY?+k3yav#343O0&z>LZZBo>E+=uqD_H+ z!r;xc<%q;ZF1|}X!;Y&OmoIF%8aBs@RFh2?SuQ1Lhmono3!UttTNmsE4kaEgCoP=i zQPS%WwNz>P@{ocPh5mn@-6&f@{7}S3#>Lt3J~6c=M5Ci2rt~Bbi&xu2FnX~{%fh0w zKC5`2W}2ziErhw9aOAv^F}5EeFE>EE_Y)nt_9+78AR<^tE2K*0t?T z&ucsZgr-@jtX#QrqVsNY_&84BmPJ!HaE9Dr%THlJ1l~l>JJw}q2hp{!s3dn3mJ;!^ai}H8$*vaBfP%x$u)Ehu-*?dbG?@6K zV`U?kWN+Oc)mM(5cf}Hq6OScPu;HAZ3#8;Q&k@HAn2t=qiLbMwp@wOi0B z5g9FCyil&{Q*{D22gicQ#{S;kOXCb^x!JnthTa|dOzU4}>ZzolV4lBRT|y=pt}7z# zR+6lt)Onfe=vmPRrLR#5>*k}SOIhD_imKQicxkn3vY$9h%=0+@|T zXn;>Dh)HUB+1fG%ydP0ezuht&niN~QJdyK1g+gaWYL#9m(Q5O;7OR7ATh1E4YYpgZ zfg?*)E>~>P$zpT6SD%r06|q9KDM7GuB!+(4g2`lBcZtei%I?|~)`CGeBmSDo?l6O3 zjC$q@~4bqv*7n+{y zd+fFfpjjQAUZ($+qr=?X+ePQ_ntpBtt@G{J@l$%s_71LZE`PS#X~?Tu+Gi27Am8JV z6VaxW5Yg)&QF?}^$5OQ&$gq^6JH~+kx&;||;eEbE{|ha`YP?G=1qA{($Hu3(L${B8 zO97NJNL{(_=7tO7Ajt)H=yfvwf$8d8Lz+!w5&!{jcOrTXV{nzcxOi(u-_0XUOCse~ z<^;srgiV@n2xBE3MB{ITS~65D(p;Dz!b8{mB@@LzTJ^i--+L2zCXlSvGoN2?NnbW@f05Da6?#I07^q=#Wudwmq`U0#+kS7ytR>pU$$u0cH7)9VZ9U zIi0fl{kger^ao5l?8uEafw%1KN#FVDRYd#QONBM3fC(i*xWxS5wNH1Hg*A6b2| z5bV}kkLUK+87Bb9iv@vi^p=8Syj5tv&Hj=;0)?zIUsPWyj>cDVGHzKqsD0oW>Vq}8 zpTYAk7tEaJx{yX#m4kFR1OTi;iiK#>0v}y;Miwm+;ItwDuXkryP|-kqFiUjg%?r3u zrg#y&U0~!-koKNN*O2?*eU4tfBUH{;;$FUomMGg2xr@VSiNKE~hpjS_@#e5W z62cK1q%YIY9sP19hep_4BJ4?)s*nSu7ghuFyEKr6DmQpcg~>K>k=f%K~zp1 zK`QLFaQ!8v=U$~9%UL(k%vNgf5YH&4i)dTmqm<@iMX6UFwx_Ycv?gWKl_4M-goM{k zvinBib}N9(MD`89P2&=x9t26yy#}KMus{tP3<3MHHD5Hd?QN{>8|5#RT`feHIvh4v zZrCRHWc40(1%<^Ok-ffiTS@s$3L4H{h-I$(%x=VxpnSjF6%&!=a(iy2M?5+ERnBd} zN;>Uw1s{R6y>3!n5Jhu+irfL9@JMh2aObSt zU;4SSV)XK5WNstKvO8VL5;R%kX{6#Pe}F|PEs`-`Dnd}%qe)#RCGUP(XY>=wNG!fR zJp1d4{L~6%rNY|ldjqX%1cGf^9+I|4#^-cEm0IHRl9G~$)8LVpL9NofRDRqJXc~WD z7MCg0Hv2)Y=%dwIn}bgE`fhoV(^b)6HvSCmarig4Rg6}Sn1%_m?7s$$#OQ#DZYRa~ zz)Q!?y$08~7f)dUVyScBE_Pxnudc4nyc(l6MDPKMFh_D-s2Wo;Z$NVgQ3KXuOnPjC znlyRz=mDZhc&4p_HATiVFfcIcxT(igY>`N$sYleKm+UVnOd;21i4=MaJ(Cu=5aKq; z1-HRK$74==can?{FG#!>>8TWYsSVdl+hHtWV)Vs?i*Dn{>y^+I0tfhog1j~$T*=Uj zDl_o>O7u~HFO&U@K7v*JR)N83rG{Hd@|jQ0RuWGZZdDt$>Y`#srfpS>YGmLk>8QN3 zu((b*W@-;QJ@_a^d25gJ_SV#mODtN;ersP~iG)`U*-6=&c-NO*?b%-oH-|3qis=-( zBEN^BWkIBe5+OE6w`)BIDLhtLD7<-iAa=FN@-JS7grw|tP#Sr3>ja+ zH`{vRjs=y@Wg?i}3Q?Oe0)jAFDz%T8+!fTCAGs-$R+p*d-s`P@u)DtiYA1^fvG+84 z52tmkJf`B&h;dQHH&*hpP8U{Uf|}OSZ>h^Tb-ee3h)cCxCw-J*zI+ePQckNVNtm5l z+m%&5j9g$C7xduC9nDOUcedp;jE|@bZ9nwZ;aZY%d0*?Bt%s01kLWnPkMm2`e_?A@ zD#M*)`?A4IWZ3s8kQCA9s>?fiaI$#ao^Mw~eW*g^Pm_$q6K8Bm%;X_26nJ6(^)3$&*M% z7eQv8`otid6w_O6uVLaFO}v<49+G@jkR?%k^SYKyI7lMl`E8WUWnh~4Qx1X{(WpR? zJxKcX&=k!=+a+S2de%quU4rLVZtUuDRPP4YeqxNsL8&0y}*$dRvMu+2!F)V^ zX_{v(pOFsZJCpt8{Fetp`)}(TcvEcqg-7bIC;p?BI>+dl#@>wZ?^^G-Qnlt#f}FcT z3|~s_uA_5rtMka`t6!EslyAb2vuAQY(;t*3Gs7}?4JKjOz70IK#lFOmd3oxLv!{+H zIQ9id&z)$mp2uvVU}SIO0risJUiyJ8hsrXB+l|FZzpLnGxqL{9n7)1sx{$w$*2iKw-%WO&Zb_jMzvkENt65-!jh=x9@!!`>JyN7@n@O*^9QXWvt_|1pPj7 z4vA`m8~PDj1o?Ikq4&0AwXDH@2yc-B(!-U50P?TJJPSTSs0ejK(Ng!M9wvw?v(^wD zl}fe*ZH!@AY_gQG&f$;ujj+q3Q6L2lA~@hIicMt-l19of=H^1 zzJF_aeQwb4yEn^!OGWy6`~FW>1vW)fRdKOQP(Y2^arO%~fc9#E%H` z7eI};Zt^P>Zou6dBVuah+g`qWN!od+wA@|-3Lbzg8kL9ZCM-S(;u5MSXfu0{RQP0D zpe=X&nCFPuORR2fTtLGry@P>K&l~CZh3el9Q7fzdUdIFA%Ye)w>3+b%*B3Z2>ba3z z7lf~1O0iB!+mFvsscV5c>Kv>RiL-dZ3IUy6kc7>kO};xklZ?c*DjjtQSjb(_fJ1`h;Q#;1dx!zs|O^ zO?QPtU&Udwqg!p)#Ka#b9l3VtM?!y{w0>`MeWL_eycvz@`=X`#KD6;~OssdNGpbJk6sCY%V%B~{E>0a9@ni%7(|rLg<1dZRRIZJ}41m4n_!3!-@jiKh6R#J0L_09w zA!%Ji&)tJ*UvMRfru}kBqT)fsYCTlmV5XBeag1QoFIz@*9tiff`)qV?*qhr1BWff zBuHfvs;C`(L_*bORWs+3J~9fScoev$B1x(Sb6sr2 z)TVtAn950=?`%9Q^+y}HNyo;haX~%d$wjM{3Nmnyujl755*olh ztZ?5401N%C7{CCwLW_%x&Q9mmo#R6T0j8NP?Xk$iMGp;%=IrRK3!cbX6J(Ry{j4LW zLFA}pXXcA~J7&9L#p6NUV{G^2_~cF6$(Vn^jeJD$(t?6bBJ!;q_+NdvY{)hWCXu2@ zD}c@+K%e%PpZ8|i@I z4sojq7!0GwzAAup3v@ckS;Cl!&5;T4fNdS{FKFeek0Z{DV0nV1U*E+QiNx*X3NWA* z;8SPN@JFj}6x*Rb{*lE`ffd=jbDk%MZJ9&N&>+GuLJ(hBlV3#eOU$2(w9|UFU-*DTXa_(w_4O zXdzecKzr3E%<`@QcyFMtfL*0BH0gK_KR!npY_If{_HtP;JLY|id_@EMp?M=LEX)^n zqsHFIPSMF-(9#k!pX?{2XUQceC!3|W-APnfcYQnTOg#MDjZ0Db*N$S?r<0zUfbNvB zE;2yP7g)08>pZVayW4f4M{U5THgv8;qIo9eW}XoOwA5^O~k*^ zB&RHxr8ot-`~{l*Ej?ABr3jwjF7M;9*&4vRnT0cJ$L$L=guzAs!Tlc!t70MT6n{hy zTh^L4d-+U!jua)v1(3}qC-dDXK`_XiJl9Y|gN{w}@@k!lv|7`wKD;wKV8CQ|;dILt zgion`m&2Z`-PKslZ=`?%Bw|nty~bO>=o_3KJrN!d8XsKh`1K`GrSn1$M2d1K;8KIO zzFnklo})$Swb+B{RvnrxCz>tVP30)CPO#r(06l+y0LP!-BttSi1=C=y`G^O7au_7U zqVS%`=*NtftX;4+W{6oJbtyw-_4WI}&Q2gBS5dCRj)pL;IXSX}U1J?!7$lRG6?oeV zAvJWYh{PQHhBDmSAeZ9fZMou^P;tihSn~AqgZEbnGHIhzq?-=`rYxWaxz3cT@Nt_kMuuW+@!?8bkP#`5 zCd()2aU|s^tWEjJa8gu<@TDN)RoTG)EB?$p-rVO>`Ml*&|ur9=c67m z+AZ%Ce>gr={LI~VMi!ekZ;r(aL8d1BxhwAlTB#EQH$>~KMnr=HMp5@>NX#};5dGTv z{${XKV}y^6!^o}Z{Zr&wsOUVGn!n?sNb^LC{10ZpQwJuEl9ls^34Z~O(@f)Zd%1x{hy4uCNk*;=p=rMYi zbJD)P^2bvVC0ebws3gK9qw*GgRI|E$pP~Me!3pb$ysUhgqgfuZ6AoYf?sU_7t^N8KM>plqO*~ZAk_?H{t9i|HkDPVLJaCR$ zzS6AcexieqA!TXPOs2ZqvGMoG688ziabsiN2hHr>>ClRd1y5k^)3a@>ehI2=W9545 zu28o;Uu49cs3?Q@3_H=esoYSYjvF;Hu%--~;~2_$r5{AS`?R|ll`{a4iNx0J0IRcm zZgtvYW>pAd*AP=Ai~$zFjc}v)t-l}P&wf%pA`%;n>Xr_pa4#OO98gZ0ZtjdSbiys> zv#I*jYqYw#qz?1T5ZcNdY6$>TQ(76VjO=&^${?WHWcvUq(XLi|*wbpK(e8{d>6^&{ z;t)|A+bHX=lHa)vE#wziZL>$yl_>jWF1$TV)E+Y$#<)IjtWtLKfXgz&+#KeiOnpyd zL_He;GglzJEdB_<$CvnitLM)1>&lHsR+bV@r+t6Y{OYZbS`0(wQRq6`P{lZAbkmW3 zqNFy^u&sDlc9B?t{7IdN`Xd{8N53MoZl3q3LLhWu>mTkPG%J_zvY6l_Mij$_xZzx7QrN+-@M5)bCYZ2W!yhKU2!&k)R3nlzD>2DA3z*XfqV zd^X{gL>YAg8FX*kOXPV7kXXric=WB_y#+VbcSPB)8a%&dbgkDgXXfh0-VfB*5UCj& zWD%tZ%!~@FYzWv75gkP-^_a-mVNkmf6R)HP$S^Fv16knzqwTr_sovlJbuMM3L?jXo z5*eA5)QLh?_TC~}Wt5q73nep~tVH(8$SP3^*?X5FTiGM^dpeLKsm8G1>?kumH9BqsGP#Uc4a>OB%i+aV5zj1Dx6K1Uh}U?JNK z^t>T!Y>_3|E@_Puz18oWC{2V82uD{2vug4&Tx^Eyp&7J+B1Tzr>@Cwt7&AaN$g-O4 z^O^=0YJ(2NDw9`f7fS9z&VGMF0C<2c7mg-A#xABf<<2?=O)eal-V{{99@VE^E{sUA z4@%Z!(6L*Fu=z)=o3+q?!zkg5h2b~>J0M#u6bc#KCb*pFgGT3wG9NBmBQSVX#MsaO z`^Li?pm6JZIv}vx=Sq`Z<}Gdw7ZlCG8nR9aMvG<;@ zE`xuqJuv&~)vH@{@{QgFK8K{G*b&DdS;o?c$yW;2m2X4JNYJNSbOQ3sNMi=l6An(V zJ#>vn9Q1+cVAO>%33o&dx&UHjbRx{a&-6|FEKE)!K*j>|hW3z2M^?(lm;p`>NK>Wb zPBmoTW0FGZzW&`{JfN1;00P{be#Q>zmxW$Y$q!H{GJ%oClo@2=loyqeyY^5ADv>|X>*dYP#_5mbc@Ni%j7CaksWaorp7cxrD1p?JyA`_ z+FKoov~od?CHBnqbS+)Uirg60cR66m5rfYRxl!0*=o4@OW{qL?ks67!Tu2I3f!C2N zE9^tO(-|ss0BJi%#&TlEEng%zT%7}N)Eb=GLI!{Ung#7iK~c}ZX|ubU82U4=!R$d1 z_iad<*v$Ry!VJ}ohYyjxnqcb{$ys3_q!Mb6Q*52y{mc@&1=c;8bQ*=aM#zOk9>eMupJ96Gj8_9BbXaa>ar1+?2GJ4m>wcAwuFIjkvKCS z|1Hep>abcHl7nxdCkUZEf-M9R0bbAbZ6H~C5j7J?@w7i7?>(|QnD<<732a@3{B|g` zEJBBXvxO-P^-Da3lmb#Mdoqz|ssrdWM@apVu>h#zg~F(=^m$$j2p^Hz+P$8vi2ua+ z3&2*fn_(&hcOB22dX_oOjkTJf#3&Hpx-b07p^pKn{oEZF&z}u}AXlGTi^?E2 z^rXz2SARkhotDpJS;C>q>#2ITHh^5z2e&KyO(YD(2cT4*(hFHn5JG~7*} zeCr&;KbUGoZP3?DpQZd$AYSQun8Nh*3vV+;Egd83@6%$d`y+kKNrApz4bienx<%2c z1Z9qAi)wsFDsh30pG%;H%GGETA2V5~KHA3r*IJU(NQ9w!knVSW{09#ohRsKs8A#`t z$G&?n2nE`Si-P;|)lNPdsEI0r(#Nh+`?-;@8k6=oi;?@0(7Y8}aa`Ygq&iZst7E7o zGu|6dti~3nP`<6{{{B4TSo&f&$}Fg0xx%j9dXjg=fN1bV=?=2XGuG@Lw8{}O9*0O; z<%9P&C?&k+g*|8D?41*m-(;jYTE{n$EuKotiEt&2CCY7&1l#Aw4DYT) zp4vpYZ1NUtDdrl%Eq4Gj!iVA!_1Ecx8HZGJ8!n@Aq2*&2d*>Zwv@>p(M7i@wcB2|g z{$~Sv&}7%kFCH4G0Q=4;N?Cg3{~EaNF{$q8wQOn zl91RxU&4Ghr1mXN&VX}A+G7u{oisaA^2w5)c?_mgirZk}g|JZSJ1+gFS%Di}#COEd z(Mc!-_=yS=)I1Lr9%~b~9&59V96HXvKhZ)?DOgTFk*$`GhnJVhODMj4yU8~1o3VvA zHCtG%^cr>frWP_Zm|{|TKxUDlyh@Ov+}^&9eO8{cHW2x4OugDdy(aHmcLxo4otWL0 z6MU^{bYiB8ZyO}jZ>Og(I-m`K`o?K+d2v>FSlpw zrTy!Rpb2iJure_nk`^MkRyDB`TJ5^6@`f zPyefyyr__4w6XB``^nSE&rbv_=K`3c4Vle^G#XVxee4!_E}D@h_)4uEm*oPhPq1h# z07E*w2oHImy%>cO1UDkJ+{2s#YDq zHu@9tLxDz*dTv6ycM=mfy%-5}xdmH66oUW4kkLy7(A`iaupQ)E=_A!^M&19hM99Bk zM5F7m4{VDK@|IL5@+=r8*p1vJ=|IKYJ{+c{8c|)#5Q4yccgHwyP$uc|6Eez$VLCV#1PqlL~4D#g|isOG3;dh z6Zf>pY^&;!V>3J@s7N|ZaFN-=4-qsizzP5Gd#(`c-;TrK3E0wv*v7k-MGBLKJ-)K( zYUx=kgkBZ&fBVjF-wuE}@NwS7QT2Wtzc6i%yw*l0vriyptP1wz_4K5|HQ# zqb9!{`1=WP^fgakGK1lcc%Z}z_dLF|z<20J#0pRCi|%EQsa>0y@K?7P2#IBXSb2}# zV%7$7ZBv0IEY8Jg-HgGK9MHFS@E|vU9q$@C)?#39f4a#kCbBlD9tTHqdWJKX*Frw+ z){&#p)#>o_`0k#v zJiGFs!4j4yll?wdOY@X2e|Q;oIB=$8u6dwAun2Z$kVOZvXQ@r@Vu~;UQ@P4pi;Vjt zRs(4Izdz81I(Mw8dcWoF zH2y~BkVui{0j|7>3fHsLEbijLG9li<+@{K?Tw34Z{`$ra>tV;nIJJ}0DRSBG8QRYi zZZc}lu>b-?>sZpWd^d}-*NSFg*Y!)gXv5@kFtYItH@!^rI5TRzEO<0ZYP!dISPnn> zJk{)982nVHtf4)4=3aF)@b%kP3R2K?-E@-~t91-v|j1?^I->$l_nIuar1 z!cpul?}DT}+~xM!auFJv_Svk?knOZJ{Y??`)2TcoS(SSt1-4Qi4L1$z;GLTo%CB|5 zad7yg{Rr>Xq1N1sJ6hVdk=dqy^p0u&Yoe!IxsZ;LzPcwq*L%11X#ncKXYA*c!Iv;oRYpJGM>L-u_uaGj-ndC?!k&Xir_=NIqNl&N9XO!JBBIFnmmNNPMOHOh`Q)u!Wob~%`}69V|GeGLQOH(Wd1YI z$+Sc2v)^b-cD9x2Qy#mL8XbTIQ$2pQMN{J`(0IoI0>5O13E&wnStbHC%gI@foh*sz zFa#W(XZ-x}^=WbbtDP1`eQdgQAL;uujgI9_{YL*-SX)~shqjj;=Cv_DfBryW zsIrc1Xu)u;P8qjJ3&89T+|KndZO@T?%*X(1S4fK8hFh|wP1HK>pZ7ZxLRtrdQNwky z{GW&khwDD2dUkor$hay884WCULN)D`u1^2_|}%^W4#Kv98r z+(vCXIO~>J2;)SOLi{IeSxgXk9wNlH!w6&98BYw&Um%!UmnB9!v)Xc?Yd?vCTsL}w+1)Yg&!K4M985XmO)Z>PY2)b=)!sq}3g@KHos-#0JV~bZRKgh$wRiT< z(py>0)J9h_i;A>fezg_4V_VX624)Eb6j|}IMID6feqLeB&e2pu4YYUXmy9uq#D%8Bs>C zNXR1#o5CkdVUA=S#f_c!l=5CHr^FP$ec#h97V@Q|heXoQimraH`Qpvje7QHgMt%+bkR8Ck zy(A<284M^GUb;fC6lCe(8Fi%bhA6(=N>!Spc251Jd+P{n`-^|?bUY`|sw^|lLNiUT z9dv3(h*2a)&r6!?e-l{YR=uws7?@264zw2Lo;VnBPuXwz}W- zSIUT+d#^=pJ1q(6xTp4Z&_P7ikeI%ux09QFzxKQ6a(3N{b96J*~0M+t|Z(7W5Z>dA{TasJJYUkrSqSU=TcJ3Xr` zon=(s*PP$FC-=NwyS4I_qPX**HMqf}vl!sE)ax|)xb<%+EeZ^sINWy9aB}mb4x3Xd zgvN-kw#Faf;KZ6+J6nkS@Ben{zms%ON&rK`G+9lsxX+J+2{S%Ebjt65cL~MdVGDVK zPzq708_EqyN}dD?)hWE@$c~04VRn zGC9aP@q+60oqry{l#U^bYow_ztpGC$p!;z=G-+5cRPGN6VOzBYU5k~n$ ztD%W3`2>{^U*BWE+GqA{2fUqX}~BrS+Ou!i=CuHG;& zw{SV%vu5GP2#hLs)ZXJyxw?3TfcOh;g?nKW=v-j#eEr%UJzIm&-gLDAivcd6b$hn- z%e^-?_jG??nZ0%{!!LO5*=GnlB-IqbcYn=gI62o%h&vxImyD-vpa{yBV=RBF=)jH5 zgn9r|t|yaf-d}gQYxrGjmSOJY+8$UZd_L^h#V>-?$*NgTEaDT|>0F2EAK8$8I7c{l z;ZqGEB{z`WqDj0}>P30b{Ao7DfE=Z~>TGG}5W(Rcz99^+8Rb7G5f2Tw=WgxoeApia z{sOJ8P)N^*57&==jl7ieu8PWO8znIXB5!JHYI=2#^X7+LvpPajuk~e~xdxY+HPT_T zr9~6H&nss5CYaQ+lJbFujIj=zVYw4g8ei@0CIi!Lrc6{V$(vZfz09f2Z2PqD_mgVN zGP3~fbpcXC6f;-~3(LJAF2p`=sYl)2qm*l@+?5&gsDA)y6l{+I4UcRY4yjHPTZavFXz@VF+Mioen%*Oij(n#QM3 zE!h3iYj=GwpYn+u!Zt?`1LTDeUP0m?%EGcZ~p2M ztSQ%@x>IF#tG8Oc7uP&Ra8hXpm!hr0P-~cdj@In!V;P5NLr0wmgHNjEad`Ow0Jx`~ zP0>{f@EV_7=u0DIlo#aXjDaS{?3X6*W~T-KvmWuB-bm<|yn4Nx-TM8jw>)I(%#4hN zAH%V*9$mW_7b5fk=7KL#guY3k+AKKzHl2UYQnQa)^|*5;p97j?dkW!N)eVIDwbVO8 zd!{qtXAhZ~d-37P2?`ok>IB zeI=$ULqSEeTC$izc6b&vMK_~7tQ&>i$?)FjtwXKwMJ$1bZO4Z684bNQF$X1H%h!BY z4B5$s=mf>!kf2LoXd#%~_1EmgP)O;Q1~4CcZ*02Rs6Ff29LB8dMWOPp7!2w)i_%uv z!mmz|KP01sZEG46KbpTcMo$-xd+YDJQ2>mKO zV+lbm;zQ00x}q)v4QZTQ^I>w`dGndl^JFZ9ZgD>4!C9~8HS9UARBfG)6v%H-&NQ-v zLsN1an?TU<^^4v83>YV7w;!zNyZ!vL$4PtB3TMd7N?wne0LPZhF7L6DPJJAwuekCv zhs0yYz@lGoaczbmzdgsY*~~)~itx(Mrd?Q=RK`oLhuItxRrr*GpTrDd%4D0BWz7_qZN3UI2@A{h{_!bL1*hxJ70HnLMDTB>di+(b+xLr|&BLfDNBPt|tcdGv z0U<>J63{0-(a>6JcEyY*-IUvQx~j^X0UW!!WYgpenr~ed%`U#a=N%mEpCj{50K}<~ zQtdRfsR_RDe#&mq^QiKv(gd4Lg)`~-UIxVb_^Cek#eC#qf_{0jZPkcU$N=d#b!U=e zDzO*)vvdfywCW3)Yr>I5PO0w6V2pvL)Y1Ndk@t`oKEl6w+hCnOXx^%%r+2-*=ZYbr zW7-@P=#^S_vpPaN5x`44@ZJdmCN2zdYn3%5#(r_3gsF$nxo7_v_(qk<$(*=oCGTDG z9#U&+RuhS|XEVXyYc=*yRa6Y@c1te$0;r^uQ2Ezy&zZiboYjL^u&GljM8~i0PAe+F zn&;au`_GU`uKQ3&B9crKyhE0(_K3?OmdlORct9?(lo1+&@V&E4d}TWvs;F-#D&<@D zyEbQASwV|MyIteu7t$k+yNGW;50KmsxtCM0;UwRL|0rQd#$oFSVV3SfHPtoS&Nv<+$9~keC&f&zPDT&U{`2FI$a`05H*tLKyY&`H z?WxELgL@%tnhf{@6a5*G31PO6eOwyK?WdD9cz4=Zd( z=405$o!LIjTTx$6isW0+O0g=ivheM;`I^2W@yRGE}NDF%5$C=A8jDC>Cn{p3OZG$!$Dw> zI%DkPkw`&67?MeGPEsuju1Wm|tGu_y#_Xi6P5*0o_3IC>>E^KUWYPNDn)L^dh^l|A zepybBR$fdhK=D=t&9N%ezv@irB|)8wv+>}?PUO1)eW$^Q1_SP|J9VxZi~|%}XIsq> zoKpMTkjqZ@bh_nws9j%RNpjv~aV{i+Q%vTaE^+!q+}IqQ7R)KSH5VX|8xk{zm=Ze9 zl)^xvddR3HJ-Gg{*F>mVvIc5%FTT*G7&UO-I#lG>f8mA4DMeyzzLN6tL~lICkON*Z z=0XXZ5)>bCJ(pHccuZ%bs&^*pUd7;$e4~l!nTJcjjEEsFKU;9}zaw5DS;jNVxq$!J ziKKT1@lUsR)-2j}9XuJvmXrB5!&y^%cnGO>M5Qv0bwETU7WB9*CB$hEB;xN6DU4>P z+?ukvZ05mpe} zEm5B5)YT<5HbLj<<(2xb>h$K}tj0aL?vzhzJ+bCzk%EYNm>TH%-KQ)++}IkxVfp$M zjF<@wj8u(ZX@d2>Y-}~`Y;5B?a^@CNg+7i9)5y|WkAI(R<&ThN2z zqM+iw)3%L*%3DbZ7fH~`lX_*uEIu`II)3-y-pX>QvSIsB!WQFk}IB% zl9iK#wD0rxiD{+1uc6WT_3Oh&YM^|!R+eMrvAloF>U<&+@(?7yzf>E-#l}D!-)i+- zJfa#~1Ol4L7Hxk?o^8qH9h=5SDCwH@d&9fDVfi9#>7X4S@0Dc9ZrubK{=>&12unHB zSCcF6p<3*bWW5Vwn;LJK{D(GK%$n@l0&et|%X04Tu!rP5jHfbRj#Ya2mK1+%TT8^C zMm(o%;0@T{nK757?YxCTp{IWI_!I~b*4&Wd3*t@w+~@1(=B5Uyb_2le-kh^5Z6r$^ z{Jntwn;__a7uv&1aG`>JVUMHRa%{9WrKrH30fWMp*WSLqq+GQT{By+K z2`s`IjroTDp!bOV<18rf8r4(4j{!f$uy0=8(3_C;F@T71Cl2lGNZ+Ku9V)V6*6X(_ zq_7}5g&D`d_e~@-w-)mf|DdVx0Mq%H&+(vyU80X5sgqI$sRKIV+f{mjw0zK)tz5Q6 zy$-vJk%ny@$NjW0t!3CQ7Kwd)c!RU9SW6=eCoEAHxS7m;X?AVf#7Ucz7({_(-G8ctugJ#XwCRSA*x=uFP6b zhpK3?0VkC@B}CYWE@GwrslE}8yO7ay!|r7BCtWOIE=%6e>+3ay-PpxqfD|*!huY4C zYvLsQcn(}iAyn4^bodu+;@h*qt*_TN==WjP70y@85lDzZ%V6E1~wt z;_Cuc(P(7idn?jL^oM$v)L>*Z5&|B)6r84Qz` z7ux5b7gB{ra>kRw{n8)rkvc-^$#o!lTV_Ym-GZc!+f0iZRg1+_Xj(D-+{p{UKhX%i z_UPZsaaWn+5aa3d2MyQim-0Z<#-USGHj8)x<@^QP?w7wi)~qvu?Yh#mwyWS>L=6{bZ{X{aE!BwKr z6W>ZDK?uTU7ju;*NQ5|gPoW2nQZJr4AjVXE?K@=q&yVR{z?@N1$`Re`_L(SX zYE<@|Mq}RobelzE5&jGt0!YG4KS#vkreu^C<=KauP01yAqi#rYj+|pz19k;ksp4VpCt$oo`Q|b+wEx$j@(hk zvy}u9VzEAoh!E!Zc2+#dL=oRkA0Ib9RcJD|x8Z#1x|hJ+I5da|+TvKkTK)8|{me`< z8p<^u_wy|JTGQ;)C9pLc!@y*(^u|lDW3F}h&PH%W(uaivt-s{-OIUBY6*1SoJlrL@ z&S5}D;wr44xCzQbd(XeADGu^L7s_o6QJV;GJ(1{DUPHMh=ZyDPj|r|OBc#@y=}}R< z_~HMTURDRREj;DU$RpBSOXLdAK|vmBy`uf;TV46{C1r**i)%DKp!O(VArc2Q7SHc9 z?(%Icih1}GI83}L^pmb0KqA{=`@i}HIq~TY%ejC>UjCW*B8~=h5~=5wwMAdFG+D|D zsLc{te@PE5lCg`WN@|}^K@#2h99E`8nm|8!Pe1RlOimVK5tDL}$GoD%(fA}z`i5zg zQ_C7)K?S4BaoWzUlg=M-@xD(165^PL(CANJnA0w7e_Bagt)-zYz3p>)l{j53zM;P* zbK4#|JZVR~qIAvCs-#xlV7KMVS^UmAZ-=!=@C!Bq;uM7K*WS;MY0bJsq~B`vu&78G zqfqPJ+(dCFZ-#am@6(#2dHYi5f6Bq3r-h3@bXSVX3P)er ztag+@ST&~7Jw-)<U$)9>XuiL1)n^3UM3 z!f2^vo^&wU4nJT4$ZpXLFHqpeX!;+CFhsF>BZ=xZL1|AT;*xiF&z>7E)Gcj~AplUu zU09TBMF6?fUv)D!#G5b}gg02)P|hM$R?adf6*=xyev^r_w1y0{2l{V31*czPp#M6o z#BE?Ooxi$wKIkU(sqTENswtwW?UuVa9#Ji5Y0?yybzHmQotM_Y=-_`hh*hHS%(=oF!er6iJ+QYVHst1zqZn6)YV(rx zR9(S2^V^MYD|fU!GUx;?|HA|gR$@GG&_>q@zE{pJ?Qpp>+ z1eSXlN3(}Ai@(U)wz}o-99U!d7f$@=ruxur;9Rs$-Xw^-=V8*&_1^3efiAOnl7jl< zp$dU{*$HFrey-r6u1N?iF?0}6A1K^IKtTERd|1j#Eej-ZY)6XWpCCkv7>#iHT_Gcp zoV1~~WI8%9MoQ3A$M!j{<%Z%H#g3f7JMs6%lML7!HDsDBeYw?c^tJPayPzNvts+8I zpO<>ebDjUN?z#>LAL6Rht6Z|cEV~j5Dd3kv(YL_8{hH)a* zI~T9>z;tdu&!NluBG$d@1Ff}dBbG$#XN3rJ9ND9N;en}0j?_!(;3Q`369LxeHL|XW zX^uZg<2HuUmW33zFe=xPU}uM-d4fXfeewIO3$WfY3`FzvvJ(ECQ;e#$f2|`OM2gJ> zxEn6=zwF(zdRSI?fLZIKM=P8oH5(TyuoD5DPZ_Q)OXJO!f)g6fT@<+ni`Ub12wzFq#%r?n^B;!lrCY|E!$QN%RJx>UqMAv(<5>Y}|&IEA5qA?#xk z^+oQ8nK5=|RAFrS{IDCQ08l)>v|n+DjxBk{0@yJ1iSq1AfE6Zhe+*DWJ1lI~q8`FR z9w}84I?-F3{F7j7!)Pr7HI%WVE>`h}V{oE(P!ZlSB@gw4zlH)VKkW)=mv z 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�FJ(#IQM7L{Vsi*2?NhT){LUR(-yts^;M>jA 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 AhyVZp diff --git a/claudedocs/archive/qa-reference-modal-test.png b/claudedocs/archive/qa-reference-modal-test.png deleted file mode 100644 index 36fe67b3f1b98f31fc1ec426879183d03e5f2b33..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 729528 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