Files
factoryOps-v2/dashboard/components/TemplateEditor.tsx
Johngreen 180cc5b163
All checks were successful
Deploy to Production / deploy (push) Successful in 1m4s
fix(ui): improve template editor modal and card-body spacing
- 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>
2026-02-10 13:28:44 +09:00

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 };