feat(WEB): 수주 Bulk Delete + Revert Force 프론트엔드 연동

- deleteOrders: for 루프 단건 삭제 → DELETE /api/v1/orders/bulk 단일 호출로 전환
- deleteOrders: force 옵션 추가 (개발환경 물리 삭제용)
- revertProductionOrder: force/reason 파라미터 추가
- 수주 상세: 되돌리기 다이얼로그에 사유 Textarea 추가
- 수주 상세: 개발환경 전용 "완전삭제 (DEV)" 버튼 추가
- 수주 목록: 개발환경 전용 selectionActions "완전삭제 (DEV)" 버튼 추가
This commit is contained in:
2026-02-21 08:56:44 +09:00
parent bb4acac3c1
commit 7d7d5356ff
3 changed files with 158 additions and 35 deletions

View File

@@ -149,6 +149,9 @@ export default function OrderDetailPage() {
const [isConfirming, setIsConfirming] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [isReverting, setIsReverting] = useState(false);
const [revertReason, setRevertReason] = useState("");
const [isForceReverting, setIsForceReverting] = useState(false);
const isDev = process.env.NODE_ENV !== 'production';
const [isRevertConfirmDialogOpen, setIsRevertConfirmDialogOpen] = useState(false);
const [isRevertingConfirm, setIsRevertingConfirm] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -322,6 +325,7 @@ export default function OrderDetailPage() {
// 생산지시 되돌리기
const handleRevertProduction = () => {
setRevertReason("");
setIsRevertDialogOpen(true);
};
@@ -329,7 +333,9 @@ export default function OrderDetailPage() {
if (order) {
setIsReverting(true);
try {
const result = await revertProductionOrder(order.id);
const result = await revertProductionOrder(order.id, {
reason: revertReason || undefined,
});
if (result.success && result.data) {
setOrder(result.data.order);
const { deletedCounts } = result.data;
@@ -349,6 +355,28 @@ export default function OrderDetailPage() {
}
};
// 생산지시 완전삭제 (force=true, 개발환경 전용)
const handleForceRevertProduction = async () => {
if (order) {
setIsForceReverting(true);
try {
const result = await revertProductionOrder(order.id, { force: true });
if (result.success && result.data) {
setOrder(result.data.order);
toast.success("생산지시가 완전삭제되었습니다. (물리 삭제)");
setIsRevertDialogOpen(false);
} else {
toast.error(result.error || "완전삭제에 실패했습니다.");
}
} catch (error) {
console.error("Error force reverting production order:", error);
toast.error("완전삭제 중 오류가 발생했습니다.");
} finally {
setIsForceReverting(false);
}
}
};
// 수주확정 되돌리기
const handleRevertConfirmation = () => {
setIsRevertConfirmDialogOpen(true);
@@ -1046,6 +1074,17 @@ export default function OrderDetailPage() {
</ul>
</div>
{/* 되돌리기 사유 */}
<div className="space-y-2">
<label className="text-sm font-medium"> </label>
<Textarea
placeholder="되돌리기 사유를 입력해주세요 (선택)"
value={revertReason}
onChange={(e) => setRevertReason(e.target.value)}
rows={3}
/>
</div>
{/* 확인 안내 */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm">
<p className="text-red-700 font-medium">
@@ -1054,21 +1093,32 @@ export default function OrderDetailPage() {
</div>
</div>
<DialogFooter>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={() => setIsRevertDialogOpen(false)}
>
</Button>
<Button
onClick={handleRevertProductionSubmit}
className="bg-amber-600 hover:bg-amber-700"
disabled={isReverting}
>
<RotateCcw className="h-4 w-4 mr-1" />
{isReverting ? "처리 중..." : "되돌리기 확정"}
</Button>
<div className="flex gap-2">
{isDev && (
<Button
onClick={handleForceRevertProduction}
variant="destructive"
disabled={isForceReverting || isReverting}
>
{isForceReverting ? "처리 중..." : "완전삭제 (DEV)"}
</Button>
)}
<Button
onClick={handleRevertProductionSubmit}
className="bg-amber-600 hover:bg-amber-700"
disabled={isReverting || isForceReverting}
>
<RotateCcw className="h-4 w-4 mr-1" />
{isReverting ? "처리 중..." : "되돌리기 확정"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -162,6 +162,9 @@ function OrderListContent() {
// 삭제 확인 다이얼로그 state (다중 선택 삭제 지원)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetIds, setDeleteTargetIds] = useState<string[]>([]);
const [isForceDeleteDialogOpen, setIsForceDeleteDialogOpen] = useState(false);
const [isForceDeleting, setIsForceDeleting] = useState(false);
const isDev = process.env.NODE_ENV !== 'production';
// 모바일 인피니티 스크롤 state
const [mobileDisplayCount, setMobileDisplayCount] = useState(20);
@@ -433,6 +436,33 @@ function OrderListContent() {
}
};
// 완전삭제 (force=true, 개발환경 전용)
const handleForceDelete = async () => {
const selectedIds = Array.from(selectedItems);
if (selectedIds.length === 0) return;
setIsForceDeleting(true);
try {
const result = await deleteOrders(selectedIds, { force: true });
if (result.success) {
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
setSelectedItems(new Set());
toast.success(`${result.deletedCount ?? selectedIds.length}개의 수주가 완전삭제되었습니다.`);
const statsResult = await getOrderStats();
if (statsResult.success && statsResult.data) {
setApiStats(statsResult.data);
}
} else {
toast.error(result.error || "완전삭제에 실패했습니다.");
}
} catch (error) {
console.error("Error force deleting orders:", error);
toast.error("완전삭제 중 오류가 발생했습니다.");
} finally {
setIsForceDeleting(false);
setIsForceDeleteDialogOpen(false);
}
};
// 체크박스 선택
const toggleSelection = (id: string) => {
const newSelection = new Set(selectedItems);
@@ -716,6 +746,18 @@ function OrderListContent() {
});
},
selectionActions: isDev ? () => (
<Button
variant="outline"
size="sm"
className="border-red-300 text-red-600 hover:bg-red-50"
onClick={() => setIsForceDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4 mr-1" />
(DEV)
</Button>
) : undefined,
headerActions: () => (
<div className="flex items-center gap-2 ml-auto">
<Button variant="outline" onClick={handleSendNotification} disabled={isPending}>
@@ -776,6 +818,26 @@ function OrderListContent() {
}
loading={isDeleting}
/>
{/* 완전삭제 확인 다이얼로그 (개발환경 전용) */}
{isDev && (
<DeleteConfirmDialog
open={isForceDeleteDialogOpen}
onOpenChange={setIsForceDeleteDialogOpen}
onConfirm={handleForceDelete}
title="⚠️ 완전삭제 (개발 전용)"
description={
<>
<strong>{Array.from(selectedItems).length}</strong> <strong className="text-red-600"> </strong>.
<br />
<span className="text-red-500 text-sm font-medium">
DB에서 .
</span>
</>
}
loading={isForceDeleting}
/>
)}
</>
),
};

View File

@@ -959,37 +959,40 @@ export async function getOrderStats(): Promise<{
}
/**
* 수주 일괄 삭제
* 수주 일괄 삭제 (Bulk API)
*/
export async function deleteOrders(ids: string[]): Promise<{
export async function deleteOrders(
ids: string[],
options?: { force?: boolean }
): Promise<{
success: boolean;
deletedCount?: number;
skippedCount?: number;
skippedIds?: number[];
error?: string;
__authError?: boolean;
}> {
try {
// 순차적으로 삭제 (API에 bulk delete가 없으므로)
let deletedCount = 0;
const errors: string[] = [];
for (const id of ids) {
const result = await deleteOrder(id);
if (result.success) {
deletedCount++;
} else {
errors.push(result.error || `ID ${id} 삭제 실패`);
}
}
if (deletedCount === 0 && errors.length > 0) {
return { success: false, error: errors[0] };
}
return { success: true, deletedCount };
} catch (error) {
console.error('[deleteOrders] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
interface BulkDeleteResponse {
deleted_count: number;
skipped_count: number;
skipped_ids: number[];
}
const body: Record<string, unknown> = { ids: ids.map(Number) };
if (options?.force) body.force = true;
const result = await executeServerAction<BulkDeleteResponse>({
url: buildApiUrl('/api/v1/orders/bulk'),
method: 'DELETE',
body,
errorMessage: '수주 일괄 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
deletedCount: result.data.deleted_count,
skippedCount: result.data.skipped_count,
skippedIds: result.data.skipped_ids,
};
}
/**
@@ -1071,7 +1074,10 @@ export async function createProductionOrder(
/**
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
*/
export async function revertProductionOrder(orderId: string): Promise<{
export async function revertProductionOrder(
orderId: string,
options?: { force?: boolean; reason?: string }
): Promise<{
success: boolean;
data?: {
order: Order;
@@ -1084,11 +1090,16 @@ export async function revertProductionOrder(orderId: string): Promise<{
interface RevertResponse {
order: ApiOrder;
deleted_counts: { work_results: number; work_order_items: number; work_orders: number };
cancelled_counts?: { work_orders: number; work_order_items: number };
previous_status: string;
}
const body: Record<string, unknown> = {};
if (options?.force !== undefined) body.force = options.force;
if (options?.reason) body.reason = options.reason;
const result = await executeServerAction<RevertResponse>({
url: buildApiUrl(`/api/v1/orders/${orderId}/revert-production`),
method: 'POST',
body: Object.keys(body).length > 0 ? body : undefined,
errorMessage: '생산지시 되돌리기에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };