refactor: [Phase 0] 공통→테넌트 모듈 의존성 해소

- InspectionReportModal/WorkLogModal/AssigneeSelectModal → document-system/modals/ dynamic import 래퍼
- ProductionOrders 타입/액션 → lib/api/production-orders/ 공유 영역 분리
- 결재(ApprovalBox), 품질(QMS), 영업(production-orders) import 경로 수정
- 하드코딩 경로 /production/work-orders → 영업 내부 경로로 변경
- dashboard-invalidation DomainKey 하드코딩 → registerDashboardDomain() 동적 레지스트리

공통 ERP에서 테넌트(생산) 직접 import 0건 달성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-03-18 14:40:28 +09:00
parent e8fafaf5f4
commit a99c3b3908
13 changed files with 221 additions and 54 deletions

View File

@@ -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';

View File

@@ -29,7 +29,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,

View File

@@ -48,13 +48,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[] }) {
@@ -243,8 +243,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) {

View File

@@ -37,12 +37,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';
// 진행 단계 컴포넌트

View File

@@ -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();

View File

@@ -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: () => (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
),
ssr: false,
},
);
export { AssigneeSelectModalImpl as AssigneeSelectModal };

View File

@@ -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: () => (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
),
ssr: false,
},
);
export { InspectionReportModalImpl as InspectionReportModal };

View File

@@ -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: () => (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
),
ssr: false,
},
);
export { WorkLogModalImpl as WorkLogModal };

View File

@@ -0,0 +1,9 @@
/**
* document-system/modals — 모듈 경계를 넘는 공유 모달 래퍼
*
* 공통 ERP 코드에서 테넌트 전용 모달을 사용할 때
* 직접 import 대신 이 래퍼를 통해 dynamic import로 접근
*/
export { InspectionReportModal } from './InspectionReportModal';
export { WorkLogModal } from './WorkLogModal';
export { AssigneeSelectModal } from './AssigneeSelectModal';

View File

@@ -0,0 +1,27 @@
'use server';
/**
* 생산지시 공유 액션 — 모듈 경계용 래퍼
*
* 원본: @/components/production/ProductionOrders/actions
* 'use server' 파일에서는 re-export 불가 → async 래퍼 함수로 위임
*/
import {
getProductionOrders as _getProductionOrders,
getProductionOrderStats as _getProductionOrderStats,
getProductionOrderDetail as _getProductionOrderDetail,
} from '@/components/production/ProductionOrders/actions';
import type { ProductionOrderListParams } from './types';
export async function getProductionOrders(params: ProductionOrderListParams) {
return _getProductionOrders(params);
}
export async function getProductionOrderStats() {
return _getProductionOrderStats();
}
export async function getProductionOrderDetail(orderId: string) {
return _getProductionOrderDetail(orderId);
}

View File

@@ -0,0 +1,22 @@
/**
* 생산지시 공유 API — 공통 ERP에서 접근하는 진입점
*
* 공통 ERP(영업 등)에서는 이 경로로 import:
* import { getProductionOrders } from '@/lib/api/production-orders';
* import type { ProductionOrder } from '@/lib/api/production-orders';
*
* 생산 모듈 내부에서는 기존 경로 유지:
* import { ... } from './actions';
* import type { ... } from './types';
*/
export { getProductionOrders, getProductionOrderStats, getProductionOrderDetail } from './actions';
export type {
ProductionStatus,
ProductionOrder,
ProductionOrderStats,
ProductionOrderDetail,
ProductionWorkOrder,
BomProcessGroup,
BomItem,
ProductionOrderListParams,
} from './types';

View File

@@ -0,0 +1,22 @@
/**
* 생산지시 공유 타입 — 모듈 경계용 re-export
*
* 원본: @/components/production/ProductionOrders/types
* 영업(공통 ERP)에서 생산(테넌트) 타입을 직접 import하지 않도록
* 공유 영역에서 re-export
*/
export type {
ProductionStatus,
ProductionOrder,
ProductionOrderStats,
ProductionOrderDetail,
ProductionWorkOrder,
BomProcessGroup,
BomItem,
ProductionOrderListParams,
ApiProductionOrder,
ApiProductionOrderDetail,
ApiProductionWorkOrder,
ApiBomProcessGroup,
ApiBomItem,
} from '@/components/production/ProductionOrders/types';

View File

@@ -2,6 +2,10 @@
* CEO 대시보드 targeted refetch 시스템
*
* CUD 발생 시 sessionStorage + CustomEvent로 대시보드 섹션별 갱신 트리거
*
* 도메인→섹션 매핑은 동적 레지스트리 패턴:
* - 공통 ERP 도메인은 여기서 직접 등록
* - 테넌트 전용 도메인은 각 모듈에서 registerDashboardDomain()으로 자기 등록
*/
// 대시보드 섹션 키 (useCEODashboard의 refetchMap과 1:1 매핑)
@@ -21,49 +25,46 @@ export type DashboardSectionKey =
| 'entertainment'
| 'welfare';
// CUD 도메인 → 영향받는 대시보드 섹션 매핑
type DomainKey =
| 'deposit'
| 'withdrawal'
| 'sales'
| 'purchase'
| 'badDebt'
| 'expectedExpense'
| 'bill'
| 'giftCertificate'
| 'journalEntry'
| 'order'
| 'stock'
| 'schedule'
| 'client'
| 'leave'
| 'approval'
| 'attendance'
| 'production'
| 'shipment'
| 'construction';
// 동적 도메인→섹션 레지스트리
const domainSectionRegistry = new Map<string, DashboardSectionKey[]>();
const DOMAIN_SECTION_MAP: Record<DomainKey, DashboardSectionKey[]> = {
deposit: ['dailyReport', 'receivable'],
withdrawal: ['dailyReport', 'monthlyExpense'],
sales: ['dailyReport', 'salesStatus', 'receivable'],
purchase: ['dailyReport', 'purchaseStatus', 'monthlyExpense'],
badDebt: ['debtCollection', 'receivable'],
expectedExpense: ['monthlyExpense'],
bill: ['dailyReport', 'receivable'],
giftCertificate: ['entertainment', 'cardManagement'],
journalEntry: ['entertainment', 'welfare', 'monthlyExpense'],
order: ['statusBoard', 'salesStatus'],
stock: ['statusBoard'],
schedule: ['statusBoard'],
client: ['statusBoard'],
leave: ['statusBoard', 'dailyAttendance'],
approval: ['statusBoard'],
attendance: ['statusBoard', 'dailyAttendance'],
production: ['statusBoard', 'dailyProduction'],
shipment: ['statusBoard', 'unshipped'],
construction: ['statusBoard', 'construction'],
};
/**
* 도메인→섹션 매핑 등록 (각 모듈에서 호출)
*
* @example
* // 생산 모듈 초기화 시
* registerDashboardDomain('production', ['statusBoard', 'dailyProduction']);
*
* // 건설 모듈 초기화 시
* registerDashboardDomain('construction', ['statusBoard', 'construction']);
*/
export function registerDashboardDomain(domain: string, sections: DashboardSectionKey[]): void {
domainSectionRegistry.set(domain, sections);
}
// ===== 공통 ERP 도메인 (테넌트 무관, 항상 등록) =====
registerDashboardDomain('deposit', ['dailyReport', 'receivable']);
registerDashboardDomain('withdrawal', ['dailyReport', 'monthlyExpense']);
registerDashboardDomain('sales', ['dailyReport', 'salesStatus', 'receivable']);
registerDashboardDomain('purchase', ['dailyReport', 'purchaseStatus', 'monthlyExpense']);
registerDashboardDomain('badDebt', ['debtCollection', 'receivable']);
registerDashboardDomain('expectedExpense', ['monthlyExpense']);
registerDashboardDomain('bill', ['dailyReport', 'receivable']);
registerDashboardDomain('giftCertificate', ['entertainment', 'cardManagement']);
registerDashboardDomain('journalEntry', ['entertainment', 'welfare', 'monthlyExpense']);
registerDashboardDomain('order', ['statusBoard', 'salesStatus']);
registerDashboardDomain('stock', ['statusBoard']);
registerDashboardDomain('schedule', ['statusBoard']);
registerDashboardDomain('client', ['statusBoard']);
registerDashboardDomain('leave', ['statusBoard', 'dailyAttendance']);
registerDashboardDomain('approval', ['statusBoard']);
registerDashboardDomain('attendance', ['statusBoard', 'dailyAttendance']);
registerDashboardDomain('shipment', ['statusBoard', 'unshipped']);
// ===== 테넌트 전용 도메인 (각 모듈에서 등록 — 현재는 하위 호환을 위해 여기서도 등록) =====
// TODO: Phase 1 완료 후 각 모듈의 초기화 코드로 이동
registerDashboardDomain('production', ['statusBoard', 'dailyProduction']);
registerDashboardDomain('construction', ['statusBoard', 'construction']);
const STORAGE_KEY = 'dashboard:stale-sections';
const EVENT_NAME = 'dashboard:invalidate';
@@ -71,8 +72,8 @@ const EVENT_NAME = 'dashboard:invalidate';
/**
* CUD 성공 후 호출 — 해당 도메인이 영향 주는 대시보드 섹션을 stale 처리
*/
export function invalidateDashboard(domain: DomainKey): void {
const sections = DOMAIN_SECTION_MAP[domain];
export function invalidateDashboard(domain: string): void {
const sections = domainSectionRegistry.get(domain);
if (!sections || sections.length === 0) return;
// 1. sessionStorage에 stale 섹션 저장 (navigation 사이 유지)