From 37f0e57b16961521477518335c2619c938e8d212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 17 Mar 2026 18:30:49 +0900 Subject: [PATCH 01/20] =?UTF-8?q?fix:=20[quality]=20=EC=84=A4=EB=B9=84?= =?UTF-8?q?=EC=A0=90=EA=B2=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quality/EquipmentInspection/index.tsx | 114 ++++++++++++++---- 1 file changed, 93 insertions(+), 21 deletions(-) diff --git a/src/components/quality/EquipmentInspection/index.tsx b/src/components/quality/EquipmentInspection/index.tsx index 13cbd84c..1ebadc6a 100644 --- a/src/components/quality/EquipmentInspection/index.tsx +++ b/src/components/quality/EquipmentInspection/index.tsx @@ -7,7 +7,7 @@ * 셀 클릭으로 ○/X/△ 토글 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useSearchParams } from 'next/navigation'; import { Loader2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -20,7 +20,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { @@ -67,13 +66,15 @@ interface GridData { nonWorkingDays: string[]; } +const YEAR_OPTIONS = Array.from({ length: 10 }, (_, i) => 2021 + i); +const MONTH_OPTIONS = Array.from({ length: 12 }, (_, i) => i + 1); + export function EquipmentInspectionGrid() { const searchParams = useSearchParams(); const [cycle, setCycle] = useState('daily'); - const [period, setPeriod] = useState(() => { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; - }); + const [year, setYear] = useState(() => new Date().getFullYear()); + const [month, setMonth] = useState(() => new Date().getMonth() + 1); + const period = `${year}-${String(month).padStart(2, '0')}`; const [lineFilter, setLineFilter] = useState('all'); const [equipmentFilter, setEquipmentFilter] = useState(() => { if (typeof window === 'undefined') return 'all'; @@ -85,6 +86,40 @@ export function EquipmentInspectionGrid() { const [isLoading, setIsLoading] = useState(true); const [showReset, setShowReset] = useState(false); + // 드래그 스크롤 + const scrollRef = useRef(null); + const isDragging = useRef(false); + const startX = useRef(0); + const scrollLeft = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + const el = scrollRef.current; + if (!el) return; + isDragging.current = true; + startX.current = e.pageX - el.offsetLeft; + scrollLeft.current = el.scrollLeft; + el.style.cursor = 'grabbing'; + el.style.userSelect = 'none'; + }, []); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isDragging.current) return; + const el = scrollRef.current; + if (!el) return; + e.preventDefault(); + const x = e.pageX - el.offsetLeft; + el.scrollLeft = scrollLeft.current - (x - startX.current); + }, []); + + const handleMouseUp = useCallback(() => { + isDragging.current = false; + const el = scrollRef.current; + if (el) { + el.style.cursor = 'grab'; + el.style.userSelect = ''; + } + }, []); + // 옵션 로드 useEffect(() => { getEquipmentOptions().then((r) => { @@ -139,13 +174,19 @@ export function EquipmentInspectionGrid() { : Array.isArray(rawLabels) ? rawLabels : []; // 주말(토/일) 계산 const [y, m] = period.split('-').map(Number); - const weekends: string[] = []; + const weekends = new Set(); const daysInMonth = new Date(y, m, 0).getDate(); for (let d = 1; d <= daysInMonth; d++) { const dow = new Date(y, m - 1, d).getDay(); - if (dow === 0 || dow === 6) weekends.push(String(d)); + if (dow === 0 || dow === 6) weekends.add(String(d)); } - setGridData({ rows, labels, nonWorkingDays: weekends }); + // API에서 받은 임시휴일 추가 (형식: "2026-03-17" → 일자 "17"으로 변환) + const apiHolidays: string[] = apiItems.length > 0 ? (apiItems[0].non_working_days ?? []) : []; + for (const dateStr of apiHolidays) { + const day = String(Number(dateStr.split('-')[2])); + weekends.add(day); + } + setGridData({ rows, labels, nonWorkingDays: Array.from(weekends) }); } else { setGridData({ rows: [], labels: [], nonWorkingDays: [] }); } @@ -187,7 +228,12 @@ export function EquipmentInspectionGrid() { return { ...prev, rows: newRows }; }); } else { - toast.error(result.error || '점검 결과 변경에 실패했습니다.'); + const errorMsg = result.error?.includes('non_working_day') + ? '비근무일에는 점검을 입력할 수 없습니다.' + : result.error?.includes('no_inspect_permission') + ? '담당자만 점검을 입력할 수 있습니다.' + : result.error || '점검 결과 변경에 실패했습니다.'; + toast.error(errorMsg); } }, [cycle, period]); @@ -243,12 +289,28 @@ export function EquipmentInspectionGrid() {
- { - if (v) setPeriod(v.substring(0, 7)); - }} - /> +
+ + +
@@ -353,7 +415,14 @@ export function EquipmentInspectionGrid() { boxShadow: '4px 0 6px -2px rgba(0,0,0,0.08)', }); return ( -
+
@@ -419,10 +488,13 @@ export function EquipmentInspectionGrid() { borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`, ...(isNonWorking ? { background: '#fef2f2' } : {}), }} - onClick={() => - row.canInspect && - handleCellClick(row.equipment.id, template.id, label) - } + onClick={() => { + if (!row.canInspect) { + toast.error('담당자만 점검을 입력할 수 있습니다.'); + return; + } + handleCellClick(row.equipment.id, template.id, label); + }} > {getResultSymbol(result)} From 1151fb0bf7a3c26d845d613fc2b5a562768277bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 17 Mar 2026 20:51:58 +0900 Subject: [PATCH 02/20] =?UTF-8?q?fix:=20[material]=20=EC=9E=85=EA=B3=A0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?+=20=EB=8B=A8=EA=B0=80=EB=AA=A9=EB=A1=9D=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=B2=84=ED=8A=BC=20=EC=A3=BC=EC=84=9D=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입고 상세/처리 다이얼로그 코드 간소화 - 단가 목록 품목마스터 동기화 버튼 주석처리 (미구현) - 테넌트 모듈 분리 분석/계획 문서 추가 --- ...nant-module-separation-dependency-audit.md | 437 ++++ ...26-03-17] tenant-module-separation-plan.md | 1811 +++++++++++++++++ .../ReceivingManagement/ReceivingDetail.tsx | 22 +- .../ReceivingProcessDialog.tsx | 30 +- .../material/ReceivingManagement/actions.ts | 4 +- src/components/pricing/PricingListClient.tsx | 28 +- 6 files changed, 2275 insertions(+), 57 deletions(-) create mode 100644 claudedocs/architecture/[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md create mode 100644 claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md 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 new file mode 100644 index 00000000..af4f9a18 --- /dev/null +++ b/claudedocs/architecture/[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md @@ -0,0 +1,437 @@ +# 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/[PLAN-2026-03-17] tenant-module-separation-plan.md b/claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md new file mode 100644 index 00000000..b9e35fe8 --- /dev/null +++ b/claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md @@ -0,0 +1,1811 @@ +# 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 + +--- + +## 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/src/components/material/ReceivingManagement/ReceivingDetail.tsx b/src/components/material/ReceivingManagement/ReceivingDetail.tsx index 711354fb..b70810fa 100644 --- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx +++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx @@ -91,16 +91,6 @@ const INITIAL_FORM_DATA: Partial = { inventoryAdjustments: [], }; -// 로트번호 생성 (YYMMDD-NN) -function generateLotNo(): string { - const now = new Date(); - const yy = String(now.getFullYear()).slice(-2); - const mm = String(now.getMonth() + 1).padStart(2, '0'); - const dd = String(now.getDate()).padStart(2, '0'); - const seq = String(Math.floor(Math.random() * 100)).padStart(2, '0'); - return `${yy}${mm}${dd}-${seq}`; -} - // localStorage에서 로그인 사용자 정보 가져오기 function getLoggedInUser(): { name: string; department: string } { if (typeof window === 'undefined') return { name: '', department: '' }; @@ -169,7 +159,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { const data = generateReceivingData(); setFormData((prev) => ({ ...prev, - lotNo: generateLotNo(), itemCode: data.itemCode, itemName: data.itemName, specification: data.specification, @@ -574,15 +563,12 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { /> - {/* 원자재로트 - 수정 가능 */} + {/* 원자재로트 - 자동채번 (readOnly) */}
- setFormData((prev) => ({ ...prev, lotNo: e.target.value }))} - placeholder="원자재로트를 입력하세요" - /> +
+ {formData.lotNo || (isNewMode ? '저장 시 자동 생성' : '-')} +
{/* 품목코드 - 검색 모달 선택 */} diff --git a/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx b/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx index a52621e7..266f9f02 100644 --- a/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx +++ b/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx @@ -23,16 +23,6 @@ import { import { Alert, AlertDescription } from '@/components/ui/alert'; import type { ReceivingDetail, ReceivingProcessFormData } from './types'; -// LOT 번호 생성 함수 (YYMMDD-NN 형식) -function generateLotNo(): string { - const now = new Date(); - const yy = String(now.getFullYear()).slice(-2); - const mm = String(now.getMonth() + 1).padStart(2, '0'); - const dd = String(now.getDate()).padStart(2, '0'); - const random = String(Math.floor(Math.random() * 100)).padStart(2, '0'); - return `${yy}${mm}${dd}-${random}`; -} - interface Props { open: boolean; onOpenChange: (open: boolean) => void; @@ -42,7 +32,7 @@ interface Props { export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete }: Props) { // 폼 데이터 - const [receivingLot, setReceivingLot] = useState(() => generateLotNo()); + const [receivingLot, setReceivingLot] = useState(''); const [supplierLot, setSupplierLot] = useState(''); const [receivingQty, setReceivingQty] = useState((detail.orderQty ?? 0).toString()); const [receivingLocation, setReceivingLocation] = useState(''); @@ -56,9 +46,7 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete const validateForm = useCallback((): boolean => { const errors: string[] = []; - if (!receivingLot.trim()) { - errors.push('입고LOT는 필수 입력 항목입니다.'); - } + // 입고LOT는 API가 자동 채번하므로 필수 검증 제거 if (!receivingQty.trim() || isNaN(Number(receivingQty)) || Number(receivingQty) <= 0) { errors.push('입고수량은 필수 입력 항목입니다. 유효한 숫자를 입력해주세요.'); @@ -68,7 +56,7 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete setValidationErrors(errors); return errors.length === 0; - }, [receivingLot, receivingQty]); + }, [receivingQty]); // 입고 처리 const handleSubmit = useCallback(async () => { @@ -161,16 +149,12 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete {/* 입력 필드 */}
- + { - setReceivingLot(e.target.value); - setValidationErrors([]); - }} - placeholder="예: 251223-41" + readOnly + className="bg-gray-50" + placeholder="저장 시 자동 생성" />
diff --git a/src/components/material/ReceivingManagement/actions.ts b/src/components/material/ReceivingManagement/actions.ts index db78735d..9e9fb219 100644 --- a/src/components/material/ReceivingManagement/actions.ts +++ b/src/components/material/ReceivingManagement/actions.ts @@ -497,7 +497,7 @@ function transformFrontendToApi( if (data.remark !== undefined) result.remark = data.remark; if (data.receivingQty !== undefined) result.receiving_qty = data.receivingQty; if (data.receivingDate !== undefined) result.receiving_date = data.receivingDate; - if (data.lotNo !== undefined) result.lot_no = data.lotNo; + // lot_no는 API가 자동 채번하므로 프론트에서 전송하지 않음 if (data.supplierMaterialNo !== undefined) result.material_no = data.supplierMaterialNo; if (data.manufacturer !== undefined) result.manufacturer = data.manufacturer; if (data.certificateFileId !== undefined) result.certificate_file_id = data.certificateFileId; @@ -511,7 +511,7 @@ function transformProcessDataToApi( ): Record { return { receiving_qty: data.receivingQty, - lot_no: data.receivingLot, + // lot_no는 API가 자동 채번하므로 프론트에서 전송하지 않음 supplier_lot: data.supplierLot, receiving_location: data.receivingLocation, remark: data.remark, diff --git a/src/components/pricing/PricingListClient.tsx b/src/components/pricing/PricingListClient.tsx index ba867b9e..4aff63be 100644 --- a/src/components/pricing/PricingListClient.tsx +++ b/src/components/pricing/PricingListClient.tsx @@ -13,7 +13,6 @@ import { Package, AlertCircle, CheckCircle2, - RefreshCw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -326,18 +325,19 @@ export function PricingListClient({ }; // 헤더 액션 (함수로 정의) - const headerActions = () => ( - - ); + // NOTE: 품목 마스터 동기화 버튼 - 실제 로직 미구현 상태로 주석처리 + // const headerActions = () => ( + // + // ); // UniversalListPage 설정 const pricingConfig: UniversalListConfig = { @@ -356,7 +356,7 @@ export function PricingListClient({ }, columns: tableColumns, - headerActions, + // headerActions, // 품목 마스터 동기화 버튼 미구현으로 주석처리 stats, tabs, From 915b5d9a75d66d0f0cbfe6349a14f1a023b51547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 18 Mar 2026 09:21:39 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20[shipment]=20=EC=B6=9C=EA=B3=A0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출고증/납품확인서 목업 데이터 → API 실데이터 전환 - 출고 등록(수동) 기능 제거 (자동생성만 유지) - 출고로트/수주로트 분리 표시, 로트번호 폴백 처리 - 출고 목록 카드뷰 불일치 수정 --- .../outbound/shipments/new/page.tsx | 10 - .../(protected)/outbound/shipments/page.tsx | 15 +- .../ShipmentManagement/ShipmentCreate.tsx | 760 --------------- .../ShipmentManagement/ShipmentDetail.tsx | 25 +- .../ShipmentManagement/ShipmentList.tsx | 26 +- .../outbound/ShipmentManagement/actions.ts | 99 +- .../documents/DeliveryConfirmation.tsx | 9 +- .../documents/ShipmentOrderDocument.tsx | 878 +++++++++--------- .../documents/ShippingSlip.tsx | 9 +- .../outbound/ShipmentManagement/index.ts | 1 - .../ShipmentManagement/shipmentConfig.ts | 18 +- .../outbound/ShipmentManagement/types.ts | 7 +- 12 files changed, 545 insertions(+), 1312 deletions(-) delete mode 100644 src/app/[locale]/(protected)/outbound/shipments/new/page.tsx delete mode 100644 src/components/outbound/ShipmentManagement/ShipmentCreate.tsx diff --git a/src/app/[locale]/(protected)/outbound/shipments/new/page.tsx b/src/app/[locale]/(protected)/outbound/shipments/new/page.tsx deleted file mode 100644 index f6d3cac9..00000000 --- a/src/app/[locale]/(protected)/outbound/shipments/new/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 출하관리 - 등록 페이지 - * URL: /outbound/shipments/new - */ - -import { ShipmentCreate } from '@/components/outbound/ShipmentManagement'; - -export default function NewShipmentPage() { - return ; -} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/outbound/shipments/page.tsx b/src/app/[locale]/(protected)/outbound/shipments/page.tsx index bdd88380..d8dabd30 100644 --- a/src/app/[locale]/(protected)/outbound/shipments/page.tsx +++ b/src/app/[locale]/(protected)/outbound/shipments/page.tsx @@ -1,21 +1,12 @@ 'use client'; /** - * 출하관리 - 목록/등록 페이지 + * 출하관리 - 목록 페이지 * URL: /outbound/shipments - * URL: /outbound/shipments?mode=new */ -import { useSearchParams } from 'next/navigation'; -import { ShipmentList, ShipmentCreate } from '@/components/outbound/ShipmentManagement'; +import { ShipmentList } from '@/components/outbound/ShipmentManagement'; export default function ShipmentsPage() { - const searchParams = useSearchParams(); - const mode = searchParams.get('mode'); - - if (mode === 'new') { - return ; - } - return ; -} +} \ No newline at end of file diff --git a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx deleted file mode 100644 index 226a442a..00000000 --- a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx +++ /dev/null @@ -1,760 +0,0 @@ -'use client'; - -/** - * 출고 등록 페이지 - * 4개 섹션 구조: 기본정보, 수주/배송정보, 배차정보, 제품내용 - */ - -import { useState, useCallback, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { Plus, Trash2, ChevronDown, Search } from 'lucide-react'; -import { getTodayString } from '@/lib/utils/date'; -import { Input } from '@/components/ui/input'; -import { DateTimePicker } from '@/components/ui/date-time-picker'; -import { DatePicker } from '@/components/ui/date-picker'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; -import { shipmentCreateConfig } from './shipmentConfig'; -import { - createShipment, - getLotOptions, - getLogisticsOptions, - getVehicleTonnageOptions, -} from './actions'; -import type { - ShipmentCreateFormData, - DeliveryMethod, - FreightCostType, - VehicleDispatch, - LotOption, - LogisticsOption, - VehicleTonnageOption, - ProductGroup, - ProductPart, -} from './types'; -import { isNextRedirectError } from '@/lib/utils/redirect-error'; -import { toast } from 'sonner'; -import { useDaumPostcode } from '@/hooks/useDaumPostcode'; -import { useDevFill } from '@/components/dev'; -import { generateShipmentData } from '@/components/dev/generators/shipmentData'; -import { mockProductGroups, mockOtherParts } from './mockData'; - -// 배송방식 옵션 -const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [ - { value: 'direct_dispatch', label: '직접배차' }, - { value: 'loading', label: '상차' }, - { value: 'kyungdong_delivery', label: '경동택배' }, - { value: 'daesin_delivery', label: '대신택배' }, - { value: 'kyungdong_freight', label: '경동화물' }, - { value: 'daesin_freight', label: '대신화물' }, - { value: 'self_pickup', label: '직접수령' }, -]; - -// 운임비용 옵션 (선불, 착불, 없음) -const freightCostOptions: { value: FreightCostType; label: string }[] = [ - { value: 'prepaid', label: '선불' }, - { value: 'collect', label: '착불' }, - { value: 'none', label: '없음' }, -]; - -// 빈 배차 행 생성 -function createEmptyDispatch(): VehicleDispatch { - return { - id: `vd-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - logisticsCompany: '', - arrivalDateTime: '', - tonnage: '', - vehicleNo: '', - driverContact: '', - remarks: '', - }; -} - -export function ShipmentCreate() { - const router = useRouter(); - - // 폼 상태 - const [formData, setFormData] = useState({ - lotNo: '', - scheduledDate: getTodayString(), - priority: 'normal', - deliveryMethod: 'direct_dispatch', - shipmentDate: '', - freightCost: 'none', - receiver: '', - receiverContact: '', - zipCode: '', - address: '', - addressDetail: '', - vehicleDispatches: [createEmptyDispatch()], - logisticsCompany: '', - vehicleTonnage: '', - loadingTime: '', - loadingManager: '', - remarks: '', - }); - - // API 옵션 데이터 상태 - const [lotOptions, setLotOptions] = useState([]); - const [logisticsOptions, setLogisticsOptions] = useState([]); - const [vehicleTonnageOptions, setVehicleTonnageOptions] = useState([]); - - // 제품 데이터 (LOT 선택 시 표시) - const [productGroups, setProductGroups] = useState([]); - const [otherParts, setOtherParts] = useState([]); - - // 로딩/에러 상태 - const [isLoading, setIsLoading] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - const [validationErrors, setValidationErrors] = useState>({}); - - // 아코디언 상태 - const [accordionValue, setAccordionValue] = useState([]); - - // 우편번호 찾기 - const { openPostcode } = useDaumPostcode({ - onComplete: (result) => { - setFormData(prev => ({ - ...prev, - zipCode: result.zonecode, - address: result.address, - })); - }, - }); - - // 옵션 데이터 로드 - const loadOptions = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - const [lotsResult, logisticsResult, tonnageResult] = await Promise.all([ - getLotOptions(), - getLogisticsOptions(), - getVehicleTonnageOptions(), - ]); - - if (lotsResult.success && lotsResult.data) { - setLotOptions(lotsResult.data); - } - if (logisticsResult.success && logisticsResult.data) { - setLogisticsOptions(logisticsResult.data); - } - if (tonnageResult.success && tonnageResult.data) { - setVehicleTonnageOptions(tonnageResult.data); - } - } catch (err) { - if (isNextRedirectError(err)) throw err; - console.error('[ShipmentCreate] loadOptions error:', err); - setError('옵션 데이터를 불러오는 중 오류가 발생했습니다.'); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - loadOptions(); - }, [loadOptions]); - - // DevToolbar 자동 채우기 - useDevFill( - 'shipment', - useCallback(() => { - const lotOptionsForGenerator = lotOptions.map(o => ({ - lotNo: o.value, - customerName: o.customerName, - siteName: o.siteName, - })); - const logisticsOptionsForGenerator = logisticsOptions.map(o => ({ - id: o.value, - name: o.label, - })); - const tonnageOptionsForGenerator = vehicleTonnageOptions.map(o => ({ - value: o.value, - label: o.label, - })); - const sampleData = generateShipmentData({ - lotOptions: lotOptionsForGenerator as unknown as LotOption[], - logisticsOptions: logisticsOptionsForGenerator as unknown as LogisticsOption[], - tonnageOptions: tonnageOptionsForGenerator, - }); - setFormData(prev => ({ ...prev, ...sampleData })); - toast.success('[Dev] 출고 폼이 자동으로 채워졌습니다.'); - }, [lotOptions, logisticsOptions, vehicleTonnageOptions]) - ); - - // LOT 선택 시 현장명/수주처 자동 매핑 + 목데이터 제품 표시 - const handleLotChange = useCallback((lotNo: string) => { - setFormData(prev => ({ ...prev, lotNo })); - if (lotNo) { - // 목데이터로 제품 그룹 표시 - setProductGroups(mockProductGroups); - setOtherParts(mockOtherParts); - } else { - setProductGroups([]); - setOtherParts([]); - } - if (validationErrors.lotNo) { - setValidationErrors(prev => { const { lotNo: _, ...rest } = prev; return rest; }); - } - }, [validationErrors]); - - // 배송방식에 따라 운임비용 '없음' 고정 여부 판단 - const isFreightCostLocked = (method: DeliveryMethod) => - method === 'direct_dispatch' || method === 'self_pickup'; - - // 폼 입력 핸들러 - const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => { - if (field === 'deliveryMethod') { - const method = value as DeliveryMethod; - if (isFreightCostLocked(method)) { - setFormData(prev => ({ ...prev, deliveryMethod: method, freightCost: 'none' as FreightCostType })); - } else { - setFormData(prev => ({ ...prev, deliveryMethod: method })); - } - } else { - setFormData(prev => ({ ...prev, [field]: value })); - } - if (validationErrors[field]) { - setValidationErrors(prev => { - const next = { ...prev }; - delete next[field]; - return next; - }); - } - }; - - // 배차 정보 핸들러 - const handleDispatchChange = (index: number, field: keyof VehicleDispatch, value: string) => { - setFormData(prev => { - const newDispatches = [...prev.vehicleDispatches]; - newDispatches[index] = { ...newDispatches[index], [field]: value }; - return { ...prev, vehicleDispatches: newDispatches }; - }); - }; - - const handleAddDispatch = () => { - setFormData(prev => ({ - ...prev, - vehicleDispatches: [...prev.vehicleDispatches, createEmptyDispatch()], - })); - }; - - const handleRemoveDispatch = (index: number) => { - setFormData(prev => ({ - ...prev, - vehicleDispatches: prev.vehicleDispatches.filter((_, i) => i !== index), - })); - }; - - // 아코디언 제어 - const handleExpandAll = useCallback(() => { - const allIds = [ - ...productGroups.map(g => g.id), - ...(otherParts.length > 0 ? ['other-parts'] : []), - ]; - setAccordionValue(allIds); - }, [productGroups, otherParts]); - - const handleCollapseAll = useCallback(() => { - setAccordionValue([]); - }, []); - - const handleCancel = useCallback(() => { - router.push('/ko/outbound/shipments'); - }, [router]); - - const validateForm = (): boolean => { - const errors: Record = {}; - if (!formData.lotNo) errors.lotNo = '로트번호는 필수 선택 항목입니다.'; - if (!formData.scheduledDate) errors.scheduledDate = '출고예정일은 필수 입력 항목입니다.'; - if (!formData.deliveryMethod) errors.deliveryMethod = '배송방식은 필수 선택 항목입니다.'; - setValidationErrors(errors); - if (Object.keys(errors).length > 0) { - const firstError = Object.values(errors)[0]; - toast.error(firstError); - } - return Object.keys(errors).length === 0; - }; - - const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => { - if (!validateForm()) return { success: false, error: '' }; - - setIsSubmitting(true); - try { - const result = await createShipment(formData); - if (!result.success) { - return { success: false, error: result.error || '출고 등록에 실패했습니다.' }; - } - return { success: true }; - } catch (err) { - if (isNextRedirectError(err)) throw err; - console.error('[ShipmentCreate] handleSubmit error:', err); - const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.'; - return { success: false, error: errorMessage }; - } finally { - setIsSubmitting(false); - } - }, [formData]); - - // 제품 부품 테이블 렌더링 - const renderPartsTable = (parts: ProductPart[]) => ( -
- - - 순번 - 품목명 - 규격 - 수량 - 단위 - - - - {parts.map((part) => ( - - {part.seq} - {part.itemName} - {part.specification} - {part.quantity} - {part.unit} - - ))} - -
- ); - - // LOT에서 선택한 정보 표시 - const selectedLot = lotOptions.find(o => o.value === formData.lotNo); - - // 폼 컨텐츠 렌더링 - const renderFormContent = useCallback((_props: { formData: Record; onChange: (key: string, value: unknown) => void; mode: string; errors: Record }) => ( -
- {/* 카드 1: 기본 정보 */} - - - 기본 정보 - - -
- {/* 출고번호 - 자동생성 */} -
-
출고번호
-
자동생성
-
- {/* 로트번호 - Select */} -
-
로트번호 *
- - {validationErrors.lotNo &&

{validationErrors.lotNo}

} -
- {/* 현장명 - LOT 선택 시 자동 매핑 */} -
-
현장명
-
{selectedLot?.siteName || '-'}
-
- {/* 수주처 - LOT 선택 시 자동 매핑 */} -
-
수주처
-
{selectedLot?.customerName || '-'}
-
-
-
-
- - {/* 카드 2: 수주/배송 정보 */} - - - 수주/배송 정보 - - -
-
- - handleInputChange('scheduledDate', date)} - disabled={isSubmitting} - className={validationErrors.scheduledDate ? 'border-red-500' : ''} - /> - {validationErrors.scheduledDate &&

{validationErrors.scheduledDate}

} -
-
- - handleInputChange('shipmentDate', date)} - disabled={isSubmitting} - /> -
-
- - - {validationErrors.deliveryMethod &&

{validationErrors.deliveryMethod}

} -
-
- - -
-
-
-
- - handleInputChange('receiver', e.target.value)} - placeholder="수신자명" - disabled={isSubmitting} - /> -
-
- - handleInputChange('receiverContact', e.target.value)} - placeholder="수신처" - disabled={isSubmitting} - /> -
-
- {/* 주소 */} -
- -
- - -
- - handleInputChange('addressDetail', e.target.value)} - placeholder="상세주소" - disabled={isSubmitting} - /> -
-
-
- - {/* 카드 3: 배차 정보 */} - - - 배차 정보 - - - - - - - 물류업체 - 입차일시 - 구분 - 차량번호 - 기사연락처 - 비고 - - - - - {formData.vehicleDispatches.map((dispatch, index) => ( - - - - - - handleDispatchChange(index, 'arrivalDateTime', val)} - size="sm" - disabled={isSubmitting} - /> - - - - - - handleDispatchChange(index, 'vehicleNo', e.target.value)} - placeholder="차량번호" - className="h-8" - disabled={isSubmitting} - /> - - - handleDispatchChange(index, 'driverContact', e.target.value)} - placeholder="연락처" - className="h-8" - disabled={isSubmitting} - /> - - - handleDispatchChange(index, 'remarks', e.target.value)} - placeholder="비고" - className="h-8" - disabled={isSubmitting} - /> - - - {formData.vehicleDispatches.length > 1 && ( - - )} - - - ))} - -
-
-
- - {/* 카드 4: 제품내용 (읽기전용 - LOT 선택 시 표시) */} - - - 제품내용 - {productGroups.length > 0 && ( - - - - - - - 모두 펼치기 - - - 모두 접기 - - - - )} - - - {productGroups.length > 0 || otherParts.length > 0 ? ( - - {productGroups.map((group: ProductGroup) => ( - - -
- {group.productName} - - ({group.specification}) - - - {group.partCount}개 부품 - -
-
- - {renderPartsTable(group.parts)} - -
- ))} - {otherParts.length > 0 && ( - - -
- 기타부품 - - {otherParts.length}개 부품 - -
-
- - {renderPartsTable(otherParts)} - -
- )} -
- ) : ( -
- {formData.lotNo ? '제품 정보를 불러오는 중...' : '로트를 선택하면 제품 목록이 표시됩니다.'} -
- )} -
-
-
- ), [ - formData, validationErrors, isSubmitting, lotOptions, logisticsOptions, - vehicleTonnageOptions, selectedLot, productGroups, otherParts, accordionValue, - handleLotChange, handleExpandAll, handleCollapseAll, openPostcode, - ]); - - if (error) { - return ( - ; onChange: (key: string, value: unknown) => void; mode: string; errors: Record }) => ( -
{error}
- )} - /> - ); - } - - return ( - { - return await handleSubmit(); - }} - renderForm={renderFormContent} - /> - ); -} diff --git a/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx b/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx index b4a61b11..a6214004 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx @@ -72,6 +72,8 @@ import type { } from './types'; import { ShippingSlip } from './documents/ShippingSlip'; import { DeliveryConfirmation } from './documents/DeliveryConfirmation'; +import type { OrderDocumentDetail } from './documents/ShipmentOrderDocument'; +import { getOrderDocumentDetail } from '@/components/orders/actions'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { toast } from 'sonner'; @@ -90,6 +92,7 @@ const STATUS_TRANSITIONS: Record = { export function ShipmentDetail({ id }: ShipmentDetailProps) { const router = useRouter(); const [previewDocument, setPreviewDocument] = useState<'shipping' | 'delivery' | null>(null); + const [orderDetail, setOrderDetail] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -139,6 +142,21 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) { loadData(); }, [loadData]); + // 문서 모달 열 때 수주 BOM 데이터 로드 + useEffect(() => { + if (!previewDocument || !detail?.orderId) { + if (!previewDocument) setOrderDetail(null); + return; + } + getOrderDocumentDetail(String(detail.orderId)).then((result) => { + if (result.success && result.data) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const raw = result.data as Record; + setOrderDetail((raw?.data ?? raw) as OrderDocumentDetail); + } + }); + }, [previewDocument, detail?.orderId]); + const _handleGoBack = useCallback(() => { router.push('/ko/outbound/shipments'); }, [router]); @@ -340,7 +358,8 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
- {renderInfoField('로트번호', detail.lotNo)} + {renderInfoField('출고로트', detail.lotNo || detail.shipmentNo)} + {renderInfoField('수주로트', detail.orderLotNo)} {renderInfoField('현장명', detail.siteName)} {renderInfoField('수주처', detail.customerName)} {renderInfoField('작성자', detail.registrant)} @@ -543,8 +562,8 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) { > {detail && ( <> - {previewDocument === 'shipping' && } - {previewDocument === 'delivery' && } + {previewDocument === 'shipping' && } + {previewDocument === 'delivery' && } )} diff --git a/src/components/outbound/ShipmentManagement/ShipmentList.tsx b/src/components/outbound/ShipmentManagement/ShipmentList.tsx index d9e4a478..1105f072 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentList.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentList.tsx @@ -21,7 +21,6 @@ import { Clock, CheckCircle2, Eye, - Plus, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -92,11 +91,6 @@ export function ShipmentList() { [router] ); - // ===== 등록 핸들러 ===== - const handleCreate = useCallback(() => { - router.push('/ko/outbound/shipments?mode=new'); - }, [router]); - // ===== 통계 카드 (3개: 당일 출고대기, 출고대기, 출고완료) ===== const stats: StatCard[] = useMemo( () => [ @@ -218,17 +212,11 @@ export function ShipmentList() { onEndDateChange: setEndDate, }, - // 등록 버튼 - createButton: { - label: '출고 등록', - onClick: handleCreate, - icon: Plus, - }, - - // 테이블 컬럼 (11개) + // 테이블 컬럼 (12개) columns: [ { key: 'no', label: '번호', className: 'w-[50px] text-center' }, - { key: 'lotNo', label: '로트번호', className: 'min-w-[120px]', copyable: true }, + { key: 'lotNo', label: '출고로트', className: 'min-w-[120px]', copyable: true }, + { key: 'orderLotNo', label: '수주로트', className: 'min-w-[120px]', copyable: true }, { key: 'siteName', label: '현장명', className: 'min-w-[100px]', copyable: true }, { key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]', copyable: true }, { key: 'receiver', label: '수신자', className: 'w-[80px] text-center', copyable: true }, @@ -300,7 +288,8 @@ export function ShipmentList() { /> {globalIndex} - {item.lotNo || item.shipmentNo || '-'} + {item.lotNo?.trim() || item.shipmentNo || '-'} + {item.orderLotNo || '-'} {item.siteName} {item.orderCustomer || item.customerName || '-'} {item.receiver || '-'} @@ -350,7 +339,8 @@ export function ShipmentList() { } infoGrid={
- + + @@ -403,7 +393,7 @@ export function ShipmentList() { /> ), }), - [stats, startDate, endDate, scheduleEvents, calendarDate, scheduleView, handleRowClick, handleCreate, handleCalendarDateClick, handleCalendarEventClick] + [stats, startDate, endDate, scheduleEvents, calendarDate, scheduleView, handleRowClick, handleCalendarDateClick, handleCalendarEventClick] ); return ; diff --git a/src/components/outbound/ShipmentManagement/actions.ts b/src/components/outbound/ShipmentManagement/actions.ts index 9536b316..d07ac3e0 100644 --- a/src/components/outbound/ShipmentManagement/actions.ts +++ b/src/components/outbound/ShipmentManagement/actions.ts @@ -31,9 +31,7 @@ import type { ShipmentPriority, DeliveryMethod, FreightCostType, - ShipmentCreateFormData, ShipmentEditFormData, - LotOption, LogisticsOption, VehicleTonnageOption, } from './types'; @@ -151,6 +149,7 @@ function transformApiToListItem(data: ShipmentApiData): ShipmentItem { id: String(data.id), shipmentNo: data.shipment_no, lotNo: data.lot_no || '', + orderLotNo: data.order_info?.order_no || '', scheduledDate: data.scheduled_date, status: data.status, priority: data.priority, @@ -189,10 +188,40 @@ function transformApiToProduct(data: ShipmentItemApiData): ShipmentProduct { // ===== API → Frontend 변환 (상세용) ===== function transformApiToDetail(data: ShipmentApiData): ShipmentDetail { + // items를 floor_unit 기준으로 productGroups 자동 그룹핑 + const rawItems = data.items || []; + const items = rawItems.map(transformApiToProduct); + const groupMap = new Map(); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const raw = rawItems[i]; + const key = item.floorUnit || `item-${item.id}`; + if (!groupMap.has(key)) { + groupMap.set(key, { productName: key, specification: '', parts: [] }); + } + groupMap.get(key)!.parts.push({ product: item, unit: raw.unit || '' }); + } + const productGroups = Array.from(groupMap.entries()).map(([key, g]) => ({ + id: key, + productName: g.productName, + specification: g.parts[0]?.product.specification || '', + partCount: g.parts.length, + parts: g.parts.map((p, i) => ({ + id: p.product.id, + seq: i + 1, + itemName: p.product.itemName, + specification: p.product.specification, + quantity: p.product.quantity, + unit: p.unit, + })), + })); + return { id: String(data.id), + orderId: data.order_id ?? data.order_info?.order_id, shipmentNo: data.shipment_no, lotNo: data.lot_no || '', + orderLotNo: data.order_info?.order_no || '', scheduledDate: data.scheduled_date, shipmentDate: (data as unknown as Record).shipment_date as string | undefined, status: data.status, @@ -238,10 +267,10 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail { remarks: '', }] : []), - // 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리 - productGroups: [], + // 제품내용 (그룹핑) - floor_unit 기준 자동 그룹핑 + productGroups, otherParts: [], - products: (data.items || []).map(transformApiToProduct), + products: items, logisticsCompany: data.logistics_company, vehicleTonnage: data.vehicle_tonnage, shippingCost: data.shipping_cost ? parseFloat(String(data.shipping_cost)) : undefined, @@ -253,11 +282,12 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail { } // ===== API → Frontend 변환 (통계용) ===== -function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number }): ShipmentStats { +function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number; completed_count?: number; ready_count?: number }): ShipmentStats { return { todayShipmentCount: data.today_shipment_count, scheduledCount: data.scheduled_count, shippingCount: data.shipping_count, + completedCount: data.completed_count || 0, urgentCount: data.urgent_count, totalCount: data.total_count || 0, }; @@ -285,37 +315,6 @@ function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): Sh return result; } -// ===== Frontend → API 변환 (등록용) ===== -function transformCreateFormToApi( - data: ShipmentCreateFormData -): Record { - const result: Record = { - lot_no: data.lotNo, - scheduled_date: data.scheduledDate, - priority: data.priority, - delivery_method: data.deliveryMethod, - logistics_company: data.logisticsCompany, - vehicle_tonnage: data.vehicleTonnage, - loading_time: data.loadingTime, - loading_manager: data.loadingManager, - remarks: data.remarks, - }; - - if (data.vehicleDispatches && data.vehicleDispatches.length > 0) { - result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({ - seq: idx + 1, - logistics_company: vd.logisticsCompany || null, - arrival_datetime: vd.arrivalDateTime || null, - tonnage: vd.tonnage || null, - vehicle_no: vd.vehicleNo || null, - driver_contact: vd.driverContact || null, - remarks: vd.remarks || null, - })); - } - - return result; -} - // ===== Frontend → API 변환 (수정용) ===== function transformEditFormToApi( data: Partial @@ -423,22 +422,6 @@ export async function getShipmentById(id: string): Promise<{ success: boolean; d return { success: result.success, data: result.data, error: result.error }; } -// ===== 출고 등록 ===== -export async function createShipment( - data: ShipmentCreateFormData -): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> { - const apiData = transformCreateFormToApi(data); - const result = await executeServerAction({ - url: buildApiUrl('/api/v1/shipments'), - method: 'POST', - body: apiData, - transform: (d: ShipmentApiData) => transformApiToDetail(d), - errorMessage: '출고 등록에 실패했습니다.', - }); - if (result.__authError) return { success: false, __authError: true }; - return { success: result.success, data: result.data, error: result.error }; -} - // ===== 출고 수정 ===== export async function updateShipment( id: string, data: Partial @@ -493,16 +476,6 @@ export async function deleteShipment(id: string): Promise<{ success: boolean; er return { success: result.success, error: result.error }; } -// ===== LOT 옵션 조회 ===== -export async function getLotOptions(): Promise<{ success: boolean; data: LotOption[]; error?: string; __authError?: boolean }> { - const result = await executeServerAction({ - url: buildApiUrl('/api/v1/shipments/options/lots'), - errorMessage: 'LOT 옵션 조회에 실패했습니다.', - }); - if (result.__authError) return { success: false, data: [], __authError: true }; - return { success: result.success, data: result.data || [], error: result.error }; -} - // ===== 물류사 옵션 조회 ===== export async function getLogisticsOptions(): Promise<{ success: boolean; data: LogisticsOption[]; error?: string; __authError?: boolean }> { const result = await executeServerAction({ diff --git a/src/components/outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx b/src/components/outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx index e3db4f54..6355f492 100644 --- a/src/components/outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx +++ b/src/components/outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx @@ -6,12 +6,13 @@ */ import type { ShipmentDetail } from '../types'; -import { ShipmentOrderDocument } from './ShipmentOrderDocument'; +import { ShipmentOrderDocument, type OrderDocumentDetail } from './ShipmentOrderDocument'; interface DeliveryConfirmationProps { data: ShipmentDetail; + orderDetail?: OrderDocumentDetail | null; } -export function DeliveryConfirmation({ data }: DeliveryConfirmationProps) { - return ; -} +export function DeliveryConfirmation({ data, orderDetail }: DeliveryConfirmationProps) { + return ; +} \ No newline at end of file diff --git a/src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx b/src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx index 8d9c9161..34a732a2 100644 --- a/src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx +++ b/src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx @@ -4,6 +4,7 @@ * 출고 문서 공통 컴포넌트 (기획서 D1.8 기준 리디자인) * - 출고증: showDispatchInfo + showLotColumn * - 납품확인서: 기본값 (배차정보 없음, LOT 컬럼 없음) + * - BOM 데이터는 orderDetail props로 전달 (수주서와 동일 API 활용) */ import { useState } from 'react'; @@ -12,78 +13,127 @@ import { DELIVERY_METHOD_LABELS } from '../types'; import { ConstructionApprovalTable } from '@/components/document-system'; import { formatNumber } from '@/lib/utils/amount'; +// ===== BOM 데이터 타입 (수주서와 동일) ===== + +interface ProductRow { + no: number; + floor?: string; + symbol?: string; + product_name?: string; + product_code?: string; + product_type?: string; + open_width?: number | string; + open_height?: number | string; + made_width?: number | string; + made_height?: number | string; + guide_rail?: string; + shaft?: string | number; + case_inch?: string | number; + bracket?: string; + capacity?: string | number; + finish?: string; + joint_bar?: number | null; +} + +interface MotorRow { + item: string; + type: string; + spec: string; + qty: number; + lot?: string; +} + +interface BendingItem { + name: string; + spec: string; + qty: number; +} + +interface BendingGroup { + group: string; + items: BendingItem[]; +} + +interface SubsidiaryItem { + name: string; + spec: string; + qty: number; +} + +export interface OrderDocumentDetail { + products?: ProductRow[]; + motors?: { left?: MotorRow[]; right?: MotorRow[] }; + bending_parts?: BendingGroup[]; + subsidiary_parts?: SubsidiaryItem[]; + category_code?: string; + // 수주 기본 정보 + order_no?: string; + received_at?: string; + delivery_date?: string; + client_name?: string; + client_contact?: string; + manager_name?: string; + receiver?: string; + receiver_contact?: string; + shipping_address?: string; + shipping_address_detail?: string; +} + interface ShipmentOrderDocumentProps { title: string; data: ShipmentDetail; + orderDetail?: OrderDocumentDetail | null; showDispatchInfo?: boolean; showLotColumn?: boolean; } -// ===== 문서 전용 목데이터 ===== - -const MOCK_SCREEN_ROWS = [ - { no: 1, type: '이(마)', code: 'FA123', openW: 4300, openH: 4300, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, - { no: 2, type: '이(마)', code: 'FA123', openW: 4300, openH: 4300, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, -]; - -const MOCK_STEEL_ROWS = [ - { no: 1, code: 'FA123', openW: 4300, openH: 3000, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, jointBar: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, - { no: 2, code: 'FA123', openW: 4300, openH: 3000, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, jointBar: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, -]; - -const MOCK_MOTOR_LEFT = [ - { item: '모터', type: '380V 단상', spec: 'KD-150K', qty: 6, lot: '123123' }, - { item: '브라켓트', type: '-', spec: '380X180', qty: 6, lot: '123123' }, - { item: '앵글', type: '밑침통 영금', spec: '40*40*380', qty: 4, lot: '123123' }, -]; - -const MOCK_MOTOR_RIGHT = [ - { item: '전동개폐기', type: '릴박스', spec: '-', qty: 1, lot: '123123' }, - { item: '전동개폐기', type: '매입', spec: '-', qty: 1, lot: '123123' }, -]; - -const MOCK_GUIDE_RAIL_ITEMS = [ - { name: '항목명', spec: 'L: 3,000', qty: 22 }, - { name: '하부BASE', spec: '130X80', qty: 22 }, -]; - -const MOCK_GUIDE_SMOKE = { name: '연기차단재(W50)', spec: '2,438', qty: 4 }; - -const MOCK_CASE_ITEMS = [ - { name: '500X330', spec: 'L: 4,000', qty: 3 }, - { name: '500X330', spec: 'L: 5,000', qty: 4 }, - { name: '상부덮개', spec: '1219X389', qty: 55 }, - { name: '측면부 (마구리)', spec: '500X355', qty: '500X355' }, -]; - -const MOCK_CASE_SMOKE = { name: '연기차단재(W80)', spec: '3,000', qty: 4 }; - -const MOCK_BOTTOM_SCREEN = [ - { name: '하단마감재', spec: '60X40', l1: 'L: 3,000', q1: 6, name2: '하단마감재', spec2: '60X40', l2: 'L: 4,000', q2: 6 }, - { name: '하단보강엘비', spec: '60X17', l1: 'L: 3,000', q1: 6, name2: '하단보강엘비', spec2: '60X17', l2: 'L: 4,000', q2: 6 }, - { name: '하단보강평철', spec: '-', l1: 'L: 3,000', q1: 6, name2: '하단보강평철', spec2: '-', l2: 'L: 4,000', q2: 6 }, - { name: '하단무게평철', spec: '50X12T', l1: 'L: 3,000', q1: 6, name2: '하단무게평철', spec2: '50X12T', l2: 'L: 4,000', q2: 6 }, -]; - -const MOCK_BOTTOM_STEEL = { spec: '60X40', length: 'L: 3,000', qty: 22 }; - -const MOCK_SUBSIDIARY = [ - { leftItem: '감기사프트', leftSpec: '4인치 4500', leftQty: 6, rightItem: '각파이프', rightSpec: '6000', rightQty: 4 }, - { leftItem: '조인트바', leftSpec: '300', leftQty: 6, rightItem: '환봉', rightSpec: '3000', rightQty: 5 }, -]; - // ===== 공통 스타일 ===== const thBase = 'border-r border-gray-400 px-1 py-1'; const tdBase = 'border-r border-gray-300 px-1 py-1'; const tdCenter = `${tdBase} text-center`; const imgPlaceholder = 'flex items-center justify-center border border-dashed border-gray-300 text-gray-400'; -export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, showLotColumn = false }: ShipmentOrderDocumentProps) { +export function ShipmentOrderDocument({ title, data, orderDetail, showDispatchInfo = false, showLotColumn = false }: ShipmentOrderDocumentProps) { const [bottomFinishView, setBottomFinishView] = useState<'screen' | 'steel'>('screen'); const deliveryMethodLabel = DELIVERY_METHOD_LABELS[data.deliveryMethod] || '-'; const fullAddress = [data.address, data.addressDetail].filter(Boolean).join(' ') || data.deliveryAddress || '-'; - const motorRows = Math.max(MOCK_MOTOR_LEFT.length, MOCK_MOTOR_RIGHT.length); + + // BOM 데이터 (orderDetail에서 추출) + const productRows = orderDetail?.products || []; + const motorsLeft = orderDetail?.motors?.left || []; + const motorsRight = orderDetail?.motors?.right || []; + const bendingParts = orderDetail?.bending_parts || []; + const subsidiaryParts = orderDetail?.subsidiary_parts || []; + + const motorRows = Math.max(motorsLeft.length, motorsRight.length); + + // 스크린/철재 제품 분리 + const screenProducts = productRows.filter(p => p.product_type !== 'steel'); + const steelProducts = productRows.filter(p => p.product_type === 'steel'); + + // 절곡물 그룹 데이터 추출 + const guideRailItems = bendingParts.find(g => g.group === '가이드레일')?.items ?? []; + const caseItems = bendingParts.find(g => g.group === '케이스')?.items ?? []; + const bottomItems = bendingParts.find(g => g.group === '하단마감')?.items ?? []; + const smokeItems = bendingParts.find(g => g.group === '연기차단재')?.items ?? []; + const guideSmokeItems = smokeItems.filter(i => i.name.includes('레일') || i.name.includes('가이드')); + const caseSmokeItems = smokeItems.filter(i => i.name.includes('케이스')); + const otherSmokeItems = smokeItems.filter(i => + !i.name.includes('레일') && !i.name.includes('가이드') && !i.name.includes('케이스') + ); + + // 부자재 좌/우 2열 변환 + const subsidiaryRows = []; + for (let i = 0; i < subsidiaryParts.length; i += 2) { + subsidiaryRows.push({ + left: subsidiaryParts[i], + right: subsidiaryParts[i + 1] ?? null, + }); + } + + // 셔터 수량: productRows 기준 (없으면 productGroups fallback) + const shutterCount = productRows.length || data.productGroups.length; return (
@@ -98,7 +148,7 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, s
- +
{/* ========== 로트번호 / 제품명 / 제품코드 / 인정번호 ========== */} @@ -106,13 +156,13 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, s 로트번호 - {data.lotNo} + {data.orderLotNo || data.lotNo} 제품명 - {data.productGroups[0]?.productName || '-'} + {productRows[0]?.product_name || data.productGroups[0]?.productName || '-'} 제품코드 - KWS01 + {productRows[0]?.product_code || '-'} 인정번호 - ABC1234 + {orderDetail?.category_code || '-'} @@ -127,7 +177,7 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, s 수주일 - {data.scheduledDate} + {orderDetail?.received_at || data.scheduledDate} 수주처 @@ -135,11 +185,11 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, s 수주 담당자 - {data.registrant || '-'} + {orderDetail?.manager_name || data.registrant || '-'} 담당자 연락처 - {data.driverContact || '-'} + {orderDetail?.client_contact || data.driverContact || '-'} @@ -156,7 +206,7 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, s 납기요청일 - {data.scheduledDate} + {orderDetail?.delivery_date || data.scheduledDate} 출고일 @@ -164,7 +214,7 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, s 셔터출수량 - {data.productGroups.length}개소 + {shutterCount}개소 @@ -234,404 +284,390 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, s ); })()} - {/* ========== 자재 및 철거 내역 헤더 ========== */} -
- 자재 및 철거 내역 -
+

아래와 같이 주문하오니 품질 및 납기일을 준수하여 주시기 바랍니다.

{/* ========== 1. 스크린 ========== */} -
-

1. 스크린

-
- - - - - - - - - - - - - - - - - - - - - - - - - {MOCK_SCREEN_ROWS.map((row) => ( - - - - - - - - - - - - - - + {screenProducts.length > 0 && ( +
+

1. 스크린

+
+
No품류부호오픈사이즈제작사이즈가이드
레일
사프트
(인치)
케이스
(인치)
모터마감
가로세로가로세로브라켓트용량Kg
{row.no}{row.type}{row.code}{formatNumber(row.openW)}{formatNumber(row.openH)}{formatNumber(row.madeW)}{formatNumber(row.madeH)}{row.guideRail}{row.shaft}{row.caseInch}{row.bracket}{row.capacity}{row.finish}
+ + + + + + + + + + + + - ))} - -
No품류부호오픈사이즈제작사이즈가이드
레일
사프트
(인치)
케이스
(인치)
모터마감
+ + 가로 + 세로 + 가로 + 세로 + 브라켓트 + 용량Kg + + + + {screenProducts.map((row) => ( + + {row.no} + {row.floor ?? '-'} + {row.symbol ?? '-'} + {row.open_width ? formatNumber(Number(row.open_width)) : '-'} + {row.open_height ? formatNumber(Number(row.open_height)) : '-'} + {row.made_width ? formatNumber(Number(row.made_width)) : '-'} + {row.made_height ? formatNumber(Number(row.made_height)) : '-'} + {row.guide_rail ?? '-'} + {row.shaft ?? '-'} + {row.case_inch ?? '-'} + {row.bracket ?? '-'} + {row.capacity ?? '-'} + {row.finish ?? '-'} + + ))} + + +
-
+ )} - {/* ========== 2. 절재 ========== */} -
-

2. 절재

-
- - - - - - - - - - - - - - - - - - - - - - - - - {MOCK_STEEL_ROWS.map((row) => ( - - - - - - - - - - - - - - + {/* ========== 2. 철재 ========== */} + {steelProducts.length > 0 && ( +
+

2. 철재

+
+
No.부호오픈사이즈제작사이즈가이드
레일
사프트
(인치)
조인트바
(규격)
케이스
(인치)
모터마감
가로세로가로세로브라켓트용량Kg
{row.no}{row.code}{formatNumber(row.openW)}{formatNumber(row.openH)}{formatNumber(row.madeW)}{formatNumber(row.madeH)}{row.guideRail}{row.shaft}{row.jointBar}{row.caseInch}{row.bracket}{row.capacity}{row.finish}
+ + + + + + + + + + + + - ))} - -
No.부호오픈사이즈제작사이즈가이드
레일
사프트
(인치)
조인트바
(규격)
케이스
(인치)
모터마감
+ + 가로 + 세로 + 가로 + 세로 + 브라켓트 + 용량Kg + + + + {steelProducts.map((row) => ( + + {row.no} + {row.symbol ?? '-'} + {row.open_width ? formatNumber(Number(row.open_width)) : '-'} + {row.open_height ? formatNumber(Number(row.open_height)) : '-'} + {row.made_width ? formatNumber(Number(row.made_width)) : '-'} + {row.made_height ? formatNumber(Number(row.made_height)) : '-'} + {row.guide_rail ?? '-'} + {row.shaft ?? '-'} + {row.joint_bar ?? '-'} + {row.case_inch ?? '-'} + {row.bracket ?? '-'} + {row.capacity ?? '-'} + {row.finish ?? '-'} + + ))} + + +
-
+ )} {/* ========== 3. 모터 ========== */} -
-

3. 모터

-
- - - - - - - - {showLotColumn && } - - - - - {showLotColumn && } - - - - {Array.from({ length: motorRows }).map((_, i) => { - const left = MOCK_MOTOR_LEFT[i]; - const right = MOCK_MOTOR_RIGHT[i]; - return ( - - - - - - {showLotColumn && } - - - - - {showLotColumn && } - - ); - })} - -
항목구분규격수량입고 LOT항목구분규격수량입고 LOT
{left?.item || ''}{left?.type || ''}{left?.spec || ''}{left?.qty ?? ''}{left?.lot || ''}{right?.item || ''}{right?.type || ''}{right?.spec || ''}{right?.qty ?? ''}{right?.lot || ''}
+ {motorRows > 0 && ( +
+

3. 모터

+
+ + + + + + + + {showLotColumn && } + + + + + {showLotColumn && } + + + + {Array.from({ length: motorRows }).map((_, i) => { + const left = motorsLeft[i]; + const right = motorsRight[i]; + return ( + + + + + + {showLotColumn && } + + + + + {showLotColumn && } + + ); + })} + +
항목구분규격수량입고 LOT항목구분규격수량입고 LOT
{left?.item || ''}{left?.type || ''}{left?.spec || ''}{left?.qty ?? ''}{left?.lot || ''}{right?.item || ''}{right?.type || ''}{right?.spec || ''}{right?.qty ?? ''}{right?.lot || ''}
+
-
+ )} {/* ========== 4. 절곡물 ========== */} -
-

4. 절곡물

+ {bendingParts.length > 0 && ( +
+

4. 절곡물

- {/* 4-1. 가이드레일 */} -
-

4-1. 가이드레일 - EGI 1.5ST + 마감재 EGI 1.1ST + 별도마감재 SUS 1.1ST

- - {/* 메인 테이블 */} -
- - - - - - - - - - - {MOCK_GUIDE_RAIL_ITEMS.map((item, i) => ( - - {i === 0 && ( - - )} - - - - - ))} - -
백면형 (120X70)항목규격수량
-
IMG
-
{item.name}{item.spec}{item.qty}
-
- - {/* 연기차단재 */} -
- - - - - - - - - - - - - - - - - -
 항목규격수량
-
IMG
-
{MOCK_GUIDE_SMOKE.name}{MOCK_GUIDE_SMOKE.spec}{MOCK_GUIDE_SMOKE.qty}
-
- -

- * 가이드레일 마감재 양측에 설치 - EGI 0.8T + 화이버글라스코팅직물 -

-
- - {/* 4-2. 케이스(셔터박스) */} -
-

4-2. 케이스(셔터박스) - EGI 1.5ST

- - {/* 메인 테이블 */} -
- - - - - - - - - - - {MOCK_CASE_ITEMS.map((item, i) => ( - - {i === 0 && ( - - )} - - - - - ))} - -
 항목규격수량
-
IMG
-
{item.name}{item.spec}{item.qty}
-
- - {/* 연기차단재 */} -
- - - - - - - - - - - - - - - - - -
 항목규격수량
-
IMG
-
{MOCK_CASE_SMOKE.name}{MOCK_CASE_SMOKE.spec}{MOCK_CASE_SMOKE.qty}
-
- -

- * 전면부, 판넬부 양측에 설치 - EGI 0.8T + 화이버글라스코팅직물 -

-
- - {/* 4-3. 하단마감재 (토글: 스크린 / 절재) */} -
- {/* 토글 버튼 */} -
- - -
- - {bottomFinishView === 'screen' ? ( - <> -

- 4-3. 하단마감재 - 하단마감재(EGI 1.5ST) + 하단보강엘비(EGI 1.5ST) + 하단 보강평철(EGI 1.1ST) + 하단 무게평철(50X12T) -

+ {/* 4-1. 가이드레일 */} + {guideRailItems.length > 0 && ( +
+

4-1. 가이드레일

- - - - - - - - + + + + - {MOCK_BOTTOM_SCREEN.map((row, i) => ( + {guideRailItems.map((item, i) => ( - - - - - - - - + {i === 0 && ( + + )} + + + ))}
항목규격길이수량항목규격길이수량 항목규격수량
{row.name}{row.spec}{row.l1}{row.q1}{row.name2}{row.spec2}{row.l2}{row.q2} +
IMG
+
{item.name}{item.spec}{item.qty}
- - ) : ( - <> -

- 4-3. 하단마감재 -EGI 1.5ST + + {/* 가이드레일 연기차단재 */} + {guideSmokeItems.length > 0 && ( +

+ + + + + + + + + + + {guideSmokeItems.map((item, i) => ( + + {i === 0 && ( + + )} + + + + + ))} + +
 항목규격수량
+
IMG
+
{item.name}{item.spec}{item.qty}
+
+ )} + +

+ * 가이드레일 마감재 양측에 설치 - EGI 0.8T + 화이버글라스코팅직물

+
+ )} + + {/* 4-2. 케이스(셔터박스) */} + {caseItems.length > 0 && ( +
+

4-2. 케이스(셔터박스)

- - - - + + + + - - - - - - + {caseItems.map((item, i) => ( + + {i === 0 && ( + + )} + + + + + ))}
하단마감재규격길이수량 항목규격수량
-
IMG
-
{MOCK_BOTTOM_STEEL.spec}{MOCK_BOTTOM_STEEL.length}{MOCK_BOTTOM_STEEL.qty}
+
IMG
+
{item.name}{item.spec}{item.qty}
- + + {/* 케이스 연기차단재 */} + {caseSmokeItems.length > 0 && ( +
+ + + + + + + + + + + {caseSmokeItems.map((item, i) => ( + + {i === 0 && ( + + )} + + + + + ))} + +
 항목규격수량
+
IMG
+
{item.name}{item.spec}{item.qty}
+
+ )} + +

+ * 전면부, 판넬부 양측에 설치 - EGI 0.8T + 화이버글라스코팅직물 +

+
+ )} + + {/* 4-3. 하단마감재 */} + {bottomItems.length > 0 && ( +
+

4-3. 하단마감재

+
+ + + + + + + + + + + {bottomItems.map((item, i) => ( + + {i === 0 && ( + + )} + + + + + ))} + +
 항목규격수량
+
IMG
+
{item.name}{item.spec}{item.qty}
+
+
+ )} + + {/* 연기차단재 (구분 불가) */} + {otherSmokeItems.length > 0 && ( +
+

연기차단재

+
+ + + + + + + + + + {otherSmokeItems.map((item, i) => ( + + + + + + ))} + +
항목규격수량
{item.name}{item.spec}{item.qty}
+
+
)}
-
+ )} {/* ========== 5. 부자재 ========== */} -
-

5. 부자재

-
- - - - - - - - - - - - - {MOCK_SUBSIDIARY.map((row, i) => ( - - - - - - - + {subsidiaryParts.length > 0 && ( +
+

5. 부자재

+
+
항목규격수량항목규격수량
{row.leftItem}{row.leftSpec}{row.leftQty}{row.rightItem}{row.rightSpec}{row.rightQty}
+ + + + + + + + - ))} - -
항목규격수량항목규격수량
+ + + {subsidiaryRows.map((row, i) => ( + + {row.left?.name ?? ''} + {row.left?.spec ?? ''} + {row.left?.qty ?? ''} + {row.right?.name ?? ''} + {row.right?.spec ?? ''} + {row.right?.qty ?? ''} + + ))} + + +
-
+ )} {/* ========== 특이사항 ========== */} {data.remarks && ( @@ -644,4 +680,4 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, s )}
); -} +} \ No newline at end of file diff --git a/src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx b/src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx index f76d7e80..1cf0ba74 100644 --- a/src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx +++ b/src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx @@ -6,12 +6,13 @@ */ import type { ShipmentDetail } from '../types'; -import { ShipmentOrderDocument } from './ShipmentOrderDocument'; +import { ShipmentOrderDocument, type OrderDocumentDetail } from './ShipmentOrderDocument'; interface ShippingSlipProps { data: ShipmentDetail; + orderDetail?: OrderDocumentDetail | null; } -export function ShippingSlip({ data }: ShippingSlipProps) { - return ; -} +export function ShippingSlip({ data, orderDetail }: ShippingSlipProps) { + return ; +} \ No newline at end of file diff --git a/src/components/outbound/ShipmentManagement/index.ts b/src/components/outbound/ShipmentManagement/index.ts index 7aed8230..ffa8d522 100644 --- a/src/components/outbound/ShipmentManagement/index.ts +++ b/src/components/outbound/ShipmentManagement/index.ts @@ -3,7 +3,6 @@ */ export { ShipmentList } from './ShipmentList'; -export { ShipmentCreate } from './ShipmentCreate'; export { ShipmentDetail } from './ShipmentDetail'; export { ShipmentEdit } from './ShipmentEdit'; diff --git a/src/components/outbound/ShipmentManagement/shipmentConfig.ts b/src/components/outbound/ShipmentManagement/shipmentConfig.ts index 4afb9ccb..0b318272 100644 --- a/src/components/outbound/ShipmentManagement/shipmentConfig.ts +++ b/src/components/outbound/ShipmentManagement/shipmentConfig.ts @@ -37,12 +37,11 @@ export const shipmentConfig: DetailConfig = { }; /** - * 출고 등록 페이지 Config - * IntegratedDetailTemplate 마이그레이션 (2025-01-20) + * 출고 수정 페이지 Config */ -export const shipmentCreateConfig: DetailConfig = { +export const shipmentEditConfig: DetailConfig = { title: '출고', - description: '새로운 출고를 등록합니다', + description: '출고 정보를 수정합니다', icon: Truck, basePath: '/outbound/shipments', fields: [], @@ -53,13 +52,4 @@ export const shipmentCreateConfig: DetailConfig = { showSave: true, submitLabel: '저장', }, -}; - -/** - * 출고 수정 페이지 Config - */ -export const shipmentEditConfig: DetailConfig = { - ...shipmentCreateConfig, - title: '출고', - description: '출고 정보를 수정합니다', -}; +}; \ No newline at end of file diff --git a/src/components/outbound/ShipmentManagement/types.ts b/src/components/outbound/ShipmentManagement/types.ts index f0306f65..1a30a24d 100644 --- a/src/components/outbound/ShipmentManagement/types.ts +++ b/src/components/outbound/ShipmentManagement/types.ts @@ -111,7 +111,8 @@ export const DELIVERY_METHOD_LABELS: Record = { export interface ShipmentItem { id: string; shipmentNo: string; // 출고번호 - lotNo: string; // 로트번호 + lotNo: string; // 출고로트 + orderLotNo?: string; // 수주로트 (수주번호) scheduledDate: string; // 출고예정일 status: ShipmentStatus; // 상태 priority: ShipmentPriority; // 우선순위 @@ -158,8 +159,10 @@ export interface ShipmentProduct { export interface ShipmentDetail { // 기본 정보 (읽기전용) id: string; + orderId?: number; // 수주 ID (문서 BOM 데이터 조회용) shipmentNo: string; // 출고번호 - lotNo: string; // 로트번호 + lotNo: string; // 출고로트 + orderLotNo?: string; // 수주로트 (수주번호) siteName: string; // 현장명 customerName: string; // 수주처 customerGrade: string; // 거래등급 From 72a34d729c70ca90a567e065358b27bde3589db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 18 Mar 2026 09:21:48 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20[quotes]=20=EB=B6=80=EA=B0=80?= =?UTF-8?q?=EC=84=B8=20=ED=83=80=EC=9E=85(vatType)=20options=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20+=20QuoteApiData=20=ED=83=80=EC=9E=85=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/quotes/types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts index 353026aa..08000495 100644 --- a/src/components/quotes/types.ts +++ b/src/components/quotes/types.ts @@ -213,6 +213,8 @@ export interface QuoteApiData { updated_by: number | null; finalized_at: string | null; finalized_by: number | null; + // options JSON (부가세 타입 등) + options?: Record | null; // 연결된 수주 ID (수주전환 시 설정) order_id?: number | null; // 관계 데이터 (with 로드 시) @@ -734,6 +736,8 @@ export interface QuoteFormDataV2 { dueDate: string; remarks: string; status: 'draft' | 'temporary' | 'final' | 'converted'; // 작성중, 임시저장, 최종저장, 수주전환 + + vatType: 'included' | 'excluded'; // 부가세 포함/별도 discountRate: number; // 할인율 (%) discountAmount: number; // 할인 금액 locations: LocationItem[]; @@ -919,6 +923,9 @@ export function transformV2ToApi( status: data.status === 'final' ? 'finalized' : 'draft', is_final: data.status === 'final', calculation_inputs: calculationInputs, + options: { + vat_type: data.vatType || 'included', + }, items: items, }; } @@ -1042,6 +1049,9 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 { remarks: apiData.remarks || apiData.description || transformed.description || '', status: mapStatus(apiData.status), // raw API: discount_rate, transformed: discountRate + vatType: apiData.options?.vat_type as 'included' | 'excluded' + || (transformed as unknown as { vatType?: string })?.vatType as 'included' | 'excluded' + || 'included', discountRate: Number(apiData.discount_rate) || transformed.discountRate || 0, discountAmount: Number(apiData.discount_amount) || transformed.discountAmount || 0, locations: locations, From 08b0b43eb87337e631ae16cafc6df1d2ab028089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 18 Mar 2026 09:21:53 +0900 Subject: [PATCH 05/20] =?UTF-8?q?fix:=20[quality]=20=EC=A0=9C=ED=92=88?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=93=B1=EB=A1=9D=20=ED=9B=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=AF=B8=EB=85=B8=EC=B6=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quality/InspectionManagement/InspectionCreate.tsx | 1 + .../quality/InspectionManagement/InspectionList.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/quality/InspectionManagement/InspectionCreate.tsx b/src/components/quality/InspectionManagement/InspectionCreate.tsx index f58c5757..2f26eadb 100644 --- a/src/components/quality/InspectionManagement/InspectionCreate.tsx +++ b/src/components/quality/InspectionManagement/InspectionCreate.tsx @@ -68,6 +68,7 @@ export function InspectionCreate() { client: '', manager: '', managerContact: '', + receptionDate: new Date().toISOString().slice(0, 10), constructionSite: { ...emptyConstructionSite }, materialDistributor: { ...emptyMaterialDistributor }, constructorInfo: { ...emptyConstructor }, diff --git a/src/components/quality/InspectionManagement/InspectionList.tsx b/src/components/quality/InspectionManagement/InspectionList.tsx index 2975a8be..fbc86aa9 100644 --- a/src/components/quality/InspectionManagement/InspectionList.tsx +++ b/src/components/quality/InspectionManagement/InspectionList.tsx @@ -54,9 +54,6 @@ const ITEMS_PER_PAGE = 20; export function InspectionList() { const router = useRouter(); - // ===== 통계 ===== - const { data: statsData, reload: reloadStats } = useStatsLoader(getInspectionStats); - // ===== 날짜 범위 ===== const today = new Date(); const [startDate, setStartDate] = useState(() => { @@ -68,6 +65,11 @@ export function InspectionList() { return getLocalDateString(d); }); + // ===== 통계 (목록과 동일 날짜 범위 적용) ===== + const { data: statsData, reload: reloadStats } = useStatsLoader( + useCallback(() => getInspectionStats({ dateFrom: startDate, dateTo: endDate }), [startDate, endDate]) + ); + // ===== 캘린더 상태 ===== const [calendarDate, setCalendarDate] = useState(new Date()); const [scheduleView, setScheduleView] = useState('month'); From 87287552fdb9331580229ab054e1a4c632c1442c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 18 Mar 2026 09:21:57 +0900 Subject: [PATCH 06/20] =?UTF-8?q?perf:=20[production-orders]=20stats=20API?= =?UTF-8?q?=20=EC=9D=B4=EC=A4=91=20=ED=98=B8=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order-management-sales/production-orders/page.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx index f80ae94f..c2214458 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx @@ -10,7 +10,7 @@ * - 서버사이드 페이지네이션 */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -181,15 +181,6 @@ export default function ProductionOrdersListPage() { completed: 0, }); - // 통계 로드 - useEffect(() => { - getProductionOrderStats().then((result) => { - if (result.success && result.data) { - setStats(result.data); - } - }); - }, []); - const handleBack = () => { router.push("/sales/order-management-sales"); }; From 0bcc7c54179c7494e958b3f2368397ab11138ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 18 Mar 2026 11:15:19 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20[material]=20=EC=9E=AC=EA=B3=A0?= =?UTF-8?q?=ED=98=84=ED=99=A9=20=EC=83=81=EC=84=B8=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?+=20=EC=9E=85=EA=B3=A0=EA=B4=80=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20+=20BOM=20=ED=8A=B8=EB=A6=AC=EB=B7=B0=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재고현황 상세 페이지 대폭 개선 - 입고관리 상세/목록 코드 정리 - BomTreeViewer 컴포넌트 신규 - 품목 상세 수정 --- .../item-management/ItemDetailClient.tsx | 5 + src/components/items/BomTreeViewer.tsx | 304 ++++++++++++++ src/components/items/ItemDetailClient.tsx | 58 +-- .../InventoryAdjustmentDialog.tsx | 4 +- .../ReceivingManagement/ReceivingDetail.tsx | 153 +------ .../ReceivingManagement/ReceivingList.tsx | 30 +- .../material/ReceivingManagement/actions.ts | 2 +- .../StockStatus/StockStatusDetail.tsx | 396 +++++++++++++----- .../material/StockStatus/actions.ts | 35 ++ 9 files changed, 654 insertions(+), 333 deletions(-) create mode 100644 src/components/items/BomTreeViewer.tsx diff --git a/src/components/business/construction/item-management/ItemDetailClient.tsx b/src/components/business/construction/item-management/ItemDetailClient.tsx index c54c6a60..cca78995 100644 --- a/src/components/business/construction/item-management/ItemDetailClient.tsx +++ b/src/components/business/construction/item-management/ItemDetailClient.tsx @@ -30,6 +30,7 @@ import { UNIT_OPTIONS, } from './constants'; import { getItem, createItem, updateItem, deleteItem, getCategoryOptions } from './actions'; +import { BomTreeViewer } from '@/components/items/BomTreeViewer'; interface ItemDetailClientProps { itemId?: string; @@ -460,6 +461,9 @@ export default function ItemDetailClient({ )} + + {/* BOM 트리 */} + {itemId && }
), [ formData, @@ -469,6 +473,7 @@ export default function ItemDetailClient({ handleAddOrderItem, handleRemoveOrderItem, handleOrderItemChange, + itemId, ]); return ( diff --git a/src/components/items/BomTreeViewer.tsx b/src/components/items/BomTreeViewer.tsx new file mode 100644 index 00000000..2c66fa36 --- /dev/null +++ b/src/components/items/BomTreeViewer.tsx @@ -0,0 +1,304 @@ +'use client'; + +/** + * BOM Tree 시각화 컴포넌트 + * + * API: GET /api/proxy/items/{id}/bom/tree + * 재귀적 트리 렌더링 + 유형별 뱃지 색상 + 펼침/접힘 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { ChevronDown, ChevronRight, ChevronsUpDown, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Package } from 'lucide-react'; + +// BOM 트리 노드 타입 +interface BomTreeNode { + id: number; + code: string; + name: string; + item_type: string; + specification?: string; + unit?: string; + quantity: number; + depth: number; + children: BomTreeNode[]; +} + +// 유형별 뱃지 스타일 +const ITEM_TYPE_COLORS: Record = { + FG: 'bg-blue-100 text-blue-800', + PT: 'bg-green-100 text-green-800', + RM: 'bg-orange-100 text-orange-800', + SM: 'bg-purple-100 text-purple-800', + CS: 'bg-gray-100 text-gray-800', + BN: 'bg-pink-100 text-pink-800', + SF: 'bg-cyan-100 text-cyan-800', +}; + +const ITEM_TYPE_LABELS: Record = { + FG: '완제품', + PT: '부품', + RM: '원자재', + SM: '부자재', + CS: '소모품', + BN: '절곡품', + SF: '반제품', +}; + +// 모든 노드의 ID를 재귀적으로 수집 +function collectNodeIds(nodes: BomTreeNode[]): Set { + const ids = new Set(); + function walk(node: BomTreeNode) { + ids.add(node.id); + node.children.forEach(walk); + } + nodes.forEach(walk); + return ids; +} + +// 개별 트리 노드 컴포넌트 +function BomTreeNodeItem({ + node, + level = 0, + expandedNodes, + onToggle, +}: { + node: BomTreeNode; + level?: number; + expandedNodes: Set; + onToggle: (id: number) => void; +}) { + const hasChildren = node.children.length > 0; + const isOpen = expandedNodes.has(node.id); + + return ( +
+
+ {/* 펼침/접힘 */} + {hasChildren ? ( + + ) : ( + + )} + + {/* 유형 뱃지 */} + + {node.item_type} + + + {/* 코드 */} + + {node.code} + + + {/* 품목명 */} + {node.name} + + {/* 규격 */} + {node.specification && ( + + ({node.specification}) + + )} + + {/* 수량 */} + + ×{node.quantity} + + + {/* 단위 */} + {node.unit && ( + {node.unit} + )} +
+ + {/* 자식 노드 */} + {isOpen && hasChildren && node.children.map((child) => ( + + ))} +
+ ); +} + +interface BomTreeViewerProps { + itemId: string; + itemType: string; +} + +export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) { + const [treeData, setTreeData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + + // 트리 데이터 로드 + const loadTree = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await fetch(`/api/proxy/items/${itemId}/bom/tree`); + const result = await response.json(); + + if (result.success && result.data) { + const data = Array.isArray(result.data) ? result.data : [result.data]; + setTreeData(data); + // 기본: 2단계까지 펼침 + const allIds = collectNodeIds(data); + setExpandedNodes(allIds); + } else { + setTreeData([]); + } + } catch { + setError('BOM 트리를 불러오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }, [itemId]); + + useEffect(() => { + // FG/PT (또는 한글: 제품/부품)만 BOM 트리 로드 + const isBomTarget = ['FG', 'PT', '제품', '부품'].includes(itemType); + if (isBomTarget) { + loadTree(); + } else { + setIsLoading(false); + } + }, [loadTree, itemType]); + + // BOM 대상이 아니면 렌더링하지 않음 + if (!['FG', 'PT', '제품', '부품'].includes(itemType)) return null; + + // 로딩 + if (isLoading) { + return ( + + + + BOM 트리를 불러오는 중... + + + ); + } + + // 에러 + if (error) { + return ( + + + {error} + + + + ); + } + + // 데이터 없음 + if (treeData.length === 0) { + return ( + + + + + BOM 트리 + + + +

+ 등록된 BOM 정보가 없습니다. +

+
+
+ ); + } + + const toggleNode = (id: number) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const expandAll = () => setExpandedNodes(collectNodeIds(treeData)); + const collapseAll = () => setExpandedNodes(new Set()); + + // 총 노드 수 계산 + const totalCount = collectNodeIds(treeData).size; + + return ( + + +
+ + + BOM 트리 + +
+ + 총 {totalCount}개 품목 + + + +
+
+
+ + {/* 범례 */} +
+ {Object.entries(ITEM_TYPE_LABELS).map(([type, label]) => ( +
+ + {type} + + {label} +
+ ))} +
+ + {/* 트리 */} +
+ {treeData.map((node) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/items/ItemDetailClient.tsx b/src/components/items/ItemDetailClient.tsx index 8fb254e9..b1dd133d 100644 --- a/src/components/items/ItemDetailClient.tsx +++ b/src/components/items/ItemDetailClient.tsx @@ -28,6 +28,7 @@ import { TableRow, } from '@/components/ui/table'; import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react'; +import { BomTreeViewer } from './BomTreeViewer'; import { downloadFileById } from '@/lib/utils/fileDownload'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { useMenuStore } from '@/stores/menuStore'; @@ -554,60 +555,9 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) { )} - {/* BOM 정보 - 절곡 부품은 제외 */} - {(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && ( - - -
- - - 부품 구성 (BOM) - - - 총 {item.bom.length}개 품목 - -
-
- -
- - - - 번호 - 품목코드 - 품목명 - 수량 - 단위 - - - - {item.bom.map((line, index) => ( - - {index + 1} - - - {line.childItemCode} - - - -
- {line.childItemName} - {line.isBending && ( - - 절곡품 - - )} -
-
- {line.quantity} - {line.unit} -
- ))} -
-
-
-
-
+ {/* BOM 트리 - FG/PT만 표시 (절곡 부품 제외) */} + {(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && ( + )} {/* 하단 액션 버튼 (sticky) */} diff --git a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx index d2afe837..b0bac1ea 100644 --- a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx +++ b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx @@ -124,7 +124,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props) return ( - + 재고 조정 @@ -162,7 +162,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props) {/* 테이블 */} -
+
diff --git a/src/components/material/ReceivingManagement/ReceivingDetail.tsx b/src/components/material/ReceivingManagement/ReceivingDetail.tsx index b70810fa..1e323744 100644 --- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx +++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx @@ -57,7 +57,6 @@ import { RECEIVING_STATUS_OPTIONS, type ReceivingDetail as ReceivingDetailType, type ReceivingStatus, - type InventoryAdjustmentRecord, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { toast } from 'sonner'; @@ -148,9 +147,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { file?: { id: number; display_name?: string; original_name?: string; file_path: string; file_size: number; mime_type?: string }; }>>([]); - // 재고 조정 이력 상태 - const [adjustments, setAdjustments] = useState([]); - // Dev 모드 폼 자동 채우기 useDevFill( 'receiving', @@ -188,10 +184,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { if (result.success && result.data) { setDetail(result.data); - // 재고 조정 이력 설정 - if (result.data.inventoryAdjustments) { - setAdjustments(result.data.inventoryAdjustments); - } // 기존 성적서 파일 정보 설정 if (result.data.certificateFileId) { setExistingCertFile({ @@ -326,30 +318,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { loadData(); }; - // 재고 조정 행 추가 - const handleAddAdjustment = () => { - const newRecord: InventoryAdjustmentRecord = { - id: `adj-${Date.now()}`, - adjustmentDate: getTodayString(), - quantity: 0, - inspector: getLoggedInUserName() || '홍길동', - }; - setAdjustments((prev) => [...prev, newRecord]); - }; - - // 재고 조정 행 삭제 - const handleRemoveAdjustment = (adjId: string) => { - setAdjustments((prev) => prev.filter((a) => a.id !== adjId)); - }; - - // 재고 조정 수량 변경 - const handleAdjustmentQtyChange = (adjId: string, value: string) => { - const numValue = value === '' || value === '-' ? 0 : Number(value); - setAdjustments((prev) => - prev.map((a) => (a.id === adjId ? { ...a, quantity: numValue } : a)) - ); - }; - // 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동 const handleCancel = () => { if (isNewMode) { @@ -496,47 +464,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { - {/* 재고 조정 */} - - - 재고 조정 - - -
-
- - - No - 조정일시 - 증감 수량 - 검수자 - - - - {adjustments.length > 0 ? ( - adjustments.map((adj, idx) => ( - - {idx + 1} - {adj.adjustmentDate} - {adj.quantity} - {adj.inspector} - - )) - ) : ( - - - 재고 조정 이력이 없습니다. - - - )} - -
-
- -
); - }, [detail, adjustments, inspectionAttachments, existingCertFile]); + }, [detail, inspectionAttachments, existingCertFile]); // ===== 등록/수정 폼 콘텐츠 ===== const renderFormContent = useCallback(() => { @@ -779,88 +709,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { - {/* 재고 조정 */} - - - 재고 조정 - - - -
- - - - No - 조정일시 - 증감 수량 - 검수자 - - - - - {adjustments.length > 0 ? ( - adjustments.map((adj, idx) => ( - - {idx + 1} - - { - setAdjustments((prev) => - prev.map((a) => - a.id === adj.id ? { ...a, adjustmentDate: date } : a - ) - ); - }} - size="sm" - /> - - - handleAdjustmentQtyChange(adj.id, e.target.value)} - className="h-8 text-sm text-center w-[100px] mx-auto" - placeholder="0" - /> - - {adj.inspector} - - - - - )) - ) : ( - - - 재고 조정 이력이 없습니다. - - - )} - -
-
-
-
); - }, [formData, adjustments, uploadedFile, existingCertFile]); + }, [formData, uploadedFile, existingCertFile]); // ===== 커스텀 헤더 액션 (view/edit 모드) ===== // 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거 diff --git a/src/components/material/ReceivingManagement/ReceivingList.tsx b/src/components/material/ReceivingManagement/ReceivingList.tsx index 948f1d57..242254d6 100644 --- a/src/components/material/ReceivingManagement/ReceivingList.tsx +++ b/src/components/material/ReceivingManagement/ReceivingList.tsx @@ -37,7 +37,6 @@ import { type FilterFieldConfig, } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; -import { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog'; import { getReceivings, getReceivingStats } from './actions'; import { RECEIVING_STATUS_LABELS, @@ -84,9 +83,6 @@ export function ReceivingList() { const [stats, setStats] = useState(null); const [totalItems, setTotalItems] = useState(0); - // ===== 재고 조정 팝업 상태 ===== - const [isAdjustmentOpen, setIsAdjustmentOpen] = useState(false); - // ===== 날짜 범위 상태 (최근 30일) ===== const today = new Date(); const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); @@ -290,17 +286,9 @@ export function ReceivingList() { // 통계 카드 stats: statCards, - // 헤더 액션 (재고 조정 + 입고 등록 버튼) + // 헤더 액션 (입고 등록 버튼) headerActions: () => (
- + + +
+ + + + No + 조정일시 + 증감 수량 + 조정 후 재고 + 사유 + 검수자 + + + + {adjustments.length > 0 ? ( + adjustments.map((adj, idx) => ( + + {idx + 1} + {adj.adjusted_at} + 0 ? 'text-blue-600' : 'text-red-600'}`}> + {adj.quantity > 0 ? `+${adj.quantity}` : adj.quantity} + + {adj.balance_qty} + {adj.remark || '-'} + {adj.inspector} + + )) + ) : ( + + + 재고 조정 이력이 없습니다. + + + )} + +
+
+
+ + ); + // 상세 보기 모드 렌더링 const renderViewContent = useCallback(() => { if (!detail) return null; return ( - - - 기본 정보 - - -
- {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */} -
- {renderReadOnlyField('자재번호', detail.stockNumber)} - {renderReadOnlyField('품목코드', detail.itemCode)} - {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')} - {renderReadOnlyField('품목명', detail.itemName)} -
+
+ + + 기본 정보 + + +
+ {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */} +
+ {renderReadOnlyField('자재번호', detail.stockNumber)} + {renderReadOnlyField('품목코드', detail.itemCode)} + {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')} + {renderReadOnlyField('품목명', detail.itemName)} +
- {/* Row 2: 규격, 단위, 재고량, 안전재고 */} -
- {renderReadOnlyField('규격', detail.specification)} - {renderReadOnlyField('단위', detail.unit)} - {renderReadOnlyField('재고량', detail.calculatedQty)} - {renderReadOnlyField('안전재고', detail.safetyStock)} -
+ {/* Row 2: 규격, 단위, 재고량, 안전재고 */} +
+ {renderReadOnlyField('규격', detail.specification)} + {renderReadOnlyField('단위', detail.unit)} + {renderReadOnlyField('재고량', detail.calculatedQty)} + {renderReadOnlyField('안전재고', detail.safetyStock)} +
- {/* Row 3: 재공품, 상태 */} -
- {renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])} - {renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])} + {/* Row 3: 재공품, 상태 */} +
+ {renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])} + {renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])} +
-
-
-
+ + + + {renderAdjustmentSection()} +
); - }, [detail]); + }, [detail, adjustments]); // 수정 모드 렌더링 const renderFormContent = useCallback(() => { if (!detail) return null; return ( - - - 기본 정보 - - -
- {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */} -
- {renderReadOnlyField('자재번호', detail.stockNumber, true)} - {renderReadOnlyField('품목코드', detail.itemCode, true)} - {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)} - {renderReadOnlyField('품목명', detail.itemName, true)} -
+
+ + + 기본 정보 + + +
+ {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */} +
+ {renderReadOnlyField('자재번호', detail.stockNumber, true)} + {renderReadOnlyField('품목코드', detail.itemCode, true)} + {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)} + {renderReadOnlyField('품목명', detail.itemName, true)} +
- {/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */} -
- {renderReadOnlyField('규격', detail.specification, true)} - {renderReadOnlyField('단위', detail.unit, true)} - {renderReadOnlyField('재고량', detail.calculatedQty, true)} + {/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */} +
+ {renderReadOnlyField('규격', detail.specification, true)} + {renderReadOnlyField('단위', detail.unit, true)} + {renderReadOnlyField('재고량', detail.calculatedQty, true)} - {/* 안전재고 (수정 가능) */} -
- - handleInputChange('safetyStock', e.target.value)} - className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500" - min={0} - /> + {/* 안전재고 (수정 가능) */} +
+ + handleInputChange('safetyStock', e.target.value)} + className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500" + min={0} + /> +
+
+ + {/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */} +
+ {/* 재공품 (읽기 전용) */} + {renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)} + {/* 상태 (수정 가능) */} +
+ + +
+ + - {/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */} -
- {/* 재공품 (읽기 전용) */} - {renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)} - {/* 상태 (수정 가능) */} -
- - -
-
-
- - + {renderAdjustmentSection()} +
); - }, [detail, formData]); + }, [detail, formData, adjustments]); // 에러 상태 표시 if (!isLoading && (error || !detail)) { @@ -301,15 +442,70 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) { } return ( - | undefined} - itemId={id} - isLoading={isLoading} - renderView={() => renderViewContent()} - renderForm={() => renderFormContent()} - onSubmit={async () => { await handleSave(); return { success: true }; }} - /> + <> + | undefined} + itemId={id} + isLoading={isLoading} + renderView={() => renderViewContent()} + renderForm={() => renderFormContent()} + onSubmit={async () => { await handleSave(); return { success: true }; }} + /> + + {/* 재고 조정 등록 다이얼로그 */} + + + + 재고 조정 + +
+
+ + setAdjustmentForm((prev) => ({ ...prev, quantity: e.target.value }))} + placeholder="양수: 증가, 음수: 감소" + className="mt-1.5" + /> +
+
+ +