feat(WEB): 공정관리 드래그 순서변경, 수주서/출고증 리디자인, 체크리스트 관리 추가
- 공정관리: 드래그&드롭 순서 변경 기능 추가 (reorderProcesses API) - 수주서(SalesOrderDocument): 기획서 D1.8 기준 리디자인, 출고증과 동일 자재 섹션 구조 - 출고증(ShipmentOrderDocument): 레이아웃 개선 - 체크리스트 관리 페이지 신규 추가 (master-data/checklist-management) - QMS 품질감사: 타입 및 목데이터 수정 - menuRefresh 유틸 정리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Edit, GripVertical, Plus, Package } from 'lucide-react';
|
||||
import { ArrowLeft, Edit, GripVertical, Plus, Package, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -19,7 +19,9 @@ import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { getProcessSteps, reorderProcessSteps } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { getProcessSteps, reorderProcessSteps, deleteProcess } from './actions';
|
||||
import type { Process, ProcessStep } from '@/types/process';
|
||||
|
||||
interface ProcessDetailProps {
|
||||
@@ -35,6 +37,10 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
const [steps, setSteps] = useState<ProcessStep[]>([]);
|
||||
const [isStepsLoading, setIsStepsLoading] = useState(true);
|
||||
|
||||
// 삭제 다이얼로그 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 드래그 상태
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
@@ -75,6 +81,24 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
router.push(`/ko/master-data/process-management/${process.id}/steps/${stepId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteProcess(process.id);
|
||||
if (result.success) {
|
||||
toast.success('공정이 삭제되었습니다.');
|
||||
router.push('/ko/master-data/process-management');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 드래그&드롭 (HTML5 네이티브) =====
|
||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
||||
setDragIndex(index);
|
||||
@@ -343,12 +367,27 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<span className="hidden md:inline">목록으로</span>
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit} size="sm" className="md:size-default">
|
||||
<Edit className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수정</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)} size="sm" className="md:size-default">
|
||||
<Trash2 className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">삭제</span>
|
||||
</Button>
|
||||
<Button onClick={handleEdit} size="sm" className="md:size-default">
|
||||
<Edit className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수정</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description="이 공정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
* - 삭제 다이얼로그
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Wrench, Plus } from 'lucide-react';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Wrench, Plus, GripVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -26,7 +27,7 @@ import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import type { Process } from '@/types/process';
|
||||
import { getProcessList, deleteProcess, deleteProcesses, toggleProcessActive, getProcessStats } from './actions';
|
||||
import { getProcessList, deleteProcess, deleteProcesses, toggleProcessActive, getProcessStats, reorderProcesses } from './actions';
|
||||
|
||||
interface ProcessListClientProps {
|
||||
initialData?: Process[];
|
||||
@@ -50,6 +51,13 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
// 검색어 상태
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 드래그&드롭 순서 변경 상태
|
||||
const [isOrderChanged, setIsOrderChanged] = useState(false);
|
||||
const dragProcessIdRef = useRef<string | null>(null);
|
||||
const dragNodeRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const allProcessesRef = useRef(allProcesses);
|
||||
allProcessesRef.current = allProcesses;
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -177,6 +185,78 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
}
|
||||
}, [allProcesses]);
|
||||
|
||||
// ===== 드래그&드롭 순서 변경 =====
|
||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, processId: string) => {
|
||||
dragProcessIdRef.current = processId;
|
||||
dragNodeRef.current = e.currentTarget;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
requestAnimationFrame(() => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.style.opacity = '0.4';
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLTableRowElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('border-t-2', 'border-t-primary');
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLTableRowElement>) => {
|
||||
const related = e.relatedTarget as Node;
|
||||
if (!e.currentTarget.contains(related)) {
|
||||
e.currentTarget.classList.remove('border-t-2', 'border-t-primary');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.style.opacity = '1';
|
||||
}
|
||||
dragProcessIdRef.current = null;
|
||||
dragNodeRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleProcessDrop = useCallback((e: React.DragEvent<HTMLTableRowElement>, dropProcessId: string) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('border-t-2', 'border-t-primary');
|
||||
const dragId = dragProcessIdRef.current;
|
||||
if (!dragId || dragId === dropProcessId) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
setAllProcesses((prev) => {
|
||||
const updated = [...prev];
|
||||
const dragIdx = updated.findIndex(p => p.id === dragId);
|
||||
const dropIdx = updated.findIndex(p => p.id === dropProcessId);
|
||||
if (dragIdx === -1 || dropIdx === -1) return prev;
|
||||
const [moved] = updated.splice(dragIdx, 1);
|
||||
updated.splice(dropIdx, 0, moved);
|
||||
return updated;
|
||||
});
|
||||
setIsOrderChanged(true);
|
||||
handleDragEnd();
|
||||
}, [handleDragEnd]);
|
||||
|
||||
const handleSaveOrder = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const orderData = allProcessesRef.current.map((p, idx) => ({ id: p.id, order: idx + 1 }));
|
||||
const result = await reorderProcesses(orderData);
|
||||
if (result.success) {
|
||||
toast.success('순서가 저장되었습니다.');
|
||||
setIsOrderChanged(false);
|
||||
} else {
|
||||
toast.error(result.error || '순서 저장에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('순서 저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Process> = useMemo(
|
||||
() => ({
|
||||
@@ -230,8 +310,11 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
// 테이블 컬럼 (showCheckbox: false + renderCustomTableHeader로 수동 관리)
|
||||
showCheckbox: false,
|
||||
columns: [
|
||||
{ key: 'drag', label: '', className: 'w-[40px]' },
|
||||
{ key: 'checkbox', label: '', className: 'w-[50px]' },
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'processCode', label: '공정번호', className: 'w-[120px]' },
|
||||
{ key: 'processName', label: '공정명', className: 'min-w-[200px]' },
|
||||
@@ -240,6 +323,25 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
],
|
||||
|
||||
// 커스텀 테이블 헤더 (드래그 → 전체선택 체크박스 → 번호 → 데이터 순)
|
||||
renderCustomTableHeader: ({ displayData, selectedItems, onToggleSelectAll }) => (
|
||||
<>
|
||||
<TableHead className="w-[40px]" />
|
||||
<TableHead className="w-[50px] text-center">
|
||||
<Checkbox
|
||||
checked={displayData.length > 0 && selectedItems.size === displayData.length}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||
<TableHead className="w-[120px]">공정번호</TableHead>
|
||||
<TableHead className="min-w-[200px]">공정명</TableHead>
|
||||
<TableHead className="w-[120px]">담당부서</TableHead>
|
||||
<TableHead className="w-[80px] text-center">품목</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
</>
|
||||
),
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
@@ -292,6 +394,13 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
);
|
||||
},
|
||||
|
||||
// 순서 변경 저장 버튼
|
||||
headerActions: () => (
|
||||
<Button variant="outline" onClick={handleSaveOrder} disabled={!isOrderChanged}>
|
||||
순서 변경 저장
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링)
|
||||
createButton: {
|
||||
label: '공정 등록',
|
||||
@@ -315,9 +424,21 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
return (
|
||||
<TableRow
|
||||
key={process.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, process.id)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleProcessDrop(e, process.id)}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleRowClick(process)}
|
||||
>
|
||||
<TableCell
|
||||
className="text-center cursor-grab active:cursor-grabbing"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
@@ -388,7 +509,7 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
);
|
||||
},
|
||||
}),
|
||||
[handleCreate, handleRowClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery]
|
||||
[handleCreate, handleRowClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery, isOrderChanged, handleSaveOrder, handleDragStart, handleDragOver, handleDragLeave, handleDragEnd, handleProcessDrop]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -321,6 +321,27 @@ export async function toggleProcessActive(id: string): Promise<{ success: boolea
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 순서 변경
|
||||
*/
|
||||
export async function reorderProcesses(
|
||||
processes: { id: string; order: number }[]
|
||||
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/processes/reorder`,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
items: processes.map((p) => ({
|
||||
id: parseInt(p.id, 10),
|
||||
sort_order: p.order,
|
||||
})),
|
||||
},
|
||||
errorMessage: '공정 순서 변경에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 옵션 목록 (드롭다운용)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user