402 lines
17 KiB
TypeScript
402 lines
17 KiB
TypeScript
import { useState } from 'react';
|
|
import { createEmptyReport, type ReportType, type Jurisdiction } from './OilSpillReportTemplate';
|
|
import { useAuthStore } from '@common/store/authStore';
|
|
import { templateTypes } from './reportTypes';
|
|
import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils';
|
|
import { saveReport } from '../services/reportsApi';
|
|
|
|
interface TemplateFormEditorProps {
|
|
onSave: () => void;
|
|
onBack: () => void;
|
|
}
|
|
|
|
function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
|
const user = useAuthStore((s) => s.user);
|
|
const [selectedType, setSelectedType] = useState<ReportType>('초기보고서');
|
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
|
const [reportMeta, setReportMeta] = useState(() => {
|
|
const now = new Date();
|
|
return {
|
|
title: '',
|
|
author: '',
|
|
jurisdiction: (user?.org?.abbr || '') as Jurisdiction,
|
|
writeTime: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`,
|
|
};
|
|
});
|
|
const [autoSave, setAutoSave] = useState(false);
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
|
|
const template = templateTypes.find((t) => t.id === selectedType)!;
|
|
|
|
const getVal = (key: string) => {
|
|
if (key === 'author') return reportMeta.author;
|
|
if (key === 'incident.writeTime') return reportMeta.writeTime;
|
|
return formData[key] || '';
|
|
};
|
|
const setVal = (key: string, val: string) => {
|
|
if (key === 'author') {
|
|
setReportMeta((p) => ({ ...p, author: val }));
|
|
return;
|
|
}
|
|
if (key === 'incident.writeTime') {
|
|
setReportMeta((p) => ({ ...p, writeTime: val }));
|
|
return;
|
|
}
|
|
setFormData((p) => ({ ...p, [key]: val }));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
const report = createEmptyReport(reportMeta.jurisdiction);
|
|
report.reportType = selectedType;
|
|
report.author = reportMeta.author;
|
|
report.title = formData['incident.name'] || `${selectedType} ${reportMeta.writeTime}`;
|
|
report.status = '완료';
|
|
report.incident.writeTime = reportMeta.writeTime;
|
|
// Map all incident fields
|
|
const incFields = [
|
|
'name',
|
|
'occurTime',
|
|
'location',
|
|
'shipName',
|
|
'accidentType',
|
|
'pollutant',
|
|
'spillAmount',
|
|
'lat',
|
|
'lon',
|
|
'depth',
|
|
'seabed',
|
|
] as const;
|
|
incFields.forEach((f) => {
|
|
const val = formData[`incident.${f}`];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if (val) (report.incident as any)[f] = val;
|
|
});
|
|
report.analysis =
|
|
formData['spreadAnalysis'] ||
|
|
formData['initialResponse'] ||
|
|
formData['responseStatus'] ||
|
|
formData['responseDetail'] ||
|
|
'';
|
|
try {
|
|
await saveReport(report);
|
|
onSave();
|
|
} catch (err) {
|
|
console.error('[reports] 저장 오류:', err);
|
|
alert('보고서 저장 중 오류가 발생했습니다.');
|
|
}
|
|
};
|
|
|
|
const doExport = (format: 'pdf' | 'hwp') => {
|
|
const meta = {
|
|
writeTime: reportMeta.writeTime,
|
|
author: reportMeta.author,
|
|
jurisdiction: reportMeta.jurisdiction,
|
|
};
|
|
const filename =
|
|
formData['incident.name'] ||
|
|
`${template.label}_${reportMeta.writeTime.replace(/[\s:]/g, '_')}`;
|
|
if (format === 'pdf') {
|
|
const html = generateReportHTML(template.label, meta, template.sections, getVal);
|
|
exportAsPDF(html, filename);
|
|
} else {
|
|
exportAsHWP(template.label, meta, template.sections, getVal, filename);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full">
|
|
{/* Left Sidebar - Template Selection */}
|
|
<div className="w-60 min-w-[240px] border-r border-stroke bg-bg-surface flex flex-col py-4 px-3 gap-2 overflow-y-auto shrink-0">
|
|
<div className="px-1 mb-2">
|
|
<h3 className="text-title-4 font-bold text-fg font-korean">표준보고서 템플릿 선택</h3>
|
|
<p className="text-caption text-fg-disabled font-korean mt-1">
|
|
템플릿을 선택하면 오른쪽에 작성 양식이 표시됩니다.
|
|
</p>
|
|
</div>
|
|
{templateTypes.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setSelectedType(t.id)}
|
|
className={`flex flex-col items-start p-3 rounded-lg border transition-all text-left ${
|
|
selectedType === t.id
|
|
? 'border-color-accent bg-[rgba(6,182,212,0.08)]'
|
|
: 'border-stroke bg-bg-elevated hover:border-stroke-light'
|
|
}`}
|
|
>
|
|
<span className="text-lg mb-1">{t.icon}</span>
|
|
<span
|
|
className={`text-label-1 font-bold font-korean ${selectedType === t.id ? 'text-color-accent' : 'text-fg'}`}
|
|
>
|
|
{t.label}
|
|
</span>
|
|
<span className="text-caption text-fg-disabled font-korean mt-0.5">{t.desc}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right - Form */}
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Form Header */}
|
|
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke bg-bg-surface">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-lg">{template.icon}</span>
|
|
<span className="text-subtitle font-bold text-fg font-korean">{template.label}</span>
|
|
<span
|
|
className="px-2 py-0.5 text-caption font-semibold rounded font-korean"
|
|
style={{
|
|
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
|
color: 'var(--color-accent)',
|
|
}}
|
|
>
|
|
작성중
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-label-2 text-fg-disabled font-korean">자동저장:</span>
|
|
<button
|
|
onClick={() => setAutoSave(!autoSave)}
|
|
className={`relative w-9 h-[18px] rounded-full transition-all ${autoSave ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}
|
|
>
|
|
<span
|
|
className={`absolute top-[2px] w-3.5 h-3.5 rounded-full bg-white shadow transition-all ${autoSave ? 'left-[18px]' : 'left-[2px]'}`}
|
|
/>
|
|
</button>
|
|
<span
|
|
className="text-label-2 font-semibold font-korean"
|
|
style={{ color: autoSave ? 'var(--color-accent)' : 'var(--fg-disabled)' }}
|
|
>
|
|
{autoSave ? 'ON' : 'OFF'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form Content */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
|
{template.sections.map((section, sIdx) => (
|
|
<div key={sIdx} className="mb-6 w-full">
|
|
<h4 className="text-title-4 font-bold font-korean mb-3 text-color-accent">
|
|
{section.title}
|
|
</h4>
|
|
<table className="w-full table-fixed border-collapse">
|
|
<colgroup>
|
|
<col style={{ width: '180px' }} />
|
|
<col />
|
|
</colgroup>
|
|
<tbody>
|
|
{section.fields.map((field, fIdx) => (
|
|
<tr key={fIdx} className="border-b border-stroke">
|
|
{field.label ? (
|
|
<>
|
|
<td className="px-4 py-3 text-label-2 font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] align-middle">
|
|
{field.label}
|
|
</td>
|
|
<td className="px-4 py-2 align-middle">
|
|
{field.type === 'text' && (
|
|
<input
|
|
value={getVal(field.key)}
|
|
onChange={(e) => setVal(field.key, e.target.value)}
|
|
placeholder={`${field.label} 입력`}
|
|
className="w-full bg-transparent text-label-1 text-fg font-korean outline-none placeholder-fg-disabled"
|
|
/>
|
|
)}
|
|
{field.type === 'checkbox-group' && field.options && (
|
|
<div className="flex items-center gap-4">
|
|
{field.options.map((opt) => (
|
|
<label
|
|
key={opt}
|
|
className="flex items-center gap-1.5 text-label-2 text-fg-sub font-korean cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="accent-[var(--color-accent)] w-3.5 h-3.5"
|
|
/>
|
|
{opt}
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</td>
|
|
</>
|
|
) : (
|
|
<td colSpan={2} className="px-4 py-3">
|
|
<textarea
|
|
value={getVal(field.key)}
|
|
onChange={(e) => setVal(field.key, e.target.value)}
|
|
placeholder="내용을 입력하세요..."
|
|
className="w-full min-h-[120px] bg-bg-elevated border border-stroke rounded-md px-3 py-2 text-label-1 text-fg font-korean outline-none placeholder-fg-disabled resize-y focus:border-color-accent"
|
|
/>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Bottom Action Bar */}
|
|
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => doExport('pdf')}
|
|
className="px-3 py-2 text-label-2 font-semibold rounded bg-color-danger text-white hover:opacity-90 transition-all"
|
|
>
|
|
PDF
|
|
</button>
|
|
<button
|
|
onClick={() => doExport('hwp')}
|
|
className="px-3 py-2 text-label-2 font-semibold rounded bg-color-info text-white hover:opacity-90 transition-all"
|
|
>
|
|
HWPX
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={onBack}
|
|
className="px-4 py-2 text-label-2 font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
|
|
>
|
|
임시저장
|
|
</button>
|
|
<button
|
|
onClick={() => setShowPreview(true)}
|
|
className="px-4 py-2 text-label-2 font-semibold rounded border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
|
>
|
|
미리보기
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
className="px-5 py-2 text-label-2 font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean flex items-center gap-1"
|
|
>
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview Modal */}
|
|
{showPreview && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
style={{ background: 'rgba(0,0,0,0.6)' }}
|
|
>
|
|
<div
|
|
className="bg-bg-base border border-stroke rounded-xl shadow-2xl flex flex-col"
|
|
style={{ width: 'calc(100vw - 48px)', height: 'calc(100vh - 48px)' }}
|
|
>
|
|
{/* Modal Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-lg">{template.icon}</span>
|
|
<span className="text-subtitle font-bold text-fg font-korean">
|
|
{template.label}
|
|
</span>
|
|
<span
|
|
className="px-2 py-0.5 text-caption font-semibold rounded font-korean"
|
|
style={{
|
|
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
|
color: 'var(--color-accent)',
|
|
}}
|
|
>
|
|
미리보기
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowPreview(false)}
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-fg-disabled hover:text-fg hover:bg-bg-elevated transition-all text-lg"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modal Body - Report Preview */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
|
<div className="w-full">
|
|
{/* Report Title */}
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-title-1 font-bold text-fg font-korean mb-1">
|
|
해양오염방제지원시스템
|
|
</h2>
|
|
<h3 className="text-subtitle font-semibold font-korean text-color-accent">
|
|
{formData['incident.name'] || template.label}
|
|
</h3>
|
|
<p className="text-label-2 text-fg-disabled font-korean mt-2">
|
|
작성일시: {reportMeta.writeTime} | 작성자: {reportMeta.author || '-'} | 관할:{' '}
|
|
{reportMeta.jurisdiction}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sections */}
|
|
{template.sections.map((section, sIdx) => (
|
|
<div key={sIdx} className="mb-5">
|
|
<h4
|
|
className="text-title-4 font-bold font-korean mb-2 px-2 py-1.5 rounded"
|
|
style={{
|
|
color: 'var(--color-accent)',
|
|
background: 'color-mix(in srgb, var(--color-accent) 6%, transparent)',
|
|
}}
|
|
>
|
|
{section.title}
|
|
</h4>
|
|
<table className="w-full table-fixed border-collapse border border-stroke">
|
|
<colgroup>
|
|
<col style={{ width: '200px' }} />
|
|
<col />
|
|
</colgroup>
|
|
<tbody>
|
|
{section.fields.map((field, fIdx) => {
|
|
const val = getVal(field.key);
|
|
return field.label ? (
|
|
<tr key={fIdx} className="border-b border-stroke">
|
|
<td className="px-4 py-2.5 text-label-2 font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] border-r border-stroke align-middle">
|
|
{field.label}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-label-1 text-fg font-korean align-middle">
|
|
{val || <span className="text-fg-disabled">-</span>}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
<tr key={fIdx} className="border-b border-stroke">
|
|
<td
|
|
colSpan={2}
|
|
className="px-4 py-3 text-label-1 text-fg font-korean whitespace-pre-wrap"
|
|
>
|
|
{val || <span className="text-fg-disabled">내용 없음</span>}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal Footer */}
|
|
<div className="flex items-center justify-end gap-3 px-6 py-3 border-t border-stroke">
|
|
<button
|
|
onClick={() => setShowPreview(false)}
|
|
className="px-4 py-2 text-label-2 font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
|
|
>
|
|
닫기
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowPreview(false);
|
|
handleSave();
|
|
}}
|
|
className="px-5 py-2 text-label-2 font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
|
>
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default TemplateFormEditor;
|