fix: API 응답 래퍼 패턴 수정 및 기능 개선

- construction actions.ts 파일들 API 응답 래퍼 패턴 수정
  - handover-report, order-management, site-management, structure-review
  - apiClient 반환값 { success, data } 구조에 맞게 수정
- ShipmentManagement 기능 개선
- WorkerScreen 컴포넌트 수정
- .gitignore에 package-lock.json, tsconfig.tsbuildinfo 추가
This commit is contained in:
2026-01-20 17:03:13 +09:00
parent 09b2c256fb
commit d12e2e0b4c
9 changed files with 729 additions and 189 deletions

View File

@@ -3,12 +3,12 @@
/**
* 출하 상세 페이지
* API 연동 완료 (2025-12-26)
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Truck,
FileText,
Receipt,
ClipboardList,
@@ -16,7 +16,12 @@ import {
Printer,
X,
Loader2,
AlertCircle,
Trash2,
ArrowRight,
ChevronDown,
} from 'lucide-react';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -33,11 +38,31 @@ import {
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
DialogDescription,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { shipmentConfig } from './shipmentConfig';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import { toast } from 'sonner';
import { PageLayout } from '@/components/organisms/PageLayout';
import { getShipmentById, deleteShipment, updateShipmentStatus } from './actions';
import {
SHIPMENT_STATUS_LABELS,
@@ -57,9 +82,31 @@ interface ShipmentDetailProps {
id: string;
}
// 상태 전이 맵: 현재 상태 → 다음 가능한 상태
const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
scheduled: 'ready',
ready: 'shipping',
shipping: 'completed',
completed: null, // 최종 상태
};
export function ShipmentDetail({ id }: ShipmentDetailProps) {
const router = useRouter();
const [previewDocument, setPreviewDocument] = useState<'shipping' | 'transaction' | 'delivery' | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 상태 변경 관련 상태
const [showStatusDialog, setShowStatusDialog] = useState(false);
const [targetStatus, setTargetStatus] = useState<ShipmentStatus | null>(null);
const [isChangingStatus, setIsChangingStatus] = useState(false);
const [statusFormData, setStatusFormData] = useState({
loadingTime: '',
vehicleNo: '',
driverName: '',
driverContact: '',
confirmedArrival: '',
});
// API 데이터 상태
const [detail, setDetail] = useState<ShipmentDetailType | null>(null);
@@ -93,19 +140,33 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
loadData();
}, [loadData]);
// 삭제 핸들러 (IntegratedDetailTemplate용)
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 목록으로 이동
const handleGoBack = useCallback(() => {
router.push('/ko/outbound/shipments');
}, [router]);
// 수정 페이지로 이동
const handleEdit = useCallback(() => {
router.push(`/ko/outbound/shipments/${id}/edit`);
}, [id, router]);
// 삭제 처리
const handleDelete = useCallback(async () => {
setIsDeleting(true);
try {
const result = await deleteShipment(id);
if (result.success) {
toast.success('출하 정보가 삭제되었습니다.');
router.push('/ko/outbound/shipments');
return { success: true };
} else {
alert(result.error || '삭제에 실패했습니다.');
}
return { success: false, error: result.error || '삭제에 실패했습니다.' };
} catch (err) {
if (isNextRedirectError(err)) throw err;
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
console.error('[ShipmentDetail] handleDelete error:', err);
alert('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
}
}, [id, router]);
@@ -117,21 +178,60 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
printArea({ title: `${docName} 인쇄` });
}, [previewDocument]);
// 수정/삭제 가능 여부 (scheduled, ready 상태에서만)
const canEdit = detail?.status === 'scheduled' || detail?.status === 'ready';
const canDelete = detail?.status === 'scheduled' || detail?.status === 'ready';
// 상태 변경 다이얼로그 열기
const handleOpenStatusDialog = useCallback((status: ShipmentStatus) => {
setTargetStatus(status);
setStatusFormData({
loadingTime: '',
vehicleNo: '',
driverName: '',
driverContact: '',
confirmedArrival: '',
});
setShowStatusDialog(true);
}, []);
// 동적 config (상태에 따른 삭제 버튼 표시 여부)
const dynamicConfig = useMemo(() => {
return {
...shipmentConfig,
actions: {
...shipmentConfig.actions,
showDelete: canDelete,
showEdit: canEdit,
},
};
}, [canDelete, canEdit]);
// 상태 변경 처리
const handleStatusChange = useCallback(async () => {
if (!targetStatus) return;
setIsChangingStatus(true);
try {
const additionalData: Record<string, string> = {};
// 상태별 추가 데이터 설정
if (targetStatus === 'ready' && statusFormData.loadingTime) {
additionalData.loadingTime = statusFormData.loadingTime;
}
if (targetStatus === 'shipping') {
if (statusFormData.vehicleNo) additionalData.vehicleNo = statusFormData.vehicleNo;
if (statusFormData.driverName) additionalData.driverName = statusFormData.driverName;
if (statusFormData.driverContact) additionalData.driverContact = statusFormData.driverContact;
}
if (targetStatus === 'completed' && statusFormData.confirmedArrival) {
additionalData.confirmedArrival = statusFormData.confirmedArrival;
}
const result = await updateShipmentStatus(
id,
targetStatus,
Object.keys(additionalData).length > 0 ? additionalData : undefined
);
if (result.success && result.data) {
setDetail(result.data);
setShowStatusDialog(false);
} else {
alert(result.error || '상태 변경에 실패했습니다.');
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentDetail] handleStatusChange error:', err);
alert('상태 변경 중 오류가 발생했습니다.');
} finally {
setIsChangingStatus(false);
}
}, [id, targetStatus, statusFormData]);
// 정보 영역 렌더링
const renderInfoField = (label: string, value: React.ReactNode, className?: string) => (
@@ -141,44 +241,104 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</div>
);
// 커스텀 헤더 액션 (문서 미리보기 버튼들)
const customHeaderActions = useMemo(() => {
// 로딩 상태 표시
if (isLoading) {
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('shipping')}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('transaction')}
>
<Receipt className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('delivery')}
>
<ClipboardList className="w-4 h-4 mr-1" />
</Button>
</>
<PageLayout>
<ContentLoadingSpinner text="출하 정보를 불러오는 중..." />
</PageLayout>
);
}, []);
// 폼 내용 렌더링
const renderFormContent = () => {
if (!detail) return null;
}
// 에러 상태 표시
if (error || !detail) {
return (
<PageLayout>
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<AlertCircle className="w-12 h-12 text-red-500" />
<p className="text-lg text-muted-foreground">{error || '데이터를 찾을 수 없습니다.'}</p>
<Button onClick={loadData}> </Button>
</div>
</PageLayout>
);
}
// 수정/삭제 가능 여부 (scheduled, ready 상태에서만)
const canEdit = detail.status === 'scheduled' || detail.status === 'ready';
const canDelete = detail.status === 'scheduled' || detail.status === 'ready';
return (
<PageLayout>
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Truck className="w-6 h-6" />
<h1 className="text-xl font-semibold"> </h1>
</div>
<div className="flex items-center gap-2">
{/* 문서 미리보기 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('shipping')}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('transaction')}
>
<Receipt className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPreviewDocument('delivery')}
>
<ClipboardList className="w-4 h-4 mr-1" />
</Button>
<div className="w-px h-6 bg-border mx-2" />
<Button variant="outline" onClick={handleGoBack}>
</Button>
{canEdit && (
<Button onClick={handleEdit}>
</Button>
)}
{canDelete && (
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
)}
{/* 상태 변경 버튼 */}
{STATUS_TRANSITIONS[detail.status] && (
<>
<div className="w-px h-6 bg-border mx-2" />
<Button
variant="default"
className="bg-blue-600 hover:bg-blue-700"
onClick={() => handleOpenStatusDialog(STATUS_TRANSITIONS[detail.status]!)}
>
<ArrowRight className="w-4 h-4 mr-1" />
{SHIPMENT_STATUS_LABELS[STATUS_TRANSITIONS[detail.status]!]}
</Button>
</>
)}
</div>
</div>
{/* 메인 콘텐츠 */}
<div className="space-y-6">
{/* 출고 정보 */}
<Card>
<CardHeader>
@@ -352,38 +512,10 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</CardContent>
</Card>
)}
</div>
);
};
// 에러 상태 표시
if (!isLoading && (error || !detail)) {
return (
<ServerErrorPage
title="출하 정보를 불러올 수 없습니다"
message={error || '출하 정보를 찾을 수 없습니다.'}
showBackButton={true}
showHomeButton={true}
/>
);
}
</div>
return (
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode="view"
initialData={{}}
itemId={id}
isLoading={isLoading}
onDelete={canDelete ? handleDelete : undefined}
headerActions={customHeaderActions}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */}
{detail && (
{/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */}
<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">
{/* 접근성을 위한 숨겨진 타이틀 */}
@@ -434,7 +566,155 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</div>
</DialogContent>
</Dialog>
)}
</>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{detail.shipmentNo}() ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 상태 변경 다이얼로그 */}
<Dialog open={showStatusDialog} onOpenChange={setShowStatusDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{detail.status && targetStatus && (
<span className="flex items-center gap-2 mt-2">
<Badge className={SHIPMENT_STATUS_STYLES[detail.status]}>
{SHIPMENT_STATUS_LABELS[detail.status]}
</Badge>
<ArrowRight className="w-4 h-4" />
<Badge className={SHIPMENT_STATUS_STYLES[targetStatus]}>
{SHIPMENT_STATUS_LABELS[targetStatus]}
</Badge>
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 출하대기로 변경 시 - 상차 시간 */}
{targetStatus === 'ready' && (
<div className="space-y-2">
<Label htmlFor="loadingTime"> ()</Label>
<Input
id="loadingTime"
type="datetime-local"
value={statusFormData.loadingTime}
onChange={(e) =>
setStatusFormData((prev) => ({ ...prev, loadingTime: e.target.value }))
}
/>
</div>
)}
{/* 배송중으로 변경 시 - 차량/운전자 정보 */}
{targetStatus === 'shipping' && (
<>
<div className="space-y-2">
<Label htmlFor="vehicleNo"> ()</Label>
<Input
id="vehicleNo"
placeholder="예: 12가 3456"
value={statusFormData.vehicleNo}
onChange={(e) =>
setStatusFormData((prev) => ({ ...prev, vehicleNo: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="driverName"> ()</Label>
<Input
id="driverName"
placeholder="운전자 이름"
value={statusFormData.driverName}
onChange={(e) =>
setStatusFormData((prev) => ({ ...prev, driverName: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="driverContact"> ()</Label>
<Input
id="driverContact"
placeholder="010-0000-0000"
value={statusFormData.driverContact}
onChange={(e) =>
setStatusFormData((prev) => ({ ...prev, driverContact: e.target.value }))
}
/>
</div>
</>
)}
{/* 배송완료로 변경 시 - 도착 확인 시간 */}
{targetStatus === 'completed' && (
<div className="space-y-2">
<Label htmlFor="confirmedArrival"> ()</Label>
<Input
id="confirmedArrival"
type="datetime-local"
value={statusFormData.confirmedArrival}
onChange={(e) =>
setStatusFormData((prev) => ({ ...prev, confirmedArrival: e.target.value }))
}
/>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowStatusDialog(false)}
disabled={isChangingStatus}
>
</Button>
<Button
onClick={handleStatusChange}
disabled={isChangingStatus}
className="bg-blue-600 hover:bg-blue-700"
>
{isChangingStatus ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'상태 변경'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</PageLayout>
);
}

View File

@@ -37,6 +37,19 @@ import type {
} from './types';
// ===== API 데이터 타입 =====
// 수주 연동 정보 (Order → Shipment)
interface OrderInfoApiData {
order_id?: number;
order_no?: string;
order_status?: string;
client_id?: number;
customer_name?: string;
site_name?: string;
delivery_address?: string;
contact?: string;
}
interface ShipmentApiData {
id: number;
shipment_no: string;
@@ -52,6 +65,8 @@ interface ShipmentApiData {
delivery_address?: string;
receiver?: string;
receiver_contact?: string;
// 수주 연동 정보 (order_info accessor)
order_info?: OrderInfoApiData;
can_ship: boolean;
deposit_confirmed: boolean;
invoice_issued: boolean;
@@ -170,11 +185,12 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
loadingManager: data.loading_manager,
loadingCompleted: data.loading_completed_at,
registrant: data.creator?.name,
customerName: data.customer_name || '',
siteName: data.site_name || '',
deliveryAddress: data.delivery_address || '',
// 발주처/배송 정보: order_info 우선 참조 (Order가 Single Source of Truth)
customerName: data.order_info?.customer_name || data.customer_name || '',
siteName: data.order_info?.site_name || data.site_name || '',
deliveryAddress: data.order_info?.delivery_address || data.delivery_address || '',
receiver: data.receiver,
receiverContact: data.receiver_contact,
receiverContact: data.order_info?.contact || data.receiver_contact,
products: (data.items || []).map(transformApiToProduct),
logisticsCompany: data.logistics_company,
vehicleTonnage: data.vehicle_tonnage,