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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user