17 Commits

Author SHA1 Message Date
563b240fbf feat: [품질검사] 검사 모달 개선 + 수주 선택 필터링
검사 모달:
- 기본값 null(미선택)으로 변경, 일괄합격/초기화 토글 버튼
- 시공 가로/세로, 변경사유 입력 필드 추가
- 검사 항목별 기준값 텍스트 표시
- 사진 첨부 기능 (최대 2장, base64)
- 이전/다음 개소 네비게이션 + 자동저장

뱃지/상태:
- legacy 검사 데이터 반영 (합격/불합격/진행중/미검사)
- 사진 없으면 진행중 처리, 뱃지 크기 통일
- Eye 아이콘 → "보기" 텍스트 뱃지
- 진행바 legacy+FQC 통합 inspectionStats

수주 선택:
- 같은 거래처(발주처) + 같은 모델만 선택 가능 필터링
- 수주 선택 시 개소별 자동 펼침 (floor, symbol, 규격 포함)
- 모달에 모델명 컬럼 추가, 필터 적용 시 제목에 안내 표시
- 변경사유 서버 저장 연동 수정
2026-03-07 01:19:17 +09:00
e75d8f9b25 fix: [제품검사 요청서] EAV 문서 없을 때 legacy fallback 적용
- useFqcMode && fqcDocument 조건으로 변경
- requestDocumentId 없는 기존 데이터에서 빈 양식 표시되던 문제 수정
2026-03-06 22:02:39 +09:00
4ea03922a3 feat: [제품검사 성적서] 8컬럼 동적 렌더링 + FQC 모드 기본값
- FqcDocumentContent: 8컬럼 시각 레이아웃 (No/검사항목/세부항목/검사기준/검사방법/검사주기/측정값/판정)
- rowSpan 병합: category 단독 + method+frequency 복합키 병합
- measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성
- InspectionReportModal: FQC 모드 우선 (template 로드 실패 시 legacy fallback)
- Lazy Snapshot 준비 (contentWrapperRef 추가)
2026-03-06 21:47:33 +09:00
295585d8b6 feat: 제품검사 요청서 양식 기반 렌더링 + Lazy Snapshot
- FqcRequestDocumentContent: template 66 기반 동적 렌더링 컴포넌트
  - 결재라인, 기본정보, 입력사항(4섹션), 사전고지 테이블
  - group_name 기반 3단 헤더 (오픈사이즈 발주/시공 병합)
- InspectionRequestModal: FQC 모드 전환 + EAV 문서 로드 + Lazy Snapshot
- fqcActions: getFqcRequestTemplate, patchDocumentSnapshot, description/groupName 타입
- types/actions: requestDocumentId 필드 추가 및 매핑
- InspectionDetail: requestDocumentId prop 전달
2026-03-06 21:43:01 +09:00
e7263feecf feat: [품질관리] 수주 연결 동기화 + 개소별 데이터 저장
- transformApiToFrontend에 orderId, inspectionData 매핑 추가
- transformFormToApi에 order_ids 추가
- updateInspection에 order_ids 동기화 + locations 데이터 전송
2026-03-06 21:09:48 +09:00
8250eaf2b5 feat: [문서스냅샷] Lazy Snapshot - 중간검사/작업일지 조회 시 자동 스냅샷 캡처
- patchDocumentSnapshot() 서버 액션 추가
- InspectionReportModal: resolve 응답의 snapshot_document_id 기반 Lazy Snapshot
- WorkLogModal: getWorkLog으로 문서 확인 후 Lazy Snapshot
- 동작: rendered_html NULL → 500ms 후 innerHTML 캡처 → 백그라운드 PATCH
2026-03-06 20:59:25 +09:00
72a2a3e9a9 fix: [문서스냅샷] 캡처 방식 보정 - 오프스크린 성적서 렌더링, readOnly 자동 캡처 제거
- ImportInspectionInputModal: 입력폼 캡처 → 오프스크린 성적서 문서 렌더링으로 변경
- InspectionReportModal: readOnly 자동 캡처 useEffect 제거 (불필요 PUT 방지)
- capture-rendered-html.tsx: 오프스크린 렌더링 유틸리티 신규 추가
2026-03-06 20:35:30 +09:00
31f523c88f feat: [문서] 모든 문서 저장 경로에 rendered_html 스냅샷 캡처 추가
- InspectionReportModal: readOnly 모드에서도 콘텐츠 로드 후 자동 캡처
- ImportInspectionInputModal: 수입검사 저장 시 폼 HTML 캡처 전송
- ReceivingManagement/actions: saveInspectionData에 rendered_html 파라미터 추가
2026-03-06 20:04:11 +09:00
a1fb0d4f9b feat: [문서] 검사성적서/작업일지 저장 시 HTML 스냅샷 캡처 전송
- InspectionReportModal: contentWrapperRef로 DOM 캡처, handleSave에서 rendered_html 포함
- WorkLogModal: contentWrapperRef로 DOM 캡처, handleSave에서 rendered_html 포함
- saveInspectionDocument/saveWorkLog 타입에 rendered_html 추가
- MNG에서 스냅샷 기반 문서 출력을 위한 프론트 파이프라인 완성
2026-03-06 17:46:06 +09:00
fe930b5831 feat: [품질관리] 수주선택 모달 발주처별 비활성화 제약 추가
- SearchableSelectionModal에 isItemDisabled 콜백 prop 추가 (공통)
  - renderItem에 isDisabled 3번째 파라미터 전달 (하위호환)
  - disabled 아이템 클릭 차단 + opacity/cursor 스타일 적용
  - 전체선택 시 disabled 아이템 제외
- OrderSelectModal: 선택된 발주처와 다른 발주처의 수주 비활성화
  - 이미 선택된 아이템은 해제 가능 (disabled 예외)
2026-03-05 23:15:17 +09:00
899493a74d feat: [품질관리] 수주선택 모달에 발주처 컬럼 추가
- OrderSelectItem 타입에 clientName 필드 추가
- actions.ts API 응답 매핑에 client_name → clientName 추가
- OrderSelectModal 테이블 헤더/바디에 발주처 컬럼 추가
- 모달 너비 sm:max-w-2xl → sm:max-w-3xl 확장
2026-03-05 23:09:05 +09:00
45ad99cb38 fix: [공통] SearchableSelectionModal 테이블 HTML 유효성 에러 수정
- renderItem이 <tr>을 반환할 때 <div>로 래핑하여 발생하던 hydration 에러 해결
- cloneElement로 key/onClick을 직접 주입하여 <tbody><div><tr> 구조 방지
- 영향범위: OrderSelectModal, SalesOrderSelectModal, TaxInvoiceIssuance
2026-03-05 21:59:44 +09:00
10c6e20db4 fix: [품질관리] 실적신고 API 응답 snake_case → camelCase 변환 추가
- transformReport: 실적신고 목록 데이터 변환
- transformStats: 통계 데이터 변환
- transformMissedReport: 누락체크 데이터 변환
- 백엔드 snake_case 응답을 프론트 camelCase 타입에 매핑
2026-03-05 21:26:01 +09:00
50e4c72c8a feat: [품질관리] 프론트엔드 API 연동 (Mock → 실제 API 전환)
- InspectionManagement/actions.ts: API 경로 /quality/documents로 변경, transformFormToApi options JSON 구조 매핑
- PerformanceReportManagement/actions.ts: API 경로 /quality/performance-reports로 변경, /missed→/missing
- InspectionManagement/types.ts: InspectionFormData에 clientId/inspectorId/receptionDate 추가
- USE_MOCK_FALLBACK = false 설정
2026-03-05 21:26:01 +09:00
eb18a3facb fix: [생산지시] BOM 공정 분류 UI 수정 + 접이식 카드
- BOM types/actions: 필드 매핑 수정 (unit, quantity, unitPrice, totalPrice, nodeName)
- BOM UI: 접이식(collapsible) Card + ChevronDown 토글
- 테이블 컬럼 변경: 품목코드, 품목명, 규격, 단위, 수량, 단가, 금액, 개소
- 중복 key 수정: `${item.id}-${idx}` 패턴 적용
2026-03-05 21:26:01 +09:00
9fc979e135 fix: [생산지시] 날짜포맷·수량→개소수·중복key 수정
- actions.ts: formatDateOnly 정규식 보강 (공백/T 구분자 모두 처리)
- actions.ts: nodeCount 매핑 추가 (node_count/nodes_count)
- types.ts: nodeCount, node_count, nodes_count 필드 추가
- page.tsx: 수량→개소 컬럼 변경, nodeCount 표시
- [id]/page.tsx: 수량→개소 표시, BOM 테이블 중복 key 수정
2026-03-05 21:26:01 +09:00
fa7efb7b24 feat: [생산지시] 목록/상세 페이지 API 연동
- types.ts: API/프론트 타입 정의 (ProductionOrder, Detail, BOM 타입)
- actions.ts: Server Actions (getProductionOrders, getProductionOrderStats, getProductionOrderDetail)
  - executePaginatedAction + buildApiUrl 패턴 적용
  - snake_case → camelCase 변환 함수
- 목록 page.tsx: 샘플데이터 → API 연동
  - 서버사이드 페이지네이션 (clientSideFiltering: false)
  - stats API로 탭 카운트 동적 반영
  - ProgressSteps 동적화 (statusCode 기반)
  - 생산지시번호 → 수주번호로 변경 (별도 PO 번호 없음)
- 상세 page.tsx: 샘플데이터 → API 연동
  - getProductionOrderDetail() API 호출
  - createProductionOrder() orders/actions.ts에서 재사용
  - BOM null 처리 (빈 상태 표시)
  - WorkOrder 상태 배지 확장 (6종: unassigned~shipped)
2026-03-05 21:26:01 +09:00
27 changed files with 2787 additions and 1265 deletions

View File

@@ -30,6 +30,7 @@ import {
Circle,
Activity,
Play,
ChevronDown,
} from "lucide-react";
import { PageLayout } from "@/components/organisms/PageLayout";
import { PageHeader } from "@/components/organisms/PageHeader";
@@ -47,143 +48,17 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { toast } from "sonner";
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
import { formatNumber } from '@/lib/utils/amount';
// 생산지시 상태 타입
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
// 작업지시 상태 타입
type WorkOrderStatus = "pending" | "in_progress" | "completed";
// 작업지시 데이터 타입
interface WorkOrder {
id: string;
workOrderNumber: string; // KD-WO-XXXXXX-XX
process: string; // 공정명
quantity: number;
status: WorkOrderStatus;
assignee: string;
}
// 생산지시 상세 데이터 타입
interface ProductionOrderDetail {
id: string;
productionOrderNumber: string;
orderNumber: string;
productionOrderDate: string;
dueDate: string;
quantity: number;
status: ProductionOrderStatus;
client: string;
siteName: string;
productType: string;
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
workOrders: WorkOrder[];
}
// 샘플 생산지시 상세 데이터
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
"PO-001": {
id: "PO-001",
productionOrderNumber: "PO-KD-TS-251217-07",
orderNumber: "KD-TS-251217-07",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-15",
quantity: 2,
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
client: "호반건설(주)",
siteName: "씨밋 광교 센트럴시티",
productType: "",
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
workOrders: [
{
id: "WO-001",
workOrderNumber: "KD-WO-251217-07",
process: "재단",
quantity: 2,
status: "completed",
assignee: "-",
},
{
id: "WO-002",
workOrderNumber: "KD-WO-251217-08",
process: "조립",
quantity: 2,
status: "completed",
assignee: "-",
},
{
id: "WO-003",
workOrderNumber: "KD-WO-251217-09",
process: "검수",
quantity: 2,
status: "completed",
assignee: "-",
},
],
},
"PO-002": {
id: "PO-002",
productionOrderNumber: "PO-KD-TS-251217-09",
orderNumber: "KD-TS-251217-09",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-10",
quantity: 10,
status: "waiting",
client: "태영건설(주)",
siteName: "데시앙 동탄 파크뷰",
productType: "",
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
workOrders: [],
},
"PO-003": {
id: "PO-003",
productionOrderNumber: "PO-KD-TS-251217-06",
orderNumber: "KD-TS-251217-06",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-10",
quantity: 1,
status: "waiting",
client: "롯데건설(주)",
siteName: "예술 검실 푸르지오",
productType: "",
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
workOrders: [],
},
"PO-004": {
id: "PO-004",
productionOrderNumber: "PO-KD-BD-251220-35",
orderNumber: "KD-BD-251220-35",
productionOrderDate: "2025-12-20",
dueDate: "2026-02-03",
quantity: 3,
status: "in_progress",
client: "현대건설(주)",
siteName: "[코레타스프] 판교 물류센터 철거현장",
productType: "",
pendingWorkOrderCount: 0,
workOrders: [
{
id: "WO-004",
workOrderNumber: "KD-WO-251220-01",
process: "재단",
quantity: 3,
status: "completed",
assignee: "-",
},
{
id: "WO-005",
workOrderNumber: "KD-WO-251220-02",
process: "조립",
quantity: 3,
status: "in_progress",
assignee: "-",
},
],
},
};
import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions";
import { createProductionOrder } from "@/components/orders/actions";
import type {
ProductionOrderDetail,
ProductionStatus,
ProductionWorkOrder,
BomProcessGroup,
} from "@/components/production/ProductionOrders/types";
// 공정 진행 현황 컴포넌트
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) {
if (workOrders.length === 0) {
return (
<Card>
@@ -202,7 +77,9 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
);
}
const completedCount = workOrders.filter((w) => w.status === "completed").length;
const completedCount = workOrders.filter(
(w) => w.status === "completed" || w.status === "shipped"
).length;
const totalCount = workOrders.length;
const progressPercent = Math.round((completedCount / totalCount) * 100);
@@ -237,25 +114,27 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
<div className="flex flex-col items-center gap-1">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full ${
wo.status === "completed"
wo.status === "completed" || wo.status === "shipped"
? "bg-green-500 text-white"
: wo.status === "in_progress"
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-400"
}`}
>
{wo.status === "completed" ? (
{wo.status === "completed" || wo.status === "shipped" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Circle className="h-4 w-4" />
)}
</div>
<span className="text-xs text-muted-foreground">{wo.process}</span>
<span className="text-xs text-muted-foreground">{wo.processName}</span>
</div>
{index < workOrders.length - 1 && (
<div
className={`w-12 h-0.5 mx-1 ${
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
wo.status === "completed" || wo.status === "shipped"
? "bg-green-500"
: "bg-gray-200"
}`}
/>
)}
@@ -269,13 +148,13 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
}
// 상태 배지 헬퍼
function getStatusBadge(status: ProductionOrderStatus) {
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
function getStatusBadge(status: ProductionStatus) {
const config: Record<ProductionStatus, { label: string; className: string }> = {
waiting: {
label: "생산대기",
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
},
in_progress: {
in_production: {
label: "생산중",
className: "bg-green-100 text-green-700 border-green-200",
},
@@ -289,22 +168,16 @@ function getStatusBadge(status: ProductionOrderStatus) {
}
// 작업지시 상태 배지 헬퍼
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
pending: {
label: "대기",
className: "bg-gray-100 text-gray-700 border-gray-200",
},
in_progress: {
label: "작업중",
className: "bg-blue-100 text-blue-700 border-blue-200",
},
completed: {
label: "완료",
className: "bg-green-100 text-green-700 border-green-200",
},
function getWorkOrderStatusBadge(status: string) {
const config: Record<string, { label: string; className: string }> = {
unassigned: { label: "미배정", className: "bg-gray-100 text-gray-700 border-gray-200" },
pending: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" },
waiting: { label: "준비중", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
in_progress: { label: "작업중", className: "bg-blue-100 text-blue-700 border-blue-200" },
completed: { label: "완료", className: "bg-green-100 text-green-700 border-green-200" },
shipped: { label: "출하", className: "bg-purple-100 text-purple-700 border-purple-200" },
};
const c = config[status];
const c = config[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
}
@@ -318,99 +191,33 @@ function InfoItem({ label, value }: { label: string; value: string }) {
);
}
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
const SAMPLE_PROCESSES = [
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
{ id: "P3", name: "3.1 케이스", quantity: 10 },
{ id: "P4", name: "4. 연기단자", quantity: 10 },
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
];
// BOM 품목 타입
interface BomItem {
id: string;
itemCode: string;
itemName: string;
spec: string;
lotNo: string;
requiredQty: number;
qty: number;
}
// BOM 공정 분류 타입
interface BomProcessGroup {
processName: string;
sizeSpec?: string;
items: BomItem[];
}
// BOM 품목별 공정 분류 목데이터
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
{
processName: "1.1 백판필름",
sizeSpec: "[20-70]",
items: [
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
],
},
{
processName: "2. 하안마감재",
sizeSpec: "[60-40]",
items: [
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
],
},
{
processName: "3.1 케이스",
sizeSpec: "[500*330]",
items: [
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
],
},
{
processName: "4. 연기단자",
sizeSpec: "",
items: [
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
],
},
];
export default function ProductionOrderDetailPage() {
const router = useRouter();
const params = useParams();
const productionOrderId = params.id as string;
const orderId = params.id as string;
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
const [loading, setLoading] = useState(true);
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [bomOpen, setBomOpen] = useState(false);
// 데이터 로드
const loadDetail = async () => {
setLoading(true);
const result = await getProductionOrderDetail(orderId);
if (result.success && result.data) {
setDetail(result.data);
} else {
setDetail(null);
}
setLoading(false);
};
useEffect(() => {
setTimeout(() => {
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
setProductionOrder(found || null);
setLoading(false);
}, 300);
}, [productionOrderId]);
loadDetail();
}, [orderId]);
const handleBack = () => {
router.push("/sales/order-management-sales/production-orders");
@@ -423,19 +230,13 @@ export default function ProductionOrderDetailPage() {
const handleConfirmCreateWorkOrder = async () => {
setIsCreating(true);
try {
// API 호출 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 500));
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
const created = Array.from({ length: workOrderCount }, (_, i) =>
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
);
setCreatedWorkOrders(created);
// 확인 팝업 닫고 성공 팝업 열기
setIsCreateWorkOrderDialogOpen(false);
setIsSuccessDialogOpen(true);
const result = await createProductionOrder(orderId);
if (result.success) {
setIsCreateWorkOrderDialogOpen(false);
setIsSuccessDialogOpen(true);
} else {
toast.error(result.error || "작업지시 생성에 실패했습니다.");
}
} finally {
setIsCreating(false);
}
@@ -457,7 +258,7 @@ export default function ProductionOrderDetailPage() {
);
}
if (!productionOrder) {
if (!detail) {
return (
<ServerErrorPage
title="생산지시 정보를 불러올 수 없습니다"
@@ -468,6 +269,9 @@ export default function ProductionOrderDetailPage() {
);
}
const hasWorkOrders = detail.workOrders.length > 0;
const canCreateWorkOrders = detail.productionStatus === "waiting" && !hasWorkOrders;
return (
<PageLayout>
{/* 헤더 */}
@@ -476,9 +280,9 @@ export default function ProductionOrderDetailPage() {
<div className="flex items-center gap-3">
<span> </span>
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
{productionOrder.productionOrderNumber}
{detail.orderNumber}
</code>
{getStatusBadge(productionOrder.status)}
{getStatusBadge(detail.productionStatus)}
</div>
}
icon={Factory}
@@ -488,10 +292,7 @@ export default function ProductionOrderDetailPage() {
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
{productionOrder.status !== "completed" &&
productionOrder.workOrders.length === 0 &&
productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<Button onClick={handleCreateWorkOrder}>
<ClipboardList className="h-4 w-4 mr-2" />
@@ -503,7 +304,7 @@ export default function ProductionOrderDetailPage() {
<div className="space-y-6">
{/* 공정 진행 현황 */}
<ProcessProgress workOrders={productionOrder.workOrders} />
<ProcessProgress workOrders={detail.workOrders} />
{/* 기본 정보 & 거래처/현장 정보 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -514,11 +315,10 @@ export default function ProductionOrderDetailPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
<InfoItem label="납기일" value={productionOrder.dueDate} />
<InfoItem label="수량" value={`${productionOrder.quantity}`} />
<InfoItem label="수주번호" value={detail.orderNumber} />
<InfoItem label="생산지시일" value={detail.productionOrderedAt} />
<InfoItem label="납기일" value={detail.deliveryDate} />
<InfoItem label="개소" value={`${formatNumber(detail.nodeCount)}개소`} />
</div>
</CardContent>
</Card>
@@ -530,112 +330,108 @@ export default function ProductionOrderDetailPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="거래처" value={productionOrder.client} />
<InfoItem label="현장명" value={productionOrder.siteName} />
<InfoItem label="제품유형" value={productionOrder.productType} />
<InfoItem label="거래처" value={detail.clientName} />
<InfoItem label="현장명" value={detail.siteName} />
</div>
</CardContent>
</Card>
</div>
{/* BOM 품목별 공정 분류 */}
<Card>
<CardHeader>
<CardTitle className="text-base">BOM </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 절곡 부품 전개도 정보 헤더 */}
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
</p>
{/* 공정별 테이블 */}
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
<div key={group.processName} className="space-y-2">
{/* 공정명 헤더 */}
<h4 className="text-sm font-semibold">
{group.processName}
{group.sizeSpec && (
<span className="ml-2 text-muted-foreground font-normal">
{group.sizeSpec}
</span>
)}
</h4>
{/* BOM 품목 테이블 */}
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>LOT NO</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-center font-medium">
{item.itemCode}
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">
{item.spec || "-"}
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.lotNo}
</code>
</TableCell>
<TableCell className="text-right">
{item.requiredQty > 0 ? formatNumber(item.requiredQty) : "-"}
</TableCell>
<TableCell className="text-center">{item.qty}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* BOM 품목별 공정 분류 (접이식) */}
{detail.bomProcessGroups.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setBomOpen((prev) => !prev)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
BOM
<span className="ml-2 text-sm font-normal text-muted-foreground">
({detail.bomProcessGroups.length} )
</span>
</CardTitle>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform ${
bomOpen ? "rotate-180" : ""
}`}
/>
</div>
))}
</CardHeader>
{bomOpen && (
<CardContent className="space-y-6 pt-0">
{detail.bomProcessGroups.map((group) => (
<div key={group.processName} className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
<Badge variant="outline">{group.processName}</Badge>
<span className="text-muted-foreground font-normal text-xs">
{group.items.length}
</span>
</h4>
{/* 합계 정보 */}
<div className="flex justify-between items-center pt-4 border-t text-sm">
<span className="text-muted-foreground"> 종류: 18개</span>
<span className="text-muted-foreground"> 중량: 25.8 kg</span>
<span className="text-muted-foreground">비고: VT칼 </span>
</div>
</CardContent>
</Card>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item, idx) => (
<TableRow key={`${item.id}-${idx}`}>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.itemCode}
</code>
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">
{item.spec || "-"}
</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
<TableCell className="text-right">{formatNumber(item.unitPrice)}</TableCell>
<TableCell className="text-right">{formatNumber(item.totalPrice)}</TableCell>
<TableCell className="text-muted-foreground text-xs">{item.nodeName || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
))}
</CardContent>
)}
</Card>
)}
{/* 작업지시서 목록 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
{productionOrder.status !== "completed" &&
productionOrder.workOrders.length === 0 &&
productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<Button onClick={handleCreateWorkOrder}>
<Play className="h-4 w-4 mr-2" />
{productionOrder.pendingWorkOrderCount > 1
? "작업지시 일괄생성"
: "작업지시 생성"}
</Button>
)}
</CardHeader>
<CardContent>
{productionOrder.workOrders.length === 0 ? (
{!hasWorkOrders ? (
<div className="text-center py-8">
<div className="flex flex-col items-center gap-2">
<ClipboardList className="h-12 w-12 text-gray-300" />
<p className="text-muted-foreground text-sm">
.
</p>
{productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<p className="text-sm text-muted-foreground">
BOM .
</p>
@@ -649,23 +445,23 @@ export default function ProductionOrderDetailPage() {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productionOrder.workOrders.map((wo) => (
{detail.workOrders.map((wo) => (
<TableRow key={wo.id}>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{wo.workOrderNumber}
{wo.workOrderNo}
</code>
</TableCell>
<TableCell>{wo.process}</TableCell>
<TableCell className="text-center">{wo.quantity}</TableCell>
<TableCell>{wo.processName}</TableCell>
<TableCell className="text-center">{wo.quantity}</TableCell>
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
<TableCell>{wo.assignee}</TableCell>
<TableCell>{wo.assignees.length > 0 ? wo.assignees.join(", ") : "-"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -676,7 +472,7 @@ export default function ProductionOrderDetailPage() {
</Card>
</div>
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
{/* 작업지시 생성 확인 다이얼로그 */}
<ConfirmDialog
open={isCreateWorkOrderDialogOpen}
onOpenChange={setIsCreateWorkOrderDialogOpen}
@@ -685,19 +481,10 @@ export default function ProductionOrderDetailPage() {
description={
<div className="space-y-4 pt-2">
<p className="font-medium text-foreground">
:
.
</p>
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
<li key={process.id} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
{process.name} ({process.quantity})
</li>
))}
</ul>
)}
<p className="text-muted-foreground">
BOM .
.
</p>
</div>
@@ -706,7 +493,7 @@ export default function ProductionOrderDetailPage() {
loading={isCreating}
/>
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
{/* 작업지시 생성 성공 다이얼로그 */}
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
@@ -716,24 +503,9 @@ export default function ProductionOrderDetailPage() {
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="font-medium text-foreground">
{createdWorkOrders.length} .
.
</span>
</div>
<div>
<p className="text-sm font-medium text-foreground mb-2"> :</p>
{createdWorkOrders.length > 0 ? (
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
{createdWorkOrders.map((wo, idx) => (
<li key={wo} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
{wo}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">-</p>
)}
</div>
<p className="text-muted-foreground">
.
</p>
@@ -749,4 +521,4 @@ export default function ProductionOrderDetailPage() {
</AlertDialog>
</PageLayout>
);
}
}

View File

@@ -4,24 +4,20 @@
* 생산지시 목록 페이지
*
* - 수주관리 > 생산지시 보기에서 접근
* - 진행 단계 바
* - 진행 단계 바 (Order 상태 기반 동적)
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
* - IntegratedListTemplateV2 템플릿 적용
* - 서버사이드 페이지네이션
*/
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -29,7 +25,6 @@ import {
ArrowLeft,
CheckCircle2,
Eye,
Trash2,
} from "lucide-react";
import {
UniversalListPage,
@@ -39,136 +34,63 @@ import {
} from "@/components/templates/UniversalListPage";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
// 생산지시 상태 타입
type ProductionOrderStatus =
| "waiting" // 생산대기
| "in_progress" // 생산중
| "completed"; // 생산완료
// 생산지시 데이터 타입
interface ProductionOrder {
id: string;
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
orderNumber: string; // KD-TS-XXXXXX-XX
siteName: string;
client: string;
quantity: number;
dueDate: string;
productionOrderDate: string;
status: ProductionOrderStatus;
workOrderCount: number;
}
// 샘플 생산지시 데이터
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
{
id: "PO-001",
productionOrderNumber: "PO-KD-TS-251217-07",
orderNumber: "KD-TS-251217-07",
siteName: "씨밋 광교 센트럴시티",
client: "호반건설(주)",
quantity: 2,
dueDate: "2026-02-15",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 3,
},
{
id: "PO-002",
productionOrderNumber: "PO-KD-TS-251217-09",
orderNumber: "KD-TS-251217-09",
siteName: "데시앙 동탄 파크뷰",
client: "태영건설(주)",
quantity: 10,
dueDate: "2026-02-10",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 0,
},
{
id: "PO-003",
productionOrderNumber: "PO-KD-TS-251217-06",
orderNumber: "KD-TS-251217-06",
siteName: "예술 검실 푸르지오",
client: "롯데건설(주)",
quantity: 1,
dueDate: "2026-02-10",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 0,
},
{
id: "PO-004",
productionOrderNumber: "PO-KD-BD-251220-35",
orderNumber: "KD-BD-251220-35",
siteName: "[코레타스프] 판교 물류센터 철거현장",
client: "현대건설(주)",
quantity: 3,
dueDate: "2026-02-03",
productionOrderDate: "2025-12-20",
status: "in_progress",
workOrderCount: 2,
},
{
id: "PO-005",
productionOrderNumber: "PO-KD-BD-251219-34",
orderNumber: "KD-BD-251219-34",
siteName: "[코레타스프1] 김포 6차 필라테스장",
client: "신성플랜(주)",
quantity: 2,
dueDate: "2026-01-15",
productionOrderDate: "2025-12-19",
status: "in_progress",
workOrderCount: 3,
},
{
id: "PO-006",
productionOrderNumber: "PO-KD-TS-250401-29",
orderNumber: "KD-TS-250401-29",
siteName: "포레나 전주",
client: "한화건설(주)",
quantity: 2,
dueDate: "2025-05-16",
productionOrderDate: "2025-04-01",
status: "completed",
workOrderCount: 3,
},
{
id: "PO-007",
productionOrderNumber: "PO-KD-BD-250331-28",
orderNumber: "KD-BD-250331-28",
siteName: "포레나 수원",
client: "포레나건설(주)",
quantity: 4,
dueDate: "2025-05-15",
productionOrderDate: "2025-03-31",
status: "completed",
workOrderCount: 3,
},
{
id: "PO-008",
productionOrderNumber: "PO-KD-TS-250314-23",
orderNumber: "KD-TS-250314-23",
siteName: "자이 흑산파크",
client: "GS건설(주)",
quantity: 3,
dueDate: "2025-04-28",
productionOrderDate: "2025-03-14",
status: "completed",
workOrderCount: 3,
},
];
import {
getProductionOrders,
getProductionOrderStats,
} from "@/components/production/ProductionOrders/actions";
import type {
ProductionOrder,
ProductionStatus,
ProductionOrderStats,
} from "@/components/production/ProductionOrders/types";
import { formatNumber } from '@/lib/utils/amount';
// 진행 단계 컴포넌트
function ProgressSteps() {
const steps = [
{ label: "수주확정", active: true, completed: true },
{ label: "생산지시", active: true, completed: false },
{ label: "작업지시", active: false, completed: false },
{ label: "생산", active: false, completed: false },
{ label: "검사출하", active: false, completed: false },
];
function ProgressSteps({ statusCode }: { statusCode?: string }) {
const getSteps = () => {
// 기본: 생산지시 목록에 있으면 수주확정, 생산지시는 이미 완료
const steps = [
{ label: "수주확정", completed: true, active: false },
{ label: "생산지시", completed: true, active: false },
{ label: "작업지시", completed: false, active: false },
{ label: "생산", completed: false, active: false },
{ label: "검사출하", completed: false, active: false },
];
if (!statusCode) return steps;
// IN_PROGRESS = 생산대기 (작업지시 배정 진행 중)
if (statusCode === "IN_PROGRESS") {
steps[2].active = true;
}
// IN_PRODUCTION = 생산중
if (statusCode === "IN_PRODUCTION") {
steps[2].completed = true;
steps[3].active = true;
}
// PRODUCED = 생산완료
if (statusCode === "PRODUCED") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].active = true;
}
// SHIPPING = 출하중
if (statusCode === "SHIPPING") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].active = true;
}
// SHIPPED = 출하완료
if (statusCode === "SHIPPED") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].completed = true;
}
return steps;
};
const steps = getSteps();
return (
<div className="flex items-center justify-center gap-2 py-4">
@@ -214,16 +136,16 @@ function ProgressSteps() {
}
// 상태 배지 헬퍼
function getStatusBadge(status: ProductionOrderStatus) {
function getStatusBadge(status: ProductionStatus) {
const config: Record<
ProductionOrderStatus,
ProductionStatus,
{ label: string; className: string }
> = {
waiting: {
label: "생산대기",
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
},
in_progress: {
in_production: {
label: "생산중",
className: "bg-green-100 text-green-700 border-green-200",
},
@@ -239,13 +161,12 @@ function getStatusBadge(status: ProductionOrderStatus) {
// 테이블 컬럼 정의
const TABLE_COLUMNS: TableColumn[] = [
{ key: "no", label: "번호", className: "w-[60px] text-center" },
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
{ key: "orderNumber", label: "수주번호", className: "min-w-[150px]" },
{ key: "siteName", label: "현장명", className: "min-w-[180px]" },
{ key: "client", label: "거래처", className: "min-w-[120px]" },
{ key: "quantity", label: "수량", className: "w-[80px] text-center" },
{ key: "dueDate", label: "납기", className: "w-[110px]" },
{ key: "productionOrderDate", label: "생산지시일", className: "w-[110px]" },
{ key: "clientName", label: "거래처", className: "min-w-[120px]" },
{ key: "nodeCount", label: "개소", className: "w-[80px] text-center" },
{ key: "deliveryDate", label: "납기", className: "w-[110px]" },
{ key: "productionOrderedAt", label: "생산지시일", className: "w-[110px]" },
{ key: "status", label: "상태", className: "w-[100px]" },
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
@@ -253,65 +174,21 @@ const TABLE_COLUMNS: TableColumn[] = [
export default function ProductionOrdersListPage() {
const router = useRouter();
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 삭제 확인 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
// 필터링된 데이터
const filteredData = orders.filter((item) => {
// 탭 필터
if (activeTab !== "all") {
const statusMap: Record<string, ProductionOrderStatus> = {
waiting: "waiting",
in_progress: "in_progress",
completed: "completed",
};
if (item.status !== statusMap[activeTab]) return false;
}
// 검색 필터
if (searchTerm) {
const term = searchTerm.toLowerCase();
return (
item.productionOrderNumber.toLowerCase().includes(term) ||
item.orderNumber.toLowerCase().includes(term) ||
item.siteName.toLowerCase().includes(term) ||
item.client.toLowerCase().includes(term)
);
}
return true;
const [stats, setStats] = useState<ProductionOrderStats>({
total: 0,
waiting: 0,
in_production: 0,
completed: 0,
});
// 페이지네이션
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
const paginatedData = filteredData.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// 탭별 건수
const tabCounts = {
all: orders.length,
waiting: orders.filter((i) => i.status === "waiting").length,
in_progress: orders.filter((i) => i.status === "in_progress").length,
completed: orders.filter((i) => i.status === "completed").length,
};
// 탭 옵션
const tabs: TabOption[] = [
{ value: "all", label: "전체", count: tabCounts.all },
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
];
// 통계 로드
useEffect(() => {
getProductionOrderStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}, []);
const handleBack = () => {
router.push("/sales/order-management-sales");
@@ -325,57 +202,13 @@ export default function ProductionOrdersListPage() {
router.push(`/sales/order-management-sales/production-orders/${item.id}?mode=view`);
};
// 개별 삭제 다이얼로그 열기
const handleDelete = (item: ProductionOrder) => {
setDeleteTargetId(item.id);
setShowDeleteDialog(true);
};
// 체크박스 선택
const toggleSelection = (id: string) => {
const newSelection = new Set(selectedItems);
if (newSelection.has(id)) {
newSelection.delete(id);
} else {
newSelection.add(id);
}
setSelectedItems(newSelection);
};
const toggleSelectAll = () => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
}
};
// 일괄 삭제 다이얼로그 열기
const handleBulkDelete = () => {
if (selectedItems.size > 0) {
setDeleteTargetId(null); // 일괄 삭제
setShowDeleteDialog(true);
}
};
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
// 실제 삭제 실행
const handleConfirmDelete = () => {
if (deleteTargetId) {
// 개별 삭제
setOrders(orders.filter((o) => o.id !== deleteTargetId));
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
} else {
// 일괄 삭제
const selectedIds = Array.from(selectedItems);
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
setSelectedItems(new Set());
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
};
// 탭 옵션 (통계 기반 동적 카운트)
const tabs: TabOption[] = [
{ value: "all", label: "전체", count: stats.total },
{ value: "waiting", label: "생산대기", count: stats.waiting, color: "yellow" },
{ value: "in_production", label: "생산중", count: stats.in_production, color: "green" },
{ value: "completed", label: "생산완료", count: stats.completed, color: "gray" },
];
// 테이블 행 렌더링
const renderTableRow = (
@@ -402,22 +235,17 @@ export default function ProductionOrdersListPage() {
</TableCell>
<TableCell>
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
{item.productionOrderNumber}
</code>
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.orderNumber}
</code>
</TableCell>
<TableCell className="max-w-[200px] truncate">
{item.siteName}
</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
<TableCell>{item.dueDate}</TableCell>
<TableCell>{item.productionOrderDate}</TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell>{item.clientName}</TableCell>
<TableCell className="text-center">{formatNumber(item.nodeCount)}</TableCell>
<TableCell>{item.deliveryDate}</TableCell>
<TableCell>{item.productionOrderedAt}</TableCell>
<TableCell>{getStatusBadge(item.productionStatus)}</TableCell>
<TableCell className="text-center">
{item.workOrderCount > 0 ? (
<Badge variant="outline">{item.workOrderCount}</Badge>
@@ -431,9 +259,6 @@ export default function ProductionOrdersListPage() {
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
@@ -463,19 +288,19 @@ export default function ProductionOrdersListPage() {
variant="outline"
className="bg-blue-50 text-blue-700 font-mono text-xs"
>
{item.productionOrderNumber}
{item.orderNumber}
</Badge>
{getStatusBadge(item.status)}
{getStatusBadge(item.productionStatus)}
</>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="수주번호" value={item.orderNumber} />
<InfoField label="현장명" value={item.siteName} />
<InfoField label="거래처" value={item.client} />
<InfoField label="수량" value={`${item.quantity}`} />
<InfoField label="납기" value={item.dueDate} />
<InfoField label="생산지시일" value={item.productionOrderDate} />
<InfoField label="거래처" value={item.clientName} />
<InfoField label="개소" value={`${formatNumber(item.nodeCount)}`} />
<InfoField label="납기" value={item.deliveryDate} />
<InfoField label="생산지시일" value={item.productionOrderedAt} />
<InfoField
label="작업지시"
value={item.workOrderCount > 0 ? `${item.workOrderCount}` : "-"}
@@ -497,18 +322,6 @@ export default function ProductionOrdersListPage() {
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
@@ -516,6 +329,43 @@ export default function ProductionOrdersListPage() {
);
};
// getList API 호출
const getList = useCallback(async (params?: { page?: number; pageSize?: number; search?: string; tab?: string }) => {
const productionStatus = params?.tab && params.tab !== "all"
? (params.tab as ProductionStatus)
: undefined;
const result = await getProductionOrders({
search: params?.search,
productionStatus,
page: params?.page,
perPage: params?.pageSize,
});
if (result.success) {
// 통계 새로고침
getProductionOrderStats().then((statsResult) => {
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
});
return {
success: true,
data: result.data,
totalCount: result.pagination?.total || 0,
totalPages: result.pagination?.lastPage || 1,
};
}
return {
success: false,
data: [],
totalCount: 0,
error: result.error,
};
}, []);
// ===== UniversalListPage 설정 =====
const productionOrderConfig: UniversalListConfig<ProductionOrder> = {
title: "생산지시 목록",
@@ -525,43 +375,19 @@ export default function ProductionOrdersListPage() {
idField: "id",
actions: {
getList: async () => ({
success: true,
data: orders,
totalCount: orders.length,
}),
getList,
},
columns: TABLE_COLUMNS,
tabs: tabs,
defaultTab: activeTab,
defaultTab: "all",
searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...",
searchPlaceholder: "수주번호, 현장명, 거래처 검색...",
itemsPerPage,
itemsPerPage: 20,
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const term = searchValue.toLowerCase();
return (
item.productionOrderNumber.toLowerCase().includes(term) ||
item.orderNumber.toLowerCase().includes(term) ||
item.siteName.toLowerCase().includes(term) ||
item.client.toLowerCase().includes(term)
);
},
tabFilter: (item, tabValue) => {
if (tabValue === "all") return true;
const statusMap: Record<string, ProductionOrderStatus> = {
waiting: "waiting",
in_progress: "in_progress",
completed: "completed",
};
return item.status === statusMap[tabValue];
},
clientSideFiltering: false,
headerActions: () => (
<Button variant="outline" onClick={handleBack}>
@@ -580,50 +406,11 @@ export default function ProductionOrdersListPage() {
renderTableRow,
renderMobileCard,
renderDialogs: () => (
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="삭제 확인"
description={
<>
<strong>{deleteCount}</strong> ?
<br />
<span className="text-muted-foreground text-sm">
. .
</span>
</>
}
/>
),
};
return (
<UniversalListPage<ProductionOrder>
config={productionOrderConfig}
initialData={orders}
initialTotalCount={orders.length}
externalSelection={{
selectedItems,
onToggleSelection: toggleSelection,
onToggleSelectAll: toggleSelectAll,
setSelectedItems,
getItemId: (item: ProductionOrder) => item.id,
}}
onTabChange={(value: string) => {
setActiveTab(value);
setCurrentPage(1);
}}
onSearchChange={setSearchTerm}
externalPagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
);
}
}

View File

@@ -35,6 +35,9 @@ import {
type InspectionTemplateResponse,
type DocumentResolveResponse,
} from './actions';
import { captureRenderedHtml } from '@/lib/utils/capture-rendered-html';
import { ImportInspectionDocument } from '@/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument';
import type { ImportInspectionTemplate, InspectionItemValue } from '@/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument';
// ===== Props =====
interface ImportInspectionInputModalProps {
@@ -636,7 +639,37 @@ export function ImportInspectionInputModal({
})),
];
// 4. 저장 API 호출
// 4. 성적서 문서를 오프스크린 렌더링하여 HTML 스냅샷 캡처 (MNG 출력용)
let renderedHtml: string | undefined;
try {
// 현재 입력값을 ImportInspectionDocument의 initialValues 형식으로 변환
const docValues: InspectionItemValue[] = template.inspectionItems
.filter(i => i.isFirstInItem !== false)
.map(item => ({
itemId: item.id,
measurements: Array.from({ length: item.measurementCount }, (_, n) => {
if (item.measurementType === 'okng') {
const v = okngValues[item.id]?.[n];
return v === 'ok' ? ('OK' as const) : v === 'ng' ? ('NG' as const) : null;
}
const v = measurements[item.id]?.[n];
return v ? Number(v) : null;
}),
result: getItemResult(item) === 'ok' ? ('OK' as const) : getItemResult(item) === 'ng' ? ('NG' as const) : null,
}));
// 성적서 문서 컴포넌트를 오프스크린에서 렌더링
renderedHtml = captureRenderedHtml(
<ImportInspectionDocument
template={template as unknown as ImportInspectionTemplate}
initialValues={docValues}
readOnly
/>
);
} catch {
// 캡처 실패 시 무시 — rendered_html 없이 저장 진행
}
// 5. 저장 API 호출
const result = await saveInspectionData({
templateId: parseInt(template.templateId),
itemId,
@@ -645,6 +678,7 @@ export function ImportInspectionInputModal({
attachments,
receivingId,
inspectionResult: overallResult,
rendered_html: renderedHtml,
});
if (result.success) {

View File

@@ -1874,6 +1874,7 @@ export async function saveInspectionData(params: {
attachments?: Array<{ file_id: number; attachment_type: string; description?: string }>;
receivingId: string;
inspectionResult?: 'pass' | 'fail' | null;
rendered_html?: string;
}): Promise<{
success: boolean;
error?: string;
@@ -1889,6 +1890,7 @@ export async function saveInspectionData(params: {
title: params.title || '수입검사 성적서',
data: params.data,
attachments: params.attachments || [],
rendered_html: params.rendered_html,
},
errorMessage: '검사 데이터 저장에 실패했습니다.',
});

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, cloneElement, isValidElement } from 'react';
import { Search, X, Loader2 } from 'lucide-react';
import {
@@ -38,6 +38,7 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
listWrapper,
infoText,
mode,
isItemDisabled,
} = props;
const {
@@ -88,15 +89,20 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
});
}, []);
// 전체선택 토글
// 전체선택 토글 (비활성 아이템 제외)
const handleToggleAll = useCallback(() => {
const targetItems = isItemDisabled
? items.filter((item) => !isItemDisabled(item, items.filter((i) => selectedIds.has(keyExtractor(i)))))
: items;
setSelectedIds((prev) => {
if (prev.size === items.length) {
const targetIds = targetItems.map((item) => keyExtractor(item));
const allSelected = targetIds.every((id) => prev.has(id));
if (allSelected) {
return new Set();
}
return new Set(items.map((item) => keyExtractor(item)));
return new Set(targetIds);
});
}, [items, keyExtractor]);
}, [items, keyExtractor, isItemDisabled, selectedIds]);
// 다중선택 확인
const handleConfirm = useCallback(() => {
@@ -107,16 +113,34 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
}
}, [mode, items, selectedIds, keyExtractor, props, onOpenChange]);
// 선택된 아이템 목록 (isItemDisabled 콜백용)
const selectedItems = useCallback(() => {
return items.filter((item) => selectedIds.has(keyExtractor(item)));
}, [items, selectedIds, keyExtractor]);
// 비활성 판정
const checkDisabled = useCallback((item: T) => {
if (!isItemDisabled) return false;
// 이미 선택된 아이템은 disabled가 아님 (해제 가능해야 함)
if (selectedIds.has(keyExtractor(item))) return false;
return isItemDisabled(item, selectedItems());
}, [isItemDisabled, selectedIds, keyExtractor, selectedItems]);
// 클릭 핸들러: 모드에 따라 분기
const handleItemClick = useCallback((item: T) => {
if (checkDisabled(item)) return;
if (mode === 'single') {
handleSingleSelect(item);
} else {
handleToggle(keyExtractor(item));
}
}, [mode, handleSingleSelect, handleToggle, keyExtractor]);
}, [mode, handleSingleSelect, handleToggle, keyExtractor, checkDisabled]);
const isAllSelected = items.length > 0 && selectedIds.size === items.length;
// 전체선택 (비활성 아이템 제외)
const enabledItems = isItemDisabled
? items.filter((item) => !checkDisabled(item))
: items;
const isAllSelected = enabledItems.length > 0 && enabledItems.every((item) => selectedIds.has(keyExtractor(item)));
const isSelected = (item: T) => selectedIds.has(keyExtractor(item));
// 빈 상태 메시지 결정
@@ -156,11 +180,42 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
);
}
const itemElements = items.map((item) => (
<div key={keyExtractor(item)} onClick={() => handleItemClick(item)} className="cursor-pointer">
{renderItem(item, isSelected(item))}
</div>
));
const itemElements = items.map((item) => {
const key = keyExtractor(item);
const disabled = checkDisabled(item);
const rendered = renderItem(item, isSelected(item), disabled);
// renderItem이 유효한 React 엘리먼트를 반환하면 key와 onClick을 직접 주입 (div 래핑 없이)
// 이렇게 하면 <TableRow> 등 테이블 요소를 <div>로 감싸는 HTML 유효성 에러를 방지
if (isValidElement(rendered)) {
return cloneElement(rendered as React.ReactElement<Record<string, unknown>>, {
key,
onClick: (e: React.MouseEvent) => {
if (disabled) return;
const existingOnClick = (rendered.props as Record<string, unknown>)?.onClick;
if (typeof existingOnClick === 'function') {
(existingOnClick as (e: React.MouseEvent) => void)(e);
}
handleItemClick(item);
},
className: [
disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
(rendered.props as Record<string, unknown>)?.className || '',
].filter(Boolean).join(' '),
});
}
// 일반 텍스트/fragment인 경우 기존 div 래핑 유지
return (
<div
key={key}
onClick={disabled ? undefined : () => handleItemClick(item)}
className={disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}
>
{rendered}
</div>
);
});
if (listWrapper) {
const selectState = mode === 'multiple'

View File

@@ -17,8 +17,10 @@ interface BaseProps<T> {
fetchData: (query: string) => Promise<T[]>;
/** 고유 키 추출 */
keyExtractor: (item: T) => string;
/** 아이템 렌더링 */
renderItem: (item: T, isSelected: boolean) => ReactNode;
/** 아이템 렌더링 (isDisabled: 비활성 상태) */
renderItem: (item: T, isSelected: boolean, isDisabled?: boolean) => ReactNode;
/** 아이템 비활성 조건 (선택된 아이템 목록 기반) */
isItemDisabled?: (item: T, selectedItems: T[]) => boolean;
// 검색 설정
/** 검색 모드: debounce(자동) vs enter(수동) */

View File

@@ -0,0 +1,115 @@
'use server';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type {
ApiProductionOrder,
ApiProductionOrderDetail,
ProductionOrder,
ProductionOrderDetail,
ProductionOrderStats,
ProductionOrderListParams,
ProductionStatus,
} from './types';
// ===== 변환 함수 =====
function formatDateOnly(dateStr: string | null | undefined): string {
if (!dateStr) return '-';
// ISO "2026-02-21T18:12:31.000000Z" 또는 "2026-02-22 03:12:31" 형식 모두 지원
return dateStr.split(/[T ]/)[0];
}
function transformApiToFrontend(data: ApiProductionOrder): ProductionOrder {
return {
id: String(data.id),
orderNumber: data.order_no,
siteName: data.site_name || '',
clientName: data.client_name || data.client?.name || '',
quantity: parseFloat(String(data.quantity)) || 0,
nodeCount: data.node_count || data.nodes_count || 0,
deliveryDate: data.delivery_date || '',
productionOrderedAt: formatDateOnly(data.production_ordered_at),
productionStatus: data.production_status,
workOrderCount: data.work_orders_count,
workOrderProgress: {
total: data.work_order_progress?.total || 0,
completed: data.work_order_progress?.completed || 0,
inProgress: data.work_order_progress?.in_progress || 0,
},
};
}
function transformDetailApiToFrontend(data: ApiProductionOrderDetail): ProductionOrderDetail {
const order = transformApiToFrontend(data.order);
return {
...order,
productionOrderedAt: formatDateOnly(data.production_ordered_at) || order.productionOrderedAt,
productionStatus: data.production_status || order.productionStatus,
nodeCount: data.node_count || order.nodeCount,
workOrderProgress: {
total: data.work_order_progress?.total || 0,
completed: data.work_order_progress?.completed || 0,
inProgress: data.work_order_progress?.in_progress || 0,
},
workOrders: (data.work_orders || []).map((wo) => ({
id: wo.id,
workOrderNo: wo.work_order_no,
processName: wo.process_name,
quantity: wo.quantity,
status: wo.status,
assignees: wo.assignees || [],
})),
bomProcessGroups: (data.bom_process_groups || []).map((group) => ({
processName: group.process_name,
sizeSpec: group.size_spec,
items: (group.items || []).map((item) => ({
id: item.id,
itemCode: item.item_code,
itemName: item.item_name,
spec: item.spec || item.specification || '',
unit: item.unit || '',
quantity: item.quantity ?? 0,
unitPrice: item.unit_price ?? 0,
totalPrice: item.total_price ?? 0,
nodeName: item.node_name || '',
})),
})),
};
}
// ===== Server Actions =====
// 목록 조회
export async function getProductionOrders(params: ProductionOrderListParams) {
return executePaginatedAction<ApiProductionOrder, ProductionOrder>({
url: buildApiUrl('/api/v1/production-orders', {
search: params.search,
production_status: params.productionStatus,
sort_by: params.sortBy,
sort_dir: params.sortDir,
page: params.page,
per_page: params.perPage,
}),
transform: transformApiToFrontend,
errorMessage: '생산지시 목록 조회에 실패했습니다.',
});
}
// 상태별 통계
export async function getProductionOrderStats() {
return executeServerAction<ProductionOrderStats>({
url: buildApiUrl('/api/v1/production-orders/stats'),
errorMessage: '생산지시 통계 조회에 실패했습니다.',
});
}
// 상세 조회
export async function getProductionOrderDetail(orderId: string) {
return executeServerAction<ApiProductionOrderDetail, ProductionOrderDetail>({
url: buildApiUrl(`/api/v1/production-orders/${orderId}`),
transform: transformDetailApiToFrontend,
errorMessage: '생산지시 상세 조회에 실패했습니다.',
});
}

View File

@@ -0,0 +1,141 @@
// 생산지시 상태 (프론트 탭용)
export type ProductionStatus = 'waiting' | 'in_production' | 'completed';
// API 응답 타입 (snake_case)
export interface ApiProductionOrder {
id: number;
order_no: string;
site_name: string;
client_name: string;
quantity: number;
node_count: number;
delivery_date: string | null;
status_code: string;
production_ordered_at: string | null;
production_status: ProductionStatus;
work_orders_count: number;
nodes_count: number;
work_order_progress: {
total: number;
completed: number;
in_progress: number;
};
client?: {
id: number;
name: string;
};
}
// 프론트 타입 (camelCase)
export interface ProductionOrder {
id: string;
orderNumber: string;
siteName: string;
clientName: string;
quantity: number;
nodeCount: number;
deliveryDate: string;
productionOrderedAt: string;
productionStatus: ProductionStatus;
workOrderCount: number;
workOrderProgress: {
total: number;
completed: number;
inProgress: number;
};
}
// 생산지시 통계
export interface ProductionOrderStats {
total: number;
waiting: number;
in_production: number;
completed: number;
}
// 생산지시 상세 API 응답
export interface ApiProductionOrderDetail {
order: ApiProductionOrder;
production_ordered_at: string | null;
production_status: ProductionStatus;
node_count: number;
work_order_progress: {
total: number;
completed: number;
in_progress: number;
};
work_orders: ApiProductionWorkOrder[];
bom_process_groups: ApiBomProcessGroup[];
}
// 상세 내 작업지시 정보
export interface ApiProductionWorkOrder {
id: number;
work_order_no: string;
process_name: string;
quantity: number;
status: string;
assignees: string[];
}
// BOM 공정 분류
export interface ApiBomProcessGroup {
process_name: string;
size_spec?: string;
items: ApiBomItem[];
}
export interface ApiBomItem {
id: number | null;
item_code: string;
item_name: string;
spec: string;
unit: string;
quantity: number;
unit_price: number;
total_price: number;
node_name: string;
}
// 프론트 상세 타입
export interface ProductionOrderDetail extends ProductionOrder {
workOrders: ProductionWorkOrder[];
bomProcessGroups: BomProcessGroup[];
}
export interface ProductionWorkOrder {
id: number;
workOrderNo: string;
processName: string;
quantity: number;
status: string;
assignees: string[];
}
export interface BomProcessGroup {
processName: string;
sizeSpec?: string;
items: BomItem[];
}
export interface BomItem {
id: number | null;
itemCode: string;
itemName: string;
spec: string;
unit: string;
quantity: number;
unitPrice: number;
totalPrice: number;
nodeName: string;
}
// 조회 파라미터
export interface ProductionOrderListParams {
search?: string;
productionStatus?: ProductionStatus;
sortBy?: string;
sortDir?: 'asc' | 'desc';
page?: number;
perPage?: number;
}

View File

@@ -857,6 +857,7 @@ export async function saveInspectionDocument(
title?: string;
data: Record<string, unknown>[];
approvers?: { role_name: string; user_id?: number }[];
rendered_html?: string;
}
): Promise<{
success: boolean;
@@ -921,6 +922,34 @@ export async function resolveInspectionDocument(
}
}
// ===== 문서 스냅샷 저장 (Lazy Snapshot) =====
export async function patchDocumentSnapshot(
documentId: number,
renderedHtml: string
): Promise<{ success: boolean; error?: string }> {
try {
const { response, error } = await serverFetch(
buildApiUrl(`/api/v1/documents/${documentId}/snapshot`),
{ method: 'PATCH', body: JSON.stringify({ rendered_html: renderedHtml }) }
);
if (error || !response) {
return { success: false, error: error?.message || 'API 요청 실패' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '스냅샷 저장에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderActions] patchDocumentSnapshot error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 문서 결재 상신 =====
export async function submitDocumentForApproval(
documentId: number

View File

@@ -24,6 +24,7 @@ import {
saveInspectionDocument,
resolveInspectionDocument,
submitDocumentForApproval,
patchDocumentSnapshot,
} from '../actions';
import type { WorkOrder, ProcessType } from '../types';
import type { InspectionReportData, InspectionReportNodeGroup } from '../actions';
@@ -164,6 +165,7 @@ export function InspectionReportModal({
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const contentRef = useRef<InspectionContentRef>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null);
// API에서 로딩된 검사 데이터 (props 없을 때 fallback)
const [apiWorkItems, setApiWorkItems] = useState<WorkItemData[] | null>(null);
@@ -183,6 +185,8 @@ export function InspectionReportModal({
const [savedDocumentId, setSavedDocumentId] = useState<number | null>(null);
const [savedDocumentStatus, setSavedDocumentStatus] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Lazy Snapshot 대상 문서 ID (rendered_html이 없는 문서)
const [snapshotDocumentId, setSnapshotDocumentId] = useState<number | null>(null);
// props에서 목업 제외한 실제 개소만 사용 (WorkerScreen에서 apiItems + mockItems가 합쳐져 전달됨)
// ★ 반드시 workItems와 inspectionDataMap을 같은 소스에서 가져와야 key 포맷이 일치함
@@ -296,7 +300,8 @@ export function InspectionReportModal({
// 4) 기존 문서의 document_data EAV 레코드 + ID/상태 추출
if (resolveResult?.success && resolveResult.data) {
const existingDoc = (resolveResult.data as Record<string, unknown>).existing_document as
const resolveData = resolveResult.data as Record<string, unknown>;
const existingDoc = resolveData.existing_document as
| { id?: number; status?: string; data?: Array<{ section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }> }
| null;
if (existingDoc?.data && existingDoc.data.length > 0) {
@@ -307,10 +312,13 @@ export function InspectionReportModal({
// 문서 ID/상태 저장 (결재 상신용)
setSavedDocumentId(existingDoc?.id ?? null);
setSavedDocumentStatus(existingDoc?.status ?? null);
// Lazy Snapshot 대상 문서 ID
setSnapshotDocumentId((resolveData.snapshot_document_id as number) ?? null);
} else {
setDocumentRecords(null);
setSavedDocumentId(null);
setSavedDocumentStatus(null);
setSnapshotDocumentId(null);
}
})
.catch(() => {
@@ -328,10 +336,30 @@ export function InspectionReportModal({
setDocumentRecords(null);
setSavedDocumentId(null);
setSavedDocumentStatus(null);
setSnapshotDocumentId(null);
setError(null);
}
}, [open, workOrderId, processType, templateData]);
// Lazy Snapshot: 콘텐츠 렌더링 완료 후 rendered_html이 없는 문서에 스냅샷 저장
useEffect(() => {
if (!snapshotDocumentId || isLoading || !order) return;
// 콘텐츠 렌더링 대기 후 캡처
const timer = setTimeout(() => {
const html = contentWrapperRef.current?.innerHTML;
if (html && html.length > 50) {
patchDocumentSnapshot(snapshotDocumentId, html).then((result) => {
if (result.success) {
setSnapshotDocumentId(null); // 저장 완료 → 재실행 방지
}
});
}
}, 500); // DOM 렌더링 완료 대기
return () => clearTimeout(timer);
}, [snapshotDocumentId, isLoading, order]);
// 템플릿 결정: prop 우선, 없으면 자체 로딩 결과 사용
const resolvedTemplateData = templateData || selfTemplateData;
const activeTemplate = resolvedTemplateData?.has_template ? resolvedTemplateData.template : null;
@@ -341,6 +369,8 @@ export function InspectionReportModal({
if (!workOrderId || !contentRef.current) return;
const data = contentRef.current.getInspectionData();
// HTML 스냅샷 캡처 (MNG 출력용)
const renderedHtml = contentWrapperRef.current?.innerHTML || undefined;
setIsSaving(true);
try {
// 템플릿 모드: Document 기반 저장 (정규화 형식)
@@ -359,6 +389,7 @@ export function InspectionReportModal({
step_id: activeStepId ?? undefined,
title: activeTemplate.title || activeTemplate.name,
data: inspData.records,
rendered_html: renderedHtml,
});
if (result.success) {
toast.success('검사 문서가 저장되었습니다.');
@@ -530,7 +561,9 @@ export function InspectionReportModal({
)}
</div>
)}
{renderContent()}
<div ref={contentWrapperRef}>
{renderContent()}
</div>
</>
)}
</DocumentViewer>

View File

@@ -11,13 +11,13 @@
* - 양식 미매핑 시 processType 폴백
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Loader2, Save } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { getWorkOrderById, getMaterialInputLots } from '../WorkOrders/actions';
import { saveWorkLog } from './actions';
import { getWorkOrderById, getMaterialInputLots, patchDocumentSnapshot } from '../WorkOrders/actions';
import { saveWorkLog, getWorkLog } from './actions';
import type { MaterialInputLot } from '../WorkOrders/actions';
import type { WorkOrder, ProcessType } from '../WorkOrders/types';
import { WorkLogContent } from './WorkLogContent';
@@ -63,6 +63,9 @@ export function WorkLogModal({
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Lazy Snapshot 대상 문서 ID
const [snapshotDocumentId, setSnapshotDocumentId] = useState<number | null>(null);
// 목업 WorkOrder 생성
const createMockOrder = (id: string, pType?: ProcessType): WorkOrder => ({
@@ -115,8 +118,9 @@ export function WorkLogModal({
Promise.all([
getWorkOrderById(workOrderId),
getMaterialInputLots(workOrderId),
getWorkLog(workOrderId),
])
.then(([orderResult, lotsResult]) => {
.then(([orderResult, lotsResult, workLogResult]) => {
if (orderResult.success && orderResult.data) {
setOrder(orderResult.data);
} else {
@@ -125,6 +129,13 @@ export function WorkLogModal({
if (lotsResult.success) {
setMaterialLots(lotsResult.data);
}
// Lazy Snapshot: 문서가 있고 rendered_html이 없으면 스냅샷 대상
if (workLogResult.success && workLogResult.data?.document) {
const doc = workLogResult.data.document as { id?: number; rendered_html?: string | null };
if (doc.id && !doc.rendered_html) {
setSnapshotDocumentId(doc.id);
}
}
})
.catch(() => {
setError('서버 오류가 발생했습니다.');
@@ -136,10 +147,29 @@ export function WorkLogModal({
// 모달 닫힐 때 상태 초기화
setOrder(null);
setMaterialLots([]);
setSnapshotDocumentId(null);
setError(null);
}
}, [open, workOrderId, processType]);
// Lazy Snapshot: 콘텐츠 렌더링 완료 후 rendered_html이 없는 문서에 스냅샷 저장
useEffect(() => {
if (!snapshotDocumentId || isLoading || !order) return;
const timer = setTimeout(() => {
const html = contentWrapperRef.current?.innerHTML;
if (html && html.length > 50) {
patchDocumentSnapshot(snapshotDocumentId, html).then((result) => {
if (result.success) {
setSnapshotDocumentId(null);
}
});
}
}, 500);
return () => clearTimeout(timer);
}, [snapshotDocumentId, isLoading, order]);
// 저장 핸들러
const handleSave = useCallback(async () => {
if (!workOrderId || !order) return;
@@ -155,9 +185,13 @@ export function WorkLogModal({
unit: item.unit || 'EA',
}));
// HTML 스냅샷 캡처 (MNG 출력용)
const renderedHtml = contentWrapperRef.current?.innerHTML || undefined;
const result = await saveWorkLog(workOrderId, {
table_data: tableData,
title: workLogTemplateName || '작업일지',
rendered_html: renderedHtml,
});
if (result.success) {
@@ -255,7 +289,9 @@ export function WorkLogModal({
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
</div>
) : (
renderContent()
<div ref={contentWrapperRef}>
{renderContent()}
</div>
)}
</DocumentViewer>
);

View File

@@ -744,6 +744,7 @@ export async function saveWorkLog(
table_data?: Array<Record<string, unknown>>;
remarks?: string;
title?: string;
rendered_html?: string;
}
): Promise<{
success: boolean;

View File

@@ -84,19 +84,45 @@ export function InspectionCreate() {
// ===== 수주 선택 처리 =====
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
const newOrderItems: OrderSettingItem[] = items.map((item) => ({
id: item.id,
orderNumber: item.orderNumber,
siteName: item.siteName,
deliveryDate: item.deliveryDate,
floor: '',
symbol: '',
orderWidth: 0,
orderHeight: 0,
constructionWidth: 0,
constructionHeight: 0,
changeReason: '',
}));
const newOrderItems: OrderSettingItem[] = items.flatMap((item) =>
item.locations.length > 0
? item.locations.map((loc) => ({
id: `${item.id}-${loc.nodeId}`,
orderId: Number(item.id),
orderNumber: item.orderNumber,
siteName: item.siteName,
clientId: item.clientId,
clientName: item.clientName,
itemId: item.itemId,
itemName: item.itemName,
deliveryDate: item.deliveryDate,
floor: loc.floor,
symbol: loc.symbol,
orderWidth: loc.orderWidth,
orderHeight: loc.orderHeight,
constructionWidth: 0,
constructionHeight: 0,
changeReason: '',
}))
: [{
id: item.id,
orderId: Number(item.id),
orderNumber: item.orderNumber,
siteName: item.siteName,
clientId: item.clientId,
clientName: item.clientName,
itemId: item.itemId,
itemName: item.itemName,
deliveryDate: item.deliveryDate,
floor: '',
symbol: '',
orderWidth: 0,
orderHeight: 0,
constructionWidth: 0,
constructionHeight: 0,
changeReason: '',
}]
);
setFormData((prev) => ({
...prev,
orderItems: [...prev.orderItems, ...newOrderItems],
@@ -659,12 +685,23 @@ export function InspectionCreate() {
</div>
), [formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen]);
// 이미 선택된 수주 ID 목록
// 이미 선택된 수주 ID 목록 (orderId 기준, 중복 제거)
const excludeOrderIds = useMemo(
() => formData.orderItems.map((item) => item.id),
() => [...new Set(formData.orderItems.map((item) => String(item.orderId ?? item.id)))],
[formData.orderItems]
);
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
const orderFilter = useMemo(() => {
if (formData.orderItems.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
const first = formData.orderItems[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}, [formData.orderItems]);
return (
<>
<IntegratedDetailTemplate
@@ -683,6 +720,9 @@ export function InspectionCreate() {
onOpenChange={setOrderModalOpen}
onSelect={handleOrderSelect}
excludeIds={excludeOrderIds}
filterClientId={orderFilter.clientId}
filterItemId={orderFilter.itemId}
filterLabel={orderFilter.label}
/>
{/* 제품검사 입력 모달 */}

View File

@@ -22,7 +22,6 @@ import {
Trash2,
ChevronDown,
ClipboardCheck,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -50,10 +49,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { inspectionConfig } from './inspectionConfig';
@@ -63,6 +58,7 @@ import {
getInspectionById,
updateInspection,
completeInspection,
saveLocationInspection,
} from './actions';
import { getFqcStatus, type FqcStatusItem } from './fqcActions';
import {
@@ -153,31 +149,54 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
// FQC 상태 데이터 (개소별 진행현황)
const [fqcStatusItems, setFqcStatusItems] = useState<FqcStatusItem[]>([]);
// 파생: 문서 매핑 (orderItemId → documentId)
const fqcDocumentMap = useMemo(() => {
const map: Record<string, number> = {};
fqcStatusItems.forEach((item) => {
if (item.documentId) map[String(item.orderItemId)] = item.documentId;
});
return map;
}, [fqcStatusItems]);
// 파생: 진행현황 통계
const fqcStats = useMemo(() => {
if (fqcStatusItems.length === 0) return null;
return {
total: fqcStatusItems.length,
passed: fqcStatusItems.filter((i) => i.judgement === '합격').length,
failed: fqcStatusItems.filter((i) => i.judgement === '불합격').length,
inProgress: fqcStatusItems.filter((i) => i.documentId != null && !i.judgement).length,
notCreated: fqcStatusItems.filter((i) => i.documentId == null).length,
};
}, [fqcStatusItems]);
// 개소별 검사 상태 집계 (legacy inspectionData + FQC 통합)
const inspectionStats = useMemo(() => {
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
if (items.length === 0) return null;
// 개소별 FQC 상태 조회 헬퍼
const getStatus = (item: OrderSettingItem) => {
// FQC 문서 기반 상태 확인
const fqcItem = fqcStatusItems.find(
(i) => i.floorCode === item.floor && i.symbolCode === item.symbol
);
if (fqcItem?.judgement === '합격') return 'passed';
if (fqcItem?.judgement === '불합격') return 'failed';
if (fqcItem?.documentId) return 'inProgress';
// legacy inspectionData 확인
if (!item.inspectionData) return 'none';
const d = item.inspectionData;
const judgmentFields = [
d.appearanceProcessing, d.appearanceSewing, d.appearanceAssembly,
d.appearanceSmokeBarrier, d.appearanceBottomFinish, d.motor, d.material,
d.lengthJudgment, d.heightJudgment, d.guideRailGap, d.bottomFinishGap,
d.fireResistanceTest, d.smokeLeakageTest, d.openCloseTest, d.impactTest,
];
const inspected = judgmentFields.filter(v => v !== null && v !== undefined);
const hasPhotos = d.productImages && d.productImages.length > 0;
if (inspected.length === 0 && !hasPhotos) return 'none';
if (inspected.length < judgmentFields.length || !hasPhotos) return 'inProgress';
if (inspected.some(v => v === 'fail')) return 'failed';
return 'passed';
};
const statuses = items.map(getStatus);
return {
total: items.length,
passed: statuses.filter(s => s === 'passed').length,
failed: statuses.filter(s => s === 'failed').length,
inProgress: statuses.filter(s => s === 'inProgress').length,
none: statuses.filter(s => s === 'none').length,
};
}, [isEditMode, formData.orderItems, inspection?.orderItems, fqcStatusItems]);
// 개소별 FQC 상태 조회 헬퍼 (floor+symbol 기반 매칭)
const getFqcItemStatus = useCallback(
(orderItemId: string): FqcStatusItem | null => {
return fqcStatusItems.find((i) => String(i.orderItemId) === orderItemId) ?? null;
(item: OrderSettingItem): FqcStatusItem | null => {
return fqcStatusItems.find(
(i) => i.floorCode === item.floor && i.symbolCode === item.symbol
) ?? null;
},
[fqcStatusItems]
);
@@ -316,19 +335,45 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
// ===== 수주 선택/삭제 처리 =====
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
const newOrderItems: OrderSettingItem[] = items.map((item) => ({
id: item.id,
orderNumber: item.orderNumber,
siteName: item.siteName,
deliveryDate: item.deliveryDate,
floor: '',
symbol: '',
orderWidth: 0,
orderHeight: 0,
constructionWidth: 0,
constructionHeight: 0,
changeReason: '',
}));
const newOrderItems: OrderSettingItem[] = items.flatMap((item) =>
item.locations.length > 0
? item.locations.map((loc) => ({
id: `${item.id}-${loc.nodeId}`,
orderId: Number(item.id),
orderNumber: item.orderNumber,
siteName: item.siteName,
clientId: item.clientId,
clientName: item.clientName,
itemId: item.itemId,
itemName: item.itemName,
deliveryDate: item.deliveryDate,
floor: loc.floor,
symbol: loc.symbol,
orderWidth: loc.orderWidth,
orderHeight: loc.orderHeight,
constructionWidth: 0,
constructionHeight: 0,
changeReason: '',
}))
: [{
id: item.id,
orderId: Number(item.id),
orderNumber: item.orderNumber,
siteName: item.siteName,
clientId: item.clientId,
clientName: item.clientName,
itemId: item.itemId,
itemName: item.itemName,
deliveryDate: item.deliveryDate,
floor: '',
symbol: '',
orderWidth: 0,
orderHeight: 0,
constructionWidth: 0,
constructionHeight: 0,
changeReason: '',
}]
);
setFormData((prev) => ({
...prev,
orderItems: [...prev.orderItems, ...newOrderItems],
@@ -342,11 +387,24 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
}));
}, []);
// 이미 선택된 수주 ID 목록 (orderId 기준, 중복 제거)
const excludeOrderIds = useMemo(
() => formData.orderItems.map((item) => item.id),
() => [...new Set(formData.orderItems.map((item) => String(item.orderId ?? item.id)))],
[formData.orderItems]
);
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
const orderFilter = useMemo(() => {
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
if (items.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
const first = items[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}, [isEditMode, formData.orderItems, inspection?.orderItems]);
// ===== 수주 설정 요약 =====
const orderSummary = useMemo(() => {
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
@@ -384,22 +442,46 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
setInspectionInputOpen(true);
}, []);
const handleInspectionComplete = useCallback((data: ProductInspectionData) => {
const handleInspectionComplete = useCallback(async (
data: ProductInspectionData,
constructionInfo?: { width: number | null; height: number | null; changeReason: string }
) => {
if (!selectedOrderItem) return;
// formData의 해당 orderItem에 inspectionData 저장
// 서버에 개소별 검사 데이터 저장
const result = await saveLocationInspection(id, selectedOrderItem.id, data, constructionInfo);
if (!result.success) {
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
return;
}
const updateItem = (item: OrderSettingItem) => {
if (item.id !== selectedOrderItem.id) return item;
const updated = { ...item, inspectionData: data };
if (constructionInfo) {
if (constructionInfo.width !== null) updated.constructionWidth = constructionInfo.width;
if (constructionInfo.height !== null) updated.constructionHeight = constructionInfo.height;
updated.changeReason = constructionInfo.changeReason;
}
return updated;
};
// 로컬 state도 반영
setFormData((prev) => ({
...prev,
orderItems: prev.orderItems.map((item) =>
item.id === selectedOrderItem.id
? { ...item, inspectionData: data }
: item
),
orderItems: prev.orderItems.map(updateItem),
}));
// inspection 데이터도 갱신 (새로고침 없이 반영)
if (inspection) {
setInspection({
...inspection,
orderItems: inspection.orderItems.map(updateItem),
});
}
toast.success('검사 데이터가 저장되었습니다.');
setSelectedOrderItem(null);
}, [selectedOrderItem]);
}, [id, selectedOrderItem, inspection]);
// ===== 시공규격/변경사유 수정 핸들러 (수정 모드) =====
const handleUpdateOrderItemField = useCallback((
@@ -418,25 +500,47 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
// ===== FQC 상태 뱃지 렌더링 =====
const renderFqcBadge = useCallback(
(item: OrderSettingItem) => {
const fqcItem = getFqcItemStatus(item.id);
const fqcItem = getFqcItemStatus(item);
if (!fqcItem) {
// FQC 데이터 없음 → legacy 상태
return item.inspectionData ? (
<Badge className="bg-green-100 text-green-800 border-0"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground"></Badge>
<Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center"></Badge>
);
}
if (fqcItem.judgement === '합격') {
return <Badge className="bg-green-100 text-green-800 border-0"></Badge>;
return <Badge className="bg-green-100 text-green-800 border-0 min-w-[3.5rem] justify-center"></Badge>;
}
if (fqcItem.judgement === '불합격') {
return <Badge className="bg-red-100 text-red-800 border-0"></Badge>;
return <Badge className="bg-red-100 text-red-800 border-0 min-w-[3.5rem] justify-center"></Badge>;
}
if (fqcItem.documentId) {
return <Badge className="bg-blue-100 text-blue-800 border-0"></Badge>;
return <Badge className="bg-blue-100 text-blue-800 border-0 min-w-[3.5rem] justify-center"></Badge>;
}
return <Badge variant="outline" className="text-muted-foreground"></Badge>;
// FQC 문서 없음 → legacy 검사 데이터 확인
if (!item.inspectionData) {
return <Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center"></Badge>;
}
const d = item.inspectionData;
const judgmentFields = [
d.appearanceProcessing, d.appearanceSewing, d.appearanceAssembly,
d.appearanceSmokeBarrier, d.appearanceBottomFinish, d.motor, d.material,
d.lengthJudgment, d.heightJudgment, d.guideRailGap, d.bottomFinishGap,
d.fireResistanceTest, d.smokeLeakageTest, d.openCloseTest, d.impactTest,
];
const inspected = judgmentFields.filter(v => v !== null && v !== undefined);
const hasPhotos = d.productImages && d.productImages.length > 0;
if (inspected.length === 0 && !hasPhotos) {
return <Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center"></Badge>;
}
if (inspected.length < judgmentFields.length || !hasPhotos) {
return <Badge className="bg-blue-100 text-blue-800 border-0 min-w-[3.5rem] justify-center"></Badge>;
}
if (inspected.some(v => v === 'fail')) {
return <Badge className="bg-red-100 text-red-800 border-0 min-w-[3.5rem] justify-center"></Badge>;
}
return <Badge className="bg-green-100 text-green-800 border-0 min-w-[3.5rem] justify-center"></Badge>;
},
[getFqcItemStatus]
);
@@ -451,15 +555,15 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
// ===== FQC 진행현황 통계 바 =====
const renderFqcProgressBar = useMemo(() => {
if (!fqcStats) return null;
const { total, passed, failed, inProgress, notCreated } = fqcStats;
if (!inspectionStats) return null;
const { total, passed, failed, inProgress, none } = inspectionStats;
return (
<div className="space-y-2">
<div className="flex items-center gap-4 text-xs">
<span className="text-green-600 font-medium"> {passed}</span>
<span className="text-red-600 font-medium"> {failed}</span>
<span className="text-blue-600 font-medium"> {inProgress}</span>
<span className="text-muted-foreground"> {notCreated}</span>
<span className="text-muted-foreground"> {none}</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden flex">
{passed > 0 && (
@@ -483,7 +587,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
</div>
</div>
);
}, [fqcStats]);
}, [inspectionStats]);
// ===== 수주 설정 아코디언 (조회 모드) =====
const renderOrderAccordion = (groups: OrderGroup[]) => {
@@ -496,20 +600,16 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
}
return (
<Accordion type="multiple" className="w-full">
<div className="space-y-4">
{groups.map((group, groupIndex) => (
<AccordionItem key={group.orderNumber} value={group.orderNumber} className="border rounded-lg mb-2">
{/* 상위 레벨: 수주번호, 현장명, 납품일, 개소 */}
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-6 text-sm w-full">
<span className="font-medium w-32">{group.orderNumber}</span>
<span className="text-muted-foreground flex-1">{group.siteName}</span>
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
<span className="text-muted-foreground w-16">{group.locationCount}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
{/* 하위 레벨: 테이블 */}
<div key={group.orderNumber} className="border rounded-lg">
<div className="flex items-center gap-6 text-sm px-4 py-3 bg-muted/30 rounded-t-lg">
<span className="font-medium w-32">{group.orderNumber}</span>
<span className="text-muted-foreground flex-1">{group.siteName}</span>
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
<span className="text-muted-foreground w-16">{group.locationCount}</span>
</div>
<div className="px-4 pb-4">
<Table>
<TableHeader>
<TableRow>
@@ -538,15 +638,14 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1.5">
{renderFqcBadge(item)}
{(getFqcItemStatus(item.id) || item.inspectionData) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
{(getFqcItemStatus(item) || item.inspectionData) && (
<Badge
variant="outline"
className="cursor-pointer hover:bg-muted"
onClick={() => handleOpenInspectionInput(item)}
>
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</Badge>
)}
</div>
</TableCell>
@@ -554,10 +653,10 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
))}
</TableBody>
</Table>
</AccordionContent>
</AccordionItem>
</div>
</div>
))}
</Accordion>
</div>
);
};
@@ -572,33 +671,27 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
}
return (
<Accordion type="multiple" className="w-full">
<div className="space-y-4">
{groups.map((group, groupIndex) => (
<AccordionItem key={group.orderNumber} value={group.orderNumber} className="border rounded-lg mb-2">
{/* 상위 레벨: 수주번호, 현장명, 납품일, 개소, 삭제 */}
<div className="flex items-center">
<AccordionTrigger className="px-4 py-3 hover:no-underline flex-1">
<div className="flex items-center gap-6 text-sm w-full">
<span className="font-medium w-32">{group.orderNumber}</span>
<span className="text-muted-foreground flex-1">{group.siteName}</span>
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
<span className="text-muted-foreground w-16">{group.locationCount}</span>
</div>
</AccordionTrigger>
<div key={group.orderNumber} className="border rounded-lg">
<div className="flex items-center gap-6 text-sm px-4 py-3 bg-muted/30 rounded-t-lg">
<span className="font-medium w-32">{group.orderNumber}</span>
<span className="text-muted-foreground flex-1">{group.siteName}</span>
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
<span className="text-muted-foreground w-16">{group.locationCount}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 mr-4 text-muted-foreground hover:text-red-600"
className="h-7 w-7 text-muted-foreground hover:text-red-600"
type="button"
onClick={() => {
// 해당 그룹의 모든 아이템 삭제
group.items.forEach((item) => handleRemoveOrderItem(item.id));
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<AccordionContent className="px-4 pb-4">
<div className="px-4 pb-4">
{/* 하위 레벨: 테이블 (시공규격, 변경사유 편집 가능) */}
<Table>
<TableHeader>
@@ -670,10 +763,10 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
))}
</TableBody>
</Table>
</AccordionContent>
</AccordionItem>
</div>
</div>
))}
</Accordion>
</div>
);
};
@@ -845,8 +938,6 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<CardTitle className="text-base"> </CardTitle>
<div className="flex items-center gap-3 text-sm">
<span>: <strong>{orderSummary.total}</strong></span>
<span className="text-green-600">: <strong>{orderSummary.same}</strong></span>
<span className="text-red-600">: <strong>{orderSummary.changed}</strong></span>
</div>
</div>
{renderFqcProgressBar}
@@ -1151,8 +1242,6 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
</div>
<div className="flex items-center gap-3 text-sm">
<span>: <strong>{orderSummary.total}</strong></span>
<span className="text-green-600">: <strong>{orderSummary.same}</strong></span>
<span className="text-red-600">: <strong>{orderSummary.changed}</strong></span>
</div>
</div>
{renderFqcProgressBar}
@@ -1240,6 +1329,9 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
onOpenChange={setOrderModalOpen}
onSelect={handleOrderSelect}
excludeIds={excludeOrderIds}
filterClientId={orderFilter.clientId}
filterItemId={orderFilter.itemId}
filterLabel={orderFilter.label}
/>
{/* 제품검사요청서 모달 */}
@@ -1247,6 +1339,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
open={requestDocOpen}
onOpenChange={setRequestDocOpen}
data={inspection ? buildRequestDocumentData(inspection) : null}
requestDocumentId={inspection?.requestDocumentId}
/>
{/* 제품검사성적서 모달 */}
@@ -1256,19 +1349,23 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
data={inspection ? buildReportDocumentData(inspection, isEditMode ? formData.orderItems : undefined) : null}
inspection={inspection}
orderItems={isEditMode ? formData.orderItems : inspection?.orderItems}
fqcDocumentMap={Object.keys(fqcDocumentMap).length > 0 ? fqcDocumentMap : undefined}
/>
{/* 제품검사 입력 모달 */}
<ProductInspectionInputModal
open={inspectionInputOpen}
onOpenChange={setInspectionInputOpen}
onOpenChange={(open) => { setInspectionInputOpen(open); if (!open) setSelectedOrderItem(null); }}
orderItemId={selectedOrderItem?.id || ''}
productName="방화셔터"
specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''}
initialData={selectedOrderItem?.inspectionData}
onComplete={handleInspectionComplete}
fqcDocumentId={selectedOrderItem ? fqcDocumentMap[selectedOrderItem.id] ?? null : null}
fqcDocumentId={selectedOrderItem?.documentId ?? null}
constructionWidth={selectedOrderItem?.constructionWidth}
constructionHeight={selectedOrderItem?.constructionHeight}
changeReason={selectedOrderItem?.changeReason}
orderItems={isEditMode ? formData.orderItems : (inspection?.orderItems || [])}
onNavigate={(item) => setSelectedOrderItem(item)}
/>
</>
);

View File

@@ -30,6 +30,12 @@ interface OrderSelectModalProps {
onSelect: (items: OrderSelectItem[]) => void;
/** 이미 선택된 항목 ID 목록 (중복 선택 방지) */
excludeIds?: string[];
/** 같은 거래처만 필터 (이미 선택된 수주의 client_id) */
filterClientId?: number | null;
/** 같은 모델만 필터 (이미 선택된 수주의 item_id) */
filterItemId?: number | null;
/** 필터 안내 텍스트 (예: "발주처A / 방화셔터") */
filterLabel?: string;
}
export function OrderSelectModal({
@@ -37,10 +43,17 @@ export function OrderSelectModal({
onOpenChange,
onSelect,
excludeIds = [],
filterClientId,
filterItemId,
filterLabel,
}: OrderSelectModalProps) {
const handleFetchData = useCallback(async (query: string) => {
try {
const result = await getOrderSelectList({ q: query || undefined });
const result = await getOrderSelectList({
q: query || undefined,
clientId: filterClientId,
itemId: filterItemId,
});
if (result.success) {
return result.data.filter((item) => !excludeIds.includes(item.id));
}
@@ -52,24 +65,32 @@ export function OrderSelectModal({
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
return [];
}
}, [excludeIds]);
}, [excludeIds, filterClientId, filterItemId]);
return (
<SearchableSelectionModal<OrderSelectItem>
open={open}
onOpenChange={onOpenChange}
title="수주 선택"
title={filterLabel ? `수주 선택 — ${filterLabel}` : '수주 선택'}
searchPlaceholder="수주번호, 현장명 검색..."
fetchData={handleFetchData}
keyExtractor={(item) => item.id}
searchMode="enter"
loadOnOpen
dialogClassName="sm:max-w-2xl"
dialogClassName="sm:max-w-3xl"
listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md"
mode="multiple"
onSelect={onSelect}
confirmLabel="선택"
allowSelectAll
isItemDisabled={(item, selectedItems) => {
// 서버 필터가 이미 적용된 경우 모달 내 추가 제한 불필요
if (filterClientId || filterItemId) return false;
// 서버 필터 없이 첫 선택 시 모달 내에서 같은 거래처+모델만 선택 가능
if (selectedItems.length === 0) return false;
const first = selectedItems[0];
return item.clientId !== first.clientId || item.itemId !== first.itemId;
}}
listWrapper={(children, selectState) => (
<Table>
<TableHeader>
@@ -84,23 +105,26 @@ export function OrderSelectModal({
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{children}
{/* 빈 상태는 공통 컴포넌트에서 처리 */}
</TableBody>
</Table>
)}
renderItem={(item, isSelected) => (
<TableRow className="cursor-pointer hover:bg-muted/50">
renderItem={(item, isSelected, isDisabled) => (
<TableRow className={isDisabled ? 'opacity-40' : 'hover:bg-muted/50'}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} />
<Checkbox checked={isSelected} disabled={isDisabled} />
</TableCell>
<TableCell>{item.orderNumber}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell>{item.clientName}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-center">{item.deliveryDate}</TableCell>
<TableCell className="text-center">{item.locationCount}</TableCell>
</TableRow>

View File

@@ -19,15 +19,21 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { getFqcTemplate, getFqcDocument, saveFqcDocument } from './fqcActions';
import type { FqcTemplate, FqcTemplateItem, FqcDocumentData } from './fqcActions';
import type { ProductInspectionData } from './types';
import type { ProductInspectionData, OrderSettingItem } from './types';
type JudgmentValue = '적합' | '부적합' | null;
interface ConstructionInfo {
width: number | null;
height: number | null;
changeReason: string;
}
interface ProductInspectionInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -35,9 +41,18 @@ interface ProductInspectionInputModalProps {
productName?: string;
specification?: string;
initialData?: ProductInspectionData;
onComplete: (data: ProductInspectionData) => void;
onComplete: (data: ProductInspectionData, constructionInfo?: ConstructionInfo) => void;
/** FQC 문서 ID (있으면 양식 기반 모드) */
fqcDocumentId?: number | null;
/** 시공 가로/세로 초기값 */
constructionWidth?: number | null;
constructionHeight?: number | null;
/** 변경사유 초기값 */
changeReason?: string;
/** 전체 주문 아이템 목록 (이전/다음 네비게이션용) */
orderItems?: OrderSettingItem[];
/** 이전/다음 이동 시 호출 (저장 후 해당 아이템으로 전환) */
onNavigate?: (item: OrderSettingItem) => void;
}
export function ProductInspectionInputModal({
@@ -49,6 +64,11 @@ export function ProductInspectionInputModal({
initialData,
onComplete,
fqcDocumentId,
constructionWidth: initialConstructionWidth,
constructionHeight: initialConstructionHeight,
changeReason: initialChangeReason = '',
orderItems = [],
onNavigate,
}: ProductInspectionInputModalProps) {
// FQC 모드 상태
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
@@ -56,6 +76,11 @@ export function ProductInspectionInputModal({
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// 시공 가로/세로/변경사유
const [conWidth, setConWidth] = useState<number | null>(null);
const [conHeight, setConHeight] = useState<number | null>(null);
const [changeReason, setChangeReason] = useState('');
// 판정 상태 (FQC 모드)
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>({});
@@ -94,6 +119,15 @@ export function ProductInspectionInputModal({
.finally(() => setIsLoadingFqc(false));
}, [open, useFqcMode, fqcDocumentId]);
// 모달 열릴 때 또는 아이템 전환 시 시공 사이즈/변경사유 초기화
useEffect(() => {
if (open) {
setConWidth(initialConstructionWidth ?? null);
setConHeight(initialConstructionHeight ?? null);
setChangeReason(initialChangeReason);
}
}, [open, orderItemId, initialConstructionWidth, initialConstructionHeight, initialChangeReason]);
// 모달 닫힐 때 상태 초기화
useEffect(() => {
if (!open) {
@@ -125,7 +159,7 @@ export function ProductInspectionInputModal({
}, [fqcTemplate, judgments]);
// FQC 검사 완료 (서버 저장)
const handleFqcComplete = useCallback(async () => {
const handleFqcComplete = useCallback(async (closeModal = true) => {
if (!fqcTemplate || !fqcDocumentId) return;
const dataSection = fqcTemplate.sections.find(s => s.items.length > 0);
@@ -134,7 +168,6 @@ export function ProductInspectionInputModal({
setIsSaving(true);
try {
// document_data 형식으로 변환
const records: Array<{
section_id: number | null;
column_id: number | null;
@@ -156,7 +189,6 @@ export function ProductInspectionInputModal({
}
});
// 종합판정
records.push({
section_id: null,
column_id: null,
@@ -173,14 +205,10 @@ export function ProductInspectionInputModal({
if (result.success) {
toast.success('검사 데이터가 저장되었습니다.');
// onComplete callback으로 로컬 상태도 업데이트
// Legacy 타입 호환: FQC 판정 데이터를 ProductInspectionData 형태로 변환
const legacyData: ProductInspectionData = {
productName,
specification,
productImages: [],
// FQC 모드에서는 모든 항목을 적합/부적합으로만 판정
// 11개 항목을 legacy 필드에 매핑 (가능한 만큼)
appearanceProcessing: judgments[0] === '적합' ? 'pass' : judgments[0] === '부적합' ? 'fail' : null,
appearanceSewing: judgments[1] === '적합' ? 'pass' : judgments[1] === '부적합' ? 'fail' : null,
appearanceAssembly: judgments[2] === '적합' ? 'pass' : judgments[2] === '부적합' ? 'fail' : null,
@@ -204,22 +232,15 @@ export function ProductInspectionInputModal({
specialNotes: '',
};
onComplete(legacyData);
onOpenChange(false);
onComplete(legacyData, { width: conWidth, height: conHeight, changeReason });
if (closeModal) onOpenChange(false);
} else {
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
}
} finally {
setIsSaving(false);
}
}, [fqcTemplate, fqcDocumentId, judgments, overallJudgment, productName, specification, onComplete, onOpenChange]);
// Legacy 완료 핸들러
const handleLegacyComplete = useCallback(() => {
if (!legacyFormData) return;
onComplete(legacyFormData);
onOpenChange(false);
}, [onComplete, onOpenChange]);
}, [fqcTemplate, fqcDocumentId, judgments, overallJudgment, productName, specification, conWidth, conHeight, changeReason, onComplete, onOpenChange]);
// ===== Legacy 모드 상태 =====
const [legacyFormData, setLegacyFormData] = useState<ProductInspectionData | null>(null);
@@ -230,30 +251,76 @@ export function ProductInspectionInputModal({
productName,
specification,
productImages: [],
appearanceProcessing: 'pass',
appearanceSewing: 'pass',
appearanceAssembly: 'pass',
appearanceSmokeBarrier: 'pass',
appearanceBottomFinish: 'pass',
motor: 'pass',
material: 'pass',
appearanceProcessing: null,
appearanceSewing: null,
appearanceAssembly: null,
appearanceSmokeBarrier: null,
appearanceBottomFinish: null,
motor: null,
material: null,
lengthValue: null,
lengthJudgment: 'pass',
lengthJudgment: null,
heightValue: null,
heightJudgment: 'pass',
heightJudgment: null,
guideRailGapValue: null,
guideRailGap: 'pass',
guideRailGap: null,
bottomFinishGapValue: null,
bottomFinishGap: 'pass',
fireResistanceTest: 'pass',
smokeLeakageTest: 'pass',
openCloseTest: 'pass',
impactTest: 'pass',
bottomFinishGap: null,
fireResistanceTest: null,
smokeLeakageTest: null,
openCloseTest: null,
impactTest: null,
hasSpecialNotes: false,
specialNotes: '',
});
}
}, [open, useFqcMode, initialData, productName, specification]);
}, [open, orderItemId, useFqcMode, initialData, productName, specification]);
// Legacy 완료 핸들러
const handleLegacyComplete = useCallback(() => {
if (!legacyFormData) return;
onComplete(legacyFormData, { width: conWidth, height: conHeight, changeReason });
onOpenChange(false);
}, [legacyFormData, conWidth, conHeight, changeReason, onComplete, onOpenChange]);
// ===== 이전/다음 네비게이션 =====
const currentIndex = orderItems.findIndex(item => item.id === orderItemId);
const totalItems = orderItems.length;
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < totalItems - 1;
const hasLegacyChanges = useCallback(() => {
if (!legacyFormData) return false;
// 검사 데이터 변경 확인
if (JSON.stringify(legacyFormData) !== JSON.stringify(initialData ?? null)) return true;
// 시공 사이즈/변경사유 변경 확인
if (conWidth !== (initialConstructionWidth ?? null)) return true;
if (conHeight !== (initialConstructionHeight ?? null)) return true;
if (changeReason !== initialChangeReason) return true;
return false;
}, [legacyFormData, initialData, conWidth, conHeight, changeReason, initialConstructionWidth, initialConstructionHeight, initialChangeReason]);
const saveAndNavigate = useCallback(async (targetItem: OrderSettingItem) => {
if (!onNavigate) return;
// 변경된 내용이 있을 때만 저장
if (useFqcMode) {
// FQC: judgments 변경 확인
const hasJudgmentChanges = Object.keys(judgments).length > 0;
if (hasJudgmentChanges) await handleFqcComplete(false);
} else if (legacyFormData && hasLegacyChanges()) {
onComplete(legacyFormData, { width: conWidth, height: conHeight, changeReason });
}
// 다음 아이템으로 이동
onNavigate(targetItem);
}, [useFqcMode, handleFqcComplete, legacyFormData, judgments, conWidth, conHeight, changeReason, hasLegacyChanges, onComplete, onNavigate]);
const handlePrev = useCallback(() => {
if (hasPrev) saveAndNavigate(orderItems[currentIndex - 1]);
}, [hasPrev, currentIndex, orderItems, saveAndNavigate]);
const handleNext = useCallback(() => {
if (hasNext) saveAndNavigate(orderItems[currentIndex + 1]);
}, [hasNext, currentIndex, orderItems, saveAndNavigate]);
// FQC 데이터 섹션
const dataSection = fqcTemplate?.sections.find(s => s.items.length > 0);
@@ -274,6 +341,41 @@ export function ProductInspectionInputModal({
</DialogTitle>
</DialogHeader>
{/* 이전/다음 네비게이션 */}
{totalItems > 1 && (
<div className="flex items-center justify-between py-2 px-1 border-b">
<Button
variant="outline"
size="sm"
onClick={handlePrev}
disabled={!hasPrev || isSaving}
className="h-8"
>
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{currentIndex + 1}</span>
<span className="text-muted-foreground">/ {totalItems}</span>
{orderItems[currentIndex] && (
<span className="text-xs text-muted-foreground ml-1">
({orderItems[currentIndex].floor}-{orderItems[currentIndex].symbol})
</span>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleNext}
disabled={!hasNext || isSaving}
className="h-8"
>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
)}
<div className="space-y-6 mt-4 overflow-y-auto flex-1 pr-2">
{/* 제품명 / 규격 */}
<div className="grid grid-cols-2 gap-4">
@@ -287,6 +389,40 @@ export function ProductInspectionInputModal({
</div>
</div>
{/* 시공 가로/세로 + 변경사유 */}
<div className="grid grid-cols-5 gap-3">
<div className="space-y-1">
<span className="text-sm text-muted-foreground"> </span>
<input
type="number"
value={conWidth ?? ''}
onChange={(e) => setConWidth(e.target.value ? Number(e.target.value) : null)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
placeholder="가로"
/>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground"> </span>
<input
type="number"
value={conHeight ?? ''}
onChange={(e) => setConHeight(e.target.value ? Number(e.target.value) : null)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
placeholder="세로"
/>
</div>
<div className="space-y-1 col-span-3">
<span className="text-sm text-muted-foreground"></span>
<input
type="text"
value={changeReason}
onChange={(e) => setChangeReason(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
placeholder="변경사유 입력"
/>
</div>
</div>
{useFqcMode ? (
// ===== FQC 양식 기반 모드 =====
isLoadingFqc ? (
@@ -298,11 +434,37 @@ export function ProductInspectionInputModal({
<>
{/* 검사항목 목록 (template 기반) */}
<div className="space-y-3">
<div className="text-sm font-medium text-blue-600 border-b pb-2">
{dataSection?.title || dataSection?.name || '검사항목'}
<span className="ml-2 text-xs text-muted-foreground font-normal">
({sortedItems.length})
</span>
<div className="flex items-center justify-between border-b pb-2">
<div className="text-sm font-medium text-blue-600">
{dataSection?.title || dataSection?.name || '검사항목'}
<span className="ml-2 text-xs text-muted-foreground font-normal">
({sortedItems.length})
</span>
</div>
{(() => {
const allPassed = sortedItems.length > 0 && sortedItems.every((_, idx) => judgments[idx] === '적합');
return allPassed ? (
<button
type="button"
onClick={() => setJudgments({})}
className="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
>
</button>
) : (
<button
type="button"
onClick={() => {
const allPass: Record<number, JudgmentValue> = {};
sortedItems.forEach((_, idx) => { allPass[idx] = '적합'; });
setJudgments(allPass);
}}
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors"
>
</button>
);
})()}
</div>
<div className="space-y-2">
@@ -353,7 +515,7 @@ export function ProductInspectionInputModal({
</Button>
<Button
onClick={useFqcMode ? handleFqcComplete : handleLegacyComplete}
onClick={useFqcMode ? () => handleFqcComplete(true) : handleLegacyComplete}
disabled={isSaving || isLoadingFqc}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
@@ -434,31 +596,94 @@ function LegacyInspectionForm({
onChange({ ...data, [key]: value });
};
const judgmentKeys: (keyof ProductInspectionData)[] = [
'appearanceProcessing', 'appearanceSewing', 'appearanceAssembly',
'appearanceSmokeBarrier', 'appearanceBottomFinish', 'motor', 'material',
'lengthJudgment', 'heightJudgment', 'guideRailGap', 'bottomFinishGap',
'fireResistanceTest', 'smokeLeakageTest', 'openCloseTest', 'impactTest',
];
const allPassed = judgmentKeys.every(k => data[k] === 'pass');
const setAllPass = () => {
const updated = { ...data };
judgmentKeys.forEach(k => { (updated as Record<string, unknown>)[k] = 'pass'; });
onChange(updated);
};
const resetAll = () => {
const updated = { ...data };
judgmentKeys.forEach(k => { (updated as Record<string, unknown>)[k] = null; });
onChange(updated);
};
return (
<>
{/* 겉모양 검사 */}
<LegacyGroup title="겉모양 검사">
<LegacyRow label="가공상태" value={data.appearanceProcessing} onChange={v => update('appearanceProcessing', v)} />
<LegacyRow label="재봉상태" value={data.appearanceSewing} onChange={v => update('appearanceSewing', v)} />
<LegacyRow label="조립상태" value={data.appearanceAssembly} onChange={v => update('appearanceAssembly', v)} />
<LegacyRow label="연기차단재" value={data.appearanceSmokeBarrier} onChange={v => update('appearanceSmokeBarrier', v)} />
<LegacyRow label="하단마감재" value={data.appearanceBottomFinish} onChange={v => update('appearanceBottomFinish', v)} />
<LegacyRow label="모터" value={data.motor} onChange={v => update('motor', v)} />
<div className="flex justify-end">
{allPassed ? (
<button
type="button"
onClick={resetAll}
className="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
>
</button>
) : (
<button
type="button"
onClick={setAllPass}
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors"
>
</button>
)}
</div>
{/* 1. 겉모양 검사 */}
<LegacyGroup title="1. 겉모양">
<LegacyRow label="가공상태" criteria="사용상 해로운 결함이 없을 것" value={data.appearanceProcessing} onChange={v => update('appearanceProcessing', v)} />
<LegacyRow label="재봉상태" criteria="내화실에 의해 견고하게 접합되어야 함" value={data.appearanceSewing} onChange={v => update('appearanceSewing', v)} />
<LegacyRow label="조립상태" criteria="핸드링이 견고하게 조립되어야 함" value={data.appearanceAssembly} onChange={v => update('appearanceAssembly', v)} />
<LegacyRow label="연기차단재" criteria="케이스 W80, 가이드레일 W50(양쪽 설치)" value={data.appearanceSmokeBarrier} onChange={v => update('appearanceSmokeBarrier', v)} />
<LegacyRow label="하단마감재" criteria="내부 무겁방절 설치 유무" value={data.appearanceBottomFinish} onChange={v => update('appearanceBottomFinish', v)} />
</LegacyGroup>
{/* 재질/치수 검사 */}
<LegacyGroup title="재질/치수 검사">
<LegacyRow label="재질" value={data.material} onChange={v => update('material', v)} />
<LegacyRow label="길이" value={data.lengthJudgment} onChange={v => update('lengthJudgment', v)} />
<LegacyRow label="높이" value={data.heightJudgment} onChange={v => update('heightJudgment', v)} />
<LegacyRow label="가이드레일 홈간격" value={data.guideRailGap} onChange={v => update('guideRailGap', v)} />
<LegacyRow label="하단마감재 간격" value={data.bottomFinishGap} onChange={v => update('bottomFinishGap', v)} />
{/* 2. 모터 */}
<LegacyGroup title="2. 모터">
<LegacyRow label="모터" criteria="인정제품과 동일사양" value={data.motor} onChange={v => update('motor', v)} />
</LegacyGroup>
{/* 시험 검사 */}
<LegacyGroup title="시험 검사">
<LegacyRow label="내화시험" value={data.fireResistanceTest} onChange={v => update('fireResistanceTest', v)} />
<LegacyRow label="차연시험" value={data.smokeLeakageTest} onChange={v => update('smokeLeakageTest', v)} />
<LegacyRow label="개폐시험" value={data.openCloseTest} onChange={v => update('openCloseTest', v)} />
<LegacyRow label="내충격시험" value={data.impactTest} onChange={v => update('impactTest', v)} />
{/* 3. 재질 */}
<LegacyGroup title="3. 재질">
<LegacyRow label="재질" criteria="WY-SC780 인쇄상태 확인" value={data.material} onChange={v => update('material', v)} />
</LegacyGroup>
{/* 4. 치수(오픈사이즈) */}
<LegacyGroup title="4. 치수(오픈사이즈)">
<LegacyRow label="길이" criteria="수주 치수 ± 30mm" value={data.lengthJudgment} onChange={v => update('lengthJudgment', v)} />
<LegacyRow label="높이" criteria="수주 치수 ± 30mm" value={data.heightJudgment} onChange={v => update('heightJudgment', v)} />
<LegacyRow label="가이드레일 간격" criteria="10 ± 5mm (측정부위 높이 100 이하)" value={data.guideRailGap} onChange={v => update('guideRailGap', v)} />
<LegacyRow label="하단막대 간격" criteria="가이드레일과 하단마감재 측 사이 25mm 이내" value={data.bottomFinishGap} onChange={v => update('bottomFinishGap', v)} />
</LegacyGroup>
{/* 5~9. 시험 검사 */}
<LegacyGroup title="5~9. 시험 검사">
<LegacyRow label="내화시험" criteria="비차열/차열성 - 공인시험기관 시험성적서" value={data.fireResistanceTest} onChange={v => update('fireResistanceTest', v)} />
<LegacyRow label="차연시험" criteria="25Pa 시 공기누설량 0.9m³/min·m² 이하" value={data.smokeLeakageTest} onChange={v => update('smokeLeakageTest', v)} />
<LegacyRow label="개폐시험" criteria="전동개폐 2.5~6.5m/min, 자중강하 3~7m/min" value={data.openCloseTest} onChange={v => update('openCloseTest', v)} />
<LegacyRow label="내충격시험" criteria="방화상 유해한 파괴, 박리 탈락 유무" value={data.impactTest} onChange={v => update('impactTest', v)} />
</LegacyGroup>
{/* 사진 첨부 */}
<LegacyGroup title="제품 사진">
<LegacyPhotoUpload
images={data.productImages}
onChange={(images) => update('productImages', images)}
maxCount={2}
/>
</LegacyGroup>
{/* 특이사항 */}
<LegacyGroup title="특이사항">
<textarea
value={data.specialNotes ?? ''}
onChange={(e) => update('specialNotes', e.target.value)}
className="w-full min-h-[60px] px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
placeholder="특이사항 입력"
/>
</LegacyGroup>
</>
);
@@ -475,17 +700,22 @@ function LegacyGroup({ title, children }: { title: string; children: React.React
function LegacyRow({
label,
criteria,
value,
onChange,
}: {
label: string;
criteria?: string;
value: 'pass' | 'fail' | null;
onChange: (v: 'pass' | 'fail') => void;
}) {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="flex gap-2">
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{label}</span>
{criteria && <p className="text-xs text-muted-foreground mt-0.5">{criteria}</p>}
</div>
<div className="flex gap-2 shrink-0">
<button
type="button"
onClick={() => onChange('pass')}
@@ -510,3 +740,69 @@ function LegacyRow({
</div>
);
}
function LegacyPhotoUpload({
images,
onChange,
maxCount,
}: {
images: string[];
onChange: (images: string[]) => void;
maxCount: number;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
Array.from(files).forEach((file) => {
if (images.length >= maxCount) return;
const reader = new FileReader();
reader.onload = (ev) => {
const dataUrl = ev.target?.result as string;
onChange([...images, dataUrl].slice(0, maxCount));
};
reader.readAsDataURL(file);
});
e.target.value = '';
};
const removeImage = (index: number) => {
onChange(images.filter((_, i) => i !== index));
};
return (
<div className="flex gap-3">
{images.map((src, idx) => (
<div key={idx} className="relative w-24 h-24 rounded-lg border overflow-hidden group">
<img src={src} alt={`사진 ${idx + 1}`} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => removeImage(idx)}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</div>
))}
{images.length < maxCount && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-24 h-24 rounded-lg border-2 border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-400 hover:border-blue-400 hover:text-blue-400 transition-colors"
>
<span className="text-2xl leading-none">+</span>
<span className="text-xs mt-1">{images.length}/{maxCount}</span>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}

View File

@@ -4,15 +4,15 @@
* 제품검사 관리 Server Actions
*
* API Endpoints:
* - GET /api/v1/inspections - 목록 조회
* - GET /api/v1/inspections/stats - 통계 조회
* - GET /api/v1/inspections/calendar - 캘린더 스케줄 조회
* - GET /api/v1/inspections/{id} - 상세 조회
* - POST /api/v1/inspections - 등록
* - PUT /api/v1/inspections/{id} - 수정
* - DELETE /api/v1/inspections/{id} - 삭제
* - PATCH /api/v1/inspections/{id}/complete - 검사 완료 처리
* - GET /api/v1/orders/select - 수주 선택 목록 조회
* - GET /api/v1/quality/documents - 목록 조회
* - GET /api/v1/quality/documents/stats - 통계 조회
* - GET /api/v1/quality/documents/calendar - 캘린더 스케줄 조회
* - GET /api/v1/quality/documents/{id} - 상세 조회
* - POST /api/v1/quality/documents - 등록
* - PUT /api/v1/quality/documents/{id} - 수정
* - DELETE /api/v1/quality/documents/{id} - 삭제
* - PATCH /api/v1/quality/documents/{id}/complete - 검사 완료 처리
* - GET /api/v1/quality/documents/available-orders - 수주 선택 목록 조회
*/
import { executeServerAction } from '@/lib/api/execute-server-action';
@@ -33,7 +33,7 @@ import {
} from './mockData';
// 개발환경 Mock 데이터 fallback 플래그
const USE_MOCK_FALLBACK = true;
const USE_MOCK_FALLBACK = false;
// ===== API 응답 타입 =====
@@ -85,8 +85,13 @@ interface ProductInspectionApi {
};
order_items: Array<{
id: string;
order_id?: number;
order_number: string;
site_name: string;
client_id?: number | null;
client_name?: string;
item_id?: number | null;
item_name?: string;
delivery_date: string;
floor: string;
symbol: string;
@@ -96,6 +101,7 @@ interface ProductInspectionApi {
construction_height: number;
change_reason: string;
}>;
request_document_id: number | null;
created_at: string;
updated_at: string;
}
@@ -127,8 +133,19 @@ interface OrderSelectItemApi {
id: number;
order_number: string;
site_name: string;
client_id: number | null;
client_name: string;
item_id: number | null;
item_name: string;
delivery_date: string;
location_count: number;
locations: Array<{
node_id: number;
floor: string;
symbol: string;
order_width: number;
order_height: number;
}>;
}
// ===== 페이지네이션 =====
@@ -219,8 +236,13 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
},
orderItems: (api.order_items || []).map((item) => ({
id: item.id,
orderId: item.order_id,
orderNumber: item.order_number,
siteName: item.site_name || '',
clientId: item.client_id ?? null,
clientName: item.client_name ?? '',
itemId: item.item_id ?? null,
itemName: item.item_name ?? '',
deliveryDate: item.delivery_date || '',
floor: item.floor,
symbol: item.symbol,
@@ -229,7 +251,10 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
constructionWidth: item.construction_width,
constructionHeight: item.construction_height,
changeReason: item.change_reason,
documentId: item.document_id ?? null,
inspectionData: item.inspection_data || undefined,
})),
requestDocumentId: api.request_document_id ?? null,
};
}
@@ -237,53 +262,51 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
function transformFormToApi(data: InspectionFormData): Record<string, unknown> {
return {
quality_doc_number: data.qualityDocNumber,
site_name: data.siteName,
client: data.client,
manager: data.manager,
manager_contact: data.managerContact,
construction_site: {
site_name: data.constructionSite.siteName,
land_location: data.constructionSite.landLocation,
lot_number: data.constructionSite.lotNumber,
client_id: data.clientId ?? null,
inspector_id: data.inspectorId ?? null,
received_date: data.receptionDate ?? null,
options: {
manager: {
name: data.manager || '',
phone: data.managerContact || '',
},
inspection: {
request_date: data.scheduleInfo?.visitRequestDate || '',
start_date: data.scheduleInfo?.startDate || '',
end_date: data.scheduleInfo?.endDate || '',
},
site_address: {
postal_code: data.scheduleInfo?.sitePostalCode || '',
address: data.scheduleInfo?.siteAddress || '',
detail: data.scheduleInfo?.siteAddressDetail || '',
},
construction_site: {
name: data.constructionSite?.siteName || '',
land_location: data.constructionSite?.landLocation || '',
lot_number: data.constructionSite?.lotNumber || '',
},
material_distributor: {
company: data.materialDistributor?.companyName || '',
address: data.materialDistributor?.companyAddress || '',
ceo: data.materialDistributor?.representativeName || '',
phone: data.materialDistributor?.phone || '',
},
contractor: {
company: data.constructorInfo?.companyName || '',
address: data.constructorInfo?.companyAddress || '',
name: data.constructorInfo?.name || '',
phone: data.constructorInfo?.phone || '',
},
supervisor: {
office: data.supervisor?.officeName || '',
address: data.supervisor?.officeAddress || '',
name: data.supervisor?.name || '',
phone: data.supervisor?.phone || '',
},
},
material_distributor: {
company_name: data.materialDistributor.companyName,
company_address: data.materialDistributor.companyAddress,
representative_name: data.materialDistributor.representativeName,
phone: data.materialDistributor.phone,
},
constructor_info: {
company_name: data.constructorInfo.companyName,
company_address: data.constructorInfo.companyAddress,
name: data.constructorInfo.name,
phone: data.constructorInfo.phone,
},
supervisor: {
office_name: data.supervisor.officeName,
office_address: data.supervisor.officeAddress,
name: data.supervisor.name,
phone: data.supervisor.phone,
},
schedule_info: {
visit_request_date: data.scheduleInfo.visitRequestDate,
start_date: data.scheduleInfo.startDate,
end_date: data.scheduleInfo.endDate,
inspector: data.scheduleInfo.inspector,
site_postal_code: data.scheduleInfo.sitePostalCode,
site_address: data.scheduleInfo.siteAddress,
site_address_detail: data.scheduleInfo.siteAddressDetail,
},
order_items: data.orderItems.map((item) => ({
order_number: item.orderNumber,
floor: item.floor,
symbol: item.symbol,
order_width: item.orderWidth,
order_height: item.orderHeight,
construction_width: item.constructionWidth,
construction_height: item.constructionHeight,
change_reason: item.changeReason,
})),
// 수주 연결
order_ids: data.orderItems?.map((item) => item.orderId ?? Number(item.id)) ?? [],
};
}
@@ -306,7 +329,7 @@ export async function getInspections(params?: {
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
const result = await executeServerAction<PaginatedResponse>({
url: buildApiUrl('/api/v1/inspections', {
url: buildApiUrl('/api/v1/quality/documents', {
page: params?.page,
per_page: params?.size,
q: params?.q,
@@ -369,7 +392,7 @@ export async function getInspectionStats(params?: {
__authError?: boolean;
}> {
const result = await executeServerAction<InspectionStatsApi>({
url: buildApiUrl('/api/v1/inspections/stats', {
url: buildApiUrl('/api/v1/quality/documents/stats', {
date_from: params?.dateFrom,
date_to: params?.dateTo,
}),
@@ -406,7 +429,7 @@ export async function getInspectionCalendar(params?: {
__authError?: boolean;
}> {
const result = await executeServerAction<CalendarItemApi[]>({
url: buildApiUrl('/api/v1/inspections/calendar', {
url: buildApiUrl('/api/v1/quality/documents/calendar', {
year: params?.year,
month: params?.month,
inspector: params?.inspector,
@@ -443,7 +466,7 @@ export async function getInspectionById(id: string): Promise<{
__authError?: boolean;
}> {
const result = await executeServerAction<ProductInspectionApi>({
url: buildApiUrl(`/api/v1/inspections/${id}`),
url: buildApiUrl(`/api/v1/quality/documents/${id}`),
errorMessage: '제품검사 상세 조회에 실패했습니다.',
});
@@ -471,7 +494,7 @@ export async function createInspection(data: InspectionFormData): Promise<{
}> {
const apiData = transformFormToApi(data);
const result = await executeServerAction<ProductInspectionApi>({
url: buildApiUrl('/api/v1/inspections'),
url: buildApiUrl('/api/v1/quality/documents'),
method: 'POST',
body: apiData,
errorMessage: '제품검사 등록에 실패했습니다.',
@@ -496,69 +519,87 @@ export async function updateInspection(
}> {
const apiData: Record<string, unknown> = {};
if (data.qualityDocNumber !== undefined) apiData.quality_doc_number = data.qualityDocNumber;
if (data.siteName !== undefined) apiData.site_name = data.siteName;
if (data.client !== undefined) apiData.client = data.client;
if (data.manager !== undefined) apiData.manager = data.manager;
if (data.managerContact !== undefined) apiData.manager_contact = data.managerContact;
if (data.clientId !== undefined) apiData.client_id = data.clientId;
if (data.inspectorId !== undefined) apiData.inspector_id = data.inspectorId;
if (data.receptionDate !== undefined) apiData.received_date = data.receptionDate;
if (data.constructionSite) {
apiData.construction_site = {
site_name: data.constructionSite.siteName,
land_location: data.constructionSite.landLocation,
lot_number: data.constructionSite.lotNumber,
};
}
if (data.materialDistributor) {
apiData.material_distributor = {
company_name: data.materialDistributor.companyName,
company_address: data.materialDistributor.companyAddress,
representative_name: data.materialDistributor.representativeName,
phone: data.materialDistributor.phone,
};
}
if (data.constructorInfo) {
apiData.constructor_info = {
company_name: data.constructorInfo.companyName,
company_address: data.constructorInfo.companyAddress,
name: data.constructorInfo.name,
phone: data.constructorInfo.phone,
};
}
if (data.supervisor) {
apiData.supervisor = {
office_name: data.supervisor.officeName,
office_address: data.supervisor.officeAddress,
name: data.supervisor.name,
phone: data.supervisor.phone,
// options 필드들은 백엔드에서 array_replace_recursive로 병합됨
const options: Record<string, unknown> = {};
if (data.manager !== undefined || data.managerContact !== undefined) {
options.manager = {
name: data.manager || '',
phone: data.managerContact || '',
};
}
if (data.scheduleInfo) {
apiData.schedule_info = {
visit_request_date: data.scheduleInfo.visitRequestDate,
start_date: data.scheduleInfo.startDate,
end_date: data.scheduleInfo.endDate,
inspector: data.scheduleInfo.inspector,
site_postal_code: data.scheduleInfo.sitePostalCode,
site_address: data.scheduleInfo.siteAddress,
site_address_detail: data.scheduleInfo.siteAddressDetail,
options.inspection = {
request_date: data.scheduleInfo.visitRequestDate || '',
start_date: data.scheduleInfo.startDate || '',
end_date: data.scheduleInfo.endDate || '',
};
options.site_address = {
postal_code: data.scheduleInfo.sitePostalCode || '',
address: data.scheduleInfo.siteAddress || '',
detail: data.scheduleInfo.siteAddressDetail || '',
};
}
if (data.orderItems) {
apiData.order_items = data.orderItems.map((item) => ({
order_number: item.orderNumber,
floor: item.floor,
symbol: item.symbol,
order_width: item.orderWidth,
order_height: item.orderHeight,
construction_width: item.constructionWidth,
construction_height: item.constructionHeight,
change_reason: item.changeReason,
if (data.constructionSite) {
options.construction_site = {
name: data.constructionSite.siteName || '',
land_location: data.constructionSite.landLocation || '',
lot_number: data.constructionSite.lotNumber || '',
};
}
if (data.materialDistributor) {
options.material_distributor = {
company: data.materialDistributor.companyName || '',
address: data.materialDistributor.companyAddress || '',
ceo: data.materialDistributor.representativeName || '',
phone: data.materialDistributor.phone || '',
};
}
if (data.constructorInfo) {
options.contractor = {
company: data.constructorInfo.companyName || '',
address: data.constructorInfo.companyAddress || '',
name: data.constructorInfo.name || '',
phone: data.constructorInfo.phone || '',
};
}
if (data.supervisor) {
options.supervisor = {
office: data.supervisor.officeName || '',
address: data.supervisor.officeAddress || '',
name: data.supervisor.name || '',
phone: data.supervisor.phone || '',
};
}
if (Object.keys(options).length > 0) {
apiData.options = options;
}
// 수주 연결 동기화 (orderItems에서 orderId 추출)
if (data.orderItems !== undefined) {
apiData.order_ids = data.orderItems.map((item) => {
// orderId가 있으면 사용, 없으면 id를 숫자로 변환
return item.orderId ?? Number(item.id);
});
// 개소별 데이터 (시공규격, 변경사유, 검사데이터)
apiData.locations = data.orderItems.map((item) => ({
id: Number(item.id),
post_width: item.constructionWidth || null,
post_height: item.constructionHeight || null,
change_reason: item.changeReason || null,
inspection_data: item.inspectionData || null,
}));
}
const result = await executeServerAction<ProductInspectionApi>({
url: buildApiUrl(`/api/v1/inspections/${id}`),
url: buildApiUrl(`/api/v1/quality/documents/${id}`),
method: 'PUT',
body: apiData,
errorMessage: '제품검사 수정에 실패했습니다.',
@@ -570,6 +611,42 @@ export async function updateInspection(
: { success: true };
}
// ===== 개소별 검사 저장 =====
export async function saveLocationInspection(
docId: string,
locationId: string,
inspectionData: Record<string, unknown>,
constructionInfo?: {
width: number | null;
height: number | null;
changeReason: string;
},
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
const body: Record<string, unknown> = {
inspection_data: inspectionData,
inspection_status: 'completed',
};
if (constructionInfo) {
body.construction_width = constructionInfo.width;
body.construction_height = constructionInfo.height;
body.change_reason = constructionInfo.changeReason;
}
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/quality/documents/${docId}/locations/${locationId}/inspect`),
method: 'POST',
body,
errorMessage: '검사 데이터 저장에 실패했습니다.',
});
return { success: result.success, error: result.error, __authError: result.__authError };
}
// ===== 삭제 =====
export async function deleteInspection(id: string): Promise<{
@@ -578,7 +655,7 @@ export async function deleteInspection(id: string): Promise<{
__authError?: boolean;
}> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/inspections/${id}`),
url: buildApiUrl(`/api/v1/quality/documents/${id}`),
method: 'DELETE',
errorMessage: '제품검사 삭제에 실패했습니다.',
});
@@ -619,6 +696,8 @@ export async function completeInspection(
export async function getOrderSelectList(params?: {
q?: string;
clientId?: number | null;
itemId?: number | null;
}): Promise<{
success: boolean;
data: OrderSelectItem[];
@@ -626,7 +705,11 @@ export async function getOrderSelectList(params?: {
__authError?: boolean;
}> {
const result = await executeServerAction<OrderSelectItemApi[]>({
url: buildApiUrl('/api/v1/orders/select', { q: params?.q }),
url: buildApiUrl('/api/v1/quality/documents/available-orders', {
q: params?.q,
client_id: params?.clientId ?? undefined,
item_id: params?.itemId ?? undefined,
}),
errorMessage: '수주 선택 목록 조회에 실패했습니다.',
});
@@ -639,6 +722,12 @@ export async function getOrderSelectList(params?: {
i.orderNumber.toLowerCase().includes(q) || i.siteName.toLowerCase().includes(q)
);
}
if (params?.clientId) {
filtered = filtered.filter(i => i.clientId === params.clientId);
}
if (params?.itemId) {
filtered = filtered.filter(i => i.itemId === params.itemId);
}
return { success: true, data: filtered };
}
return { success: false, data: [], error: result.error, __authError: result.__authError };
@@ -651,8 +740,19 @@ export async function getOrderSelectList(params?: {
id: String(item.id),
orderNumber: item.order_number,
siteName: item.site_name,
clientId: item.client_id ?? null,
clientName: item.client_name ?? '',
itemId: item.item_id ?? null,
itemName: item.item_name ?? '',
deliveryDate: item.delivery_date,
locationCount: item.location_count,
locations: (item.locations || []).map((loc) => ({
nodeId: loc.node_id,
floor: loc.floor,
symbol: loc.symbol,
orderWidth: loc.order_width,
orderHeight: loc.order_height,
})),
})),
};
}

View File

@@ -1,17 +1,20 @@
'use client';
/**
* FQC 제품검사 성적서 - 양식 기반 렌더링
* FQC 제품검사 성적서 - 양식 기반 렌더링 (8컬럼)
*
* documents 시스템의 template 구조를 기반으로 렌더링:
* - 결재라인 (3인: 작성/검토/승인)
* - 기본정보 (7필드: 납품명, 제품명, 발주처, LOT NO, 로트크기, 검사일자, 검사자)
* - 검사항목 테이블 (4컬럼: NO, 검사항목, 검사기준, 판정)
* - 11개 설치 후 최종검사 항목 (모두 visual/checkbox → 적합/부적합)
* - 종합판정 (자동 계산)
* - 검사항목 테이블 (8컬럼 시각 레이아웃)
* - 1~6: section_item 읽기전용 (No, 검사항목, 세부항목, 검사기준, 검사방법, 검사주기)
* - 7~8: template column 편집 (측정값, 판정)
* - rowSpan: category 단독 + method+frequency 복합키 병합
* - measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성
* - 종합판정 (자동 계산, measurement_type='none' 제외)
*
* readonly=true → 조회 모드 (InspectionReportModal에서 사용)
* readonly=false → 편집 모드 (ProductInspectionInputModal 대체)
* readonly=true → 조회 모드
* readonly=false → 편집 모드
*/
import { useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
@@ -55,6 +58,73 @@ interface FqcDocumentContentProps {
type JudgmentValue = '적합' | '부적합' | null;
// ===== rowSpan 병합 유틸 =====
/** 단일 필드 기준 연속 rowSpan 계산 (category용) */
function buildFieldRowSpan(items: FqcTemplateItem[], field: 'category') {
const spans = new Map<number, number>();
const covered = new Set<number>();
let i = 0;
while (i < items.length) {
const value = items[i][field];
if (!value || value === '-') { i++; continue; }
let span = 1;
while (i + span < items.length && items[i + span][field] === value) {
covered.add(i + span);
span++;
}
if (span > 1) spans.set(i, span);
i += span;
}
return { spans, covered };
}
/** 복합 키 기준 연속 rowSpan 계산 (method+frequency용) */
function buildCompositeRowSpan(items: FqcTemplateItem[]) {
const spans = new Map<number, number>();
const covered = new Set<number>();
let i = 0;
while (i < items.length) {
const method = items[i].method || '';
const freq = items[i].frequency || '';
if (!method && !freq) { i++; continue; }
const key = `${method}|${freq}`;
let span = 1;
while (i + span < items.length) {
const nextMethod = items[i + span].method || '';
const nextFreq = items[i + span].frequency || '';
if (!nextMethod && !nextFreq) break;
if (`${nextMethod}|${nextFreq}` !== key) break;
covered.add(i + span);
span++;
}
if (span > 1) spans.set(i, span);
i += span;
}
return { spans, covered };
}
/** category별 그룹 번호 생성 */
function buildCategoryNumbers(items: FqcTemplateItem[]): Map<number, number> {
const numbers = new Map<number, number>();
let num = 0;
let lastCategory = '';
for (let i = 0; i < items.length; i++) {
const cat = items[i].category;
if (cat && cat !== '-' && cat !== lastCategory) {
num++;
lastCategory = cat;
}
numbers.set(i, num);
}
return numbers;
}
// ===== Component =====
export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentContentProps>(
@@ -75,31 +145,52 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
[template.sections]
);
// 판정 컬럼 ID 찾기
const sectionItems = useMemo(
() => dataSection?.items.sort((a, b) => a.sortOrder - b.sortOrder) ?? [],
[dataSection]
);
// 컬럼 ID 찾기
const judgmentColumnId = useMemo(
() => template.columns.find(c => c.label === '판정')?.id ?? null,
[template.columns]
);
const measurementColumnId = useMemo(
() => template.columns.find(c => c.label === '측정값')?.id ?? null,
[template.columns]
);
// 기존 문서 데이터에서 판정값 추출
// rowSpan 계산
const categoryCoverage = useMemo(() => buildFieldRowSpan(sectionItems, 'category'), [sectionItems]);
const methodFreqCoverage = useMemo(() => buildCompositeRowSpan(sectionItems), [sectionItems]);
const categoryNumbers = useMemo(() => buildCategoryNumbers(sectionItems), [sectionItems]);
// 기존 문서 데이터에서 판정값 + 측정값 추출
const initialJudgments = useMemo(() => {
const map: Record<number, JudgmentValue> = {};
if (!dataSection || !judgmentColumnId) return map;
for (const d of documentData) {
if (
d.sectionId === dataSection.id &&
d.columnId === judgmentColumnId &&
d.fieldKey === 'result'
) {
if (d.sectionId === dataSection.id && d.columnId === judgmentColumnId && d.fieldKey === 'result') {
map[d.rowIndex] = (d.fieldValue as JudgmentValue) ?? null;
}
}
return map;
}, [documentData, dataSection, judgmentColumnId]);
// 판정 상태 (편집 모드용)
const initialMeasurements = useMemo(() => {
const map: Record<number, string> = {};
if (!dataSection || !measurementColumnId) return map;
for (const d of documentData) {
if (d.sectionId === dataSection.id && d.columnId === measurementColumnId && d.fieldKey === 'measured_value') {
map[d.rowIndex] = d.fieldValue ?? '';
}
}
return map;
}, [documentData, dataSection, measurementColumnId]);
// 상태 (편집 모드용)
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>(initialJudgments);
const [measurements, setMeasurements] = useState<Record<number, string>>(initialMeasurements);
// 판정 토글
const toggleJudgment = useCallback(
@@ -113,41 +204,32 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
[readonly]
);
// 종합판정 자동 계산
// 측정값 변경
const updateMeasurement = useCallback(
(rowIndex: number, value: string) => {
if (readonly) return;
setMeasurements(prev => ({ ...prev, [rowIndex]: value }));
},
[readonly]
);
// 종합판정 자동 계산 (measurement_type='none' 제외)
const overallJudgment = useMemo(() => {
if (!dataSection) return null;
const items = dataSection.items;
if (items.length === 0) return null;
const activeItems = sectionItems.filter(item => item.measurementType !== 'none');
if (activeItems.length === 0) return null;
const values = items.map((_, idx) => judgments[idx]);
const activeIndices = sectionItems
.map((item, idx) => item.measurementType !== 'none' ? idx : -1)
.filter(idx => idx >= 0);
const values = activeIndices.map(idx => judgments[idx]);
const hasValue = values.some(v => v !== undefined && v !== null);
if (!hasValue) return null;
if (values.some(v => v === '부적합')) return '불합격' as const;
if (values.every(v => v === '적합')) return '합격' as const;
return null;
}, [dataSection, judgments]);
// 기본필드 값 조회
const getBasicFieldValue = useCallback(
(fieldKey: string): string => {
const field = template.basicFields.find(f => f.fieldKey === fieldKey);
if (!field) return '';
// bf_{id} 형식 (mng show.blade.php 호환)
const bfKey = `bf_${field.id}`;
if (basicFieldValues[bfKey]) return basicFieldValues[bfKey];
const found = documentData.find(d => d.fieldKey === bfKey && !d.sectionId);
if (found?.fieldValue) return found.fieldValue;
// 레거시 호환: bf_{label} 형식
const legacyKey = `bf_${field.label}`;
if (basicFieldValues[legacyKey]) return basicFieldValues[legacyKey];
const legacyFound = documentData.find(d => d.fieldKey === legacyKey && !d.sectionId);
return legacyFound?.fieldValue ?? '';
},
[basicFieldValues, documentData, template.basicFields]
);
}, [dataSection, sectionItems, judgments]);
// ref를 통해 데이터 추출 (편집 모드에서 저장 시 사용)
useImperativeHandle(ref, () => ({
@@ -160,17 +242,33 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
field_value: string | null;
}> = [];
if (dataSection && judgmentColumnId) {
dataSection.items.forEach((_, idx) => {
const value = judgments[idx];
if (value) {
records.push({
section_id: dataSection.id,
column_id: judgmentColumnId,
row_index: idx,
field_key: 'result',
field_value: value,
});
if (dataSection) {
sectionItems.forEach((item, idx) => {
// 판정
if (judgmentColumnId && item.measurementType !== 'none') {
const value = judgments[idx];
if (value) {
records.push({
section_id: dataSection.id,
column_id: judgmentColumnId,
row_index: idx,
field_key: 'result',
field_value: value,
});
}
}
// 측정값
if (measurementColumnId && item.measurementType !== 'none') {
const value = measurements[idx];
if (value !== undefined && value !== '') {
records.push({
section_id: dataSection.id,
column_id: measurementColumnId,
row_index: idx,
field_key: 'measured_value',
field_value: value,
});
}
}
});
}
@@ -203,7 +301,6 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
const sorted = [...template.basicFields].sort((a, b) => a.sortOrder - b.sortOrder);
const pairs: Array<{ label: string; value: string }> = [];
for (const field of sorted) {
// bf_{id} 형식 우선, 레거시 bf_{label} fallback
const bfKey = `bf_${field.id}`;
const legacyKey = `bf_${field.label}`;
const value = basicFieldValues[bfKey]
@@ -249,7 +346,6 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
</div>
<table className="w-full">
<tbody>
{/* 2열씩 표시 */}
{Array.from({ length: Math.ceil(basicFieldPairs.length / 2) }, (_, rowIdx) => {
const left = basicFieldPairs[rowIdx * 2];
const right = basicFieldPairs[rowIdx * 2 + 1];
@@ -307,44 +403,100 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
</div>
))}
{/* 검사항목 테이블 */}
{/* 검사항목 테이블 (8컬럼 시각 레이아웃) */}
{dataSection && (
<table className="w-full border-collapse border border-gray-400 mb-4">
<thead>
<tr className="bg-gray-100">
{template.columns
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(col => (
<th
key={col.id}
className="border border-gray-400 px-2 py-1 text-center"
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
<th className="border border-gray-400 px-2 py-1 w-10 text-center">No.</th>
<th className="border border-gray-400 px-2 py-1 w-24"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1 w-16 text-center whitespace-pre-line">{'검사\n방법'}</th>
<th className="border border-gray-400 px-2 py-1 w-16 text-center whitespace-pre-line">{'검사\n주기'}</th>
<th className="border border-gray-400 px-2 py-1 w-16 text-center"></th>
<th className="border border-gray-400 px-2 py-1 w-28 text-center"></th>
</tr>
</thead>
<tbody>
{dataSection.items
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item, idx) => (
<InspectionRow
key={item.id}
item={item}
rowIndex={idx}
judgment={judgments[idx] ?? null}
onToggleJudgment={toggleJudgment}
readonly={readonly}
/>
))}
{sectionItems.map((item, idx) => (
<tr key={item.id} className="border-b border-gray-300">
{/* 1. No. — category 그룹 병합과 동일 */}
{!categoryCoverage.covered.has(idx) && (
<td
className="border border-gray-400 px-2 py-1 text-center align-middle font-medium"
rowSpan={categoryCoverage.spans.get(idx) || 1}
>
{categoryNumbers.get(idx)}
</td>
)}
{/* 2. 검사항목 — category 그룹 병합 */}
{!categoryCoverage.covered.has(idx) && (
<td
className="border border-gray-400 px-2 py-1 align-middle font-medium whitespace-pre-line"
rowSpan={categoryCoverage.spans.get(idx) || 1}
>
{item.category || '-'}
</td>
)}
{/* 3. 세부항목 */}
<td className="border border-gray-400 px-2 py-1 whitespace-pre-line">
{item.itemName === '-' ? '' : item.itemName}
</td>
{/* 4. 검사기준 */}
<td className="border border-gray-400 px-2 py-1 whitespace-pre-line">
{item.standard || '-'}
</td>
{/* 5. 검사방법 — method+frequency 복합키 병합 */}
{!methodFreqCoverage.covered.has(idx) && (
<td
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
rowSpan={methodFreqCoverage.spans.get(idx) || 1}
>
{item.method || ''}
</td>
)}
{/* 6. 검사주기 — method+frequency 복합키 병합 (동일 span) */}
{!methodFreqCoverage.covered.has(idx) && (
<td
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
rowSpan={methodFreqCoverage.spans.get(idx) || 1}
>
{item.frequency || ''}
</td>
)}
{/* 7. 측정값 */}
<td className="border border-gray-400 px-1 py-1 text-center align-middle">
<MeasurementCell
item={item}
rowIndex={idx}
value={measurements[idx] ?? ''}
judgment={judgments[idx] ?? null}
onChange={updateMeasurement}
onToggle={toggleJudgment}
readonly={readonly}
type="measurement"
/>
</td>
{/* 8. 판정 */}
<td className="border border-gray-400 px-1 py-1 text-center align-middle">
<MeasurementCell
item={item}
rowIndex={idx}
value={measurements[idx] ?? ''}
judgment={judgments[idx] ?? null}
onChange={updateMeasurement}
onToggle={toggleJudgment}
readonly={readonly}
type="judgment"
/>
</td>
</tr>
))}
{dataSection.items.length === 0 && (
{sectionItems.length === 0 && (
<tr>
<td
colSpan={template.columns.length}
className="border border-gray-400 px-2 py-4 text-center text-gray-400"
>
<td colSpan={8} className="border border-gray-400 px-2 py-4 text-center text-gray-400">
.
</td>
</tr>
@@ -354,7 +506,7 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
<tr>
<td
className="border border-gray-400 bg-gray-100 px-2 py-2 font-bold text-center"
colSpan={template.columns.length - 1}
colSpan={7}
>
</td>
@@ -386,69 +538,116 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
}
);
// ===== 검사항목 행 =====
// ===== 측정값/판정 셀 =====
interface InspectionRowProps {
interface MeasurementCellProps {
item: FqcTemplateItem;
rowIndex: number;
value: string;
judgment: JudgmentValue;
onToggleJudgment: (rowIndex: number, value: JudgmentValue) => void;
onChange: (rowIndex: number, value: string) => void;
onToggle: (rowIndex: number, value: JudgmentValue) => void;
readonly: boolean;
type: 'measurement' | 'judgment';
}
function InspectionRow({ item, rowIndex, judgment, onToggleJudgment, readonly }: InspectionRowProps) {
function MeasurementCell({ item, rowIndex, value, judgment, onChange, onToggle, readonly, type }: MeasurementCellProps) {
// none → 비활성
if (item.measurementType === 'none') {
return <span className="text-gray-300">-</span>;
}
if (type === 'measurement') {
// checkbox → 양호/불량 텍스트
if (item.measurementType === 'checkbox') {
if (readonly) {
return <span className="text-[10px]">{value || '-'}</span>;
}
return (
<div className="flex items-center justify-center gap-1">
<button
type="button"
onClick={() => onChange(rowIndex, value === '양호' ? '' : '양호')}
className={`px-1 py-0.5 rounded text-[9px] border transition-colors ${
value === '양호'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
}`}
>
</button>
<button
type="button"
onClick={() => onChange(rowIndex, value === '불량' ? '' : '불량')}
className={`px-1 py-0.5 rounded text-[9px] border transition-colors ${
value === '불량'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
}`}
>
</button>
</div>
);
}
// numeric → 숫자 입력
if (item.measurementType === 'numeric') {
if (readonly) {
return <span className="text-[10px]">{value || '-'}</span>;
}
return (
<input
type="number"
value={value}
onChange={e => onChange(rowIndex, e.target.value)}
className="w-full text-center text-[10px] border border-gray-300 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
placeholder="-"
/>
);
}
return <span className="text-gray-300">-</span>;
}
// type === 'judgment'
if (readonly) {
return (
<div className="flex items-center justify-center gap-1 text-[10px]">
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
{judgment === '적합' ? '■' : '□'}
</span>
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
{judgment === '부적합' ? '■' : '□'}
</span>
</div>
);
}
return (
<tr className="border-b border-gray-300">
{/* NO */}
<td className="border border-gray-400 px-2 py-1 text-center align-middle font-medium w-10">
{rowIndex + 1}
</td>
{/* 검사항목 */}
<td className="border border-gray-400 px-2 py-1 align-middle">
{item.itemName}
</td>
{/* 검사기준 */}
<td className="border border-gray-400 px-2 py-1 align-middle">
{item.standard || item.frequency || '-'}
</td>
{/* 판정 */}
<td className="border border-gray-400 px-1 py-1 text-center align-middle w-28">
{readonly ? (
<div className="flex items-center justify-center gap-1 text-[10px]">
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
{judgment === '적합' ? '■' : '□'}
</span>
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
{judgment === '부적합' ? '■' : '□'}
</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={() => onToggleJudgment(rowIndex, '적합')}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
judgment === '적합'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
}`}
>
</button>
<button
type="button"
onClick={() => onToggleJudgment(rowIndex, '부적합')}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
judgment === '부적합'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
}`}
>
</button>
</div>
)}
</td>
</tr>
<div className="flex items-center justify-center gap-1">
<button
type="button"
onClick={() => onToggle(rowIndex, '적합')}
className={`px-1.5 py-0.5 rounded text-[9px] border transition-colors ${
judgment === '적합'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
}`}
>
</button>
<button
type="button"
onClick={() => onToggle(rowIndex, '부적합')}
className={`px-1.5 py-0.5 rounded text-[9px] border transition-colors ${
judgment === '부적합'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
}`}
>
</button>
</div>
);
}

View File

@@ -0,0 +1,461 @@
'use client';
/**
* 제품검사 요청서 - 양식 기반 동적 렌더링
*
* template (ID 66) 구조를 기반으로 렌더링:
* - approvalLines: 결재라인 (작성/승인)
* - basicFields: 기본 정보 필드 (수주처, 업체명 등)
* - sections[0-3]: 입력사항 (건축공사장, 자재유통업자, 공사시공자, 공사감리자)
* - sections[4]: 검사대상 사전 고지 정보 (description + columns 테이블)
* - columns: 사전 고지 테이블 컬럼 (8개, group_name으로 병합 헤더)
*/
import {
ConstructionApprovalTable,
DocumentWrapper,
DocumentTable,
DOC_STYLES,
} from '@/components/document-system';
import type { FqcTemplate, FqcDocumentData } from '../fqcActions';
interface FqcRequestDocumentContentProps {
template: FqcTemplate;
documentData?: FqcDocumentData[];
documentNo?: string;
createdDate?: string;
readonly?: boolean;
}
/** 라벨 셀 */
const lbl = `${DOC_STYLES.label} w-28`;
/** 서브 라벨 셀 */
const subLbl = 'bg-gray-50 px-2 py-1 font-medium border-r border-gray-300 w-28';
/** 값 셀 */
const val = DOC_STYLES.value;
/** EAV 데이터에서 field_key로 값 조회 */
function getFieldValue(
data: FqcDocumentData[] | undefined,
fieldKey: string,
): string {
if (!data) return '';
const found = data.find(d => d.fieldKey === fieldKey && d.sectionId === null);
return found?.fieldValue || '';
}
/** EAV 데이터에서 섹션 아이템 값 조회 */
function getSectionItemValue(
data: FqcDocumentData[] | undefined,
sectionId: number,
fieldKey: string,
): string {
if (!data) return '';
const found = data.find(
d => d.sectionId === sectionId && d.fieldKey === fieldKey
);
return found?.fieldValue || '';
}
/** EAV 데이터에서 테이블 행 데이터 조회 */
function getTableRows(
data: FqcDocumentData[] | undefined,
columns: FqcTemplate['columns'],
): Array<Record<string, string>> {
if (!data) return [];
// column_id가 있는 데이터만 필터 → row_index로 그룹핑
const columnData = data.filter(d => d.columnId !== null);
if (columnData.length === 0) return [];
const rowMap = new Map<number, Record<string, string>>();
for (const d of columnData) {
if (!rowMap.has(d.rowIndex)) rowMap.set(d.rowIndex, {});
const row = rowMap.get(d.rowIndex)!;
row[d.fieldKey] = d.fieldValue || '';
}
return Array.from(rowMap.entries())
.sort(([a], [b]) => a - b)
.map(([, row]) => row);
}
export function FqcRequestDocumentContent({
template,
documentData,
documentNo,
createdDate,
}: FqcRequestDocumentContentProps) {
const { approvalLines, basicFields, sections, columns } = template;
// 섹션 분리: 입력사항 섹션 (items 있는 것) vs 사전 고지 섹션 (items 없는 것)
const inputSections = sections.filter(s => s.items.length > 0);
const noticeSections = sections.filter(s => s.items.length === 0);
const noticeSection = noticeSections[0]; // 검사대상 사전 고지 정보
// 기본필드를 2열로 배치하기 위한 페어링
const sortedFields = [...basicFields].sort((a, b) => a.sortOrder - b.sortOrder);
const fieldPairs: Array<[typeof sortedFields[0], typeof sortedFields[0] | undefined]> = [];
for (let i = 0; i < sortedFields.length; i += 2) {
fieldPairs.push([sortedFields[i], sortedFields[i + 1]]);
}
// 테이블 행 데이터
const tableRows = getTableRows(documentData, columns);
const sortedColumns = [...columns].sort((a, b) => a.sortOrder - b.sortOrder);
// group_name으로 컬럼 그룹 분석 (병합 헤더용)
const groupInfo = buildGroupInfo(sortedColumns);
return (
<DocumentWrapper fontSize="text-[11px]">
{/* 헤더: 제목 + 결재란 */}
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-2xl font-bold tracking-widest mb-2">
{template.title || template.name}
</h1>
<div className="text-[10px] space-y-1">
<div className="flex gap-4">
{documentNo && <span>: <strong>{documentNo}</strong></span>}
{createdDate && <span>: <strong>{createdDate}</strong></span>}
</div>
</div>
</div>
<ConstructionApprovalTable
approvers={{
writer: approvalLines[0]
? { name: '', department: approvalLines[0].department }
: undefined,
approver1: approvalLines[1]
? { name: '', department: approvalLines[1].department }
: undefined,
approver2: approvalLines[2]
? { name: '', department: approvalLines[2].department }
: undefined,
approver3: approvalLines[3]
? { name: '', department: approvalLines[3].department }
: undefined,
}}
/>
</div>
{/* 기본 정보 */}
<DocumentTable header="기본 정보" headerVariant="light" spacing="mb-4">
<tbody>
{fieldPairs.map(([left, right], idx) => (
<tr
key={idx}
className={idx < fieldPairs.length - 1 ? 'border-b border-gray-300' : ''}
>
<td className={lbl}>{left.label}</td>
<td className={right ? `${val} border-r border-gray-300` : val} colSpan={right ? 1 : 3}>
{getFieldValue(documentData, left.fieldKey) || '-'}
</td>
{right && (
<>
<td className={lbl}>{right.label}</td>
<td className={val}>
{getFieldValue(documentData, right.fieldKey) || '-'}
</td>
</>
)}
</tr>
))}
</tbody>
</DocumentTable>
{/* 입력사항: 동적 섹션 */}
{inputSections.length > 0 && (
<div className="border border-gray-400 mb-4">
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">
</div>
{inputSections.map((section, sIdx) => (
<div
key={section.id}
className={sIdx < inputSections.length - 1 ? 'border-b border-gray-300' : ''}
>
<div className="bg-gray-100 px-2 py-1 font-medium border-b border-gray-300">
{section.title || section.name}
</div>
<table className="w-full">
<tbody>
{buildSectionRows(section, documentData).map((row, rIdx) => (
<tr
key={rIdx}
className={rIdx < buildSectionRows(section, documentData).length - 1 ? 'border-b border-gray-300' : ''}
>
{row.map((cell, cIdx) => (
<td
key={cIdx}
className={
cell.isLabel
? `${subLbl}${cell.width ? ` ${cell.width}` : ''}`
: cIdx < row.length - 1
? `${val} border-r border-gray-300`
: val
}
>
{cell.value || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
)}
{/* 검사 요청 시 필독 (사전 고지 섹션의 description) */}
{noticeSection?.description && (
<DocumentTable header="검사 요청 시 필독" headerVariant="dark" spacing="mb-4">
<tbody>
<tr>
<td className="px-4 py-3 text-[11px] leading-relaxed text-center">
<p>{noticeSection.description}</p>
</td>
</tr>
</tbody>
</DocumentTable>
)}
{/* 검사대상 사전 고지 정보 테이블 */}
{sortedColumns.length > 0 && (
<DocumentTable
header={noticeSection?.title || '검사대상 사전 고지 정보'}
headerVariant="dark"
spacing="mb-4"
>
<thead>
{/* 그룹 헤더가 있으면 3단 헤더 */}
{groupInfo.hasGroups ? (
<>
<tr className="bg-gray-100 border-b border-gray-300">
{groupInfo.topRow.map((cell, i) => (
<th
key={i}
className={`${DOC_STYLES.th}${i === groupInfo.topRow.length - 1 ? ' border-r-0' : ''}`}
colSpan={cell.colSpan}
rowSpan={cell.rowSpan}
style={cell.width ? { width: cell.width } : undefined}
>
{cell.label}
</th>
))}
</tr>
<tr className="bg-gray-100 border-b border-gray-300">
{groupInfo.midRow.map((cell, i) => (
<th key={i} className={DOC_STYLES.th} colSpan={cell.colSpan}>
{cell.label}
</th>
))}
</tr>
<tr className="bg-gray-100 border-b border-gray-400">
{groupInfo.botRow.map((cell, i) => (
<th
key={i}
className={DOC_STYLES.th}
style={cell.width ? { width: cell.width } : undefined}
>
{cell.label}
</th>
))}
</tr>
</>
) : (
<tr className="bg-gray-100 border-b border-gray-400">
{sortedColumns.map((col, i) => (
<th
key={col.id}
className={`${DOC_STYLES.th}${i === sortedColumns.length - 1 ? ' border-r-0' : ''}`}
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
</tr>
)}
</thead>
<tbody>
{tableRows.length > 0 ? (
tableRows.map((row, rIdx) => (
<tr key={rIdx} className="border-b border-gray-300">
{sortedColumns.map((col, cIdx) => (
<td
key={col.id}
className={
cIdx === sortedColumns.length - 1
? DOC_STYLES.td
: DOC_STYLES.tdCenter
}
>
{col.label === 'No.' ? rIdx + 1 : (row[col.label] || '-')}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={sortedColumns.length} className="px-2 py-4 text-center text-gray-400">
.
</td>
</tr>
)}
</tbody>
</DocumentTable>
)}
{/* 서명 영역 */}
<div className="mt-8 text-center text-[10px]">
<p> .</p>
<div className="mt-6">
<p>{createdDate || ''}</p>
</div>
</div>
</DocumentWrapper>
);
}
// ===== 유틸 함수 =====
interface CellInfo {
isLabel: boolean;
value: string;
width?: string;
}
/** 섹션 아이템을 2열 레이아웃의 행으로 변환 */
function buildSectionRows(
section: FqcTemplate['sections'][0],
data?: FqcDocumentData[],
): CellInfo[][] {
const items = [...section.items].sort((a, b) => a.sortOrder - b.sortOrder);
const rows: CellInfo[][] = [];
// 3개 아이템이면 한 행에 3개, 그 외 2개씩
if (items.length === 3) {
rows.push(
items.map((item, i) => [
{ isLabel: true, value: item.itemName, width: i === 2 ? 'w-20' : undefined },
{ isLabel: false, value: getSectionItemValue(data, section.id, item.itemName) },
]).flat()
);
} else {
for (let i = 0; i < items.length; i += 2) {
const left = items[i];
const right = items[i + 1];
const row: CellInfo[] = [
{ isLabel: true, value: left.itemName },
{ isLabel: false, value: getSectionItemValue(data, section.id, left.itemName) },
];
if (right) {
row.push(
{ isLabel: true, value: right.itemName },
{ isLabel: false, value: getSectionItemValue(data, section.id, right.itemName) },
);
}
rows.push(row);
}
}
return rows;
}
interface HeaderCell {
label: string;
colSpan?: number;
rowSpan?: number;
width?: string;
}
interface GroupInfo {
hasGroups: boolean;
topRow: HeaderCell[];
midRow: HeaderCell[];
botRow: HeaderCell[];
}
/** 컬럼 group_name을 분석하여 3단 헤더 구조 생성 */
function buildGroupInfo(columns: FqcTemplate['columns']): GroupInfo {
const groups = columns.filter(c => c.groupName);
if (groups.length === 0) return { hasGroups: false, topRow: [], midRow: [], botRow: [] };
// group_name별로 그룹핑
const groupMap = new Map<string, typeof columns>();
for (const col of columns) {
if (col.groupName) {
if (!groupMap.has(col.groupName)) groupMap.set(col.groupName, []);
groupMap.get(col.groupName)!.push(col);
}
}
// 오픈사이즈(발주규격), 오픈사이즈(시공후규격) 패턴 감지
// group_name 패턴: "오픈사이즈(발주규격)", "오픈사이즈(시공후규격)"
const parentGroups = new Map<string, string[]>();
for (const gName of groupMap.keys()) {
const match = gName.match(/^(.+?)\((.+?)\)$/);
if (match) {
const parent = match[1];
if (!parentGroups.has(parent)) parentGroups.set(parent, []);
parentGroups.get(parent)!.push(gName);
}
}
const topRow: HeaderCell[] = [];
const midRow: HeaderCell[] = [];
const botRow: HeaderCell[] = [];
let colIdx = 0;
while (colIdx < columns.length) {
const col = columns[colIdx];
if (!col.groupName) {
// 그룹이 없는 독립 컬럼 → rowSpan=3
topRow.push({ label: col.label, rowSpan: 3, width: col.width || undefined });
colIdx++;
} else {
// 그룹 컬럼 → 상위 그룹 확인
const match = col.groupName.match(/^(.+?)\((.+?)\)$/);
if (match) {
const parentName = match[1];
const subGroups = parentGroups.get(parentName) || [];
// 상위 그룹의 모든 하위 컬럼 수
let totalCols = 0;
for (const sg of subGroups) {
totalCols += groupMap.get(sg)!.length;
}
topRow.push({ label: parentName, colSpan: totalCols });
// 중간행: 각 하위 그룹
for (const sg of subGroups) {
const subMatch = sg.match(/\((.+?)\)$/);
const subLabel = subMatch ? subMatch[1] : sg;
const subCols = groupMap.get(sg)!;
midRow.push({ label: subLabel, colSpan: subCols.length });
// 하단행: 실제 컬럼 라벨
for (const sc of subCols) {
// 라벨에서 그룹 프리픽스 제거 (발주 가로 → 가로)
const cleanLabel = sc.label.replace(/^(발주|시공)\s*/, '');
botRow.push({ label: cleanLabel, width: sc.width || undefined });
}
}
// 이 그룹에 속한 컬럼 수만큼 건너뛰기
colIdx += totalCols;
} else {
// 단순 그룹 (parentGroup 없이)
const gCols = groupMap.get(col.groupName)!;
topRow.push({ label: col.groupName, colSpan: gCols.length, rowSpan: 2 });
for (const gc of gCols) {
botRow.push({ label: gc.label, width: gc.width || undefined });
}
colIdx += gCols.length;
}
}
}
return { hasGroups: true, topRow, midRow, botRow };
}

View File

@@ -11,7 +11,7 @@
* Fallback: 문서가 없는 경우 기존 하드코딩 InspectionReportDocument 사용
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ChevronLeft, ChevronRight, Loader2, AlertCircle } from 'lucide-react';
@@ -36,8 +36,6 @@ interface InspectionReportModalProps {
inspection?: ProductInspection | null;
/** 페이지네이션용: orderItems (수정 모드에서는 formData.orderItems) */
orderItems?: OrderSettingItem[];
/** FQC 문서 ID 매핑 (orderItemId → documentId) */
fqcDocumentMap?: Record<string, number>;
}
export function InspectionReportModal({
@@ -46,19 +44,23 @@ export function InspectionReportModal({
data,
inspection,
orderItems,
fqcDocumentMap,
}: InspectionReportModalProps) {
const [currentPage, setCurrentPage] = useState(1);
const [inputPage, setInputPage] = useState('1');
// rendered_html 캡처용 ref (Phase 1.3 준비)
const contentWrapperRef = useRef<HTMLDivElement>(null);
// FQC 문서/양식 상태
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(null);
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
const [fqcError, setFqcError] = useState<string | null>(null);
const [templateLoadFailed, setTemplateLoadFailed] = useState(false);
// 양식 기반 모드 사용 여부
const useFqcMode = !!fqcDocumentMap && Object.keys(fqcDocumentMap).length > 0;
// FQC 모드 우선 (orderItems에 documentId가 있으면 FQC 문서 존재)
const hasFqcDocuments = !!orderItems && orderItems.some((i) => i.documentId);
const useFqcMode = !templateLoadFailed && (hasFqcDocuments || !!fqcTemplate);
// 총 페이지 수
const totalPages = useMemo(() => {
@@ -73,27 +75,30 @@ export function InspectionReportModal({
setInputPage('1');
setFqcDocument(null);
setFqcError(null);
setTemplateLoadFailed(false);
}
}, [open]);
// FQC 양식 로드 (한 번만)
// FQC 양식 로드 (항상 시도, template 로드 실패 시 legacy fallback)
useEffect(() => {
if (!open || !useFqcMode || fqcTemplate) return;
if (!open || fqcTemplate || templateLoadFailed) return;
getFqcTemplate().then(result => {
if (result.success && result.data) {
setFqcTemplate(result.data);
} else {
setTemplateLoadFailed(true);
}
});
}, [open, useFqcMode, fqcTemplate]);
}, [open, fqcTemplate, templateLoadFailed]);
// 페이지 변경 시 FQC 문서 로드
useEffect(() => {
if (!open || !useFqcMode || !orderItems || !fqcDocumentMap) return;
if (!open || !hasFqcDocuments || !orderItems) return;
const currentItem = orderItems[currentPage - 1];
if (!currentItem) return;
const documentId = fqcDocumentMap[currentItem.id];
const documentId = currentItem.documentId;
if (!documentId) {
setFqcDocument(null);
setFqcError(null);
@@ -113,7 +118,7 @@ export function InspectionReportModal({
}
})
.finally(() => setIsLoadingFqc(false));
}, [open, useFqcMode, currentPage, orderItems, fqcDocumentMap]);
}, [open, useFqcMode, currentPage, orderItems]);
// 기존 모드: 현재 페이지 문서 데이터 (fallback)
const legacyCurrentData = useMemo(() => {
@@ -243,13 +248,23 @@ export function InspectionReportModal({
<p>{fqcError}</p>
</div>
) : fqcDocument && fqcTemplate ? (
<FqcDocumentContent
template={fqcTemplate}
documentData={fqcDocument.data}
documentNo={fqcDocument.documentNo}
createdDate={formatDate(fqcDocument.createdAt)}
readonly={true}
/>
<div ref={contentWrapperRef}>
<FqcDocumentContent
template={fqcTemplate}
documentData={fqcDocument.data}
documentNo={fqcDocument.documentNo}
createdDate={formatDate(fqcDocument.createdAt)}
readonly={true}
/>
</div>
) : fqcTemplate && !hasFqcDocuments ? (
// template은 있지만 문서가 없는 경우 → legacy fallback
legacyCurrentData ? <InspectionReportDocument data={legacyCurrentData} /> : (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<AlertCircle className="w-8 h-8 mb-2" />
<p> FQC .</p>
</div>
)
) : (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<AlertCircle className="w-8 h-8 mb-2" />

View File

@@ -2,25 +2,115 @@
/**
* 제품검사요청서 모달
* DocumentViewer를 사용하여 문서 표시 + 인쇄/PDF 기능 제공
*
* 양식 기반 전환:
* - getFqcRequestTemplate()로 template 66 조회
* - requestDocumentId가 있으면 EAV 문서 로드 → FqcRequestDocumentContent로 렌더링
* - Lazy Snapshot: rendered_html이 없으면 렌더링 완료 후 자동 캡처/저장
* - Fallback: template 로드 실패 시 기존 InspectionRequestDocument 사용
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { Loader2, AlertCircle } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { FqcRequestDocumentContent } from './FqcRequestDocumentContent';
import { InspectionRequestDocument } from './InspectionRequestDocument';
import { getFqcRequestTemplate, getFqcDocument, patchDocumentSnapshot } from '../fqcActions';
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
import type { FqcTemplate, FqcDocument } from '../fqcActions';
interface InspectionRequestModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: InspectionRequestDocumentType | null;
/** EAV 요청서 문서 ID (API에서 자동생성된 Document ID) */
requestDocumentId?: number | null;
}
export function InspectionRequestModal({
open,
onOpenChange,
data,
requestDocumentId,
}: InspectionRequestModalProps) {
if (!data) return null;
const contentWrapperRef = useRef<HTMLDivElement>(null);
const snapshotSentRef = useRef(false);
// FQC 양식/문서 상태
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(null);
const [templateLoadFailed, setTemplateLoadFailed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const useFqcMode = !templateLoadFailed && !!fqcTemplate;
// 모달 열릴 때 초기화
useEffect(() => {
if (open) {
setTemplateLoadFailed(false);
setFqcDocument(null);
snapshotSentRef.current = false;
}
}, [open]);
// 양식 로드
useEffect(() => {
if (!open || fqcTemplate || templateLoadFailed) return;
setIsLoading(true);
getFqcRequestTemplate()
.then(result => {
if (result.success && result.data) {
setFqcTemplate(result.data);
} else {
setTemplateLoadFailed(true);
}
})
.finally(() => setIsLoading(false));
}, [open, fqcTemplate, templateLoadFailed]);
// EAV 문서 로드 (requestDocumentId가 있는 경우)
useEffect(() => {
if (!open || !requestDocumentId || !fqcTemplate) return;
setIsLoading(true);
getFqcDocument(requestDocumentId)
.then(result => {
if (result.success && result.data) {
setFqcDocument(result.data);
}
})
.finally(() => setIsLoading(false));
}, [open, requestDocumentId, fqcTemplate]);
// Lazy Snapshot: FQC 모드 렌더링 완료 후 rendered_html 캡처
const captureSnapshot = useCallback(() => {
if (snapshotSentRef.current || !requestDocumentId || !contentWrapperRef.current) return;
// 렌더링 완료 대기 (다음 프레임)
requestAnimationFrame(() => {
const html = contentWrapperRef.current?.innerHTML;
if (html && html.length > 50) {
snapshotSentRef.current = true;
patchDocumentSnapshot(requestDocumentId, html).catch(() => {
// 실패해도 UI에 영향 없음
});
}
});
}, [requestDocumentId]);
// FQC 문서 렌더링 완료 시 스냅샷 캡처
useEffect(() => {
if (!useFqcMode || isLoading || !fqcDocument) return;
captureSnapshot();
}, [useFqcMode, isLoading, fqcDocument, captureSnapshot]);
if (!data && !useFqcMode) return null;
const documentNo = fqcDocument?.documentNo ?? data?.documentNumber;
const createdDate = data?.createdDate;
const pdfMeta = documentNo
? { documentNumber: documentNo, createdDate: createdDate ?? '' }
: undefined;
return (
<DocumentViewer
@@ -28,12 +118,31 @@ export function InspectionRequestModal({
preset="readonly"
open={open}
onOpenChange={onOpenChange}
pdfMeta={{
documentNumber: data.documentNumber,
createdDate: data.createdDate,
}}
pdfMeta={pdfMeta}
>
<InspectionRequestDocument data={data} />
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
) : useFqcMode && fqcDocument ? (
<div ref={contentWrapperRef}>
<FqcRequestDocumentContent
template={fqcDocument.template ?? fqcTemplate}
documentData={fqcDocument.data}
documentNo={documentNo}
createdDate={createdDate}
readonly={true}
/>
</div>
) : data ? (
<InspectionRequestDocument data={data} />
) : (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<AlertCircle className="w-8 h-8 mb-2" />
<p> .</p>
</div>
)}
</DocumentViewer>
);
}

View File

@@ -3,3 +3,4 @@ export { InspectionRequestModal } from './InspectionRequestModal';
export { InspectionReportDocument } from './InspectionReportDocument';
export { InspectionReportModal } from './InspectionReportModal';
export { FqcDocumentContent } from './FqcDocumentContent';
export { FqcRequestDocumentContent } from './FqcRequestDocumentContent';

View File

@@ -26,6 +26,8 @@ interface TemplateItemApi {
measurement_type: string;
frequency: string;
sort_order: number;
category: string | null;
method: string | null;
}
/** 양식 섹션 */
@@ -45,6 +47,7 @@ interface TemplateColumnApi {
label: string;
column_type: string;
width: string | null;
group_name: string | null;
sort_order: number;
}
@@ -158,12 +161,15 @@ export interface FqcTemplateItem {
measurementType: string;
frequency: string;
sortOrder: number;
category: string;
method: string;
}
export interface FqcTemplateSection {
id: number;
name: string;
title: string | null;
description: string | null;
imagePath: string | null;
sortOrder: number;
items: FqcTemplateItem[];
@@ -174,6 +180,7 @@ export interface FqcTemplateColumn {
label: string;
columnType: string;
width: string | null;
groupName: string | null;
sortOrder: number;
}
@@ -276,6 +283,7 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate {
id: s.id,
name: s.name,
title: s.title,
description: s.description ?? null,
imagePath: s.image_path,
sortOrder: s.sort_order,
items: (s.items || []).map(item => ({
@@ -287,6 +295,8 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate {
measurementType: item.measurement_type,
frequency: item.frequency,
sortOrder: item.sort_order,
category: item.category || '',
method: item.method || '',
})),
})),
columns: (api.columns || []).map(c => ({
@@ -294,6 +304,7 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate {
label: c.label,
columnType: c.column_type,
width: c.width,
groupName: c.group_name ?? null,
sortOrder: c.sort_order,
})),
};
@@ -346,6 +357,7 @@ function transformFqcStatus(api: FqcStatusResponse): FqcStatus {
// ===== Server Actions =====
const FQC_TEMPLATE_ID = 65;
const FQC_REQUEST_TEMPLATE_ID = 66;
/**
* FQC 양식 상세 조회
@@ -364,6 +376,23 @@ export async function getFqcTemplate(): Promise<{
});
}
/**
* 제품검사 요청서 양식 상세 조회
* GET /v1/document-templates/{id}
*/
export async function getFqcRequestTemplate(): Promise<{
success: boolean;
data?: FqcTemplate;
error?: string;
__authError?: boolean;
}> {
return executeServerAction<DocumentTemplateApi, FqcTemplate>({
url: buildApiUrl(`/api/v1/document-templates/${FQC_REQUEST_TEMPLATE_ID}`),
transform: transformTemplate,
errorMessage: '제품검사 요청서 양식 조회에 실패했습니다.',
});
}
/**
* FQC 문서 일괄생성
* POST /v1/documents/bulk-create-fqc
@@ -431,6 +460,7 @@ export async function saveFqcDocument(params: {
templateId?: number;
itemId?: number;
title?: string;
renderedHtml?: string;
data: Array<{
section_id?: number | null;
column_id?: number | null;
@@ -451,6 +481,7 @@ export async function saveFqcDocument(params: {
if (params.documentId) body.document_id = params.documentId;
if (params.itemId) body.item_id = params.itemId;
if (params.title) body.title = params.title;
if (params.renderedHtml) body.rendered_html = params.renderedHtml;
return executeServerAction({
url: buildApiUrl('/api/v1/documents/upsert'),
@@ -458,4 +489,20 @@ export async function saveFqcDocument(params: {
body,
errorMessage: 'FQC 검사 데이터 저장에 실패했습니다.',
});
}
/**
* 문서 rendered_html 스냅샷 저장 (Lazy Snapshot)
* PATCH /v1/documents/{id}/snapshot
*/
export async function patchDocumentSnapshot(
documentId: number,
renderedHtml: string,
): Promise<{ success: boolean; error?: string }> {
return executeServerAction({
url: buildApiUrl(`/api/v1/documents/${documentId}/snapshot`),
method: 'PATCH',
body: { rendered_html: renderedHtml },
errorMessage: '스냅샷 저장에 실패했습니다.',
});
}

View File

@@ -62,13 +62,32 @@ const defaultSupervisor = {
// ===== Mock 수주 선택 목록 (모달용) =====
export const mockOrderSelectItems: OrderSelectItem[] = [
{ id: 'os-1', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
{ id: 'os-2', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
{ id: 'os-3', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
{ id: 'os-4', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
{ id: 'os-5', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
{ id: 'os-6', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
{ id: 'os-7', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
{ id: 'os-1', orderNumber: '123123', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 3, locations: [
{ nodeId: 101, floor: '1', symbol: 'A', orderWidth: 4100, orderHeight: 2700 },
{ nodeId: 102, floor: '2층', symbol: 'B', orderWidth: 3800, orderHeight: 2500 },
{ nodeId: 103, floor: '3층', symbol: 'C', orderWidth: 4200, orderHeight: 2800 },
] },
{ id: 'os-2', orderNumber: '123124', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 2, locations: [
{ nodeId: 201, floor: '1', symbol: 'D', orderWidth: 3500, orderHeight: 2400 },
{ nodeId: 202, floor: '2층', symbol: 'E', orderWidth: 3600, orderHeight: 2500 },
] },
{ id: 'os-3', orderNumber: '123125', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 20, itemName: '스크린', deliveryDate: '2026-01-01', locationCount: 1, locations: [
{ nodeId: 301, floor: '1층', symbol: 'F', orderWidth: 5000, orderHeight: 3000 },
] },
{ id: 'os-4', orderNumber: '123126', siteName: '현장명', clientId: 2, clientName: '발주처B', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 2, locations: [
{ nodeId: 401, floor: '1층', symbol: 'G', orderWidth: 4000, orderHeight: 2600 },
{ nodeId: 402, floor: '2층', symbol: 'H', orderWidth: 4100, orderHeight: 2700 },
] },
{ id: 'os-5', orderNumber: '123127', siteName: '현장명', clientId: 2, clientName: '발주처B', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 1, locations: [
{ nodeId: 501, floor: '1층', symbol: 'I', orderWidth: 3900, orderHeight: 2500 },
] },
{ id: 'os-6', orderNumber: '123128', siteName: '현장명', clientId: 3, clientName: '발주처C', itemId: 30, itemName: '절곡물', deliveryDate: '2026-01-01', locationCount: 2, locations: [
{ nodeId: 601, floor: '1층', symbol: 'J', orderWidth: 2000, orderHeight: 1500 },
{ nodeId: 602, floor: '2층', symbol: 'K', orderWidth: 2100, orderHeight: 1600 },
] },
{ id: 'os-7', orderNumber: '123129', siteName: '현장명', clientId: 3, clientName: '발주처C', itemId: 30, itemName: '절곡물', deliveryDate: '2026-01-01', locationCount: 1, locations: [
{ nodeId: 701, floor: '1층', symbol: 'L', orderWidth: 2200, orderHeight: 1700 },
] },
];
// ===== Mock 수주 설정 항목 =====

View File

@@ -61,6 +61,10 @@ export interface OrderSettingItem {
orderId?: number; // 수주 DB ID (FQC 문서 연동용)
orderNumber: string; // 수주번호
siteName: string; // 현장명
clientId?: number | null; // 발주처 ID
clientName?: string; // 발주처명
itemId?: number | null; // 품목(모델) ID
itemName?: string; // 품목(모델)명
deliveryDate: string; // 납품일
floor: string; // 층수
symbol: string; // 부호
@@ -69,6 +73,8 @@ export interface OrderSettingItem {
constructionWidth: number; // 시공 규격 - 가로
constructionHeight: number; // 시공 규격 - 세로
changeReason: string; // 변경사유
// FQC 성적서 EAV 문서 ID (quality_document_locations.document_id)
documentId?: number | null;
// 검사 결과 데이터
inspectionData?: ProductInspectionData;
}
@@ -120,8 +126,22 @@ export interface OrderSelectItem {
id: string;
orderNumber: string; // 수주번호
siteName: string; // 현장명
clientId: number | null; // 발주처 ID
clientName: string; // 발주처
itemId: number | null; // 품목(모델) ID
itemName: string; // 품목(모델)명
deliveryDate: string; // 납품일
locationCount: number; // 개소
locations: OrderSelectLocation[]; // 개소 상세
}
// 수주의 개소(root node) 정보
export interface OrderSelectLocation {
nodeId: number;
floor: string;
symbol: string;
orderWidth: number;
orderHeight: number;
}
// ===== 메인 데이터 =====
@@ -153,6 +173,9 @@ export interface ProductInspection {
// 수주 설정
orderItems: OrderSettingItem[];
// EAV 요청서 문서 ID (자동생성)
requestDocumentId?: number | null;
}
// ===== 통계 =====
@@ -181,6 +204,9 @@ export interface InspectionFormData {
qualityDocNumber: string;
siteName: string;
client: string;
clientId?: number; // 수주처 ID (API용)
inspectorId?: number; // 검사자 ID (API용)
receptionDate?: string; // 접수일 (API용)
manager: string;
managerContact: string;
constructionSite: ConstructionSiteInfo;

View File

@@ -4,13 +4,12 @@
* 실적신고관리 Server Actions
*
* API Endpoints:
* - GET /api/v1/performance-reports - 분기별 실적신고 목록
* - GET /api/v1/performance-reports/stats - 통계
* - GET /api/v1/performance-reports/missed - 누락체크 목록
* - PATCH /api/v1/performance-reports/confirm - 선택 확정
* - PATCH /api/v1/performance-reports/unconfirm - 확정 해제
* - POST /api/v1/performance-reports/distribute - 배포
* - PATCH /api/v1/performance-reports/memo - 메모 일괄 적용
* - GET /api/v1/quality/performance-reports - 분기별 실적신고 목록
* - GET /api/v1/quality/performance-reports/stats - 통계
* - GET /api/v1/quality/performance-reports/missing - 누락체크 목록
* - PATCH /api/v1/quality/performance-reports/confirm - 선택 확정
* - PATCH /api/v1/quality/performance-reports/unconfirm - 확정 해제
* - PATCH /api/v1/quality/performance-reports/memo - 메모 일괄 적용
*/
import { executeServerAction } from '@/lib/api/execute-server-action';
@@ -28,7 +27,50 @@ import {
} from './mockData';
// 개발환경 Mock 데이터 fallback 플래그
const USE_MOCK_FALLBACK = true;
const USE_MOCK_FALLBACK = false;
// ===== API 응답 → 프론트 타입 변환 =====
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformReport(item: any): PerformanceReport {
return {
id: String(item.id ?? ''),
qualityDocNumber: item.quality_doc_number ?? '',
createdDate: item.created_date ?? '',
siteName: item.site_name ?? '',
client: item.client ?? '',
locationCount: item.location_count ?? 0,
requiredInfo: item.required_info ?? '',
confirmStatus: item.confirm_status === 'confirmed' ? '확정' : '미확정',
confirmDate: item.confirm_date ?? '',
memo: item.memo ?? '',
year: item.year ?? new Date().getFullYear(),
quarter: item.quarter ?? 'Q1',
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformStats(item: any): PerformanceReportStats {
return {
totalCount: item.total_count ?? 0,
confirmedCount: item.confirmed_count ?? 0,
unconfirmedCount: item.unconfirmed_count ?? 0,
totalLocations: item.total_locations ?? 0,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformMissedReport(item: any): MissedReport {
return {
id: String(item.id ?? ''),
qualityDocNumber: item.quality_doc_number ?? item.order_number ?? '',
siteName: item.site_name ?? '',
client: item.client ?? '',
locationCount: item.location_count ?? 0,
inspectionCompleteDate: item.delivery_date ?? '',
memo: item.memo ?? '',
};
}
// ===== 페이지네이션 =====
@@ -58,7 +100,7 @@ export async function getPerformanceReports(params?: {
interface ApiListData { items?: PerformanceReport[]; current_page?: number; last_page?: number; per_page?: number; total?: number }
const result = await executeServerAction<ApiListData>({
url: buildApiUrl('/api/v1/performance-reports', {
url: buildApiUrl('/api/v1/quality/performance-reports', {
page: params?.page,
per_page: params?.size,
q: params?.q,
@@ -92,9 +134,11 @@ export async function getPerformanceReports(params?: {
}
const d = result.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items = (d?.items || []).map((item: any) => transformReport(item));
return {
success: true,
data: d?.items || [],
data: items,
pagination: {
currentPage: d?.current_page || 1,
lastPage: d?.last_page || 1,
@@ -116,7 +160,7 @@ export async function getPerformanceReportStats(params?: {
__authError?: boolean;
}> {
const result = await executeServerAction<PerformanceReportStats>({
url: buildApiUrl('/api/v1/performance-reports/stats', {
url: buildApiUrl('/api/v1/quality/performance-reports/stats', {
year: params?.year,
quarter: params?.quarter && params.quarter !== '전체' ? params.quarter : undefined,
}),
@@ -127,7 +171,7 @@ export async function getPerformanceReportStats(params?: {
if (USE_MOCK_FALLBACK) return { success: true, data: mockPerformanceReportStats };
return { success: false, error: result.error, __authError: result.__authError };
}
return { success: true, data: result.data };
return { success: true, data: result.data ? transformStats(result.data) : undefined };
}
// ===== 누락체크 목록 조회 =====
@@ -147,7 +191,7 @@ export async function getMissedReports(params?: {
interface ApiMissedData { items?: MissedReport[]; current_page?: number; last_page?: number; per_page?: number; total?: number }
const result = await executeServerAction<ApiMissedData>({
url: buildApiUrl('/api/v1/performance-reports/missed', {
url: buildApiUrl('/api/v1/quality/performance-reports/missing', {
page: params?.page,
per_page: params?.size,
q: params?.q,
@@ -177,9 +221,11 @@ export async function getMissedReports(params?: {
}
const d = result.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items = (d?.items || []).map((item: any) => transformMissedReport(item));
return {
success: true,
data: d?.items || [],
data: items,
pagination: {
currentPage: d?.current_page || 1,
lastPage: d?.last_page || 1,
@@ -197,7 +243,7 @@ export async function confirmReports(ids: string[]): Promise<{
__authError?: boolean;
}> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/performance-reports/confirm'),
url: buildApiUrl('/api/v1/quality/performance-reports/confirm'),
method: 'PATCH',
body: { ids },
errorMessage: '확정 처리에 실패했습니다.',
@@ -214,7 +260,7 @@ export async function unconfirmReports(ids: string[]): Promise<{
__authError?: boolean;
}> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/performance-reports/unconfirm'),
url: buildApiUrl('/api/v1/quality/performance-reports/unconfirm'),
method: 'PATCH',
body: { ids },
errorMessage: '확정 해제에 실패했습니다.',
@@ -223,7 +269,7 @@ export async function unconfirmReports(ids: string[]): Promise<{
return { success: result.success, error: result.error, __authError: result.__authError };
}
// ===== 배포 =====
// ===== 배포 (TODO: 백엔드 API 미구현 - 추후 추가 예정) =====
export async function distributeReports(ids: string[]): Promise<{
success: boolean;
@@ -231,12 +277,11 @@ export async function distributeReports(ids: string[]): Promise<{
__authError?: boolean;
}> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/performance-reports/distribute'),
url: buildApiUrl('/api/v1/quality/performance-reports/distribute'),
method: 'POST',
body: { ids },
errorMessage: '배포에 실패했습니다.',
});
if (!result.success && USE_MOCK_FALLBACK) return { success: true };
return { success: result.success, error: result.error, __authError: result.__authError };
}
@@ -248,7 +293,7 @@ export async function updateMemo(ids: string[], memo: string): Promise<{
__authError?: boolean;
}> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/performance-reports/memo'),
url: buildApiUrl('/api/v1/quality/performance-reports/memo'),
method: 'PATCH',
body: { ids, memo },
errorMessage: '메모 저장에 실패했습니다.',

View File

@@ -0,0 +1,36 @@
/**
* 오프스크린 렌더링으로 React 컴포넌트의 HTML을 캡처하는 유틸리티
*
* 사용 사례: 입력 화면에서 저장 시, 해당 데이터의 "문서 뷰" HTML을 캡처하여
* rendered_html로 저장 (MNG 출력용)
*/
import { createRoot } from 'react-dom/client';
import { flushSync } from 'react-dom';
/**
* React 엘리먼트를 오프스크린에서 렌더링하고 innerHTML을 캡처합니다.
*
* @param element - 렌더링할 React 엘리먼트 (예: <ImportInspectionDocument template={...} readOnly />)
* @returns 캡처된 HTML 문자열, 실패 시 undefined
*/
export function captureRenderedHtml(element: React.ReactElement): string | undefined {
try {
const container = document.createElement('div');
container.style.cssText = 'position:fixed;left:-9999px;top:0;visibility:hidden;width:210mm;';
document.body.appendChild(container);
const root = createRoot(container);
flushSync(() => {
root.render(element);
});
const html = container.innerHTML;
root.unmount();
document.body.removeChild(container);
return html && html.length > 50 ? html : undefined;
} catch {
return undefined;
}
}