feat: QMS 체크리스트 템플릿 관리 및 견적/리스트 개선

- QMS 체크리스트 템플릿 에디터 추가 (ChecklistTemplateEditor)
- AuditSettingsPanel, Day1DocumentSection 기능 확장
- 견적 등록(QuoteRegistration) 개선
- IntegratedListTemplateV2 수정
- 건설 카테고리 actions 수정
This commit is contained in:
유병철
2026-03-11 11:06:10 +09:00
parent 81affdc441
commit e9ac2470e1
10 changed files with 1382 additions and 132 deletions

View File

@@ -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 호출

View File

@@ -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<TabType>('display');
const handleToggle = (key: keyof AuditDisplaySettings) => {
onSettingsChange({
...settings,
@@ -48,7 +79,7 @@ export function AuditSettingsPanel({
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-gray-600" />
<h3 className="font-semibold text-gray-900"> </h3>
<h3 className="font-semibold text-gray-900"></h3>
</div>
<button
type="button"
@@ -59,103 +90,184 @@ export function AuditSettingsPanel({
</button>
</div>
{/* 설정 항목 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 레이아웃 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"></h4>
<div className="space-y-3">
<SettingRow
label="진행률 표시"
description="상단 전체 심사 진행률 바를 표시합니다"
checked={settings.showProgressBar}
onChange={() => handleToggle('showProgressBar')}
/>
<SettingRow
label="문서 뷰어"
description="우측 문서 미리보기 패널을 표시합니다"
checked={settings.showDocumentViewer}
onChange={() => handleToggle('showDocumentViewer')}
/>
<SettingRow
label="기준 문서화 섹션"
description="중앙 기준 문서 목록 패널을 표시합니다"
checked={settings.showDocumentSection}
onChange={() => handleToggle('showDocumentSection')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 점검표 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="space-y-3">
<SettingRow
label="완료된 항목 표시"
description="완료된 점검 항목을 목록에 표시합니다"
checked={settings.showCompletedItems}
onChange={() => handleToggle('showCompletedItems')}
/>
<SettingRow
label="모든 카테고리 펼치기"
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
checked={settings.expandAllCategories}
onChange={() => handleToggle('expandAllCategories')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 빠른 설정 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: true,
showDocumentViewer: true,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: true,
})}
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
>
</button>
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: false,
showDocumentViewer: false,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: false,
})}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
</div>
</div>
{/* */}
<div className="flex border-b border-gray-200">
<button
type="button"
onClick={() => setActiveTab('display')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
activeTab === 'display'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
)}
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setActiveTab('checklist')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
activeTab === 'checklist'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
)}
>
<ListChecks className="h-3.5 w-3.5" />
</button>
</div>
{/* 하단 안내 */}
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-xs text-gray-500">
</p>
{/* 탭 컨텐츠 */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'display' ? (
<DisplaySettingsContent
settings={settings}
onToggle={handleToggle}
onSettingsChange={onSettingsChange}
/>
) : checklistManagement ? (
<ChecklistTemplateEditor
categories={checklistManagement.categories}
versions={checklistManagement.versions}
currentVersion={checklistManagement.currentVersion}
hasChanges={checklistManagement.hasChanges}
saving={checklistManagement.saving}
onAddCategory={checklistManagement.onAddCategory}
onUpdateCategoryTitle={checklistManagement.onUpdateCategoryTitle}
onDeleteCategory={checklistManagement.onDeleteCategory}
onMoveCategoryUp={checklistManagement.onMoveCategoryUp}
onMoveCategoryDown={checklistManagement.onMoveCategoryDown}
onAddSubItem={checklistManagement.onAddSubItem}
onUpdateSubItemName={checklistManagement.onUpdateSubItemName}
onDeleteSubItem={checklistManagement.onDeleteSubItem}
onMoveSubItemUp={checklistManagement.onMoveSubItemUp}
onMoveSubItemDown={checklistManagement.onMoveSubItemDown}
onSave={checklistManagement.onSave}
onReset={checklistManagement.onReset}
onRestoreVersion={checklistManagement.onRestoreVersion}
/>
) : (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
...
</div>
)}
</div>
{/* 하단 안내 (화면 설정 탭일 때만) */}
{activeTab === 'display' && (
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-xs text-gray-500">
</p>
</div>
)}
</div>
</div>
);
}
// ===== 화면 설정 탭 컨텐츠 (기존 코드 분리) =====
interface DisplaySettingsContentProps {
settings: AuditDisplaySettings;
onToggle: (key: keyof AuditDisplaySettings) => void;
onSettingsChange: (settings: AuditDisplaySettings) => void;
}
function DisplaySettingsContent({ settings, onToggle, onSettingsChange }: DisplaySettingsContentProps) {
return (
<div className="space-y-4">
{/* 레이아웃 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"></h4>
<div className="space-y-3">
<SettingRow
label="진행률 표시"
description="상단 전체 심사 진행률 바를 표시합니다"
checked={settings.showProgressBar}
onChange={() => onToggle('showProgressBar')}
/>
<SettingRow
label="문서 뷰어"
description="우측 문서 미리보기 패널을 표시합니다"
checked={settings.showDocumentViewer}
onChange={() => onToggle('showDocumentViewer')}
/>
<SettingRow
label="기준 문서화 섹션"
description="중앙 기준 문서 목록 패널을 표시합니다"
checked={settings.showDocumentSection}
onChange={() => onToggle('showDocumentSection')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 점검표 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="space-y-3">
<SettingRow
label="완료된 항목 표시"
description="완료된 점검 항목을 목록에 표시합니다"
checked={settings.showCompletedItems}
onChange={() => onToggle('showCompletedItems')}
/>
<SettingRow
label="모든 카테고리 펼치기"
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
checked={settings.expandAllCategories}
onChange={() => onToggle('expandAllCategories')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 빠른 설정 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: true,
showDocumentViewer: true,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: true,
})}
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
>
</button>
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: false,
showDocumentViewer: false,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: false,
})}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
</div>
</div>
</div>
);
}
// ===== 공통 설정 행 =====
interface SettingRowProps {
label: string;
description: string;
@@ -202,4 +314,4 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
<span> </span>
</button>
);
}
}

View File

@@ -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<Set<string>>(
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 (
<div className="space-y-3">
{/* 버전 셀렉트박스 */}
<VersionSelectBox
versions={versions}
currentVersion={currentVersion}
onRestoreVersion={onRestoreVersion}
/>
{/* 카테고리 목록 */}
<div className="space-y-1.5">
{categories.map((category, catIdx) => (
<CategoryEditor
key={category.id}
category={category}
index={catIdx}
isFirst={catIdx === 0}
isLast={catIdx === categories.length - 1}
isExpanded={expandedCategories.has(category.id)}
onToggleExpand={() => 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)}
/>
))}
</div>
{/* 카테고리 추가 */}
<button
type="button"
onClick={onAddCategory}
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs text-blue-600 bg-blue-50 rounded-lg border border-dashed border-blue-200 hover:bg-blue-100 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
{/* 저장/초기화 */}
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
<button
type="button"
onClick={onReset}
disabled={!hasChanges}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onSave()}
disabled={!hasChanges || saving}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Save className="h-3.5 w-3.5" />
{saving ? '저장 중...' : '저장 (새 버전)'}
</button>
</div>
{hasChanges && (
<p className="text-[10px] text-amber-600 text-center">
</p>
)}
</div>
);
}
// ===== 카테고리 편집 =====
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 (
<div className="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
{/* 카테고리 헤더 */}
<div className="flex items-center gap-1 px-2 py-1.5">
{/* 순서 변경 */}
<div className="flex flex-col">
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
{/* 펼치기/접기 */}
<button
type="button"
onClick={onToggleExpand}
className="p-0.5 text-gray-500"
>
<ChevronRight className={cn(
'h-3.5 w-3.5 transition-transform',
isExpanded && 'rotate-90'
)} />
</button>
{/* 제목 */}
{editing ? (
<div className="flex-1 flex items-center gap-1">
<input
type="text"
value={editValue}
onChange={(e) => 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
/>
<button type="button" onClick={handleSaveTitle} className="p-0.5 text-green-600">
<Check className="h-3 w-3" />
</button>
<button type="button" onClick={() => { setEditValue(category.title); setEditing(false); }} className="p-0.5 text-gray-400">
<X className="h-3 w-3" />
</button>
</div>
) : (
<span className="flex-1 text-xs font-medium text-gray-800 truncate">
{index + 1}. {category.title}
</span>
)}
{/* 항목 수 */}
<span className="text-[10px] text-gray-400 mr-1">
{category.subItems.length}
</span>
{/* 편집/삭제 */}
{!editing && (
<>
<button
type="button"
onClick={() => { setEditValue(category.title); setEditing(true); }}
className="p-1 text-gray-400 hover:text-blue-600 rounded"
>
<Pencil className="h-3 w-3" />
</button>
<button
type="button"
onClick={onDelete}
className="p-1 text-gray-400 hover:text-red-600 rounded"
>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
</div>
{/* 하위 항목 */}
{isExpanded && (
<div className="border-t border-gray-200 bg-white">
{category.subItems.map((subItem, subIdx) => (
<SubItemEditor
key={subItem.id}
subItem={subItem}
index={subIdx}
isFirst={subIdx === 0}
isLast={subIdx === category.subItems.length - 1}
onUpdateName={(name) => onUpdateSubItemName(subItem.id, name)}
onDelete={() => onDeleteSubItem(subItem.id)}
onMoveUp={() => onMoveSubItemUp(subIdx)}
onMoveDown={() => onMoveSubItemDown(subIdx)}
/>
))}
{/* 항목 추가 */}
<button
type="button"
onClick={onAddSubItem}
className="w-full flex items-center justify-center gap-1 py-1.5 text-[10px] text-blue-500 hover:bg-blue-50 transition-colors"
>
<Plus className="h-3 w-3" />
</button>
</div>
)}
</div>
);
}
// ===== 하위 항목 편집 =====
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 (
<div className="flex items-center gap-1 px-2 py-1 border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
{/* 순서 변경 */}
<div className="flex flex-col ml-4">
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronUp className="h-2.5 w-2.5" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronDown className="h-2.5 w-2.5" />
</button>
</div>
{/* 이름 */}
{editing ? (
<div className="flex-1 flex items-center gap-1">
<input
type="text"
value={editValue}
onChange={(e) => 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
/>
<button type="button" onClick={handleSave} className="p-0.5 text-green-600">
<Check className="h-2.5 w-2.5" />
</button>
</div>
) : (
<span
className="flex-1 text-[11px] text-gray-700 cursor-pointer hover:text-blue-600 truncate"
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
>
{subItem.name}
</span>
)}
{/* 편집/삭제 */}
{!editing && (
<>
<button
type="button"
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
className="p-0.5 text-gray-300 hover:text-blue-500"
>
<Pencil className="h-2.5 w-2.5" />
</button>
<button
type="button"
onClick={onDelete}
className="p-0.5 text-gray-300 hover:text-red-500"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</>
)}
</div>
);
}
// ===== 버전 검색 셀렉트박스 =====
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<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className="relative">
{/* 트리거 버튼 */}
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
'w-full flex items-center justify-between px-3 py-2 text-sm bg-white border rounded-lg transition-colors',
open ? 'border-blue-400 ring-1 ring-blue-200' : 'border-gray-200 hover:border-gray-300'
)}
>
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-gray-500" />
<span className="font-medium text-gray-800">v{currentVersion}</span>
{currentV && (
<span className="text-xs text-gray-400">{currentV.createdAt}</span>
)}
</div>
<ChevronsUpDown className="h-3.5 w-3.5 text-gray-400" />
</button>
{/* 드롭다운 */}
{open && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden">
{/* 검색 */}
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<input
ref={searchInputRef}
type="text"
value={search}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 text-gray-400 hover:text-gray-600"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* 버전 목록 */}
<div className="max-h-48 overflow-y-auto">
{filteredVersions.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400">
</div>
) : (
filteredVersions.map(v => {
const isCurrent = v.version === currentVersion;
return (
<div
key={v.id}
className={cn(
'flex items-center justify-between px-3 py-2 text-xs transition-colors cursor-pointer',
isCurrent
? 'bg-blue-50'
: 'hover:bg-gray-50'
)}
onClick={() => !isCurrent && handleRestore(v.id)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn(
'font-semibold',
isCurrent ? 'text-blue-600' : 'text-gray-700'
)}>
v{v.version}
</span>
{isCurrent && (
<span className="text-[9px] bg-blue-100 text-blue-600 px-1 py-0.5 rounded">
</span>
)}
<span className="text-gray-400">{v.createdAt}</span>
<span className="text-gray-400">({v.createdBy})</span>
</div>
{v.description && (
<p className="text-gray-500 mt-0.5 truncate">{v.description}</p>
)}
</div>
{!isCurrent && (
<span className="ml-2 text-[10px] text-blue-500 flex-shrink-0">
</span>
)}
</div>
);
})
)}
</div>
{/* 하단 카운트 */}
<div className="px-3 py-1.5 border-t border-gray-100 bg-gray-50">
<span className="text-[10px] text-gray-400">
{versions.length}
{search && ` / ${filteredVersions.length}개 검색됨`}
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -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({
/>
))}
</div>
{/* 파일 업로드 */}
<DocumentUploadArea
onUpload={(file) => onFileUpload?.(checkItem.subItemId, file)}
/>
</div>
{/* 확인 버튼 */}
@@ -163,4 +182,155 @@ function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
</div>
</div>
);
}
// ===== 파일 업로드 영역 =====
interface DocumentUploadAreaProps {
onUpload: (file: File) => void;
}
function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) {
const [isDragging, setIsDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="mt-2 p-2.5 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-2">
<div className={cn(
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center',
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
)}>
<FileText className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{pendingFile.name}</p>
<p className="text-[10px] text-gray-500">{sizeMB} MB</p>
</div>
<button
type="button"
onClick={handleCancelUpload}
className="p-1 text-gray-400 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={handleCancelUpload}
className="flex-1 py-1.5 text-xs text-gray-600 bg-white border border-gray-200 rounded-md hover:bg-gray-50"
>
</button>
<button
type="button"
onClick={handleConfirmUpload}
disabled={uploading}
className="flex-1 py-1.5 text-xs text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-1"
>
{uploading ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
...
</>
) : (
'업로드'
)}
</button>
</div>
</div>
);
}
return (
<>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTENSIONS}
onChange={handleInputChange}
className="hidden"
/>
<div
onDragEnter={(e) => { 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'
)}
>
<Upload className="h-3.5 w-3.5 text-gray-400" />
<span className="text-xs text-gray-500">
(PDF, Excel, Word, HWP)
</span>
</div>
</>
);
}

View File

@@ -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<ChecklistCategory[]>(
() => structuredClone(MOCK_DAY1_CATEGORIES)
);
// 마지막 저장 상태 (초기화용)
const savedRef = useRef<ChecklistCategory[]>(structuredClone(MOCK_DAY1_CATEGORIES));
const [versions] = useState<ChecklistTemplateVersion[]>(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,
};
}

View File

@@ -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,
}}
/>
<InspectionModal

View File

@@ -92,3 +92,22 @@ export interface Day2Progress {
completed: number;
total: number;
}
// ===== 점검표 템플릿 관리 타입 =====
// 점검표 템플릿 버전
export interface ChecklistTemplateVersion {
id: string;
version: number;
createdAt: string;
createdBy: string;
description?: string;
}
// 점검표 템플릿 (API 응답)
export interface ChecklistTemplate {
id: string;
currentVersion: number;
categories: ChecklistCategory[];
versions: ChecklistTemplateVersion[];
}

View File

@@ -59,7 +59,8 @@ export async function getCategories(): Promise<{
data: ApiCategory[];
}>('/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);

View File

@@ -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<LocationItem, "id">): Promise<boolean> => {
const newLocation: LocationItem = {
@@ -774,37 +764,34 @@ export function QuoteRegistration({
</div>
<div>
<label className="text-sm font-medium text-gray-700"> <span className="text-red-500">*</span></label>
<Select
<SearchableSelect
options={clients.map((c) => ({ value: c.id, label: c.vendorName }))}
value={formData.clientId}
onValueChange={handleClientChange}
onChange={(value, option) => {
setFormData((prev) => ({
...prev,
clientId: value,
clientName: option.label,
}));
}}
placeholder={isLoadingClients ? "로딩 중..." : "수주처를 선택하세요"}
searchPlaceholder="수주처 검색..."
emptyText="수주처가 없습니다"
disabled={isViewMode || isLoadingClients}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "수주처를 선택하세요"} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.vendorName}
</SelectItem>
))}
</SelectContent>
</Select>
isLoading={isLoadingClients}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
list="siteNameList"
placeholder="현장명을 입력하세요"
<SearchableSelect
options={siteNames.map((name) => ({ value: name, label: name }))}
value={formData.siteName}
onChange={(e) => handleFieldChange("siteName", e.target.value)}
onChange={(value) => handleFieldChange("siteName", value)}
placeholder="현장명을 선택하세요"
searchPlaceholder="현장명 검색..."
emptyText="현장명이 없습니다"
disabled={isViewMode}
/>
<datalist id="siteNameList">
{siteNames.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
</div>

View File

@@ -977,8 +977,8 @@ export function IntegratedListTemplateV2<T = any>({
showActions={tableColumns.some(col => col.key === 'actions')}
/>
) : (
<Table className="table-fixed">
{columnSettings && (
<Table className={columnSettings && Object.keys(columnSettings.columnWidths).length > 0 ? "table-fixed" : "table-auto [&_td]:whitespace-nowrap [&_th]:whitespace-nowrap"}>
{columnSettings && Object.keys(columnSettings.columnWidths).length > 0 && (
<colgroup>
{showCheckbox && <col style={{ width: 50 }} />}
{tableColumns.map((col) => (
@@ -1046,6 +1046,24 @@ export function IntegratedListTemplateV2<T = any>({
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) => {