All checks were successful
Deploy to Production / deploy (push) Successful in 1m4s
- Add gap to card-body for consistent field spacing - Wrap modal content in modal-body-form with proper gap - Use modal-lg for item add modal to prevent field overflow Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { useMachines } from '@/lib/hooks';
|
|
import type { InspectionTemplate, InspectionTemplateItem } from '@/lib/types';
|
|
|
|
const SCHEDULE_LABELS: Record<string, string> = {
|
|
daily: '일일',
|
|
weekly: '주간',
|
|
monthly: '월간',
|
|
yearly: '연간',
|
|
ad_hoc: '수시',
|
|
};
|
|
|
|
const MODE_LABELS: Record<string, string> = {
|
|
checklist: '체크리스트',
|
|
measurement: '측정',
|
|
monitoring: '모니터링',
|
|
};
|
|
|
|
const DATA_TYPE_LABELS: Record<string, string> = {
|
|
numeric: '숫자',
|
|
boolean: '예/아니오',
|
|
text: '텍스트',
|
|
select: '선택',
|
|
};
|
|
|
|
interface ItemDraft {
|
|
id?: string;
|
|
name: string;
|
|
category: string;
|
|
data_type: string;
|
|
unit: string;
|
|
spec_min: string;
|
|
spec_max: string;
|
|
select_options_text: string;
|
|
equipment_part_id: string;
|
|
is_required: boolean;
|
|
}
|
|
|
|
interface TemplateDraft {
|
|
name: string;
|
|
subject_type: string;
|
|
machine_id: string;
|
|
product_code: string;
|
|
schedule_type: string;
|
|
inspection_mode: string;
|
|
}
|
|
|
|
interface Props {
|
|
tenantId: string;
|
|
initialData?: InspectionTemplate | null;
|
|
onSave: (meta: TemplateDraft, items: ItemDraft[]) => Promise<void>;
|
|
isEdit?: boolean;
|
|
}
|
|
|
|
function itemToItemDraft(item: InspectionTemplateItem): ItemDraft {
|
|
return {
|
|
id: item.id,
|
|
name: item.name,
|
|
category: item.category || '',
|
|
data_type: item.data_type,
|
|
unit: item.unit || '',
|
|
spec_min: item.spec_min !== null ? String(item.spec_min) : '',
|
|
spec_max: item.spec_max !== null ? String(item.spec_max) : '',
|
|
select_options_text: item.select_options ? item.select_options.join(', ') : '',
|
|
equipment_part_id: item.equipment_part_id || '',
|
|
is_required: item.is_required,
|
|
};
|
|
}
|
|
|
|
const EMPTY_ITEM: ItemDraft = {
|
|
name: '',
|
|
category: '',
|
|
data_type: 'numeric',
|
|
unit: '',
|
|
spec_min: '',
|
|
spec_max: '',
|
|
select_options_text: '',
|
|
equipment_part_id: '',
|
|
is_required: true,
|
|
};
|
|
|
|
export function TemplateEditor({ tenantId, initialData, onSave, isEdit }: Props) {
|
|
const [meta, setMeta] = useState<TemplateDraft>({
|
|
name: initialData?.name || '',
|
|
subject_type: initialData?.subject_type || 'equipment',
|
|
machine_id: initialData?.machine_id || '',
|
|
product_code: initialData?.product_code || '',
|
|
schedule_type: initialData?.schedule_type || 'daily',
|
|
inspection_mode: initialData?.inspection_mode || 'measurement',
|
|
});
|
|
|
|
const [items, setItems] = useState<ItemDraft[]>(
|
|
initialData?.items?.map(itemToItemDraft) || []
|
|
);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [showAddItem, setShowAddItem] = useState(false);
|
|
const [newItem, setNewItem] = useState<ItemDraft>({ ...EMPTY_ITEM });
|
|
|
|
const { machines } = useMachines(tenantId);
|
|
|
|
const handleMetaChange = useCallback((field: string, value: string) => {
|
|
setMeta(prev => ({ ...prev, [field]: value }));
|
|
}, []);
|
|
|
|
const addItem = useCallback(() => {
|
|
if (!newItem.name.trim()) return;
|
|
setItems(prev => [...prev, { ...newItem }]);
|
|
setNewItem({ ...EMPTY_ITEM });
|
|
setShowAddItem(false);
|
|
}, [newItem]);
|
|
|
|
const removeItem = useCallback((idx: number) => {
|
|
if (!confirm('이 검사 항목을 삭제하시겠습니까?')) return;
|
|
setItems(prev => prev.filter((_, i) => i !== idx));
|
|
}, []);
|
|
|
|
const moveItem = useCallback((idx: number, direction: -1 | 1) => {
|
|
setItems(prev => {
|
|
const next = [...prev];
|
|
const target = idx + direction;
|
|
if (target < 0 || target >= next.length) return prev;
|
|
[next[idx], next[target]] = [next[target], next[idx]];
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const updateItem = useCallback((idx: number, field: string, value: string | boolean) => {
|
|
setItems(prev => prev.map((item, i) =>
|
|
i === idx ? { ...item, [field]: value } : item
|
|
));
|
|
}, []);
|
|
|
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!meta.name.trim()) return;
|
|
setSubmitting(true);
|
|
try {
|
|
await onSave(meta, items);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, [meta, items, onSave]);
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
|
<div className="card-header">
|
|
<h3>기본 정보</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="form-field">
|
|
<label>템플릿 이름 *</label>
|
|
<input
|
|
type="text"
|
|
placeholder="예: 1호기 일일검사"
|
|
value={meta.name}
|
|
onChange={e => handleMetaChange('name', e.target.value)}
|
|
disabled={submitting}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<div className="form-field">
|
|
<label>검사 대상</label>
|
|
<div className="radio-group">
|
|
<label className="radio-label">
|
|
<input
|
|
type="radio"
|
|
name="subject_type"
|
|
value="equipment"
|
|
checked={meta.subject_type === 'equipment'}
|
|
onChange={e => handleMetaChange('subject_type', e.target.value)}
|
|
disabled={submitting}
|
|
/>
|
|
설비검사
|
|
</label>
|
|
<label className="radio-label">
|
|
<input
|
|
type="radio"
|
|
name="subject_type"
|
|
value="product"
|
|
checked={meta.subject_type === 'product'}
|
|
onChange={e => handleMetaChange('subject_type', e.target.value)}
|
|
disabled={submitting}
|
|
/>
|
|
품질검사
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{meta.subject_type === 'equipment' && (
|
|
<div className="form-field">
|
|
<label>대상 설비</label>
|
|
<select
|
|
value={meta.machine_id}
|
|
onChange={e => handleMetaChange('machine_id', e.target.value)}
|
|
disabled={submitting}
|
|
>
|
|
<option value="">선택 안함</option>
|
|
{machines.map(m => (
|
|
<option key={m.id} value={m.id}>{m.name} ({m.equipment_code})</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-row">
|
|
<div className="form-field">
|
|
<label>검사 주기</label>
|
|
<select
|
|
value={meta.schedule_type}
|
|
onChange={e => handleMetaChange('schedule_type', e.target.value)}
|
|
disabled={submitting}
|
|
>
|
|
{Object.entries(SCHEDULE_LABELS).map(([value, label]) => (
|
|
<option key={value} value={value}>{label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="form-field">
|
|
<label>검사 모드</label>
|
|
<select
|
|
value={meta.inspection_mode}
|
|
onChange={e => handleMetaChange('inspection_mode', e.target.value)}
|
|
disabled={submitting}
|
|
>
|
|
{Object.entries(MODE_LABELS).map(([value, label]) => (
|
|
<option key={value} value={value}>{label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
|
<div className="card-header">
|
|
<h3>검사 항목 ({items.length}개)</h3>
|
|
<button
|
|
type="button"
|
|
className="btn-outline btn-sm"
|
|
onClick={() => setShowAddItem(true)}
|
|
disabled={submitting}
|
|
>
|
|
<span className="material-symbols-outlined">add</span>
|
|
항목 추가
|
|
</button>
|
|
</div>
|
|
<div className="card-body">
|
|
{items.length === 0 ? (
|
|
<div className="empty-state" style={{ padding: '2rem' }}>
|
|
<span className="material-symbols-outlined">playlist_add</span>
|
|
<p>검사 항목이 없습니다. 항목을 추가하세요.</p>
|
|
</div>
|
|
) : (
|
|
<div className="items-list">
|
|
{items.map((item, idx) => (
|
|
<div key={idx} className="item-row">
|
|
<div className="item-order">
|
|
<button type="button" className="btn-icon" onClick={() => moveItem(idx, -1)} disabled={idx === 0 || submitting}>
|
|
<span className="material-symbols-outlined">keyboard_arrow_up</span>
|
|
</button>
|
|
<span className="order-num">{idx + 1}</span>
|
|
<button type="button" className="btn-icon" onClick={() => moveItem(idx, 1)} disabled={idx === items.length - 1 || submitting}>
|
|
<span className="material-symbols-outlined">keyboard_arrow_down</span>
|
|
</button>
|
|
</div>
|
|
<div className="item-fields">
|
|
<div className="form-row">
|
|
<div className="form-field">
|
|
<input
|
|
type="text"
|
|
placeholder="항목명"
|
|
value={item.name}
|
|
onChange={e => updateItem(idx, 'name', e.target.value)}
|
|
disabled={submitting}
|
|
/>
|
|
</div>
|
|
<div className="form-field" style={{ maxWidth: '120px' }}>
|
|
<select
|
|
value={item.data_type}
|
|
onChange={e => updateItem(idx, 'data_type', e.target.value)}
|
|
disabled={submitting}
|
|
>
|
|
{Object.entries(DATA_TYPE_LABELS).map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<label className="checkbox-label" style={{ minWidth: 'auto' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={item.is_required}
|
|
onChange={e => updateItem(idx, 'is_required', e.target.checked)}
|
|
disabled={submitting}
|
|
/>
|
|
필수
|
|
</label>
|
|
</div>
|
|
{item.data_type === 'numeric' && (
|
|
<div className="form-row">
|
|
<div className="form-field">
|
|
<input type="text" placeholder="단위 (예: ℃)" value={item.unit} onChange={e => updateItem(idx, 'unit', e.target.value)} disabled={submitting} />
|
|
</div>
|
|
<div className="form-field">
|
|
<input type="number" placeholder="하한" value={item.spec_min} onChange={e => updateItem(idx, 'spec_min', e.target.value)} disabled={submitting} step="any" />
|
|
</div>
|
|
<div className="form-field">
|
|
<input type="number" placeholder="상한" value={item.spec_max} onChange={e => updateItem(idx, 'spec_max', e.target.value)} disabled={submitting} step="any" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{item.data_type === 'select' && (
|
|
<div className="form-field">
|
|
<input
|
|
type="text"
|
|
placeholder="선택지 (쉼표로 구분, 예: 양호, 불량, N/A)"
|
|
value={item.select_options_text}
|
|
onChange={e => updateItem(idx, 'select_options_text', e.target.value)}
|
|
disabled={submitting}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button type="button" className="btn-icon btn-danger" onClick={() => removeItem(idx)} disabled={submitting}>
|
|
<span className="material-symbols-outlined">delete</span>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showAddItem && (
|
|
<div className="modal-overlay" onClick={() => setShowAddItem(false)}>
|
|
<div className="modal-content modal-lg" onClick={e => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>검사 항목 추가</h3>
|
|
<button type="button" className="modal-close" onClick={() => setShowAddItem(false)}>
|
|
<span className="material-symbols-outlined">close</span>
|
|
</button>
|
|
</div>
|
|
<div className="modal-body-form">
|
|
<div className="form-field">
|
|
<label>항목명 *</label>
|
|
<input
|
|
type="text"
|
|
placeholder="예: 유압 압력"
|
|
value={newItem.name}
|
|
onChange={e => setNewItem(prev => ({ ...prev, name: e.target.value }))}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="form-row">
|
|
<div className="form-field">
|
|
<label>데이터 유형</label>
|
|
<select value={newItem.data_type} onChange={e => setNewItem(prev => ({ ...prev, data_type: e.target.value }))}>
|
|
{Object.entries(DATA_TYPE_LABELS).map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="form-field">
|
|
<label>카테고리</label>
|
|
<input type="text" placeholder="예: 안전" value={newItem.category} onChange={e => setNewItem(prev => ({ ...prev, category: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
{newItem.data_type === 'numeric' && (
|
|
<div className="form-row">
|
|
<div className="form-field">
|
|
<label>단위</label>
|
|
<input type="text" placeholder="예: ℃" value={newItem.unit} onChange={e => setNewItem(prev => ({ ...prev, unit: e.target.value }))} />
|
|
</div>
|
|
<div className="form-field">
|
|
<label>하한</label>
|
|
<input type="number" placeholder="스펙 하한" value={newItem.spec_min} onChange={e => setNewItem(prev => ({ ...prev, spec_min: e.target.value }))} step="any" />
|
|
</div>
|
|
<div className="form-field">
|
|
<label>상한</label>
|
|
<input type="number" placeholder="스펙 상한" value={newItem.spec_max} onChange={e => setNewItem(prev => ({ ...prev, spec_max: e.target.value }))} step="any" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{newItem.data_type === 'select' && (
|
|
<div className="form-field">
|
|
<label>선택지</label>
|
|
<input
|
|
type="text"
|
|
placeholder="쉼표로 구분 (예: 양호, 불량, N/A)"
|
|
value={newItem.select_options_text}
|
|
onChange={e => setNewItem(prev => ({ ...prev, select_options_text: e.target.value }))}
|
|
/>
|
|
</div>
|
|
)}
|
|
<label className="checkbox-label">
|
|
<input type="checkbox" checked={newItem.is_required} onChange={e => setNewItem(prev => ({ ...prev, is_required: e.target.checked }))} />
|
|
필수 항목
|
|
</label>
|
|
<div className="modal-actions">
|
|
<button type="button" className="btn-outline" onClick={() => setShowAddItem(false)}>취소</button>
|
|
<button type="button" className="btn-primary" onClick={addItem} disabled={!newItem.name.trim()}>
|
|
<span className="material-symbols-outlined">add</span>
|
|
추가
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-actions">
|
|
<button type="submit" className="btn-primary" disabled={submitting || !meta.name.trim()}>
|
|
{submitting ? (
|
|
<span className="material-symbols-outlined spinning">progress_activity</span>
|
|
) : (
|
|
<span className="material-symbols-outlined">{isEdit ? 'save' : 'add'}</span>
|
|
)}
|
|
{isEdit ? '저장' : '템플릿 생성'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export { SCHEDULE_LABELS, MODE_LABELS, DATA_TYPE_LABELS };
|
|
export type { TemplateDraft, ItemDraft };
|