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

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