From 3ea6a57a10a27ebc99dbe21aceaa4ad2dfbaff38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Feb 2026 17:52:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EA=B3=B5=EC=A0=95=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=EB=B3=80=EA=B2=BD,=20=EC=88=98=EC=A3=BC=EC=84=9C/=EC=B6=9C?= =?UTF-8?q?=EA=B3=A0=EC=A6=9D=20=EB=A6=AC=EB=94=94=EC=9E=90=EC=9D=B8,=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공정관리: 드래그&드롭 순서 변경 기능 추가 (reorderProcesses API) - 수주서(SalesOrderDocument): 기획서 D1.8 기준 리디자인, 출고증과 동일 자재 섹션 구조 - 출고증(ShipmentOrderDocument): 레이아웃 개선 - 체크리스트 관리 페이지 신규 추가 (master-data/checklist-management) - QMS 품질감사: 타입 및 목데이터 수정 - menuRefresh 유틸 정리 Co-Authored-By: Claude Opus 4.6 --- claudedocs/dev/[REF] all-pages-test-urls.md | 2 + .../[id]/items/[itemId]/page.tsx | 22 + .../checklist-management/[id]/page.tsx | 21 + .../master-data/checklist-management/page.tsx | 30 + .../qms/components/AuditProgressBar.tsx | 4 +- .../quality/qms/components/DayTabs.tsx | 8 +- .../(protected)/quality/qms/mockData.ts | 2 +- .../[locale]/(protected)/quality/qms/page.tsx | 8 +- .../[locale]/(protected)/quality/qms/types.ts | 2 +- .../checklist-management/ChecklistDetail.tsx | 315 +++++++ .../ChecklistDetailClient.tsx | 122 +++ .../checklist-management/ChecklistForm.tsx | 172 ++++ .../ChecklistListClient.tsx | 519 +++++++++++ .../checklist-management/ItemDetail.tsx | 223 +++++ .../checklist-management/ItemDetailClient.tsx | 110 +++ .../checklist-management/ItemForm.tsx | 350 ++++++++ .../checklist-management/actions.ts | 342 +++++++ src/components/checklist-management/index.ts | 7 + .../orders/documents/SalesOrderDocument.tsx | 844 +++++++++--------- .../documents/ShipmentOrderDocument.tsx | 832 +++++++++-------- .../documents/ShippingSlip.tsx | 2 +- .../process-management/ProcessDetail.tsx | 51 +- .../process-management/ProcessListClient.tsx | 133 ++- src/components/process-management/actions.ts | 21 + src/lib/utils/menuRefresh.ts | 12 +- src/types/checklist.ts | 73 ++ 26 files changed, 3398 insertions(+), 829 deletions(-) create mode 100644 src/app/[locale]/(protected)/master-data/checklist-management/[id]/items/[itemId]/page.tsx create mode 100644 src/app/[locale]/(protected)/master-data/checklist-management/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/master-data/checklist-management/page.tsx create mode 100644 src/components/checklist-management/ChecklistDetail.tsx create mode 100644 src/components/checklist-management/ChecklistDetailClient.tsx create mode 100644 src/components/checklist-management/ChecklistForm.tsx create mode 100644 src/components/checklist-management/ChecklistListClient.tsx create mode 100644 src/components/checklist-management/ItemDetail.tsx create mode 100644 src/components/checklist-management/ItemDetailClient.tsx create mode 100644 src/components/checklist-management/ItemForm.tsx create mode 100644 src/components/checklist-management/actions.ts create mode 100644 src/components/checklist-management/index.ts create mode 100644 src/types/checklist.ts diff --git a/claudedocs/dev/[REF] all-pages-test-urls.md b/claudedocs/dev/[REF] all-pages-test-urls.md index 885745cc..abd27a48 100644 --- a/claudedocs/dev/[REF] all-pages-test-urls.md +++ b/claudedocs/dev/[REF] all-pages-test-urls.md @@ -90,12 +90,14 @@ http://localhost:3000/ko/sales/quote-management/test/1/edit # 🧪 견적 수 | **공정관리** | `/ko/master-data/process-management` | ✅ | | **단가표관리** | `/ko/master-data/pricing-table-management` | 🆕 NEW | | **└ 단가배포관리** | `/ko/master-data/price-distribution` | 🆕 NEW | +| **점검표관리** | `/ko/master-data/checklist-management` | 🆕 NEW | ``` http://localhost:3000/ko/master-data/item-master-data-management http://localhost:3000/ko/master-data/process-management # 공정관리 http://localhost:3000/ko/master-data/pricing-table-management # 🆕 단가표관리 http://localhost:3000/ko/master-data/price-distribution # 🆕 단가배포관리 +http://localhost:3000/ko/master-data/checklist-management # 🆕 점검표관리 ``` --- diff --git a/src/app/[locale]/(protected)/master-data/checklist-management/[id]/items/[itemId]/page.tsx b/src/app/[locale]/(protected)/master-data/checklist-management/[id]/items/[itemId]/page.tsx new file mode 100644 index 00000000..779c22b9 --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/checklist-management/[id]/items/[itemId]/page.tsx @@ -0,0 +1,22 @@ +'use client'; + +/** + * 점검표 항목 상세/수정/등록 페이지 + * + * - /[id]/items/[itemId] → 상세 보기 + * - /[id]/items/[itemId]?mode=edit → 수정 + * - /[id]/items/new → 등록 + */ + +import { use } from 'react'; +import { ItemDetailClient } from '@/components/checklist-management'; + +export default function ItemDetailPage({ + params, +}: { + params: Promise<{ id: string; itemId: string }>; +}) { + const { id, itemId } = use(params); + + return ; +} diff --git a/src/app/[locale]/(protected)/master-data/checklist-management/[id]/page.tsx b/src/app/[locale]/(protected)/master-data/checklist-management/[id]/page.tsx new file mode 100644 index 00000000..7bea8c26 --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/checklist-management/[id]/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +/** + * 점검표 상세/수정 페이지 + * + * - /[id] → 상세 보기 (view 모드) + * - /[id]?mode=edit → 수정 (edit 모드) + */ + +import { use } from 'react'; +import { ChecklistDetailClient } from '@/components/checklist-management'; + +export default function ChecklistDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + + return ; +} diff --git a/src/app/[locale]/(protected)/master-data/checklist-management/page.tsx b/src/app/[locale]/(protected)/master-data/checklist-management/page.tsx new file mode 100644 index 00000000..5e169ee5 --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/checklist-management/page.tsx @@ -0,0 +1,30 @@ +'use client'; + +/** + * 점검표 목록/등록 페이지 + */ + +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import ChecklistListClient from '@/components/checklist-management/ChecklistListClient'; +import { ChecklistDetailClient } from '@/components/checklist-management'; +import { ListPageSkeleton } from '@/components/ui/skeleton'; + +function ChecklistManagementContent() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') { + return ; + } + + return ; +} + +export default function ChecklistManagementPage() { + return ( + }> + + + ); +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/AuditProgressBar.tsx b/src/app/[locale]/(protected)/quality/qms/components/AuditProgressBar.tsx index 04d78aca..66b937fc 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/AuditProgressBar.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/AuditProgressBar.tsx @@ -48,7 +48,7 @@ export function AuditProgressBar({ activeDay === 1 ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200' )}>
- 1일차: 기준/매뉴얼 + 기준/매뉴얼 심사
- 2일차: 로트추적 + 로트 추적 심사 - 1일차: 기준/매뉴얼 + 기준/매뉴얼 심사 1일차 - 2일차: 로트추적 + 로트 추적 심사 2일차 - 1일차: 기준/매뉴얼 + 기준/매뉴얼 심사 1일차
@@ -128,7 +128,7 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }: {/* 2일차 진행률 */}
- 2일차: 로트추적 + 로트 추적 심사 2일차
diff --git a/src/app/[locale]/(protected)/quality/qms/mockData.ts b/src/app/[locale]/(protected)/quality/qms/mockData.ts index cf6a0e07..29fe4632 100644 --- a/src/app/[locale]/(protected)/quality/qms/mockData.ts +++ b/src/app/[locale]/(protected)/quality/qms/mockData.ts @@ -291,7 +291,7 @@ export const DEFAULT_DOCUMENTS: Document[] = [ { id: 'def-8', type: 'quality', title: '품질관리서', count: 0, items: [] }, ]; -// ===== 1일차: 기준/매뉴얼 심사 Mock 데이터 ===== +// ===== 기준/매뉴얼 심사 심사 Mock 데이터 ===== // 1일차 점검표 카테고리 export const MOCK_DAY1_CATEGORIES: ChecklistCategory[] = [ diff --git a/src/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index c92289eb..4828ea23 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -255,7 +255,7 @@ export default function QualityInspectionPage() { : 'bg-white border-gray-200 text-gray-700 hover:border-blue-300' }`} > - 1일차: 기준/매뉴얼 심사 + 기준/매뉴얼 심사 심사
)} @@ -282,7 +282,7 @@ export default function QualityInspectionPage() { /> {activeDay === 1 ? ( - // ===== 1일차: 기준/매뉴얼 심사 ===== + // ===== 기준/매뉴얼 심사 심사 =====
{/* 좌측: 점검표 항목 */}
) : ( - // ===== 2일차: 로트추적 심사 ===== + // ===== 로트 추적 심사 심사 =====
state.sidebarCollapsed); + const { canUpdate } = usePermission(); + + const [items, setItems] = useState([]); + const [isItemsLoading, setIsItemsLoading] = useState(true); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // 드래그 상태 + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const dragNodeRef = useRef(null); + + // 항목 목록 로드 + useEffect(() => { + const loadItems = async () => { + setIsItemsLoading(true); + const result = await getChecklistItems(checklist.id); + if (result.success && result.data) { + setItems(result.data); + } + setIsItemsLoading(false); + }; + loadItems(); + }, [checklist.id]); + + // 네비게이션 + const handleEdit = () => { + router.push(`/ko/master-data/checklist-management/${checklist.id}?mode=edit`); + }; + + const handleList = () => { + router.push('/ko/master-data/checklist-management'); + }; + + const handleAddItem = () => { + router.push(`/ko/master-data/checklist-management/${checklist.id}/items/new`); + }; + + const handleItemClick = (itemId: string) => { + router.push(`/ko/master-data/checklist-management/${checklist.id}/items/${itemId}`); + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + const result = await deleteChecklist(checklist.id); + if (result.success) { + toast.success('점검표가 삭제되었습니다.'); + router.push('/ko/master-data/checklist-management'); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } catch { + toast.error('삭제 중 오류가 발생했습니다.'); + } finally { + setIsDeleting(false); + setDeleteDialogOpen(false); + } + }; + + // ===== 드래그&드롭 ===== + const handleDragStart = useCallback( + (e: React.DragEvent, index: number) => { + setDragIndex(index); + dragNodeRef.current = e.currentTarget; + e.dataTransfer.effectAllowed = 'move'; + requestAnimationFrame(() => { + if (dragNodeRef.current) dragNodeRef.current.style.opacity = '0.4'; + }); + }, + [] + ); + + const handleDragOver = useCallback( + (e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverIndex(index); + }, + [] + ); + + const handleDragEnd = useCallback(() => { + if (dragNodeRef.current) dragNodeRef.current.style.opacity = '1'; + setDragIndex(null); + setDragOverIndex(null); + dragNodeRef.current = null; + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (dragIndex === null || dragIndex === dropIndex) return; + + setItems((prev) => { + const updated = [...prev]; + const [moved] = updated.splice(dragIndex, 1); + updated.splice(dropIndex, 0, moved); + const reordered = updated.map((item, i) => ({ ...item, order: i + 1 })); + + reorderChecklistItems( + checklist.id, + reordered.map((it) => ({ id: it.id, order: it.order })) + ); + + return reordered; + }); + + handleDragEnd(); + }, + [dragIndex, handleDragEnd, checklist.id] + ); + + return ( + + + +
+ {/* 기본 정보 */} + + + 기본 정보 + + +
+
+
점검표 번호
+
{checklist.checklistCode}
+
+
+
점검표
+
{checklist.checklistName}
+
+
+
상태
+ + {checklist.status} + +
+
+
+
+ + {/* 항목 테이블 */} + + +
+ + 항목 목록 + {!isItemsLoading && ( + + 총 {items.length}건 + + )} + + +
+
+ + {isItemsLoading ? ( +
로딩 중...
+ ) : items.length === 0 ? ( +
+ 등록된 항목이 없습니다. [항목 등록] 버튼으로 추가해주세요. +
+ ) : ( +
+ + + + + + + + + + + + + {items.map((item, index) => ( + handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragEnd={handleDragEnd} + onDrop={(e) => handleDrop(e, index)} + onClick={() => handleItemClick(item.id)} + className={`border-b cursor-pointer transition-colors hover:bg-muted/50 ${ + dragOverIndex === index && dragIndex !== index + ? 'border-t-2 border-t-primary' + : '' + }`} + > + + + + + + + + + ))} + +
+ + No. + + 항목 번호 + + 순서 + + 항목명 + + 문서 + + 사용 +
e.stopPropagation()} + > + + + {index + 1} + {item.itemCode} + {item.order} + {item.itemName} + {item.documentCount} + + + {item.status} + +
+
+ )} +
+
+
+ + {/* 하단 액션 버튼 (sticky) */} +
+ + {canUpdate && ( +
+ + +
+ )} +
+ + +
+ ); +} diff --git a/src/components/checklist-management/ChecklistDetailClient.tsx b/src/components/checklist-management/ChecklistDetailClient.tsx new file mode 100644 index 00000000..47d20f3d --- /dev/null +++ b/src/components/checklist-management/ChecklistDetailClient.tsx @@ -0,0 +1,122 @@ +'use client'; + +/** + * 점검표 상세 클라이언트 컴포넌트 + * + * 라우팅: + * - /[id] → 상세 보기 (view) + * - /[id]?mode=edit → 수정 (edit) + * - ?mode=new → 등록 (create) + */ + +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { ChecklistDetail } from './ChecklistDetail'; +import { ChecklistForm } from './ChecklistForm'; +import { getChecklistById } from './actions'; +import type { Checklist } from '@/types/checklist'; +import { DetailPageSkeleton } from '@/components/ui/skeleton'; +import { ErrorCard } from '@/components/ui/error-card'; +import { toast } from 'sonner'; + +type DetailMode = 'view' | 'edit' | 'create'; + +interface ChecklistDetailClientProps { + checklistId?: string; +} + +const BASE_PATH = '/ko/master-data/checklist-management'; + +export function ChecklistDetailClient({ checklistId }: ChecklistDetailClientProps) { + const searchParams = useSearchParams(); + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + const isNewMode = !checklistId || checklistId === 'new'; + + const [mode, setMode] = useState(() => { + if (isNewMode) return 'create'; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + const [checklistData, setChecklistData] = useState(null); + const [isLoading, setIsLoading] = useState(!isNewMode); + const [error, setError] = useState(null); + + useEffect(() => { + if (isNewMode) { + setIsLoading(false); + return; + } + + const loadData = async () => { + setIsLoading(true); + setError(null); + try { + const result = await getChecklistById(checklistId!); + if (result.success && result.data) { + setChecklistData(result.data); + } else { + setError(result.error || '점검표 정보를 찾을 수 없습니다.'); + toast.error('점검표를 불러오는데 실패했습니다.'); + } + } catch { + setError('점검표 정보를 불러오는 중 오류가 발생했습니다.'); + toast.error('점검표를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [checklistId, isNewMode]); + + useEffect(() => { + if (!isNewMode && modeFromQuery === 'edit') { + setMode('edit'); + } else if (!isNewMode && !modeFromQuery) { + setMode('view'); + } + }, [modeFromQuery, isNewMode]); + + if (isLoading) { + return ; + } + + if (error && !isNewMode) { + return ( + + ); + } + + if (mode === 'create') { + return ; + } + + if (mode === 'edit' && checklistData) { + return ; + } + + if (mode === 'view' && checklistData) { + return ; + } + + return ( + + ); +} diff --git a/src/components/checklist-management/ChecklistForm.tsx b/src/components/checklist-management/ChecklistForm.tsx new file mode 100644 index 00000000..ed91209e --- /dev/null +++ b/src/components/checklist-management/ChecklistForm.tsx @@ -0,0 +1,172 @@ +'use client'; + +/** + * 점검표 등록/수정 폼 컴포넌트 + * + * 기획서 스크린샷 2 기준: + * - 기본 정보: 점검표 번호(자동), 점검표명, 상태 + */ + +import { useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import { toast } from 'sonner'; +import type { Checklist } from '@/types/checklist'; +import { createChecklist, updateChecklist } from './actions'; +import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types'; + +const createConfig: DetailConfig = { + title: '점검표', + description: '새로운 점검표를 등록합니다', + basePath: '', + fields: [], + actions: { + showBack: true, + showEdit: false, + showDelete: false, + showSave: true, + submitLabel: '등록', + }, +}; + +const editConfig: DetailConfig = { + ...createConfig, + description: '점검표 정보를 수정합니다', + actions: { + ...createConfig.actions, + submitLabel: '저장', + }, +}; + +interface ChecklistFormProps { + mode: 'create' | 'edit'; + initialData?: Checklist; +} + +export function ChecklistForm({ mode, initialData }: ChecklistFormProps) { + const router = useRouter(); + const isEdit = mode === 'edit'; + + const [checklistName, setChecklistName] = useState(initialData?.checklistName || ''); + const [status, setStatus] = useState(initialData?.status || '사용'); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => { + if (!checklistName.trim()) { + toast.error('점검표명을 입력해주세요.'); + return { success: false, error: '점검표명을 입력해주세요.' }; + } + + const formData = { + checklistName: checklistName.trim(), + status: status as '사용' | '미사용', + }; + + setIsLoading(true); + try { + if (isEdit && initialData?.id) { + const result = await updateChecklist(initialData.id, formData); + if (result.success) { + toast.success('점검표가 수정되었습니다.'); + router.push(`/ko/master-data/checklist-management/${initialData.id}`); + return { success: true }; + } else { + toast.error(result.error || '수정에 실패했습니다.'); + return { success: false, error: result.error }; + } + } else { + const result = await createChecklist(formData); + if (result.success && result.data) { + toast.success('점검표가 등록되었습니다.'); + router.push(`/ko/master-data/checklist-management/${result.data.id}`); + return { success: true }; + } else { + toast.error(result.error || '등록에 실패했습니다.'); + return { success: false, error: result.error }; + } + } + } catch { + toast.error('처리 중 오류가 발생했습니다.'); + return { success: false, error: '처리 중 오류가 발생했습니다.' }; + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + if (isEdit && initialData?.id) { + router.push(`/ko/master-data/checklist-management/${initialData.id}`); + } else { + router.push('/ko/master-data/checklist-management'); + } + }; + + const renderFormContent = useCallback( + () => ( +
+ + + 기본 정보 + + +
+
+ + +
+
+ + setChecklistName(e.target.value)} + placeholder="점검표명을 입력하세요" + /> +
+
+ + +
+
+
+
+
+ ), + [checklistName, status, initialData?.checklistCode] + ); + + const config = isEdit ? editConfig : createConfig; + + return ( + + ); +} diff --git a/src/components/checklist-management/ChecklistListClient.tsx b/src/components/checklist-management/ChecklistListClient.tsx new file mode 100644 index 00000000..5a8acb12 --- /dev/null +++ b/src/components/checklist-management/ChecklistListClient.tsx @@ -0,0 +1,519 @@ +'use client'; + +/** + * 점검표 목록 - UniversalListPage 기반 + * + * 공정 목록(ProcessListClient)과 동일한 패턴: + * - 클라이언트 사이드 필터링 (상태별) + * - 드래그&드롭 순서 변경 + * - 상태 토글 + */ + +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { ClipboardList, 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 { + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, + type ListParams, +} from '@/components/templates/UniversalListPage'; +import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; +import { toast } from 'sonner'; +import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; +import type { Checklist } from '@/types/checklist'; +import { + getChecklistList, + deleteChecklist, + deleteChecklists, + toggleChecklistStatus, + getChecklistStats, + reorderChecklists, +} from './actions'; + +export default function ChecklistListClient() { + const router = useRouter(); + + // ===== 상태 ===== + const [allChecklists, setAllChecklists] = useState([]); + const [stats, setStats] = useState({ total: 0, active: 0, inactive: 0 }); + const [isLoading, setIsLoading] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + + // 날짜 범위 상태 + const [startDate, setStartDate] = useState('2025-01-01'); + const [endDate, setEndDate] = useState('2025-12-31'); + + // 검색어 상태 + const [searchQuery, setSearchQuery] = useState(''); + + // 드래그&드롭 순서 변경 상태 + const [isOrderChanged, setIsOrderChanged] = useState(false); + const dragIdRef = useRef(null); + const dragNodeRef = useRef(null); + const allChecklistsRef = useRef(allChecklists); + allChecklistsRef.current = allChecklists; + + // ===== 데이터 로드 ===== + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const [listResult, statsResult] = await Promise.all([ + getChecklistList(), + getChecklistStats(), + ]); + + if (listResult.success && listResult.data) { + setAllChecklists(listResult.data.items); + } + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + } catch { + toast.error('데이터 로드에 실패했습니다.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + // ===== 핸들러 ===== + const handleRowClick = useCallback( + (checklist: Checklist) => { + router.push(`/ko/master-data/checklist-management/${checklist.id}`); + }, + [router] + ); + + const handleCreate = useCallback(() => { + router.push('/ko/master-data/checklist-management?mode=new'); + }, [router]); + + const handleDeleteConfirm = useCallback(async () => { + if (!deleteTargetId) return; + setIsLoading(true); + try { + const result = await deleteChecklist(deleteTargetId); + if (result.success) { + toast.success('점검표가 삭제되었습니다.'); + setAllChecklists((prev) => prev.filter((c) => c.id !== deleteTargetId)); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } catch { + toast.error('삭제 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + setDeleteDialogOpen(false); + setDeleteTargetId(null); + } + }, [deleteTargetId]); + + const handleBulkDelete = useCallback( + async (selectedIds: string[]) => { + if (selectedIds.length === 0) { + toast.warning('삭제할 항목을 선택해주세요.'); + return; + } + setIsLoading(true); + try { + const result = await deleteChecklists(selectedIds); + if (result.success) { + toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`); + await loadData(); + } else { + toast.error(result.error || '일괄 삭제에 실패했습니다.'); + } + } catch { + toast.error('일괄 삭제 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }, + [loadData] + ); + + const handleToggleStatus = useCallback( + async (checklistId: string) => { + setIsLoading(true); + try { + const result = await toggleChecklistStatus(checklistId); + if (result.success && result.data) { + toast.success('상태가 변경되었습니다.'); + setAllChecklists((prev) => + prev.map((c) => (c.id === checklistId ? result.data! : c)) + ); + } else { + toast.error(result.error || '상태 변경에 실패했습니다.'); + } + } catch { + toast.error('상태 변경 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }, + [] + ); + + // ===== 드래그&드롭 ===== + const handleDragStart = useCallback( + (e: React.DragEvent, id: string) => { + dragIdRef.current = id; + dragNodeRef.current = e.currentTarget; + e.dataTransfer.effectAllowed = 'move'; + requestAnimationFrame(() => { + if (dragNodeRef.current) dragNodeRef.current.style.opacity = '0.4'; + }); + }, + [] + ); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + e.currentTarget.classList.add('border-t-2', 'border-t-primary'); + }, + [] + ); + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + 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'; + dragIdRef.current = null; + dragNodeRef.current = null; + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent, dropId: string) => { + e.preventDefault(); + e.currentTarget.classList.remove('border-t-2', 'border-t-primary'); + const dragId = dragIdRef.current; + if (!dragId || dragId === dropId) { + handleDragEnd(); + return; + } + setAllChecklists((prev) => { + const updated = [...prev]; + const dragIdx = updated.findIndex((c) => c.id === dragId); + const dropIdx = updated.findIndex((c) => c.id === dropId); + 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 = allChecklistsRef.current.map((c, idx) => ({ + id: c.id, + order: idx + 1, + })); + const result = await reorderChecklists(orderData); + if (result.success) { + toast.success('순서가 저장되었습니다.'); + setIsOrderChanged(false); + } else { + toast.error(result.error || '순서 저장에 실패했습니다.'); + } + } catch { + toast.error('순서 저장 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }, []); + + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + title: '점검표 목록', + icon: ClipboardList, + basePath: '/master-data/checklist-management', + idField: 'id', + + actions: { + getList: async (params?: ListParams) => { + try { + const [listResult, statsResult] = await Promise.all([ + getChecklistList(), + getChecklistStats(), + ]); + if (listResult.success && listResult.data) { + setAllChecklists(listResult.data.items); + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + return { + success: true, + data: listResult.data.items, + totalCount: listResult.data.items.length, + totalPages: 1, + }; + } + return { success: false, error: '데이터 로드에 실패했습니다.' }; + } catch { + return { success: false, error: '서버 오류가 발생했습니다.' }; + } + }, + deleteItem: async (id: string) => { + const result = await deleteChecklist(id); + return { success: result.success, error: result.error }; + }, + deleteBulk: async (ids: string[]) => { + const result = await deleteChecklists(ids); + return { success: result.success, error: result.error }; + }, + }, + + // 테이블 컬럼 (showCheckbox: false → 수동 관리) + showCheckbox: false, + columns: [ + { key: 'drag', label: '', className: 'w-[40px]' }, + { key: 'checkbox', label: '', className: 'w-[50px]' }, + { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, + { key: 'checklistCode', label: '점검표 번호', className: 'w-[120px]' }, + { key: 'checklistName', label: '점검표', className: 'min-w-[200px]' }, + { key: 'items', label: '항목', className: 'w-[80px] text-center' }, + { key: 'documents', label: '문서', className: 'w-[80px] text-center' }, + { key: 'status', label: '상태', className: 'w-[80px] text-center' }, + ], + + // 커스텀 테이블 헤더 (드래그 → 전체선택 체크박스 → No. → 데이터 순) + renderCustomTableHeader: ({ displayData, selectedItems, onToggleSelectAll }) => ( + <> + + + 0 && selectedItems.size === displayData.length} + onCheckedChange={onToggleSelectAll} + /> + + No. + 점검표 번호 + 점검표 + 항목 + 문서 + 상태 + + ), + + clientSideFiltering: true, + itemsPerPage: 20, + + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + + dateRangeSelector: { + enabled: true, + showPresets: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + filterConfig: [ + { + key: 'status', + label: '상태', + type: 'single' as const, + options: [ + { value: '사용', label: '사용' }, + { value: '미사용', label: '미사용' }, + ], + allOptionLabel: '전체', + }, + ], + initialFilters: { status: '' }, + + customFilterFn: ( + items: Checklist[], + filterValues: Record + ) => { + const statusFilter = filterValues.status as string; + if (!statusFilter) return items; + return items.filter((item) => item.status === statusFilter); + }, + + searchFilter: (item, searchValue) => { + if (!searchValue || !searchValue.trim()) return true; + const search = searchValue.toLowerCase().trim(); + return ( + (item.checklistCode || '').toLowerCase().includes(search) || + (item.checklistName || '').toLowerCase().includes(search) + ); + }, + + headerActions: () => ( + + ), + + createButton: { + label: '점검표 등록', + onClick: handleCreate, + icon: Plus, + }, + + onBulkDelete: handleBulkDelete, + + renderTableRow: ( + checklist: Checklist, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleDragStart(e, checklist.id)} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDragEnd={handleDragEnd} + onDrop={(e) => handleDrop(e, checklist.id)} + className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`} + onClick={() => handleRowClick(checklist)} + > + e.stopPropagation()} + > + + + e.stopPropagation()}> + + + + {globalIndex} + + {checklist.checklistCode} + {checklist.checklistName} + {checklist.itemCount} + {checklist.documentCount} + e.stopPropagation()} + > + handleToggleStatus(checklist.id)} + > + {checklist.status} + + + + ), + + renderMobileCard: ( + checklist: Checklist, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(checklist)} + headerBadges={ + <> + + #{globalIndex} + + + {checklist.checklistCode} + + + } + title={checklist.checklistName} + statusBadge={ + { + e.stopPropagation(); + handleToggleStatus(checklist.id); + }} + > + {checklist.status} + + } + infoGrid={ +
+ + +
+ } + /> + ), + }), + [ + handleCreate, + handleRowClick, + handleToggleStatus, + handleBulkDelete, + startDate, + endDate, + searchQuery, + isOrderChanged, + handleSaveOrder, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDragEnd, + handleDrop, + ] + ); + + return ( + <> + + + + ); +} diff --git a/src/components/checklist-management/ItemDetail.tsx b/src/components/checklist-management/ItemDetail.tsx new file mode 100644 index 00000000..f3bfac73 --- /dev/null +++ b/src/components/checklist-management/ItemDetail.tsx @@ -0,0 +1,223 @@ +'use client'; + +/** + * 항목 상세 뷰 컴포넌트 + * + * 기획서 스크린샷 3 기준: + * - 기본 정보: 항목 번호, 항목명, 소개, 상태 + * - 문서 정보: 순서, 문서 번호, 문서, 개정, 시행일 + * - 하단: 삭제, 수정 버튼 + */ + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft, Edit, GripVertical, 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'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { PageHeader } from '@/components/organisms/PageHeader'; +import { useMenuStore } from '@/store/menuStore'; +import { usePermission } from '@/hooks/usePermission'; +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { deleteChecklistItem } from './actions'; +import type { ChecklistItem } from '@/types/checklist'; + +interface ItemDetailProps { + item: ChecklistItem; + checklistId: string; +} + +export function ItemDetail({ item, checklistId }: ItemDetailProps) { + const router = useRouter(); + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); + const { canUpdate, canDelete } = usePermission(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleEdit = () => { + router.push( + `/ko/master-data/checklist-management/${checklistId}/items/${item.id}?mode=edit` + ); + }; + + const handleBack = () => { + router.push(`/ko/master-data/checklist-management/${checklistId}`); + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + const result = await deleteChecklistItem(checklistId, item.id); + if (result.success) { + toast.success('항목이 삭제되었습니다.'); + router.push(`/ko/master-data/checklist-management/${checklistId}`); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } catch { + toast.error('삭제 중 오류가 발생했습니다.'); + } finally { + setIsDeleting(false); + setShowDeleteDialog(false); + } + }; + + const documents = item.documents || []; + + return ( + + + +
+ {/* 기본 정보 */} + + + 기본 정보 + + +
+
+
항목 번호
+
{item.itemCode}
+
+
+
항목명
+
{item.itemName}
+
+
+
소개
+
{item.description || '-'}
+
+
+
상태
+ + {item.status} + +
+
+
+
+ + {/* 문서 정보 */} + + + 문서 정보 + + + {documents.length === 0 ? ( +
+ 등록된 문서가 없습니다. +
+ ) : ( +
+ + + + + + + + + + + + {documents.map((doc) => ( + + + + + + + + + ))} + +
+ + 순서 + + 문서 번호 + + 문서 + + 개정 + + 시행일 +
+ + + {doc.order} + + {doc.documentCode} + + {doc.documentName} + {doc.revision}{doc.effectiveDate}
+
+ )} +
+
+
+ + {/* 하단 액션 버튼 (sticky) */} +
+ +
+ {canDelete && ( + + )} + {canUpdate && ( + + )} +
+
+ + + + + 항목 삭제 + + '{item.itemName}' 항목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + + {isDeleting ? '삭제 중...' : '삭제'} + + + + +
+ ); +} diff --git a/src/components/checklist-management/ItemDetailClient.tsx b/src/components/checklist-management/ItemDetailClient.tsx new file mode 100644 index 00000000..77ea1db5 --- /dev/null +++ b/src/components/checklist-management/ItemDetailClient.tsx @@ -0,0 +1,110 @@ +'use client'; + +/** + * 항목 상세 클라이언트 컴포넌트 + * + * 라우팅: + * - /[id]/items/[itemId] → 상세 보기 + * - /[id]/items/[itemId]?mode=edit → 수정 + * - /[id]/items/new → 등록 + */ + +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { ItemDetail } from './ItemDetail'; +import { ItemForm } from './ItemForm'; +import { getChecklistItemById } from './actions'; +import type { ChecklistItem } from '@/types/checklist'; +import { DetailPageSkeleton } from '@/components/ui/skeleton'; +import { ErrorCard } from '@/components/ui/error-card'; +import { toast } from 'sonner'; + +type DetailMode = 'view' | 'edit' | 'create'; + +interface ItemDetailClientProps { + checklistId: string; + itemId: string; +} + +export function ItemDetailClient({ checklistId, itemId }: ItemDetailClientProps) { + const searchParams = useSearchParams(); + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + const isNewMode = itemId === 'new'; + + const [mode] = useState(() => { + if (isNewMode) return 'create'; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + const [itemData, setItemData] = useState(null); + const [isLoading, setIsLoading] = useState(!isNewMode); + const [error, setError] = useState(null); + + useEffect(() => { + if (isNewMode) { + setIsLoading(false); + return; + } + + const loadData = async () => { + setIsLoading(true); + setError(null); + try { + const result = await getChecklistItemById(checklistId, itemId); + if (result.success && result.data) { + setItemData(result.data); + } else { + setError(result.error || '항목 정보를 찾을 수 없습니다.'); + toast.error('항목을 불러오는데 실패했습니다.'); + } + } catch { + setError('항목 정보를 불러오는 중 오류가 발생했습니다.'); + toast.error('항목을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [checklistId, itemId, isNewMode]); + + if (isLoading) { + return ; + } + + if (error && !isNewMode) { + return ( + + ); + } + + if (mode === 'create') { + return ; + } + + if (mode === 'edit' && itemData) { + return ; + } + + if (mode === 'view' && itemData) { + return ; + } + + return ( + + ); +} diff --git a/src/components/checklist-management/ItemForm.tsx b/src/components/checklist-management/ItemForm.tsx new file mode 100644 index 00000000..76009f4c --- /dev/null +++ b/src/components/checklist-management/ItemForm.tsx @@ -0,0 +1,350 @@ +'use client'; + +/** + * 항목 등록/수정 폼 컴포넌트 + * + * 기획서 스크린샷 3 기준: + * - 기본 정보: 항목 번호(자동), 항목명, 소개, 상태 + * - 문서 정보: 순서, 문서 번호, 문서, 개정, 시행일 + 행 추가/삭제 + */ + +import { useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { Plus, Trash2, GripVertical } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import { toast } from 'sonner'; +import type { ChecklistItem, ChecklistDocumentFormData } from '@/types/checklist'; +import { createChecklistItem, updateChecklistItem } from './actions'; +import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types'; + +const itemCreateConfig: DetailConfig = { + title: '항목', + description: '새로운 항목을 등록합니다', + basePath: '', + fields: [], + actions: { + showBack: true, + showEdit: false, + showDelete: false, + showSave: true, + submitLabel: '등록', + }, +}; + +const itemEditConfig: DetailConfig = { + ...itemCreateConfig, + description: '항목 정보를 수정합니다', + actions: { + ...itemCreateConfig.actions, + submitLabel: '저장', + }, +}; + +interface ItemFormProps { + mode: 'create' | 'edit'; + checklistId: string; + initialData?: ChecklistItem; +} + +export function ItemForm({ mode, checklistId, initialData }: ItemFormProps) { + const router = useRouter(); + const isEdit = mode === 'edit'; + + const [itemName, setItemName] = useState(initialData?.itemName || ''); + const [description, setDescription] = useState(initialData?.description || ''); + const [status, setStatus] = useState(initialData?.status || '사용'); + + // 문서 목록 상태 + const [documents, setDocuments] = useState(() => { + if (initialData?.documents && initialData.documents.length > 0) { + return initialData.documents.map((doc) => ({ + id: doc.id, + documentCode: doc.documentCode, + documentName: doc.documentName, + revision: doc.revision, + effectiveDate: doc.effectiveDate, + order: doc.order, + })); + } + return []; + }); + + const handleAddDocument = () => { + setDocuments((prev) => [ + ...prev, + { + documentCode: '', + documentName: '', + revision: '', + effectiveDate: '', + order: prev.length + 1, + }, + ]); + }; + + const handleRemoveDocument = (index: number) => { + setDocuments((prev) => { + const updated = prev.filter((_, i) => i !== index); + return updated.map((doc, i) => ({ ...doc, order: i + 1 })); + }); + }; + + const handleDocumentChange = ( + index: number, + field: keyof ChecklistDocumentFormData, + value: string | number + ) => { + setDocuments((prev) => + prev.map((doc, i) => (i === index ? { ...doc, [field]: value } : doc)) + ); + }; + + const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => { + if (!itemName.trim()) { + toast.error('항목명을 입력해주세요.'); + return { success: false, error: '항목명을 입력해주세요.' }; + } + + const formData = { + itemName: itemName.trim(), + description: description.trim(), + status: status as '사용' | '미사용', + documents, + }; + + try { + if (isEdit && initialData?.id) { + const result = await updateChecklistItem(checklistId, initialData.id, formData); + if (result.success) { + toast.success('항목이 수정되었습니다.'); + router.push( + `/ko/master-data/checklist-management/${checklistId}/items/${initialData.id}` + ); + return { success: true }; + } else { + toast.error(result.error || '수정에 실패했습니다.'); + return { success: false, error: result.error }; + } + } else { + const result = await createChecklistItem(checklistId, formData); + if (result.success && result.data) { + toast.success('항목이 등록되었습니다.'); + router.push(`/ko/master-data/checklist-management/${checklistId}`); + return { success: true }; + } else { + toast.error(result.error || '등록에 실패했습니다.'); + return { success: false, error: result.error }; + } + } + } catch { + toast.error('처리 중 오류가 발생했습니다.'); + return { success: false, error: '처리 중 오류가 발생했습니다.' }; + } + }; + + const handleCancel = () => { + if (isEdit && initialData?.id) { + router.push( + `/ko/master-data/checklist-management/${checklistId}/items/${initialData.id}` + ); + } else { + router.push(`/ko/master-data/checklist-management/${checklistId}`); + } + }; + + const renderFormContent = useCallback( + () => ( +
+ {/* 기본 정보 */} + + + 기본 정보 + + +
+
+ + +
+
+ + setItemName(e.target.value)} + placeholder="항목명을 입력하세요" + /> +
+
+ + setDescription(e.target.value)} + placeholder="소개를 입력하세요" + /> +
+
+ + +
+
+
+
+ + {/* 문서 정보 */} + + +
+ + 문서 정보 + + 총 {documents.length}건 + + + +
+
+ + {documents.length === 0 ? ( +
+ 등록된 문서가 없습니다. [문서 추가] 버튼으로 추가해주세요. +
+ ) : ( +
+ + + + + + + + + + + + + {documents.map((doc, index) => ( + + + + + + + + + + ))} + +
+ + 순서 + + 문서 번호 + + 문서 + + 개정 + + 시행일 + + 삭제 +
+ + + {doc.order} + + + handleDocumentChange(index, 'documentCode', e.target.value) + } + placeholder="문서 번호" + className="h-8" + /> + + + handleDocumentChange(index, 'documentName', e.target.value) + } + placeholder="문서명" + className="h-8" + /> + + + handleDocumentChange(index, 'revision', e.target.value) + } + placeholder="REV" + className="h-8" + /> + + + handleDocumentChange(index, 'effectiveDate', e.target.value) + } + className="h-8" + /> + + +
+
+ )} +
+
+
+ ), + [itemName, description, status, documents, initialData?.itemCode] + ); + + const config = isEdit ? itemEditConfig : itemCreateConfig; + + return ( + + ); +} diff --git a/src/components/checklist-management/actions.ts b/src/components/checklist-management/actions.ts new file mode 100644 index 00000000..ead690d9 --- /dev/null +++ b/src/components/checklist-management/actions.ts @@ -0,0 +1,342 @@ +'use server'; + +/** + * 점검표 관리 Server Actions + * + * 현재: Mock 데이터 기반 + * 추후: executeServerAction으로 백엔드 API 연동 전환 + */ + +import type { + Checklist, + ChecklistFormData, + ChecklistItem, + ChecklistItemFormData, + ChecklistDocument, +} from '@/types/checklist'; + +// ============================================================================ +// Mock 데이터 +// ============================================================================ + +const MOCK_DOCUMENTS: ChecklistDocument[] = [ + { + id: 'doc-1', + itemId: 'item-1', + documentCode: 'QM-std-1-1-1', + documentName: '문서명.pdf', + revision: 'REV12', + effectiveDate: '2026-01-05', + order: 1, + }, + { + id: 'doc-2', + itemId: 'item-1', + documentCode: 'QM-std-1-1-1', + documentName: '문서명.pdf', + revision: 'REV12', + effectiveDate: '2026-01-06', + order: 2, + }, +]; + +const MOCK_ITEMS: ChecklistItem[] = [ + { + id: 'item-1', + checklistId: 'cl-1', + itemCode: '123123', + itemName: '1. 수입검사 기준 확인', + description: '소개 문구', + documentCount: 3, + status: '사용', + order: 1, + documents: MOCK_DOCUMENTS, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + id: 'item-2', + checklistId: 'cl-1', + itemCode: '123123', + itemName: '항목명', + description: '', + documentCount: 3, + status: '사용', + order: 2, + documents: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + id: 'item-3', + checklistId: 'cl-1', + itemCode: '123123', + itemName: '항목명', + description: '', + documentCount: 3, + status: '사용', + order: 3, + documents: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, +]; + +const MOCK_CHECKLISTS: Checklist[] = [ + { + id: 'cl-1', + checklistCode: '변호명', + checklistName: '점검표명', + itemCount: 3, + documentCount: 6, + status: '사용', + order: 1, + createdAt: '2025-09-01T00:00:00Z', + updatedAt: '2025-09-01T00:00:00Z', + }, + { + id: 'cl-2', + checklistCode: '변호명', + checklistName: '점검표명', + itemCount: 3, + documentCount: 6, + status: '미사용', + order: 2, + createdAt: '2025-09-01T00:00:00Z', + updatedAt: '2025-09-01T00:00:00Z', + }, + { + id: 'cl-3', + checklistCode: '변호명', + checklistName: '점검표명', + itemCount: 3, + documentCount: 6, + status: '사용', + order: 3, + createdAt: '2025-09-01T00:00:00Z', + updatedAt: '2025-09-01T00:00:00Z', + }, + { + id: 'cl-4', + checklistCode: '변호명', + checklistName: '점검표명', + itemCount: 3, + documentCount: 6, + status: '미사용', + order: 4, + createdAt: '2025-09-01T00:00:00Z', + updatedAt: '2025-09-01T00:00:00Z', + }, + { + id: 'cl-5', + checklistCode: '변호명', + checklistName: '점검표명', + itemCount: 3, + documentCount: 6, + status: '사용', + order: 5, + createdAt: '2025-09-01T00:00:00Z', + updatedAt: '2025-09-01T00:00:00Z', + }, + { + id: 'cl-6', + checklistCode: '변호명', + checklistName: '점검표명', + itemCount: 3, + documentCount: 6, + status: '미사용', + order: 6, + createdAt: '2025-09-01T00:00:00Z', + updatedAt: '2025-09-01T00:00:00Z', + }, + { + id: 'cl-7', + checklistCode: '변호명', + checklistName: '점검표명', + itemCount: 3, + documentCount: 6, + status: '사용', + order: 7, + createdAt: '2025-09-01T00:00:00Z', + updatedAt: '2025-09-01T00:00:00Z', + }, +]; + +// ============================================================================ +// 점검표 CRUD +// ============================================================================ + +export async function getChecklistList(): Promise<{ + success: boolean; + data?: { items: Checklist[] }; + error?: string; +}> { + return { success: true, data: { items: [...MOCK_CHECKLISTS] } }; +} + +export async function getChecklistById(id: string): Promise<{ + success: boolean; + data?: Checklist; + error?: string; +}> { + const checklist = MOCK_CHECKLISTS.find((c) => c.id === id); + if (!checklist) return { success: false, error: '점검표를 찾을 수 없습니다.' }; + return { success: true, data: { ...checklist } }; +} + +export async function createChecklist(data: ChecklistFormData): Promise<{ + success: boolean; + data?: Checklist; + error?: string; +}> { + const newChecklist: Checklist = { + id: `cl-${Date.now()}`, + checklistCode: `CL-${String(MOCK_CHECKLISTS.length + 1).padStart(3, '0')}`, + checklistName: data.checklistName, + itemCount: 0, + documentCount: 0, + status: data.status, + order: MOCK_CHECKLISTS.length + 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return { success: true, data: newChecklist }; +} + +export async function updateChecklist( + id: string, + data: ChecklistFormData +): Promise<{ success: boolean; data?: Checklist; error?: string }> { + const checklist = MOCK_CHECKLISTS.find((c) => c.id === id); + if (!checklist) return { success: false, error: '점검표를 찾을 수 없습니다.' }; + const updated = { + ...checklist, + checklistName: data.checklistName, + status: data.status, + updatedAt: new Date().toISOString(), + }; + return { success: true, data: updated }; +} + +export async function deleteChecklist(id: string): Promise<{ + success: boolean; + error?: string; +}> { + return { success: true }; +} + +export async function deleteChecklists(ids: string[]): Promise<{ + success: boolean; + deletedCount?: number; + error?: string; +}> { + return { success: true, deletedCount: ids.length }; +} + +export async function toggleChecklistStatus(id: string): Promise<{ + success: boolean; + data?: Checklist; + error?: string; +}> { + const checklist = MOCK_CHECKLISTS.find((c) => c.id === id); + if (!checklist) return { success: false, error: '점검표를 찾을 수 없습니다.' }; + const toggled = { + ...checklist, + status: checklist.status === '사용' ? '미사용' as const : '사용' as const, + }; + return { success: true, data: toggled }; +} + +export async function reorderChecklists( + items: { id: string; order: number }[] +): Promise<{ success: boolean; error?: string }> { + return { success: true }; +} + +export async function getChecklistStats(): Promise<{ + success: boolean; + data?: { total: number; active: number; inactive: number }; + error?: string; +}> { + const active = MOCK_CHECKLISTS.filter((c) => c.status === '사용').length; + return { + success: true, + data: { + total: MOCK_CHECKLISTS.length, + active, + inactive: MOCK_CHECKLISTS.length - active, + }, + }; +} + +// ============================================================================ +// 점검표 항목 CRUD +// ============================================================================ + +export async function getChecklistItems(checklistId: string): Promise<{ + success: boolean; + data?: ChecklistItem[]; + error?: string; +}> { + return { success: true, data: [...MOCK_ITEMS] }; +} + +export async function getChecklistItemById( + checklistId: string, + itemId: string +): Promise<{ success: boolean; data?: ChecklistItem; error?: string }> { + const item = MOCK_ITEMS.find((i) => i.id === itemId); + if (!item) return { success: false, error: '항목을 찾을 수 없습니다.' }; + return { success: true, data: { ...item, documents: [...MOCK_DOCUMENTS] } }; +} + +export async function createChecklistItem( + checklistId: string, + data: ChecklistItemFormData +): Promise<{ success: boolean; data?: ChecklistItem; error?: string }> { + const newItem: ChecklistItem = { + id: `item-${Date.now()}`, + checklistId, + itemCode: String(Date.now()).slice(-6), + itemName: data.itemName, + description: data.description, + documentCount: data.documents.length, + status: data.status, + order: MOCK_ITEMS.length + 1, + documents: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return { success: true, data: newItem }; +} + +export async function updateChecklistItem( + checklistId: string, + itemId: string, + data: ChecklistItemFormData +): Promise<{ success: boolean; data?: ChecklistItem; error?: string }> { + const item = MOCK_ITEMS.find((i) => i.id === itemId); + if (!item) return { success: false, error: '항목을 찾을 수 없습니다.' }; + const updated = { + ...item, + itemName: data.itemName, + description: data.description, + status: data.status, + updatedAt: new Date().toISOString(), + }; + return { success: true, data: updated }; +} + +export async function deleteChecklistItem( + checklistId: string, + itemId: string +): Promise<{ success: boolean; error?: string }> { + return { success: true }; +} + +export async function reorderChecklistItems( + checklistId: string, + items: { id: string; order: number }[] +): Promise<{ success: boolean; error?: string }> { + return { success: true }; +} \ No newline at end of file diff --git a/src/components/checklist-management/index.ts b/src/components/checklist-management/index.ts new file mode 100644 index 00000000..747c5c2f --- /dev/null +++ b/src/components/checklist-management/index.ts @@ -0,0 +1,7 @@ +export { default as ChecklistListClient } from './ChecklistListClient'; +export { ChecklistDetailClient } from './ChecklistDetailClient'; +export { ChecklistDetail } from './ChecklistDetail'; +export { ChecklistForm } from './ChecklistForm'; +export { ItemDetailClient } from './ItemDetailClient'; +export { ItemDetail } from './ItemDetail'; +export { ItemForm } from './ItemForm'; diff --git a/src/components/orders/documents/SalesOrderDocument.tsx b/src/components/orders/documents/SalesOrderDocument.tsx index a45bf417..e36069c1 100644 --- a/src/components/orders/documents/SalesOrderDocument.tsx +++ b/src/components/orders/documents/SalesOrderDocument.tsx @@ -1,13 +1,13 @@ "use client"; /** - * 수주서 문서 컴포넌트 - * - 스크린샷 기반 디자인 - * - 제목 좌측, 결재란 우측 - * - 신청업체/신청내용/납품정보 3열 구조 - * - 스크린, 모터, 절곡물 테이블 + * 수주서 문서 컴포넌트 (기획서 D1.8 기준 리디자인) + * - 출고증(ShipmentOrderDocument)과 동일한 자재 섹션 구조 + * - 수주서 전용 헤더 (결재란, 로트번호, 제품코드, 인정번호) + * - 배차정보/LOT 컬럼 없음 */ +import { useState } from "react"; import { getTodayString } from "@/utils/date"; import { OrderItem } from "../actions"; import { ProductInfo } from "./OrderDocumentModal"; @@ -15,8 +15,8 @@ import { ConstructionApprovalTable } from "@/components/document-system"; interface SalesOrderDocumentProps { documentNumber?: string; - orderNumber: string; // 로트번호 - certificationNumber?: string; // 인정번호 + orderNumber: string; + certificationNumber?: string; orderDate?: string; client: string; siteName?: string; @@ -35,23 +35,64 @@ interface SalesOrderDocumentProps { remarks?: string; } -/** - * 수량 포맷 함수 - */ -function formatQuantity(quantity: number, unit?: string): string { - const countableUnits = ["EA", "SET", "PCS", "개", "세트", "BOX", "ROLL"]; - const upperUnit = (unit || "").toUpperCase(); +// ===== 문서 전용 목데이터 (출고증과 동일 구조) ===== - if (countableUnits.includes(upperUnit)) { - return Math.round(quantity).toLocaleString(); - } +const MOCK_SCREEN_ROWS = [ + { no: 1, type: '이(마)', code: 'FA123', openW: 4300, openH: 4300, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, + { no: 2, type: '이(마)', code: 'FA123', openW: 4300, openH: 4300, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, +]; - const rounded = Math.round(quantity * 10000) / 10000; - return rounded.toLocaleString(undefined, { - minimumFractionDigits: 0, - maximumFractionDigits: 4 - }); -} +const MOCK_STEEL_ROWS = [ + { no: 1, code: 'FA123', openW: 4300, openH: 3000, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, jointBar: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, + { no: 2, code: 'FA123', openW: 4300, openH: 3000, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, jointBar: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, +]; + +const MOCK_MOTOR_LEFT = [ + { item: '모터', type: '380V 단상', spec: 'KD-150K', qty: 6 }, + { item: '브라켓트', type: '-', spec: '380X180', qty: 6 }, + { item: '앵글', type: '밑침통 영금', spec: '40*40*380', qty: 4 }, +]; + +const MOCK_MOTOR_RIGHT = [ + { item: '전동개폐기', type: '릴박스', spec: '-', qty: 1 }, + { item: '전동개폐기', type: '매입', spec: '-', qty: 1 }, +]; + +const MOCK_GUIDE_RAIL_ITEMS = [ + { name: '항목명', spec: 'L: 3,000', qty: 22 }, + { name: '하부BASE', spec: '130X80', qty: 22 }, +]; + +const MOCK_GUIDE_SMOKE = { name: '연기차단재(W50)', spec: '2,438', qty: 4 }; + +const MOCK_CASE_ITEMS = [ + { name: '500X330', spec: 'L: 4,000', qty: 3 }, + { name: '500X330', spec: 'L: 5,000', qty: 4 }, + { name: '상부덮개', spec: '1219X389', qty: 55 }, + { name: '측면부 (마구리)', spec: '500X355', qty: '500X355' }, +]; + +const MOCK_CASE_SMOKE = { name: '연기차단재(W80)', spec: '3,000', qty: 4 }; + +const MOCK_BOTTOM_SCREEN = [ + { name: '하단마감재', spec: '60X40', l1: 'L: 3,000', q1: 6, name2: '하단마감재', spec2: '60X40', l2: 'L: 4,000', q2: 6 }, + { name: '하단보강엘비', spec: '60X17', l1: 'L: 3,000', q1: 6, name2: '하단보강엘비', spec2: '60X17', l2: 'L: 4,000', q2: 6 }, + { name: '하단보강평철', spec: '-', l1: 'L: 3,000', q1: 6, name2: '하단보강평철', spec2: '-', l2: 'L: 4,000', q2: 6 }, + { name: '하단무게평철', spec: '50X12T', l1: 'L: 3,000', q1: 6, name2: '하단무게평철', spec2: '50X12T', l2: 'L: 4,000', q2: 6 }, +]; + +const MOCK_BOTTOM_STEEL = { spec: '60X40', length: 'L: 3,000', qty: 22 }; + +const MOCK_SUBSIDIARY = [ + { leftItem: '감기사프트', leftSpec: '4인치 4500', leftQty: 6, rightItem: '각파이프', rightSpec: '6000', rightQty: 4 }, + { leftItem: '조인트바', leftSpec: '300', leftQty: 6, rightItem: '환봉', rightSpec: '3000', rightQty: 5 }, +]; + +// ===== 공통 스타일 ===== +const thBase = 'border-r border-gray-400 px-1 py-1'; +const tdBase = 'border-r border-gray-300 px-1 py-1'; +const tdCenter = `${tdBase} text-center`; +const imgPlaceholder = 'flex items-center justify-center border border-dashed border-gray-300 text-gray-400'; export function SalesOrderDocument({ documentNumber = "ABC123", @@ -73,53 +114,14 @@ export function SalesOrderDocument({ products = [], remarks, }: SalesOrderDocumentProps) { - // 스크린 제품만 필터링 - const screenProducts = products.filter(p => - p.productCategory?.includes("스크린") || - p.productName?.includes("스크린") || - p.productName?.includes("방화") || - p.productName?.includes("셔터") - ); + const [bottomFinishView, setBottomFinishView] = useState<'screen' | 'steel'>('screen'); - // 모터 아이템 필터링 - const motorItems = items.filter(item => - item.itemName?.toLowerCase().includes("모터") || - item.type?.includes("모터") || - item.itemCode?.startsWith("MT") - ); - - // 브라켓 아이템 필터링 - const bracketItems = items.filter(item => - item.itemName?.includes("브라켓") || - item.type?.includes("브라켓") - ); - - // 가이드레일 아이템 필터링 - const guideRailItems = items.filter(item => - item.itemName?.includes("가이드") || - item.itemName?.includes("레일") || - item.type?.includes("가이드") - ); - - // 케이스 아이템 필터링 - const caseItems = items.filter(item => - item.itemName?.includes("케이스") || - item.itemName?.includes("셔터박스") || - item.type?.includes("케이스") - ); - - // 하단마감재 아이템 필터링 - const bottomFinishItems = items.filter(item => - item.itemName?.includes("하단") || - item.itemName?.includes("마감") || - item.type?.includes("하단마감") - ); + const motorRows = Math.max(MOCK_MOTOR_LEFT.length, MOCK_MOTOR_RIGHT.length); return (
- {/* 헤더: 수주서 제목 (좌측) + 결재란 (우측) */} + {/* ========== 헤더: 수주서 제목 + 결재란 ========== */}
- {/* 수주서 제목 (좌측) */}

수 주 서

@@ -129,30 +131,26 @@ export function SalesOrderDocument({
- - {/* 결재란 (우측) */} - +
- {/* 상품명 / 제품명 / 로트번호 / 인정번호 */} + {/* ========== 로트번호 / 제품명 / 제품코드 / 인정번호 ========== */} - - - - + + + +
상품명{products[0]?.productCategory || "-"}제품명{products[0]?.productName || "-"} 로트번호 {orderNumber}제품명{products[0]?.productName || "-"}제품코드KWS01 인정번호 {certificationNumber}
- {/* 3열 섹션: 신청업체 | 신청내용 | 납품정보 */} + {/* ========== 3열 섹션: 신청업체 | 신청내용 | 납품정보 ========== */}
{/* 신청업체 */} @@ -165,21 +163,17 @@ export function SalesOrderDocument({ {orderDate} - 업체명 + 수주처 {client} 수주 담당자 {manager} - + 담당자 연락처 {managerContact} - - 배송지 주소 - {address} -
@@ -201,14 +195,10 @@ export function SalesOrderDocument({ 출고일 {expectedShipDate} - + 셔터출수량 {shutterCount}개소 - -   -   -
@@ -230,377 +220,411 @@ export function SalesOrderDocument({ 인수자연락처 {recipientContact} - + 배송방법 {deliveryMethod} - -   -   -
+ {/* 배송지 주소 - 한 줄 병합 */} +
+
배송지 주소
+
{address}
+

아래와 같이 주문하오니 품질 및 납기일을 준수하여 주시기 바랍니다.

- {/* 1. 스크린 테이블 */} + {/* ========== 1. 스크린 ========== */}

1. 스크린

- - - - - - - - - + + + + + + + + + - - - - - - + + + + + + - {screenProducts.length > 0 ? ( - screenProducts.map((product, index) => ( - - - - - - - - - - - - - - - - )) - ) : ( - - + {MOCK_SCREEN_ROWS.map((row) => ( + + + + + + + + + + + + + + - )} + ))}
No품류부호오픈사이즈제작사이즈가이드레일
유형
샤프트
(인치)
케이스
(인치)
모터No품류부호오픈사이즈제작사이즈가이드
레일
사프트
(인치)
케이스
(인치)
모터 마감
가로세로가로세로브라켓트용량Kg가로세로가로세로브라켓트용량Kg
{index + 1}{product.productCategory || "-"}{product.code || "-"}{product.openWidth || "-"}{product.openHeight || "-"}{product.openWidth || "-"}{product.openHeight || "-"}백면형
(120X70)
55380X180300SUS마감
- 등록된 스크린 제품이 없습니다 -
{row.no}{row.type}{row.code}{row.openW.toLocaleString()}{row.openH.toLocaleString()}{row.madeW.toLocaleString()}{row.madeH.toLocaleString()}{row.guideRail}{row.shaft}{row.caseInch}{row.bracket}{row.capacity}{row.finish}
- {/* 2. 모터 테이블 */} + {/* ========== 2. 철재 ========== */}
-

2. 모터

+

2. 철재

- - - - - - - - + + + + + + + + + + + + + + + + + + - {(motorItems.length > 0 || bracketItems.length > 0) ? ( - <> - {/* 모터 행 */} - - - - - - - - - - - {/* 브라켓트 행 */} - - - - - - - - - - - {/* 브라켓트 추가 행 (밑침통 영금) */} - - - - - - - - - - - - ) : ( - - + {MOCK_STEEL_ROWS.map((row) => ( + + + + + + + + + + + + + + - )} + ))}
항목구분규격수량항목구분규격수량No.부호오픈사이즈제작사이즈가이드
레일
사프트
(인치)
조인트바
(규격)
케이스
(인치)
모터마감
가로세로가로세로브라켓트용량Kg
모터(380V 단상)모터 용량{motorItems[0]?.spec || "KD-150K"}{motorItems[0] ? formatQuantity(motorItems[0].quantity, motorItems[0].unit) : "6"}모터(380V 단상)모터 용량{motorItems[1]?.spec || "KD-150K"}{motorItems[1] ? formatQuantity(motorItems[1].quantity, motorItems[1].unit) : "6"}
브라켓트브라켓트{bracketItems[0]?.spec || "380X180 [2-4\"]"}{bracketItems[0] ? formatQuantity(bracketItems[0].quantity, bracketItems[0].unit) : "6"}브라켓트브라켓트{bracketItems[1]?.spec || "380X180 [2-4\"]"}{bracketItems[1] ? formatQuantity(bracketItems[1].quantity, bracketItems[1].unit) : "6"}
브라켓트밑침통 영금{bracketItems[2]?.spec || "∠40-40 L380"}{bracketItems[2] ? formatQuantity(bracketItems[2].quantity, bracketItems[2].unit) : "44"}
- 등록된 모터/브라켓 품목이 없습니다 -
{row.no}{row.code}{row.openW.toLocaleString()}{row.openH.toLocaleString()}{row.madeW.toLocaleString()}{row.madeH.toLocaleString()}{row.guideRail}{row.shaft}{row.jointBar}{row.caseInch}{row.bracket}{row.capacity}{row.finish}
- {/* 3. 절곡물 */} + {/* ========== 3. 모터 ========== */}
-

3. 절곡물

- - {/* 3-1. 가이드레일 */} -
-

3-1. 가이드레일 - EGI 1.5ST + 마감재 EGI 1.1ST + 별도마감재 SUS 1.1ST

-
- - - - - - - - - - - - - {guideRailItems.length > 0 ? ( - <> - {/* 1행: L: 3,000 / 22 */} - - - - - - - - - {/* 2행: 하부BASE */} - - - - - - - {/* 3행: 빈 행 */} - - - - - - - {/* 4행: 제품명 */} - - - - - - - - ) : ( - - +

3. 모터

+
+
백면형 (120X70)길이수량측면형 (120X120)길이수량
-
- IMG -
-
L: 3,00022 -
- IMG -
-
L: 3,00022
하부BASE
[130X80]
22
제품명KSS01제품명KSS01
- 등록된 가이드레일이 없습니다 -
+ + + + + + + + + + + + + + {Array.from({ length: motorRows }).map((_, i) => { + const left = MOCK_MOTOR_LEFT[i]; + const right = MOCK_MOTOR_RIGHT[i]; + return ( + + + + + + + + + - )} - -
항목구분규격수량항목구분규격수량
{left?.item || ''}{left?.type || ''}{left?.spec || ''}{left?.qty ?? ''}{right?.item || ''}{right?.type || ''}{right?.spec || ''}{right?.qty ?? ''}
-
- - {/* 연기차단재 정보 */} -
- - - - - - - - - - - - - - - - -
-
연기차단재(W50)
-
• 가이드레일 마감재
-
양측에 설치
-
-
EGI 0.8T +
-
화이버글라스코팅직물
-
-
- IMG -
-
규격3,0004,000
수량441
-
- -
- • 별도 추가사항 -
-
- - {/* 3-2. 케이스(셔터박스) */} -
-

3-2. 케이스(셔터박스) - EGI 1.5ST

-
- - - - - - - - - - - - - {caseItems.length > 0 ? ( - - - - - - - - - ) : ( - - - - )} - -
 규격길이수량측면부수량
-
- IMG -
-
- 500X330
(150X300,
400K원) -
- L: 4,000
L: 5,000
상부덮개
(1219X389) -
- 3
4
55 -
500X35522
- 등록된 케이스가 없습니다 -
-
- - {/* 연기차단재 정보 */} -
- - - - - - - - - - - - - - -
-
연기차단재(W50)
-
• 판넬부, 전면부
-
감싸에 설치
-
-
EGI 0.8T +
-
화이버글라스코팅직물
-
-
- IMG -
-
규격3,000
수량44
-
-
- - {/* 3-3. 하단마감재 */} -
-

3-3. 하단마감재 - 하단마감재(EGI 1.5ST) + 하단보강앨비(EGI 1.5ST) + 하단 보강철(EGI 1.1ST) + 하단 무게형 철(50X12T)

-
- - - - - - - - - - - - - - - - {bottomFinishItems.length > 0 ? ( - - - - - - - - - - - - ) : ( - - - - )} - -
구성품길이수량구성품길이수량구성품길이수량
하단마감재
(60X40)
L: 4,00011하단보강
(60X17)
L: 4,00011하단무게
[50X12T]
L: 4,00011
- 등록된 하단마감재가 없습니다 -
-
+ ); + })} + +
- {/* 특이사항 */} + {/* ========== 4. 절곡물 ========== */} +
+

4. 절곡물

+ + {/* 4-1. 가이드레일 */} +
+

4-1. 가이드레일 - EGI 1.5ST + 마감재 EGI 1.1ST + 별도마감재 SUS 1.1ST

+ +
+ + + + + + + + + + + {MOCK_GUIDE_RAIL_ITEMS.map((item, i) => ( + + {i === 0 && ( + + )} + + + + + ))} + +
백면형 (120X70)항목규격수량
+
IMG
+
{item.name}{item.spec}{item.qty}
+
+ + {/* 연기차단재 */} +
+ + + + + + + + + + + + + + + + + +
 항목규격수량
+
IMG
+
{MOCK_GUIDE_SMOKE.name}{MOCK_GUIDE_SMOKE.spec}{MOCK_GUIDE_SMOKE.qty}
+
+ +

+ * 가이드레일 마감재 양측에 설치 - EGI 0.8T + 화이버글라스코팅직물 +

+
+ + {/* 4-2. 케이스(셔터박스) */} +
+

4-2. 케이스(셔터박스) - EGI 1.5ST

+ +
+ + + + + + + + + + + {MOCK_CASE_ITEMS.map((item, i) => ( + + {i === 0 && ( + + )} + + + + + ))} + +
 항목규격수량
+
IMG
+
{item.name}{item.spec}{item.qty}
+
+ + {/* 연기차단재 */} +
+ + + + + + + + + + + + + + + + + +
 항목규격수량
+
IMG
+
{MOCK_CASE_SMOKE.name}{MOCK_CASE_SMOKE.spec}{MOCK_CASE_SMOKE.qty}
+
+ +

+ * 전면부, 판넬부 양측에 설치 - EGI 0.8T + 화이버글라스코팅직물 +

+
+ + {/* 4-3. 하단마감재 (토글: 스크린 / 절재) */} +
+
+ + +
+ + {bottomFinishView === 'screen' ? ( + <> +

+ 4-3. 하단마감재 - 하단마감재(EGI 1.5ST) + 하단보강엘비(EGI 1.5ST) + 하단 보강평철(EGI 1.1ST) + 하단 무게평철(50X12T) +

+
+ + + + + + + + + + + + + + + {MOCK_BOTTOM_SCREEN.map((row, i) => ( + + + + + + + + + + + ))} + +
항목규격길이수량항목규격길이수량
{row.name}{row.spec}{row.l1}{row.q1}{row.name2}{row.spec2}{row.l2}{row.q2}
+
+ + ) : ( + <> +

+ 4-3. 하단마감재 -EGI 1.5ST +

+
+ + + + + + + + + + + + + + + + + +
하단마감재규격길이수량
+
IMG
+
{MOCK_BOTTOM_STEEL.spec}{MOCK_BOTTOM_STEEL.length}{MOCK_BOTTOM_STEEL.qty}
+
+ + )} +
+
+ + {/* ========== 5. 부자재 ========== */} +
+

5. 부자재

+
+ + + + + + + + + + + + + {MOCK_SUBSIDIARY.map((row, i) => ( + + + + + + + + + ))} + +
항목규격수량항목규격수량
{row.leftItem}{row.leftSpec}{row.leftQty}{row.rightItem}{row.rightSpec}{row.rightQty}
+
+
+ + {/* ========== 특이사항 ========== */} {remarks && (

【 특이사항 】

@@ -611,4 +635,4 @@ export function SalesOrderDocument({ )}
); -} +} \ No newline at end of file diff --git a/src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx b/src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx index 449387a1..763c571b 100644 --- a/src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx +++ b/src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx @@ -1,11 +1,12 @@ 'use client'; /** - * 출고 문서 공통 컴포넌트 (수주서 레이아웃 기반) - * - 출고증, 납품확인서에서 제목만 변경하여 사용 - * - 수주서(SalesOrderDocument)와 동일한 레이아웃 + * 출고 문서 공통 컴포넌트 (기획서 D1.8 기준 리디자인) + * - 출고증: showDispatchInfo + showLotColumn + * - 납품확인서: 기본값 (배차정보 없음, LOT 컬럼 없음) */ +import { useState } from 'react'; import type { ShipmentDetail } from '../types'; import { DELIVERY_METHOD_LABELS } from '../types'; import { ConstructionApprovalTable } from '@/components/document-system'; @@ -14,56 +15,78 @@ interface ShipmentOrderDocumentProps { title: string; data: ShipmentDetail; showDispatchInfo?: boolean; + showLotColumn?: boolean; } -export function ShipmentOrderDocument({ title, data, showDispatchInfo = false }: ShipmentOrderDocumentProps) { - // 스크린 제품 필터링 (productGroups 기반) - const screenProducts = data.productGroups.filter(g => - g.productName?.includes('스크린') || - g.productName?.includes('방화') || - g.productName?.includes('셔터') - ); +// ===== 문서 전용 목데이터 ===== - // 전체 부품 목록 - const allParts = [ - ...data.productGroups.flatMap(g => g.parts), - ...data.otherParts, - ]; +const MOCK_SCREEN_ROWS = [ + { no: 1, type: '이(마)', code: 'FA123', openW: 4300, openH: 4300, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, + { no: 2, type: '이(마)', code: 'FA123', openW: 4300, openH: 4300, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, +]; - // 모터 아이템 필터링 - const motorItems = allParts.filter(part => - part.itemName?.includes('모터') - ); +const MOCK_STEEL_ROWS = [ + { no: 1, code: 'FA123', openW: 4300, openH: 3000, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, jointBar: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, + { no: 2, code: 'FA123', openW: 4300, openH: 3000, madeW: 4300, madeH: 3000, guideRail: '백면형', shaft: 5, jointBar: 5, caseInch: 5, bracket: '500X300 380X180', capacity: 300, finish: 'SUS마감' }, +]; - // 브라켓 아이템 필터링 - const bracketItems = allParts.filter(part => - part.itemName?.includes('브라켓') - ); +const MOCK_MOTOR_LEFT = [ + { item: '모터', type: '380V 단상', spec: 'KD-150K', qty: 6, lot: '123123' }, + { item: '브라켓트', type: '-', spec: '380X180', qty: 6, lot: '123123' }, + { item: '앵글', type: '밑침통 영금', spec: '40*40*380', qty: 4, lot: '123123' }, +]; - // 가이드레일 아이템 필터링 - const guideRailItems = allParts.filter(part => - part.itemName?.includes('가이드') || - part.itemName?.includes('레일') - ); +const MOCK_MOTOR_RIGHT = [ + { item: '전동개폐기', type: '릴박스', spec: '-', qty: 1, lot: '123123' }, + { item: '전동개폐기', type: '매입', spec: '-', qty: 1, lot: '123123' }, +]; - // 케이스 아이템 필터링 - const caseItems = allParts.filter(part => - part.itemName?.includes('케이스') || - part.itemName?.includes('셔터박스') - ); +const MOCK_GUIDE_RAIL_ITEMS = [ + { name: '항목명', spec: 'L: 3,000', qty: 22 }, + { name: '하부BASE', spec: '130X80', qty: 22 }, +]; - // 하단마감재 아이템 필터링 - const bottomFinishItems = allParts.filter(part => - part.itemName?.includes('하단') || - part.itemName?.includes('마감') - ); +const MOCK_GUIDE_SMOKE = { name: '연기차단재(W50)', spec: '2,438', qty: 4 }; + +const MOCK_CASE_ITEMS = [ + { name: '500X330', spec: 'L: 4,000', qty: 3 }, + { name: '500X330', spec: 'L: 5,000', qty: 4 }, + { name: '상부덮개', spec: '1219X389', qty: 55 }, + { name: '측면부 (마구리)', spec: '500X355', qty: '500X355' }, +]; + +const MOCK_CASE_SMOKE = { name: '연기차단재(W80)', spec: '3,000', qty: 4 }; + +const MOCK_BOTTOM_SCREEN = [ + { name: '하단마감재', spec: '60X40', l1: 'L: 3,000', q1: 6, name2: '하단마감재', spec2: '60X40', l2: 'L: 4,000', q2: 6 }, + { name: '하단보강엘비', spec: '60X17', l1: 'L: 3,000', q1: 6, name2: '하단보강엘비', spec2: '60X17', l2: 'L: 4,000', q2: 6 }, + { name: '하단보강평철', spec: '-', l1: 'L: 3,000', q1: 6, name2: '하단보강평철', spec2: '-', l2: 'L: 4,000', q2: 6 }, + { name: '하단무게평철', spec: '50X12T', l1: 'L: 3,000', q1: 6, name2: '하단무게평철', spec2: '50X12T', l2: 'L: 4,000', q2: 6 }, +]; + +const MOCK_BOTTOM_STEEL = { spec: '60X40', length: 'L: 3,000', qty: 22 }; + +const MOCK_SUBSIDIARY = [ + { leftItem: '감기사프트', leftSpec: '4인치 4500', leftQty: 6, rightItem: '각파이프', rightSpec: '6000', rightQty: 4 }, + { leftItem: '조인트바', leftSpec: '300', leftQty: 6, rightItem: '환봉', rightSpec: '3000', rightQty: 5 }, +]; + +// ===== 공통 스타일 ===== +const thBase = 'border-r border-gray-400 px-1 py-1'; +const tdBase = 'border-r border-gray-300 px-1 py-1'; +const tdCenter = `${tdBase} text-center`; +const imgPlaceholder = 'flex items-center justify-center border border-dashed border-gray-300 text-gray-400'; + +export function ShipmentOrderDocument({ title, data, showDispatchInfo = false, showLotColumn = false }: ShipmentOrderDocumentProps) { + const [bottomFinishView, setBottomFinishView] = useState<'screen' | 'steel'>('screen'); const deliveryMethodLabel = DELIVERY_METHOD_LABELS[data.deliveryMethod] || '-'; const fullAddress = [data.address, data.addressDetail].filter(Boolean).join(' ') || data.deliveryAddress || '-'; + const motorRows = Math.max(MOCK_MOTOR_LEFT.length, MOCK_MOTOR_RIGHT.length); return (
- {/* 헤더: 제목 (좌측) + 결재란 (우측) */} + {/* ========== 헤더: 제목 + 결재란 ========== */}

{title}

@@ -74,30 +97,26 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false }:
- - {/* 결재란 (우측) */} - +
- {/* 상품명 / 제품명 / 로트번호 / 인정번호 */} + {/* ========== 로트번호 / 제품명 / 제품코드 / 인정번호 ========== */} - - - - + + + + - +
상품명{data.productGroups[0]?.productName || '-'}제품명{data.products[0]?.itemName || '-'} 로트번호 {data.lotNo}제품명{data.productGroups[0]?.productName || '-'}제품코드KWS01 인정번호-ABC1234
- {/* 3열 섹션: 신청업체 | 신청내용 | 납품정보 */} + {/* ========== 3열 섹션: 신청업체 | 신청내용 | 납품정보 ========== */}
{/* 신청업체 */} @@ -110,21 +129,17 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false }: {data.scheduledDate} - 업체명 + 수주처 {data.customerName} 수주 담당자 {data.registrant || '-'} - + 담당자 연락처 {data.driverContact || '-'} - - 배송지 주소 - {fullAddress} -
@@ -146,14 +161,10 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false }: 출고일 {data.shipmentDate || data.scheduledDate} - + 셔터출수량 {data.productGroups.length}개소 - -   -   -
@@ -175,21 +186,22 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false }: 인수자연락처 {data.receiverContact || '-'} - + 배송방법 {deliveryMethodLabel} - -   -   -
+ {/* 배송지 주소 - 한 줄 병합 */} +
+
배송지 주소
+
{fullAddress}
+
- {/* 배차정보 (출고증에서만 표시) */} + {/* ========== 배차정보 (출고증에서만) ========== */} {showDispatchInfo && (() => { const dispatch = data.vehicleDispatches[0]; return ( @@ -221,366 +233,406 @@ export function ShipmentOrderDocument({ title, data, showDispatchInfo = false }: ); })()} -

아래와 같이 주문하오니 품질 및 납기일을 준수하여 주시기 바랍니다.

+ {/* ========== 자재 및 철거 내역 헤더 ========== */} +
+ 자재 및 철거 내역 +
- {/* 1. 스크린 테이블 */} + {/* ========== 1. 스크린 ========== */}

1. 스크린

- - - - - - - - - + + + + + + + + + - - - - - - + + + + + + - {screenProducts.length > 0 ? ( - screenProducts.map((group, index) => { - // specification에서 가로x세로 파싱 (예: "2000 x 2500 mm") - const specMatch = group.specification?.match(/(\d+)\s*[xX×]\s*(\d+)/); - const width = specMatch ? specMatch[1] : '-'; - const height = specMatch ? specMatch[2] : '-'; - - return ( - - - - - - - - - - - - - - - - ); - }) - ) : ( - - + {MOCK_SCREEN_ROWS.map((row) => ( + + + + + + + + + + + + + + - )} + ))}
No품류부호오픈사이즈제작사이즈가이드레일
유형
샤프트
(인치)
케이스
(인치)
모터No품류부호오픈사이즈제작사이즈가이드
레일
사프트
(인치)
케이스
(인치)
모터 마감
가로세로가로세로브라켓트용량Kg가로세로가로세로브라켓트용량Kg
{index + 1}{group.productName || '-'}-{width}{height}{width}{height}백면형
(120X70)
55380X180300SUS마감
- 등록된 스크린 제품이 없습니다 -
{row.no}{row.type}{row.code}{row.openW.toLocaleString()}{row.openH.toLocaleString()}{row.madeW.toLocaleString()}{row.madeH.toLocaleString()}{row.guideRail}{row.shaft}{row.caseInch}{row.bracket}{row.capacity}{row.finish}
- {/* 2. 모터 테이블 */} + {/* ========== 2. 절재 ========== */}
-

2. 모터

+

2. 절재

- - - - - - - - + + + + + + + + + + + + + + + + + + - {(motorItems.length > 0 || bracketItems.length > 0) ? ( - <> - {/* 모터 행 */} - - - - - - - - - - - {/* 브라켓트 행 */} - - - - - - - - - - - {/* 브라켓트 추가 행 */} - - - - - - - - - - - - ) : ( - - + {MOCK_STEEL_ROWS.map((row) => ( + + + + + + + + + + + + + + - )} + ))}
항목구분규격수량항목구분규격수량No.부호오픈사이즈제작사이즈가이드
레일
사프트
(인치)
조인트바
(규격)
케이스
(인치)
모터마감
가로세로가로세로브라켓트용량Kg
모터(380V 단상)모터 용량{motorItems[0]?.specification || 'KD-150K'}{motorItems[0]?.quantity ?? '-'}모터(380V 단상)모터 용량{motorItems[1]?.specification || 'KD-150K'}{motorItems[1]?.quantity ?? '-'}
브라켓트브라켓트{bracketItems[0]?.specification || '380X180 [2-4"]'}{bracketItems[0]?.quantity ?? '-'}브라켓트브라켓트{bracketItems[1]?.specification || '380X180 [2-4"]'}{bracketItems[1]?.quantity ?? '-'}
브라켓트밑침통 영금{bracketItems[2]?.specification || '∠40-40 L380'}{bracketItems[2]?.quantity ?? '-'}
- 등록된 모터/브라켓 품목이 없습니다 -
{row.no}{row.code}{row.openW.toLocaleString()}{row.openH.toLocaleString()}{row.madeW.toLocaleString()}{row.madeH.toLocaleString()}{row.guideRail}{row.shaft}{row.jointBar}{row.caseInch}{row.bracket}{row.capacity}{row.finish}
- {/* 3. 절곡물 */} + {/* ========== 3. 모터 ========== */}
-

3. 절곡물

- - {/* 3-1. 가이드레일 */} -
-

3-1. 가이드레일 - EGI 1.5ST + 마감재 EGI 1.1ST + 별도마감재 SUS 1.1ST

-
- - - - - - - - - - - - - {guideRailItems.length > 0 ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - - +

3. 모터

+
+
백면형 (120X70)길이수량측면형 (120X120)길이수량
-
- IMG -
-
L: 3,000{guideRailItems[0]?.quantity ?? 22} -
- IMG -
-
L: 3,000{guideRailItems[1]?.quantity ?? 22}
하부BASE
[130X80]
22
제품명KSS01제품명KSS01
- 등록된 가이드레일이 없습니다 -
+ + + + + + + {showLotColumn && } + + + + + {showLotColumn && } + + + + {Array.from({ length: motorRows }).map((_, i) => { + const left = MOCK_MOTOR_LEFT[i]; + const right = MOCK_MOTOR_RIGHT[i]; + return ( + + + + + + {showLotColumn && } + + + + + {showLotColumn && } - )} - -
항목구분규격수량입고 LOT항목구분규격수량입고 LOT
{left?.item || ''}{left?.type || ''}{left?.spec || ''}{left?.qty ?? ''}{left?.lot || ''}{right?.item || ''}{right?.type || ''}{right?.spec || ''}{right?.qty ?? ''}{right?.lot || ''}
-
- - {/* 연기차단재 정보 */} -
- - - - - - - - - - - - - - - - -
-
연기차단재(W50)
-
• 가이드레일 마감재
-
양측에 설치
-
-
EGI 0.8T +
-
화이버글라스코팅직물
-
-
- IMG -
-
규격3,0004,000
수량441
-
- -
- • 별도 추가사항 -
-
- - {/* 3-2. 케이스(셔터박스) */} -
-

3-2. 케이스(셔터박스) - EGI 1.5ST

-
- - - - - - - - - - - - - {caseItems.length > 0 ? ( - - - - - - - - - ) : ( - - - - )} - -
 규격길이수량측면부수량
-
- IMG -
-
- 500X330
(150X300,
400K원) -
- L: 4,000
L: 5,000
상부덮개
(1219X389) -
- 3
4
55 -
500X35522
- 등록된 케이스가 없습니다 -
-
- - {/* 연기차단재 정보 */} -
- - - - - - - - - - - - - - -
-
연기차단재(W50)
-
• 판넬부, 전면부
-
감싸에 설치
-
-
EGI 0.8T +
-
화이버글라스코팅직물
-
-
- IMG -
-
규격3,000
수량44
-
-
- - {/* 3-3. 하단마감재 */} -
-

3-3. 하단마감재 - 하단마감재(EGI 1.5ST) + 하단보강앨비(EGI 1.5ST) + 하단 보강철(EGI 1.1ST) + 하단 무게형 철(50X12T)

-
- - - - - - - - - - - - - - - - {bottomFinishItems.length > 0 ? ( - - - - - - - - - - - - ) : ( - - - - )} - -
구성품길이수량구성품길이수량구성품길이수량
하단마감재
(60X40)
L: 4,00011하단보강
(60X17)
L: 4,00011하단무게
[50X12T]
L: 4,00011
- 등록된 하단마감재가 없습니다 -
-
+ ); + })} + +
- {/* 특이사항 */} + {/* ========== 4. 절곡물 ========== */} +
+

4. 절곡물

+ + {/* 4-1. 가이드레일 */} +
+

4-1. 가이드레일 - EGI 1.5ST + 마감재 EGI 1.1ST + 별도마감재 SUS 1.1ST

+ + {/* 메인 테이블 */} +
+ + + + + + + + + + + {MOCK_GUIDE_RAIL_ITEMS.map((item, i) => ( + + {i === 0 && ( + + )} + + + + + ))} + +
백면형 (120X70)항목규격수량
+
IMG
+
{item.name}{item.spec}{item.qty}
+
+ + {/* 연기차단재 */} +
+ + + + + + + + + + + + + + + + + +
 항목규격수량
+
IMG
+
{MOCK_GUIDE_SMOKE.name}{MOCK_GUIDE_SMOKE.spec}{MOCK_GUIDE_SMOKE.qty}
+
+ +

+ * 가이드레일 마감재 양측에 설치 - EGI 0.8T + 화이버글라스코팅직물 +

+
+ + {/* 4-2. 케이스(셔터박스) */} +
+

4-2. 케이스(셔터박스) - EGI 1.5ST

+ + {/* 메인 테이블 */} +
+ + + + + + + + + + + {MOCK_CASE_ITEMS.map((item, i) => ( + + {i === 0 && ( + + )} + + + + + ))} + +
 항목규격수량
+
IMG
+
{item.name}{item.spec}{item.qty}
+
+ + {/* 연기차단재 */} +
+ + + + + + + + + + + + + + + + + +
 항목규격수량
+
IMG
+
{MOCK_CASE_SMOKE.name}{MOCK_CASE_SMOKE.spec}{MOCK_CASE_SMOKE.qty}
+
+ +

+ * 전면부, 판넬부 양측에 설치 - EGI 0.8T + 화이버글라스코팅직물 +

+
+ + {/* 4-3. 하단마감재 (토글: 스크린 / 절재) */} +
+ {/* 토글 버튼 */} +
+ + +
+ + {bottomFinishView === 'screen' ? ( + <> +

+ 4-3. 하단마감재 - 하단마감재(EGI 1.5ST) + 하단보강엘비(EGI 1.5ST) + 하단 보강평철(EGI 1.1ST) + 하단 무게평철(50X12T) +

+
+ + + + + + + + + + + + + + + {MOCK_BOTTOM_SCREEN.map((row, i) => ( + + + + + + + + + + + ))} + +
항목규격길이수량항목규격길이수량
{row.name}{row.spec}{row.l1}{row.q1}{row.name2}{row.spec2}{row.l2}{row.q2}
+
+ + ) : ( + <> +

+ 4-3. 하단마감재 -EGI 1.5ST +

+
+ + + + + + + + + + + + + + + + + +
하단마감재규격길이수량
+
IMG
+
{MOCK_BOTTOM_STEEL.spec}{MOCK_BOTTOM_STEEL.length}{MOCK_BOTTOM_STEEL.qty}
+
+ + )} +
+
+ + {/* ========== 5. 부자재 ========== */} +
+

5. 부자재

+
+ + + + + + + + + + + + + {MOCK_SUBSIDIARY.map((row, i) => ( + + + + + + + + + ))} + +
항목규격수량항목규격수량
{row.leftItem}{row.leftSpec}{row.leftQty}{row.rightItem}{row.rightSpec}{row.rightQty}
+
+
+ + {/* ========== 특이사항 ========== */} {data.remarks && (

【 특이사항 】

diff --git a/src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx b/src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx index b25b7bcc..f76d7e80 100644 --- a/src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx +++ b/src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx @@ -13,5 +13,5 @@ interface ShippingSlipProps { } export function ShippingSlip({ data }: ShippingSlipProps) { - return ; + return ; } diff --git a/src/components/process-management/ProcessDetail.tsx b/src/components/process-management/ProcessDetail.tsx index a3f77d1e..857368f6 100644 --- a/src/components/process-management/ProcessDetail.tsx +++ b/src/components/process-management/ProcessDetail.tsx @@ -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([]); const [isStepsLoading, setIsStepsLoading] = useState(true); + // 삭제 다이얼로그 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + // 드래그 상태 const [dragIndex, setDragIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(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, index: number) => { setDragIndex(index); @@ -343,12 +367,27 @@ export function ProcessDetail({ process }: ProcessDetailProps) { 목록으로 {canUpdate && ( - +
+ + +
)}
+ + {/* 삭제 확인 다이얼로그 */} + ); } diff --git a/src/components/process-management/ProcessListClient.tsx b/src/components/process-management/ProcessListClient.tsx index eada0796..16c43380 100644 --- a/src/components/process-management/ProcessListClient.tsx +++ b/src/components/process-management/ProcessListClient.tsx @@ -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(null); + const dragNodeRef = useRef(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, 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) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + e.currentTarget.classList.add('border-t-2', 'border-t-primary'); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + 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, 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 = 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 }) => ( + <> + + + 0 && selectedItems.size === displayData.length} + onCheckedChange={onToggleSelectAll} + /> + + 번호 + 공정번호 + 공정명 + 담당부서 + 품목 + 상태 + + ), + // 클라이언트 사이드 필터링 clientSideFiltering: true, itemsPerPage: 20, @@ -292,6 +394,13 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr ); }, + // 순서 변경 저장 버튼 + headerActions: () => ( + + ), + // 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링) createButton: { label: '공정 등록', @@ -315,9 +424,21 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr return ( 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)} > + e.stopPropagation()} + > + + e.stopPropagation()}> { + 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 }; +} + /** * 공정 옵션 목록 (드롭다운용) */ diff --git a/src/lib/utils/menuRefresh.ts b/src/lib/utils/menuRefresh.ts index be2f165f..61a9eaa1 100644 --- a/src/lib/utils/menuRefresh.ts +++ b/src/lib/utils/menuRefresh.ts @@ -91,7 +91,9 @@ export async function refreshMenus(): Promise { const data = await response.json(); - if (!data.menus || !Array.isArray(data.menus)) { + // 백엔드 ApiResponse::success() 응답 형식: { success, message, data: [...] } + const apiMenus = data.data; + if (!apiMenus || !Array.isArray(apiMenus)) { return { success: false, updated: false, @@ -100,7 +102,7 @@ export async function refreshMenus(): Promise { } // 3. 메뉴 변환 - const transformedMenus = transformApiMenusToMenuItems(data.menus); + const transformedMenus = transformApiMenusToMenuItems(apiMenus); const newHash = generateMenuHash(transformedMenus); // 4. 변경 없으면 업데이트 스킵 @@ -159,7 +161,9 @@ export async function forceRefreshMenus(): Promise { const data = await response.json(); - if (!data.menus || !Array.isArray(data.menus)) { + // 백엔드 ApiResponse::success() 응답 형식: { success, message, data: [...] } + const apiMenus = data.data; + if (!apiMenus || !Array.isArray(apiMenus)) { return { success: false, updated: false, @@ -167,7 +171,7 @@ export async function forceRefreshMenus(): Promise { }; } - const transformedMenus = transformApiMenusToMenuItems(data.menus); + const transformedMenus = transformApiMenusToMenuItems(apiMenus); // localStorage 업데이트 const userData = localStorage.getItem('user'); diff --git a/src/types/checklist.ts b/src/types/checklist.ts new file mode 100644 index 00000000..b838e93a --- /dev/null +++ b/src/types/checklist.ts @@ -0,0 +1,73 @@ +/** + * 점검표 관리 타입 정의 + */ + +// ============================================================================ +// 점검표 (Checklist) +// ============================================================================ + +export interface Checklist { + id: string; + checklistCode: string; // 점검표 번호 + checklistName: string; // 점검표명 + itemCount: number; // 항목 수 + documentCount: number; // 문서 수 + status: '사용' | '미사용'; + order: number; // 정렬 순서 + items?: ChecklistItem[]; // 하위 항목 목록 + createdAt: string; + updatedAt: string; +} + +export interface ChecklistFormData { + checklistName: string; + status: '사용' | '미사용'; +} + +// ============================================================================ +// 점검표 항목 (Checklist Item) +// ============================================================================ + +export interface ChecklistItem { + id: string; + checklistId: string; // 소속 점검표 ID + itemCode: string; // 항목 번호 + itemName: string; // 항목명 + description: string; // 소개 + documentCount: number; // 문서 수 + status: '사용' | '미사용'; + order: number; // 정렬 순서 + documents?: ChecklistDocument[]; // 하위 문서 목록 + createdAt: string; + updatedAt: string; +} + +export interface ChecklistItemFormData { + itemName: string; + description: string; + status: '사용' | '미사용'; + documents: ChecklistDocumentFormData[]; +} + +// ============================================================================ +// 점검표 문서 (Checklist Document) +// ============================================================================ + +export interface ChecklistDocument { + id: string; + itemId: string; // 소속 항목 ID + documentCode: string; // 문서 번호 + documentName: string; // 문서명 (파일명) + revision: string; // 개정 (REV12 등) + effectiveDate: string; // 시행일 + order: number; // 정렬 순서 +} + +export interface ChecklistDocumentFormData { + id?: string; + documentCode: string; + documentName: string; + revision: string; + effectiveDate: string; + order: number; +} \ No newline at end of file