diff --git a/CLAUDE.md b/CLAUDE.md index 18adf690..785d508b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -593,6 +593,73 @@ import { FormField } from '@/components/molecules/FormField'; --- +## Module Separation Architecture +**Priority**: ๐Ÿ”ด + +### ๊ฐœ์š” +๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ์•„ํ‚คํ…์ฒ˜. ํ…Œ๋„ŒํŠธ๋ณ„๋กœ ํ•„์š”ํ•œ ๋ชจ๋“ˆ๋งŒ ํ™œ์„ฑํ™”ํ•˜์—ฌ ๋ถˆํ•„์š”ํ•œ ๊ธฐ๋Šฅ ์ˆจ๊น€. +`tenant.options.industry` ๋ฏธ์„ค์ • ์‹œ **๋ชจ๋“  ๋ชจ๋“ˆ ํ™œ์„ฑํ™”** (๊ธฐ์กด ๋™์ž‘ 100% ์œ ์ง€). + +### ํ•ต์‹ฌ ํŒจํ„ด: moduleAware ์•ˆ์ „ ์žฅ์น˜ +```typescript +const { isEnabled, tenantIndustry } = useModules(); +const moduleAware = !!tenantIndustry; // industry ๋ฏธ์„ค์ • โ†’ false โ†’ ์ „๋ถ€ ํ—ˆ์šฉ + +if (!moduleAware) return allData; // ๊ธฐ์กด๊ณผ ๋™์ผ +return filteredData; // ๋ชจ๋“ˆ ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง +``` + +### ๋ชจ๋“ˆ ๊ตฌ์กฐ +``` +src/modules/ +โ”œโ”€โ”€ types.ts # ModuleId, TenantIndustry, MODULE_REGISTRY +โ”œโ”€โ”€ config.ts # INDUSTRY_MODULES (์—…์ข…๋ณ„ ํ™œ์„ฑ ๋ชจ๋“ˆ ๋งคํ•‘) +โ”œโ”€โ”€ ModuleGuard.tsx # ๋ผ์šฐํŠธ ๊ธฐ๋ฐ˜ ์ ‘๊ทผ ์ œ์–ด (layout.tsx์—์„œ ์‚ฌ์šฉ) +โ””โ”€โ”€ ModuleProvider.tsx # React Context (tenant API โ†’ enabledModules ๊ณ„์‚ฐ) + +src/hooks/useModules.ts # { isEnabled, tenantIndustry, enabledModules, ... } +``` + +### ๋ชจ๋“ˆ ID ๋ชฉ๋ก +| ModuleId | ์„ค๋ช… | ํ…Œ๋„ŒํŠธ | +|----------|------|--------| +| `production` | ์ƒ์‚ฐ๊ด€๋ฆฌ | ๊ฒฝ๋™ | +| `quality` | ํ’ˆ์งˆ๊ด€๋ฆฌ | ๊ฒฝ๋™ | +| `construction` | ์‹œ๊ณต๊ด€๋ฆฌ | ์ฃผ์ผ | +| `vehicle-management` | ์ฐจ๋Ÿ‰๊ด€๋ฆฌ | ์„ ํƒ์  | + +### ๋ผ์šฐํŠธ ๊ฐ€๋“œ vs ๋ช…์‹œ์  ๊ฐ€๋“œ +- **๋ผ์šฐํŠธ ๊ฐ€๋“œ (ModuleGuard)**: `/production/*`, `/quality/*` ๋“ฑ ์ „์šฉ ๋ผ์šฐํŠธ +- **๋ช…์‹œ์  ๊ฐ€๋“œ (useModules)**: ๊ณตํ†ต ๋ผ์šฐํŠธ ๋‚ด ๋ชจ๋“ˆ ์˜์กด ํŽ˜์ด์ง€ (์˜ˆ: `/sales/*/production-orders`) + +```typescript +// ๊ณตํ†ต ๋ผ์šฐํŠธ ๋‚ด ๋ชจ๋“ˆ ์˜์กด ํŽ˜์ด์ง€ โ€” ๋ช…์‹œ์  ๊ฐ€๋“œ ํ•„์ˆ˜ +if (tenantIndustry && !isEnabled('production')) { + return
์ƒ์‚ฐ๊ด€๋ฆฌ ๋ชจ๋“ˆ์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
; +} +``` + +### ํฌ๋กœ์Šค ๋ชจ๋“ˆ ์ž„ํฌํŠธ ๊ทœ์น™ +- **Common โ†’ Tenant ์ง์ ‘ import ๊ธˆ์ง€** (๊ฒ€์ฆ ์Šคํฌ๋ฆฝํŠธ: `scripts/verify-module-separation.sh`) +- **ํ—ˆ์šฉ ์˜ˆ์™ธ**: `// MODULE_SEPARATION_OK` ์ฃผ์„ + `src/lib/api/` ๊ณต์œ  ๋ž˜ํผ +- **Tenant โ†’ Common import**: ์ž์œ  +- **Tenant โ†’ Tenant import**: ๊ธˆ์ง€ (dynamic import๋งŒ ํ—ˆ์šฉ) + +### MODULE.md ๊ฒฝ๊ณ„ ๋งˆ์ปค +๊ฐ ํ…Œ๋„ŒํŠธ ๋ชจ๋“ˆ ๋””๋ ‰ํ† ๋ฆฌ์— `MODULE.md` ํŒŒ์ผ๋กœ ๋ชจ๋“ˆ ๊ฒฝ๊ณ„ ๋ฌธ์„œํ™”: +- `src/components/production/MODULE.md` +- `src/components/quality/MODULE.md` +- `src/components/business/construction/MODULE.md` +- `src/components/vehicle-management/MODULE.md` + +### Path Aliases +```json +// tsconfig.json +"@modules/*": ["./src/modules/*"] +``` + +--- + ## User Environment **Priority**: ๐ŸŸข 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-11] dynamic-multi-tenant-page-system.md b/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md index 862c9106..c8bce86a 100644 --- a/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md +++ b/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md @@ -1,11 +1,14 @@ # ๋™์  ๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ ํŽ˜์ด์ง€ ์‹œ์Šคํ…œ ์„ค๊ณ„ > ์ž‘์„ฑ์ผ: 2026-03-11 -> ์ƒํƒœ: ์ดˆ์•ˆ (๋ฐฑ์—”๋“œ ๋…ผ์˜ ํ•„์š”) +> ์ตœ์ข… ์—…๋ฐ์ดํŠธ: 2026-03-18 +> ์ƒํƒœ: ์ดˆ์•ˆ (๋ฐฑ์—”๋“œ ๋…ผ์˜ ์ง„ํ–‰ ์ค‘) > ๊ด€๋ จ ๋ฌธ์„œ: > - `[VISION-2026-02-19] dynamic-rendering-platform-strategy.md` > - `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md` > - `[DESIGN-2026-02-11] dynamic-field-type-extension.md` +> - `[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md` +> - `[PLAN-2026-03-17] tenant-module-separation-plan.md` โ€” **๋ณธ ์„ค๊ณ„์˜ ์‹คํ–‰ ๊ณ„ํš (Phase 0~3)** --- @@ -761,9 +764,61 @@ DynamicItemForm์˜ ComputedField โ†’ computed ํƒ€์ž…์œผ๋กœ ๋ฒ”์šฉํ™” ### ๊ทœ์น™ 17: ์ ์ง„์  ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ „๋žต +#### 17-1. 3๋‹จ๊ณ„ ์•„ํ‚คํ…์ฒ˜ ๋ฐฉํ–ฅ (2026-03-17 ํ™•์ธ) + +``` +1๋‹จ๊ณ„: ํ˜„์žฌ โ†’ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ + - ๊ณตํ†ต ERP / ํ…Œ๋„ŒํŠธ๋ณ„ ๋ชจ๋“ˆ ๋ฌผ๋ฆฌ์  ๋ถ„๋ฆฌ + - ์„ ๊ฒฐ๊ณผ์ œ ํ•ด์†Œ (์•„๋ž˜ 17-2 ์ฐธ์กฐ) + +2๋‹จ๊ณ„: ๋ชจ๋“ˆ ๋ถ„๋ฆฌ โ†’ JSON ๋™์  ์กฐ๋ฆฝ + - ํ…Œ๋„ŒํŠธ ๋ชจ๋“ˆ์„ manifest/JSON ๊ธฐ๋ฐ˜์œผ๋กœ ์ „ํ™˜ + - ๋™์  ํŽ˜์ด์ง€ ๋ Œ๋”๋Ÿฌ ๋„์ž… + +3๋‹จ๊ณ„: ์ตœ์ข… โ€” ๋นˆ ํŽ˜์ด์ง€ ์…ธ + ๋ฐฑ์—”๋“œ JSON์œผ๋กœ ํŽ˜์ด์ง€ ์ž๋™ ์กฐ๋ฆฝ + - ์ด ๋ฌธ์„œ์˜ ์ตœ์ข… ๋ชฉํ‘œ +``` + +#### 17-2. ์„ ๊ฒฐ๊ณผ์ œ (๋ชจ๋“ˆ ๋ถ„๋ฆฌ ์ „ ํ•ด๊ฒฐ ํ•„์ˆ˜) + +| # | ๊ณผ์ œ | ๋‚ด์šฉ | ์˜ˆ์ƒ | +|---|------|------|------| +| 1 | CEO ๋Œ€์‹œ๋ณด๋“œ ํ…Œ๋„ŒํŠธ ์˜์กด์„ฑ ํ•ด์†Œ | ์ƒ์‚ฐ/๊ฑด์„ค ์„น์…˜ ์ง์ ‘ import โ†’ ๋™์  ๋กœ๋”ฉ ์ „ํ™˜ | - | +| 2 | ๊ณต์œ  ์ปดํฌ๋„ŒํŠธ ์ถ”์ถœ | ๊ฒฐ์žฌ/์˜์—…(๊ณตํ†ต)์ด ์ƒ์‚ฐ(๊ฒฝ๋™) ์ฝ”๋“œ ์ง์ ‘ import | - | +| 3 | ๋ผ์šฐํŠธ ๊ฐ€๋“œ ์ถ”๊ฐ€ | ํ…Œ๋„ŒํŠธ ๋ฏธ๋ณด์œ  ๋ชจ๋“ˆ URL ์ง์ ‘ ์ ‘๊ทผ ์ฐจ๋‹จ | - | +| 4 | dashboard-invalidation ๋™์ ํ™” | production/construction ๋„๋ฉ”์ธ ํ‚ค ํ•˜๋“œ์ฝ”๋”ฉ ์ œ๊ฑฐ | - | + +> ์„ ๊ฒฐ๊ณผ์ œ ํ•ด์†Œ ์˜ˆ์ƒ: 3~4์ผ, ์ดํ›„ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ๋ณธ์ž‘์—…์€ ๋ณ„๋„ ์‚ฐ์ • + +**ํ•ต์‹ฌ ์˜์กด์„ฑ ์œ„๋ฐ˜ (๊ณตํ†ต โ†’ ํ…Œ๋„ŒํŠธ ๋ฐฉํ–ฅ, ์ˆ˜์ • ํ•„์š”)**: +``` +ApprovalBox โ†’ production/InspectionReportModal +Sales/production-orders โ†’ production/ProductionOrders (actions+types+UI) +Sales โ†’ router.push("/production/work-orders") ํ•˜๋“œ์ฝ”๋”ฉ +CEODashboard โ†’ DailyProductionSection, ConstructionSection ์ง์ ‘ import +dashboard-invalidation.ts โ†’ production/construction ๋„๋ฉ”์ธ ํ‚ค +``` + +**์•ˆ์ „ํ•œ ๋ถ€๋ถ„**: +- ํ…Œ๋„ŒํŠธ ๊ฐ„ ๊ต์ฐจ ์˜์กด์„ฑ ์—†์Œ (์ƒ์‚ฐโ†”๊ฑด์„ค = 0) +- ๊ฑด์„ค(์ฃผ์ผ) ๋ชจ๋“ˆ ์™„์ „ ๋…๋ฆฝ โ†’ ๋ฐ”๋กœ ๋ถ„๋ฆฌ ๊ฐ€๋Šฅ +- Zustand ์Šคํ† ์–ด, API ํ”„๋ก์‹œ, ๋ฉ”๋‰ด ์‹œ์Šคํ…œ์€ ๋ฌด๊ด€ + +#### 17-3. ํ…Œ๋„ŒํŠธ๋ณ„ ํŽ˜์ด์ง€ ํ˜„ํ™ฉ (2026-03-17 ๋ถ„์„) + +| ํ…Œ๋„ŒํŠธ | ์—…์ข… | ์ „์šฉ ๋ชจ๋“ˆ | ํŽ˜์ด์ง€ ์ˆ˜ | +|--------|------|----------|:---:| +| ๊ณตํ†ต ERP | ์ „ ์—…์ข… | ํšŒ๊ณ„, ์ธ์‚ฌ, ๊ฒฐ์žฌ, ๊ฒŒ์‹œํŒ, ์„ค์ •, ๊ณ ๊ฐ์„ผํ„ฐ ๋“ฑ | ~165 | +| ๊ฒฝ๋™ | ์…”ํ„ฐ ์ œ์กฐ (MES) | ์ƒ์‚ฐ, ํ’ˆ์งˆ๊ด€๋ฆฌ | ~27 | +| ์ฃผ์ผ | ๊ฑด์„ค ์‹œ๊ณต | ๊ฑด์„ค/ํ”„๋กœ์ ํŠธ, ์ž…์ฐฐ, ๊ธฐ์„ฑ | ~48 | +| (์˜ต์…˜) | - | ์ฐจ๋Ÿ‰๊ด€๋ฆฌ | ~13 | + +#### 17-4. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ Phase + | Phase | ๋ฒ”์œ„ | ์˜ˆ์ƒ ๊ธฐ๊ฐ„ | ์ƒํƒœ | |-------|------|----------|------| -| **Phase 0** | ์ธํ”„๋ผ ๊ตฌ์ถ• | 2-3์ฃผ | โณ ์ค€๋น„ | +| **์„ ๊ฒฐ๊ณผ์ œ** | ์˜์กด์„ฑ ํ•ด์†Œ (17-2) | 3-4์ผ | โณ ์ค€๋น„ | +| **Phase 0** | ์ธํ”„๋ผ ๊ตฌ์ถ• | 2-3์ฃผ | โณ | | | - catch-all ๋ผ์šฐํ„ฐ | | | | | - pageConfigStore | | | | | - DynamicListPage/FormPage ๋ Œ๋”๋Ÿฌ | | | @@ -776,13 +831,13 @@ DynamicItemForm์˜ ComputedField โ†’ computed ํƒ€์ž…์œผ๋กœ ๋ฒ”์šฉํ™” | | - ๊ฑฐ๋ž˜์ฒ˜๊ด€๋ฆฌ, ์„ค๋น„๊ด€๋ฆฌ ๋“ฑ | | | | **Phase 3** | ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ํŽ˜์ด์ง€ ์ „ํ™˜ | 6-8์ฃผ | โณ | | | - ๊ฒฌ์ , ์ˆ˜์ฃผ, ์ƒ์‚ฐ ๋“ฑ ๋กœ์ง ์žˆ๋Š” ํŽ˜์ด์ง€ | | | -| | - ๋กœ์ง ๋ธ”๋ก ๊ตฌ์ถ• ๋ณ‘ํ–‰ | | | | **Phase 4** | ๊ธฐ์กด ์ •์  โ†’ ๋™์  ์™„์ „ ์ „ํ™˜ | ์ง€์†์  | โณ | | | - ๋‚จ์€ ํ•˜๋“œ์ฝ”๋”ฉ ํŽ˜์ด์ง€ ์ ์ง„์  ์ „ํ™˜ | | | ``` ์ „ํ™˜ ํŒ๋‹จ ๊ธฐ์ค€: +[์„ ํ–‰] ์„ ๊ฒฐ๊ณผ์ œ ํ•ด์†Œ (์˜์กด์„ฑ ๋ถ„๋ฆฌ) โ†’ ์„ ๊ฒฐ๊ณผ์ œ Phase [์‰ฌ์›€] ์ˆœ์ˆ˜ CRUD (๋ฆฌ์ŠคํŠธ+ํผ) โ†’ Phase 2์—์„œ ์ „ํ™˜ [๋ณดํ†ต] CRUD + ๋‹จ์ˆœ ๊ณ„์‚ฐ โ†’ Phase 2~3 [์–ด๋ ค์›€] ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง โ†’ Phase 3 @@ -886,9 +941,10 @@ DynamicItemForm์˜ ComputedField โ†’ computed ํƒ€์ž…์œผ๋กœ ๋ฒ”์šฉํ™” | ๋™์  ํ•„๋“œ ํƒ€์ž… ์„ค๊ณ„ | `claudedocs/architecture/[DESIGN-2026-02-11]` | 4-Level ๊ตฌ์กฐ, 14์ข… ํ•„๋“œ | | ๋™์  ํ•„๋“œ ๊ตฌํ˜„ ํ˜„ํ™ฉ | `claudedocs/architecture/[IMPL-2026-02-11]` | Phase 1~3 ํ”„๋ก ํŠธ ๊ตฌํ˜„ ์™„๋ฃŒ | | ๋ฐฑ์—”๋“œ API ์ŠคํŽ™ | `claudedocs/item-master/[API-REQUEST-2026-02-12]` | ๋™์  ํ•„๋“œ ํƒ€์ž… ๋ฐฑ์—”๋“œ ์š”์ฒญ์„œ | +| ํ…Œ๋„ŒํŠธ ๋ชจ๋“ˆ ์˜์กด์„ฑ ๋ถ„์„ | `claudedocs/architecture/[ANALYSIS-2026-03-17]` | 3ํ…Œ๋„ŒํŠธ ๋ถ„๋ฆฌ, ์„ ๊ฒฐ๊ณผ์ œ 4๊ฐœ, ์˜์กด์„ฑ ์œ„๋ฐ˜ ๋ชฉ๋ก | --- -**๋ฌธ์„œ ๋ฒ„์ „**: 1.2 -**๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ**: 2026-03-11 +**๋ฌธ์„œ ๋ฒ„์ „**: 1.3 +**๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ**: 2026-03-18 **๋‹ค์Œ ๋‹จ๊ณ„**: ๋ฐฑ์—”๋“œ ํšŒ์˜ โ†’ ํ˜‘์˜ ํ•„์š” ํ•ญ๋ชฉ ํ™•์ • โ†’ v2.0 ์ž‘์„ฑ โ†’ `sam-docs/frontend/v2/`์— ์ตœ์ข…๋ณธ ๋“ฑ๋ก diff --git a/claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md b/claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md new file mode 100644 index 00000000..11dd8e1c --- /dev/null +++ b/claudedocs/architecture/[PLAN-2026-03-17] tenant-module-separation-plan.md @@ -0,0 +1,1844 @@ +# Tenant-Based Module Separation: Implementation Plan + +**Date**: 2026-03-17 +**Status**: APPROVED PLAN +**Prerequisite**: `[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md` +**Estimated Total Effort**: 12-16 working days across 4 phases +**Zero Downtime Requirement**: All changes are additive; no page removal until Phase 3 + +### ๊ด€๋ จ ๋ฌธ์„œ (๋กœ๋“œ๋งต ์ƒ ์œ„์น˜) + +| ๋ฌธ์„œ | ์—ญํ•  | ๊ด€๊ณ„ | +|------|------|------| +| `[VISION-2026-02-19] dynamic-rendering-platform-strategy.md` | ํ”Œ๋žซํผ ๋น„์ „ | ์ตœ์ƒ์œ„ ๋ฐฉํ–ฅ์„ฑ | +| `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md` | ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ ๋กœ๋“œ๋งต | ์ „์ฒด ๋‹จ๊ณ„ ์ •์˜ | +| `[DESIGN-2026-02-11] dynamic-field-type-extension.md` | ๋™์  ํ•„๋“œ ํƒ€์ž… ์„ค๊ณ„ | v3 ๋ Œ๋”๋Ÿฌ ๊ธฐ์ดˆ | +| `[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md` | **v3 ์ตœ์ข… ์„ค๊ณ„ (JSON ๋™์  ํŽ˜์ด์ง€)** | **๋ณธ ๊ณ„ํš์˜ ๋„์ฐฉ์ ** | +| `[ANALYSIS-2026-03-17] tenant-module-separation-dependency-audit.md` | ์˜์กด์„ฑ ๊ฐ์‚ฌ | ๋ณธ ๊ณ„ํš์˜ ๊ทผ๊ฑฐ ๋ฐ์ดํ„ฐ | +| **๋ณธ ๋ฌธ์„œ (Phase 0~3)** | **ํ”„๋ก ํŠธ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ์‹คํ–‰ ๊ณ„ํš** | **v3๋กœ ๊ฐ€๋Š” ์ง•๊ฒ€๋‹ค๋ฆฌ** | + +``` +๋กœ๋“œ๋งต ์ „์ฒด ํ๋ฆ„: + +[VISION 02-19] ํ”Œ๋žซํผ ๋น„์ „ + โ”‚ +[PLAN 02-06] ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ ๋กœ๋“œ๋งต + โ”‚ +[DESIGN 02-11] ๋™์  ํ•„๋“œ ํƒ€์ž… + โ”‚ +[PLAN 03-11] v3 ์ตœ์ข… ์„ค๊ณ„ (JSON ๋™์  ํŽ˜์ด์ง€ + JSONB + pageType ๋ Œ๋”๋Ÿฌ) + โ”‚ +[ANALYSIS 03-17] ์˜์กด์„ฑ ๊ฐ์‚ฌ โ”€โ”€โ†’ ํ˜„์žฌ ์ฝ”๋“œ์˜ ๋ชจ๋“ˆ ๊ฐ„ ๊ฒฐํ•ฉ 6๊ฑด ๋ฐœ๊ฒฌ + โ”‚ +[PLAN 03-17] โ˜… ๋ณธ ๋ฌธ์„œ โ˜… (Phase 0~3: ํ”„๋ก ํŠธ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ) + โ”‚ +[v2] ๋ฐฑ์—”๋“œ page_configs JSONB API โ†’ useModules() ์—ฐ๊ฒฐ + โ”‚ +[v3] catch-all route + DynamicListPage/FormPage ๋ Œ๋”๋Ÿฌ + โ”‚ +[์ตœ์ข…] ํ…Œ๋„ŒํŠธ ์ถ”๊ฐ€ = ์–ด๋“œ๋ฏผ config ๋“ฑ๋ก โ†’ ์ฝ”๋“œ ๋ณ€๊ฒฝ 0์ค„ +``` + +--- + +## Table of Contents + +1. [Architecture Overview](#1-architecture-overview) +2. [Phase 0: Prerequisite Fixes (4-5 days)](#2-phase-0-prerequisite-fixes) +3. [Phase 1: Module Registry & Route Guard (3-4 days)](#3-phase-1-module-registry--route-guard) +4. [Phase 2: Dashboard Decoupling (2-3 days)](#4-phase-2-dashboard-decoupling) +5. [Phase 3: Physical Separation (2-3 days)](#5-phase-3-physical-separation) +6. [Phase 4: Manifest-Based Module Loading (Future)](#6-phase-4-manifest-based-module-loading) +7. [Testing Strategy](#7-testing-strategy) +8. [Risk Register](#8-risk-register) +9. [Folder Structure Before/After](#9-folder-structure-beforeafter) +10. [Migration Order & Parallelism](#10-migration-order--parallelism) + +--- + +## 1. Architecture Overview + +### Current State + +``` +src/ + app/[locale]/(protected)/ + production/ (12 pages) -- Kyungdong tenant + quality/ (14 pages) -- Kyungdong tenant + construction/ (57 pages) -- Juil tenant + vehicle-management/ (12 pages) -- Optional module + accounting/ (32 pages) -- Common ERP + sales/ (22 pages) -- Common ERP (has 3 pages that import from production) + approval/ (6 pages) -- Common ERP (1 component imports from production) + ...other common modules + components/ + production/ (56 files) + quality/ (35+ files) + business/construction/ (161 files) + vehicle-management/ (13 files) + ...common components +``` + +### Target State (End of Phase 3) + +``` +src/ + modules/ # NEW: module registry + manifest + index.ts # module registry + types.ts # TenantModule, ModuleManifest types + tenant-config.ts # tenant -> module mapping + route-guard.ts # route access check utility + interfaces/ # NEW: shared type contracts + production-orders.ts # types+actions shared between sales & production + inspection-documents.ts # InspectionReportModal props interface + dashboard-sections.ts # dynamic section registration types + app/[locale]/(protected)/ + production/ # unchanged location, guarded by middleware + quality/ # unchanged location, guarded by middleware + construction/ # unchanged location, guarded by middleware + vehicle-management/ # unchanged location, guarded by middleware + components/ + document-system/modals/ # NEW: extracted shared modals + InspectionReportModal.tsx # moved from production + WorkLogModal.tsx # moved from production + production/ # unchanged, but no longer imported by common + quality/ # unchanged + business/construction/ # unchanged +``` + +### Dependency Direction Rules + +``` +ALLOWED: Tenant Module -----> Common ERP + (production -> @/lib/, @/components/ui/, @/hooks/, etc.) + +FORBIDDEN: Common ERP ----X---> Tenant Module + (approval -> production components) + (sales -> production actions/types) + (dashboard -> production/construction data) + +EXCEPTION: Common ERP ~~~?~~~> Tenant Module via: + 1. Dynamic import with fallback (lazy + error boundary) + 2. Shared interface in @/interfaces/ (types only) + 3. Module registry callback (runtime registration) +``` + +--- + +## 2. Phase 0: Prerequisite Fixes + +> **Goal**: Break all forbidden dependency arrows (Common -> Tenant) without changing runtime behavior. +> **Duration**: 4-5 days +> **Risk**: LOW (pure refactoring, no user-facing changes) +> **Rollback**: `git revert` each commit independently + +### Task 0.1: Extract InspectionReportModal to Shared Location + +**Problem**: `ApprovalBox/index.tsx` (common) imports `InspectionReportModal` from `production/WorkOrders/documents/`. Also `quality/qms/page.tsx` imports it. + +**Strategy**: Create a shared document modal system under `@/components/document-system/modals/`. + +**Files to create**: +- `src/components/document-system/modals/InspectionReportModal.tsx` +- `src/components/document-system/modals/WorkLogModal.tsx` +- `src/components/document-system/modals/index.ts` + +**Files to modify**: +- `src/components/approval/ApprovalBox/index.tsx` (change import path) +- `src/app/[locale]/(protected)/quality/qms/page.tsx` (change import path) +- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx` (re-export from shared) +- `src/components/production/WorkOrders/documents/WorkLogModal.tsx` (re-export from shared) +- `src/components/production/WorkOrders/documents/index.ts` (update exports) + +**Code pattern**: +```typescript +// NEW: src/components/document-system/modals/InspectionReportModal.tsx +// Copy the full 570-line component here (from production/WorkOrders/documents/) +// Keep all existing props and behavior identical + +// MODIFY: src/components/production/WorkOrders/documents/InspectionReportModal.tsx +// Replace with re-export to avoid breaking internal production imports: +export { InspectionReportModal } from '@/components/document-system/modals/InspectionReportModal'; + +// MODIFY: src/components/approval/ApprovalBox/index.tsx line 76 +// FROM: +import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal'; +// TO: +import { InspectionReportModal } from '@/components/document-system/modals/InspectionReportModal'; + +// MODIFY: src/app/[locale]/(protected)/quality/qms/page.tsx lines 11-12 +// FROM: +import { InspectionReportModal } from '@/components/production/WorkOrders/documents'; +import { WorkLogModal } from '@/components/production/WorkOrders/documents'; +// TO: +import { InspectionReportModal } from '@/components/document-system/modals'; +import { WorkLogModal } from '@/components/document-system/modals'; +``` + +**Dependency analysis for InspectionReportModal.tsx (570 lines)**: +The modal imports from: +- `@/types/process.ts` (shared types, already in Common ERP) +- `@/lib/api/` utilities (Common ERP) +- `@/components/ui/` (Common ERP) +- `./inspection-shared` and `./Slat*Content`, `./Screen*Content`, `./Bending*Content` (production-internal) + +The production-internal content components (SlatInspectionContent, ScreenInspectionContent, etc.) are **rendered inside** InspectionReportModal via a switch statement. These are Kyungdong-specific inspection forms. + +**Revised strategy**: Instead of moving the entire modal with its production-specific content components, create a **wrapper pattern**: + +```typescript +// NEW: src/components/document-system/modals/InspectionReportModal.tsx +// This is a THIN wrapper that dynamic-imports the actual modal +'use client'; + +import dynamic from 'next/dynamic'; +import type { ComponentProps } from 'react'; + +// Dynamic import with loading fallback +const InspectionReportModalImpl = dynamic( + () => import('@/components/production/WorkOrders/documents/InspectionReportModal') + .then(mod => ({ default: mod.InspectionReportModal })), + { + loading: () => null, + ssr: false, + } +); + +// Re-export props type from a shared interface (no production dependency) +export interface InspectionReportModalProps { + isOpen: boolean; + onClose: () => void; + workOrderId: number | null; + type?: 'inspection' | 'worklog'; +} + +export function InspectionReportModal(props: InspectionReportModalProps) { + if (!props.isOpen) return null; + return ; +} +``` + +This pattern: +- Breaks the static import chain (Common no longer statically depends on production) +- Uses `next/dynamic` so the production code is only loaded at runtime when needed +- If production module is absent at build time, the dynamic import fails gracefully (modal just doesn't render) +- Zero behavior change for existing users + +**Estimated time**: 0.5 day +**Risk**: LOW +**Dependencies**: None +**Rollback**: Revert import paths + +--- + +### Task 0.2: Extract Production Orders Shared Interface + +**Problem**: 3 files under `sales/order-management-sales/production-orders/` import from `@/components/production/ProductionOrders/actions.ts` and `types.ts`. + +**Strategy**: Create a shared interface file with the types and action signatures that sales needs. The sales pages will use this shared interface. The actual implementation stays in production. + +**Files to create**: +- `src/interfaces/production-orders.ts` + +**Files to modify**: +- `src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx` +- `src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx` +- `src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx` + +**Code pattern**: +```typescript +// NEW: src/interfaces/production-orders.ts +// Extract ONLY the types and action signatures that sales needs + +export interface ProductionOrder { + id: number; + order_number: string; + status: ProductionStatus; + // ... (copy from production/ProductionOrders/types.ts) +} + +export type ProductionStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface ProductionOrderDetail extends ProductionOrder { + work_orders: ProductionWorkOrder[]; + // ... +} + +export interface ProductionWorkOrder { + id: number; + // ... +} + +export interface ProductionOrderStats { + total: number; + pending: number; + in_progress: number; + completed: number; +} + +// Server actions -- these call the SAME backend API endpoint regardless of module presence +// The backend API /api/v1/production-orders/* exists independently of the frontend module +'use server'; + +import { executeServerAction, executePaginatedAction } from '@/lib/api/server-actions'; +import { buildApiUrl } from '@/lib/api/query-params'; + +export async function getProductionOrders(params: { page?: number; search?: string; status?: string }) { + return executePaginatedAction({ + url: buildApiUrl('/api/v1/production-orders', params), + }); +} + +export async function getProductionOrderDetail(id: number) { + return executeServerAction({ + url: buildApiUrl(`/api/v1/production-orders/${id}`), + }); +} + +export async function getProductionOrderStats() { + return executeServerAction({ + url: buildApiUrl('/api/v1/production-orders/stats'), + }); +} +``` + +**Important**: The actions in this shared interface call the **same backend API endpoints** as the production module's actions. The backend API exists independently. This is safe because the sales production-orders view is read-only (viewing production order status from sales perspective). + +**For AssigneeSelectModal** (imported by sales/[id]/production-order/page.tsx): +```typescript +// Same dynamic import pattern as Task 0.1 +// NEW: src/interfaces/components/AssigneeSelectModal.tsx +import dynamic from 'next/dynamic'; + +const AssigneeSelectModalImpl = dynamic( + () => import('@/components/production/WorkOrders/AssigneeSelectModal') + .then(mod => ({ default: mod.AssigneeSelectModal })), + { loading: () => null, ssr: false } +); + +export interface AssigneeSelectModalProps { + isOpen: boolean; + onClose: () => void; + onSelect: (assignee: { id: number; name: string }) => void; +} + +export function AssigneeSelectModal(props: AssigneeSelectModalProps) { + if (!props.isOpen) return null; + return ; +} +``` + +**Estimated time**: 1 day +**Risk**: MEDIUM (sales production-orders functionality must be verified) +**Dependencies**: None +**Rollback**: Revert 3 page files to original imports + +--- + +### Task 0.3: Fix Hardcoded Route Navigation + +**Problem**: `sales/production-orders/[id]/page.tsx` line 247 has `router.push("/production/work-orders")`. + +**Strategy**: Wrap in a module-aware navigation helper. + +**File to create**: +- `src/modules/route-resolver.ts` + +**File to modify**: +- `src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx` + +**Code pattern**: +```typescript +// NEW: src/modules/route-resolver.ts +/** + * Resolve routes that may point to tenant-specific modules. + * Falls back to a safe alternative if the target module is not available. + */ +export function resolveTenantRoute( + path: string, + fallback: string = '/dashboard' +): string { + // Phase 1: Simple passthrough (all modules present in monolith) + // Phase 2+: Check module registry for route availability + return path; +} + +// Common route mappings for cross-module navigation +export const CROSS_MODULE_ROUTES = { + workOrders: '/production/work-orders', + constructionContract: '/construction/project/contract', +} as const; + +// MODIFY: sales/production-orders/[id]/page.tsx line 247 +// FROM: +router.push("/production/work-orders"); +// TO: +import { resolveTenantRoute } from '@/modules/route-resolver'; +// ... +router.push(resolveTenantRoute("/production/work-orders", "/sales/order-management-sales/production-orders")); +``` + +**Estimated time**: 0.25 day +**Risk**: LOW +**Dependencies**: None +**Rollback**: Revert to hardcoded string + +--- + +### Task 0.4: Fix QMS Production Type Imports + +**Problem**: `quality/qms/mockData.ts` imports `WorkOrder` type from `production/ProductionDashboard/types` and `ShipmentDetail` from `outbound/ShipmentManagement/types`. + +**Strategy**: The QMS page and quality module are in the SAME tenant package (Kyungdong) as production, so production->quality dependencies are acceptable. However, the `outbound` import is cross-tenant (quality -> Common ERP direction), which is actually the ALLOWED direction. No change needed for outbound imports. + +For the production type import in mockData: since this is mock data, we can inline the type or import from the shared interface. + +**Files to modify**: +- `src/app/[locale]/(protected)/quality/qms/mockData.ts` (replace production type import) + +**Code pattern**: +```typescript +// MODIFY: quality/qms/mockData.ts +// FROM: +import type { WorkOrder } from '@/components/production/ProductionDashboard/types'; +// TO: +// Inline the minimal type needed for mock data +interface WorkOrderMock { + id: number; + work_order_number: string; + status: string; + // ... only fields used in mockData +} +``` + +**Estimated time**: 0.25 day +**Risk**: LOW +**Dependencies**: None + +--- + +### Task 0.5: Fix Dev Generator Production Import + +**Problem**: `src/components/dev/generators/workOrderData.ts` imports `ProcessOption` from production. + +**Strategy**: Move `ProcessOption` type to shared interfaces. + +**Files to modify**: +- `src/components/dev/generators/workOrderData.ts` +- `src/interfaces/production-orders.ts` (add ProcessOption type) + +**Estimated time**: 0.25 day +**Risk**: LOW (dev-only code) +**Dependencies**: Task 0.2 + +--- + +### Task 0.6: Make Dashboard Invalidation Dynamic + +**Problem**: `src/lib/dashboard-invalidation.ts` hardcodes `'production'` and `'construction'` as `DomainKey` values. + +**Strategy**: Change from hardcoded union type to a registry-based system. + +**File to modify**: +- `src/lib/dashboard-invalidation.ts` + +**Code pattern**: +```typescript +// MODIFY: src/lib/dashboard-invalidation.ts + +// BEFORE: hardcoded type union +type DomainKey = 'deposit' | 'withdrawal' | ... | 'production' | 'construction'; +const DOMAIN_SECTION_MAP: Record = { ... }; + +// AFTER: registry-based +type CoreDomainKey = 'deposit' | 'withdrawal' | 'sales' | 'purchase' | 'badDebt' + | 'expectedExpense' | 'bill' | 'giftCertificate' | 'journalEntry' + | 'order' | 'stock' | 'schedule' | 'client' | 'leave' + | 'approval' | 'attendance'; + +// Extendable domain key (modules can register additional domains) +type DomainKey = CoreDomainKey | string; + +// Core mappings (always present) +const CORE_DOMAIN_SECTION_MAP: Record = { + deposit: ['dailyReport', 'receivable'], + withdrawal: ['dailyReport', 'monthlyExpense'], + // ... all core mappings +}; + +// Extended mappings (registered by modules) +const extendedDomainMap = new Map(); + +/** Register module-specific dashboard domain mappings */ +export function registerDashboardDomain(domain: string, sections: DashboardSectionKey[]): void { + extendedDomainMap.set(domain, sections); +} + +// Updated function +export function invalidateDashboard(domain: DomainKey): void { + const sections = (CORE_DOMAIN_SECTION_MAP as Record)[domain] + ?? extendedDomainMap.get(domain) + ?? []; + if (sections.length === 0) return; + // ... rest unchanged +} +``` + +Then in production/construction modules, register on load: +```typescript +// src/components/production/WorkOrders/WorkOrderCreate.tsx (at module level) +import { registerDashboardDomain } from '@/lib/dashboard-invalidation'; +registerDashboardDomain('production', ['statusBoard', 'dailyProduction']); +registerDashboardDomain('shipment', ['statusBoard', 'unshipped']); + +// src/components/business/construction/.../ConstructionDetailClient.tsx +registerDashboardDomain('construction', ['statusBoard', 'construction']); +``` + +**Estimated time**: 0.5 day +**Risk**: LOW (backward compatible, registration is additive) +**Dependencies**: None +**Rollback**: Revert to hardcoded map + +--- + +### Phase 0 Summary + +| Task | Effort | Risk | Parallel? | +|------|--------|------|-----------| +| 0.1 Extract InspectionReportModal | 0.5d | LOW | Yes | +| 0.2 Extract Production Orders interface | 1d | MEDIUM | Yes | +| 0.3 Fix hardcoded route navigation | 0.25d | LOW | Yes | +| 0.4 Fix QMS production type imports | 0.25d | LOW | Yes | +| 0.5 Fix dev generator import | 0.25d | LOW | After 0.2 | +| 0.6 Make dashboard invalidation dynamic | 0.5d | LOW | Yes | +| **Total** | **2.75d** | | | +| **Buffer** | **+1.25d** | | | +| **Phase 0 Total** | **4d** | | | + +**Phase 0 Exit Criteria**: +- Zero imports from `@/components/production/` in any file under `src/components/approval/` +- Zero imports from `@/components/production/` in any file under `src/app/[locale]/(protected)/sales/` +- Zero hardcoded production/construction route strings outside their own modules +- `dashboard-invalidation.ts` has no hardcoded `'production'` or `'construction'` in its type definitions +- All existing functionality works identically (regression test) + +--- + +## 3. Phase 1: Module Registry & Route Guard + +> **Goal**: Create a tenant-aware module system and enforce route-level access control. +> **Duration**: 3-4 days +> **Prerequisite**: Phase 0 complete +> **Risk**: MEDIUM (middleware change affects all routes) + +### Task 1.1: Define Module Registry Types + +**File to create**: `src/modules/types.ts` + +```typescript +/** + * Module definition for tenant-based separation. + * Each module represents a group of pages and components + * that belong to a specific tenant or are optional add-ons. + */ + +export type ModuleId = + | 'common' // Always available + | 'production' // Kyungdong: Shutter MES + | 'quality' // Kyungdong: Quality management + | 'construction' // Juil: Construction management + | 'vehicle-management'; // Optional add-on + +export interface ModuleManifest { + id: ModuleId; + name: string; + description: string; + /** Route prefixes owned by this module (e.g., ['/production', '/quality']) */ + routePrefixes: string[]; + /** Dashboard section keys this module contributes */ + dashboardSections?: string[]; + /** Dashboard domain keys for invalidation */ + dashboardDomains?: Record; +} + +export interface TenantModuleConfig { + tenantId: number; + /** Which industry type this tenant belongs to */ + industry?: string; + /** Explicitly enabled modules (overrides industry defaults) */ + enabledModules: ModuleId[]; +} + +/** Runtime module availability check result */ +export interface ModuleAccess { + allowed: boolean; + reason?: 'not_licensed' | 'not_configured' | 'route_not_found'; + redirectTo?: string; +} +``` + +**Estimated time**: 0.25 day + +--- + +### Task 1.2: Create Module Registry + +**File to create**: `src/modules/index.ts` + +```typescript +import type { ModuleManifest, ModuleId } from './types'; + +/** + * Static module manifest registry. + * + * Phase 1: All modules registered here. + * Phase 2: Loaded from backend JSON. + */ +const MODULE_REGISTRY: Record = { + common: { + id: 'common', + name: 'Common ERP', + description: 'Core ERP modules available to all tenants', + routePrefixes: [ + '/dashboard', '/accounting', '/sales', '/hr', '/approval', + '/board', '/boards', '/customer-center', '/settings', + '/master-data', '/material', '/outbound', '/reports', + '/company-info', '/subscription', '/payment-history', + ], + }, + production: { + id: 'production', + name: 'Production Management', + description: 'Shutter MES production and work order management', + routePrefixes: ['/production'], + dashboardSections: ['production', 'shipment'], + dashboardDomains: { + production: ['statusBoard', 'dailyProduction'], + shipment: ['statusBoard', 'unshipped'], + }, + }, + quality: { + id: 'quality', + name: 'Quality Management', + description: 'Equipment and inspection management', + routePrefixes: ['/quality'], + dashboardSections: [], + }, + construction: { + id: 'construction', + name: 'Construction Management', + description: 'Juil construction project management', + routePrefixes: ['/construction'], + dashboardSections: ['construction'], + dashboardDomains: { + construction: ['statusBoard', 'construction'], + }, + }, + 'vehicle-management': { + id: 'vehicle-management', + name: 'Vehicle Management', + description: 'Vehicle and forklift management', + routePrefixes: ['/vehicle-management', '/vehicle'], + dashboardSections: [], + }, +}; + +/** Get module manifest by ID */ +export function getModuleManifest(moduleId: ModuleId): ModuleManifest | undefined { + return MODULE_REGISTRY[moduleId]; +} + +/** Get all registered module manifests */ +export function getAllModules(): ModuleManifest[] { + return Object.values(MODULE_REGISTRY); +} + +/** Find which module owns a given route prefix */ +export function getModuleForRoute(pathname: string): ModuleId { + for (const [moduleId, manifest] of Object.entries(MODULE_REGISTRY)) { + if (moduleId === 'common') continue; // Check specific modules first + for (const prefix of manifest.routePrefixes) { + if (pathname === prefix || pathname.startsWith(prefix + '/')) { + return moduleId as ModuleId; + } + } + } + return 'common'; // Default: common ERP +} + +/** Get all route prefixes for a set of enabled modules */ +export function getAllowedRoutePrefixes(enabledModules: ModuleId[]): string[] { + const prefixes: string[] = []; + for (const moduleId of ['common' as ModuleId, ...enabledModules]) { + const manifest = MODULE_REGISTRY[moduleId]; + if (manifest) { + prefixes.push(...manifest.routePrefixes); + } + } + return prefixes; +} + +/** Get dashboard section keys for enabled modules */ +export function getEnabledDashboardSections(enabledModules: ModuleId[]): string[] { + const sections: string[] = []; + for (const moduleId of enabledModules) { + const manifest = MODULE_REGISTRY[moduleId]; + if (manifest?.dashboardSections) { + sections.push(...manifest.dashboardSections); + } + } + return sections; +} +``` + +**Estimated time**: 0.5 day + +--- + +### Task 1.3: Create Tenant Configuration + +**File to create**: `src/modules/tenant-config.ts` + +```typescript +import type { ModuleId, TenantModuleConfig } from './types'; + +/** + * Phase 1: Hardcoded tenant-module mappings. + * Phase 2: Loaded from backend API response (/api/auth/user). + * + * Industry-based default modules: + * - 'shutter_mes' (Kyungdong): production + quality + * - 'construction' (Juil): construction + * - undefined / other: common only + */ + +const INDUSTRY_MODULE_MAP: Record = { + shutter_mes: ['production', 'quality'], + construction: ['construction'], +}; + +/** + * Resolve enabled modules for a tenant. + * + * Priority: + * 1. Explicit tenant configuration from backend (Phase 2) + * 2. Industry-based defaults + * 3. Empty (common ERP only) + */ +export function resolveEnabledModules(options: { + industry?: string; + explicitModules?: ModuleId[]; + optionalModules?: ModuleId[]; +}): ModuleId[] { + const { industry, explicitModules, optionalModules = [] } = options; + + // Phase 2: Backend provides explicit module list + if (explicitModules && explicitModules.length > 0) { + return [...explicitModules, ...optionalModules]; + } + + // Phase 1: Industry-based defaults + const industryModules = industry ? (INDUSTRY_MODULE_MAP[industry] ?? []) : []; + return [...industryModules, ...optionalModules]; +} + +/** + * Check if a specific module is enabled for the current tenant. + * This is the primary API for components to check module availability. + */ +export function isModuleEnabled( + moduleId: ModuleId, + enabledModules: ModuleId[] +): boolean { + if (moduleId === 'common') return true; + return enabledModules.includes(moduleId); +} +``` + +**Estimated time**: 0.25 day + +--- + +### Task 1.4: Create useModules Hook + +**File to create**: `src/hooks/useModules.ts` + +```typescript +'use client'; + +import { useMemo } from 'react'; +import { useAuthStore } from '@/stores/authStore'; +import type { ModuleId } from '@/modules/types'; +import { resolveEnabledModules, isModuleEnabled } from '@/modules/tenant-config'; +import { getModuleForRoute, getEnabledDashboardSections } from '@/modules'; + +/** + * Hook to access tenant module configuration. + * Returns enabled modules and helper functions. + */ +export function useModules() { + const tenant = useAuthStore((state) => state.currentUser?.tenant); + + const enabledModules = useMemo(() => { + if (!tenant) return []; + return resolveEnabledModules({ + industry: tenant.options?.industry, + // Phase 2: read from tenant.options?.modules + }); + }, [tenant]); + + const isEnabled = useMemo(() => { + return (moduleId: ModuleId) => isModuleEnabled(moduleId, enabledModules); + }, [enabledModules]); + + const isRouteAllowed = useMemo(() => { + return (pathname: string) => { + const owningModule = getModuleForRoute(pathname); + if (owningModule === 'common') return true; + return enabledModules.includes(owningModule); + }; + }, [enabledModules]); + + const dashboardSections = useMemo(() => { + return getEnabledDashboardSections(enabledModules); + }, [enabledModules]); + + return { + enabledModules, + isEnabled, + isRouteAllowed, + dashboardSections, + tenantIndustry: tenant?.options?.industry, + }; +} +``` + +**Estimated time**: 0.25 day + +--- + +### Task 1.5: Add Route Guard to Middleware + +**Problem**: Currently, any authenticated user can access any route via URL. Tenant users should only access their licensed modules. + +**Strategy**: Add a module check step to the existing middleware at `src/middleware.ts`, between the authentication check (step 7) and the i18n middleware (step 8). + +**File to modify**: `src/middleware.ts` + +**Design considerations**: +- Middleware runs on Edge Runtime -- cannot access Zustand store +- Must read tenant info from cookies or a lightweight API call +- First iteration: read `tenant_modules` cookie set at login +- The cookie is set by the login flow (or the API proxy refresh flow) + +**Code pattern**: +```typescript +// ADD to src/middleware.ts after step 7 (authentication check) + +// 7.5: Module-based route guard +// Read tenant's enabled modules from cookie (set at login) +const tenantModulesCookie = request.cookies.get('tenant_modules')?.value; +if (tenantModulesCookie) { + const enabledModules: string[] = JSON.parse(tenantModulesCookie); + + // Import route check logic (keep it simple for Edge Runtime) + const moduleRouteMap: Record = { + production: ['/production'], + quality: ['/quality'], + construction: ['/construction'], + 'vehicle-management': ['/vehicle-management', '/vehicle'], + }; + + // Check if the requested path belongs to a module the tenant doesn't have + for (const [moduleId, prefixes] of Object.entries(moduleRouteMap)) { + for (const prefix of prefixes) { + if ( + (pathnameWithoutLocale === prefix || pathnameWithoutLocale.startsWith(prefix + '/')) && + !enabledModules.includes(moduleId) && + !enabledModules.includes('all') // Superadmin override + ) { + // Redirect to dashboard with a message parameter + return NextResponse.redirect( + new URL('/dashboard?module_denied=true', request.url) + ); + } + } + } +} +``` + +**Backend requirement**: The login API response or `/api/auth/user` response needs to include `enabled_modules` or `tenant.options.modules`. This cookie must be set during login flow. + +**File to modify for cookie setting**: The login server action or API proxy that handles authentication. Specifically: +- `src/lib/api/auth/` -- wherever the login response is processed and cookies are set + +**Alternative (simpler, Phase 1 only)**: Skip middleware route guard initially. Instead, add a client-side `` component to the `(protected)/layout.tsx` that checks the route against enabled modules and shows an "access denied" page. + +```typescript +// NEW: src/components/auth/ModuleGuard.tsx +'use client'; + +import { usePathname } from 'next/navigation'; +import { useModules } from '@/hooks/useModules'; + +export function ModuleGuard({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const { isRouteAllowed } = useModules(); + + // Remove locale prefix for checking + const cleanPath = pathname.replace(/^\/[a-z]{2}\//, '/'); + + if (!isRouteAllowed(cleanPath)) { + return ( +
+

์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ

+

+ ํ˜„์žฌ ๊ณ„์•ฝ์— ํฌํ•จ๋˜์ง€ ์•Š์€ ๋ชจ๋“ˆ์ž…๋‹ˆ๋‹ค. +

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

์ƒ์‚ฐ๊ด€๋ฆฌ ๋ชจ๋“ˆ์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+
+
+ ); + } + + // ... existing page content +} +``` + +**Estimated time**: 0.5 day +**Risk**: LOW + +--- + +### Task 3.4: Update tsconfig Path Aliases (Optional) + +For future package extraction, add path aliases that make module boundaries explicit. + +**File to modify**: `tsconfig.json` + +```json +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "@modules/*": ["./src/modules/*"], + "@interfaces/*": ["./src/interfaces/*"] + } + } +} +``` + +**Estimated time**: 0.25 day +**Risk**: LOW + +--- + +### Task 3.5: Document Module Boundaries in CLAUDE.md + +**File to modify**: Project `CLAUDE.md` + +Add a section documenting the module separation architecture: + +```markdown +## Module Separation Architecture +**Priority**: RED + +### Module Ownership +| Module ID | Route Prefixes | Component Path | Tenant | +|-----------|----------------|----------------|--------| +| common | /dashboard, /accounting, /sales, ... | src/components/{accounting,approval,...} | All | +| production | /production | src/components/production/ | Kyungdong | +| quality | /quality | src/components/quality/ | Kyungdong | +| construction | /construction | src/components/business/construction/ | Juil | +| vehicle-management | /vehicle-management | src/components/vehicle-management/ | Optional | + +### Dependency Rules +- ALLOWED: tenant module -> Common ERP (e.g., production -> @/lib/*) +- FORBIDDEN: Common ERP -> tenant module (e.g., approval -> production) +- SHARED: Use @/interfaces/ for types, @/components/document-system/ for shared modals +- DYNAMIC: Use next/dynamic for optional cross-module component loading + +### Verification +Run `scripts/verify-module-separation.sh` to check for forbidden imports. +``` + +**Estimated time**: 0.25 day +**Risk**: LOW + +--- + +### Phase 3 Summary + +| Task | Effort | Risk | Parallel? | +|------|--------|------|-----------| +| 3.1 Module boundary markers | 0.25d | LOW | Yes | +| 3.2 Verification script | 0.5d | LOW | Yes | +| 3.3 Sales conditional loading | 0.5d | LOW | Yes | +| 3.4 tsconfig paths | 0.25d | LOW | Yes | +| 3.5 Document in CLAUDE.md | 0.25d | LOW | Yes | +| **Total** | **1.75d** | | | +| **Buffer** | **+0.5d** | | | +| **Phase 3 Total** | **2.25d** | | | + +**Phase 3 Exit Criteria**: +- `verify-module-separation.sh` passes with 0 violations +- Each module has MODULE.md with dependency documentation +- Sales production-orders pages check module availability +- tsconfig has @modules/ and @interfaces/ aliases +- CLAUDE.md documents module separation rules + +--- + +## 6. Phase 4: Manifest-Based Module Loading (Future) + +> **Goal**: Backend-driven module configuration. No frontend code changes needed for new tenants. +> **Duration**: 5-8 days (separate project) +> **Prerequisite**: Phase 3 complete + backend API changes + +This phase is a separate project. Documenting the design here for reference. + +### 4.1: Backend Module Configuration API + +**New endpoint**: `GET /api/v1/tenant/modules` + +```json +{ + "tenant_id": 282, + "modules": [ + { + "id": "production", + "enabled": true, + "config": { + "features": ["work-orders", "worker-screen", "production-dashboard"], + "hidden_features": ["screen-production"] + } + }, + { + "id": "quality", + "enabled": true, + "config": { + "features": ["equipment", "inspections", "qms"] + } + } + ], + "dashboard_sections": ["production", "shipment", "unshipped"], + "menu_overrides": {} +} +``` + +### 4.2: Frontend Manifest Loader + +```typescript +// src/modules/manifest-loader.ts +export async function loadModuleManifest(tenantId: number): Promise { + const response = await fetch(`/api/proxy/tenant/modules`); + const data = await response.json(); + return data.modules; +} +``` + +### 4.3: Dynamic Route Generation + +Using Next.js catch-all routes with module manifest: + +```typescript +// src/app/[locale]/(protected)/[...slug]/page.tsx +// This catch-all route handles module pages that are dynamically enabled. +// Phase 4 only -- requires significant backend work. +``` + +### 4.4: Module Feature Flags + +Fine-grained control within modules: + +```typescript +// e.g., production module has features: work-orders, worker-screen, etc. +// A tenant might have production enabled but worker-screen disabled +function isFeatureEnabled(moduleId: string, featureId: string): boolean; +``` + +--- + +## 7. Testing Strategy + +### Phase 0 Testing + +| Test | Method | Who | +|------|--------|-----| +| ApprovalBox renders correctly | Manual: navigate to /approval/inbox, open a work_order linked document | User | +| Sales production-orders list works | Manual: /sales/order-management-sales/production-orders | User | +| Sales production-order detail works | Manual: click an item in the list above | User | +| QMS page renders | Manual: /quality/qms | User | +| Dashboard invalidation still works | Manual: create a work order, navigate to dashboard, verify production section refreshes | User | +| Build passes | User runs `npm run build` | User | + +### Phase 1 Testing + +| Test | Method | Who | +|------|--------|-----| +| Module guard blocks unauthorized routes | Set tenant industry to 'construction', navigate to /production | Dev | +| Module guard allows authorized routes | Set tenant industry to 'shutter_mes', navigate to /production | Dev | +| Common routes always accessible | Navigate to /accounting, /sales, /hr with any tenant | Dev | +| Module-denied toast appears | Navigate to denied route, verify redirect + toast | Dev | +| Build passes | User runs `npm run build` | User | + +### Phase 2 Testing + +| Test | Method | Who | +|------|--------|-----| +| Kyungdong dashboard shows production | Log in as Kyungdong tenant, check dashboard sections | Dev | +| Kyungdong dashboard hides construction | Same login, verify no construction section | Dev | +| Juil dashboard shows construction | Log in as Juil tenant, check dashboard | Dev | +| Juil dashboard hides production | Same login, verify no production/shipment sections | Dev | +| Common tenant shows neither | Log in as common-only tenant, check dashboard | Dev | +| Settings dialog matches | Open settings, verify only available sections shown | Dev | +| Calendar navigation handles missing modules | Click calendar item that would go to disabled module | Dev | +| No console errors | Check browser console during all above tests | Dev | +| Build passes | User runs `npm run build` | User | + +### Phase 3 Testing + +| Test | Method | Who | +|------|--------|-----| +| Verification script passes | Run `scripts/verify-module-separation.sh` | Dev | +| Sales production-orders disabled for non-production tenant | Navigate as construction tenant | Dev | +| Full regression test | Navigate all major pages as each tenant type | Dev + User | +| Build passes | User runs `npm run build` | User | + +--- + +## 8. Risk Register + +| # | Risk | Probability | Impact | Mitigation | Phase | +|---|------|------------|--------|------------|-------| +| R1 | InspectionReportModal dynamic import fails silently | LOW | HIGH | Error boundary wrapper, fallback UI, monitoring | 0 | +| R2 | Sales production-orders breaks after interface extraction | MEDIUM | HIGH | Keep original actions.ts as fallback, test all 3 pages | 0 | +| R3 | Middleware route guard blocks legitimate access | MEDIUM | CRITICAL | Start with client-side guard (softer failure), add middleware later | 1 | +| R4 | Tenant industry field not set for existing tenants | HIGH | MEDIUM | Default to 'all modules enabled' when industry is undefined | 1 | +| R5 | Dashboard section removal changes layout/spacing | LOW | LOW | Test each section combination, verify CSS grid/flex behavior | 2 | +| R6 | Dashboard API call failure on disabled module endpoint | LOW | MEDIUM | Graceful null handling already exists (data ?? fallback) | 2 | +| R7 | Build size increases due to dynamic imports | LOW | LOW | Measure bundle size before/after, dynamic imports reduce initial bundle | 3 | +| R8 | Developer accidentally adds forbidden import | MEDIUM | LOW | Verification script in CI, CLAUDE.md rules, MODULE.md docs | 3 | + +### Rollback Strategy Per Phase + +| Phase | Rollback Method | Time to Rollback | +|-------|----------------|------------------| +| Phase 0 | `git revert` each task commit | < 5 minutes | +| Phase 1 | Remove ModuleGuard from layout.tsx, revert middleware | < 10 minutes | +| Phase 2 | Revert CEODashboard changes (remove isEnabled checks) | < 10 minutes | +| Phase 3 | No runtime changes to revert (documentation + scripts only) | N/A | + +--- + +## 9. Folder Structure Before/After + +### Before (Current) + +``` +src/ + app/[locale]/(protected)/ + accounting/ # Common ERP + approval/ # Common ERP (imports from production -- VIOLATION) + board/ # Common ERP + construction/ # Juil tenant + customer-center/ # Common ERP + dashboard/ # Common ERP (renders production/construction -- VIOLATION) + hr/ # Common ERP + master-data/ # Common ERP + material/ # Common ERP + outbound/ # Common ERP + production/ # Kyungdong tenant + quality/ # Kyungdong tenant + reports/ # Common ERP + sales/ # Common ERP (imports from production -- VIOLATION) + settings/ # Common ERP + vehicle-management/ # Optional + components/ + approval/ # imports InspectionReportModal from production + business/ + CEODashboard/ # hardcodes production/construction sections + construction/ # Juil tenant components + production/ # Kyungdong tenant components + quality/ # Kyungdong tenant components + vehicle-management/ # Optional components + lib/ + dashboard-invalidation.ts # hardcodes production/construction +``` + +### After (End of Phase 3) + +``` +src/ + modules/ # NEW + index.ts # module registry + types.ts # ModuleId, ModuleManifest, TenantModuleConfig + tenant-config.ts # industry -> module mapping + route-resolver.ts # tenant-aware route resolution + interfaces/ # NEW + production-orders.ts # shared types + actions for sales <-> production + components/ + AssigneeSelectModal.tsx # dynamic import wrapper + app/[locale]/(protected)/ + accounting/ # Common ERP + approval/ # Common ERP (no more production imports) + board/ # Common ERP + construction/ # Juil tenant (guarded by ModuleGuard) + customer-center/ # Common ERP + dashboard/ # Common ERP (conditionally renders sections) + hr/ # Common ERP + master-data/ # Common ERP + material/ # Common ERP + outbound/ # Common ERP + production/ # Kyungdong tenant (guarded by ModuleGuard) + quality/ # Kyungdong tenant (guarded by ModuleGuard) + reports/ # Common ERP + sales/ # Common ERP (production-orders guarded) + settings/ # Common ERP + vehicle-management/ # Optional (guarded by ModuleGuard) + components/ + auth/ + ModuleGuard.tsx # NEW: route-level module access check + approval/ # clean (no production imports) + business/ + CEODashboard/ # module-aware section rendering + construction/ # Juil tenant (with MODULE.md) + document-system/ + modals/ # NEW: shared modals + InspectionReportModal.tsx # dynamic import wrapper + WorkLogModal.tsx # dynamic import wrapper + index.ts + production/ # Kyungdong tenant (with MODULE.md) + WorkOrders/documents/ + InspectionReportModal.tsx # re-exports from document-system + WorkLogModal.tsx # re-exports from document-system + quality/ # Kyungdong tenant (with MODULE.md) + vehicle-management/ # Optional (with MODULE.md) + hooks/ + useModules.ts # NEW: tenant module access hook + lib/ + dashboard-invalidation.ts # registry-based (no hardcoded modules) + scripts/ + verify-module-separation.sh # NEW: import violation checker +``` + +--- + +## 10. Migration Order & Parallelism + +### Execution Timeline + +``` +Week 1 (Phase 0): + Day 1-2: Tasks 0.1, 0.2, 0.3, 0.4 (all parallel) + Day 3: Task 0.5, 0.6 + Day 4: Phase 0 testing + buffer + +Week 2 (Phase 1 + Phase 2): + Day 1: Tasks 1.1, 1.2, 1.3 (parallel) + Day 2: Tasks 1.4, 1.5 (sequential) + Day 3: Task 1.6 + Phase 1 testing + Day 4: Tasks 2.1, 2.2, 2.3 (2.1 first, then parallel) + Day 5: Tasks 2.4, 2.5, 2.6 + Phase 2 testing + +Week 3 (Phase 3 + Buffer): + Day 1: Tasks 3.1, 3.2, 3.3, 3.4, 3.5 (all parallel) + Day 2: Phase 3 testing + full regression + Day 3: Buffer / bug fixes +``` + +### Parallelism Map + +``` +Phase 0: + [0.1 InspReportModal] [0.2 ProdOrders Interface] [0.3 Route Nav] [0.4 QMS Types] + | | | | + v v | | + [0.5 Dev Gen] ----------------+ | + | | + [0.6 Dashboard Invalidation] --+----------------------------------------+ + | + v + Phase 0 Testing + +Phase 1: + [1.1 Types] ----+----> [1.2 Registry] ----+ + | | + +----> [1.3 Tenant Config]-+----> [1.4 useModules] ----> [1.5 Route Guard] ----> [1.6 Toast] + | + v + Phase 1 Testing + +Phase 2: + [2.1 Module-aware types] ----+----> [2.2 Dashboard] + |----> [2.3 Settings Dialog] + |----> [2.4 Hook Skip] + |----> [2.5 Calendar Routes] + +----> [2.6 Summary Nav] + | + v + Phase 2 Testing + +Phase 3: + [3.1 MODULE.md] [3.2 Verify Script] [3.3 Sales Guard] [3.4 tsconfig] [3.5 CLAUDE.md] + | | | | | + v v v v v + Phase 3 Testing + Full Regression +``` + +### What Can Be Done Today (Before Backend Changes) + +Everything in Phases 0-3 can proceed without backend changes. The `useModules` hook falls back to industry-based module resolution using the existing `tenant.options.industry` field in the auth store. The only backend requirement is that this field is populated correctly for existing tenants. + +If `industry` is not set for some tenants, the system defaults to showing all modules (current behavior), so there is zero risk of breaking existing functionality. + +--- + +## Appendix A: File Count Summary + +| Category | Files | Pages | +|----------|-------|-------| +| Common ERP components | ~400+ | ~165 | +| Production (Kyungdong) | 56 component files | 12 pages | +| Quality (Kyungdong) | 35+ component files | 14 pages | +| Construction (Juil) | 161 component files | 57 pages | +| Vehicle Management (Optional) | 13 component files | 12 pages | +| **Total** | ~665+ | 275 | + +## Appendix B: New Files Created (All Phases) + +| File | Phase | Purpose | +|------|-------|---------| +| `src/modules/types.ts` | 1 | Module type definitions | +| `src/modules/index.ts` | 1 | Module registry | +| `src/modules/tenant-config.ts` | 1 | Tenant-to-module mapping | +| `src/modules/route-resolver.ts` | 0 | Tenant-aware route resolution | +| `src/interfaces/production-orders.ts` | 0 | Shared production order types/actions | +| `src/interfaces/components/AssigneeSelectModal.tsx` | 0 | Dynamic import wrapper | +| `src/components/document-system/modals/InspectionReportModal.tsx` | 0 | Dynamic import wrapper | +| `src/components/document-system/modals/WorkLogModal.tsx` | 0 | Dynamic import wrapper | +| `src/components/document-system/modals/index.ts` | 0 | Barrel export | +| `src/components/auth/ModuleGuard.tsx` | 1 | Route-level module check | +| `src/hooks/useModules.ts` | 1 | Module access hook | +| `scripts/verify-module-separation.sh` | 3 | Import violation checker | +| `src/components/production/MODULE.md` | 3 | Module boundary doc | +| `src/components/quality/MODULE.md` | 3 | Module boundary doc | +| `src/components/business/construction/MODULE.md` | 3 | Module boundary doc | +| `src/components/vehicle-management/MODULE.md` | 3 | Module boundary doc | + +**Total new files**: 16 +**Total modified files**: ~20 + +## Appendix C: Backend API Request Summary + +| # | Type | Endpoint | Description | Required Phase | +|---|------|----------|-------------|----------------| +| B1 | MODIFY | `GET /api/auth/user` | Add `tenant.options.modules` array | Phase 1 (optional, has fallback) | +| B2 | NEW | `GET /api/v1/tenant/modules` | Full module configuration | Phase 4 | + +--- + +**Document Version**: 1.0 +**Author**: Claude (System Architect analysis) +**Review Required By**: Backend team (B1, B2), Frontend lead (all phases) diff --git a/claudedocs/architecture/module-separation-guide.md b/claudedocs/architecture/module-separation-guide.md new file mode 100644 index 00000000..c35dadc0 --- /dev/null +++ b/claudedocs/architecture/module-separation-guide.md @@ -0,0 +1,315 @@ +# SAM ERP ๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ์•„ํ‚คํ…์ฒ˜ + +> ์ž‘์„ฑ์ผ: 2026-03-18 +> ์ƒํƒœ: ํ”„๋ก ํŠธ์—”๋“œ Phase 0~3 ์™„๋ฃŒ / ๋ฐฑ์—”๋“œ ์ž‘์—… ํ•„์š” + +--- + +## 0. ์™œ ์‚ฐ์—…๊ตฐ๋ณ„ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ๊ฐ€ ํ•„์š”ํ•œ๊ฐ€ (ํ˜‘์˜ ํ•„์š”) + +### ํ˜„์žฌ ์ƒํ™ฉ: ํ•˜๋‚˜์˜ ERP, ๋‹ค๋ฅธ ์—…์ข…์˜ ๊ณ ๊ฐ์‚ฌ + +SAM ERP๋Š” **ํ•˜๋‚˜์˜ ์ฝ”๋“œ๋ฒ ์ด์Šค**๋กœ ์—ฌ๋Ÿฌ ํšŒ์‚ฌ(ํ…Œ๋„ŒํŠธ)์— ์„œ๋น„์Šค๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +๊ทธ๋Ÿฐ๋ฐ ๊ณ ๊ฐ์‚ฌ๋งˆ๋‹ค ์—…์ข…์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค: + +``` +๊ฒฝ๋™ โ†’ ์…”ํ„ฐ ์ œ์กฐ์—… (MES) โ†’ ์ƒ์‚ฐ๊ด€๋ฆฌ, ํ’ˆ์งˆ๊ด€๋ฆฌ, ์ฐจ๋Ÿ‰๊ด€๋ฆฌ๊ฐ€ ํ•„์š” +์ฃผ์ผ โ†’ ๊ฑด์„ค์—… โ†’ ์‹œ๊ณต๊ด€๋ฆฌ, ์ฐจ๋Ÿ‰๊ด€๋ฆฌ๊ฐ€ ํ•„์š” +A์‚ฌ โ†’ ์œ ํ†ต/์„œ๋น„์Šค์—… โ†’ ๊ณตํ†ต ERP(ํšŒ๊ณ„/์ธ์‚ฌ/์˜์—…)๋งŒ ํ•„์š” +``` + +ํ˜„์žฌ๋Š” **๋ชจ๋“  ํ…Œ๋„ŒํŠธ์—๊ฒŒ ๋ชจ๋“  ๋ฉ”๋‰ด๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค.** +๊ฒฝ๋™ ์ง์›์—๊ฒŒ ์‹œ๊ณต๊ด€๋ฆฌ ๋ฉ”๋‰ด๊ฐ€ ๋ณด์ด๊ณ , ์ฃผ์ผ ์ง์›์—๊ฒŒ ์ƒ์‚ฐ๊ด€๋ฆฌ ๋ฉ”๋‰ด๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. +โ†’ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ๋ฉ”๋‰ด๊ฐ€ ๋…ธ์ถœ๋˜์–ด ํ˜ผ๋ž€์„ ์ฃผ๊ณ , ๋Œ€์‹œ๋ณด๋“œ์—๋„ ๋ถˆํ•„์š”ํ•œ ์„น์…˜์ด ๋‚˜ํƒ€๋‚จ. + +### ์ œ์•ˆ: ์—…์ข…(industry) ๊ธฐ๋ฐ˜ ๋ชจ๋“ˆ ON/OFF + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ SAM ERP (๊ณตํ†ต) โ”‚ +โ”‚ ํšŒ๊ณ„ ยท ์ธ์‚ฌ ยท ์˜์—… ยท ๊ฒฐ์žฌ ยท ๊ฒŒ์‹œํŒ ยท ์„ค์ • โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ ์ƒ์‚ฐ๊ด€๋ฆฌ โ”‚ ํ’ˆ์งˆ๊ด€๋ฆฌ โ”‚ ์‹œ๊ณต๊ด€๋ฆฌ โ”‚ ์ฐจ๋Ÿ‰๊ด€๋ฆฌ โ”‚ +โ”‚ (MES) โ”‚ โ”‚ (๊ฑด์„ค) โ”‚ (์„ ํƒ) โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ +โ”‚ ์…”ํ„ฐ MES ์—…์ข… โ”‚ ๊ฑด์„ค ์—…์ข… โ”‚ โ”‚ +โ”‚ (๊ฒฝ๋™) โ”‚ (์ฃผ์ผ) โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +- **๊ณตํ†ต ๋ชจ๋“ˆ**: ๋ชจ๋“  ํ…Œ๋„ŒํŠธ๊ฐ€ ์‚ฌ์šฉ (ํšŒ๊ณ„, ์ธ์‚ฌ, ์˜์—… ๋“ฑ) +- **์—…์ข… ๋ชจ๋“ˆ**: ํ…Œ๋„ŒํŠธ์˜ ์—…์ข…์— ๋”ฐ๋ผ ์ž๋™์œผ๋กœ ์ผœ์ง/๊บผ์ง +- **์„ ํƒ ๋ชจ๋“ˆ**: ์—…์ข…๊ณผ ๊ด€๊ณ„์—†์ด ๊ฐœ๋ณ„ ์„ ํƒ ๊ฐ€๋Šฅ (์ฐจ๋Ÿ‰๊ด€๋ฆฌ ๋“ฑ) + +### ํ˜‘์˜๊ฐ€ ํ•„์š”ํ•œ ๋ถ€๋ถ„ + +์ด ๊ตฌ์กฐ๋กœ ๊ฐ€๋ ค๋ฉด ๋‹ค์Œ ์‚ฌํ•ญ์˜ ํ•ฉ์˜๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค: + +#### 1) ์—…์ข… ๋ถ„๋ฅ˜ ์ฒด๊ณ„ +ํ˜„์žฌ ํ”„๋ก ํŠธ์—”๋“œ์— ํ•˜๋“œ์ฝ”๋”ฉ๋œ ๋งคํ•‘: + +| ์—…์ข… ์ฝ”๋“œ | ์˜๋ฏธ | ํ™œ์„ฑ ๋ชจ๋“ˆ | +|-----------|------|-----------| +| `shutter_mes` | ์…”ํ„ฐ ์ œ์กฐ (MES) | ์ƒ์‚ฐ๊ด€๋ฆฌ + ํ’ˆ์งˆ๊ด€๋ฆฌ + ์ฐจ๋Ÿ‰๊ด€๋ฆฌ | +| `construction` | ๊ฑด์„ค์—… | ์‹œ๊ณต๊ด€๋ฆฌ + ์ฐจ๋Ÿ‰๊ด€๋ฆฌ | + +**Q. ์ด ๋ถ„๋ฅ˜๊ฐ€ ๋งž๋Š”์ง€? ์ถ”๊ฐ€ํ•  ์—…์ข…์ด ์žˆ๋Š”์ง€?** +์˜ˆ: ์ผ๋ฐ˜ ์ œ์กฐ์—…, ๋„์†Œ๋งค์—…, ์„œ๋น„์Šค์—… ๋“ฑ + +#### 2) ๋ชจ๋“ˆ ๊ฒฝ๊ณ„ +ํ˜„์žฌ ์ •์˜๋œ ๋ชจ๋“ˆ ๋‹จ์œ„: + +| ๋ชจ๋“ˆ | ํฌํ•จ ๊ธฐ๋Šฅ | ๋น„๊ณ  | +|------|----------|------| +| ๊ณตํ†ต ERP | ๋Œ€์‹œ๋ณด๋“œ, ํšŒ๊ณ„, ์ธ์‚ฌ, ์˜์—…, ๊ฒฐ์žฌ, ๊ฒŒ์‹œํŒ, ์„ค์ • ๋“ฑ | ํ•ญ์ƒ ON | +| ์ƒ์‚ฐ๊ด€๋ฆฌ | ์ƒ์‚ฐ์ง€์‹œ, ์ž‘์—…์ง€์‹œ, ์ž‘์—…์ผ๋ณด | ๊ฒฝ๋™ ์ „์šฉ | +| ํ’ˆ์งˆ๊ด€๋ฆฌ | ์„ค๋น„์ ๊ฒ€, ์ˆ˜๋ฆฌ์š”์ฒญ, ๊ฒ€์‚ฌ | ๊ฒฝ๋™ ์ „์šฉ | +| ์‹œ๊ณต๊ด€๋ฆฌ | ํ”„๋กœ์ ํŠธ, ๊ณ„์•ฝ, ๊ธฐ์„ฑ, ์‹œ๊ณต์ผ๋ณด | ์ฃผ์ผ ์ „์šฉ | +| ์ฐจ๋Ÿ‰๊ด€๋ฆฌ | ์ฐจ๋Ÿ‰๋“ฑ๋ก, ์šดํ–‰์ผ์ง€, ์ง€๊ฒŒ์ฐจ | ์„ ํƒ์  | + +**Q. ๋ชจ๋“ˆ ๋‹จ์œ„ ๋ฒ”์œ„๊ฐ€ ์ ์ ˆํ•œ์ง€? ๋ถ„๋ฆฌ/ํ†ตํ•ฉ์ด ํ•„์š”ํ•œ ๋ชจ๋“ˆ์ด ์žˆ๋Š”์ง€?** + +#### 3) ํ™œ์„ฑํ™” ๋ฐฉ์‹ +| ๋ฐฉ์‹ | ์žฅ์  | ๋‹จ์  | +|------|------|------| +| **A. ์—…์ข… ์ž๋™** (ํ˜„์žฌ) | ๊ฐ„๋‹จ, ์‹ค์ˆ˜ ๋ฐฉ์ง€ | ์œ ์—ฐ์„ฑ ๋‚ฎ์Œ | +| **B. ๋ชจ๋“ˆ ๊ฐœ๋ณ„ ์„ ํƒ** (ํ–ฅํ›„) | ์œ ์—ฐํ•จ | ๊ด€๋ฆฌ ๋ณต์žก | +| **C. ์—…์ข… ๊ธฐ๋ณธ๊ฐ’ + ๊ฐœ๋ณ„ ์žฌ์ •์˜** | ๊ท ํ˜• | ๊ตฌํ˜„ ๋ณต์žก๋„ ์ค‘๊ฐ„ | + +**Q. ์–ด๋–ค ๋ฐฉ์‹์„ ์ฑ„ํƒํ•  ๊ฒƒ์ธ์ง€?** +ํ˜„์žฌ ํ”„๋ก ํŠธ์—”๋“œ๋Š” A ๋ฐฉ์‹์œผ๋กœ ๊ตฌํ˜„, B/C๋กœ ํ™•์žฅ ๊ฐ€๋Šฅํ•˜๋„๋ก ์„ค๊ณ„๋จ. + +#### 4) ์ ์šฉ ์‹œ์ ๊ณผ ๋ฒ”์œ„ +- ๋ฐฑ์—”๋“œ์—์„œ `tenant.options.industry` ๊ฐ’๋งŒ ์„ธํŒ…ํ•˜๋ฉด ์ฆ‰์‹œ ๋™์ž‘ +- ๊ฐ’์„ ์•ˆ ๋„ฃ์œผ๋ฉด ๊ธฐ์กด๊ณผ 100% ๋™์ผ (๋ถ€์ž‘์šฉ ์ œ๋กœ) +- **Q. ์–ธ์ œ๋ถ€ํ„ฐ, ์–ด๋–ค ํ…Œ๋„ŒํŠธ๋ถ€ํ„ฐ ์ ์šฉํ•  ๊ฒƒ์ธ์ง€?** + +--- + +## 1. ๊ฐœ์š” + +### ๋ชฉํ‘œ +ํ•˜๋‚˜์˜ SAM ERP ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ **ํ…Œ๋„ŒํŠธ(ํšŒ์‚ฌ)๋ณ„๋กœ ํ•„์š”ํ•œ ๋ชจ๋“ˆ๋งŒ ํ™œ์„ฑํ™”**ํ•˜์—ฌ, +๋ถˆํ•„์š”ํ•œ ๋ฉ”๋‰ดยทํŽ˜์ด์ง€ยท๋Œ€์‹œ๋ณด๋“œ ์„น์…˜์„ ์ˆจ๊ธฐ๋Š” ๊ตฌ์กฐ. + +### ํ˜„์žฌ ํ…Œ๋„ŒํŠธ๋ณ„ ๋ชจ๋“ˆ ๊ตฌ์„ฑ +| ์—…์ข… ์ฝ”๋“œ | ํ…Œ๋„ŒํŠธ ์˜ˆ์‹œ | ํ™œ์„ฑ ๋ชจ๋“ˆ | +|-----------|------------|-----------| +| `shutter_mes` | ๊ฒฝ๋™ | ์ƒ์‚ฐ๊ด€๋ฆฌ, ํ’ˆ์งˆ๊ด€๋ฆฌ, ์ฐจ๋Ÿ‰๊ด€๋ฆฌ | +| `construction` | ์ฃผ์ผ | ์‹œ๊ณต๊ด€๋ฆฌ, ์ฐจ๋Ÿ‰๊ด€๋ฆฌ | +| (๋ฏธ์„ค์ •) | ๊ธฐํƒ€ ๋ชจ๋“  ํ…Œ๋„ŒํŠธ | **์ „์ฒด ๋ชจ๋“ˆ ํ™œ์„ฑํ™” (๊ธฐ์กด๊ณผ ๋™์ผ)** | + +### ์•ˆ์ „ ์›์น™ +``` +tenant.options.industry๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์€ ํ…Œ๋„ŒํŠธ โ†’ ๋ชจ๋“  ๊ธฐ๋Šฅ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ += ๊ธฐ์กด ๋™์ž‘ 100% ์œ ์ง€, ๋ถ€์ž‘์šฉ ์ œ๋กœ +``` + +--- + +## 2. ํ”„๋ก ํŠธ์—”๋“œ ๊ตฌ์กฐ (์™„๋ฃŒ) + +### ํŒŒ์ผ ๊ตฌ์กฐ +``` +src/modules/ +โ”œโ”€โ”€ types.ts # ModuleId ํƒ€์ž… ์ •์˜ +โ”œโ”€โ”€ tenant-config.ts # ์—…์ข…โ†’๋ชจ๋“ˆ ๋งคํ•‘ (resolveEnabledModules) +โ””โ”€โ”€ index.ts # ๋ชจ๋“ˆ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ (๋ผ์šฐํŠธ ๋งคํ•‘, ๋Œ€์‹œ๋ณด๋“œ ์„น์…˜) + +src/hooks/ +โ””โ”€โ”€ useModules.ts # React ํ›…: isEnabled(), isRouteAllowed(), tenantIndustry +``` + +### ๋ชจ๋“ˆ ID ๋ชฉ๋ก +| ModuleId | ์ด๋ฆ„ | ์†Œ์œ  ๋ผ์šฐํŠธ | ๋Œ€์‹œ๋ณด๋“œ ์„น์…˜ | +|----------|------|------------|--------------| +| `common` | ๊ณตํ†ต ERP | /dashboard, /accounting, /sales, /hr, /approval, /settings ๋“ฑ | ์ „๋ถ€ | +| `production` | ์ƒ์‚ฐ๊ด€๋ฆฌ | /production | dailyProduction, unshipped | +| `quality` | ํ’ˆ์งˆ๊ด€๋ฆฌ | /quality | - | +| `construction` | ์‹œ๊ณต๊ด€๋ฆฌ | /construction | construction | +| `vehicle-management` | ์ฐจ๋Ÿ‰๊ด€๋ฆฌ | /vehicle-management, /vehicle | - | + +### ํ”„๋ก ํŠธ์—”๋“œ ๋™์ž‘ ํ๋ฆ„ +``` +1. ๋กœ๊ทธ์ธ โ†’ authStore์— tenant ์ •๋ณด ์ €์žฅ +2. useModules() ํ›…์ด tenant.options.industry ์ฝ์Œ +3. industry ๊ฐ’์œผ๋กœ INDUSTRY_MODULE_MAP ์กฐํšŒ โ†’ ํ™œ์„ฑ ๋ชจ๋“ˆ ๋ชฉ๋ก ๊ฒฐ์ • +4. ๊ฐ ์ปดํฌ๋„ŒํŠธ์—์„œ isEnabled('production') ๋“ฑ์œผ๋กœ ๋ถ„๊ธฐ +``` + +### ์ ์šฉ๋œ ์˜์—ญ + +#### A. CEO ๋Œ€์‹œ๋ณด๋“œ +- **์„น์…˜ ํ•„ํ„ฐ๋ง**: ๋น„ํ™œ์„ฑ ๋ชจ๋“ˆ์˜ ๋Œ€์‹œ๋ณด๋“œ ์„น์…˜ ์ž๋™ ์ œ์™ธ +- **API ํ˜ธ์ถœ ์ฐจ๋‹จ**: ๋น„ํ™œ์„ฑ ๋ชจ๋“ˆ์˜ API๋Š” ํ˜ธ์ถœํ•˜์ง€ ์•Š์Œ (null endpoint) +- **์„ค์ • ํŒ์—…**: ๋น„ํ™œ์„ฑ ๋ชจ๋“ˆ ์„น์…˜์€ ์„ค์ •์—์„œ๋„ ์•ˆ ๋ณด์ž„ +- **์บ˜๋ฆฐ๋”**: ๋น„ํ™œ์„ฑ ๋ชจ๋“ˆ์˜ ์ผ์ • ์œ ํ˜• ํ•„ํ„ฐ ์ˆจ๊น€ +- **์š”์•ฝ ๋„ค๋น„**: ๋น„ํ™œ์„ฑ ์„น์…˜ ์ž๋™ ์ œ์™ธ + +#### B. ๋ผ์šฐํŠธ ์ ‘๊ทผ ์ œ์–ด +- `/production/*`, `/quality/*`, `/construction/*` ๋“ฑ ์ „์šฉ ๋ผ์šฐํŠธ๋Š” ๋ชจ๋“ˆ ๋น„ํ™œ์„ฑ ์‹œ ์ ‘๊ทผ ์ฐจ๋‹จ +- `/sales/*/production-orders` ๊ฐ™์€ ๊ณตํ†ต ๋ผ์šฐํŠธ ๋‚ด ๋ชจ๋“ˆ ์˜์กด ํŽ˜์ด์ง€๋Š” ๋ช…์‹œ์  ๊ฐ€๋“œ ์ ์šฉ + +#### C. ์‚ฌ์ด๋“œ๋ฐ” ๋ฉ”๋‰ด +- ๋น„ํ™œ์„ฑ ๋ชจ๋“ˆ์˜ ๋ฉ”๋‰ด ํ•ญ๋ชฉ ์ˆจ๊น€ (isRouteAllowed ๊ธฐ๋ฐ˜) + +--- + +## 3. ๋ฐฑ์—”๋“œ ํ•„์š” ์ž‘์—… + +### 3.1 tenants ํ…Œ์ด๋ธ” options ํ•„๋“œ์— industry ์ถ”๊ฐ€ +**์šฐ์„ ์ˆœ์œ„: ๐Ÿ”ด ํ•„์ˆ˜** + +ํ˜„์žฌ ํ”„๋ก ํŠธ์—”๋“œ๋Š” `tenant.options.industry` ๊ฐ’์„ ์ฝ์–ด์„œ ๋ชจ๋“ˆ์„ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค. +์ด ๊ฐ’์ด ๋ฐฑ์—”๋“œ์—์„œ ๋‚ด๋ ค์™€์•ผ ์‹ค์ œ๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. + +```php +// tenants ํ…Œ์ด๋ธ”์˜ options JSON ์ปฌ๋Ÿผ์— industry ์ถ”๊ฐ€ +// ์˜ˆ์‹œ ๋ฐ์ดํ„ฐ: +{ + "industry": "shutter_mes" // ๊ฒฝ๋™: ์…”ํ„ฐ MES +} +{ + "industry": "construction" // ์ฃผ์ผ: ๊ฑด์„ค +} +// ๋‹ค๋ฅธ ํ…Œ๋„ŒํŠธ: industry ํ‚ค ์—†์Œ โ†’ ํ”„๋ก ํŠธ์—์„œ ์ „์ฒด ๋ชจ๋“ˆ ํ™œ์„ฑํ™” +``` + +**์ž‘์—… ๋‚ด์šฉ:** +1. `tenants` ํ…Œ์ด๋ธ”์˜ `options` JSON ์ปฌ๋Ÿผ์— `industry` ํ‚ค ์ถ”๊ฐ€ (๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ถˆํ•„์š”, JSON์ด๋ฏ€๋กœ) +2. ๊ฒฝ๋™ ํ…Œ๋„ŒํŠธ: `options->industry = 'shutter_mes'` +3. ์ฃผ์ผ ํ…Œ๋„ŒํŠธ: `options->industry = 'construction'` +4. ํ…Œ๋„ŒํŠธ ์ •๋ณด API ์‘๋‹ต์— `options.industry` ํฌํ•จ ํ™•์ธ + +**ํ™•์ธ ํฌ์ธํŠธ:** +- ํ”„๋ก ํŠธ์—”๋“œ์—์„œ `authStore.currentUser.tenant.options.industry`๋กœ ์ ‘๊ทผ +- ํ˜„์žฌ ๋กœ๊ทธ์ธ API(`/api/v1/auth/me` ๋˜๋Š” ์œ ์‚ฌ)์˜ ์‘๋‹ต์—์„œ tenant.options๊ฐ€ ํฌํ•จ๋˜๋Š”์ง€ ํ™•์ธ +- ํฌํ•จ ์•ˆ ๋˜๋ฉด ์‘๋‹ต์— ์ถ”๊ฐ€ ํ•„์š” + +### 3.2 (์„ ํƒ) ํ…Œ๋„ŒํŠธ ๊ด€๋ฆฌ ํ™”๋ฉด์—์„œ industry ์„ค์ • UI +**์šฐ์„ ์ˆœ์œ„: ๐ŸŸก ์„ ํƒ** + +๊ด€๋ฆฌ์ž๊ฐ€ ํ…Œ๋„ŒํŠธ๋ณ„ ์—…์ข…์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋Š” UI. ๊ธ‰ํ•˜์ง€ ์•Š์Œ โ€” DB ์ง์ ‘ ์ˆ˜์ •์œผ๋กœ ์ถฉ๋ถ„. + +### 3.3 (Phase 2 ์˜ˆ์ •) ๋ช…์‹œ์  ๋ชจ๋“ˆ ๋ชฉ๋ก API +**์šฐ์„ ์ˆœ์œ„: ๐ŸŸข ํ–ฅํ›„** + +ํ˜„์žฌ๋Š” `industry` โ†’ ํ”„๋ก ํŠธ์—”๋“œ ํ•˜๋“œ์ฝ”๋”ฉ ๋งคํ•‘์œผ๋กœ ๋ชจ๋“ˆ ๊ฒฐ์ •. +ํ–ฅํ›„ ๋ฐฑ์—”๋“œ์—์„œ ์ง์ ‘ ๋ชจ๋“ˆ ๋ชฉ๋ก์„ ๋‚ด๋ ค์ฃผ๋ฉด ๋” ์œ ์—ฐํ•ด์ง. + +```php +// tenant.options ์˜ˆ์‹œ (Phase 2) +{ + "industry": "shutter_mes", + "modules": ["production", "quality", "vehicle-management"] // ๋ช…์‹œ์  ๋ชฉ๋ก +} +``` + +ํ”„๋ก ํŠธ์—”๋“œ๋Š” ์ด๋ฏธ ์ด ๊ตฌ์กฐ๋ฅผ ์ง€์›ํ•˜๋„๋ก ์ค€๋น„๋˜์–ด ์žˆ์Œ: +```typescript +// src/modules/tenant-config.ts +export function resolveEnabledModules(options) { + // Phase 2: ๋ฐฑ์—”๋“œ๊ฐ€ ๋ช…์‹œ์  ๋ชจ๋“ˆ ๋ชฉ๋ก ์ œ๊ณต โ†’ ์šฐ์„  ์‚ฌ์šฉ + if (explicitModules && explicitModules.length > 0) { + return explicitModules; + } + // Phase 1: industry ๊ธฐ๋ฐ˜ ๊ธฐ๋ณธ๊ฐ’ (ํ˜„์žฌ) + if (industry) { + return INDUSTRY_MODULE_MAP[industry] ?? []; + } + return []; +} +``` + +--- + +## 4. ์—…์ข…๋ณ„ ๋ชจ๋“ˆ ๋งคํ•‘ (ํ”„๋ก ํŠธ์—”๋“œ ํ•˜๋“œ์ฝ”๋”ฉ) + +```typescript +// src/modules/tenant-config.ts +const INDUSTRY_MODULE_MAP: Record = { + shutter_mes: ['production', 'quality', 'vehicle-management'], + construction: ['construction', 'vehicle-management'], +}; +``` + +์ƒˆ๋กœ์šด ์—…์ข… ์ถ”๊ฐ€ ์‹œ: +1. ์—ฌ๊ธฐ์— ๋งคํ•‘ ์ถ”๊ฐ€ +2. ํ•„์š”ํ•˜๋ฉด `ModuleId` ํƒ€์ž…์— ์ƒˆ ๋ชจ๋“ˆ ID ์ถ”๊ฐ€ +3. `MODULE_REGISTRY` (src/modules/index.ts)์— ๋ผ์šฐํŠธ/๋Œ€์‹œ๋ณด๋“œ ์„น์…˜ ๋“ฑ๋ก + +--- + +## 5. ํ•ต์‹ฌ ์ฝ”๋“œ ํŒจํ„ด + +### ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ• +```typescript +import { useModules } from '@/hooks/useModules'; + +function MyComponent() { + const { isEnabled, tenantIndustry } = useModules(); + + // ์•ˆ์ „ ์žฅ์น˜: industry ๋ฏธ์„ค์ •์ด๋ฉด ๋ชจ๋“  ๊ธฐ๋Šฅ ํ™œ์„ฑ + const moduleAware = !!tenantIndustry; + + if (moduleAware && !isEnabled('production')) { + return
์ƒ์‚ฐ๊ด€๋ฆฌ ๋ชจ๋“ˆ์ด ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.
; + } + + // ์ƒ์‚ฐ๊ด€๋ฆฌ ๊ธฐ๋Šฅ ๋ Œ๋”๋ง... +} +``` + +### ํฌ๋กœ์Šค ๋ชจ๋“ˆ ์ž„ํฌํŠธ ๊ทœ์น™ +``` +โœ… Common โ†’ Common (์ž์œ ) +โœ… Tenant โ†’ Common (์ž์œ ) +โœ… Common โ†’ Tenant (๋ž˜ํผ ๊ฒฝ์œ ) (src/lib/api/์—์„œ MODULE_SEPARATION_OK ์ฃผ์„๊ณผ ํ•จ๊ป˜) +โŒ Common โ†’ Tenant (์ง์ ‘) (scripts/verify-module-separation.sh๊ฐ€ ๊ฒ€์ถœ) +โŒ Tenant โ†’ Tenant (๊ธˆ์ง€, dynamic import๋งŒ ํ—ˆ์šฉ) +``` + +--- + +## 6. ๊ตฌํ˜„ ์ด๋ ฅ + +| Phase | ๋‚ด์šฉ | ์ปค๋ฐ‹ | ์ƒํƒœ | +|-------|------|------|------| +| Phase 0 | ํฌ๋กœ์Šค ๋ชจ๋“ˆ ์˜์กด์„ฑ ํ•ด์†Œ | `a99c3b39` | โœ… ์™„๋ฃŒ | +| Phase 1 | ๋ชจ๋“ˆ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ + ๋ผ์šฐํŠธ ๊ฐ€๋“œ | `0a65609e` | โœ… ์™„๋ฃŒ | +| Phase 2 | CEO ๋Œ€์‹œ๋ณด๋“œ ๋ชจ๋“ˆ ๋””์ปคํ”Œ๋ง | `46501214` | โœ… ์™„๋ฃŒ | +| Phase 3 | ๋ฌผ๋ฆฌ์  ๋ถ„๋ฆฌ (๊ฒฝ๊ณ„ ๋งˆ์ปค, ๊ฒ€์ฆ, ๊ฐ€๋“œ, ๋ฌธ์„œ) | `4b8ca09e` | โœ… ์™„๋ฃŒ | + +--- + +## 7. ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค + +### ํ…Œ์ŠคํŠธ ๋ฐฉ๋ฒ• +๋ฐฑ์—”๋“œ์—์„œ `tenant.options.industry`๋ฅผ ์„ค์ •ํ•œ ํ›„: + +| ์‹œ๋‚˜๋ฆฌ์˜ค | ์˜ˆ์ƒ ๊ฒฐ๊ณผ | +|----------|----------| +| industry ๋ฏธ์„ค์ • ํ…Œ๋„ŒํŠธ ๋กœ๊ทธ์ธ | ๊ธฐ์กด๊ณผ ์™„์ „ ๋™์ผ (๋ชจ๋“  ๋ฉ”๋‰ด/๊ธฐ๋Šฅ ํ‘œ์‹œ) | +| `shutter_mes` ํ…Œ๋„ŒํŠธ ๋กœ๊ทธ์ธ | ์‹œ๊ณต๊ด€๋ฆฌ ๋ฉ”๋‰ด ์ˆจ๊น€, ๋Œ€์‹œ๋ณด๋“œ ์‹œ๊ณต ์„น์…˜ ์•ˆ ๋ณด์ž„ | +| `construction` ํ…Œ๋„ŒํŠธ ๋กœ๊ทธ์ธ | ์ƒ์‚ฐ/ํ’ˆ์งˆ ๋ฉ”๋‰ด ์ˆจ๊น€, ๋Œ€์‹œ๋ณด๋“œ ์ƒ์‚ฐ ์„น์…˜ ์•ˆ ๋ณด์ž„ | +| `shutter_mes`์—์„œ `/construction` ์ง์ ‘ ์ ‘๊ทผ | ์ ‘๊ทผ ์ฐจ๋‹จ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ | +| `construction`์—์„œ `/production` ์ง์ ‘ ์ ‘๊ทผ | ์ ‘๊ทผ ์ฐจ๋‹จ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ | + +### ๋กค๋ฐฑ ๋ฐฉ๋ฒ• +๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ DB์—์„œ `tenant.options.industry` ๊ฐ’๋งŒ ์ œ๊ฑฐํ•˜๋ฉด ์ฆ‰์‹œ ์›๋ณต. +ํ”„๋ก ํŠธ์—”๋“œ ์ฝ”๋“œ ๋ณ€๊ฒฝ ๋ถˆํ•„์š”. + +--- + +## 8. ํ–ฅํ›„ ๋กœ๋“œ๋งต + +``` +ํ˜„์žฌ (Phase 1) ํ–ฅํ›„ (Phase 2) ์ตœ์ข… (Phase 3) +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ industry ํ•˜๋“œ์ฝ”๋”ฉ โ”‚ โ†’ โ”‚ ๋ฐฑ์—”๋“œ modules ๋ชฉ๋ก โ”‚ โ†’ โ”‚ JSON ์Šคํ‚ค๋งˆ ๊ธฐ๋ฐ˜ โ”‚ +โ”‚ ๋งคํ•‘์œผ๋กœ ๋ชจ๋“ˆ ๊ฒฐ์ • โ”‚ โ”‚ API์—์„œ ์ง์ ‘ ์ˆ˜์‹  โ”‚ โ”‚ ๋™์  ํŽ˜์ด์ง€ ์กฐ๋ฆฝ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +- **Phase 2**: `tenant.options.modules = ["production", "quality"]` ํ˜•ํƒœ๋กœ ๋ฐฑ์—”๋“œ์—์„œ ๋ช…์‹œ์  ๋ชจ๋“ˆ ๋ชฉ๋ก ์ „๋‹ฌ โ†’ ์—…์ข… ๋งคํ•‘ ํ…Œ์ด๋ธ” ๋ถˆํ•„์š” +- **Phase 3**: ๊ฐ ๋ชจ๋“ˆ์˜ ํŽ˜์ด์ง€ ๊ตฌ์„ฑ์„ JSON ์Šคํ‚ค๋งˆ๋กœ ์ •์˜ โ†’ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์—†์ด ํ…Œ๋„ŒํŠธ๋ณ„ ํ™”๋ฉด ์ปค์Šคํ„ฐ๋งˆ์ด์ง• diff --git a/public/sounds/default.wav b/public/sounds/default.wav index e69de29b..59238b1b 100644 Binary files a/public/sounds/default.wav and b/public/sounds/default.wav differ diff --git a/public/sounds/sam_voice.wav b/public/sounds/sam_voice.wav new file mode 100644 index 00000000..59238b1b Binary files /dev/null and b/public/sounds/sam_voice.wav differ diff --git a/scripts/verify-module-separation.sh b/scripts/verify-module-separation.sh new file mode 100755 index 00000000..df268a26 --- /dev/null +++ b/scripts/verify-module-separation.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# =================================================================== +# Module Separation Verification Script +# +# ๊ณตํ†ต ERP โ†’ ํ…Œ๋„ŒํŠธ ๋ชจ๋“ˆ ๊ฐ„ ๊ธˆ์ง€๋œ ์ •์  import๋ฅผ ๊ฒ€์‚ฌํ•ฉ๋‹ˆ๋‹ค. +# Phase 0์—์„œ ํ•ด์†Œํ•œ ์˜์กด์„ฑ์ด ๋‹ค์‹œ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋„๋ก CI์—์„œ ์‹คํ–‰ ๊ฐ€๋Šฅ. +# +# ์‚ฌ์šฉ๋ฒ•: bash scripts/verify-module-separation.sh +# ์ข…๋ฃŒ์ฝ”๋“œ: 0 = ํ†ต๊ณผ, 1 = ์œ„๋ฐ˜ ๋ฐœ๊ฒฌ +# =================================================================== + +set -euo pipefail + +echo "=================================================" +echo " Module Separation Verification" +echo "=================================================" +echo "" + +# ํ…Œ๋„ŒํŠธ ์ „์šฉ ๊ฒฝ๋กœ ํŒจํ„ด (from 'xxx' ๋˜๋Š” from "xxx" ํ˜•ํƒœ๋กœ ๊ฒ€์ƒ‰) +TENANT_PATHS=( + "@/components/production/" + "@/components/quality/" + "@/components/business/construction/" + "@/components/vehicle-management/" +) + +# ๊ณตํ†ต ERP ์†Œ์Šค ๋””๋ ‰ํ† ๋ฆฌ (ํ…Œ๋„ŒํŠธ ํŽ˜์ด์ง€ ์ œ์™ธ) +COMMON_DIRS=( + "src/components/approval" + "src/components/accounting" + "src/components/auth" + "src/components/atoms" + "src/components/board" + "src/components/business/CEODashboard" + "src/components/business/DashboardSwitcher.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 + # ๋””๋ ‰ํ† ๋ฆฌ/ํŒŒ์ผ์ด ์—†์œผ๋ฉด ์Šคํ‚ต + [ -e "$dir" ] || continue + + for tenant_path in "${TENANT_PATHS[@]}"; do + # ์ •์  import ๊ฒ€์ƒ‰ (dynamic import๋Š” ํ—ˆ์šฉ) + found=$(grep -rn "from ['\"]${tenant_path}" "$dir" \ + --include="*.ts" --include="*.tsx" 2>/dev/null \ + | grep -v "dynamic(" \ + | grep -v "// MODULE_SEPARATION_OK" \ + || true) + + if [ -n "$found" ]; then + echo "VIOLATION: $dir โ†’ $tenant_path" + echo "$found" + echo "" + VIOLATIONS=$((VIOLATIONS + 1)) + fi + done +done + +echo "=================================================" +if [ $VIOLATIONS -eq 0 ]; then + echo " PASSED: No forbidden imports found." + exit 0 +else + echo " FAILED: Found $VIOLATIONS forbidden import(s)." + echo "" + echo " ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•:" + echo " - dynamic import (next/dynamic)๋กœ ๊ต์ฒด" + echo " - @/lib/api/ ๋˜๋Š” @/interfaces/๋กœ ํƒ€์ž… ์ด๋™" + echo " - @/components/document-system/์œผ๋กœ ๊ณต์œ  ๋ชจ๋‹ฌ ์ด๋™" + echo " - ๋ถˆ๊ฐ€ํ”ผํ•œ ๊ฒฝ์šฐ // MODULE_SEPARATION_OK ์ฃผ์„ ์ถ”๊ฐ€" + exit 1 +fi diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx index ed11b71a..56beea9b 100644 --- a/src/app/[locale]/(protected)/layout.tsx +++ b/src/app/[locale]/(protected)/layout.tsx @@ -7,6 +7,7 @@ import { ApiErrorProvider } from '@/contexts/ApiErrorContext'; import { FCMProvider } from '@/contexts/FCMProvider'; import { DevFillProvider, DevToolbar } from '@/components/dev'; import { PermissionGate } from '@/contexts/PermissionContext'; +import { ModuleGuard } from '@/components/auth/ModuleGuard'; /** * Protected Layout @@ -44,7 +45,9 @@ export default function ProtectedLayout({ - {children} + + {children} + 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/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index dece05ae..af0c5ac8 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -8,8 +8,7 @@ import { ReportList } from './components/ReportList'; import { RouteList } from './components/RouteList'; import { DocumentList } from './components/DocumentList'; import { InspectionModal } from './components/InspectionModal'; -import { InspectionReportModal } from '@/components/production/WorkOrders/documents'; -import { WorkLogModal } from '@/components/production/WorkOrders/documents'; +import { InspectionReportModal, WorkLogModal } from '@/components/document-system/modals'; import { ProductInspectionViewModal } from '@/components/quality/InspectionManagement/ProductInspectionViewModal'; import { getDocumentDetail } from './actions'; import { DayTabs } from './components/DayTabs'; diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx index c32ba1bf..bf39bcda 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx @@ -16,6 +16,7 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter, useParams } from "next/navigation"; +import { useModules } from "@/hooks/useModules"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -29,7 +30,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Factory, ArrowLeft, BarChart3, CheckCircle2, AlertCircle, User } from "lucide-react"; -import { AssigneeSelectModal } from "@/components/production/WorkOrders/AssigneeSelectModal"; +import { AssigneeSelectModal } from "@/components/document-system/modals"; import { PageLayout } from "@/components/organisms/PageLayout"; import { AlertDialog, @@ -345,6 +346,24 @@ export default function ProductionOrderCreatePage() { const router = useRouter(); const params = useParams(); const orderId = params.id as string; + const { isEnabled, tenantIndustry } = useModules(); + + // ์ƒ์‚ฐ ๋ชจ๋“ˆ ๋น„ํ™œ์„ฑ ์‹œ ์ ‘๊ทผ ์ฐจ๋‹จ (tenantIndustry ๋ฏธ์„ค์ • ์‹œ ์ „๋ถ€ ํ—ˆ์šฉ) + if (tenantIndustry && !isEnabled('production')) { + return ( + +
+

์ƒ์‚ฐ๊ด€๋ฆฌ ๋ชจ๋“ˆ์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+ +
+
+ ); + } const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx index 2a38594f..f3572858 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx @@ -4,7 +4,7 @@ * ์ˆ˜์ฃผ๊ด€๋ฆฌ - IntegratedListTemplateV2 ์ ์šฉ * * ์ˆ˜์ฃผ ๊ด€๋ฆฌ ํŽ˜์ด์ง€ - * - ์ƒ๋‹จ ํ†ต๊ณ„ ์นด๋“œ: ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ฃผ, ๋ถ„ํ•  ๋Œ€๊ธฐ, ์ƒ์‚ฐ์ง€์‹œ ๋Œ€๊ธฐ, ์ถœํ•˜ ๋Œ€๊ธฐ + * - ์ƒ๋‹จ ํ†ต๊ณ„ ์นด๋“œ: N์›” ์ˆ˜์ฃผ(๊ธฐ๊ฐ„ ์ˆ˜์ฃผ), ์ˆ˜์ฃผ, ์ƒ์‚ฐ, ์ถœํ•˜ * - ์ƒํƒœ ํ•„ํ„ฐ: ์…€๋ ‰ํŠธ๋ฐ•์Šค (์ „์ฒด, ์ˆ˜์ฃผ๋“ฑ๋ก, N์ž์ˆ˜์ •, ์ˆ˜์ฃผํ™•์ •, ์ƒ์‚ฐ์ง€์‹œ์™„๋ฃŒ) * - ๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ: ๋‹ฌ๋ ฅ * - ์™„์ „ํ•œ ๋ฐ˜์‘ํ˜• ์ง€์› @@ -41,7 +41,7 @@ import { TableCell, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; -import { formatAmount, formatAmountManwon } from "@/lib/utils/amount"; +import { formatAmount } from "@/lib/utils/amount"; import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard"; import { ConfirmDialog, DeleteConfirmDialog } from "@/components/ui/confirm-dialog"; import { @@ -131,9 +131,16 @@ function OrderListContent() { const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; - // ๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ ์ƒํƒœ - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); + // ๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ ์ƒํƒœ (์ด๋ฒˆ๋‹ฌ ์ดˆ๊ธฐ๊ฐ’) + const [startDate, setStartDate] = useState(() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; + }); + const [endDate, setEndDate] = useState(() => { + const d = new Date(); + const last = new Date(d.getFullYear(), d.getMonth() + 1, 0); + return `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(last.getDate()).padStart(2, '0')}`; + }); // ํ•„ํ„ฐ ์ƒํƒœ const [filterValues, setFilterValues] = useState>({ @@ -181,7 +188,7 @@ function OrderListContent() { try { setIsLoading(true); const [ordersResult, statsResult] = await Promise.all([ - getOrders(), + getOrders({ order_type: 'ORDER' }), getOrderStats(), ]); @@ -276,53 +283,57 @@ function OrderListContent() { setMobileDisplayCount(20); }, [searchTerm, filterValues]); - // ํ†ต๊ณ„ ๊ณ„์‚ฐ (API stats ์šฐ์„  ์‚ฌ์šฉ, ์—†์œผ๋ฉด ๋กœ์ปฌ ๊ณ„์‚ฐ) - const now = new Date(); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + // ํ†ต๊ณ„ ๊ณ„์‚ฐ โ€” ๋‚ ์งœ ๋ฒ”์œ„ ๋‚ด ๋ฐ์ดํ„ฐ ๊ธฐ์ค€ + const periodLabel = useMemo(() => { + if (!startDate || !endDate) return '์ „์ฒด ์ˆ˜์ฃผ'; + const sm = startDate.slice(0, 7); // YYYY-MM + const em = endDate.slice(0, 7); + if (sm === em) { + return `${parseInt(startDate.slice(5, 7))}์›” ์ˆ˜์ฃผ`; + } + return '๊ธฐ๊ฐ„ ์ˆ˜์ฃผ'; + }, [startDate, endDate]); - // ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ฃผ ๊ธˆ์•ก (์ˆ˜์ฃผํ™•์ • ์ดํ›„ ๊ฑด๋งŒ - ์ทจ์†Œ, ์ˆ˜์ฃผ๋“ฑ๋ก ์ œ์™ธ) - const thisMonthOrders = orders.filter( - (o) => - new Date(o.orderDate) >= startOfMonth && - o.status !== "cancelled" && - o.status !== "order_registered" - ); - const thisMonthAmount = apiStats?.thisMonthAmount ?? thisMonthOrders.reduce((sum, o) => sum + (Number(o.amount) || 0), 0); + // ๋‚ ์งœ ๋ฒ”์œ„ ๋‚ด ์ˆ˜์ฃผ ๊ฑด์ˆ˜ (์ทจ์†Œ ์ œ์™ธ) + const periodOrderCount = orders.filter((o) => o.status !== "cancelled").length; - - // ๋ถ„ํ•  ๋Œ€๊ธฐ (์˜ˆ์‹œ: ์ˆ˜์ฃผํ™•์ • ์ƒํƒœ) - const splitPendingCount = apiStats?.splitPending ?? orders.filter((o) => o.status === "order_confirmed").length; - - // ์ƒ์‚ฐ์ง€์‹œ ๋Œ€๊ธฐ (์ˆ˜์ฃผํ™•์ • ์ƒํƒœ ์ค‘ ์ƒ์‚ฐ์ง€์‹œ ์•ˆ๋œ ๊ฒƒ) - const productionPendingCount = apiStats?.productionPending ?? orders.filter( - (o) => o.status === "order_confirmed" || o.status === "order_registered" + // ์ˆ˜์ฃผ: ์ƒ์‚ฐ์— ๋„˜์–ด๊ฐ€์ง€ ์•Š์€ ๊ฑด (DRAFT + CONFIRMED) + const orderCount = orders.filter( + (o) => o.status === "order_registered" || o.status === "order_confirmed" ).length; - // ์ถœํ•˜ ๋Œ€๊ธฐ (์ž‘์—…์™„๋ฃŒ ์ƒํƒœ) - const shipPendingCount = apiStats?.shipPending ?? orders.filter((o) => o.status === "work_completed").length; + // ์ƒ์‚ฐ: ์ƒ์‚ฐ์ง€์‹œ๋Œ€๊ธฐ + ์ƒ์‚ฐ์ค‘ (IN_PROGRESS + IN_PRODUCTION) + const productionCount = orders.filter( + (o) => o.status === "production_ordered" || o.status === "in_production" + ).length; + + // ์ถœํ•˜: ์ถœํ•˜๋Œ€๊ธฐ ~ ์ถœ๊ณ ์ค‘ (PRODUCED + SHIPPING) + const shipCount = orders.filter( + (o) => o.status === "produced" || o.status === "shipping" + ).length; const stats = [ { - label: "์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ฃผ", - value: formatAmountManwon(thisMonthAmount), + label: periodLabel, + value: `${periodOrderCount}๊ฑด`, icon: DollarSign, iconColor: "text-blue-600", }, { - label: "๋ถ„ํ•  ๋Œ€๊ธฐ", - value: `${splitPendingCount}๊ฑด`, - icon: SplitSquareVertical, + label: "์ˆ˜์ฃผ", + value: `${orderCount}๊ฑด`, + icon: ClipboardList, iconColor: "text-orange-600", }, { - label: "์ƒ์‚ฐ์ง€์‹œ ๋Œ€๊ธฐ", - value: `${productionPendingCount}๊ฑด`, - icon: ClipboardList, + label: "์ƒ์‚ฐ", + value: `${productionCount}๊ฑด`, + icon: SplitSquareVertical, iconColor: "text-green-600", }, { - label: "์ถœํ•˜ ๋Œ€๊ธฐ", - value: `${shipPendingCount}๊ฑด`, + label: "์ถœํ•˜", + value: `${shipCount}๊ฑด`, icon: Truck, iconColor: "text-purple-600", }, diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx index c400eaf3..1b0ec878 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx @@ -11,6 +11,7 @@ import { useState, useEffect } from "react"; import { useRouter, useParams } from "next/navigation"; +import { useModules } from "@/hooks/useModules"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -48,13 +49,13 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { toast } from "sonner"; import { ServerErrorPage } from "@/components/common/ServerErrorPage"; import { formatNumber } from '@/lib/utils/amount'; -import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions"; +import { getProductionOrderDetail } from "@/lib/api/production-orders"; import { createProductionOrder } from "@/components/orders/actions"; import type { ProductionOrderDetail, ProductionStatus, ProductionWorkOrder, -} from "@/components/production/ProductionOrders/types"; +} from "@/lib/api/production-orders"; // ๊ณต์ • ์ง„ํ–‰ ํ˜„ํ™ฉ ์ปดํฌ๋„ŒํŠธ function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) { @@ -194,6 +195,24 @@ export default function ProductionOrderDetailPage() { const router = useRouter(); const params = useParams(); const orderId = params.id as string; + const { isEnabled, tenantIndustry } = useModules(); + + // ์ƒ์‚ฐ ๋ชจ๋“ˆ ๋น„ํ™œ์„ฑ ์‹œ ์ ‘๊ทผ ์ฐจ๋‹จ (tenantIndustry ๋ฏธ์„ค์ • ์‹œ ์ „๋ถ€ ํ—ˆ์šฉ) + if (tenantIndustry && !isEnabled('production')) { + return ( + +
+

์ƒ์‚ฐ๊ด€๋ฆฌ ๋ชจ๋“ˆ์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+ +
+
+ ); + } const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); @@ -243,8 +262,8 @@ export default function ProductionOrderDetailPage() { const handleSuccessDialogClose = () => { setIsSuccessDialogOpen(false); - // ์ž‘์—…์ง€์‹œ ๊ด€๋ฆฌ ํŽ˜์ด์ง€๋กœ ์ด๋™ - router.push("/production/work-orders"); + // ์ƒ์‚ฐ์ง€์‹œ ๋ชฉ๋ก์œผ๋กœ ์ด๋™ (๊ณตํ†ต ERP ์˜์—ญ ๋‚ด ๊ฒฝ๋กœ) + router.push("/sales/order-management-sales/production-orders"); }; if (loading) { 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..1ac16634 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,8 +10,9 @@ * - ์„œ๋ฒ„์‚ฌ์ด๋“œ ํŽ˜์ด์ง€๋„ค์ด์…˜ */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useRouter } from "next/navigation"; +import { useModules } from "@/hooks/useModules"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -37,12 +38,12 @@ import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard"; import { getProductionOrders, getProductionOrderStats, -} from "@/components/production/ProductionOrders/actions"; +} from "@/lib/api/production-orders"; import type { ProductionOrder, ProductionStatus, ProductionOrderStats, -} from "@/components/production/ProductionOrders/types"; +} from "@/lib/api/production-orders"; import { formatNumber } from '@/lib/utils/amount'; // ์ง„ํ–‰ ๋‹จ๊ณ„ ์ปดํฌ๋„ŒํŠธ @@ -174,6 +175,23 @@ const TABLE_COLUMNS: TableColumn[] = [ export default function ProductionOrdersListPage() { const router = useRouter(); + const { isEnabled, tenantIndustry } = useModules(); + + // ์ƒ์‚ฐ ๋ชจ๋“ˆ ๋น„ํ™œ์„ฑ ์‹œ ์ ‘๊ทผ ์ฐจ๋‹จ (tenantIndustry ๋ฏธ์„ค์ • ์‹œ ์ „๋ถ€ ํ—ˆ์šฉ) + if (tenantIndustry && !isEnabled('production')) { + return ( +
+

์ƒ์‚ฐ๊ด€๋ฆฌ ๋ชจ๋“ˆ์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+ +
+ ); + } + const [stats, setStats] = useState({ total: 0, waiting: 0, @@ -181,15 +199,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"); }; diff --git a/src/app/[locale]/(protected)/subscription/page.tsx b/src/app/[locale]/(protected)/subscription/page.tsx index 30d7733b..6fab712d 100644 --- a/src/app/[locale]/(protected)/subscription/page.tsx +++ b/src/app/[locale]/(protected)/subscription/page.tsx @@ -1,71 +1,16 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { CreditCard } from 'lucide-react'; -import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement'; -import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions'; -import { PageLayout } from '@/components/organisms/PageLayout'; -import { PageHeader } from '@/components/organisms/PageHeader'; -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; +/** + * /subscription โ†’ /usage ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + */ -export default function SubscriptionPage() { - const [data, setData] = useState>['data']>(); - const [isLoading, setIsLoading] = useState(true); +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +export default function SubscriptionRedirect() { + const router = useRouter(); useEffect(() => { - getSubscriptionData() - .then(result => { - setData(result.data); - }) - .finally(() => setIsLoading(false)); - }, []); - - if (isLoading) { - return ( - - - {/* ํ—ค๋” ์•ก์…˜ ๋ฒ„ํŠผ ์Šค์ผˆ๋ ˆํ†ค */} -
- - -
-
- {/* ๊ตฌ๋… ์ •๋ณด ์นด๋“œ ๊ทธ๋ฆฌ๋“œ ์Šค์ผˆ๋ ˆํ†ค */} -
- {[1, 2, 3].map((i) => ( - - - - - - - ))} -
- {/* ๊ตฌ๋… ์ •๋ณด ์นด๋“œ ์Šค์ผˆ๋ ˆํ†ค */} - - - - -
- {[1, 2, 3].map((i) => ( -
- - - -
- ))} -
-
-
-
-
- ); - } - - return ; -} \ No newline at end of file + router.replace('/usage'); + }, [router]); + return null; +} diff --git a/src/app/[locale]/(protected)/usage/page.tsx b/src/app/[locale]/(protected)/usage/page.tsx new file mode 100644 index 00000000..6884d1f9 --- /dev/null +++ b/src/app/[locale]/(protected)/usage/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { CreditCard } from 'lucide-react'; +import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement'; +import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions'; +import type { SubscriptionInfo } from '@/components/settings/SubscriptionManagement/types'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { PageHeader } from '@/components/organisms/PageHeader'; +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +function UsageSkeleton() { + return ( + + +
+
+ {[1, 2, 3].map((i) => ( + + + + + + + ))} +
+ + + +
+ {[1, 2].map((i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ + + + + + + +
+
+ ); +} + +export default function UsagePage() { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getSubscriptionData() + .then(result => setData(result.data)) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) return ; + return ; +} diff --git a/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx b/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx index bfb15412..ffded344 100644 --- a/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx +++ b/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx @@ -78,6 +78,7 @@ export function ManualJournalEntryModal({ const [journalDate, setJournalDate] = useState(() => getTodayString()); const [journalNumber, setJournalNumber] = useState('์ž๋™์ƒ์„ฑ'); const [description, setDescription] = useState(''); + const [receiptNo, setReceiptNo] = useState(''); // ๋ถ„๊ฐœ ํ–‰ const [rows, setRows] = useState([createEmptyRow()]); @@ -93,6 +94,7 @@ export function ManualJournalEntryModal({ setJournalDate(getTodayString()); setJournalNumber('์ž๋™์ƒ์„ฑ'); setDescription(''); + setReceiptNo(''); setRows([createEmptyRow()]); getVendorList().then((vendorsRes) => { @@ -172,6 +174,7 @@ export function ManualJournalEntryModal({ const result = await createManualJournal({ journalDate, description, + receiptNo: receiptNo || undefined, rows, }); if (result.success) { @@ -186,7 +189,7 @@ export function ManualJournalEntryModal({ } finally { setIsSubmitting(false); } - }, [journalDate, description, rows, totals, onOpenChange, onSuccess]); + }, [journalDate, description, receiptNo, rows, totals, onOpenChange, onSuccess]); return ( @@ -197,7 +200,7 @@ export function ManualJournalEntryModal({ {/* ๊ฑฐ๋ž˜ ์ •๋ณด */} -
+
{/* ๋ถ„๊ฐœ ๋‚ด์—ญ ํ—ค๋” */} diff --git a/src/components/accounting/GeneralJournalEntry/actions.ts b/src/components/accounting/GeneralJournalEntry/actions.ts index 5e486062..92ad2353 100644 --- a/src/components/accounting/GeneralJournalEntry/actions.ts +++ b/src/components/accounting/GeneralJournalEntry/actions.ts @@ -62,6 +62,7 @@ export async function getJournalSummary(params: { export async function createManualJournal(data: { journalDate: string; description: string; + receiptNo?: string; rows: JournalEntryRow[]; }): Promise { return executeServerAction({ @@ -70,6 +71,7 @@ export async function createManualJournal(data: { body: { journal_date: data.journalDate, description: data.description, + receipt_no: data.receiptNo || null, rows: data.rows.map((r) => ({ side: r.side, account_subject_id: r.accountSubjectId, diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx index 3556c4db..7efc9bdd 100644 --- a/src/components/approval/ApprovalBox/index.tsx +++ b/src/components/approval/ApprovalBox/index.tsx @@ -73,7 +73,7 @@ import { } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; -import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal'; +import { InspectionReportModal } from '@/components/document-system/modals'; export function ApprovalBox() { const router = useRouter(); diff --git a/src/components/auth/ModuleGuard.tsx b/src/components/auth/ModuleGuard.tsx new file mode 100644 index 00000000..079c59a9 --- /dev/null +++ b/src/components/auth/ModuleGuard.tsx @@ -0,0 +1,60 @@ +'use client'; + +/** + * ๋ชจ๋“ˆ ๋ผ์šฐํŠธ ๊ฐ€๋“œ + * + * ํ˜„์žฌ ํ…Œ๋„ŒํŠธ๊ฐ€ ๋ณด์œ ํ•˜์ง€ ์•Š์€ ๋ชจ๋“ˆ์˜ ํŽ˜์ด์ง€์— ์ ‘๊ทผ ์‹œ ์ฐจ๋‹จ. + * (protected)/layout.tsx์—์„œ PermissionGate ๋‚ด๋ถ€์— ๋ž˜ํ•‘. + * + * Phase 1: ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ ๊ฐ€๋“œ (๋ฐฑ์—”๋“œ ๋ณ€๊ฒฝ ๋ถˆํ•„์š”) + * Phase 2: middleware.ts๋กœ ์ด๋™ (์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๊ฐ€๋“œ) + */ + +import { usePathname, useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { toast } from 'sonner'; +import { useModules } from '@/hooks/useModules'; +import { ShieldAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export function ModuleGuard({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const { isRouteAllowed, tenantIndustry } = useModules(); + + // locale ์ ‘๋‘์‚ฌ ์ œ๊ฑฐ (์˜ˆ: /ko/production โ†’ /production) + const cleanPath = pathname.replace(/^\/[a-z]{2}(?=\/)/, ''); + + const allowed = isRouteAllowed(cleanPath); + + useEffect(() => { + // industry๊ฐ€ ์•„์ง ์„ค์ •๋˜์ง€ ์•Š์€ ํ…Œ๋„ŒํŠธ๋Š” ๊ฐ€๋“œ ๋น„ํ™œ์„ฑ (์ „๋ถ€ ํ—ˆ์šฉ) + if (!tenantIndustry) return; + + if (!allowed) { + toast.error('์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†๋Š” ๋ชจ๋“ˆ์ž…๋‹ˆ๋‹ค.'); + } + }, [allowed, tenantIndustry]); + + // industry ๋ฏธ์„ค์ • ์‹œ ๊ฐ€๋“œ ๋น„ํ™œ์„ฑ (ํ•˜์œ„ ํ˜ธํ™˜) + if (!tenantIndustry) { + return <>{children}; + } + + if (!allowed) { + return ( +
+ +

์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ

+

+ ํ˜„์žฌ ๊ณ„์•ฝ์— ํฌํ•จ๋˜์ง€ ์•Š์€ ๋ชจ๋“ˆ์ž…๋‹ˆ๋‹ค. +

+ +
+ ); + } + + return <>{children}; +} diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 05940b6e..f2ec3373 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -41,17 +41,23 @@ import { getCardManagementModalConfigWithData } from './modalConfigs'; import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers'; import { toast } from 'sonner'; import { consumeStaleSections, DASHBOARD_INVALIDATE_EVENT, type DashboardSectionKey } from '@/lib/dashboard-invalidation'; +import { useModules } from '@/hooks/useModules'; +import { sectionRequiresModule } from './types'; export function CEODashboard() { const router = useRouter(); - // API ๋ฐ์ดํ„ฐ Hook + // ๋ชจ๋“ˆ ํ™œ์„ฑํ™” ์ •๋ณด (tenantIndustry ๋ฏธ์„ค์ • ์‹œ ๋ชจ๋“  ๋ชจ๋“ˆ ํ‘œ์‹œ) + const { isEnabled, tenantIndustry } = useModules(); + const moduleAware = !!tenantIndustry; // industry ์„ค์ • ์‹œ์—๋งŒ ๋ชจ๋“ˆ ํ•„ํ„ฐ๋ง ์ ์šฉ + + // API ๋ฐ์ดํ„ฐ Hook (๋ชจ๋“ˆ ๋น„ํ™œ์„ฑ ์‹œ API ํ˜ธ์ถœ ์Šคํ‚ต) const apiData = useCEODashboard({ salesStatus: true, purchaseStatus: true, - dailyProduction: true, - unshipped: true, - construction: true, + dailyProduction: !moduleAware || isEnabled('production'), + unshipped: true, // ๊ณตํ†ต (outbound/logistics) + construction: !moduleAware || isEnabled('construction'), dailyAttendance: true, }); @@ -548,8 +554,16 @@ export function CEODashboard() { } }, [calendarData]); - // ์„น์…˜ ์ˆœ์„œ - const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; + // ์„น์…˜ ์ˆœ์„œ (๋ชจ๋“ˆ ๋น„ํ™œ์„ฑ ์„น์…˜ ํ•„ํ„ฐ๋ง) + const sectionOrder = useMemo(() => { + const rawOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; + if (!moduleAware) return rawOrder; // industry ๋ฏธ์„ค์ • ์‹œ ์ „๋ถ€ ํ‘œ์‹œ + return rawOrder.filter((key) => { + const requiredModule = sectionRequiresModule(key); + if (!requiredModule) return true; // ๊ณตํ†ต ์„น์…˜ + return isEnabled(requiredModule); + }); + }, [dashboardSettings.sectionOrder, moduleAware, isEnabled]); // ์š”์•ฝ ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ” ํ›… const { summaries, activeSectionKey, sectionRefs, scrollToSection } = useSectionSummary({ diff --git a/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx b/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx index cccd950f..34614f8a 100644 --- a/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx +++ b/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -19,7 +19,8 @@ import type { WelfareCalculationType, SectionKey, } from '../types'; -import { DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types'; +import { DEFAULT_SECTION_ORDER, SECTION_LABELS, sectionRequiresModule } from '../types'; +import { useModules } from '@/hooks/useModules'; import { SectionRow, StatusBoardItemsList, @@ -40,6 +41,10 @@ export function DashboardSettingsDialog({ settings, onSave, }: DashboardSettingsDialogProps) { + // ๋ชจ๋“ˆ ํ™œ์„ฑํ™” ์ •๋ณด (industry ๋ฏธ์„ค์ • ์‹œ ์ „๋ถ€ ํ‘œ์‹œ) + const { isEnabled, tenantIndustry } = useModules(); + const moduleAware = !!tenantIndustry; + const [localSettings, setLocalSettings] = useState(settings); const [expandedSections, setExpandedSections] = useState>({ todayIssueList: false, @@ -53,8 +58,16 @@ export function DashboardSettingsDialog({ const [draggedSection, setDraggedSection] = useState(null); const [dragOverSection, setDragOverSection] = useState(null); - // ์„น์…˜ ์ˆœ์„œ - const sectionOrder = localSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; + // ์„น์…˜ ์ˆœ์„œ (๋ชจ๋“ˆ ๋น„ํ™œ์„ฑ ์„น์…˜ ์ˆจ๊น€) + const sectionOrder = useMemo(() => { + const rawOrder = localSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; + if (!moduleAware) return rawOrder; + return rawOrder.filter((key: SectionKey) => { + const requiredModule = sectionRequiresModule(key); + if (!requiredModule) return true; + return isEnabled(requiredModule); + }); + }, [localSettings.sectionOrder, moduleAware, isEnabled]); // settings๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ useEffect(() => { diff --git a/src/components/business/CEODashboard/sections/CalendarSection.tsx b/src/components/business/CEODashboard/sections/CalendarSection.tsx index ba010adb..8b6f003e 100644 --- a/src/components/business/CEODashboard/sections/CalendarSection.tsx +++ b/src/components/business/CEODashboard/sections/CalendarSection.tsx @@ -16,6 +16,7 @@ import { ScheduleCalendar } from '@/components/common/ScheduleCalendar'; import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types'; import { getCalendarEventsForYear, type CalendarEvent } from '@/constants/calendarEvents'; import { useCalendarScheduleStore } from '@/stores/useCalendarScheduleStore'; +import { useModules } from '@/hooks/useModules'; import { CollapsibleDashboardCard } from '../components'; import type { CalendarScheduleItem, @@ -117,6 +118,12 @@ const TASK_FILTER_OPTIONS: { value: ExtendedTaskFilterType; label: string }[] = { value: 'issue', label: '์ด์Šˆ' }, ]; +// ์ผ์ • ํƒ€์ž… โ†’ ๋ชจ๋“ˆ ๋งคํ•‘ (์ด ํƒ€์ž…์˜ ๋งํฌ/ํ•„ํ„ฐ๊ฐ€ ํ•ด๋‹น ๋ชจ๋“ˆ์„ ์š”๊ตฌ) +const SCHEDULE_TYPE_MODULE: Record = { + order: 'production', + construction: 'construction', +}; + export function CalendarSection({ schedules, issues = [], @@ -124,12 +131,24 @@ export function CalendarSection({ onScheduleEdit, }: CalendarSectionProps) { const router = useRouter(); + const { isEnabled, tenantIndustry } = useModules(); + const moduleAware = !!tenantIndustry; const [selectedDate, setSelectedDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date()); const [, _setViewType] = useState('month'); const [deptFilter, setDeptFilter] = useState('all'); const [taskFilter, setTaskFilter] = useState('all'); + // ๋ชจ๋“ˆ ๊ธฐ๋ฐ˜ ์—…๋ฌด ํ•„ํ„ฐ ์˜ต์…˜ (๋น„ํ™œ์„ฑ ๋ชจ๋“ˆ ํ•„ํ„ฐ ์ˆจ๊น€) + const filteredTaskFilterOptions = useMemo(() => { + if (!moduleAware) return TASK_FILTER_OPTIONS; + return TASK_FILTER_OPTIONS.filter((option) => { + const requiredModule = SCHEDULE_TYPE_MODULE[option.value]; + if (!requiredModule) return true; + return isEnabled(requiredModule as 'production' | 'construction'); + }); + }, [moduleAware, isEnabled]); + // ์Šคํ† ์–ด์—์„œ ๊ณตํœด์ผ/์„ธ๋ฌด์ผ์ • ๊ฐ€์ ธ์˜ค๊ธฐ (API ์—ฐ๋™) const schedulesByYear = useCalendarScheduleStore((s) => s.schedulesByYear); const fetchSchedules = useCalendarScheduleStore((s) => s.fetchSchedules); @@ -272,7 +291,13 @@ export function CalendarSection({ }; // ์ผ์ • ํƒ€์ž…๋ณ„ ์ƒ์„ธ ํŽ˜์ด์ง€ ๋งํฌ ์ƒ์„ฑ (bill_123 โ†’ /ko/accounting/bills/123) + // ๋ชจ๋“ˆ ๋น„ํ™œ์„ฑ ์‹œ ํ•ด๋‹น ํƒ€์ž…์˜ ๋งํฌ ์ˆจ๊น€ const getScheduleLink = (schedule: CalendarScheduleItem): string | null => { + // ๋ชจ๋“ˆ ์˜์กด ํƒ€์ž…์ธ๋ฐ ํ•ด๋‹น ๋ชจ๋“ˆ์ด ๋น„ํ™œ์„ฑ์ด๋ฉด ๋งํฌ ์—†์Œ + const requiredModule = SCHEDULE_TYPE_MODULE[schedule.type]; + if (moduleAware && requiredModule && !isEnabled(requiredModule as 'production' | 'construction')) { + return null; + } const basePath = SCHEDULE_TYPE_ROUTES[schedule.type]; if (!basePath) return null; // expected_expense๋Š” ๋ชฉ๋ก ํŽ˜์ด์ง€๋งŒ ์กด์žฌ (์ƒ์„ธ ํŽ˜์ด์ง€ ์—†์Œ) @@ -383,7 +408,7 @@ export function CalendarSection({ - {TASK_FILTER_OPTIONS.map((option) => ( + {filteredTaskFilterOptions.map((option) => ( {option.label} @@ -432,7 +457,7 @@ export function CalendarSection({ - {TASK_FILTER_OPTIONS.map((option) => ( + {filteredTaskFilterOptions.map((option) => ( {option.label} diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts index 44b34bd9..790d072c 100644 --- a/src/components/business/CEODashboard/types.ts +++ b/src/components/business/CEODashboard/types.ts @@ -3,6 +3,7 @@ */ import type React from 'react'; +import type { ModuleId } from '@/modules/types'; // ์ฒดํฌํฌ์ธํŠธ ํƒ€์ž… (๊ฒฝ๊ณ /์„ฑ๊ณต/์—๋Ÿฌ/์ •๋ณด) export type CheckPointType = 'success' | 'warning' | 'error' | 'info'; @@ -716,6 +717,21 @@ export interface DetailModalConfig { table?: TableConfig; } +// ===== ๋ชจ๋“ˆ๋ณ„ ์„น์…˜ ๋งคํ•‘ (Phase 2: Dashboard Decoupling) ===== + +/** ํŠน์ • ๋ชจ๋“ˆ์ด ํ•„์š”ํ•œ ์„น์…˜ ๋งคํ•‘ (์—ฌ๊ธฐ ์—†๋Š” ์„น์…˜์€ ๊ณตํ†ต = ํ•ญ์ƒ ํ‘œ์‹œ) */ +export const MODULE_DEPENDENT_SECTIONS: Partial> = { + production: 'production', + shipment: 'production', + construction: 'construction', + // unshipped๋Š” ๊ณตํ†ต(outbound/logistics) โ€” ๋ชจ๋“ˆ ์˜์กด์„ฑ ์—†์Œ +}; + +/** ์„น์…˜์ด ์š”๊ตฌํ•˜๋Š” ๋ชจ๋“ˆ ID ๋ฐ˜ํ™˜. ๊ณตํ†ต ์„น์…˜์ด๋ฉด null */ +export function sectionRequiresModule(sectionKey: SectionKey): ModuleId | null { + return MODULE_DEPENDENT_SECTIONS[sectionKey] ?? null; +} + // ๊ธฐ๋ณธ ์„ค์ •๊ฐ’ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = { // ์ƒˆ ์˜ค๋Š˜์˜ ์ด์Šˆ (๋ฆฌ์ŠคํŠธ ํ˜•ํƒœ) diff --git a/src/components/business/CEODashboard/useSectionSummary.ts b/src/components/business/CEODashboard/useSectionSummary.ts index bfe8f1a2..dd0f20e8 100644 --- a/src/components/business/CEODashboard/useSectionSummary.ts +++ b/src/components/business/CEODashboard/useSectionSummary.ts @@ -2,7 +2,8 @@ import { useMemo, useEffect, useState, useRef, useCallback } from 'react'; import type { CEODashboardData, DashboardSettings, SectionKey } from './types'; -import { SECTION_LABELS } from './types'; +import { SECTION_LABELS, sectionRequiresModule } from './types'; +import { useModules } from '@/hooks/useModules'; export type SummaryStatus = 'normal' | 'warning' | 'danger'; @@ -220,10 +221,21 @@ export function useSectionSummary({ // ์นฉ ํด๋ฆญ์œผ๋กœ ์„ ํƒ๋œ ํ‚ค โ€” ํ•ด๋‹น ์„น์…˜์ด ํ™”๋ฉด์— ๋ณด์ด๋Š” ํ•œ ์œ ์ง€ const pinnedKey = useRef(null); - // ํ™œ์„ฑํ™”๋œ ์„น์…˜๋งŒ ํ•„ํ„ฐ + // ๋ชจ๋“ˆ ํ™œ์„ฑํ™” ์ •๋ณด (tenantIndustry ๋ฏธ์„ค์ • ์‹œ ์ „๋ถ€ ํ‘œ์‹œ) + const { isEnabled, tenantIndustry } = useModules(); + const moduleAware = !!tenantIndustry; + + // ํ™œ์„ฑํ™”๋œ ์„น์…˜๋งŒ ํ•„ํ„ฐ (์„ค์ • + ๋ชจ๋“ˆ) const enabledSections = useMemo( - () => sectionOrder.filter((key) => isSectionEnabled(key, dashboardSettings)), - [sectionOrder, dashboardSettings], + () => sectionOrder.filter((key) => { + if (!isSectionEnabled(key, dashboardSettings)) return false; + if (moduleAware) { + const requiredModule = sectionRequiresModule(key); + if (requiredModule && !isEnabled(requiredModule)) return false; + } + return true; + }), + [sectionOrder, dashboardSettings, moduleAware, isEnabled], ); // ์š”์•ฝ ๋ฐ์ดํ„ฐ ๊ณ„์‚ฐ diff --git a/src/components/business/construction/MODULE.md b/src/components/business/construction/MODULE.md new file mode 100644 index 00000000..b1498417 --- /dev/null +++ b/src/components/business/construction/MODULE.md @@ -0,0 +1,32 @@ +# Construction Module (๊ฑด์„ค๊ด€๋ฆฌ) + +**Module ID**: `construction` +**Tenant**: Juil (์ฃผ์ผ๊ฑด์„ค) +**Route Prefixes**: `/construction` +**Component Count**: 161 files + +## Dependencies on Common ERP +- `@/lib/api/*` โ€” Server actions, API client +- `@/components/ui/*` โ€” UI primitives (shadcn/ui) +- `@/components/templates/*` โ€” IntegratedListTemplateV2 ๋“ฑ +- `@/components/organisms/*` โ€” PageLayout, PageHeader +- `@/hooks/*` โ€” usePermission, useModules ๋“ฑ +- `@/stores/authStore` โ€” Tenant ์ •๋ณด +- `@/components/common/*` โ€” ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ + +## Exports to Common ERP +**NONE** โ€” ๊ฑด์„ค ๋ชจ๋“ˆ์€ ๋…๋ฆฝ์ ์œผ๋กœ ์ž‘๋™. + +## Related Dashboard Sections +- `construction` (์‹œ๊ณต ํ˜„ํ™ฉ) + +## Subdirectories +- `bidding/` โ€” ์ž…์ฐฐ ๊ด€๋ฆฌ +- `contract/` โ€” ๊ณ„์•ฝ ๊ด€๋ฆฌ +- `estimates/` โ€” ๊ฒฌ์  ๊ด€๋ฆฌ +- `progress-billing/` โ€” ๊ธฐ์„ฑ ๊ด€๋ฆฌ +- `site-management/` โ€” ํ˜„์žฅ ๊ด€๋ฆฌ +- `labor-management/` โ€” ๋…ธ๋ฌด ๊ด€๋ฆฌ +- `item-management/` โ€” ์ž์žฌ ๊ด€๋ฆฌ +- `partners/` โ€” ํ˜‘๋ ฅ์—…์ฒด ๊ด€๋ฆฌ +- ๊ธฐํƒ€ 20๊ฐœ ํ•˜์œ„ ๋„๋ฉ”์ธ 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/document-system/modals/AssigneeSelectModal.tsx b/src/components/document-system/modals/AssigneeSelectModal.tsx new file mode 100644 index 00000000..0df27bea --- /dev/null +++ b/src/components/document-system/modals/AssigneeSelectModal.tsx @@ -0,0 +1,29 @@ +'use client'; + +/** + * AssigneeSelectModal โ€” ๊ณต์œ  ๋ž˜ํผ + * + * ์›๋ณธ: @/components/production/WorkOrders/AssigneeSelectModal + * ๋ชฉ์ : ๊ณตํ†ต ERP(์˜์—…)์—์„œ ์ƒ์‚ฐ ๋ชจ๋“ˆ์„ ์ง์ ‘ importํ•˜์ง€ ์•Š๋„๋ก + * dynamic import๋กœ ์ •์  ์˜์กด์„ฑ ์ฒด์ธ์„ ๋Š์Œ + */ + +import dynamic from 'next/dynamic'; +import { Loader2 } from 'lucide-react'; + +const AssigneeSelectModalImpl = dynamic( + () => + import('@/components/production/WorkOrders/AssigneeSelectModal').then( + (mod) => mod.AssigneeSelectModal, + ), + { + loading: () => ( +
+ +
+ ), + ssr: false, + }, +); + +export { AssigneeSelectModalImpl as AssigneeSelectModal }; diff --git a/src/components/document-system/modals/InspectionReportModal.tsx b/src/components/document-system/modals/InspectionReportModal.tsx new file mode 100644 index 00000000..bad3100f --- /dev/null +++ b/src/components/document-system/modals/InspectionReportModal.tsx @@ -0,0 +1,29 @@ +'use client'; + +/** + * InspectionReportModal โ€” ๊ณต์œ  ๋ž˜ํผ + * + * ์›๋ณธ: @/components/production/WorkOrders/documents/InspectionReportModal + * ๋ชฉ์ : ๊ณตํ†ต ERP(๊ฒฐ์žฌ, ํ’ˆ์งˆ)์—์„œ ์ƒ์‚ฐ ๋ชจ๋“ˆ์„ ์ง์ ‘ importํ•˜์ง€ ์•Š๋„๋ก + * dynamic import๋กœ ์ •์  ์˜์กด์„ฑ ์ฒด์ธ์„ ๋Š์Œ + */ + +import dynamic from 'next/dynamic'; +import { Loader2 } from 'lucide-react'; + +const InspectionReportModalImpl = dynamic( + () => + import('@/components/production/WorkOrders/documents/InspectionReportModal').then( + (mod) => mod.InspectionReportModal, + ), + { + loading: () => ( +
+ +
+ ), + ssr: false, + }, +); + +export { InspectionReportModalImpl as InspectionReportModal }; diff --git a/src/components/document-system/modals/WorkLogModal.tsx b/src/components/document-system/modals/WorkLogModal.tsx new file mode 100644 index 00000000..961e750a --- /dev/null +++ b/src/components/document-system/modals/WorkLogModal.tsx @@ -0,0 +1,29 @@ +'use client'; + +/** + * WorkLogModal โ€” ๊ณต์œ  ๋ž˜ํผ + * + * ์›๋ณธ: @/components/production/WorkOrders/documents/WorkLogModal + * ๋ชฉ์ : ๊ณตํ†ต ERP(ํ’ˆ์งˆ QMS)์—์„œ ์ƒ์‚ฐ ๋ชจ๋“ˆ์„ ์ง์ ‘ importํ•˜์ง€ ์•Š๋„๋ก + * dynamic import๋กœ ์ •์  ์˜์กด์„ฑ ์ฒด์ธ์„ ๋Š์Œ + */ + +import dynamic from 'next/dynamic'; +import { Loader2 } from 'lucide-react'; + +const WorkLogModalImpl = dynamic( + () => + import('@/components/production/WorkOrders/documents/WorkLogModal').then( + (mod) => mod.WorkLogModal, + ), + { + loading: () => ( +
+ +
+ ), + ssr: false, + }, +); + +export { WorkLogModalImpl as WorkLogModal }; diff --git a/src/components/document-system/modals/index.ts b/src/components/document-system/modals/index.ts new file mode 100644 index 00000000..a67412a4 --- /dev/null +++ b/src/components/document-system/modals/index.ts @@ -0,0 +1,9 @@ +/** + * document-system/modals โ€” ๋ชจ๋“ˆ ๊ฒฝ๊ณ„๋ฅผ ๋„˜๋Š” ๊ณต์œ  ๋ชจ๋‹ฌ ๋ž˜ํผ + * + * ๊ณตํ†ต ERP ์ฝ”๋“œ์—์„œ ํ…Œ๋„ŒํŠธ ์ „์šฉ ๋ชจ๋‹ฌ์„ ์‚ฌ์šฉํ•  ๋•Œ + * ์ง์ ‘ import ๋Œ€์‹  ์ด ๋ž˜ํผ๋ฅผ ํ†ตํ•ด dynamic import๋กœ ์ ‘๊ทผ + */ +export { InspectionReportModal } from './InspectionReportModal'; +export { WorkLogModal } from './WorkLogModal'; +export { AssigneeSelectModal } from './AssigneeSelectModal'; diff --git a/src/components/items/BomTreeViewer.tsx b/src/components/items/BomTreeViewer.tsx new file mode 100644 index 00000000..533b9f29 --- /dev/null +++ b/src/components/items/BomTreeViewer.tsx @@ -0,0 +1,332 @@ +'use client'; + +/** + * BOM Tree ์‹œ๊ฐํ™” ์ปดํฌ๋„ŒํŠธ + * + * API: GET /api/proxy/items/{id}/bom/tree + * 3๋‹จ๊ณ„ ํŠธ๋ฆฌ: FG(๋ฃจํŠธ) โ†’ CAT(์นดํ…Œ๊ณ ๋ฆฌ ๊ทธ๋ฃน) โ†’ PT(๋ถ€ํ’ˆ) + * CAT ๋…ธ๋“œ: ์นดํ…Œ๊ณ ๋ฆฌ ํ—ค๋”๋กœ ๋ Œ๋”๋ง (์ ‘ํž˜/ํŽผ์นจ, count ํ‘œ์‹œ) + * PT ๋…ธ๋“œ: ํ’ˆ๋ชฉ ํ–‰์œผ๋กœ ๋ Œ๋”๋ง (์ฝ”๋“œ, ํ’ˆ๋ชฉ๋ช…, ์ˆ˜๋Ÿ‰, ๋‹จ์œ„) + */ + +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; + count?: number; // CAT ๋…ธ๋“œ โ€” ํ•˜์œ„ ํ’ˆ๋ชฉ ๊ฑด์ˆ˜ + 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', +}; + +// ๋…ธ๋“œ๋ณ„ ๊ณ ์œ  ํ‚ค ์ƒ์„ฑ (CAT ๋…ธ๋“œ๋Š” id=0์ด๋ฏ€๋กœ ์ด๋ฆ„ ๊ธฐ๋ฐ˜) +function getNodeKey(node: BomTreeNode, index: number): string { + if (node.item_type === 'CAT') return `cat-${index}-${node.name}`; + return `item-${node.id}`; +} + +// ๋ชจ๋“  ๋…ธ๋“œ์˜ ํ‚ค๋ฅผ ์žฌ๊ท€์ ์œผ๋กœ ์ˆ˜์ง‘ +function collectAllKeys(nodes: BomTreeNode[]): Set { + const keys = new Set(); + function walk(node: BomTreeNode, index: number) { + keys.add(getNodeKey(node, index)); + node.children?.forEach((child, i) => walk(child, i)); + } + nodes.forEach((node, i) => walk(node, i)); + return keys; +} + +// ์นดํ…Œ๊ณ ๋ฆฌ ๋…ธ๋“œ ์ปดํฌ๋„ŒํŠธ +function CategoryNode({ + node, + nodeKey, + isOpen, + onToggle, +}: { + node: BomTreeNode; + nodeKey: string; + isOpen: boolean; + onToggle: (key: string) => void; +}) { + const hasChildren = node.children && node.children.length > 0; + + return ( +
+ {/* ์นดํ…Œ๊ณ ๋ฆฌ ํ—ค๋” */} +
hasChildren && onToggle(nodeKey)} + > + {hasChildren && ( + isOpen + ? + : + )} + {node.name} + {node.count ?? node.children?.length ?? 0}๊ฑด +
+ + {/* ํ•˜์œ„ ํ’ˆ๋ชฉ (์ ‘ํž˜/ํŽผ์นจ) */} + {isOpen && hasChildren && ( +
+ {node.children.map((child, i) => ( + + ))} +
+ )} +
+ ); +} + +// ํ’ˆ๋ชฉ(PT) ๋…ธ๋“œ ์ปดํฌ๋„ŒํŠธ +function ItemNode({ node }: { node: BomTreeNode }) { + return ( +
+
+ {/* ์œ ํ˜• ๋ฑƒ์ง€ */} + + {node.item_type} + + + {/* ์ฝ”๋“œ โ€” PC๋งŒ ์ธ๋ผ์ธ ํ‘œ์‹œ */} + + {node.code} + + + {/* ํ’ˆ๋ชฉ๋ช… */} + {node.name} + + {/* ์ˆ˜๋Ÿ‰ + ๋‹จ์œ„ */} + + {node.quantity != null && ( + x{node.quantity} + )} + {node.unit && ( + {node.unit} + )} + +
+ {/* ์ฝ”๋“œ 2์ค„ โ€” ๋ชจ๋ฐ”์ผ๋งŒ */} + + {node.code} + +
+ ); +} + +// ๋ฒ”์šฉ ๋…ธ๋“œ ๋ Œ๋”๋Ÿฌ (CAT ๋ถ„๊ธฐ) +function BomNodeRenderer({ + node, + index, + expandedNodes, + onToggle, +}: { + node: BomTreeNode; + index: number; + expandedNodes: Set; + onToggle: (key: string) => void; +}) { + const nodeKey = getNodeKey(node, index); + const isCategory = node.item_type === 'CAT'; + + if (isCategory) { + return ( + + ); + } + + return ; +} + +interface BomTreeViewerProps { + itemId: string; + itemType: string; +} + +export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) { + const [treeData, setTreeData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [allExpanded, setAllExpanded] = useState(true); + + // ํŠธ๋ฆฌ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + 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 root = result.data as BomTreeNode; + setTreeData(root); + // ๊ธฐ๋ณธ: ๋ชจ๋“  ์นดํ…Œ๊ณ ๋ฆฌ ํŽผ์นจ + if (root.children) { + setExpandedNodes(collectAllKeys(root.children)); + } + } else { + setTreeData(null); + } + } catch { + setError('BOM ํŠธ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setIsLoading(false); + } + }, [itemId]); + + useEffect(() => { + 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 || !treeData.children || treeData.children.length === 0) { + return ( + + + + + ๋ถ€ํ’ˆ ๊ตฌ์„ฑ (BOM) + + + +

+ ๋“ฑ๋ก๋œ BOM ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. +

+
+
+ ); + } + + const toggleNode = (key: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const expandAll = () => { setExpandedNodes(collectAllKeys(treeData.children)); setAllExpanded(true); }; + const collapseAll = () => { setExpandedNodes(new Set()); setAllExpanded(false); }; + const toggleAll = () => { if (allExpanded) collapseAll(); else expandAll(); }; + + // ์นดํ…Œ๊ณ ๋ฆฌ ๊ทธ๋ฃน ์ˆ˜ & ์ด ํ’ˆ๋ชฉ ์ˆ˜ + const categories = treeData.children.filter(n => n.item_type === 'CAT'); + const totalItems = categories.reduce((sum, cat) => sum + (cat.count ?? cat.children?.length ?? 0), 0); + const groupCount = categories.length; + + return ( + + + {/* PC: ํ•œ ์ค„ ๋ ˆ์ด์•„์›ƒ */} +
+ + + ๋ถ€ํ’ˆ ๊ตฌ์„ฑ (BOM) + +
+ + ์ด {totalItems}๊ฐœ ํ’ˆ๋ชฉ ยท {groupCount}๊ฐœ ๊ทธ๋ฃน + + +
+
+ {/* ๋ชจ๋ฐ”์ผ: ์ค„๋ฐ”๊ฟˆ ๋ ˆ์ด์•„์›ƒ */} +
+ + + ๋ถ€ํ’ˆ ๊ตฌ์„ฑ (BOM) + + + ์ด {totalItems}๊ฐœ ํ’ˆ๋ชฉ ยท {groupCount}๊ฐœ ๊ทธ๋ฃน + + +
+
+ +
+ {treeData.children.map((node, i) => ( + + ))} +
+
+
+ ); +} 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/ImportInspectionInputModal.tsx b/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx index a4740820..63d0e7d2 100644 --- a/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx +++ b/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx @@ -18,8 +18,8 @@ import { DialogContent, DialogHeader, DialogTitle, - VisuallyHidden, DialogDescription, + VisuallyHidden, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -670,12 +670,10 @@ export function ImportInspectionInputModal({ // ์บก์ฒ˜ ์‹คํŒจ ์‹œ ๋ฌด์‹œ โ€” rendered_html ์—†์ด ์ €์žฅ ์ง„ํ–‰ } - // 5. ์ €์žฅ API ํ˜ธ์ถœ (rendered_html์ด ๋„ˆ๋ฌด ํฌ๋ฉด ์ œ์™ธ โ€” 413 ๋ฐฉ์ง€) - const MAX_HTML_SIZE = 500 * 1024; // 500KB ์ œํ•œ - const safeHtml = renderedHtml && renderedHtml.length <= MAX_HTML_SIZE ? renderedHtml : undefined; - if (renderedHtml && renderedHtml.length > MAX_HTML_SIZE) { - console.warn(`[ImportInspection] rendered_html ํฌ๊ธฐ ์ดˆ๊ณผ (${(renderedHtml.length / 1024).toFixed(0)}KB), ์ œ์™ธํ•˜๊ณ  ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.`); - } + // 5. ์ €์žฅ API ํ˜ธ์ถœ (rendered_html 500KB ์ดˆ๊ณผ ์‹œ ์ œ์™ธ โ€” 413 ์—๋Ÿฌ ๋ฐฉ์ง€) + const MAX_HTML_SIZE = 500 * 1024; + const safeHtml = renderedHtml && renderedHtml.length <= MAX_HTML_SIZE + ? renderedHtml : undefined; const result = await saveInspectionData({ templateId: parseInt(template.templateId), diff --git a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx index 867403c5..12b398b1 100644 --- a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx +++ b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx @@ -126,7 +126,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props) return ( - + ์žฌ๊ณ  ์กฐ์ • ์žฌ๊ณ  ์ˆ˜๋Ÿ‰ ์กฐ์ • @@ -165,7 +165,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 5c291ef3..e8f51812 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'; @@ -68,7 +67,7 @@ interface Props { mode?: 'view' | 'edit' | 'new'; } -// ์ดˆ๊ธฐ ํผ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ (์„ธ์…˜ ์‚ฌ์šฉ์ž, ์˜ค๋Š˜ ๋‚ ์งœ ๊ธฐ๋ณธ๊ฐ’) +// ์ดˆ๊ธฐ ํผ ๋ฐ์ดํ„ฐ (๋™์  ํ•จ์ˆ˜ โ€” ์„ธ์…˜ ์‚ฌ์šฉ์ž ์ด๋ฆ„ + ์˜ค๋Š˜ ๋‚ ์งœ ๊ธฐ๋ณธ๊ฐ’) function createInitialFormData(): Partial { return { materialNo: '', @@ -93,16 +92,6 @@ function createInitialFormData(): Partial { }; } -// ๋กœํŠธ๋ฒˆํ˜ธ ์ƒ์„ฑ (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: '' }; @@ -160,9 +149,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', @@ -171,7 +157,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, @@ -201,10 +186,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({ @@ -243,9 +224,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { if (templateCheck.success) { setHasInspectionTemplate(templateCheck.hasTemplate); } - if (templateCheck.attachments && templateCheck.attachments.length > 0) { - setInspectionAttachments(templateCheck.attachments); - } + setInspectionAttachments(templateCheck.attachments ?? []); } else { setHasInspectionTemplate(false); } @@ -296,14 +275,14 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { const result = await createReceiving(saveData); if (result.success) { toast.success('์ž…๊ณ ๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); - // ๋“ฑ๋ก ์™„๋ฃŒ ํ›„ ์ƒ์„ฑ๋œ ์ž…๊ณ  ์ƒ์„ธ ํŽ˜์ด์ง€๋กœ ๋ฐ”๋กœ ์ด๋™ (๋ชฉ๋ก ๊ฒฝ์œ  ๋ฐฉ์ง€) const newId = result.data?.id; if (newId) { router.push(`/ko/material/receiving-management/${newId}?mode=view`); } else { router.push('/ko/material/receiving-management'); } - return { success: true }; + // ์ปค์Šคํ…€ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ฒ˜๋ฆฌ: error='' โ†’ ํ…œํ”Œ๋ฆฟ์˜ navigateToList() ํ˜ธ์ถœ ๋ฐฉ์ง€ + return { success: false, error: '' }; } else { toast.error(result.error || '๋“ฑ๋ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); return { success: false, error: result.error }; @@ -345,30 +324,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) { @@ -515,47 +470,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(() => { @@ -582,15 +499,12 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { /> - {/* ์›์ž์žฌ๋กœํŠธ - ์ˆ˜์ • ๊ฐ€๋Šฅ */} + {/* ์›์ž์žฌ๋กœํŠธ - ์ž๋™์ฑ„๋ฒˆ (readOnly) */}
- setFormData((prev) => ({ ...prev, lotNo: e.target.value }))} - placeholder="์›์ž์žฌ๋กœํŠธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”" - /> +
+ {formData.lotNo || (isNewMode ? '์ €์žฅ ์‹œ ์ž๋™ ์ƒ์„ฑ' : '-')} +
{/* ํ’ˆ๋ชฉ์ฝ”๋“œ - ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ ์„ ํƒ */} @@ -801,88 +715,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" + /> +
+
+ +