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:
유병철
2026-02-02 16:46:52 +09:00
parent 1a69324d59
commit ca6247286a
28 changed files with 4195 additions and 1776 deletions

View File

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

View File

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

View File

@@ -107,6 +107,9 @@ export const MOCK_SHIPMENT_DETAIL: ShipmentDetail = {
driverName: '최운전',
driverContact: '010-5555-6666',
remarks: '하차 시 주의 요망',
vehicleDispatches: [],
productGroups: [],
otherParts: [],
};
// 품질관리서 목록

View File

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

View 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>
);
}

View File

@@ -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}

View File

@@ -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
*/

View File

@@ -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 });
}
/**
* 월간 뷰에서 주 단위로 날짜 분할
*/

View File

@@ -73,5 +73,6 @@ export function generateShipmentData(
loadingTime: '',
loadingManager: '',
remarks: randomRemark(),
vehicleDispatches: [],
};
}

View File

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

View File

@@ -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} />;

View File

@@ -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: [],

View File

@@ -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. 스크린 테이블 */}

View File

@@ -13,5 +13,5 @@ interface ShippingSlipProps {
}
export function ShippingSlip({ data }: ShippingSlipProps) {
return <ShipmentOrderDocument title="출 고 증" data={data} />;
return <ShipmentOrderDocument title="출 고 증" data={data} showDispatchInfo />;
}

View File

@@ -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}
/>
</>
);
}
}

View File

@@ -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} />;

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export { InspectionRequestDocument } from './InspectionRequestDocument';
export { InspectionRequestModal } from './InspectionRequestModal';
export { InspectionReportDocument } from './InspectionReportDocument';
export { InspectionReportModal } from './InspectionReportModal';

View File

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

View File

@@ -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: '수정',

View File

@@ -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 === '완료' ? '합격' : '합격',
});

View File

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