feat(WEB): 수입검사 관리 대폭 개선, 캘린더 DayTimeView 추가 및 출고 기능 보완
- 수입검사: InspectionCreate/Detail/List 대폭 개선, OrderSelectModal/문서 컴포넌트 신규 추가 - 수입검사: actions/types/mockData/inspectionConfig 전면 리팩토링 - QMS: InspectionModalV2/ImportInspectionDocument 개선 - 캘린더: DayTimeView 신규 추가, CalendarHeader/ScheduleCalendar/utils 확장 - 출고: ShipmentDetail/List/actions 개선, ShipmentOrderDocument/ShippingSlip 수정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { AlertCircle, Loader2, Save } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { Document, DocumentItem } from '../types';
|
||||
import { MOCK_ORDER_DATA, MOCK_SHIPMENT_DETAIL } from '../mockData';
|
||||
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
JointbarInspectionDocument,
|
||||
QualityDocumentUploader,
|
||||
} from './documents';
|
||||
import type { ImportInspectionTemplate } from './documents/ImportInspectionDocument';
|
||||
import type { ImportInspectionTemplate, ImportInspectionRef } from './documents/ImportInspectionDocument';
|
||||
|
||||
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
|
||||
import {
|
||||
@@ -293,6 +295,10 @@ export const InspectionModalV2 = ({
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
|
||||
const [templateError, setTemplateError] = useState<string | null>(null);
|
||||
|
||||
// 수입검사 저장용 ref/상태
|
||||
const importDocRef = useRef<ImportInspectionRef>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 수입검사 템플릿 로드 (모달 열릴 때)
|
||||
useEffect(() => {
|
||||
if (isOpen && doc?.type === 'import' && itemName && specification) {
|
||||
@@ -385,6 +391,23 @@ export const InspectionModalV2 = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 수입검사 저장 핸들러
|
||||
const handleImportSave = useCallback(async () => {
|
||||
if (!importDocRef.current) return;
|
||||
|
||||
const data = importDocRef.current.getInspectionData();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: 실제 저장 API 연동
|
||||
console.log('[InspectionModalV2] 수입검사 저장 데이터:', data);
|
||||
toast.success('검사 데이터가 저장되었습니다.');
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 수입검사 문서 렌더링 (Lazy Loading)
|
||||
const renderImportInspectionDocument = () => {
|
||||
if (isLoadingTemplate) {
|
||||
@@ -396,7 +419,7 @@ export const InspectionModalV2 = ({
|
||||
}
|
||||
|
||||
// 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용
|
||||
return <ImportInspectionDocument template={importTemplate || undefined} />;
|
||||
return <ImportInspectionDocument ref={importDocRef} template={importTemplate || undefined} />;
|
||||
};
|
||||
|
||||
// 문서 타입에 따른 컨텐츠 렌더링
|
||||
@@ -433,6 +456,18 @@ export const InspectionModalV2 = ({
|
||||
console.log('[InspectionModalV2] 다운로드 요청:', doc.type);
|
||||
};
|
||||
|
||||
// 수입검사 저장 버튼 (toolbarExtra)
|
||||
const importToolbarExtra = doc.type === 'import' ? (
|
||||
<Button onClick={handleImportSave} disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title={doc.title}
|
||||
@@ -441,6 +476,7 @@ export const InspectionModalV2 = ({
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
onDownload={handleDownload}
|
||||
toolbarExtra={importToolbarExtra}
|
||||
>
|
||||
{renderDocumentContent()}
|
||||
</DocumentViewer>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
* - API 데이터 기반 동적 렌더링
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { DocumentHeader, QualityApprovalTable } from '@/components/document-system';
|
||||
import React, { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
@@ -92,6 +91,11 @@ export interface ImportInspectionTemplate {
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
/** ref를 통한 데이터 접근 인터페이스 */
|
||||
export interface ImportInspectionRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
/** 컴포넌트 Props */
|
||||
export interface ImportInspectionDocumentProps {
|
||||
template?: ImportInspectionTemplate;
|
||||
@@ -325,12 +329,12 @@ const isValueInRange = (
|
||||
// 컴포넌트
|
||||
// ============================================
|
||||
|
||||
export const ImportInspectionDocument = ({
|
||||
export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportInspectionDocumentProps>(function ImportInspectionDocument({
|
||||
template = MOCK_EGI_TEMPLATE,
|
||||
initialValues,
|
||||
onValuesChange,
|
||||
readOnly = false,
|
||||
}: ImportInspectionDocumentProps) => {
|
||||
}, ref) {
|
||||
// 검사 항목별 입력값 상태
|
||||
const [values, setValues] = useState<Record<string, InspectionItemValue>>(() => {
|
||||
const initial: Record<string, InspectionItemValue> = {};
|
||||
@@ -416,6 +420,22 @@ export const ImportInspectionDocument = ({
|
||||
return null;
|
||||
}, [values, template.inspectionItems]);
|
||||
|
||||
// ref를 통한 데이터 접근
|
||||
useImperativeHandle(ref, () => ({
|
||||
getInspectionData: () => ({
|
||||
templateId: template.templateId,
|
||||
values: Object.values(values),
|
||||
overallResult,
|
||||
}),
|
||||
}), [template.templateId, values, overallResult]);
|
||||
|
||||
// 날짜 포맷
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
// OK/NG 선택 핸들러
|
||||
const handleResultChange = useCallback((itemId: string, result: JudgmentResult) => {
|
||||
if (readOnly) return;
|
||||
@@ -599,18 +619,40 @@ export const ImportInspectionDocument = ({
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 w-full text-sm shadow-sm print:shadow-none">
|
||||
{/* 문서 헤더 */}
|
||||
<DocumentHeader
|
||||
title="수 입 검 사 성 적 서"
|
||||
logo={{ text: 'KD', subtext: '경동기업' }}
|
||||
customApproval={
|
||||
<QualityApprovalTable
|
||||
type="2col"
|
||||
approvers={{ writer: headerInfo.approvers.writer }}
|
||||
reportDate={headerInfo.reportDate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* 문서 헤더 (중간검사 성적서 스타일) */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">수입검사 성적서</h1>
|
||||
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||
문서번호: {headerInfo.lotNo || template.templateId} | 작성일자: {fullDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 결재란 */}
|
||||
<table className="border-collapse text-sm flex-shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}>결<br/>재</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">작성</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center">{headerInfo.approvers.writer || '-'}</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 테이블 - 6컬럼 구조 */}
|
||||
<table className="w-full border-collapse mb-4 text-xs">
|
||||
@@ -807,4 +849,4 @@ export const ImportInspectionDocument = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -107,6 +107,9 @@ export const MOCK_SHIPMENT_DETAIL: ShipmentDetail = {
|
||||
driverName: '최운전',
|
||||
driverContact: '010-5555-6666',
|
||||
remarks: '하차 시 주의 요망',
|
||||
vehicleDispatches: [],
|
||||
productGroups: [],
|
||||
otherParts: [],
|
||||
};
|
||||
|
||||
// 품질관리서 목록
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import type { CalendarHeaderProps, CalendarView } from './types';
|
||||
import { formatYearMonth } from './utils';
|
||||
import { formatYearMonth, formatYearMonthDay } from './utils';
|
||||
|
||||
/**
|
||||
* 달력 헤더 컴포넌트
|
||||
@@ -27,6 +27,11 @@ export function CalendarHeader({
|
||||
{ value: 'month', label: '월' },
|
||||
];
|
||||
|
||||
// 뷰에 따른 날짜 표시 형식
|
||||
const dateLabel = view === 'day-time'
|
||||
? formatYearMonthDay(currentDate)
|
||||
: formatYearMonth(currentDate);
|
||||
|
||||
// 뷰 전환 버튼 렌더링 (재사용)
|
||||
const renderViewTabs = (className?: string) => (
|
||||
<div className={cn('flex rounded-md border', className)}>
|
||||
@@ -71,7 +76,7 @@ export function CalendarHeader({
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-bold text-center whitespace-nowrap px-1">
|
||||
{formatYearMonth(currentDate)}
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
@@ -111,8 +116,8 @@ export function CalendarHeader({
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-lg font-bold min-w-[120px] text-center">
|
||||
{formatYearMonth(currentDate)}
|
||||
<span className="text-lg font-bold min-w-[120px] text-center whitespace-nowrap">
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
|
||||
166
src/components/common/ScheduleCalendar/DayTimeView.tsx
Normal file
166
src/components/common/ScheduleCalendar/DayTimeView.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { formatDate, checkIsToday } from './utils';
|
||||
import { EVENT_COLORS } from './types';
|
||||
import type { DayTimeViewProps, ScheduleEvent } from './types';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
|
||||
/**
|
||||
* 일간 시간축 뷰 (day-time)
|
||||
*
|
||||
* 좌측에 시간 라벨, 오른쪽에 해당 날짜의 이벤트 블록 표시
|
||||
* startTime이 없는 이벤트는 all-day 영역에 표시
|
||||
*/
|
||||
export function DayTimeView({
|
||||
currentDate,
|
||||
events,
|
||||
timeRange = { start: 1, end: 12 },
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
}: DayTimeViewProps) {
|
||||
const today = checkIsToday(currentDate);
|
||||
const dayStr = formatDate(currentDate, 'yyyy-MM-dd');
|
||||
const weekdayLabel = format(currentDate, 'EEEE', { locale: ko });
|
||||
|
||||
// 시간 슬롯 생성
|
||||
const timeSlots = useMemo(() => {
|
||||
const slots: { hour: number; label: string }[] = [];
|
||||
for (let h = timeRange.start; h <= timeRange.end; h++) {
|
||||
slots.push({ hour: h, label: `AM ${h}시` });
|
||||
}
|
||||
return slots;
|
||||
}, [timeRange]);
|
||||
|
||||
// 이벤트를 종일/시간 분류
|
||||
const { allDayEvents, timedEvents } = useMemo(() => {
|
||||
const allDay: ScheduleEvent[] = [];
|
||||
const timed: ScheduleEvent[] = [];
|
||||
|
||||
events.forEach((event) => {
|
||||
const eventStart = parseISO(event.startDate);
|
||||
const eventEnd = parseISO(event.endDate);
|
||||
const current = parseISO(dayStr);
|
||||
|
||||
if (current >= eventStart && current <= eventEnd) {
|
||||
if (event.startTime) {
|
||||
timed.push(event);
|
||||
} else {
|
||||
allDay.push(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { allDayEvents: allDay, timedEvents: timed };
|
||||
}, [events, dayStr]);
|
||||
|
||||
// 시간 문자열에서 hour 추출
|
||||
const getHourFromTime = (time: string): number => {
|
||||
const [hours] = time.split(':').map(Number);
|
||||
return hours;
|
||||
};
|
||||
|
||||
// 이벤트 색상 클래스
|
||||
const getEventColorClasses = (event: ScheduleEvent): string => {
|
||||
const color = event.color || 'blue';
|
||||
return EVENT_COLORS[color] || EVENT_COLORS.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[300px]">
|
||||
{/* 헤더: 시간라벨컬럼 + 날짜 */}
|
||||
<div className="grid grid-cols-[60px_1fr] border-b">
|
||||
<div className="border-r bg-muted/30" />
|
||||
<div
|
||||
className={cn(
|
||||
'text-center py-2 cursor-pointer transition-colors',
|
||||
today && 'bg-primary/5',
|
||||
)}
|
||||
onClick={() => onDateClick(currentDate)}
|
||||
>
|
||||
<div className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
today && 'text-primary font-semibold',
|
||||
)}>
|
||||
{weekdayLabel}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'text-sm font-medium',
|
||||
today && 'text-primary',
|
||||
)}>
|
||||
{format(currentDate, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All-day 영역 */}
|
||||
{allDayEvents.length > 0 && (
|
||||
<div className="grid grid-cols-[60px_1fr] border-b">
|
||||
<div className="border-r bg-muted/30 flex items-center justify-center">
|
||||
<span className="text-[10px] text-muted-foreground">종일</span>
|
||||
</div>
|
||||
<div className="p-0.5 min-h-[28px]">
|
||||
{allDayEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'text-[10px] px-1 py-0.5 rounded truncate cursor-pointer hover:opacity-80 mb-0.5',
|
||||
getEventColorClasses(event),
|
||||
)}
|
||||
title={event.title}
|
||||
onClick={() => onEventClick(event)}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 시간 그리드 */}
|
||||
{timeSlots.map((slot) => {
|
||||
const slotEvents = timedEvents.filter((event) => {
|
||||
if (!event.startTime) return false;
|
||||
return getHourFromTime(event.startTime) === slot.hour;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.hour}
|
||||
className="grid grid-cols-[60px_1fr] border-b last:border-b-0"
|
||||
>
|
||||
{/* 시간 라벨 */}
|
||||
<div className="border-r bg-muted/30 flex items-start justify-center pt-1">
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{slot.label}
|
||||
</span>
|
||||
</div>
|
||||
{/* 이벤트 셀 */}
|
||||
<div className={cn(
|
||||
'p-0.5 min-h-[40px] relative',
|
||||
today && 'bg-primary/[0.02]',
|
||||
)}>
|
||||
{slotEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-1 rounded cursor-pointer hover:opacity-80 mb-0.5 leading-tight',
|
||||
getEventColorClasses(event),
|
||||
)}
|
||||
title={event.title}
|
||||
onClick={() => onEventClick(event)}
|
||||
>
|
||||
<span className="line-clamp-2">{event.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { CalendarHeader } from './CalendarHeader';
|
||||
import { DayTimeView } from './DayTimeView';
|
||||
import { MonthView } from './MonthView';
|
||||
import { WeekView } from './WeekView';
|
||||
import { WeekTimeView } from './WeekTimeView';
|
||||
import type { ScheduleCalendarProps, CalendarView } from './types';
|
||||
import { getNextMonth, getPrevMonth } from './utils';
|
||||
import { getNextMonth, getPrevMonth, getNextDay, getPrevDay, getNextWeek, getPrevWeek } from './utils';
|
||||
|
||||
/**
|
||||
* 스케줄 달력 공통 컴포넌트
|
||||
@@ -64,23 +65,37 @@ export function ScheduleCalendar({
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 이전 달
|
||||
// 이전 (뷰에 따라 일/주/월 단위)
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
const newDate = getPrevMonth(currentDate);
|
||||
let newDate: Date;
|
||||
if (view === 'day-time') {
|
||||
newDate = getPrevDay(currentDate);
|
||||
} else if (view === 'week-time' || view === 'week') {
|
||||
newDate = getPrevWeek(currentDate);
|
||||
} else {
|
||||
newDate = getPrevMonth(currentDate);
|
||||
}
|
||||
if (controlledDate === undefined) {
|
||||
setInternalDate(newDate);
|
||||
}
|
||||
onMonthChange?.(newDate);
|
||||
}, [currentDate, controlledDate, onMonthChange]);
|
||||
}, [currentDate, view, controlledDate, onMonthChange]);
|
||||
|
||||
// 다음 달
|
||||
// 다음 (뷰에 따라 일/주/월 단위)
|
||||
const handleNextMonth = useCallback(() => {
|
||||
const newDate = getNextMonth(currentDate);
|
||||
let newDate: Date;
|
||||
if (view === 'day-time') {
|
||||
newDate = getNextDay(currentDate);
|
||||
} else if (view === 'week-time' || view === 'week') {
|
||||
newDate = getNextWeek(currentDate);
|
||||
} else {
|
||||
newDate = getNextMonth(currentDate);
|
||||
}
|
||||
if (controlledDate === undefined) {
|
||||
setInternalDate(newDate);
|
||||
}
|
||||
onMonthChange?.(newDate);
|
||||
}, [currentDate, controlledDate, onMonthChange]);
|
||||
}, [currentDate, view, controlledDate, onMonthChange]);
|
||||
|
||||
// 뷰 변경
|
||||
const handleViewChange = useCallback((newView: CalendarView) => {
|
||||
@@ -130,6 +145,15 @@ export function ScheduleCalendar({
|
||||
<div className="flex items-center justify-center min-h-[300px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : view === 'day-time' ? (
|
||||
<DayTimeView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
selectedDate={selectedDate}
|
||||
timeRange={timeRange}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
) : view === 'week-time' ? (
|
||||
<WeekTimeView
|
||||
currentDate={currentDate}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/**
|
||||
* 달력 뷰 모드
|
||||
*/
|
||||
export type CalendarView = 'week' | 'month' | 'week-time';
|
||||
export type CalendarView = 'day-time' | 'week' | 'month' | 'week-time';
|
||||
|
||||
/**
|
||||
* 일정 이벤트
|
||||
@@ -170,6 +170,19 @@ export interface WeekTimeViewProps {
|
||||
onEventClick: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일간 시간축 뷰 Props (day-time)
|
||||
*/
|
||||
export interface DayTimeViewProps {
|
||||
currentDate: Date;
|
||||
events: ScheduleEvent[];
|
||||
selectedDate: Date | null;
|
||||
/** 표시할 시간 범위 (기본: 1~12) */
|
||||
timeRange?: { start: number; end: number };
|
||||
onDateClick: (date: Date) => void;
|
||||
onEventClick: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간 뷰 Props
|
||||
*/
|
||||
|
||||
@@ -345,6 +345,41 @@ export function assignGlobalEventRows(events: ScheduleEvent[]): Map<string, numb
|
||||
return rowMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 날로 이동
|
||||
*/
|
||||
export function getNextDay(date: Date): Date {
|
||||
return addDays(date, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 날로 이동
|
||||
*/
|
||||
export function getPrevDay(date: Date): Date {
|
||||
return addDays(date, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 주로 이동
|
||||
*/
|
||||
export function getNextWeek(date: Date): Date {
|
||||
return addDays(date, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 주로 이동
|
||||
*/
|
||||
export function getPrevWeek(date: Date): Date {
|
||||
return addDays(date, -7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 년월일 포맷 (예: "2026년 2월 2일 (월)")
|
||||
*/
|
||||
export function formatYearMonthDay(date: Date): string {
|
||||
return format(date, 'yyyy년 M월 d일 (EEE)', { locale: ko });
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 뷰에서 주 단위로 날짜 분할
|
||||
*/
|
||||
|
||||
@@ -73,5 +73,6 @@ export function generateShipmentData(
|
||||
loadingTime: '',
|
||||
loadingManager: '',
|
||||
remarks: randomRemark(),
|
||||
vehicleDispatches: [],
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
Trash2,
|
||||
X,
|
||||
Printer,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -42,7 +40,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { DocumentViewer } from '@/components/document-system/viewer/DocumentViewer';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -73,7 +71,6 @@ import type {
|
||||
} from './types';
|
||||
import { ShippingSlip } from './documents/ShippingSlip';
|
||||
import { DeliveryConfirmation } from './documents/DeliveryConfirmation';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface ShipmentDetailProps {
|
||||
@@ -167,11 +164,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handlePrint = useCallback(() => {
|
||||
const docName = previewDocument === 'shipping' ? '출고증' : '납품확인서';
|
||||
printArea({ title: `${docName} 인쇄` });
|
||||
}, [previewDocument]);
|
||||
|
||||
const handleOpenStatusDialog = useCallback((status: ShipmentStatus) => {
|
||||
setTargetStatus(status);
|
||||
setStatusFormData({
|
||||
@@ -541,57 +533,21 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
headerActions={renderHeaderActions()}
|
||||
/>
|
||||
|
||||
{/* 문서 미리보기 다이얼로그 */}
|
||||
<Dialog open={previewDocument !== null} onOpenChange={() => setPreviewDocument(null)}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>
|
||||
{previewDocument === 'shipping' && '출고증'}
|
||||
{previewDocument === 'delivery' && '납품확인서'}
|
||||
</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">
|
||||
{previewDocument === 'shipping' && '출고증 미리보기'}
|
||||
{previewDocument === 'delivery' && '납품확인서'}
|
||||
</span>
|
||||
{detail && (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{detail.customerName}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({detail.shipmentNo})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setPreviewDocument(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<div className="print-area m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{previewDocument === 'shipping' && <ShippingSlip data={detail} />}
|
||||
{previewDocument === 'delivery' && <DeliveryConfirmation data={detail} />}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 문서 미리보기 (DocumentViewer 통일 패턴) */}
|
||||
<DocumentViewer
|
||||
title={previewDocument === 'shipping' ? '출고증' : '납품확인서'}
|
||||
subtitle={detail ? `${detail.customerName} (${detail.shipmentNo})` : undefined}
|
||||
preset="readonly"
|
||||
open={previewDocument !== null}
|
||||
onOpenChange={() => setPreviewDocument(null)}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{previewDocument === 'shipping' && <ShippingSlip data={detail} />}
|
||||
{previewDocument === 'delivery' && <DeliveryConfirmation data={detail} />}
|
||||
</>
|
||||
)}
|
||||
</DocumentViewer>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar/ScheduleCalendar';
|
||||
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
|
||||
import type { ScheduleEvent, CalendarView } from '@/components/common/ScheduleCalendar/types';
|
||||
import { getShipments, getShipmentStats } from './actions';
|
||||
import {
|
||||
SHIPMENT_STATUS_LABELS,
|
||||
@@ -68,20 +68,9 @@ export function ShipmentList() {
|
||||
|
||||
// ===== 캘린더 상태 =====
|
||||
const [calendarDate, setCalendarDate] = useState(new Date());
|
||||
const [calendarDateInitialized, setCalendarDateInitialized] = useState(false);
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('day-time');
|
||||
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
|
||||
|
||||
// 데이터 로드 후 캘린더를 데이터 날짜로 이동
|
||||
useEffect(() => {
|
||||
if (!calendarDateInitialized && shipmentData.length > 0) {
|
||||
const firstDate = shipmentData[0].scheduledDate;
|
||||
if (firstDate) {
|
||||
setCalendarDate(new Date(firstDate));
|
||||
setCalendarDateInitialized(true);
|
||||
}
|
||||
}
|
||||
}, [shipmentData, calendarDateInitialized]);
|
||||
|
||||
// 초기 통계 로드
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
@@ -414,23 +403,27 @@ export function ShipmentList() {
|
||||
);
|
||||
},
|
||||
|
||||
// 하단 캘린더 (시간축 주간 뷰)
|
||||
// 하단 캘린더 (일/주 토글)
|
||||
afterTableContent: (
|
||||
<ScheduleCalendar
|
||||
events={scheduleEvents}
|
||||
currentDate={calendarDate}
|
||||
view="week-time"
|
||||
view={scheduleView}
|
||||
onDateClick={handleCalendarDateClick}
|
||||
onEventClick={handleCalendarEventClick}
|
||||
onMonthChange={setCalendarDate}
|
||||
onViewChange={setScheduleView}
|
||||
titleSlot="출고 스케줄"
|
||||
weekStartsOn={0}
|
||||
availableViews={[]}
|
||||
availableViews={[
|
||||
{ value: 'day-time', label: '일' },
|
||||
{ value: 'week-time', label: '주' },
|
||||
]}
|
||||
timeRange={{ start: 1, end: 12 }}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[stats, startDate, endDate, scheduleEvents, calendarDate, handleRowClick, handleCreate, handleCalendarDateClick, handleCalendarEventClick]
|
||||
[stats, startDate, endDate, scheduleEvents, calendarDate, scheduleView, handleRowClick, handleCreate, handleCalendarDateClick, handleCalendarEventClick]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
|
||||
@@ -178,12 +178,12 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
shipmentNo: data.shipment_no,
|
||||
lotNo: data.lot_no || '',
|
||||
scheduledDate: data.scheduled_date,
|
||||
shipmentDate: (data as Record<string, unknown>).shipment_date as string | undefined,
|
||||
shipmentDate: (data as unknown as Record<string, unknown>).shipment_date as string | undefined,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
deliveryMethod: data.delivery_method,
|
||||
freightCost: (data as Record<string, unknown>).freight_cost as FreightCostType | undefined,
|
||||
freightCostLabel: (data as Record<string, unknown>).freight_cost_label as string | undefined,
|
||||
freightCost: (data as unknown as Record<string, unknown>).freight_cost as FreightCostType | undefined,
|
||||
freightCostLabel: (data as unknown as Record<string, unknown>).freight_cost_label as string | undefined,
|
||||
depositConfirmed: data.deposit_confirmed,
|
||||
invoiceIssued: data.invoice_issued,
|
||||
customerGrade: data.customer_grade || '',
|
||||
@@ -197,11 +197,21 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
deliveryAddress: data.order_info?.delivery_address || data.delivery_address || '',
|
||||
receiver: data.receiver,
|
||||
receiverContact: data.order_info?.contact || data.receiver_contact,
|
||||
zipCode: (data as Record<string, unknown>).zip_code as string | undefined,
|
||||
address: (data as Record<string, unknown>).address as string | undefined,
|
||||
addressDetail: (data as Record<string, unknown>).address_detail as string | undefined,
|
||||
// 배차 정보 (다중 행) - API 준비 후 연동
|
||||
vehicleDispatches: [],
|
||||
zipCode: (data as unknown as Record<string, unknown>).zip_code as string | undefined,
|
||||
address: (data as unknown as Record<string, unknown>).address as string | undefined,
|
||||
addressDetail: (data as unknown as Record<string, unknown>).address_detail as string | undefined,
|
||||
// 배차 정보 - 기존 단일 필드에서 구성 (다중 행 API 준비 전까지)
|
||||
vehicleDispatches: data.vehicle_no || data.logistics_company || data.driver_contact
|
||||
? [{
|
||||
id: `vd-${data.id}`,
|
||||
logisticsCompany: data.logistics_company || '-',
|
||||
arrivalDateTime: data.confirmed_arrival || data.expected_arrival || '-',
|
||||
tonnage: data.vehicle_tonnage || '-',
|
||||
vehicleNo: data.vehicle_no || '-',
|
||||
driverContact: data.driver_contact || '-',
|
||||
remarks: '',
|
||||
}]
|
||||
: [],
|
||||
// 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리
|
||||
productGroups: [],
|
||||
otherParts: [],
|
||||
|
||||
@@ -13,9 +13,10 @@ import { ConstructionApprovalTable } from '@/components/document-system';
|
||||
interface ShipmentOrderDocumentProps {
|
||||
title: string;
|
||||
data: ShipmentDetail;
|
||||
showDispatchInfo?: boolean;
|
||||
}
|
||||
|
||||
export function ShipmentOrderDocument({ title, data }: ShipmentOrderDocumentProps) {
|
||||
export function ShipmentOrderDocument({ title, data, showDispatchInfo = false }: ShipmentOrderDocumentProps) {
|
||||
// 스크린 제품 필터링 (productGroups 기반)
|
||||
const screenProducts = data.productGroups.filter(g =>
|
||||
g.productName?.includes('스크린') ||
|
||||
@@ -188,6 +189,38 @@ export function ShipmentOrderDocument({ title, data }: ShipmentOrderDocumentProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배차정보 (출고증에서만 표시) */}
|
||||
{showDispatchInfo && (() => {
|
||||
const dispatch = data.vehicleDispatches[0];
|
||||
return (
|
||||
<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>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-1 py-1 w-[1%] font-medium border-r border-gray-300 whitespace-nowrap">물류업체</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{dispatch?.logisticsCompany || data.logisticsCompany || '-'}</td>
|
||||
<td className="bg-gray-100 px-1 py-1 w-[1%] font-medium border-r border-gray-300 whitespace-nowrap">입차일시</td>
|
||||
<td className="px-2 py-1" colSpan={3}>{dispatch?.arrivalDateTime || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-1 py-1 w-[1%] font-medium border-r border-gray-300 whitespace-nowrap">톤수</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{dispatch?.tonnage || data.vehicleTonnage || '-'}</td>
|
||||
<td className="bg-gray-100 px-1 py-1 w-[1%] font-medium border-r border-gray-300 whitespace-nowrap">차량번호</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{dispatch?.vehicleNo || data.vehicleNo || '-'}</td>
|
||||
<td className="bg-gray-100 px-1 py-1 w-[1%] font-medium border-r border-gray-300 whitespace-nowrap">기사연락처</td>
|
||||
<td className="px-2 py-1 whitespace-nowrap">{dispatch?.driverContact || data.driverContact || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 px-1 py-1 w-[1%] font-medium border-r border-gray-300 whitespace-nowrap">비고</td>
|
||||
<td className="px-2 py-1" colSpan={5}>{dispatch?.remarks || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<p className="text-[10px] mb-4">아래와 같이 주문하오니 품질 및 납기일을 준수하여 주시기 바랍니다.</p>
|
||||
|
||||
{/* 1. 스크린 테이블 */}
|
||||
|
||||
@@ -13,5 +13,5 @@ interface ShippingSlipProps {
|
||||
}
|
||||
|
||||
export function ShippingSlip({ data }: ShippingSlipProps) {
|
||||
return <ShipmentOrderDocument title="출 고 증" data={data} />;
|
||||
return <ShipmentOrderDocument title="출 고 증" data={data} showDispatchInfo />;
|
||||
}
|
||||
|
||||
@@ -1,348 +1,562 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 등록 페이지
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* 제품검사 등록 페이지
|
||||
*
|
||||
* 기획서 기반 전면 재구축:
|
||||
* - 기본정보 입력
|
||||
* - 건축공사장, 자재유통업자, 공사시공자, 공사감리자 정보
|
||||
* - 검사 정보 (검사방문요청일, 기간, 검사자, 현장주소)
|
||||
* - 수주 설정 정보 (수주 선택 → 규격 비교 테이블)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { qualityInspectionCreateConfig } from './inspectionConfig';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { qualityInspectionCreateConfig } from './inspectionConfig';
|
||||
import { toast } from 'sonner';
|
||||
import { createInspection } from './actions';
|
||||
import { isOrderSpecSame, calculateOrderSummary } from './mockData';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { inspectionItemsTemplate, judgeMeasurement } from './mockData';
|
||||
import type { InspectionItem, QualityCheckItem, MeasurementItem } from './types';
|
||||
import { OrderSelectModal } from './OrderSelectModal';
|
||||
import type { InspectionFormData, OrderSettingItem, OrderSelectItem } from './types';
|
||||
import {
|
||||
emptyConstructionSite,
|
||||
emptyMaterialDistributor,
|
||||
emptyConstructor,
|
||||
emptySupervisor,
|
||||
emptyScheduleInfo,
|
||||
} from './mockData';
|
||||
|
||||
export function InspectionCreate() {
|
||||
const router = useRouter();
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
lotNo: 'WO-251219-05', // 자동 (예시)
|
||||
itemName: '조인트바', // 자동 (예시)
|
||||
processName: '조립 공정', // 자동 (예시)
|
||||
quantity: 50,
|
||||
inspector: '',
|
||||
remarks: '',
|
||||
const [formData, setFormData] = useState<InspectionFormData>({
|
||||
qualityDocNumber: '',
|
||||
siteName: '',
|
||||
client: '',
|
||||
manager: '',
|
||||
managerContact: '',
|
||||
constructionSite: { ...emptyConstructionSite },
|
||||
materialDistributor: { ...emptyMaterialDistributor },
|
||||
constructorInfo: { ...emptyConstructor },
|
||||
supervisor: { ...emptySupervisor },
|
||||
scheduleInfo: { ...emptyScheduleInfo },
|
||||
orderItems: [],
|
||||
});
|
||||
|
||||
// 검사 항목 상태
|
||||
const [inspectionItems, setInspectionItems] = useState<InspectionItem[]>(
|
||||
inspectionItemsTemplate.map(item => ({ ...item }))
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [orderModalOpen, setOrderModalOpen] = useState(false);
|
||||
|
||||
// ===== 수주 선택 처리 =====
|
||||
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
|
||||
const newOrderItems: OrderSettingItem[] = items.map((item) => ({
|
||||
id: item.id,
|
||||
orderNumber: item.orderNumber,
|
||||
floor: '',
|
||||
symbol: '',
|
||||
orderWidth: 0,
|
||||
orderHeight: 0,
|
||||
constructionWidth: 0,
|
||||
constructionHeight: 0,
|
||||
changeReason: '',
|
||||
}));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderItems: [...prev.orderItems, ...newOrderItems],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 수주 항목 삭제 =====
|
||||
const handleRemoveOrderItem = useCallback((itemId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderItems: prev.orderItems.filter((item) => item.id !== itemId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 폼 필드 변경 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof InspectionFormData>(
|
||||
key: K,
|
||||
value: InspectionFormData[K]
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const updateNested = useCallback((
|
||||
section: 'constructionSite' | 'materialDistributor' | 'constructorInfo' | 'supervisor' | 'scheduleInfo',
|
||||
field: string,
|
||||
value: string
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...(prev[section] as unknown as Record<string, unknown>),
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 수주 설정 요약 =====
|
||||
const orderSummary = useMemo(
|
||||
() => calculateOrderSummary(formData.orderItems),
|
||||
[formData.orderItems]
|
||||
);
|
||||
|
||||
// validation 에러 상태
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 제출 상태
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: string, value: string | number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// 입력 시 에러 클리어
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 품질 검사 항목 결과 변경 (양호/불량)
|
||||
const handleQualityResultChange = useCallback((itemId: string, result: '양호' | '불량') => {
|
||||
setInspectionItems(prev => prev.map(item => {
|
||||
if (item.id === itemId && item.type === 'quality') {
|
||||
return {
|
||||
...item,
|
||||
result,
|
||||
judgment: result === '양호' ? '적합' : '부적합',
|
||||
} as QualityCheckItem;
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
// 입력 시 에러 클리어
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 측정 항목 값 변경
|
||||
const handleMeasurementChange = useCallback((itemId: string, value: string) => {
|
||||
setInspectionItems(prev => prev.map(item => {
|
||||
if (item.id === itemId && item.type === 'measurement') {
|
||||
const measuredValue = parseFloat(value) || 0;
|
||||
const judgment = judgeMeasurement(item.spec, measuredValue);
|
||||
return {
|
||||
...item,
|
||||
measuredValue,
|
||||
judgment,
|
||||
} as MeasurementItem;
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
// 입력 시 에러 클리어
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 취소
|
||||
// ===== 취소 =====
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push('/quality/inspections');
|
||||
}, [router]);
|
||||
|
||||
// validation 체크
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드: 작업자
|
||||
if (!formData.inspector.trim()) {
|
||||
errors.push('작업자는 필수 입력 항목입니다.');
|
||||
// ===== 등록 제출 =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 필수 필드 검증
|
||||
if (!formData.siteName.trim()) {
|
||||
toast.error('현장명은 필수 입력 항목입니다.');
|
||||
return { success: false, error: '현장명을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 검사 항목 validation
|
||||
inspectionItems.forEach((item, index) => {
|
||||
if (item.type === 'quality') {
|
||||
const qualityItem = item as QualityCheckItem;
|
||||
if (!qualityItem.result) {
|
||||
errors.push(`${index + 1}. ${item.name}: 결과를 선택해주세요.`);
|
||||
}
|
||||
} else if (item.type === 'measurement') {
|
||||
const measurementItem = item as MeasurementItem;
|
||||
if (measurementItem.measuredValue === undefined || measurementItem.measuredValue === null) {
|
||||
errors.push(`${index + 1}. ${item.name}: 측정값을 입력해주세요.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// 검사완료
|
||||
const handleSubmit = async () => {
|
||||
// validation 체크
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
if (!formData.client.trim()) {
|
||||
toast.error('수주처는 필수 입력 항목입니다.');
|
||||
return { success: false, error: '수주처를 입력해주세요.' };
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createInspection({
|
||||
inspectionType: 'PQC', // 기본값: 공정검사
|
||||
lotNo: formData.lotNo,
|
||||
itemName: formData.itemName,
|
||||
processName: formData.processName,
|
||||
quantity: formData.quantity,
|
||||
unit: 'EA', // 기본 단위
|
||||
remarks: formData.remarks || undefined,
|
||||
items: inspectionItems,
|
||||
});
|
||||
|
||||
const result = await createInspection(formData);
|
||||
if (result.success) {
|
||||
toast.success('검사가 등록되었습니다.');
|
||||
toast.success('제품검사가 등록되었습니다.');
|
||||
router.push('/quality/inspections');
|
||||
} else {
|
||||
toast.error(result.error || '검사 등록에 실패했습니다.');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '등록에 실패했습니다.' };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[InspectionCreate] handleSubmit error:', error);
|
||||
toast.error('검사 등록 중 오류가 발생했습니다.');
|
||||
return { success: false, error: '등록 중 오류가 발생했습니다.' };
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
}, [formData, router]);
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
// ===== 수주 설정 테이블 =====
|
||||
const renderOrderTable = (items: OrderSettingItem[]) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12 text-center">No.</TableHead>
|
||||
<TableHead>수주번호</TableHead>
|
||||
<TableHead>층수</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead className="text-center">수주 가로</TableHead>
|
||||
<TableHead className="text-center">수주 세로</TableHead>
|
||||
<TableHead className="text-center">시공 가로</TableHead>
|
||||
<TableHead className="text-center">시공 세로</TableHead>
|
||||
<TableHead className="text-center">일치</TableHead>
|
||||
<TableHead>변경사유</TableHead>
|
||||
<TableHead className="w-12 text-center">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, index) => {
|
||||
const isSame = isOrderSpecSame(item);
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.floor}</TableCell>
|
||||
<TableCell>{item.symbol}</TableCell>
|
||||
<TableCell className="text-center">{item.orderWidth}</TableCell>
|
||||
<TableCell className="text-center">{item.orderHeight}</TableCell>
|
||||
<TableCell className="text-center">{item.constructionWidth}</TableCell>
|
||||
<TableCell className="text-center">{item.constructionHeight}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSame ? (
|
||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">일치</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs bg-red-50 text-red-700 border-red-200">불일치</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.changeReason || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-600"
|
||||
type="button"
|
||||
onClick={() => handleRemoveOrderItem(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center text-muted-foreground py-8">
|
||||
수주를 선택해주세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
// ===== 폼 렌더링 =====
|
||||
const renderFormContent = useCallback(() => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>품질관리서 번호</Label>
|
||||
<Input
|
||||
value={formData.qualityDocNumber}
|
||||
onChange={(e) => updateField('qualityDocNumber', e.target.value)}
|
||||
placeholder="품질관리서 번호 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>현장명 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
value={formData.siteName}
|
||||
onChange={(e) => updateField('siteName', e.target.value)}
|
||||
placeholder="현장명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수주처 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
value={formData.client}
|
||||
onChange={(e) => updateField('client', e.target.value)}
|
||||
placeholder="수주처 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>담당자</Label>
|
||||
<Input
|
||||
value={formData.manager}
|
||||
onChange={(e) => updateField('manager', e.target.value)}
|
||||
placeholder="담당자 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>담당자 연락처</Label>
|
||||
<Input
|
||||
value={formData.managerContact}
|
||||
onChange={(e) => updateField('managerContact', e.target.value)}
|
||||
placeholder="담당자 연락처 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 개요 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 개요</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">LOT NO (자동)</Label>
|
||||
{/* 건축공사장 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">건축공사장 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={formData.constructionSite.siteName}
|
||||
onChange={(e) => updateNested('constructionSite', 'siteName', e.target.value)}
|
||||
placeholder="현장명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>대지위치</Label>
|
||||
<Input
|
||||
value={formData.constructionSite.landLocation}
|
||||
onChange={(e) => updateNested('constructionSite', 'landLocation', e.target.value)}
|
||||
placeholder="대지위치 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>지번</Label>
|
||||
<Input
|
||||
value={formData.constructionSite.lotNumber}
|
||||
onChange={(e) => updateNested('constructionSite', 'lotNumber', e.target.value)}
|
||||
placeholder="지번 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자재유통업자 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">자재유통업자 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>회사명</Label>
|
||||
<Input
|
||||
value={formData.materialDistributor.companyName}
|
||||
onChange={(e) => updateNested('materialDistributor', 'companyName', e.target.value)}
|
||||
placeholder="회사명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>회사주소</Label>
|
||||
<Input
|
||||
value={formData.materialDistributor.companyAddress}
|
||||
onChange={(e) => updateNested('materialDistributor', 'companyAddress', e.target.value)}
|
||||
placeholder="회사주소 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>대표자명</Label>
|
||||
<Input
|
||||
value={formData.materialDistributor.representativeName}
|
||||
onChange={(e) => updateNested('materialDistributor', 'representativeName', e.target.value)}
|
||||
placeholder="대표자명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>전화번호</Label>
|
||||
<Input
|
||||
value={formData.materialDistributor.phone}
|
||||
onChange={(e) => updateNested('materialDistributor', 'phone', e.target.value)}
|
||||
placeholder="전화번호 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 공사시공자 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">공사시공자 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>회사명</Label>
|
||||
<Input
|
||||
value={formData.constructorInfo.companyName}
|
||||
onChange={(e) => updateNested('constructorInfo', 'companyName', e.target.value)}
|
||||
placeholder="회사명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>회사주소</Label>
|
||||
<Input
|
||||
value={formData.constructorInfo.companyAddress}
|
||||
onChange={(e) => updateNested('constructorInfo', 'companyAddress', e.target.value)}
|
||||
placeholder="회사주소 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>성명</Label>
|
||||
<Input
|
||||
value={formData.constructorInfo.name}
|
||||
onChange={(e) => updateNested('constructorInfo', 'name', e.target.value)}
|
||||
placeholder="성명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>전화번호</Label>
|
||||
<Input
|
||||
value={formData.constructorInfo.phone}
|
||||
onChange={(e) => updateNested('constructorInfo', 'phone', e.target.value)}
|
||||
placeholder="전화번호 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 공사감리자 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">공사감리자 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>사무소명</Label>
|
||||
<Input
|
||||
value={formData.supervisor.officeName}
|
||||
onChange={(e) => updateNested('supervisor', 'officeName', e.target.value)}
|
||||
placeholder="사무소명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사무소주소</Label>
|
||||
<Input
|
||||
value={formData.supervisor.officeAddress}
|
||||
onChange={(e) => updateNested('supervisor', 'officeAddress', e.target.value)}
|
||||
placeholder="사무소주소 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>성명</Label>
|
||||
<Input
|
||||
value={formData.supervisor.name}
|
||||
onChange={(e) => updateNested('supervisor', 'name', e.target.value)}
|
||||
placeholder="성명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>전화번호</Label>
|
||||
<Input
|
||||
value={formData.supervisor.phone}
|
||||
onChange={(e) => updateNested('supervisor', 'phone', e.target.value)}
|
||||
placeholder="전화번호 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>검사방문요청일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduleInfo.visitRequestDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'visitRequestDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>검사시작일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduleInfo.startDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'startDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>검사종료일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduleInfo.endDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'endDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>검사자</Label>
|
||||
<Input
|
||||
value={formData.scheduleInfo.inspector}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'inspector', e.target.value)}
|
||||
placeholder="검사자 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 현장 주소 */}
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>우편번호</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.lotNo}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">품목명 (자동)</Label>
|
||||
<Input
|
||||
value={formData.itemName}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">공정명 (자동)</Label>
|
||||
<Input
|
||||
value={formData.processName}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수량</Label>
|
||||
<QuantityInput
|
||||
value={formData.quantity}
|
||||
onChange={(value) => handleInputChange('quantity', value ?? 0)}
|
||||
placeholder="수량 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>작업자 *</Label>
|
||||
<Input
|
||||
value={formData.inspector}
|
||||
onChange={(e) => handleInputChange('inspector', e.target.value)}
|
||||
placeholder="작업자 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<Label>특이사항</Label>
|
||||
<Input
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
value={formData.scheduleInfo.sitePostalCode}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'sitePostalCode', e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<Button variant="outline" size="sm" type="button">
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 기준 및 도해 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 기준 및 도해</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-48 bg-muted rounded-lg border-2 border-dashed">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>템플릿에서 설정한 조인트바 표준 도면이 표시됨</p>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label>주소</Label>
|
||||
<Input
|
||||
value={formData.scheduleInfo.siteAddress}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'siteAddress', e.target.value)}
|
||||
placeholder="주소 입력"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-2">
|
||||
<Label>상세주소</Label>
|
||||
<Input
|
||||
value={formData.scheduleInfo.siteAddressDetail}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'siteAddressDetail', e.target.value)}
|
||||
placeholder="상세주소 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 데이터 입력 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 데이터 입력</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
* 측정값을 입력하면 판정이 자동 처리됩니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{inspectionItems.map((item, index) => (
|
||||
<div key={item.id} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">
|
||||
{index + 1}. {item.name}
|
||||
{item.type === 'measurement' && ` (${(item as MeasurementItem).unit})`}
|
||||
</h4>
|
||||
<span className={`text-sm font-medium ${
|
||||
item.judgment === '적합' ? 'text-green-600' :
|
||||
item.judgment === '부적합' ? 'text-red-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
판정: {item.judgment || '-'}
|
||||
</span>
|
||||
</div>
|
||||
{/* 수주 설정 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-base">수주 설정 정보</CardTitle>
|
||||
<Button variant="outline" size="sm" type="button" onClick={() => setOrderModalOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
수주 선택
|
||||
</Button>
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{renderOrderTable(formData.orderItems)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
), [formData, orderSummary, updateField, updateNested, handleRemoveOrderItem, orderModalOpen]);
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">기준(Spec)</Label>
|
||||
<Input
|
||||
value={item.spec}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.type === 'quality' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>결과 입력 *</Label>
|
||||
<RadioGroup
|
||||
value={(item as QualityCheckItem).result || ''}
|
||||
onValueChange={(value) => handleQualityResultChange(item.id, value as '양호' | '불량')}
|
||||
className="flex items-center gap-4 h-10"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="양호" id={`${item.id}-pass`} />
|
||||
<Label htmlFor={`${item.id}-pass`} className="cursor-pointer">양호</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="불량" id={`${item.id}-fail`} />
|
||||
<Label htmlFor={`${item.id}-fail`} className="cursor-pointer">불량</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>측정값 입력 ({(item as MeasurementItem).unit}) *</Label>
|
||||
<NumberInput
|
||||
step={0.1}
|
||||
allowDecimal
|
||||
value={(item as MeasurementItem).measuredValue ?? undefined}
|
||||
onChange={(value) => handleMeasurementChange(item.id, String(value ?? ''))}
|
||||
placeholder={`측정값 입력 (${(item as MeasurementItem).unit})`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
), [formData, inspectionItems, validationErrors, handleInputChange, handleQualityResultChange, handleMeasurementChange]);
|
||||
// 이미 선택된 수주 ID 목록
|
||||
const excludeOrderIds = useMemo(
|
||||
() => formData.orderItems.map((item) => item.id),
|
||||
[formData.orderItems]
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={qualityInspectionCreateConfig}
|
||||
mode="create"
|
||||
isLoading={false}
|
||||
isSubmitting={isSubmitting}
|
||||
onBack={handleCancel}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={qualityInspectionCreateConfig}
|
||||
mode="create"
|
||||
isLoading={false}
|
||||
isSubmitting={isSubmitting}
|
||||
onBack={handleCancel}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
|
||||
<OrderSelectModal
|
||||
open={orderModalOpen}
|
||||
onOpenChange={setOrderModalOpen}
|
||||
onSelect={handleOrderSelect}
|
||||
excludeIds={excludeOrderIds}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,53 +1,80 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 목록 - UniversalListPage 마이그레이션
|
||||
* 제품검사 목록
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 서버 사이드 페이지네이션 (getInspections API)
|
||||
* - 통계 카드 (getInspectionStats API)
|
||||
* - 상태별 탭 필터 (전체/대기/진행중/완료)
|
||||
* 기획서 기반 전면 재구축:
|
||||
* - DateRangeSelector (기간 필터)
|
||||
* - 통계 카드 3개 (접수/진행중/완료)
|
||||
* - 테이블 (11개 컬럼)
|
||||
* - 하단 제품검사 스케줄 캘린더 (주/월 뷰)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, ClipboardCheck, Clock, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Plus,
|
||||
FileInput,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type TabOption,
|
||||
type StatCard,
|
||||
type ListParams,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { getInspections, getInspectionStats } from './actions';
|
||||
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar/ScheduleCalendar';
|
||||
import type { ScheduleEvent, CalendarView } from '@/components/common/ScheduleCalendar/types';
|
||||
import { getInspections, getInspectionStats, getInspectionCalendar } from './actions';
|
||||
import { statusColorMap } from './mockData';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { statusColorMap, inspectionTypeLabels } from './mockData';
|
||||
import type { Inspection, InspectionStatus, InspectionStats } from './types';
|
||||
import type { ProductInspection, InspectionStats, InspectionStatus } from './types';
|
||||
|
||||
// 탭 필터 정의
|
||||
type TabFilter = '전체' | InspectionStatus;
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function InspectionList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 통계 데이터 (외부 관리) =====
|
||||
// ===== 통계 =====
|
||||
const [statsData, setStatsData] = useState<InspectionStats>({
|
||||
waitingCount: 0,
|
||||
receptionCount: 0,
|
||||
inProgressCount: 0,
|
||||
completedCount: 0,
|
||||
defectRate: 0,
|
||||
});
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
// ===== 날짜 범위 =====
|
||||
const today = new Date();
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
// ===== 캘린더 상태 =====
|
||||
const [calendarDate, setCalendarDate] = useState(new Date());
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('month');
|
||||
const [calendarEvents, setCalendarEvents] = useState<ScheduleEvent[]>([]);
|
||||
const [calendarStatusFilter, setCalendarStatusFilter] = useState<string>('전체');
|
||||
const [calendarInspectorFilter, setCalendarInspectorFilter] = useState<string>('전체');
|
||||
|
||||
// 초기 통계 로드
|
||||
useEffect(() => {
|
||||
@@ -65,74 +92,136 @@ export function InspectionList() {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
// ===== 행 클릭 핸들러 =====
|
||||
// 캘린더 데이터 로드
|
||||
const loadCalendarData = useCallback(async () => {
|
||||
try {
|
||||
const year = calendarDate.getFullYear();
|
||||
const month = calendarDate.getMonth() + 1;
|
||||
const result = await getInspectionCalendar({
|
||||
year,
|
||||
month,
|
||||
status: calendarStatusFilter !== '전체' ? calendarStatusFilter as InspectionStatus : undefined,
|
||||
inspector: calendarInspectorFilter !== '전체' ? calendarInspectorFilter : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const events: ScheduleEvent[] = result.data.map((item) => {
|
||||
const statusColorMap: Record<string, string> = {
|
||||
접수: 'blue',
|
||||
진행중: 'indigo',
|
||||
완료: 'blue',
|
||||
};
|
||||
return {
|
||||
id: item.id,
|
||||
title: `${item.inspector} – ${item.siteName} / ${item.status}`,
|
||||
startDate: item.startDate,
|
||||
endDate: item.endDate,
|
||||
color: statusColorMap[item.status] || 'blue',
|
||||
status: item.status,
|
||||
data: item,
|
||||
};
|
||||
});
|
||||
setCalendarEvents(events);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[InspectionList] loadCalendarData error:', error);
|
||||
}
|
||||
}, [calendarDate, calendarStatusFilter, calendarInspectorFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCalendarData();
|
||||
}, [loadCalendarData]);
|
||||
|
||||
// ===== 행 클릭 =====
|
||||
const handleRowClick = useCallback(
|
||||
(item: Inspection) => {
|
||||
(item: ProductInspection) => {
|
||||
router.push(`/quality/inspections/${item.id}?mode=view`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// ===== 등록 핸들러 =====
|
||||
// ===== 등록 =====
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/quality/inspections?mode=new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 탭 옵션 (통계 기반) =====
|
||||
const tabs: TabOption[] = useMemo(
|
||||
() => [
|
||||
{ value: '전체', label: '전체', count: totalItems },
|
||||
{ value: '대기', label: '대기', count: statsData.waitingCount, color: 'gray' },
|
||||
{ value: '진행중', label: '진행중', count: statsData.inProgressCount, color: 'blue' },
|
||||
{ value: '완료', label: '완료', count: statsData.completedCount, color: 'green' },
|
||||
],
|
||||
[totalItems, statsData]
|
||||
);
|
||||
// ===== 캘린더 핸들러 =====
|
||||
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
|
||||
if (event.data && typeof event.data === 'object' && 'id' in event.data) {
|
||||
const item = event.data as { id: string };
|
||||
router.push(`/quality/inspections/${item.id}?mode=view`);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
const handleCalendarDateClick = useCallback((date: Date) => {
|
||||
setCalendarDate(date);
|
||||
}, []);
|
||||
|
||||
// ===== 통계 카드 (3개: 접수/진행중/완료) =====
|
||||
const stats: StatCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: '금일 대기 건수',
|
||||
value: `${statsData.waitingCount}건`,
|
||||
icon: Clock,
|
||||
label: '접수',
|
||||
value: statsData.receptionCount,
|
||||
icon: FileInput,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '진행 중 검사',
|
||||
value: `${statsData.inProgressCount}건`,
|
||||
icon: PlayCircle,
|
||||
label: '진행중',
|
||||
value: statsData.inProgressCount,
|
||||
icon: Loader2,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '금일 완료 건수',
|
||||
value: `${statsData.completedCount}건`,
|
||||
label: '완료',
|
||||
value: statsData.completedCount,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '불량 발생률',
|
||||
value: `${statsData.defectRate.toFixed(1)}%`,
|
||||
icon: AlertTriangle,
|
||||
iconColor: statsData.defectRate > 0 ? 'text-red-600' : 'text-gray-400',
|
||||
},
|
||||
],
|
||||
[statsData]
|
||||
);
|
||||
|
||||
// ===== 캘린더 필터 슬롯 =====
|
||||
const calendarFilterSlot = useMemo(
|
||||
() => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={calendarStatusFilter} onValueChange={setCalendarStatusFilter}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="전체">전체</SelectItem>
|
||||
<SelectItem value="접수">접수</SelectItem>
|
||||
<SelectItem value="진행중">진행중</SelectItem>
|
||||
<SelectItem value="완료">완료</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={calendarInspectorFilter} onValueChange={setCalendarInspectorFilter}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="전체">전체</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
[calendarStatusFilter, calendarInspectorFilter]
|
||||
);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Inspection> = useMemo(
|
||||
const config: UniversalListConfig<ProductInspection> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '검사 목록',
|
||||
description: '품질검사 관리',
|
||||
title: '제품검사 목록',
|
||||
description: '제품검사를 관리합니다',
|
||||
icon: ClipboardCheck,
|
||||
basePath: '/quality/inspections',
|
||||
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// API 액션 (서버 사이드 페이지네이션)
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
@@ -140,19 +229,17 @@ export function InspectionList() {
|
||||
page: params?.page || 1,
|
||||
size: params?.pageSize || ITEMS_PER_PAGE,
|
||||
q: params?.search || undefined,
|
||||
status: params?.tab !== '전체' ? (params?.tab as InspectionStatus) : undefined,
|
||||
dateFrom: startDate,
|
||||
dateTo: endDate,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 다시 로드
|
||||
// 통계 재로드
|
||||
const statsResult = await getInspectionStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatsData(statsResult.data);
|
||||
}
|
||||
|
||||
// totalItems 업데이트 (탭용)
|
||||
setTotalItems(result.pagination.total);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
@@ -168,15 +255,36 @@ export function InspectionList() {
|
||||
},
|
||||
},
|
||||
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '제품검사 등록',
|
||||
onClick: handleCreate,
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'inspectionType', label: '검사유형', className: 'w-[80px]' },
|
||||
{ key: 'requestDate', label: '요청일', className: 'w-[100px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'lotNo', label: 'LOT NO', className: 'min-w-[130px]' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px]' },
|
||||
{ key: 'inspector', label: '담당자', className: 'w-[80px]' },
|
||||
{ key: 'no', label: 'No.', className: 'w-[50px] text-center' },
|
||||
{ key: 'qualityDocNumber', label: '품질관리서 번호', className: 'min-w-[120px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
|
||||
{ key: 'client', label: '수주처', className: 'min-w-[80px]' },
|
||||
{ key: 'locationCount', label: '개소', className: 'w-[60px] text-center' },
|
||||
{ key: 'requiredInfo', label: '필수정보', className: 'w-[90px] text-center' },
|
||||
{ key: 'inspectionPeriod', label: '검사기간', className: 'min-w-[140px]' },
|
||||
{ key: 'inspector', label: '검사자', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[70px] text-center' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[80px] text-center' },
|
||||
{ key: 'receptionDate', label: '접수일', className: 'w-[100px] text-center' },
|
||||
],
|
||||
|
||||
// 서버 사이드 페이지네이션
|
||||
@@ -184,29 +292,17 @@ export function InspectionList() {
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: 'LOT번호, 품목명, 공정명 검색...',
|
||||
|
||||
// 탭 설정
|
||||
tabs,
|
||||
defaultTab: '전체',
|
||||
searchPlaceholder: '품질관리서 번호, 현장명, 수주처 검색...',
|
||||
|
||||
// 통계 카드
|
||||
stats,
|
||||
|
||||
// 헤더 액션 (등록 버튼)
|
||||
headerActions: () => (
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
검사 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Inspection,
|
||||
item: ProductInspection,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Inspection>
|
||||
handlers: SelectionHandlers & RowClickHandlers<ProductInspection>
|
||||
) => {
|
||||
return (
|
||||
<TableRow
|
||||
@@ -221,30 +317,36 @@ export function InspectionList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.inspectionType}
|
||||
</Badge>
|
||||
<TableCell className="font-medium">{item.qualityDocNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.requiredInfo === '완료' ? (
|
||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">완료</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs bg-red-50 text-red-700 border-red-200">{item.requiredInfo}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.requestDate}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemName}</TableCell>
|
||||
<TableCell>{item.lotNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusColorMap[item.status]} border-0`}>
|
||||
<TableCell>{item.inspectionPeriod}</TableCell>
|
||||
<TableCell className="text-center">{item.inspector || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${statusColorMap[item.status]} border-0`}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{item.inspector || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.author || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.receptionDate}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Inspection,
|
||||
item: ProductInspection,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Inspection>
|
||||
handlers: SelectionHandlers & RowClickHandlers<ProductInspection>
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
@@ -256,30 +358,50 @@ export function InspectionList() {
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{item.inspectionType}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{item.qualityDocNumber}</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
title={item.siteName}
|
||||
statusBadge={
|
||||
<Badge className={`${statusColorMap[item.status]} border-0`}>
|
||||
<Badge className={`text-xs ${statusColorMap[item.status]} border-0`}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="검사유형" value={inspectionTypeLabels[item.inspectionType]} />
|
||||
<InfoField label="LOT NO" value={item.lotNo} />
|
||||
<InfoField label="요청일" value={item.requestDate} />
|
||||
<InfoField label="담당자" value={item.inspector || '-'} />
|
||||
<InfoField label="공정명" value={item.processName} />
|
||||
<InfoField label="수량" value={`${item.quantity} ${item.unit}`} />
|
||||
<InfoField label="수주처" value={item.client} />
|
||||
<InfoField label="개소" value={String(item.locationCount)} />
|
||||
<InfoField label="검사기간" value={item.inspectionPeriod} />
|
||||
<InfoField label="검사자" value={item.inspector || '-'} />
|
||||
<InfoField label="작성자" value={item.author || '-'} />
|
||||
<InfoField label="접수일" value={item.receptionDate} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 하단 캘린더
|
||||
afterTableContent: (
|
||||
<ScheduleCalendar
|
||||
events={calendarEvents}
|
||||
currentDate={calendarDate}
|
||||
view={scheduleView}
|
||||
onDateClick={handleCalendarDateClick}
|
||||
onEventClick={handleCalendarEventClick}
|
||||
onMonthChange={setCalendarDate}
|
||||
onViewChange={setScheduleView}
|
||||
titleSlot="제품검사 스케줄"
|
||||
filterSlot={calendarFilterSlot}
|
||||
weekStartsOn={0}
|
||||
availableViews={[
|
||||
{ value: 'week', label: '주' },
|
||||
{ value: 'month', label: '월' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[tabs, stats, handleRowClick, handleCreate]
|
||||
[stats, startDate, endDate, calendarEvents, calendarDate, scheduleView, calendarFilterSlot, handleRowClick, handleCreate, handleCalendarDateClick, handleCalendarEventClick]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
|
||||
220
src/components/quality/InspectionManagement/OrderSelectModal.tsx
Normal file
220
src/components/quality/InspectionManagement/OrderSelectModal.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수주 선택 모달
|
||||
*
|
||||
* 기획서 기반 신규 생성:
|
||||
* - 검색 입력
|
||||
* - 체크박스 테이블 (수주번호, 현장명, 납품일, 개소)
|
||||
* - 취소/선택 버튼
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { getOrderSelectList } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { OrderSelectItem } from './types';
|
||||
|
||||
interface OrderSelectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (items: OrderSelectItem[]) => void;
|
||||
/** 이미 선택된 항목 ID 목록 (중복 선택 방지) */
|
||||
excludeIds?: string[];
|
||||
}
|
||||
|
||||
export function OrderSelectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
excludeIds = [],
|
||||
}: OrderSelectModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [items, setItems] = useState<OrderSelectItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 데이터 로드
|
||||
const loadItems = useCallback(async (q?: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getOrderSelectList({ q: q || undefined });
|
||||
if (result.success) {
|
||||
// 이미 선택된 항목 제외
|
||||
const filtered = result.data.filter((item) => !excludeIds.includes(item.id));
|
||||
setItems(filtered);
|
||||
} else {
|
||||
toast.error(result.error || '수주 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[OrderSelectModal] loadItems error:', error);
|
||||
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [excludeIds]);
|
||||
|
||||
// 모달 열릴 때 데이터 로드 & 상태 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchTerm('');
|
||||
setSelectedIds(new Set());
|
||||
loadItems();
|
||||
}
|
||||
}, [open, loadItems]);
|
||||
|
||||
// 검색
|
||||
const handleSearch = useCallback(() => {
|
||||
loadItems(searchTerm);
|
||||
}, [searchTerm, loadItems]);
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}, [handleSearch]);
|
||||
|
||||
// 체크박스 토글
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleToggleAll = useCallback(() => {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.size === items.length) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(items.map((item) => item.id));
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
// 선택 확인
|
||||
const handleConfirm = useCallback(() => {
|
||||
const selectedItems = items.filter((item) => selectedIds.has(item.id));
|
||||
onSelect(selectedItems);
|
||||
onOpenChange(false);
|
||||
}, [items, selectedIds, onSelect, onOpenChange]);
|
||||
|
||||
const isAllSelected = items.length > 0 && selectedIds.size === items.length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="수주번호, 현장명 검색..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="max-h-[400px] overflow-y-auto border rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleToggleAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>수주번호</TableHead>
|
||||
<TableHead>현장명</TableHead>
|
||||
<TableHead className="text-center">납품일</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggle(item.id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(item.id)}
|
||||
onCheckedChange={() => handleToggle(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryDate}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
{searchTerm ? '검색 결과가 없습니다.' : '수주 데이터가 없습니다.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
선택 ({selectedIds.size}건)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,435 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 제품검사성적서 문서
|
||||
*
|
||||
* 기획서 기반:
|
||||
* - 결재라인 (작성/승인)
|
||||
* - 기본정보 (제품명, LOT NO, 제품코드, 로트크기 등)
|
||||
* - 제품 사진
|
||||
* - 검사항목 테이블 (No, 검사항목, 검사기준, 검사방법, 검사주기, 측정값, 판정)
|
||||
* - No/검사항목: 그룹별 rowSpan 병합
|
||||
* - 검사방법/검사주기: 크로스그룹 병합 (methodSpan/freqSpan) + 그룹 내 병합 하이브리드
|
||||
* - 판정: □ 적합 / □ 부적합 클릭 가능, judgmentSpan으로 크로스그룹 병합
|
||||
* - 특이사항 + 종합판정(자동계산)을 테이블 마지막 행으로 포함
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ConstructionApprovalTable } from '@/components/document-system';
|
||||
import type { InspectionReportDocument as InspectionReportDocumentType, ReportInspectionItem } from '../types';
|
||||
|
||||
interface InspectionReportDocumentProps {
|
||||
data: InspectionReportDocumentType;
|
||||
}
|
||||
|
||||
/** 검사항목을 No 기준으로 그룹화 */
|
||||
function groupItemsByNo(items: ReportInspectionItem[]) {
|
||||
const groups: { no: number; category: string; rows: ReportInspectionItem[] }[] = [];
|
||||
let currentGroup: (typeof groups)[number] | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
if (!currentGroup || currentGroup.no !== item.no) {
|
||||
currentGroup = { no: item.no, category: item.category, rows: [] };
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup.rows.push(item);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 크로스그룹 병합 커버리지 계산: { map: flatIndex→rowSpan, covered: Set<coveredIndices> } */
|
||||
function buildCoverageMap(items: ReportInspectionItem[], field: 'methodSpan' | 'freqSpan' | 'judgmentSpan' | 'subCategorySpan' | 'measuredValueSpan') {
|
||||
const map: Record<number, number> = {};
|
||||
const covered = new Set<number>();
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
const span = item[field];
|
||||
if (span && span > 0) {
|
||||
map[idx] = span;
|
||||
for (let i = idx + 1; i < idx + span && i < items.length; i++) {
|
||||
covered.add(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { map, covered };
|
||||
}
|
||||
|
||||
export function InspectionReportDocument({ data }: InspectionReportDocumentProps) {
|
||||
// 판정 인터랙션을 위한 stateful items
|
||||
const [items, setItems] = useState<ReportInspectionItem[]>(() => data.inspectionItems);
|
||||
|
||||
const groups = useMemo(() => groupItemsByNo(items), [items]);
|
||||
|
||||
// 크로스그룹 병합 렌더맵
|
||||
const methodCoverage = useMemo(() => buildCoverageMap(items, 'methodSpan'), [items]);
|
||||
const freqCoverage = useMemo(() => buildCoverageMap(items, 'freqSpan'), [items]);
|
||||
const judgmentCoverage = useMemo(() => buildCoverageMap(items, 'judgmentSpan'), [items]);
|
||||
const subCatCoverage = useMemo(() => buildCoverageMap(items, 'subCategorySpan'), [items]);
|
||||
const measuredValueCoverage = useMemo(() => buildCoverageMap(items, 'measuredValueSpan'), [items]);
|
||||
|
||||
// 그룹별 flat index 오프셋
|
||||
const groupOffsets = useMemo(() => {
|
||||
const offsets: number[] = [];
|
||||
let offset = 0;
|
||||
for (const group of groups) {
|
||||
offsets.push(offset);
|
||||
offset += group.rows.length;
|
||||
}
|
||||
return offsets;
|
||||
}, [groups]);
|
||||
|
||||
// 종합판정 자동 계산
|
||||
const calculatedJudgment = useMemo(() => {
|
||||
const processedBySpan = new Set<number>();
|
||||
const judgments: ('적합' | '부적합' | undefined)[] = [];
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
if (item.hideJudgment) return;
|
||||
if (processedBySpan.has(idx)) return;
|
||||
|
||||
if (item.judgmentSpan) {
|
||||
judgments.push(item.judgment);
|
||||
for (let i = idx + 1; i < idx + item.judgmentSpan && i < items.length; i++) {
|
||||
processedBySpan.add(i);
|
||||
}
|
||||
} else if (!judgmentCoverage.covered.has(idx)) {
|
||||
judgments.push(item.judgment);
|
||||
}
|
||||
});
|
||||
|
||||
if (judgments.some(j => j === '부적합')) return '불합격';
|
||||
if (judgments.every(j => j === '적합')) return '합격';
|
||||
return '합격';
|
||||
}, [items, judgmentCoverage.covered]);
|
||||
|
||||
// 판정 클릭 핸들러
|
||||
const handleJudgmentClick = useCallback((flatIdx: number, value: '적합' | '부적합') => {
|
||||
setItems(prev => {
|
||||
const next = [...prev];
|
||||
next[flatIdx] = {
|
||||
...next[flatIdx],
|
||||
judgment: next[flatIdx].judgment === value ? undefined : value,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full text-[11px]">
|
||||
{/* 헤더: 제목 (좌측) + 결재란 (우측) */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-widest mb-2">제 품 검 사 성 적 서</h1>
|
||||
<div className="text-[10px] space-y-1">
|
||||
<div className="flex gap-4">
|
||||
<span>문서번호: <strong>{data.documentNumber}</strong></span>
|
||||
<span>작성일자: <strong>{data.createdDate}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructionApprovalTable
|
||||
approvers={{
|
||||
writer: data.approvalLine[0]
|
||||
? { name: data.approvalLine[0].name, department: data.approvalLine[0].department }
|
||||
: undefined,
|
||||
approver1: data.approvalLine[1]
|
||||
? { name: data.approvalLine[1].name, department: data.approvalLine[1].department }
|
||||
: undefined,
|
||||
approver2: data.approvalLine[2]
|
||||
? { name: data.approvalLine[2].name, department: data.approvalLine[2].department }
|
||||
: undefined,
|
||||
approver3: data.approvalLine[3]
|
||||
? { name: data.approvalLine[3].name, department: data.approvalLine[3].department }
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<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>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">제품명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.productName || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">제품 LOT NO</td>
|
||||
<td className="px-2 py-1">{data.productLotNo || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">제품코드</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.productCode || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">로트크기</td>
|
||||
<td className="px-2 py-1">{data.lotSize || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">수주처</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.client || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">검사일자</td>
|
||||
<td className="px-2 py-1">{data.inspectionDate || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">현장명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.siteName || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">검사자</td>
|
||||
<td className="px-2 py-1">{data.inspector || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 제품 사진 */}
|
||||
<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>
|
||||
<div className="p-4 flex items-center justify-center min-h-[200px]">
|
||||
{data.productImage ? (
|
||||
<img
|
||||
src={data.productImage}
|
||||
alt="제품 사진"
|
||||
className="max-h-[300px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400 text-center">
|
||||
<div className="border-2 border-dashed border-gray-300 p-8 rounded">
|
||||
제품 사진 없음
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검사항목 테이블 + 특이사항 + 종합판정 */}
|
||||
<table className="w-full border-collapse border border-gray-400 mb-4">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<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" colSpan={2}>검사기준</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>
|
||||
{groups.map((group, groupIdx) =>
|
||||
group.rows.map((row, rowIdx) => {
|
||||
const flatIdx = groupOffsets[groupIdx] + rowIdx;
|
||||
|
||||
// === 검사방법 셀 렌더 결정 ===
|
||||
let renderMethod = false;
|
||||
let methodRowSpan = 1;
|
||||
if (methodCoverage.map[flatIdx] !== undefined) {
|
||||
// 크로스그룹 병합 시작점
|
||||
renderMethod = true;
|
||||
methodRowSpan = methodCoverage.map[flatIdx];
|
||||
} else if (!methodCoverage.covered.has(flatIdx) && rowIdx === 0) {
|
||||
// 크로스그룹에 속하지 않음 → 그룹 내 병합 (첫 행)
|
||||
renderMethod = true;
|
||||
methodRowSpan = group.rows.length;
|
||||
}
|
||||
|
||||
// === 검사주기 셀 렌더 결정 ===
|
||||
let renderFreq = false;
|
||||
let freqRowSpan = 1;
|
||||
if (freqCoverage.map[flatIdx] !== undefined) {
|
||||
renderFreq = true;
|
||||
freqRowSpan = freqCoverage.map[flatIdx];
|
||||
} else if (!freqCoverage.covered.has(flatIdx) && rowIdx === 0) {
|
||||
renderFreq = true;
|
||||
freqRowSpan = group.rows.length;
|
||||
}
|
||||
|
||||
// === 판정 셀 렌더 결정 ===
|
||||
let renderJudgment = false;
|
||||
let judgmentRowSpan = 1;
|
||||
if (judgmentCoverage.map[flatIdx] !== undefined) {
|
||||
// 크로스그룹 판정 병합 시작점
|
||||
renderJudgment = true;
|
||||
judgmentRowSpan = judgmentCoverage.map[flatIdx];
|
||||
} else if (!judgmentCoverage.covered.has(flatIdx)) {
|
||||
// 일반 per-row 판정
|
||||
renderJudgment = true;
|
||||
judgmentRowSpan = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={flatIdx} className="border-b border-gray-300">
|
||||
{/* No. - 그룹 첫 행만 rowSpan */}
|
||||
{rowIdx === 0 && (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 text-center align-middle font-medium"
|
||||
rowSpan={group.rows.length}
|
||||
>
|
||||
{group.no}
|
||||
</td>
|
||||
)}
|
||||
{/* 검사항목 - 그룹 첫 행만 rowSpan */}
|
||||
{rowIdx === 0 && (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 align-middle font-medium whitespace-pre-line"
|
||||
rowSpan={group.rows.length}
|
||||
>
|
||||
{group.category}
|
||||
</td>
|
||||
)}
|
||||
{/* 검사기준: subCategory 셀 */}
|
||||
{(() => {
|
||||
// subCategorySpan 시작점 → 병합 셀 렌더
|
||||
if (subCatCoverage.map[flatIdx] !== undefined) {
|
||||
return (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 align-middle font-medium whitespace-pre-line text-center"
|
||||
rowSpan={subCatCoverage.map[flatIdx]}
|
||||
>
|
||||
{row.subCategory}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
// 다른 셀의 subCategorySpan에 포함 → 렌더 안함
|
||||
if (subCatCoverage.covered.has(flatIdx)) {
|
||||
return null;
|
||||
}
|
||||
// 개별 subCategory 있음 → 단일 셀
|
||||
if (row.subCategory) {
|
||||
return (
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium whitespace-pre-line">
|
||||
{row.subCategory}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
// subCategory 없음 → 렌더 안함 (criteria가 colSpan=2)
|
||||
return null;
|
||||
})()}
|
||||
{/* 검사기준: criteria 셀 */}
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 whitespace-pre-line"
|
||||
colSpan={
|
||||
// subCategory 셀이 없는 경우 (no subCategory AND not covered by span) → colSpan=2
|
||||
!row.subCategory && !subCatCoverage.covered.has(flatIdx) && subCatCoverage.map[flatIdx] === undefined
|
||||
? 2
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{row.criteria}
|
||||
</td>
|
||||
{/* 검사방법 */}
|
||||
{renderMethod && (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
|
||||
rowSpan={methodRowSpan}
|
||||
>
|
||||
{row.method || ''}
|
||||
</td>
|
||||
)}
|
||||
{/* 검사주기 */}
|
||||
{renderFreq && (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
|
||||
rowSpan={freqRowSpan}
|
||||
>
|
||||
{row.frequency || ''}
|
||||
</td>
|
||||
)}
|
||||
{/* 측정값 */}
|
||||
{(() => {
|
||||
if (measuredValueCoverage.map[flatIdx] !== undefined) {
|
||||
return (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 text-center align-middle"
|
||||
rowSpan={measuredValueCoverage.map[flatIdx]}
|
||||
>
|
||||
{row.measuredValue || ''}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (measuredValueCoverage.covered.has(flatIdx)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">
|
||||
{row.measuredValue || ''}
|
||||
</td>
|
||||
);
|
||||
})()}
|
||||
{/* 판정 */}
|
||||
{renderJudgment && (
|
||||
row.hideJudgment ? (
|
||||
<td
|
||||
className="border border-gray-400 px-1 py-1"
|
||||
rowSpan={judgmentRowSpan}
|
||||
/>
|
||||
) : (
|
||||
<td
|
||||
className="border border-gray-400 px-1 py-1 text-center align-middle"
|
||||
rowSpan={judgmentRowSpan}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1 text-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
className={`cursor-pointer hover:opacity-80 ${
|
||||
row.judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'
|
||||
}`}
|
||||
onClick={() => handleJudgmentClick(flatIdx, '적합')}
|
||||
>
|
||||
{row.judgment === '적합' ? '■' : '□'} 적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`cursor-pointer hover:opacity-80 ${
|
||||
row.judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'
|
||||
}`}
|
||||
onClick={() => handleJudgmentClick(flatIdx, '부적합')}
|
||||
>
|
||||
{row.judgment === '부적합' ? '■' : '□'} 부적합
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="border border-gray-400 px-2 py-4 text-center text-gray-400">
|
||||
검사항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* 특이사항 + 종합판정 (테이블 마지막 행) */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-100 px-2 py-2 font-bold text-center" colSpan={2}>
|
||||
특이사항
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-2" colSpan={4}>
|
||||
{data.specialNotes || ''}
|
||||
</td>
|
||||
<td className="border border-gray-400 bg-gray-100 px-2 py-2 font-bold text-center">
|
||||
종합판정
|
||||
</td>
|
||||
<td className={`border border-gray-400 px-2 py-2 text-center font-bold text-sm ${
|
||||
calculatedJudgment === '합격' ? 'text-blue-600' : 'text-red-600'
|
||||
}`}>
|
||||
{calculatedJudgment}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 서명 영역 */}
|
||||
<div className="mt-8 text-center text-[10px]">
|
||||
<p>위 내용과 같이 제품검사 결과를 보고합니다.</p>
|
||||
<div className="mt-6">
|
||||
<p>{data.createdDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 제품검사성적서 모달
|
||||
* DocumentViewer를 사용하여 문서 표시 + 인쇄/PDF 기능 제공
|
||||
*/
|
||||
|
||||
import { Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { InspectionReportDocument } from './InspectionReportDocument';
|
||||
import type { InspectionReportDocument as InspectionReportDocumentType } from '../types';
|
||||
|
||||
interface InspectionReportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
data: InspectionReportDocumentType | null;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
export function InspectionReportModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
onSave,
|
||||
}: InspectionReportModalProps) {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="제품검사성적서"
|
||||
preset="readonly"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
pdfMeta={{
|
||||
documentNumber: data.documentNumber,
|
||||
createdDate: data.createdDate,
|
||||
}}
|
||||
toolbarExtra={
|
||||
<Button onClick={onSave} size="sm">
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
저장
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<InspectionReportDocument data={data} />
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 제품검사요청서 문서
|
||||
*
|
||||
* 기획서 기반:
|
||||
* - 결재라인 (작성/승인)
|
||||
* - 기본정보 (수주처, 업체명, 담당자 등)
|
||||
* - 입력사항 (건축공사장, 자재유통업자, 공사시공자, 공사감리자)
|
||||
* - 검사대상 사전 고지 정보 테이블
|
||||
*/
|
||||
|
||||
import { ConstructionApprovalTable } from '@/components/document-system';
|
||||
import { isOrderSpecSame } from '../mockData';
|
||||
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
|
||||
|
||||
interface InspectionRequestDocumentProps {
|
||||
data: InspectionRequestDocumentType;
|
||||
}
|
||||
|
||||
export function InspectionRequestDocument({ data }: InspectionRequestDocumentProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full text-[11px]">
|
||||
{/* 헤더: 제목 (좌측) + 결재란 (우측) */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-widest mb-2">제 품 검 사 요 청 서</h1>
|
||||
<div className="text-[10px] space-y-1">
|
||||
<div className="flex gap-4">
|
||||
<span>문서번호: <strong>{data.documentNumber}</strong></span>
|
||||
<span>작성일자: <strong>{data.createdDate}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructionApprovalTable
|
||||
approvers={{
|
||||
writer: data.approvalLine[0]
|
||||
? { name: data.approvalLine[0].name, department: data.approvalLine[0].department }
|
||||
: undefined,
|
||||
approver1: data.approvalLine[1]
|
||||
? { name: data.approvalLine[1].name, department: data.approvalLine[1].department }
|
||||
: undefined,
|
||||
approver2: data.approvalLine[2]
|
||||
? { name: data.approvalLine[2].name, department: data.approvalLine[2].department }
|
||||
: undefined,
|
||||
approver3: data.approvalLine[3]
|
||||
? { name: data.approvalLine[3].name, department: data.approvalLine[3].department }
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<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>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">수주처</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.client || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">업체명</td>
|
||||
<td className="px-2 py-1">{data.companyName || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">담당자</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.manager || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">수주번호</td>
|
||||
<td className="px-2 py-1">{data.orderNumber || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">담당자 연락처</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.managerContact || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">현장명</td>
|
||||
<td className="px-2 py-1">{data.siteName || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">납품일</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.deliveryDate || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">현장 주소</td>
|
||||
<td className="px-2 py-1">{data.siteAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">총 개소</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.totalLocations || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">접수일</td>
|
||||
<td className="px-2 py-1">{data.receptionDate || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-t border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">검사방문요청일</td>
|
||||
<td className="px-2 py-1" colSpan={3}>{data.visitRequestDate || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 입력사항: 4개 섹션 */}
|
||||
<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>
|
||||
|
||||
{/* 건축공사장 정보 */}
|
||||
<div className="border-b border-gray-300">
|
||||
<div className="bg-gray-100 px-2 py-1 font-medium border-b border-gray-300">건축공사장 정보</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">현장명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.constructionSite.siteName || '-'}</td>
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">대지위치</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.constructionSite.landLocation || '-'}</td>
|
||||
<td className="bg-gray-50 px-2 py-1 w-20 font-medium border-r border-gray-300">지번</td>
|
||||
<td className="px-2 py-1">{data.constructionSite.lotNumber || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 자재유통업자 정보 */}
|
||||
<div className="border-b border-gray-300">
|
||||
<div className="bg-gray-100 px-2 py-1 font-medium border-b border-gray-300">자재유통업자 정보</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">회사명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.materialDistributor.companyName || '-'}</td>
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">회사주소</td>
|
||||
<td className="px-2 py-1">{data.materialDistributor.companyAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">대표자명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.materialDistributor.representativeName || '-'}</td>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">전화번호</td>
|
||||
<td className="px-2 py-1">{data.materialDistributor.phone || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 공사시공자 정보 */}
|
||||
<div className="border-b border-gray-300">
|
||||
<div className="bg-gray-100 px-2 py-1 font-medium border-b border-gray-300">공사시공자 정보</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">회사명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.constructorInfo.companyName || '-'}</td>
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">회사주소</td>
|
||||
<td className="px-2 py-1">{data.constructorInfo.companyAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">성명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.constructorInfo.name || '-'}</td>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">전화번호</td>
|
||||
<td className="px-2 py-1">{data.constructorInfo.phone || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 공사감리자 정보 */}
|
||||
<div>
|
||||
<div className="bg-gray-100 px-2 py-1 font-medium border-b border-gray-300">공사감리자 정보</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">사무소명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.supervisor.officeName || '-'}</td>
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">사무소주소</td>
|
||||
<td className="px-2 py-1">{data.supervisor.officeAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">성명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">{data.supervisor.name || '-'}</td>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">전화번호</td>
|
||||
<td className="px-2 py-1">{data.supervisor.phone || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검사대상 사전 고지 정보 */}
|
||||
<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>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-10 text-center">No.</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1">수주번호</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1">층수</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1">부호</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 text-center">수주 가로</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 text-center">수주 세로</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 text-center">시공 가로</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 text-center">시공 세로</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 text-center">일치</th>
|
||||
<th className="px-2 py-1">변경사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.priorNoticeItems.map((item, index) => {
|
||||
const isSame = isOrderSpecSame(item);
|
||||
return (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{index + 1}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">{item.orderNumber}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">{item.floor}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">{item.symbol}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderWidth}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderHeight}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionWidth}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionHeight}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center font-medium">
|
||||
<span className={isSame ? 'text-green-700' : 'text-red-700'}>
|
||||
{isSame ? '일치' : '불일치'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1">{item.changeReason || '-'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{data.priorNoticeItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-2 py-4 text-center text-gray-400">
|
||||
검사대상 사전 고지 정보가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 서명 영역 */}
|
||||
<div className="mt-8 text-center text-[10px]">
|
||||
<p>위 내용과 같이 제품검사를 요청합니다.</p>
|
||||
<div className="mt-6">
|
||||
<p>{data.createdDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 제품검사요청서 모달
|
||||
* DocumentViewer를 사용하여 문서 표시 + 인쇄/PDF 기능 제공
|
||||
*/
|
||||
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { InspectionRequestDocument } from './InspectionRequestDocument';
|
||||
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
|
||||
|
||||
interface InspectionRequestModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
data: InspectionRequestDocumentType | null;
|
||||
}
|
||||
|
||||
export function InspectionRequestModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
}: InspectionRequestModalProps) {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="제품검사요청서"
|
||||
preset="readonly"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
pdfMeta={{
|
||||
documentNumber: data.documentNumber,
|
||||
createdDate: data.createdDate,
|
||||
}}
|
||||
>
|
||||
<InspectionRequestDocument data={data} />
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { InspectionRequestDocument } from './InspectionRequestDocument';
|
||||
export { InspectionRequestModal } from './InspectionRequestModal';
|
||||
export { InspectionReportDocument } from './InspectionReportDocument';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
@@ -1,22 +1,31 @@
|
||||
/**
|
||||
* 검사관리 컴포넌트 및 타입 export
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* 제품검사 관리 컴포넌트 및 타입 export
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
// Note: mockData.ts는 statusColorMap, inspectionTypeLabels 등 상수만 사용 중 (삭제 시 분리 필요)
|
||||
export * from './mockData';
|
||||
export { InspectionList } from './InspectionList';
|
||||
export { InspectionCreate } from './InspectionCreate';
|
||||
export { InspectionDetail } from './InspectionDetail';
|
||||
export { OrderSelectModal } from './OrderSelectModal';
|
||||
|
||||
// 문서 컴포넌트
|
||||
export {
|
||||
InspectionRequestDocument,
|
||||
InspectionRequestModal,
|
||||
InspectionReportDocument,
|
||||
InspectionReportModal,
|
||||
} from './documents';
|
||||
|
||||
// Server Actions (API 연동)
|
||||
export {
|
||||
getInspections,
|
||||
getInspectionStats,
|
||||
getInspectionCalendar,
|
||||
getInspectionById,
|
||||
createInspection,
|
||||
updateInspection,
|
||||
deleteInspection,
|
||||
completeInspection,
|
||||
} from './actions';
|
||||
getOrderSelectList,
|
||||
} from './actions';
|
||||
|
||||
@@ -4,14 +4,13 @@ import { ClipboardCheck } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 품질검사 등록 페이지 Config
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
* 제품검사 등록 페이지 Config
|
||||
*/
|
||||
export const qualityInspectionCreateConfig: DetailConfig = {
|
||||
title: '품질검사',
|
||||
description: '품질검사를 등록합니다',
|
||||
title: '제품검사',
|
||||
description: '제품검사를 등록합니다',
|
||||
icon: ClipboardCheck,
|
||||
basePath: '/quality/inspection-management',
|
||||
basePath: '/quality/inspections',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
@@ -23,22 +22,18 @@ export const qualityInspectionCreateConfig: DetailConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 검수관리 상세 페이지 Config
|
||||
*
|
||||
* 참고: 이 config는 타이틀/버튼 영역만 정의
|
||||
* 폼 내용은 기존 InspectionDetail의 renderView/renderForm에서 처리
|
||||
* (검사 데이터 테이블, 측정값 입력, validation 등 특수 기능 유지)
|
||||
* 제품검사 상세 페이지 Config
|
||||
*/
|
||||
export const inspectionConfig: DetailConfig = {
|
||||
title: '검사 상세',
|
||||
description: '검사 정보를 조회하고 관리합니다',
|
||||
title: '제품검사 상세',
|
||||
description: '제품검사 상세를 관리합니다',
|
||||
icon: ClipboardCheck,
|
||||
basePath: '/quality/inspections',
|
||||
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
|
||||
fields: [],
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: false, // 검수관리는 삭제 기능 없음
|
||||
showDelete: false,
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
editLabel: '수정',
|
||||
|
||||
@@ -1,305 +1,476 @@
|
||||
import type {
|
||||
Inspection,
|
||||
ProductInspection,
|
||||
InspectionStats,
|
||||
InspectionItem,
|
||||
InspectionCalendarItem,
|
||||
OrderSelectItem,
|
||||
OrderSettingItem,
|
||||
InspectionStatus,
|
||||
InspectionRequestDocument,
|
||||
InspectionReportDocument,
|
||||
ReportInspectionItem,
|
||||
} from './types';
|
||||
import { getTodayString } from '@/utils/date';
|
||||
|
||||
// 검사 항목 템플릿 (조인트바 예시)
|
||||
export const inspectionItemsTemplate: InspectionItem[] = [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '높이(H)',
|
||||
type: 'measurement',
|
||||
spec: '16.5 ± 1',
|
||||
unit: 'mm',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '길이(L)',
|
||||
type: 'measurement',
|
||||
spec: '300 ± 4',
|
||||
unit: 'mm',
|
||||
},
|
||||
];
|
||||
// ===== 상태/색상 매핑 =====
|
||||
|
||||
// Mock 검사 데이터 (스크린샷 기반)
|
||||
export const mockInspections: Inspection[] = [
|
||||
{
|
||||
id: '1',
|
||||
inspectionNo: 'QC-251219-01',
|
||||
inspectionType: 'IQC',
|
||||
requestDate: '2025-12-19',
|
||||
itemName: 'EGI 철골판 1.5ST',
|
||||
lotNo: 'MAT-251219-01',
|
||||
processName: '입고 검사',
|
||||
quantity: 100,
|
||||
unit: 'EA',
|
||||
status: '대기',
|
||||
inspector: undefined,
|
||||
items: [],
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
inspectionNo: 'QC-251219-02',
|
||||
inspectionType: 'PQC',
|
||||
requestDate: '2025-12-19',
|
||||
inspectionDate: '2025-12-19',
|
||||
itemName: '조인트바',
|
||||
lotNo: 'WO-251219-05',
|
||||
processName: '조립 공정',
|
||||
quantity: 50,
|
||||
unit: 'EA',
|
||||
status: '진행중',
|
||||
result: undefined,
|
||||
inspector: '홍길동',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
result: '양호',
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '높이(H)',
|
||||
type: 'measurement',
|
||||
spec: '16.5 ± 1',
|
||||
unit: 'mm',
|
||||
measuredValue: 16.6,
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '길이(L)',
|
||||
type: 'measurement',
|
||||
spec: '300 ± 4',
|
||||
unit: 'mm',
|
||||
measuredValue: 301,
|
||||
judgment: '적합',
|
||||
},
|
||||
],
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
inspectionNo: 'QC-251218-03',
|
||||
inspectionType: 'FQC',
|
||||
requestDate: '2025-12-18',
|
||||
inspectionDate: '2025-12-18',
|
||||
itemName: '방화샤터 완제품',
|
||||
lotNo: 'WO-251218-02',
|
||||
processName: '최종 검사',
|
||||
quantity: 10,
|
||||
unit: 'EA',
|
||||
status: '완료',
|
||||
result: '합격',
|
||||
inspector: '김철수',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
result: '양호',
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '높이(H)',
|
||||
type: 'measurement',
|
||||
spec: '16.5 ± 1',
|
||||
unit: 'mm',
|
||||
measuredValue: 16.6,
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '길이(L)',
|
||||
type: 'measurement',
|
||||
spec: '300 ± 4',
|
||||
unit: 'mm',
|
||||
measuredValue: 301,
|
||||
judgment: '적합',
|
||||
},
|
||||
],
|
||||
remarks: '',
|
||||
opinion: '특이사항 없음. 후공정(포장) 인계 완료함.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-1',
|
||||
fileName: '현장_검사_사진_01.jpg',
|
||||
fileUrl: '/uploads/inspection/현장_검사_사진_01.jpg',
|
||||
fileType: 'image/jpeg',
|
||||
uploadedAt: '2025-12-18T10:30:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
inspectionNo: 'QC-251218-04',
|
||||
inspectionType: 'PQC',
|
||||
requestDate: '2025-12-18',
|
||||
inspectionDate: '2025-12-18',
|
||||
itemName: '슬랫 성형품',
|
||||
lotNo: 'WO-251218-01',
|
||||
processName: '성형 공정',
|
||||
quantity: 200,
|
||||
unit: 'EA',
|
||||
status: '완료',
|
||||
result: '합격',
|
||||
inspector: '이영희',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
result: '양호',
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '높이(H)',
|
||||
type: 'measurement',
|
||||
spec: '16.5 ± 1',
|
||||
unit: 'mm',
|
||||
measuredValue: 16.4,
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '길이(L)',
|
||||
type: 'measurement',
|
||||
spec: '300 ± 4',
|
||||
unit: 'mm',
|
||||
measuredValue: 299,
|
||||
judgment: '적합',
|
||||
},
|
||||
],
|
||||
remarks: '',
|
||||
opinion: '검사 완료. 이상 없음.',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
inspectionNo: 'QC-251218-05',
|
||||
inspectionType: 'IQC',
|
||||
requestDate: '2025-12-18',
|
||||
inspectionDate: '2025-12-18',
|
||||
itemName: '스테인레스 코일',
|
||||
lotNo: 'MAT-251218-03',
|
||||
processName: '입고 검사',
|
||||
quantity: 5,
|
||||
unit: 'ROLL',
|
||||
status: '완료',
|
||||
result: '합격',
|
||||
inspector: '박민수',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
result: '양호',
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '두께',
|
||||
type: 'measurement',
|
||||
spec: '1.2 ± 0.1',
|
||||
unit: 'mm',
|
||||
measuredValue: 1.19,
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '폭',
|
||||
type: 'measurement',
|
||||
spec: '1000 ± 5',
|
||||
unit: 'mm',
|
||||
measuredValue: 1001,
|
||||
judgment: '적합',
|
||||
},
|
||||
],
|
||||
remarks: '',
|
||||
opinion: '입고 검사 완료. 품질 적합.',
|
||||
},
|
||||
];
|
||||
|
||||
// 통계 데이터 계산
|
||||
export const calculateStats = (inspections: Inspection[]): InspectionStats => {
|
||||
const today = getTodayString();
|
||||
|
||||
const waitingCount = inspections.filter(i => i.status === '대기').length;
|
||||
const inProgressCount = inspections.filter(i => i.status === '진행중').length;
|
||||
const completedToday = inspections.filter(
|
||||
i => i.status === '완료' && i.inspectionDate === today
|
||||
).length;
|
||||
|
||||
const totalCompleted = inspections.filter(i => i.status === '완료').length;
|
||||
const defectCount = inspections.filter(i => i.result === '불합격').length;
|
||||
const defectRate = totalCompleted > 0 ? (defectCount / totalCompleted) * 100 : 0;
|
||||
|
||||
return {
|
||||
waitingCount,
|
||||
inProgressCount,
|
||||
completedCount: completedToday,
|
||||
defectRate: Math.round(defectRate * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
// 기본 통계 (mockData 기준)
|
||||
export const mockStats: InspectionStats = {
|
||||
waitingCount: 1,
|
||||
inProgressCount: 1,
|
||||
completedCount: 3,
|
||||
defectRate: 0.0,
|
||||
};
|
||||
|
||||
// 검사유형 라벨
|
||||
export const inspectionTypeLabels: Record<string, string> = {
|
||||
IQC: '입고검사',
|
||||
PQC: '공정검사',
|
||||
FQC: '최종검사',
|
||||
};
|
||||
|
||||
// 상태 컬러 매핑
|
||||
export const statusColorMap: Record<string, string> = {
|
||||
대기: 'bg-gray-100 text-gray-800',
|
||||
export const statusColorMap: Record<InspectionStatus, string> = {
|
||||
접수: 'bg-gray-100 text-gray-800',
|
||||
진행중: 'bg-blue-100 text-blue-800',
|
||||
완료: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 판정 컬러 매핑
|
||||
export const statusCalendarColorMap: Record<InspectionStatus, string> = {
|
||||
접수: 'bg-blue-500',
|
||||
진행중: 'bg-blue-700',
|
||||
완료: 'bg-blue-400',
|
||||
};
|
||||
|
||||
export const judgmentColorMap: Record<string, string> = {
|
||||
합격: 'bg-green-100 text-green-800',
|
||||
불합격: 'bg-red-100 text-red-800',
|
||||
적합: 'text-green-600',
|
||||
부적합: 'text-red-600',
|
||||
};
|
||||
|
||||
// 측정값 판정 함수
|
||||
export const judgeMeasurement = (spec: string, value: number): '적합' | '부적합' => {
|
||||
// spec 예시: "16.5 ± 1" 또는 "300 ± 4"
|
||||
const match = spec.match(/^([\d.]+)\s*±\s*([\d.]+)$/);
|
||||
if (!match) return '적합'; // 파싱 실패 시 기본 적합
|
||||
// ===== 공통 관련자 정보 기본값 =====
|
||||
|
||||
const [, targetStr, toleranceStr] = match;
|
||||
const target = parseFloat(targetStr);
|
||||
const tolerance = parseFloat(toleranceStr);
|
||||
const defaultConstructionSite = {
|
||||
siteName: '현장명',
|
||||
landLocation: '주소명',
|
||||
lotNumber: '',
|
||||
};
|
||||
|
||||
const min = target - tolerance;
|
||||
const max = target + tolerance;
|
||||
const defaultMaterialDistributor = {
|
||||
companyName: '회사명',
|
||||
companyAddress: '주소명',
|
||||
representativeName: '홍길동',
|
||||
phone: '02-1234-1234',
|
||||
};
|
||||
|
||||
return value >= min && value <= max ? '적합' : '부적합';
|
||||
};
|
||||
const defaultConstructor = {
|
||||
companyName: '회사명',
|
||||
companyAddress: '주소명',
|
||||
name: '홍길동',
|
||||
phone: '02-1234-1234',
|
||||
};
|
||||
|
||||
const defaultSupervisor = {
|
||||
officeName: '회사명',
|
||||
officeAddress: '주소명',
|
||||
name: '홍길동',
|
||||
phone: '02-1234-1234',
|
||||
};
|
||||
|
||||
// ===== 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 },
|
||||
];
|
||||
|
||||
// ===== Mock 수주 설정 항목 =====
|
||||
|
||||
const defaultOrderItems: OrderSettingItem[] = [
|
||||
{
|
||||
id: 'oi-1',
|
||||
orderNumber: '123123',
|
||||
floor: '1층',
|
||||
symbol: '부호명',
|
||||
orderWidth: 4100,
|
||||
orderHeight: 2700,
|
||||
constructionWidth: 4100,
|
||||
constructionHeight: 2700,
|
||||
changeReason: '',
|
||||
},
|
||||
{
|
||||
id: 'oi-2',
|
||||
orderNumber: '123123',
|
||||
floor: '2층',
|
||||
symbol: '부호명',
|
||||
orderWidth: 4100,
|
||||
orderHeight: 2700,
|
||||
constructionWidth: 4100,
|
||||
constructionHeight: 2700,
|
||||
changeReason: '',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== Mock 제품검사 데이터 =====
|
||||
|
||||
export const mockInspections: ProductInspection[] = [
|
||||
{
|
||||
id: '1',
|
||||
qualityDocNumber: '123123',
|
||||
siteName: '현장명',
|
||||
client: '회사명',
|
||||
locationCount: 5,
|
||||
requiredInfo: '완료',
|
||||
inspectionPeriod: '2026-01-01',
|
||||
inspector: '홍길동',
|
||||
status: '접수',
|
||||
author: '홍길동',
|
||||
receptionDate: '2026-01-01',
|
||||
manager: '홍길동',
|
||||
managerContact: '010-1234-1234',
|
||||
constructionSite: defaultConstructionSite,
|
||||
materialDistributor: defaultMaterialDistributor,
|
||||
constructorInfo: defaultConstructor,
|
||||
supervisor: defaultSupervisor,
|
||||
scheduleInfo: {
|
||||
visitRequestDate: '2026-01-01',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-01',
|
||||
inspector: '홍길동',
|
||||
sitePostalCode: '123',
|
||||
siteAddress: '서울특별시 서초구 서초대로 123',
|
||||
siteAddressDetail: '대한건물 12층 1201호',
|
||||
},
|
||||
orderItems: defaultOrderItems,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
qualityDocNumber: '123123',
|
||||
siteName: '현장명',
|
||||
client: '회사명',
|
||||
locationCount: 5,
|
||||
requiredInfo: '4건 누락',
|
||||
inspectionPeriod: '2026-01-01~2026-01-02',
|
||||
inspector: '홍길동',
|
||||
status: '진행중',
|
||||
author: '홍길동',
|
||||
receptionDate: '2026-01-01',
|
||||
manager: '홍길동',
|
||||
managerContact: '010-1234-1234',
|
||||
constructionSite: defaultConstructionSite,
|
||||
materialDistributor: defaultMaterialDistributor,
|
||||
constructorInfo: defaultConstructor,
|
||||
supervisor: defaultSupervisor,
|
||||
scheduleInfo: {
|
||||
visitRequestDate: '2026-01-01',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-02',
|
||||
inspector: '홍길동',
|
||||
sitePostalCode: '123',
|
||||
siteAddress: '서울특별시 서초구 서초대로 123',
|
||||
siteAddressDetail: '대한건물 12층 1201호',
|
||||
},
|
||||
orderItems: defaultOrderItems,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
qualityDocNumber: '123123',
|
||||
siteName: '현장명',
|
||||
client: '회사명',
|
||||
locationCount: 5,
|
||||
requiredInfo: '3건 누락',
|
||||
inspectionPeriod: '2026-01-01',
|
||||
inspector: '홍길동',
|
||||
status: '접수',
|
||||
author: '홍길동',
|
||||
receptionDate: '2026-01-01',
|
||||
manager: '홍길동',
|
||||
managerContact: '010-1234-1234',
|
||||
constructionSite: defaultConstructionSite,
|
||||
materialDistributor: defaultMaterialDistributor,
|
||||
constructorInfo: defaultConstructor,
|
||||
supervisor: defaultSupervisor,
|
||||
scheduleInfo: {
|
||||
visitRequestDate: '2026-01-01',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-01',
|
||||
inspector: '홍길동',
|
||||
sitePostalCode: '123',
|
||||
siteAddress: '서울특별시 서초구 서초대로 123',
|
||||
siteAddressDetail: '대한건물 12층 1201호',
|
||||
},
|
||||
orderItems: defaultOrderItems,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
qualityDocNumber: '123123',
|
||||
siteName: '현장명',
|
||||
client: '회사명',
|
||||
locationCount: 5,
|
||||
requiredInfo: '완료',
|
||||
inspectionPeriod: '2026-01-01~2026-01-02',
|
||||
inspector: '홍길동',
|
||||
status: '완료',
|
||||
author: '홍길동',
|
||||
receptionDate: '2026-01-01',
|
||||
manager: '홍길동',
|
||||
managerContact: '010-1234-1234',
|
||||
constructionSite: defaultConstructionSite,
|
||||
materialDistributor: defaultMaterialDistributor,
|
||||
constructorInfo: defaultConstructor,
|
||||
supervisor: defaultSupervisor,
|
||||
scheduleInfo: {
|
||||
visitRequestDate: '2026-01-01',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-02',
|
||||
inspector: '홍길동',
|
||||
sitePostalCode: '123',
|
||||
siteAddress: '서울특별시 서초구 서초대로 123',
|
||||
siteAddressDetail: '대한건물 12층 1201호',
|
||||
},
|
||||
orderItems: defaultOrderItems,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
qualityDocNumber: '123123',
|
||||
siteName: '현장명',
|
||||
client: '회사명',
|
||||
locationCount: 5,
|
||||
requiredInfo: '4건 누락',
|
||||
inspectionPeriod: '2026-01-01',
|
||||
inspector: '홍길동',
|
||||
status: '완료',
|
||||
author: '홍길동',
|
||||
receptionDate: '2026-01-01',
|
||||
manager: '홍길동',
|
||||
managerContact: '010-1234-1234',
|
||||
constructionSite: defaultConstructionSite,
|
||||
materialDistributor: defaultMaterialDistributor,
|
||||
constructorInfo: defaultConstructor,
|
||||
supervisor: defaultSupervisor,
|
||||
scheduleInfo: {
|
||||
visitRequestDate: '2026-01-01',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-01',
|
||||
inspector: '진행중',
|
||||
sitePostalCode: '123',
|
||||
siteAddress: '서울특별시 서초구 서초대로 123',
|
||||
siteAddressDetail: '대한건물 12층 1201호',
|
||||
},
|
||||
orderItems: defaultOrderItems,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
qualityDocNumber: '123123',
|
||||
siteName: '현장명',
|
||||
client: '회사명',
|
||||
locationCount: 5,
|
||||
requiredInfo: '3건 누락',
|
||||
inspectionPeriod: '2026-01-01~2026-01-02',
|
||||
inspector: '홍길동',
|
||||
status: '접수',
|
||||
author: '홍길동',
|
||||
receptionDate: '2026-01-01',
|
||||
manager: '홍길동',
|
||||
managerContact: '010-1234-1234',
|
||||
constructionSite: defaultConstructionSite,
|
||||
materialDistributor: defaultMaterialDistributor,
|
||||
constructorInfo: defaultConstructor,
|
||||
supervisor: defaultSupervisor,
|
||||
scheduleInfo: {
|
||||
visitRequestDate: '2026-01-01',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-02',
|
||||
inspector: '홍길동',
|
||||
sitePostalCode: '123',
|
||||
siteAddress: '서울특별시 서초구 서초대로 123',
|
||||
siteAddressDetail: '대한건물 12층 1201호',
|
||||
},
|
||||
orderItems: defaultOrderItems,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
qualityDocNumber: '123123',
|
||||
siteName: '현장명',
|
||||
client: '회사명',
|
||||
locationCount: 5,
|
||||
requiredInfo: '완료',
|
||||
inspectionPeriod: '2026-01-01',
|
||||
inspector: '홍길동',
|
||||
status: '완료',
|
||||
author: '홍길동',
|
||||
receptionDate: '2026-01-01',
|
||||
manager: '홍길동',
|
||||
managerContact: '010-1234-1234',
|
||||
constructionSite: defaultConstructionSite,
|
||||
materialDistributor: defaultMaterialDistributor,
|
||||
constructorInfo: defaultConstructor,
|
||||
supervisor: defaultSupervisor,
|
||||
scheduleInfo: {
|
||||
visitRequestDate: '2026-01-01',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-01',
|
||||
inspector: '홍길동',
|
||||
sitePostalCode: '123',
|
||||
siteAddress: '서울특별시 서초구 서초대로 123',
|
||||
siteAddressDetail: '대한건물 12층 1201호',
|
||||
},
|
||||
orderItems: defaultOrderItems,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== Mock 통계 =====
|
||||
|
||||
export const mockStats: InspectionStats = {
|
||||
receptionCount: 10,
|
||||
inProgressCount: 10,
|
||||
completedCount: 10,
|
||||
};
|
||||
|
||||
// ===== 통계 계산 =====
|
||||
|
||||
export const calculateStats = (inspections: ProductInspection[]): InspectionStats => {
|
||||
return {
|
||||
receptionCount: inspections.filter(i => i.status === '접수').length,
|
||||
inProgressCount: inspections.filter(i => i.status === '진행중').length,
|
||||
completedCount: inspections.filter(i => i.status === '완료').length,
|
||||
};
|
||||
};
|
||||
|
||||
// ===== Mock 캘린더 데이터 =====
|
||||
|
||||
export const mockCalendarItems: InspectionCalendarItem[] = [
|
||||
{ id: 'cal-1', startDate: '2026-01-13', endDate: '2026-01-14', inspector: '홍길동', siteName: '현장명', status: '완료' },
|
||||
{ id: 'cal-2', startDate: '2026-01-15', endDate: '2026-01-17', inspector: '홍길동', siteName: '현장명', status: '진행중' },
|
||||
{ id: 'cal-3', startDate: '2026-01-23', endDate: '2026-01-25', inspector: '홍길동', siteName: '현장명', status: '진행중' },
|
||||
{ id: 'cal-4', startDate: '2026-01-26', endDate: '2026-01-26', inspector: '홍길동', siteName: '현장명', status: '접수' },
|
||||
{ id: 'cal-5', startDate: '2026-01-26', endDate: '2026-01-27', inspector: '홍길동', siteName: '현장명', status: '접수' },
|
||||
{ id: 'cal-6', startDate: '2026-01-27', endDate: '2026-01-28', inspector: '홍길동', siteName: '현장명', status: '접수' },
|
||||
{ id: 'cal-7', startDate: '2026-01-28', endDate: '2026-01-28', inspector: '홍길동', siteName: '현장명', status: '접수' },
|
||||
];
|
||||
|
||||
// ===== 수주 규격 비교 유틸 =====
|
||||
|
||||
export const isOrderSpecSame = (item: OrderSettingItem): boolean => {
|
||||
return item.orderWidth === item.constructionWidth && item.orderHeight === item.constructionHeight;
|
||||
};
|
||||
|
||||
export const calculateOrderSummary = (items: OrderSettingItem[]) => {
|
||||
const total = items.length;
|
||||
const same = items.filter(isOrderSpecSame).length;
|
||||
const changed = total - same;
|
||||
return { total, same, changed };
|
||||
};
|
||||
|
||||
// ===== 빈 폼 기본값 =====
|
||||
|
||||
export const emptyConstructionSite = {
|
||||
siteName: '',
|
||||
landLocation: '',
|
||||
lotNumber: '',
|
||||
};
|
||||
|
||||
export const emptyMaterialDistributor = {
|
||||
companyName: '',
|
||||
companyAddress: '',
|
||||
representativeName: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
export const emptyConstructor = {
|
||||
companyName: '',
|
||||
companyAddress: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
export const emptySupervisor = {
|
||||
officeName: '',
|
||||
officeAddress: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
export const emptyScheduleInfo = {
|
||||
visitRequestDate: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
inspector: '',
|
||||
sitePostalCode: '',
|
||||
siteAddress: '',
|
||||
siteAddressDetail: '',
|
||||
};
|
||||
|
||||
// ===== 문서 데이터 변환 헬퍼 =====
|
||||
|
||||
/** ProductInspection → InspectionRequestDocument 변환 */
|
||||
export const buildRequestDocumentData = (
|
||||
inspection: ProductInspection
|
||||
): InspectionRequestDocument => ({
|
||||
documentNumber: `REQ-${inspection.qualityDocNumber}`,
|
||||
createdDate: inspection.receptionDate,
|
||||
approvalLine: [
|
||||
{ role: '작성', name: inspection.author, department: '' },
|
||||
{ role: '승인', name: '', department: '' },
|
||||
],
|
||||
client: inspection.client,
|
||||
companyName: inspection.materialDistributor.companyName,
|
||||
manager: inspection.manager,
|
||||
orderNumber: inspection.orderItems[0]?.orderNumber || '',
|
||||
managerContact: inspection.managerContact,
|
||||
siteName: inspection.siteName,
|
||||
deliveryDate: '',
|
||||
siteAddress: `${inspection.scheduleInfo.siteAddress} ${inspection.scheduleInfo.siteAddressDetail}`.trim(),
|
||||
totalLocations: String(inspection.locationCount),
|
||||
receptionDate: inspection.receptionDate,
|
||||
visitRequestDate: inspection.scheduleInfo.visitRequestDate,
|
||||
constructionSite: inspection.constructionSite,
|
||||
materialDistributor: inspection.materialDistributor,
|
||||
constructorInfo: inspection.constructorInfo,
|
||||
supervisor: inspection.supervisor,
|
||||
priorNoticeItems: inspection.orderItems,
|
||||
});
|
||||
|
||||
/** Mock 검사항목 (제품검사성적서용) - 기획서 기반 상세 항목 */
|
||||
export const mockReportInspectionItems: ReportInspectionItem[] = [
|
||||
// 1. 겉모양 (5개 세부항목) — 항목1+2+3 병합: 육안검사/전수검사 (7행)
|
||||
{ no: 1, category: '겉모양', subCategory: '가공상태', criteria: '사용상 해로운 결함이 없을 것', method: '육안검사', frequency: '전수검사', methodSpan: 7, freqSpan: 7, measuredValueSpan: 7 },
|
||||
{ no: 1, category: '겉모양', subCategory: '재봉상태', criteria: '내화실에 의해 견고하게 접합되어야 함', method: '', frequency: '' },
|
||||
{ no: 1, category: '겉모양', subCategory: '조립상태', criteria: '핸드링이 견고하게 조립되어야 함', method: '', frequency: '' },
|
||||
{ no: 1, category: '겉모양', subCategory: '연기차단재', criteria: '연기차단재 설치여부(케이스 W80, 가이드레일 W50(양쪽 설치))', method: '', frequency: '' },
|
||||
{ no: 1, category: '겉모양', subCategory: '하단마감재', criteria: '내부 무겁방절 설치 유무', method: '', frequency: '' },
|
||||
// 2. 모터
|
||||
{ no: 2, category: '모터', criteria: '인정제품과 동일사양', method: '', frequency: '' },
|
||||
// 3. 재질
|
||||
{ no: 3, category: '재질', criteria: 'WY-SC780 인쇄상태 확인', method: '', frequency: '' },
|
||||
// 4. 치수(오픈사이즈) (4개 세부항목) — 항목4만 병합: 체크검사/전수검사 (4행)
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '길이', criteria: '수주 치수 ± 30mm', method: '체크검사', frequency: '전수검사', methodSpan: 4, freqSpan: 4 },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '높이', criteria: '수주 치수 ± 30mm', method: '', frequency: '' },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '가이드레일 간격', criteria: '10 ± 5mm (측정부위 @ 높이 100 이하)', method: '', frequency: '' },
|
||||
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '하단막대 간격', criteria: '간격 (③+④)\n가이드레일과 하단마감재 측 사이 25mm 이내', method: '', frequency: '' },
|
||||
// 5. 작동테스트 — 판정 없음
|
||||
{ no: 5, category: '작동테스트', subCategory: '개폐성능', criteria: '작동 유무 확인\n(일부 및 완전폐쇄)', method: '', frequency: '', hideJudgment: true },
|
||||
// 6. 내화시험 (3개 세부항목) — "비차열\n차열성" 3행 병합, 항목 6+7+8+9 검사방법/주기/판정 모두 병합 (10행)
|
||||
{ no: 6, category: '내화시험', subCategory: '비차열\n차열성', subCategorySpan: 3, criteria: '6mm 균열게이지 관통 후 150mm 이동 유무', method: '공인\n시험기관\n시험\n성적서', frequency: '1회/5년', methodSpan: 10, freqSpan: 10, measuredValueSpan: 10, judgmentSpan: 10 },
|
||||
{ no: 6, category: '내화시험', criteria: '25mm 균열게이지 관통 유무', method: '', frequency: '' },
|
||||
{ no: 6, category: '내화시험', criteria: '10초 이상 지속되는 화염 발생 유무', method: '', frequency: '' },
|
||||
// 7. 차연시험 — No.6의 span에 포함됨
|
||||
{ no: 7, category: '차연시험', subCategory: '공기누설량', criteria: '25Pa 일 때 공기누설량 0.9m³/min·m² 이하', method: '', frequency: '' },
|
||||
// 8. 개폐시험 (5개 세부항목) — "평균속도" 2행 병합 (전도개폐 + 자중강화)
|
||||
{ no: 8, category: '개폐시험', criteria: '개폐의 원활한 작동', method: '', frequency: '' },
|
||||
{ no: 8, category: '개폐시험', subCategory: '평균속도', subCategorySpan: 2, criteria: '전도개폐 2.5~6.5m/min', method: '', frequency: '' },
|
||||
{ no: 8, category: '개폐시험', criteria: '자중강화 3~7m/min', method: '', frequency: '' },
|
||||
{ no: 8, category: '개폐시험', criteria: '개폐 시 상부 및 하부 끝부분에서 자동정지', method: '', frequency: '' },
|
||||
{ no: 8, category: '개폐시험', criteria: '강화 중 임의의 위치에서 정지', method: '', frequency: '' },
|
||||
// 9. 내충격시험
|
||||
{ no: 9, category: '내충격시험', criteria: '방화상 유해한 파괴, 박리 탈락 유무', method: '', frequency: '' },
|
||||
];
|
||||
|
||||
/** ProductInspection → InspectionReportDocument 변환 */
|
||||
export const buildReportDocumentData = (
|
||||
inspection: ProductInspection
|
||||
): InspectionReportDocument => ({
|
||||
documentNumber: `RPT-${inspection.qualityDocNumber}`,
|
||||
createdDate: inspection.receptionDate,
|
||||
approvalLine: [
|
||||
{ role: '작성', name: inspection.scheduleInfo.inspector || inspection.author, department: '' },
|
||||
{ role: '승인', name: '', department: '' },
|
||||
],
|
||||
productName: '방화스크린',
|
||||
productLotNo: inspection.qualityDocNumber,
|
||||
productCode: '',
|
||||
lotSize: String(inspection.locationCount),
|
||||
client: inspection.client,
|
||||
inspectionDate: inspection.scheduleInfo.startDate,
|
||||
siteName: inspection.siteName,
|
||||
inspector: inspection.scheduleInfo.inspector,
|
||||
inspectionItems: mockReportInspectionItems,
|
||||
specialNotes: '',
|
||||
finalJudgment: inspection.status === '완료' ? '합격' : '합격',
|
||||
});
|
||||
|
||||
@@ -1,96 +1,156 @@
|
||||
// 검사관리 타입 정의
|
||||
// 제품검사 관리 타입 정의
|
||||
|
||||
// 검사유형
|
||||
export type InspectionType = 'IQC' | 'PQC' | 'FQC';
|
||||
// ===== 기본 열거 타입 =====
|
||||
|
||||
// 검사 상태
|
||||
export type InspectionStatus = '대기' | '진행중' | '완료';
|
||||
export type InspectionStatus = '접수' | '진행중' | '완료';
|
||||
|
||||
// 판정 결과
|
||||
export type JudgmentResult = '합격' | '불합격';
|
||||
|
||||
// 검사 항목 결과
|
||||
export type ItemJudgment = '적합' | '부적합';
|
||||
// ===== 관련자 정보 =====
|
||||
|
||||
// 검사 항목 (가공상태 - 양호/불량)
|
||||
export interface QualityCheckItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'quality'; // 양호/불량 선택
|
||||
spec: string;
|
||||
result?: '양호' | '불량';
|
||||
judgment?: ItemJudgment;
|
||||
// 건축공사장 정보
|
||||
export interface ConstructionSiteInfo {
|
||||
siteName: string; // 현장명
|
||||
landLocation: string; // 대지위치
|
||||
lotNumber: string; // 지번
|
||||
}
|
||||
|
||||
// 측정 항목 (높이, 길이 등 - 수치 입력)
|
||||
export interface MeasurementItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'measurement'; // 수치 입력
|
||||
spec: string; // 예: "16.5 ± 1"
|
||||
unit: string; // 예: "mm"
|
||||
measuredValue?: number;
|
||||
judgment?: ItemJudgment;
|
||||
// 자재유통업자 정보
|
||||
export interface MaterialDistributorInfo {
|
||||
companyName: string; // 회사명
|
||||
companyAddress: string; // 회사주소
|
||||
representativeName: string; // 대표자명
|
||||
phone: string; // 전화번호
|
||||
}
|
||||
|
||||
// 검사 항목 통합 타입
|
||||
export type InspectionItem = QualityCheckItem | MeasurementItem;
|
||||
|
||||
// 검사 데이터
|
||||
export interface Inspection {
|
||||
id: string;
|
||||
inspectionNo: string; // 검사번호 (예: QC-251219-05)
|
||||
inspectionType: InspectionType;
|
||||
requestDate: string;
|
||||
inspectionDate?: string;
|
||||
itemName: string; // 품목명
|
||||
lotNo: string;
|
||||
processName: string; // 공정명
|
||||
quantity: number;
|
||||
unit: string;
|
||||
status: InspectionStatus;
|
||||
result?: JudgmentResult;
|
||||
inspector?: string; // 검사자
|
||||
items: InspectionItem[]; // 검사 항목들
|
||||
remarks?: string; // 특이사항
|
||||
opinion?: string; // 종합 의견
|
||||
attachments?: InspectionAttachment[];
|
||||
// 공사시공자 정보
|
||||
export interface ConstructorInfo {
|
||||
companyName: string; // 회사명
|
||||
companyAddress: string; // 회사주소
|
||||
name: string; // 성명
|
||||
phone: string; // 전화번호
|
||||
}
|
||||
|
||||
// 첨부파일
|
||||
export interface InspectionAttachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileType: string;
|
||||
uploadedAt: string;
|
||||
// 공사감리자 정보
|
||||
export interface SupervisorInfo {
|
||||
officeName: string; // 사무소명
|
||||
officeAddress: string; // 사무소주소
|
||||
name: string; // 성명
|
||||
phone: string; // 전화번호
|
||||
}
|
||||
|
||||
// 통계 카드 데이터
|
||||
// ===== 검사 일정/현장 정보 =====
|
||||
|
||||
export interface InspectionScheduleInfo {
|
||||
visitRequestDate: string; // 검사방문요청일
|
||||
startDate: string; // 검사시작일
|
||||
endDate: string; // 검사종료일
|
||||
inspector: string; // 검사자
|
||||
sitePostalCode: string; // 우편번호
|
||||
siteAddress: string; // 현장 주소
|
||||
siteAddressDetail: string; // 상세 주소
|
||||
}
|
||||
|
||||
// ===== 수주 관련 =====
|
||||
|
||||
// 수주 설정 항목 (상세 페이지 테이블)
|
||||
export interface OrderSettingItem {
|
||||
id: string;
|
||||
orderNumber: string; // 수주번호
|
||||
floor: string; // 층수
|
||||
symbol: string; // 부호
|
||||
orderWidth: number; // 수주 규격 - 가로
|
||||
orderHeight: number; // 수주 규격 - 세로
|
||||
constructionWidth: number; // 시공 규격 - 가로
|
||||
constructionHeight: number; // 시공 규격 - 세로
|
||||
changeReason: string; // 변경사유
|
||||
}
|
||||
|
||||
// 수주 선택 모달 항목
|
||||
export interface OrderSelectItem {
|
||||
id: string;
|
||||
orderNumber: string; // 수주번호
|
||||
siteName: string; // 현장명
|
||||
deliveryDate: string; // 납품일
|
||||
locationCount: number; // 개소
|
||||
}
|
||||
|
||||
// ===== 메인 데이터 =====
|
||||
|
||||
// 제품검사 데이터 (리스트 + 상세 공용)
|
||||
export interface ProductInspection {
|
||||
id: string;
|
||||
qualityDocNumber: string; // 품질관리서 번호
|
||||
siteName: string; // 현장명
|
||||
client: string; // 수주처
|
||||
locationCount: number; // 개소
|
||||
requiredInfo: string; // 필수정보 (완료 / N건 누락)
|
||||
inspectionPeriod: string; // 검사기간 (2026-01-01 또는 2026-01-01~2026-01-02)
|
||||
inspector: string; // 검사자
|
||||
status: InspectionStatus; // 상태
|
||||
author: string; // 작성자
|
||||
receptionDate: string; // 접수일
|
||||
manager: string; // 담당자
|
||||
managerContact: string; // 담당자 연락처
|
||||
|
||||
// 관련자 정보
|
||||
constructionSite: ConstructionSiteInfo;
|
||||
materialDistributor: MaterialDistributorInfo;
|
||||
constructorInfo: ConstructorInfo;
|
||||
supervisor: SupervisorInfo;
|
||||
|
||||
// 검사 일정/현장
|
||||
scheduleInfo: InspectionScheduleInfo;
|
||||
|
||||
// 수주 설정
|
||||
orderItems: OrderSettingItem[];
|
||||
}
|
||||
|
||||
// ===== 통계 =====
|
||||
|
||||
export interface InspectionStats {
|
||||
waitingCount: number;
|
||||
inProgressCount: number;
|
||||
completedCount: number;
|
||||
defectRate: number; // 불량 발생률 (%)
|
||||
receptionCount: number; // 접수
|
||||
inProgressCount: number; // 진행중
|
||||
completedCount: number; // 완료
|
||||
}
|
||||
|
||||
// 검사 등록 폼 데이터
|
||||
// ===== 캘린더 스케줄 =====
|
||||
|
||||
export interface InspectionCalendarItem {
|
||||
id: string;
|
||||
startDate: string; // 시작일
|
||||
endDate: string; // 종료일
|
||||
inspector: string; // 검사자
|
||||
siteName: string; // 현장명
|
||||
status: InspectionStatus; // 상태
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
|
||||
// 등록 폼
|
||||
export interface InspectionFormData {
|
||||
lotNo: string;
|
||||
itemName: string;
|
||||
processName: string;
|
||||
quantity: number;
|
||||
inspector: string;
|
||||
remarks?: string;
|
||||
items: InspectionItem[];
|
||||
qualityDocNumber: string;
|
||||
siteName: string;
|
||||
client: string;
|
||||
manager: string;
|
||||
managerContact: string;
|
||||
constructionSite: ConstructionSiteInfo;
|
||||
materialDistributor: MaterialDistributorInfo;
|
||||
constructorInfo: ConstructorInfo;
|
||||
supervisor: SupervisorInfo;
|
||||
scheduleInfo: InspectionScheduleInfo;
|
||||
orderItems: OrderSettingItem[];
|
||||
}
|
||||
|
||||
// 검사 수정 폼 데이터
|
||||
// 수정 폼
|
||||
export interface InspectionEditFormData extends InspectionFormData {
|
||||
editReason: string; // 수정 사유 (필수)
|
||||
editReason: string;
|
||||
}
|
||||
|
||||
// 필터 옵션
|
||||
// ===== 필터 =====
|
||||
|
||||
export interface InspectionFilter {
|
||||
search: string;
|
||||
status: InspectionStatus | '전체';
|
||||
@@ -100,10 +160,83 @@ export interface InspectionFilter {
|
||||
};
|
||||
}
|
||||
|
||||
// 테이블 컬럼 타입
|
||||
// ===== 문서 관련 =====
|
||||
|
||||
// 결재라인
|
||||
export interface ApprovalLineItem {
|
||||
role: string; // 작성, 승인
|
||||
name: string; // 이름
|
||||
department: string; // 부서명
|
||||
}
|
||||
|
||||
// 제품검사요청서
|
||||
export interface InspectionRequestDocument {
|
||||
documentNumber: string;
|
||||
createdDate: string;
|
||||
approvalLine: ApprovalLineItem[];
|
||||
// 기본정보
|
||||
client: string;
|
||||
companyName: string;
|
||||
manager: string;
|
||||
orderNumber: string;
|
||||
managerContact: string;
|
||||
siteName: string;
|
||||
deliveryDate: string;
|
||||
siteAddress: string;
|
||||
totalLocations: string;
|
||||
receptionDate: string;
|
||||
visitRequestDate: string;
|
||||
// 입력사항
|
||||
constructionSite: ConstructionSiteInfo;
|
||||
materialDistributor: MaterialDistributorInfo;
|
||||
constructorInfo: ConstructorInfo;
|
||||
supervisor: SupervisorInfo;
|
||||
// 검사대상 사전 고지 정보
|
||||
priorNoticeItems: OrderSettingItem[];
|
||||
}
|
||||
|
||||
// 제품검사성적서 검사항목
|
||||
export interface ReportInspectionItem {
|
||||
no: number;
|
||||
category: string; // 검사항목 대분류 (겉모양, 모터, 재질 등)
|
||||
subCategory?: string; // 세부항목 (가공상태, 재봉상태 등)
|
||||
criteria: string; // 검사기준
|
||||
method: string; // 검사방법
|
||||
frequency: string; // 검사주기
|
||||
measuredValue?: string; // 측정값
|
||||
judgment?: '적합' | '부적합'; // 판정
|
||||
subCategorySpan?: number; // 세부항목 셀 rowSpan (예: 비차열/차열성 3행 병합)
|
||||
measuredValueSpan?: number; // 측정값 셀 rowSpan (크로스그룹 병합)
|
||||
methodSpan?: number; // 검사방법 셀 rowSpan (크로스그룹 병합)
|
||||
freqSpan?: number; // 검사주기 셀 rowSpan (크로스그룹 병합)
|
||||
judgmentSpan?: number; // 판정 셀 rowSpan (크로스그룹 병합, 예: 항목6+7+8+9)
|
||||
hideJudgment?: boolean; // 판정 표시 안함 (빈 셀 렌더)
|
||||
}
|
||||
|
||||
// 제품검사성적서
|
||||
export interface InspectionReportDocument {
|
||||
documentNumber: string;
|
||||
createdDate: string;
|
||||
approvalLine: ApprovalLineItem[];
|
||||
productName: string; // 제품명
|
||||
productLotNo: string; // 제품 LOT NO
|
||||
productCode: string; // 제품코드
|
||||
lotSize: string; // 로트크기
|
||||
client: string; // 수주처
|
||||
inspectionDate: string; // 검사일자
|
||||
siteName: string; // 현장명
|
||||
inspector: string; // 검사자
|
||||
productImage?: string; // 제품 사진 URL
|
||||
inspectionItems: ReportInspectionItem[];
|
||||
specialNotes: string; // 특이사항
|
||||
finalJudgment: JudgmentResult; // 최종 판정
|
||||
}
|
||||
|
||||
// ===== 테이블 =====
|
||||
|
||||
export interface InspectionTableColumn {
|
||||
key: keyof Inspection | 'no' | 'checkbox' | 'actions';
|
||||
key: string;
|
||||
label: string;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user