feat: [shipment] 출하관리 UI 개선 - cancelled 상태 추가, 삭제 버튼 제거, 제품코드 표시, 상태 버튼 워딩 수정

- 출하 cancelled 상태 추가 및 UI 반영
- 출하 상세에서 삭제 버튼/기능 제거
- 출하 상세 제품그룹에 제품코드 표시
- 상태 변경 버튼 워딩 수정
This commit is contained in:
2026-03-18 14:57:35 +09:00
parent 4650121416
commit 3ec6fdd71b
4 changed files with 13 additions and 77 deletions

View File

@@ -12,7 +12,6 @@ import {
ClipboardList,
ArrowRight,
Loader2,
Trash2,
ChevronDown,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -31,7 +30,6 @@ import {
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
DialogDescription,
DialogFooter,
@@ -56,7 +54,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { shipmentConfig } from './shipmentConfig';
import { getShipmentById, deleteShipment, updateShipmentStatus } from './actions';
import { getShipmentById, updateShipmentStatus } from './actions';
import {
SHIPMENT_STATUS_LABELS,
SHIPMENT_STATUS_STYLES,
@@ -93,9 +91,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
const router = useRouter();
const [previewDocument, setPreviewDocument] = useState<'shipping' | 'delivery' | null>(null);
const [orderDetail, setOrderDetail] = useState<OrderDocumentDetail | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 상태 변경 관련 상태
const [showStatusDialog, setShowStatusDialog] = useState(false);
const [targetStatus, setTargetStatus] = useState<ShipmentStatus | null>(null);
@@ -165,25 +160,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
router.push(`/ko/outbound/shipments/${id}?mode=edit`);
}, [id, router]);
const handleDelete = useCallback(async () => {
setIsDeleting(true);
try {
const result = await deleteShipment(id);
if (result.success) {
router.push('/ko/outbound/shipments');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentDetail] handleDelete error:', err);
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
}
}, [id, router]);
const handleOpenStatusDialog = useCallback((status: ShipmentStatus) => {
setTargetStatus(status);
setStatusFormData({
@@ -259,7 +235,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
);
const canEdit = detail ? (detail.status === 'scheduled' || detail.status === 'ready') : false;
const canDelete = detail ? (detail.status === 'scheduled' || detail.status === 'ready') : false;
// 제품 부품 테이블 렌더링
const renderPartsTable = (parts: ProductPart[]) => (
@@ -309,16 +284,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
<ClipboardList className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
{canDelete && (
<Button
variant="destructive"
size="sm"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{STATUS_TRANSITIONS[detail.status] && detail.canShip && (
<Button
variant="default"
@@ -327,7 +292,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
onClick={() => handleOpenStatusDialog(STATUS_TRANSITIONS[detail.status]!)}
>
<ArrowRight className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">{SHIPMENT_STATUS_LABELS[STATUS_TRANSITIONS[detail.status]!]} </span>
<span className="hidden md:inline">{SHIPMENT_STATUS_LABELS[STATUS_TRANSITIONS[detail.status]!]} </span>
</Button>
)}
{STATUS_TRANSITIONS[detail.status] && !detail.canShip && (
@@ -343,7 +308,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
)}
</>
);
}, [detail, canDelete, handleOpenStatusDialog]);
}, [detail, handleOpenStatusDialog]);
// 컨텐츠 렌더링
const renderViewContent = useCallback((_data: Record<string, unknown>) => {
@@ -568,22 +533,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
)}
</DocumentViewer>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="출고 정보 삭제"
description={
<>
{detail?.shipmentNo}() ?
<br />
.
</>
}
loading={isDeleting}
/>
{/* 상태 변경 다이얼로그 */}
<Dialog open={showStatusDialog} onOpenChange={setShowStatusDialog}>
<DialogContent className="sm:max-w-md">

View File

@@ -125,6 +125,7 @@ interface ShipmentItemApiData {
unit?: string;
lot_no?: string;
stock_lot_id?: number;
product_name?: string;
remarks?: string;
}
@@ -191,20 +192,20 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
// items를 floor_unit 기준으로 productGroups 자동 그룹핑
const rawItems = data.items || [];
const items = rawItems.map(transformApiToProduct);
const groupMap = new Map<string, { productName: string; specification: string; parts: { product: ShipmentProduct; unit: string }[] }>();
const groupMap = new Map<string, { productName: string; productCode: string; parts: { product: ShipmentProduct; unit: string }[] }>();
for (let i = 0; i < items.length; i++) {
const item = items[i];
const raw = rawItems[i];
const key = item.floorUnit || `item-${item.id}`;
if (!groupMap.has(key)) {
groupMap.set(key, { productName: key, specification: '', parts: [] });
groupMap.set(key, { productName: key, productCode: raw.product_name || '', parts: [] });
}
groupMap.get(key)!.parts.push({ product: item, unit: raw.unit || '' });
}
const productGroups = Array.from(groupMap.entries()).map(([key, g]) => ({
id: key,
productName: g.productName,
specification: g.parts[0]?.product.specification || '',
specification: g.productCode,
partCount: g.parts.length,
parts: g.parts.map((p, i) => ({
id: p.product.id,
@@ -465,17 +466,6 @@ export async function updateShipmentStatus(
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 삭제 =====
export async function deleteShipment(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/shipments/${id}`),
method: 'DELETE',
errorMessage: '출고 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, error: result.error };
}
// ===== 물류사 옵션 조회 =====
export async function getLogisticsOptions(): Promise<{ success: boolean; data: LogisticsOption[]; error?: string; __authError?: boolean }> {
const result = await executeServerAction<LogisticsOption[]>({

View File

@@ -12,7 +12,6 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
*
* 특이사항:
* - view 모드만 지원 (수정은 별도 /edit 페이지로 이동)
* - 삭제 기능 있음 (scheduled, ready 상태에서만)
* - 문서 미리보기: 출고증, 거래명세서, 납품확인서
*/
export const shipmentConfig: DetailConfig = {
@@ -24,15 +23,10 @@ export const shipmentConfig: DetailConfig = {
gridColumns: 2,
actions: {
showBack: true,
showDelete: true, // 상태에 따라 동적으로 처리
showDelete: false,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
deleteConfirmMessage: {
title: '출고 정보 삭제',
description: '이 출고 정보를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
},
},
};

View File

@@ -5,9 +5,10 @@
// 출고 상태
export type ShipmentStatus =
| 'scheduled' // 출고예정
| 'ready' // 출고대기 (출고대기)
| 'ready' // 출고대기
| 'shipping' // 배송중
| 'completed'; // 배송완료 (출고완료)
| 'completed' // 배송완료 (출고완료)
| 'cancelled'; // 취소
// 상태 라벨
export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
@@ -15,6 +16,7 @@ export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
ready: '출고대기',
shipping: '배송중',
completed: '출고완료',
cancelled: '취소',
};
// 상태 스타일
@@ -23,6 +25,7 @@ export const SHIPMENT_STATUS_STYLES: Record<ShipmentStatus, string> = {
ready: 'bg-yellow-100 text-yellow-800',
shipping: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
// 출고 우선순위