feat: QMS 체크리스트 템플릿 관리 및 견적/리스트 개선
- QMS 체크리스트 템플릿 에디터 추가 (ChecklistTemplateEditor) - AuditSettingsPanel, Day1DocumentSection 기능 확장 - 견적 등록(QuoteRegistration) 개선 - IntegratedListTemplateV2 수정 - 건설 카테고리 actions 수정
This commit is contained in:
@@ -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 호출
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user