From e9ac2470e1e46f968ad2debb5f5c1f2c5eb4c112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 11 Mar 2026 11:06:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20QMS=20=EC=B2=B4=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EA=B2=AC=EC=A0=81/=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QMS 체크리스트 템플릿 에디터 추가 (ChecklistTemplateEditor) - AuditSettingsPanel, Day1DocumentSection 기능 확장 - 견적 등록(QuoteRegistration) 개선 - IntegratedListTemplateV2 수정 - 건설 카테고리 actions 수정 --- ...3-11] qms-checklist-template-management.md | 96 +++ .../qms/components/AuditSettingsPanel.tsx | 298 ++++++--- .../components/ChecklistTemplateEditor.tsx | 605 ++++++++++++++++++ .../qms/components/Day1DocumentSection.tsx | 174 ++++- .../quality/qms/hooks/useChecklistTemplate.ts | 218 +++++++ .../[locale]/(protected)/quality/qms/page.tsx | 24 + .../[locale]/(protected)/quality/qms/types.ts | 19 + .../category-management/actions.ts | 3 +- src/components/quotes/QuoteRegistration.tsx | 55 +- .../templates/IntegratedListTemplateV2.tsx | 22 +- 10 files changed, 1382 insertions(+), 132 deletions(-) create mode 100644 claudedocs/[FEAT-2026-03-11] qms-checklist-template-management.md create mode 100644 src/app/[locale]/(protected)/quality/qms/components/ChecklistTemplateEditor.tsx create mode 100644 src/app/[locale]/(protected)/quality/qms/hooks/useChecklistTemplate.ts diff --git a/claudedocs/[FEAT-2026-03-11] qms-checklist-template-management.md b/claudedocs/[FEAT-2026-03-11] qms-checklist-template-management.md new file mode 100644 index 00000000..8d43b78a --- /dev/null +++ b/claudedocs/[FEAT-2026-03-11] qms-checklist-template-management.md @@ -0,0 +1,96 @@ +# QMS 점검표 항목 관리 기능 + +## 개요 +품질인정심사 시스템(QMS)의 "화면 설정" 패널에 **점검표 항목 관리** 섹션을 추가하여, +카테고리/항목의 CRUD + 순서 변경 + 버전 관리를 지원한다. + +## 현재 구조 +- 점검표 데이터: `MOCK_DAY1_CATEGORIES` (mockData.ts) — Mock 상태 +- 타입: `ChecklistCategory` → `ChecklistSubItem[]` +- 설정 패널: `AuditSettingsPanel.tsx` — 레이아웃/점검표 옵션 토글만 존재 +- 데이터 훅: `useDay1Audit.ts` — `USE_MOCK = true` + +## 구현 범위 + +### 1. 점검표 템플릿 관리 UI (화면 설정 패널 내) +**위치**: AuditSettingsPanel → 새 섹션 "점검표 항목 관리" + +**기능**: +- 현재 버전 표시 + 버전 이력 드롭다운 +- 카테고리 CRUD (추가/수정/삭제) +- 하위 항목 CRUD (추가/수정/삭제) +- 순서 변경 (위/아래 버튼 — 드래그앤드롭 라이브러리 미사용) +- "저장 (새 버전 생성)" 버튼 → API 호출 +- "초기화" 버튼 → 마지막 저장 상태로 복원 + +### 2. 데이터 구조 (프론트) + +```typescript +// 점검표 템플릿 버전 +interface ChecklistTemplateVersion { + id: string; + version: number; + createdAt: string; + createdBy: string; + description?: string; // 변경 사유 +} + +// 점검표 템플릿 (API 응답) +interface ChecklistTemplate { + id: string; + currentVersion: number; + categories: ChecklistCategory[]; // 기존 타입 재사용 + versions: ChecklistTemplateVersion[]; +} +``` + +### 3. API 엔드포인트 (Mock → 추후 연동) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/v1/qms/checklist-templates/current` | 현재 템플릿 조회 | +| POST | `/api/v1/qms/checklist-templates` | 새 버전 저장 | +| GET | `/api/v1/qms/checklist-templates/versions` | 버전 이력 조회 | +| GET | `/api/v1/qms/checklist-templates/versions/:id` | 특정 버전 조회 | +| POST | `/api/v1/qms/checklist-templates/versions/:id/restore` | 버전 복원 | + +### 4. UI 구성 (설정 패널 내) + +``` +━━ 점검표 항목 관리 ━━ + +[v3 (2026-03-10) ▾] ← 버전 셀렉트 (이력 조회/복원) + +── 카테고리 ── +┌─────────────────────────────────────┐ +│ [⬆][⬇] 1. 원재료 품질관리 기준 [✏️][🗑] │ +│ [⬆][⬇] 수입검사 기준 확인 [✏️][🗑] │ +│ [⬆][⬇] 불합격품 처리 기준 확인 [✏️][🗑] │ +│ [⬆][⬇] 자재 보관 기준 확인 [✏️][🗑] │ +│ [+ 항목 추가] │ +├─────────────────────────────────────┤ +│ [⬆][⬇] 2. 제조공정 관리 기준 [✏️][🗑] │ +│ ... │ +└─────────────────────────────────────┘ +[+ 카테고리 추가] + +━━━━━━━━━━━━━━━━━━━━━━━━ +[초기화] [저장 (새 버전)] +``` + +### 5. 작업 목록 + +- [ ] types.ts에 템플릿 관련 타입 추가 +- [ ] ChecklistTemplateEditor 컴포넌트 생성 (편집 UI) +- [ ] AuditSettingsPanel에 탭/섹션 추가 ("화면 설정" / "점검표 관리") +- [ ] useChecklistTemplate 훅 생성 (상태 관리 + Mock 데이터) +- [ ] page.tsx 연동 (훅 → 설정 패널 props) +- [ ] 버전 이력 UI (Select 드롭다운 + 복원 확인) + +### 6. 설계 결정 + +- **드래그앤드롭 미사용**: 패키지 추가 없이 ⬆⬇ 버튼으로 순서 변경 +- **설정 패널 분리**: 기존 "화면 설정"과 "점검표 관리"를 탭으로 분리 +- **Mock 우선**: `USE_MOCK = true`로 시작, API 연동 시 교체 +- **인라인 편집**: 항목명 클릭 시 input으로 전환 (별도 모달 없음) +- **낙관적 업데이트**: 로컬 편집 → 저장 버튼 클릭 시 한번에 API 호출 diff --git a/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx index d5667fc7..362a5978 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx @@ -1,8 +1,11 @@ 'use client'; -import React from 'react'; -import { Settings, X, Eye, EyeOff } from 'lucide-react'; +import React, { useState } from 'react'; +import { Settings, X, Eye, EyeOff, ListChecks } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import { ChecklistTemplateEditor } from './ChecklistTemplateEditor'; +import type { ChecklistCategory, ChecklistTemplateVersion } from '../types'; export interface AuditDisplaySettings { showProgressBar: boolean; @@ -12,19 +15,47 @@ export interface AuditDisplaySettings { expandAllCategories: boolean; } +// 점검표 관리 props +export interface ChecklistManagementProps { + categories: ChecklistCategory[]; + versions: ChecklistTemplateVersion[]; + currentVersion: number; + hasChanges: boolean; + saving: boolean; + onAddCategory: () => void; + onUpdateCategoryTitle: (categoryId: string, title: string) => void; + onDeleteCategory: (categoryId: string) => void; + onMoveCategoryUp: (index: number) => void; + onMoveCategoryDown: (index: number) => void; + onAddSubItem: (categoryId: string) => void; + onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void; + onDeleteSubItem: (categoryId: string, subItemId: string) => void; + onMoveSubItemUp: (categoryId: string, index: number) => void; + onMoveSubItemDown: (categoryId: string, index: number) => void; + onSave: (description?: string) => void; + onReset: () => void; + onRestoreVersion: (versionId: string) => void; +} + interface AuditSettingsPanelProps { isOpen: boolean; onClose: () => void; settings: AuditDisplaySettings; onSettingsChange: (settings: AuditDisplaySettings) => void; + checklistManagement?: ChecklistManagementProps; } +type TabType = 'display' | 'checklist'; + export function AuditSettingsPanel({ isOpen, onClose, settings, onSettingsChange, + checklistManagement, }: AuditSettingsPanelProps) { + const [activeTab, setActiveTab] = useState('display'); + const handleToggle = (key: keyof AuditDisplaySettings) => { onSettingsChange({ ...settings, @@ -48,7 +79,7 @@ export function AuditSettingsPanel({
-

화면 설정

+

설정

- {/* 설정 항목 */} -
- {/* 레이아웃 섹션 */} -
-

레이아웃

-
- handleToggle('showProgressBar')} - /> - handleToggle('showDocumentViewer')} - /> - handleToggle('showDocumentSection')} - /> -
-
- - {/* 구분선 */} -
- - {/* 점검표 섹션 */} -
-

점검표 옵션

-
- handleToggle('showCompletedItems')} - /> - handleToggle('expandAllCategories')} - /> -
-
- - {/* 구분선 */} -
- - {/* 빠른 설정 */} -
-

빠른 설정

-
- - -
-
+ {/* 탭 */} +
+ +
- {/* 하단 안내 */} -
-

- 설정은 자동으로 저장됩니다 -

+ {/* 탭 컨텐츠 */} +
+ {activeTab === 'display' ? ( + + ) : checklistManagement ? ( + + ) : ( +
+ 점검표 관리 데이터를 불러오는 중... +
+ )} +
+ + {/* 하단 안내 (화면 설정 탭일 때만) */} + {activeTab === 'display' && ( +
+

+ 설정은 자동으로 저장됩니다 +

+
+ )} +
+
+ ); +} + +// ===== 화면 설정 탭 컨텐츠 (기존 코드 분리) ===== + +interface DisplaySettingsContentProps { + settings: AuditDisplaySettings; + onToggle: (key: keyof AuditDisplaySettings) => void; + onSettingsChange: (settings: AuditDisplaySettings) => void; +} + +function DisplaySettingsContent({ settings, onToggle, onSettingsChange }: DisplaySettingsContentProps) { + return ( +
+ {/* 레이아웃 섹션 */} +
+

레이아웃

+
+ onToggle('showProgressBar')} + /> + onToggle('showDocumentViewer')} + /> + onToggle('showDocumentSection')} + /> +
+
+ + {/* 구분선 */} +
+ + {/* 점검표 섹션 */} +
+

점검표 옵션

+
+ onToggle('showCompletedItems')} + /> + onToggle('expandAllCategories')} + /> +
+
+ + {/* 구분선 */} +
+ + {/* 빠른 설정 */} +
+

빠른 설정

+
+ +
); } +// ===== 공통 설정 행 ===== + interface SettingRowProps { label: string; description: string; @@ -202,4 +314,4 @@ export function SettingsButton({ onClick }: SettingsButtonProps) { 화면 설정 ); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/ChecklistTemplateEditor.tsx b/src/app/[locale]/(protected)/quality/qms/components/ChecklistTemplateEditor.tsx new file mode 100644 index 00000000..1f8d8ee3 --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/ChecklistTemplateEditor.tsx @@ -0,0 +1,605 @@ +'use client'; + +import React, { useState, useMemo, useRef, useEffect } from 'react'; +import { + ChevronUp, + ChevronDown, + Pencil, + Trash2, + Plus, + Check, + X, + ChevronRight, + Save, + RotateCcw, + History, + Search, + ChevronsUpDown, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ChecklistCategory, ChecklistSubItem, ChecklistTemplateVersion } from '../types'; + +interface ChecklistTemplateEditorProps { + categories: ChecklistCategory[]; + versions: ChecklistTemplateVersion[]; + currentVersion: number; + hasChanges: boolean; + saving: boolean; + // 카테고리 + onAddCategory: () => void; + onUpdateCategoryTitle: (categoryId: string, title: string) => void; + onDeleteCategory: (categoryId: string) => void; + onMoveCategoryUp: (index: number) => void; + onMoveCategoryDown: (index: number) => void; + // 하위 항목 + onAddSubItem: (categoryId: string) => void; + onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void; + onDeleteSubItem: (categoryId: string, subItemId: string) => void; + onMoveSubItemUp: (categoryId: string, index: number) => void; + onMoveSubItemDown: (categoryId: string, index: number) => void; + // 저장/초기화/복원 + onSave: (description?: string) => void; + onReset: () => void; + onRestoreVersion: (versionId: string) => void; +} + +export function ChecklistTemplateEditor({ + categories, + versions, + currentVersion, + hasChanges, + saving, + onAddCategory, + onUpdateCategoryTitle, + onDeleteCategory, + onMoveCategoryUp, + onMoveCategoryDown, + onAddSubItem, + onUpdateSubItemName, + onDeleteSubItem, + onMoveSubItemUp, + onMoveSubItemDown, + onSave, + onReset, + onRestoreVersion, +}: ChecklistTemplateEditorProps) { + const [expandedCategories, setExpandedCategories] = useState>( + new Set(categories.map(c => c.id)) + ); + + const toggleExpand = (categoryId: string) => { + setExpandedCategories(prev => { + const next = new Set(prev); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + return next; + }); + }; + + return ( +
+ {/* 버전 셀렉트박스 */} + + + {/* 카테고리 목록 */} +
+ {categories.map((category, catIdx) => ( + toggleExpand(category.id)} + onUpdateTitle={(title) => onUpdateCategoryTitle(category.id, title)} + onDelete={() => onDeleteCategory(category.id)} + onMoveUp={() => onMoveCategoryUp(catIdx)} + onMoveDown={() => onMoveCategoryDown(catIdx)} + onAddSubItem={() => onAddSubItem(category.id)} + onUpdateSubItemName={(subItemId, name) => onUpdateSubItemName(category.id, subItemId, name)} + onDeleteSubItem={(subItemId) => onDeleteSubItem(category.id, subItemId)} + onMoveSubItemUp={(idx) => onMoveSubItemUp(category.id, idx)} + onMoveSubItemDown={(idx) => onMoveSubItemDown(category.id, idx)} + /> + ))} +
+ + {/* 카테고리 추가 */} + + + {/* 저장/초기화 */} +
+ + +
+ + {hasChanges && ( +

+ 저장하지 않은 변경사항이 있습니다 +

+ )} +
+ ); +} + +// ===== 카테고리 편집 ===== + +interface CategoryEditorProps { + category: ChecklistCategory; + index: number; + isFirst: boolean; + isLast: boolean; + isExpanded: boolean; + onToggleExpand: () => void; + onUpdateTitle: (title: string) => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + onAddSubItem: () => void; + onUpdateSubItemName: (subItemId: string, name: string) => void; + onDeleteSubItem: (subItemId: string) => void; + onMoveSubItemUp: (index: number) => void; + onMoveSubItemDown: (index: number) => void; +} + +function CategoryEditor({ + category, + index, + isFirst, + isLast, + isExpanded, + onToggleExpand, + onUpdateTitle, + onDelete, + onMoveUp, + onMoveDown, + onAddSubItem, + onUpdateSubItemName, + onDeleteSubItem, + onMoveSubItemUp, + onMoveSubItemDown, +}: CategoryEditorProps) { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(category.title); + + const handleSaveTitle = () => { + const trimmed = editValue.trim(); + if (trimmed) { + onUpdateTitle(trimmed); + } else { + setEditValue(category.title); + } + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSaveTitle(); + if (e.key === 'Escape') { + setEditValue(category.title); + setEditing(false); + } + }; + + return ( +
+ {/* 카테고리 헤더 */} +
+ {/* 순서 변경 */} +
+ + +
+ + {/* 펼치기/접기 */} + + + {/* 제목 */} + {editing ? ( +
+ setEditValue(e.target.value)} + onBlur={handleSaveTitle} + onKeyDown={handleKeyDown} + className="flex-1 text-xs px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400" + autoFocus + /> + + +
+ ) : ( + + {index + 1}. {category.title} + + )} + + {/* 항목 수 */} + + {category.subItems.length} + + + {/* 편집/삭제 */} + {!editing && ( + <> + + + + )} +
+ + {/* 하위 항목 */} + {isExpanded && ( +
+ {category.subItems.map((subItem, subIdx) => ( + onUpdateSubItemName(subItem.id, name)} + onDelete={() => onDeleteSubItem(subItem.id)} + onMoveUp={() => onMoveSubItemUp(subIdx)} + onMoveDown={() => onMoveSubItemDown(subIdx)} + /> + ))} + + {/* 항목 추가 */} + +
+ )} +
+ ); +} + +// ===== 하위 항목 편집 ===== + +interface SubItemEditorProps { + subItem: ChecklistSubItem; + index: number; + isFirst: boolean; + isLast: boolean; + onUpdateName: (name: string) => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +function SubItemEditor({ + subItem, + index, + isFirst, + isLast, + onUpdateName, + onDelete, + onMoveUp, + onMoveDown, +}: SubItemEditorProps) { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(subItem.name); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed) { + onUpdateName(trimmed); + } else { + setEditValue(subItem.name); + } + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') { + setEditValue(subItem.name); + setEditing(false); + } + }; + + return ( +
+ {/* 순서 변경 */} +
+ + +
+ + {/* 이름 */} + {editing ? ( +
+ setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="flex-1 text-[11px] px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400" + autoFocus + /> + +
+ ) : ( + { setEditValue(subItem.name); setEditing(true); }} + > + {subItem.name} + + )} + + {/* 편집/삭제 */} + {!editing && ( + <> + + + + )} +
+ ); +} + +// ===== 버전 검색 셀렉트박스 ===== + +interface VersionSelectBoxProps { + versions: ChecklistTemplateVersion[]; + currentVersion: number; + onRestoreVersion: (versionId: string) => void; +} + +function VersionSelectBox({ versions, currentVersion, onRestoreVersion }: VersionSelectBoxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const containerRef = useRef(null); + const searchInputRef = useRef(null); + + // 외부 클릭 시 닫기 + useEffect(() => { + if (!open) return; + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + setSearch(''); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + // 열릴 때 검색 input 포커스 + useEffect(() => { + if (open) searchInputRef.current?.focus(); + }, [open]); + + const currentV = versions.find(v => v.version === currentVersion); + + const filteredVersions = useMemo(() => { + if (!search.trim()) return versions; + const term = search.toLowerCase(); + return versions.filter(v => + `v${v.version}`.includes(term) || + v.createdAt.includes(term) || + v.createdBy.toLowerCase().includes(term) || + (v.description?.toLowerCase().includes(term)) + ); + }, [versions, search]); + + const handleRestore = (versionId: string) => { + onRestoreVersion(versionId); + setOpen(false); + setSearch(''); + }; + + return ( +
+ {/* 트리거 버튼 */} + + + {/* 드롭다운 */} + {open && ( +
+ {/* 검색 */} +
+
+ + setSearch(e.target.value)} + placeholder="버전, 날짜, 설명 검색..." + className="w-full pl-7 pr-2 py-1.5 text-xs border border-gray-200 rounded-md focus:outline-none focus:border-blue-300 focus:ring-1 focus:ring-blue-200" + /> + {search && ( + + )} +
+
+ + {/* 버전 목록 */} +
+ {filteredVersions.length === 0 ? ( +
+ 검색 결과가 없습니다 +
+ ) : ( + filteredVersions.map(v => { + const isCurrent = v.version === currentVersion; + return ( +
!isCurrent && handleRestore(v.id)} + > +
+
+ + v{v.version} + + {isCurrent && ( + + 현재 + + )} + {v.createdAt} + ({v.createdBy}) +
+ {v.description && ( +

{v.description}

+ )} +
+ {!isCurrent && ( + + 복원 + + )} +
+ ); + }) + )} +
+ + {/* 하단 카운트 */} +
+ + 전체 {versions.length}개 버전 + {search && ` / ${filteredVersions.length}개 검색됨`} + +
+
+ )} +
+ ); +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx index 81e8b588..ee1769dd 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx @@ -1,11 +1,23 @@ 'use client'; -import React from 'react'; -import { FileText, Download, Eye, CheckCircle2 } from 'lucide-react'; +import React, { useState, useRef, useCallback } from 'react'; +import { FileText, Download, Eye, CheckCircle2, Upload, X, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; import type { Day1CheckItem, StandardDocument } from '../types'; +const ACCEPTED_EXTENSIONS = '.pdf,.xlsx,.xls,.doc,.docx,.hwp'; +const ACCEPTED_MIME = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/haansofthwp', +]; +const MAX_FILE_SIZE_MB = 20; + interface Day1DocumentSectionProps { checkItem: Day1CheckItem | null; selectedDocumentId: string | null; @@ -13,6 +25,7 @@ interface Day1DocumentSectionProps { onConfirmComplete: () => void; isCompleted: boolean; isMock?: boolean; + onFileUpload?: (subItemId: string, file: File) => void; } export function Day1DocumentSection({ @@ -22,6 +35,7 @@ export function Day1DocumentSection({ onConfirmComplete, isCompleted, isMock, + onFileUpload, }: Day1DocumentSectionProps) { if (!checkItem) { return ( @@ -69,6 +83,11 @@ export function Day1DocumentSection({ /> ))}
+ + {/* 파일 업로드 */} + onFileUpload?.(checkItem.subItemId, file)} + />
{/* 확인 버튼 */} @@ -163,4 +182,155 @@ function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
); +} + +// ===== 파일 업로드 영역 ===== + +interface DocumentUploadAreaProps { + onUpload: (file: File) => void; +} + +function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) { + const [isDragging, setIsDragging] = useState(false); + const [uploading, setUploading] = useState(false); + const [pendingFile, setPendingFile] = useState(null); + const fileInputRef = useRef(null); + + const validateFile = useCallback((file: File): string | null => { + const sizeMB = file.size / (1024 * 1024); + if (sizeMB > MAX_FILE_SIZE_MB) { + return `파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하여야 합니다.`; + } + // 확장자 체크 + const ext = file.name.split('.').pop()?.toLowerCase(); + const allowed = ['pdf', 'xlsx', 'xls', 'doc', 'docx', 'hwp']; + if (!ext || !allowed.includes(ext)) { + return 'PDF, Excel, Word, HWP 파일만 업로드 가능합니다.'; + } + return null; + }, []); + + const handleFile = useCallback((file: File) => { + const error = validateFile(file); + if (error) { + toast.error(error); + return; + } + setPendingFile(file); + }, [validateFile]); + + const handleConfirmUpload = useCallback(async () => { + if (!pendingFile) return; + setUploading(true); + try { + // Mock: 0.5초 딜레이 + await new Promise(resolve => setTimeout(resolve, 500)); + onUpload(pendingFile); + toast.success(`${pendingFile.name} 업로드 완료 (Mock)`); + setPendingFile(null); + } finally { + setUploading(false); + } + }, [pendingFile, onUpload]); + + const handleCancelUpload = useCallback(() => { + setPendingFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleFile(file); + }; + + // 선택된 파일 미리보기 + if (pendingFile) { + const ext = pendingFile.name.split('.').pop()?.toLowerCase(); + const sizeMB = (pendingFile.size / (1024 * 1024)).toFixed(1); + return ( +
+
+
+ +
+
+

{pendingFile.name}

+

{sizeMB} MB

+
+ +
+
+ + +
+
+ ); + } + + return ( + <> + +
{ e.preventDefault(); e.stopPropagation(); setIsDragging(true); }} + onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }} + onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + className={cn( + 'mt-2 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border-2 border-dashed cursor-pointer transition-colors', + isDragging + ? 'border-blue-400 bg-blue-50' + : 'border-gray-200 hover:border-blue-300 hover:bg-gray-50' + )} + > + + + 파일 업로드 (PDF, Excel, Word, HWP) + +
+ + ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/hooks/useChecklistTemplate.ts b/src/app/[locale]/(protected)/quality/qms/hooks/useChecklistTemplate.ts new file mode 100644 index 00000000..ba9bc9ed --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/hooks/useChecklistTemplate.ts @@ -0,0 +1,218 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { toast } from 'sonner'; +import type { ChecklistCategory, ChecklistSubItem, ChecklistTemplate, ChecklistTemplateVersion } from '../types'; +import { MOCK_DAY1_CATEGORIES } from '../mockData'; + +const USE_MOCK = true; + +// Mock 버전 데이터 +const MOCK_VERSIONS: ChecklistTemplateVersion[] = [ + { id: 'v8', version: 8, createdAt: '2026-03-10', createdBy: '홍길동', description: '검사설비 항목 추가' }, + { id: 'v7', version: 7, createdAt: '2026-03-05', createdBy: '김영수', description: '문서관리 기준 보완' }, + { id: 'v6', version: 6, createdAt: '2026-02-28', createdBy: '홍길동', description: '클레임 처리 항목 신규' }, + { id: 'v5', version: 5, createdAt: '2026-02-20', createdBy: '이민정', description: '출하검사 기준 수정' }, + { id: 'v4', version: 4, createdAt: '2026-02-15', createdBy: '홍길동', description: '제조공정 기준 세분화' }, + { id: 'v3', version: 3, createdAt: '2026-02-01', createdBy: '박서연', description: 'KS인증 항목 반영' }, + { id: 'v2', version: 2, createdAt: '2026-01-25', createdBy: '김영수', description: '설비점검 이력 추가' }, + { id: 'v1', version: 1, createdAt: '2026-01-20', createdBy: '홍길동', description: '초기 점검표 생성' }, +]; + +function generateId() { + return `item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + +export function useChecklistTemplate() { + // 편집 중인 카테고리 (원본 복사본) + const [editCategories, setEditCategories] = useState( + () => structuredClone(MOCK_DAY1_CATEGORIES) + ); + // 마지막 저장 상태 (초기화용) + const savedRef = useRef(structuredClone(MOCK_DAY1_CATEGORIES)); + + const [versions] = useState(MOCK_VERSIONS); + const [currentVersion, setCurrentVersion] = useState(8); + const [saving, setSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + // === 변경 추적 === + const markChanged = useCallback(() => setHasChanges(true), []); + + // === 카테고리 CRUD === + const addCategory = useCallback(() => { + setEditCategories(prev => [ + ...prev, + { id: generateId(), title: '새 카테고리', subItems: [] }, + ]); + markChanged(); + }, [markChanged]); + + const updateCategoryTitle = useCallback((categoryId: string, title: string) => { + setEditCategories(prev => + prev.map(cat => cat.id === categoryId ? { ...cat, title } : cat) + ); + markChanged(); + }, [markChanged]); + + const deleteCategory = useCallback((categoryId: string) => { + setEditCategories(prev => prev.filter(cat => cat.id !== categoryId)); + markChanged(); + }, [markChanged]); + + // === 카테고리 순서 변경 === + const moveCategoryUp = useCallback((index: number) => { + if (index <= 0) return; + setEditCategories(prev => { + const next = [...prev]; + [next[index - 1], next[index]] = [next[index], next[index - 1]]; + return next; + }); + markChanged(); + }, [markChanged]); + + const moveCategoryDown = useCallback((index: number) => { + setEditCategories(prev => { + if (index >= prev.length - 1) return prev; + const next = [...prev]; + [next[index], next[index + 1]] = [next[index + 1], next[index]]; + return next; + }); + markChanged(); + }, [markChanged]); + + // === 하위 항목 CRUD === + const addSubItem = useCallback((categoryId: string) => { + setEditCategories(prev => + prev.map(cat => { + if (cat.id !== categoryId) return cat; + return { + ...cat, + subItems: [ + ...cat.subItems, + { id: generateId(), name: '새 항목', isCompleted: false }, + ], + }; + }) + ); + markChanged(); + }, [markChanged]); + + const updateSubItemName = useCallback((categoryId: string, subItemId: string, name: string) => { + setEditCategories(prev => + prev.map(cat => { + if (cat.id !== categoryId) return cat; + return { + ...cat, + subItems: cat.subItems.map(item => + item.id === subItemId ? { ...item, name } : item + ), + }; + }) + ); + markChanged(); + }, [markChanged]); + + const deleteSubItem = useCallback((categoryId: string, subItemId: string) => { + setEditCategories(prev => + prev.map(cat => { + if (cat.id !== categoryId) return cat; + return { + ...cat, + subItems: cat.subItems.filter(item => item.id !== subItemId), + }; + }) + ); + markChanged(); + }, [markChanged]); + + // === 하위 항목 순서 변경 === + const moveSubItemUp = useCallback((categoryId: string, index: number) => { + if (index <= 0) return; + setEditCategories(prev => + prev.map(cat => { + if (cat.id !== categoryId) return cat; + const items = [...cat.subItems]; + [items[index - 1], items[index]] = [items[index], items[index - 1]]; + return { ...cat, subItems: items }; + }) + ); + markChanged(); + }, [markChanged]); + + const moveSubItemDown = useCallback((categoryId: string, index: number) => { + setEditCategories(prev => + prev.map(cat => { + if (cat.id !== categoryId) return cat; + if (index >= cat.subItems.length - 1) return cat; + const items = [...cat.subItems]; + [items[index], items[index + 1]] = [items[index + 1], items[index]]; + return { ...cat, subItems: items }; + }) + ); + markChanged(); + }, [markChanged]); + + // === 저장 === + const saveTemplate = useCallback(async (description?: string) => { + if (USE_MOCK) { + setSaving(true); + // Mock: 0.5초 딜레이 + await new Promise(resolve => setTimeout(resolve, 500)); + savedRef.current = structuredClone(editCategories); + setCurrentVersion(prev => prev + 1); + setHasChanges(false); + setSaving(false); + toast.success(`점검표 v${currentVersion + 1} 저장 완료`); + return editCategories; + } + + // TODO: API 연동 + // const result = await saveChecklistTemplate({ categories: editCategories, description }); + return editCategories; + }, [editCategories, currentVersion]); + + // === 초기화 === + const resetToSaved = useCallback(() => { + setEditCategories(structuredClone(savedRef.current)); + setHasChanges(false); + }, []); + + // === 버전 복원 === + const restoreVersion = useCallback(async (versionId: string) => { + if (USE_MOCK) { + // Mock: 버전에 상관없이 현재 데이터 유지 (실제로는 API에서 해당 버전 데이터 조회) + toast.success('해당 버전으로 복원되었습니다. (Mock)'); + return; + } + // TODO: API 연동 + }, []); + + return { + // 데이터 + editCategories, + versions, + currentVersion, + hasChanges, + saving, + + // 카테고리 + addCategory, + updateCategoryTitle, + deleteCategory, + moveCategoryUp, + moveCategoryDown, + + // 하위 항목 + addSubItem, + updateSubItemName, + deleteSubItem, + moveSubItemUp, + moveSubItemDown, + + // 저장/초기화 + saveTemplate, + resetToSaved, + restoreVersion, + }; +} diff --git a/src/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index 7596b216..b05127a3 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -14,6 +14,7 @@ import { Day1DocumentViewer } from './components/Day1DocumentViewer'; import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel'; import { useDay1Audit } from './hooks/useDay1Audit'; import { useDay2LotAudit } from './hooks/useDay2LotAudit'; +import { useChecklistTemplate } from './hooks/useChecklistTemplate'; // 기본 설정값 const DEFAULT_SETTINGS: AuditDisplaySettings = { @@ -48,6 +49,9 @@ export default function QualityInspectionPage() { isMock: day1IsMock, } = useDay1Audit(); + // 점검표 템플릿 관리 훅 + const checklistTemplate = useChecklistTemplate(); + // 2일차 커스텀 훅 const { selectedYear, @@ -222,6 +226,26 @@ export default function QualityInspectionPage() { onClose={() => setSettingsOpen(false)} settings={displaySettings} onSettingsChange={setDisplaySettings} + checklistManagement={{ + categories: checklistTemplate.editCategories, + versions: checklistTemplate.versions, + currentVersion: checklistTemplate.currentVersion, + hasChanges: checklistTemplate.hasChanges, + saving: checklistTemplate.saving, + onAddCategory: checklistTemplate.addCategory, + onUpdateCategoryTitle: checklistTemplate.updateCategoryTitle, + onDeleteCategory: checklistTemplate.deleteCategory, + onMoveCategoryUp: checklistTemplate.moveCategoryUp, + onMoveCategoryDown: checklistTemplate.moveCategoryDown, + onAddSubItem: checklistTemplate.addSubItem, + onUpdateSubItemName: checklistTemplate.updateSubItemName, + onDeleteSubItem: checklistTemplate.deleteSubItem, + onMoveSubItemUp: checklistTemplate.moveSubItemUp, + onMoveSubItemDown: checklistTemplate.moveSubItemDown, + onSave: checklistTemplate.saveTemplate, + onReset: checklistTemplate.resetToSaved, + onRestoreVersion: checklistTemplate.restoreVersion, + }} /> ('/categories', { params: { per_page: '100' } }); - const categories = (response.data || []) + const rawData = Array.isArray(response.data) ? response.data : (response.data as unknown as { data: ApiCategory[] })?.data || []; + const categories = rawData .map(transformCategory) .sort((a, b) => a.order - b.order); diff --git a/src/components/quotes/QuoteRegistration.tsx b/src/components/quotes/QuoteRegistration.tsx index 36729a5c..f3883e85 100644 --- a/src/components/quotes/QuoteRegistration.tsx +++ b/src/components/quotes/QuoteRegistration.tsx @@ -25,7 +25,7 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -// FormField는 실제로 사용되지 않으므로 제거 +import { SearchableSelect } from "../ui/searchable-select"; import { LocationListPanel } from "./LocationListPanel"; import { LocationDetailPanel } from "./LocationDetailPanel"; @@ -433,16 +433,6 @@ export function QuoteRegistration({ setFormData((prev) => ({ ...prev, [field]: value })); }, []); - // 발주처 선택 - const handleClientChange = useCallback((clientId: string) => { - const client = clients.find((c) => c.id === clientId); - setFormData((prev) => ({ - ...prev, - clientId, - clientName: client?.vendorName || "", - })); - }, [clients]); - // 개소 추가 (BOM 계산 성공 시에만 추가, 성공/실패 반환) const handleAddLocation = useCallback(async (location: Omit): Promise => { const newLocation: LocationItem = { @@ -774,37 +764,34 @@ export function QuoteRegistration({
- + isLoading={isLoadingClients} + />
- ({ value: name, label: name }))} value={formData.siteName} - onChange={(e) => handleFieldChange("siteName", e.target.value)} + onChange={(value) => handleFieldChange("siteName", value)} + placeholder="현장명을 선택하세요" + searchPlaceholder="현장명 검색..." + emptyText="현장명이 없습니다" disabled={isViewMode} /> - - {siteNames.map((name) => ( -
diff --git a/src/components/templates/IntegratedListTemplateV2.tsx b/src/components/templates/IntegratedListTemplateV2.tsx index 29891862..1fec1a2b 100644 --- a/src/components/templates/IntegratedListTemplateV2.tsx +++ b/src/components/templates/IntegratedListTemplateV2.tsx @@ -977,8 +977,8 @@ export function IntegratedListTemplateV2({ showActions={tableColumns.some(col => col.key === 'actions')} /> ) : ( - - {columnSettings && ( +
0 ? "table-fixed" : "table-auto [&_td]:whitespace-nowrap [&_th]:whitespace-nowrap"}> + {columnSettings && Object.keys(columnSettings.columnWidths).length > 0 && ( {showCheckbox && } {tableColumns.map((col) => ( @@ -1046,6 +1046,24 @@ export function IntegratedListTemplateV2({ e.preventDefault(); const th = (e.target as HTMLElement).parentElement; if (!th) return; + + // 첫 리사이즈 시 모든 컬럼의 현재 너비를 스냅샷 저장 + // → table-auto → table-fixed 전환 시 다른 컬럼 크기 유지 + if (Object.keys(columnSettings.columnWidths).length === 0) { + const headerRow = th.parentElement; + if (headerRow) { + const allThs = headerRow.querySelectorAll('th'); + const offset = showCheckbox ? 1 : 0; + allThs.forEach((cell, idx) => { + if (idx < offset) return; + const colKey = tableColumns[idx - offset]?.key; + if (colKey) { + columnSettings.onColumnResize(colKey, cell.offsetWidth); + } + }); + } + } + const startX = e.clientX; const startWidth = th.offsetWidth; const onMouseMove = (ev: MouseEvent) => {