feat(WEB): 수주 삭제 기능 추가 및 출하목록 데이터 수정

- 수주 상세 페이지에 삭제 기능 추가
  - 수주등록/취소 상태에서 삭제 버튼 표시
  - 삭제 확인 다이얼로그 구현
  - 삭제 후 목록 페이지로 이동

- 출하목록 데이터 누락 수정
  - 발주처/현장명 order_info 참조하도록 수정
  - 배송방식 라벨 API 응답값 사용

- 공통코드 API 함수 추가
  - getDeliveryMethodCodes, getDeliveryMethodOptions
  - getCodeLabel 유틸 함수
This commit is contained in:
2026-01-23 16:29:55 +09:00
parent 662a0cc4ac
commit bdf2bf8beb
5 changed files with 145 additions and 6 deletions

View File

@@ -35,6 +35,7 @@ import {
ChevronDown,
ChevronRight,
ChevronsUpDown,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
@@ -64,6 +65,7 @@ import {
updateOrderStatus,
revertProductionOrder,
revertOrderConfirmation,
deleteOrder,
type Order,
type OrderStatus,
} from "@/components/orders";
@@ -115,6 +117,8 @@ export default function OrderDetailPage() {
const [isReverting, setIsReverting] = useState(false);
const [isRevertConfirmDialogOpen, setIsRevertConfirmDialogOpen] = useState(false);
const [isRevertingConfirm, setIsRevertingConfirm] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 취소 폼 상태
const [cancelReason, setCancelReason] = useState("");
@@ -296,6 +300,31 @@ export default function OrderDetailPage() {
}
};
// 수주 삭제
const handleDelete = () => {
setIsDeleteDialogOpen(true);
};
const handleDeleteSubmit = async () => {
if (order) {
setIsDeleting(true);
try {
const result = await deleteOrder(order.id);
if (result.success) {
toast.success("수주가 삭제되었습니다.");
router.push("/sales/order-management-sales");
} else {
toast.error(result.error || "수주 삭제에 실패했습니다.");
}
} catch (error) {
console.error("Error deleting order:", error);
toast.error("수주 삭제 중 오류가 발생했습니다.");
} finally {
setIsDeleting(false);
}
}
};
// 문서 모달 열기
const openDocumentModal = (type: OrderDocumentType) => {
setDocumentType(type);
@@ -727,6 +756,8 @@ export default function OrderDetailPage() {
order.status !== "shipped" &&
order.status !== "cancelled" &&
order.status !== "production_ordered";
// 삭제 버튼은 수주등록 또는 취소 상태에서 표시
const showDeleteButton = order.status === "order_registered" || order.status === "cancelled";
return (
<div className="flex items-center gap-2 flex-wrap">
@@ -772,9 +803,15 @@ export default function OrderDetailPage() {
</Button>
)}
{showDeleteButton && (
<Button variant="outline" onClick={handleDelete} className="border-red-200 text-red-600 hover:border-red-300 hover:bg-red-50">
<Trash2 className="h-4 w-4 mr-2" />
</Button>
)}
</div>
);
}, [order, handleEdit, handleConfirmOrder, handleProductionOrder, handleViewProductionOrder, handleRevertProduction, handleRevertConfirmation, handleCancel]);
}, [order, handleEdit, handleConfirmOrder, handleProductionOrder, handleViewProductionOrder, handleRevertProduction, handleRevertConfirmation, handleCancel, handleDelete]);
return (
<>
@@ -1116,6 +1153,80 @@ export default function OrderDetailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 수주 삭제 다이얼로그 */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-600" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 수주 정보 박스 */}
<div className="border rounded-lg p-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{order.lotNumber}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>{order.client}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>{order.siteName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium text-green-600">
{formatAmount(order.totalAmount || 0)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground"> </span>
{getOrderStatusBadge(order.status)}
</div>
</div>
{/* 경고 메시지 */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm space-y-1">
<p className="font-medium mb-2 text-red-700"> </p>
<ul className="space-y-1 text-red-600">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
{/* 확인 안내 */}
<div className="bg-gray-100 border border-gray-200 rounded-lg p-4 text-sm">
<p className="text-gray-700 font-medium">
?
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
</Button>
<Button
onClick={handleDeleteSubmit}
className="bg-red-600 hover:bg-red-700"
disabled={isDeleting}
>
<Trash2 className="h-4 w-4 mr-1" />
{isDeleting ? "삭제 중..." : "삭제 확정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</>

View File

@@ -41,7 +41,6 @@ import { getShipments, getShipmentStats, getShipmentStatsByStatus } from './acti
import {
SHIPMENT_STATUS_LABELS,
SHIPMENT_STATUS_STYLES,
DELIVERY_METHOD_LABELS,
} from './types';
import type { ShipmentItem, ShipmentStatus, ShipmentStats, ShipmentStatusStats } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -287,7 +286,7 @@ export function ShipmentList() {
<X className="w-4 h-4 mx-auto text-red-600" />
)}
</TableCell>
<TableCell className="text-center">{DELIVERY_METHOD_LABELS[item.deliveryMethod]}</TableCell>
<TableCell className="text-center">{item.deliveryMethodLabel}</TableCell>
<TableCell>{item.customerName}</TableCell>
<TableCell className="max-w-[120px] truncate">{item.siteName}</TableCell>
<TableCell className="text-center">{item.manager || '-'}</TableCell>
@@ -331,7 +330,7 @@ export function ShipmentList() {
<InfoField label="로트번호" value={item.lotNo} />
<InfoField label="발주처" value={item.customerName} />
<InfoField label="출고예정일" value={item.scheduledDate} />
<InfoField label="배송방식" value={DELIVERY_METHOD_LABELS[item.deliveryMethod]} />
<InfoField label="배송방식" value={item.deliveryMethodLabel} />
<InfoField label="출하가능" value={item.canShip ? '가능' : '불가'} />
</div>
}

View File

@@ -144,8 +144,10 @@ function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
status: data.status,
priority: data.priority,
deliveryMethod: data.delivery_method,
customerName: data.customer_name || '',
siteName: data.site_name || '',
deliveryMethodLabel: data.delivery_method_label || data.delivery_method,
// 발주처/배송 정보: 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 || '',
manager: data.loading_manager,
canShip: data.can_ship,
depositConfirmed: data.deposit_confirmed,

View File

@@ -58,6 +58,7 @@ export interface ShipmentItem {
status: ShipmentStatus; // 상태
priority: ShipmentPriority; // 우선순위
deliveryMethod: DeliveryMethod; // 배송방식
deliveryMethodLabel: string; // 배송방식 라벨 (API에서 조회)
customerName: string; // 발주처
siteName: string; // 현장명
manager?: string; // 담당

View File

@@ -118,4 +118,30 @@ export async function getItemTypeCodes() {
*/
export async function getItemTypeOptions() {
return getCommonCodeOptions('item_type');
}
/**
* 배송방식 코드 조회
*/
export async function getDeliveryMethodCodes() {
return getCommonCodes('delivery_method');
}
/**
* 배송방식 옵션 조회
*/
export async function getDeliveryMethodOptions() {
return getCommonCodeOptions('delivery_method');
}
/**
* 코드값으로 라벨 조회 (code → name 매핑)
*/
export async function getCodeLabel(group: string, code: string): Promise<string> {
const result = await getCommonCodes(group);
if (result.success && result.data) {
const found = result.data.find((item) => item.code === code);
return found?.name || code;
}
return code;
}