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:
유병철
2026-02-09 17:52:43 +09:00
parent ce36101929
commit 3ea6a57a10
26 changed files with 3398 additions and 829 deletions

View File

@@ -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 (