feat(reports): 보고서 탭 localStorage → DB/API 전환

- DB 마이그레이션 007_reports.sql: 7개 테이블 (REPORT_TMPL, REPORT_TMPL_SECT,
  REPORT_ANALYSIS_CTGR, REPORT_CTGR_SECT, REPORT, REPORT_SECT_DATA 등)
  + 초기 데이터 (5개 템플릿, 3개 카테고리, 섹션 정의)
- 백엔드 reportsService.ts: 템플릿/카테고리 조회, 보고서 CRUD, 섹션 UPSERT
- 백엔드 reportsRouter.ts: GET/POST only 패턴 (보안취약점 가이드 준수)
  - GET /api/reports, GET /api/reports/:sn (조회)
  - POST /api/reports (생성), POST /:sn/update (수정), POST /:sn/delete (삭제)
  - POST /:sn/sections/:sectCd (개별 섹션 수정)
- 프론트 reportsApi.ts: API 호출 + OilSpillReportData ↔ API 변환 + 캐싱
- 프론트 4개 컴포넌트 localStorage → API 전환:
  ReportsView, OilSpillReportTemplate, TemplateFormEditor, ReportGenerator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-28 20:59:11 +09:00
부모 dcaf8da474
커밋 844eebb7cc
9개의 변경된 파일1348개의 추가작업 그리고 49개의 파일을 삭제

파일 보기

@ -0,0 +1,185 @@
import { Router } from 'express';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
import { AuthError } from '../auth/authService.js';
import {
getTemplates,
getCategories,
listReports,
getReport,
createReport,
updateReport,
deleteReport,
updateReportSection,
} from './reportsService.js';
const router = Router();
// ============================================================
// GET /api/reports/templates — 템플릿 목록 + 섹션 정의
// ============================================================
router.get('/templates', requireAuth, async (_req, res) => {
try {
const templates = await getTemplates();
res.json(templates);
} catch (err) {
console.error('[reports] 템플릿 조회 오류:', err);
res.status(500).json({ error: '템플릿 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/reports/categories — 분석 카테고리 목록 + 섹션 정의
// ============================================================
router.get('/categories', requireAuth, async (_req, res) => {
try {
const categories = await getCategories();
res.json(categories);
} catch (err) {
console.error('[reports] 카테고리 조회 오류:', err);
res.status(500).json({ error: '카테고리 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/reports — 보고서 목록
// ============================================================
router.get('/', requireAuth, requirePermission('reports', 'READ'), async (req, res) => {
try {
const { jrsdCd, tmplCd, sttsCd, search, page, size } = req.query;
const result = await listReports({
jrsdCd: jrsdCd as string | undefined,
tmplCd: tmplCd as string | undefined,
sttsCd: sttsCd as string | undefined,
search: search as string | undefined,
page: page ? parseInt(page as string, 10) : undefined,
size: size ? parseInt(size as string, 10) : undefined,
});
res.json(result);
} catch (err) {
console.error('[reports] 목록 조회 오류:', err);
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message });
} else {
res.status(500).json({ error: '보고서 목록 조회 중 오류가 발생했습니다.' });
}
}
});
// ============================================================
// GET /api/reports/:sn — 보고서 상세
// ============================================================
router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return;
}
const report = await getReport(sn);
res.json(report);
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message });
} else {
console.error('[reports] 상세 조회 오류:', err);
res.status(500).json({ error: '보고서 조회 중 오류가 발생했습니다.' });
}
}
});
// ============================================================
// POST /api/reports — 보고서 생성
// ============================================================
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
try {
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections } = req.body;
const result = await createReport({
tmplSn,
ctgrSn,
acdntSn,
title,
jrsdCd,
sttsCd,
authorId: req.user!.sub,
sections,
});
res.status(201).json(result);
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message });
} else {
console.error('[reports] 생성 오류:', err);
res.status(500).json({ error: '보고서 생성 중 오류가 발생했습니다.' });
}
}
});
// ============================================================
// POST /api/reports/:sn/update — 보고서 수정
// ============================================================
router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'), async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return;
}
const { title, jrsdCd, sttsCd, acdntSn, sections } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections }, req.user!.sub);
res.json({ success: true });
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message });
} else {
console.error('[reports] 수정 오류:', err);
res.status(500).json({ error: '보고서 수정 중 오류가 발생했습니다.' });
}
}
});
// ============================================================
// POST /api/reports/:sn/delete — 보고서 삭제 (논리 삭제)
// ============================================================
router.post('/:sn/delete', requireAuth, requirePermission('reports', 'DELETE'), async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return;
}
await deleteReport(sn, req.user!.sub);
res.json({ success: true });
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message });
} else {
console.error('[reports] 삭제 오류:', err);
res.status(500).json({ error: '보고서 삭제 중 오류가 발생했습니다.' });
}
}
});
// ============================================================
// POST /api/reports/:sn/sections/:sectCd — 개별 섹션 수정
// ============================================================
router.post('/:sn/sections/:sectCd', requireAuth, requirePermission('reports', 'UPDATE'), async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return;
}
const { sectData, includeYn } = req.body;
await updateReportSection(sn, req.params.sectCd as string, sectData, includeYn, req.user!.sub);
res.json({ success: true });
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message });
} else {
console.error('[reports] 섹션 수정 오류:', err);
res.status(500).json({ error: '섹션 수정 중 오류가 발생했습니다.' });
}
}
});
export default router;

파일 보기

@ -0,0 +1,521 @@
import { wingPool } from '../db/wingDb.js';
import { AuthError } from '../auth/authService.js';
// ============================================================
// 인터페이스
// ============================================================
interface TemplateSectionDef {
sectCd: string;
sectNm: string;
fieldDef: unknown[];
sortOrd: number;
}
interface TemplateItem {
tmplSn: number;
tmplCd: string;
tmplNm: string;
icon: string;
tmplDc: string;
sections: TemplateSectionDef[];
}
interface CategoryTemplateDef {
icon: string;
label: string;
}
interface CategorySectionDef {
sectCd: string;
sectNm: string;
icon: string;
sectDc: string;
dfltYn: string;
sortOrd: number;
}
interface CategoryItem {
ctgrSn: number;
ctgrCd: string;
ctgrNm: string;
icon: string;
ctgrDc: string;
colorCd: string;
borderColor: string;
bgActive: string;
reportNm: string;
templates: CategoryTemplateDef[];
sections: CategorySectionDef[];
}
interface ReportListItem {
reportSn: number;
tmplCd: string | null;
tmplNm: string | null;
ctgrCd: string | null;
ctgrNm: string | null;
title: string;
jrsdCd: string | null;
sttsCd: string;
authorId: string;
authorName: string;
regDtm: string;
mdfcnDtm: string | null;
}
interface SectionData {
sectCd: string;
includeYn: string;
sectData: unknown;
sortOrd: number;
}
interface ReportDetail extends ReportListItem {
acdntSn: number | null;
sections: SectionData[];
}
interface ListReportsInput {
jrsdCd?: string;
tmplCd?: string;
sttsCd?: string;
search?: string;
page?: number;
size?: number;
}
interface ListReportsResult {
items: ReportListItem[];
totalCount: number;
page: number;
size: number;
}
interface CreateReportInput {
tmplSn?: number;
ctgrSn?: number;
acdntSn?: number;
title: string;
jrsdCd?: string;
sttsCd?: string;
authorId: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
interface UpdateReportInput {
title?: string;
jrsdCd?: string;
sttsCd?: string;
acdntSn?: number | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
// ============================================================
// 템플릿/카테고리 조회
// ============================================================
export async function getTemplates(): Promise<TemplateItem[]> {
const tmplRes = await wingPool.query(
`SELECT TMPL_SN, TMPL_CD, TMPL_NM, ICON, TMPL_DC
FROM REPORT_TMPL WHERE USE_YN = 'Y' ORDER BY SORT_ORD`
);
const sectRes = await wingPool.query(
`SELECT s.TMPL_SN, s.SECT_CD, s.SECT_NM, s.FIELD_DEF, s.SORT_ORD
FROM REPORT_TMPL_SECT s
JOIN REPORT_TMPL t ON t.TMPL_SN = s.TMPL_SN
WHERE s.USE_YN = 'Y' AND t.USE_YN = 'Y'
ORDER BY s.TMPL_SN, s.SORT_ORD`
);
const sectMap = new Map<number, TemplateSectionDef[]>();
for (const row of sectRes.rows) {
const sn = row.tmpl_sn;
if (!sectMap.has(sn)) sectMap.set(sn, []);
sectMap.get(sn)!.push({
sectCd: row.sect_cd,
sectNm: row.sect_nm,
fieldDef: row.field_def,
sortOrd: row.sort_ord,
});
}
return tmplRes.rows.map((r) => ({
tmplSn: r.tmpl_sn,
tmplCd: r.tmpl_cd,
tmplNm: r.tmpl_nm,
icon: r.icon || '',
tmplDc: r.tmpl_dc || '',
sections: sectMap.get(r.tmpl_sn) || [],
}));
}
export async function getCategories(): Promise<CategoryItem[]> {
const ctgrRes = await wingPool.query(
`SELECT CTGR_SN, CTGR_CD, CTGR_NM, ICON, CTGR_DC,
COLOR_CD, BORDER_COLOR, BG_ACTIVE, REPORT_NM
FROM REPORT_ANALYSIS_CTGR WHERE USE_YN = 'Y' ORDER BY SORT_ORD`
);
const tmplRes = await wingPool.query(
`SELECT ct.CTGR_SN, ct.ICON, ct.LABEL
FROM REPORT_CTGR_TMPL ct
JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = ct.CTGR_SN
WHERE c.USE_YN = 'Y'
ORDER BY ct.CTGR_SN, ct.SORT_ORD`
);
const sectRes = await wingPool.query(
`SELECT cs.CTGR_SN, cs.SECT_CD, cs.SECT_NM, cs.ICON, cs.SECT_DC, cs.DFLT_YN, cs.SORT_ORD
FROM REPORT_CTGR_SECT cs
JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = cs.CTGR_SN
WHERE c.USE_YN = 'Y'
ORDER BY cs.CTGR_SN, cs.SORT_ORD`
);
const tmplMap = new Map<number, CategoryTemplateDef[]>();
for (const row of tmplRes.rows) {
const sn = row.ctgr_sn;
if (!tmplMap.has(sn)) tmplMap.set(sn, []);
tmplMap.get(sn)!.push({ icon: row.icon || '', label: row.label });
}
const sectMap = new Map<number, CategorySectionDef[]>();
for (const row of sectRes.rows) {
const sn = row.ctgr_sn;
if (!sectMap.has(sn)) sectMap.set(sn, []);
sectMap.get(sn)!.push({
sectCd: row.sect_cd,
sectNm: row.sect_nm,
icon: row.icon || '',
sectDc: row.sect_dc || '',
dfltYn: row.dflt_yn,
sortOrd: row.sort_ord,
});
}
return ctgrRes.rows.map((r) => ({
ctgrSn: r.ctgr_sn,
ctgrCd: r.ctgr_cd,
ctgrNm: r.ctgr_nm,
icon: r.icon || '',
ctgrDc: r.ctgr_dc || '',
colorCd: r.color_cd || '',
borderColor: r.border_color || '',
bgActive: r.bg_active || '',
reportNm: r.report_nm || '',
templates: tmplMap.get(r.ctgr_sn) || [],
sections: sectMap.get(r.ctgr_sn) || [],
}));
}
// ============================================================
// 보고서 CRUD
// ============================================================
export async function listReports(input: ListReportsInput): Promise<ListReportsResult> {
const page = Math.max(1, input.page || 1);
const size = Math.min(100, Math.max(1, input.size || 20));
const offset = (page - 1) * size;
const conditions: string[] = ["r.USE_YN = 'Y'"];
const params: unknown[] = [];
let idx = 1;
if (input.jrsdCd) {
conditions.push(`r.JRSD_CD = $${idx++}`);
params.push(input.jrsdCd);
}
if (input.tmplCd) {
conditions.push(`t.TMPL_CD = $${idx++}`);
params.push(input.tmplCd);
}
if (input.sttsCd) {
conditions.push(`r.STTS_CD = $${idx++}`);
params.push(input.sttsCd);
}
if (input.search) {
conditions.push(`r.TITLE ILIKE $${idx++}`);
params.push(`%${input.search}%`);
}
const where = conditions.join(' AND ');
const countRes = await wingPool.query(
`SELECT COUNT(*) AS cnt
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
WHERE ${where}`,
params
);
const totalCount = parseInt(countRes.rows[0].cnt, 10);
const dataRes = await wingPool.query(
`SELECT r.REPORT_SN, t.TMPL_CD, t.TMPL_NM,
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
LEFT JOIN AUTH_USER u ON u.USER_ID = r.AUTHOR_ID
WHERE ${where}
ORDER BY r.REG_DTM DESC
LIMIT $${idx++} OFFSET $${idx++}`,
[...params, size, offset]
);
return {
items: dataRes.rows.map((r) => ({
reportSn: r.report_sn,
tmplCd: r.tmpl_cd,
tmplNm: r.tmpl_nm,
ctgrCd: r.ctgr_cd,
ctgrNm: r.ctgr_nm,
title: r.title,
jrsdCd: r.jrsd_cd,
sttsCd: r.stts_cd,
authorId: r.author_id,
authorName: r.author_name || '',
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
})),
totalCount,
page,
size,
};
}
export async function getReport(reportSn: number): Promise<ReportDetail> {
const res = await wingPool.query(
`SELECT r.REPORT_SN, t.TMPL_CD, t.TMPL_NM,
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
LEFT JOIN AUTH_USER u ON u.USER_ID = r.AUTHOR_ID
WHERE r.REPORT_SN = $1 AND r.USE_YN = 'Y'`,
[reportSn]
);
if (res.rows.length === 0) {
throw new AuthError('보고서를 찾을 수 없습니다.', 404);
}
const r = res.rows[0];
const sectRes = await wingPool.query(
`SELECT SECT_CD, INCLUDE_YN, SECT_DATA, SORT_ORD
FROM REPORT_SECT_DATA
WHERE REPORT_SN = $1
ORDER BY SORT_ORD`,
[reportSn]
);
return {
reportSn: r.report_sn,
tmplCd: r.tmpl_cd,
tmplNm: r.tmpl_nm,
ctgrCd: r.ctgr_cd,
ctgrNm: r.ctgr_nm,
title: r.title,
jrsdCd: r.jrsd_cd,
sttsCd: r.stts_cd,
acdntSn: r.acdnt_sn,
authorId: r.author_id,
authorName: r.author_name || '',
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
sections: sectRes.rows.map((s) => ({
sectCd: s.sect_cd,
includeYn: s.include_yn,
sectData: s.sect_data,
sortOrd: s.sort_ord,
})),
};
}
export async function createReport(input: CreateReportInput): Promise<{ sn: number }> {
if (!input.title?.trim()) {
throw new AuthError('보고서 제목은 필수입니다.', 400);
}
const client = await wingPool.connect();
try {
await client.query('BEGIN');
const res = await client.query(
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING REPORT_SN`,
[
input.tmplSn || null,
input.ctgrSn || null,
input.acdntSn || null,
input.title.trim(),
input.jrsdCd || null,
input.sttsCd || 'DRAFT',
input.authorId,
]
);
const reportSn = res.rows[0].report_sn;
if (input.sections && input.sections.length > 0) {
for (const sect of input.sections) {
await client.query(
`INSERT INTO REPORT_SECT_DATA (REPORT_SN, SECT_CD, INCLUDE_YN, SECT_DATA, SORT_ORD)
VALUES ($1, $2, $3, $4, $5)`,
[
reportSn,
sect.sectCd,
sect.includeYn || 'Y',
JSON.stringify(sect.sectData || {}),
sect.sortOrd || 0,
]
);
}
}
await client.query('COMMIT');
return { sn: reportSn };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function updateReport(
reportSn: number,
input: UpdateReportInput,
requesterId: string
): Promise<void> {
const existing = await wingPool.query(
`SELECT AUTHOR_ID FROM REPORT WHERE REPORT_SN = $1 AND USE_YN = 'Y'`,
[reportSn]
);
if (existing.rows.length === 0) {
throw new AuthError('보고서를 찾을 수 없습니다.', 404);
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 보고서만 수정할 수 있습니다.', 403);
}
const client = await wingPool.connect();
try {
await client.query('BEGIN');
// 메타데이터 업데이트
const sets: string[] = ['MDFCN_DTM = NOW()'];
const params: unknown[] = [];
let idx = 1;
if (input.title !== undefined) {
sets.push(`TITLE = $${idx++}`);
params.push(input.title.trim());
}
if (input.jrsdCd !== undefined) {
sets.push(`JRSD_CD = $${idx++}`);
params.push(input.jrsdCd);
}
if (input.sttsCd !== undefined) {
sets.push(`STTS_CD = $${idx++}`);
params.push(input.sttsCd);
}
if (input.acdntSn !== undefined) {
sets.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn);
}
params.push(reportSn);
await client.query(
`UPDATE REPORT SET ${sets.join(', ')} WHERE REPORT_SN = $${idx}`,
params
);
// 섹션 데이터 upsert
if (input.sections && input.sections.length > 0) {
for (const sect of input.sections) {
await client.query(
`INSERT INTO REPORT_SECT_DATA (REPORT_SN, SECT_CD, INCLUDE_YN, SECT_DATA, SORT_ORD)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (REPORT_SN, SECT_CD) DO UPDATE
SET INCLUDE_YN = EXCLUDED.INCLUDE_YN,
SECT_DATA = EXCLUDED.SECT_DATA,
SORT_ORD = EXCLUDED.SORT_ORD`,
[
reportSn,
sect.sectCd,
sect.includeYn || 'Y',
JSON.stringify(sect.sectData || {}),
sect.sortOrd || 0,
]
);
}
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function deleteReport(reportSn: number, requesterId: string): Promise<void> {
const existing = await wingPool.query(
`SELECT AUTHOR_ID FROM REPORT WHERE REPORT_SN = $1 AND USE_YN = 'Y'`,
[reportSn]
);
if (existing.rows.length === 0) {
throw new AuthError('보고서를 찾을 수 없습니다.', 404);
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 보고서만 삭제할 수 있습니다.', 403);
}
await wingPool.query(
`UPDATE REPORT SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE REPORT_SN = $1`,
[reportSn]
);
}
export async function updateReportSection(
reportSn: number,
sectCd: string,
sectData: unknown,
includeYn: string,
requesterId: string
): Promise<void> {
const existing = await wingPool.query(
`SELECT AUTHOR_ID FROM REPORT WHERE REPORT_SN = $1 AND USE_YN = 'Y'`,
[reportSn]
);
if (existing.rows.length === 0) {
throw new AuthError('보고서를 찾을 수 없습니다.', 404);
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 보고서만 수정할 수 있습니다.', 403);
}
await wingPool.query(
`INSERT INTO REPORT_SECT_DATA (REPORT_SN, SECT_CD, INCLUDE_YN, SECT_DATA)
VALUES ($1, $2, $3, $4)
ON CONFLICT (REPORT_SN, SECT_CD) DO UPDATE
SET INCLUDE_YN = EXCLUDED.INCLUDE_YN,
SECT_DATA = EXCLUDED.SECT_DATA`,
[reportSn, sectCd, includeYn || 'Y', JSON.stringify(sectData || {})]
);
await wingPool.query(
`UPDATE REPORT SET MDFCN_DTM = NOW() WHERE REPORT_SN = $1`,
[reportSn]
);
}

파일 보기

@ -15,6 +15,7 @@ import menuRouter from './menus/menuRouter.js'
import auditRouter from './audit/auditRouter.js'
import boardRouter from './board/boardRouter.js'
import hnsRouter from './hns/hnsRouter.js'
import reportsRouter from './reports/reportsRouter.js'
import {
sanitizeBody,
sanitizeQuery,
@ -141,6 +142,7 @@ app.use('/api/board', boardRouter)
app.use('/api/layers', layersRouter)
app.use('/api/simulation', simulationLimiter, simulationRouter)
app.use('/api/hns', hnsRouter)
app.use('/api/reports', reportsRouter)
// 헬스 체크
app.get('/health', (_req, res) => {

파일 보기

@ -0,0 +1,238 @@
-- ============================================================
-- 007_reports.sql
-- 보고서 시스템: 템플릿 + 섹션 조합 기반 보고서 CRUD
-- ============================================================
-- 1. 보고서 템플릿 마스터 (5종)
CREATE TABLE IF NOT EXISTS REPORT_TMPL (
TMPL_SN SERIAL PRIMARY KEY,
TMPL_CD VARCHAR(30) NOT NULL UNIQUE,
TMPL_NM VARCHAR(100) NOT NULL,
ICON VARCHAR(10),
TMPL_DC VARCHAR(500),
SORT_ORD SMALLINT DEFAULT 0,
USE_YN CHAR(1) DEFAULT 'Y',
REG_DTM TIMESTAMPTZ DEFAULT NOW()
);
-- 2. 템플릿 섹션 정의 (각 템플릿별 N개 섹션)
CREATE TABLE IF NOT EXISTS REPORT_TMPL_SECT (
SECT_SN SERIAL PRIMARY KEY,
TMPL_SN INTEGER NOT NULL REFERENCES REPORT_TMPL(TMPL_SN),
SECT_CD VARCHAR(50) NOT NULL,
SECT_NM VARCHAR(100) NOT NULL,
FIELD_DEF JSONB NOT NULL,
SORT_ORD SMALLINT DEFAULT 0,
USE_YN CHAR(1) DEFAULT 'Y',
UNIQUE(TMPL_SN, SECT_CD)
);
-- 3. 분석 카테고리 (3종)
CREATE TABLE IF NOT EXISTS REPORT_ANALYSIS_CTGR (
CTGR_SN SERIAL PRIMARY KEY,
CTGR_CD VARCHAR(30) NOT NULL UNIQUE,
CTGR_NM VARCHAR(100) NOT NULL,
ICON VARCHAR(10),
CTGR_DC VARCHAR(500),
COLOR_CD VARCHAR(30),
BORDER_COLOR VARCHAR(50),
BG_ACTIVE VARCHAR(50),
REPORT_NM VARCHAR(100),
SORT_ORD SMALLINT DEFAULT 0,
USE_YN CHAR(1) DEFAULT 'Y'
);
-- 4. 분석 카테고리별 템플릿 목록
CREATE TABLE IF NOT EXISTS REPORT_CTGR_TMPL (
CTGR_TMPL_SN SERIAL PRIMARY KEY,
CTGR_SN INTEGER NOT NULL REFERENCES REPORT_ANALYSIS_CTGR(CTGR_SN),
ICON VARCHAR(10),
LABEL VARCHAR(50) NOT NULL,
SORT_ORD SMALLINT DEFAULT 0
);
-- 5. 분석 카테고리별 섹션
CREATE TABLE IF NOT EXISTS REPORT_CTGR_SECT (
SECT_SN SERIAL PRIMARY KEY,
CTGR_SN INTEGER NOT NULL REFERENCES REPORT_ANALYSIS_CTGR(CTGR_SN),
SECT_CD VARCHAR(50) NOT NULL,
SECT_NM VARCHAR(100) NOT NULL,
ICON VARCHAR(10),
SECT_DC VARCHAR(200),
DFLT_YN CHAR(1) DEFAULT 'Y',
SORT_ORD SMALLINT DEFAULT 0,
UNIQUE(CTGR_SN, SECT_CD)
);
-- 6. 보고서 인스턴스
CREATE TABLE IF NOT EXISTS REPORT (
REPORT_SN SERIAL PRIMARY KEY,
TMPL_SN INTEGER REFERENCES REPORT_TMPL(TMPL_SN),
CTGR_SN INTEGER REFERENCES REPORT_ANALYSIS_CTGR(CTGR_SN),
ACDNT_SN INTEGER,
TITLE VARCHAR(200) NOT NULL,
JRSD_CD VARCHAR(20),
STTS_CD VARCHAR(20) DEFAULT 'DRAFT',
AUTHOR_ID UUID NOT NULL REFERENCES AUTH_USER(USER_ID),
USE_YN CHAR(1) DEFAULT 'Y',
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
MDFCN_DTM TIMESTAMPTZ,
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
);
-- 7. 보고서 섹션 데이터
CREATE TABLE IF NOT EXISTS REPORT_SECT_DATA (
DATA_SN SERIAL PRIMARY KEY,
REPORT_SN INTEGER NOT NULL REFERENCES REPORT(REPORT_SN) ON DELETE CASCADE,
SECT_CD VARCHAR(50) NOT NULL,
INCLUDE_YN CHAR(1) DEFAULT 'Y',
SECT_DATA JSONB NOT NULL DEFAULT '{}',
SORT_ORD SMALLINT DEFAULT 0,
UNIQUE(REPORT_SN, SECT_CD)
);
-- 인덱스
CREATE INDEX IF NOT EXISTS IDX_REPORT_TMPL ON REPORT(TMPL_SN);
CREATE INDEX IF NOT EXISTS IDX_REPORT_CTGR ON REPORT(CTGR_SN);
CREATE INDEX IF NOT EXISTS IDX_REPORT_AUTHOR ON REPORT(AUTHOR_ID);
CREATE INDEX IF NOT EXISTS IDX_REPORT_STTS ON REPORT(STTS_CD);
CREATE INDEX IF NOT EXISTS IDX_REPORT_REG_DTM ON REPORT(REG_DTM DESC);
CREATE INDEX IF NOT EXISTS IDX_REPORT_SECT_REPORT ON REPORT_SECT_DATA(REPORT_SN);
-- ============================================================
-- 초기 데이터: 보고서 템플릿 5종
-- ============================================================
INSERT INTO REPORT_TMPL (TMPL_CD, TMPL_NM, ICON, TMPL_DC, SORT_ORD) VALUES
('INITIAL', '초기보고서', '📋', '사고 발생 직후 초기 상황 보고', 1),
('COMMAND', '지휘부 보고', '📊', '지휘부 보고용 요약 문서', 2),
('FORECAST', '예측보고서', '📈', '확산예측 결과 및 민감자원 분석', 3),
('COMPREHENSIVE', '종합보고서', '📑', '전체 사고 대응 종합 보고 문서', 4),
('SPILL', '유출유 보고', '🛢️', '유류오염사고 대응지원 상황도', 5)
ON CONFLICT (TMPL_CD) DO NOTHING;
-- ============================================================
-- 초기 데이터: 템플릿별 섹션 정의
-- ============================================================
-- 초기보고서 (5 섹션)
INSERT INTO REPORT_TMPL_SECT (TMPL_SN, SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
SELECT t.TMPL_SN, v.SECT_CD, v.SECT_NM, v.FIELD_DEF::jsonb, v.SORT_ORD
FROM REPORT_TMPL t, (VALUES
('basic-info', '1. 기본정보', '[{"key":"incident.writeTime","label":"보고일시","type":"text"},{"key":"author","label":"작성자","type":"text"},{"key":"targets","label":"보고대상","type":"checkbox-group","options":["본청","지방청","유관기서"]}]', 1),
('incident-overview','2. 사고개요', '[{"key":"incident.name","label":"사고명","type":"text"},{"key":"incident.occurTime","label":"발생일시","type":"text"},{"key":"incident.location","label":"발생위치","type":"text"},{"key":"incident.shipName","label":"사고선박","type":"text"},{"key":"incident.accidentType","label":"사고유형","type":"text"}]', 2),
('spill-status', '3. 유출현황', '[{"key":"incident.pollutant","label":"유출유종","type":"text"},{"key":"incident.spillAmount","label":"유출량","type":"text"},{"key":"spillPattern","label":"유출형태","type":"text"},{"key":"spreadStatus","label":"확산현황","type":"text"}]', 3),
('initial-response', '4. 초동조치 사항', '[{"key":"initialResponse","label":"","type":"textarea"}]', 4),
('future-plan', '5. 향후 대응계획', '[{"key":"futurePlan","label":"","type":"textarea"}]', 5)
) AS v(SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
WHERE t.TMPL_CD = 'INITIAL'
ON CONFLICT (TMPL_SN, SECT_CD) DO NOTHING;
-- 지휘부 보고 (4 섹션)
INSERT INTO REPORT_TMPL_SECT (TMPL_SN, SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
SELECT t.TMPL_SN, v.SECT_CD, v.SECT_NM, v.FIELD_DEF::jsonb, v.SORT_ORD
FROM REPORT_TMPL t, (VALUES
('basic-info', '1. 기본정보', '[{"key":"incident.writeTime","label":"보고일시","type":"text"},{"key":"author","label":"작성자","type":"text"}]', 1),
('incident-summary','2. 사고 요약', '[{"key":"incident.name","label":"사고명","type":"text"},{"key":"incident.occurTime","label":"발생일시","type":"text"},{"key":"incident.location","label":"발생위치","type":"text"},{"key":"incident.pollutant","label":"유출유종","type":"text"},{"key":"incident.spillAmount","label":"유출량","type":"text"}]', 2),
('response-status', '3. 대응현황', '[{"key":"responseStatus","label":"","type":"textarea"}]', 3),
('suggestions', '4. 건의사항', '[{"key":"suggestions","label":"","type":"textarea"}]', 4)
) AS v(SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
WHERE t.TMPL_CD = 'COMMAND'
ON CONFLICT (TMPL_SN, SECT_CD) DO NOTHING;
-- 예측보고서 (5 섹션)
INSERT INTO REPORT_TMPL_SECT (TMPL_SN, SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
SELECT t.TMPL_SN, v.SECT_CD, v.SECT_NM, v.FIELD_DEF::jsonb, v.SORT_ORD
FROM REPORT_TMPL t, (VALUES
('basic-info', '1. 기본정보', '[{"key":"incident.writeTime","label":"보고일시","type":"text"},{"key":"author","label":"작성자","type":"text"}]', 1),
('incident-overview','2. 사고개요', '[{"key":"incident.name","label":"사고명","type":"text"},{"key":"incident.occurTime","label":"발생일시","type":"text"},{"key":"incident.location","label":"발생위치","type":"text"},{"key":"incident.pollutant","label":"유출유종","type":"text"},{"key":"incident.spillAmount","label":"유출량","type":"text"}]', 2),
('weather-summary', '3. 해양기상 현황', '[{"key":"weatherSummary","label":"","type":"textarea"}]', 3),
('spread-result', '4. 확산예측 결과', '[{"key":"spreadResult","label":"","type":"textarea"}]', 4),
('sensitive-impact', '5. 민감자원 영향', '[{"key":"sensitiveImpact","label":"","type":"textarea"}]', 5)
) AS v(SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
WHERE t.TMPL_CD = 'FORECAST'
ON CONFLICT (TMPL_SN, SECT_CD) DO NOTHING;
-- 종합보고서 (6 섹션)
INSERT INTO REPORT_TMPL_SECT (TMPL_SN, SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
SELECT t.TMPL_SN, v.SECT_CD, v.SECT_NM, v.FIELD_DEF::jsonb, v.SORT_ORD
FROM REPORT_TMPL t, (VALUES
('basic-info', '1. 기본정보', '[{"key":"incident.writeTime","label":"보고일시","type":"text"},{"key":"author","label":"작성자","type":"text"}]', 1),
('incident-overview', '2. 사고개요', '[{"key":"incident.name","label":"사고명","type":"text"},{"key":"incident.occurTime","label":"발생일시","type":"text"},{"key":"incident.location","label":"발생위치","type":"text"},{"key":"incident.shipName","label":"사고선박","type":"text"},{"key":"incident.accidentType","label":"사고유형","type":"text"}]', 2),
('spill-spread', '3. 유출 및 확산현황', '[{"key":"incident.pollutant","label":"유출유종","type":"text"},{"key":"incident.spillAmount","label":"유출량","type":"text"},{"key":"spreadSummary","label":"확산현황","type":"textarea"}]', 3),
('response-detail', '4. 대응현황', '[{"key":"responseDetail","label":"","type":"textarea"}]', 4),
('damage-report', '5. 피해현황', '[{"key":"damageReport","label":"","type":"textarea"}]', 5),
('future-plan-detail','6. 향후계획', '[{"key":"futurePlanDetail","label":"","type":"textarea"}]', 6)
) AS v(SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
WHERE t.TMPL_CD = 'COMPREHENSIVE'
ON CONFLICT (TMPL_SN, SECT_CD) DO NOTHING;
-- 유출유 보고 (14 섹션)
INSERT INTO REPORT_TMPL_SECT (TMPL_SN, SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
SELECT t.TMPL_SN, v.SECT_CD, v.SECT_NM, v.FIELD_DEF::jsonb, v.SORT_ORD
FROM REPORT_TMPL t, (VALUES
('incident-info', '1. 사고 정보', '[{"key":"incident.name","label":"사고명","type":"text"},{"key":"incident.writeTime","label":"작성시간","type":"text"},{"key":"incident.shipName","label":"선명(시설명)","type":"text"},{"key":"agent","label":"제원","type":"text"},{"key":"incident.location","label":"사고위치","type":"text"},{"key":"incident.lat","label":"좌표(위도)","type":"text"},{"key":"incident.lon","label":"좌표(경도)","type":"text"},{"key":"incident.occurTime","label":"발생시각","type":"text"},{"key":"incident.accidentType","label":"사고유형","type":"text"},{"key":"incident.pollutant","label":"오염물질","type":"text"},{"key":"incident.spillAmount","label":"유출 추정량(㎘)","type":"text"},{"key":"incident.depth","label":"수심","type":"text"},{"key":"incident.seabed","label":"저질","type":"text"}]', 1),
('tide-info', '2. 해양기상정보 - 조석정보', '[{"key":"tideDate","label":"일자","type":"text"},{"key":"tideName","label":"물때","type":"text"},{"key":"lowTide1","label":"저조(1차)","type":"text"},{"key":"highTide1","label":"고조(1차)","type":"text"},{"key":"lowTide2","label":"저조(2차)","type":"text"},{"key":"highTide2","label":"고조(2차)","type":"text"}]', 2),
('weather-info', '2. 해양기상정보 - 기상예보', '[{"key":"weatherTime","label":"기상 예측시간","type":"text"},{"key":"sunrise","label":"일출","type":"text"},{"key":"sunset","label":"일몰","type":"text"},{"key":"windDir","label":"풍향","type":"text"},{"key":"windSpeed","label":"풍속(m/s)","type":"text"},{"key":"currentDir","label":"유향","type":"text"},{"key":"currentSpeed","label":"유속(knot)","type":"text"},{"key":"waveHeight","label":"파고(m)","type":"text"}]', 3),
('spread-detail', '3. 유출유 확산예측 - 시간별 상세정보', '[{"key":"spread3h_weathered","label":"3시간 풍화량(kL)","type":"text"},{"key":"spread3h_seaRemain","label":"3시간 해상잔존량(kL)","type":"text"},{"key":"spread3h_coastAttach","label":"3시간 연안부착량(kL)","type":"text"},{"key":"spread3h_area","label":"3시간 오염해역면적(km²)","type":"text"},{"key":"spread6h_weathered","label":"6시간 풍화량(kL)","type":"text"},{"key":"spread6h_seaRemain","label":"6시간 해상잔존량(kL)","type":"text"},{"key":"spread6h_coastAttach","label":"6시간 연안부착량(kL)","type":"text"},{"key":"spread6h_area","label":"6시간 오염해역면적(km²)","type":"text"}]', 4),
('analysis', '3. 분석', '[{"key":"spreadAnalysis","label":"","type":"textarea"}]', 5),
('aquaculture', '4. 민감자원 - 양식장 분포(10km 내)', '[{"key":"aquaculture","label":"","type":"textarea"}]', 6),
('beaches', '4. 민감자원 - 해수욕장/수산시장/해수취수시설', '[{"key":"beaches","label":"","type":"textarea"}]', 7),
('esi-coast', '4. 해안선(ESI) 분포', '[{"key":"esi1_vertical","label":"ESI 1 수직암반(km)","type":"text"},{"key":"esi2_horizontal","label":"ESI 2 수평암반(km)","type":"text"},{"key":"esi3_finesand","label":"ESI 3 세립질 모래(km)","type":"text"},{"key":"esi4_coarsesand","label":"ESI 4 조립질 모래(km)","type":"text"},{"key":"esi5_sandgravel","label":"ESI 5 모래·자갈(km)","type":"text"},{"key":"esi6a_gravel","label":"ESI 6A 자갈(km)","type":"text"},{"key":"esi6b_riprap","label":"ESI 6B 투과성 사석(km)","type":"text"},{"key":"esi7_semiclosed","label":"ESI 7 반폐쇄성 해안(km)","type":"text"},{"key":"esi8a_mudflat","label":"ESI 8A 갯벌(km)","type":"text"},{"key":"esi8b_saltmarsh","label":"ESI 8B 염습지(km)","type":"text"}]', 8),
('bio-species', '4. 생물종(보호종) / 서식지 분포', '[{"key":"bioSpecies","label":"","type":"textarea"}]', 9),
('sensitivity', '4. 통합민감도 평가', '[{"key":"sens_veryHigh","label":"매우 높음(km²)","type":"text"},{"key":"sens_high","label":"높음(km²)","type":"text"},{"key":"sens_medium","label":"보통(km²)","type":"text"},{"key":"sens_low","label":"낮음(km²)","type":"text"}]', 10),
('defense-strategy', '5. 방제전략 - 방제자원 배치 현황(반경 30km)', '[{"key":"defenseShips","label":"","type":"textarea"}]', 11),
('other-equipment', '5. 기타 장비', '[{"key":"otherEquipment","label":"","type":"textarea"}]', 12),
('oil-recovery', '6. 방제선/자원 동원 결과', '[{"key":"oilRecovery","label":"","type":"textarea"}]', 13),
('result-summary', '6. 동원 방제선 내역', '[{"key":"totalSpill","label":"유출량(kL)","type":"text"},{"key":"totalWeathered","label":"누적풍화량(kL)","type":"text"},{"key":"totalRecovered","label":"누적회수량(kL)","type":"text"},{"key":"totalSeaRemain","label":"누적해상잔존량(kL)","type":"text"},{"key":"totalCoastAttach","label":"누적연안부착량(kL)","type":"text"}]', 14)
) AS v(SECT_CD, SECT_NM, FIELD_DEF, SORT_ORD)
WHERE t.TMPL_CD = 'SPILL'
ON CONFLICT (TMPL_SN, SECT_CD) DO NOTHING;
-- ============================================================
-- 초기 데이터: 분석 카테고리 3종
-- ============================================================
INSERT INTO REPORT_ANALYSIS_CTGR (CTGR_CD, CTGR_NM, ICON, CTGR_DC, COLOR_CD, BORDER_COLOR, BG_ACTIVE, REPORT_NM, SORT_ORD) VALUES
('OIL', '유출유 확산예측', '🛢', 'KOSPS · OpenDrift · POSEIDON', 'var(--cyan)', 'rgba(6,182,212,0.4)', 'rgba(6,182,212,0.08)', '유출유 확산예측 보고서', 1),
('HNS', 'HNS 대기확산', '🧪', 'ALOHA · WRF-Chem', 'var(--orange)', 'rgba(249,115,22,0.4)', 'rgba(249,115,22,0.08)','HNS 대기확산 예측보고서', 2),
('RESCUE', '긴급구난', '🚨', '복원성 · 좌초위험 분석', 'var(--red)', 'rgba(239,68,68,0.4)', 'rgba(239,68,68,0.08)', '긴급구난 상황보고서', 3)
ON CONFLICT (CTGR_CD) DO NOTHING;
-- 카테고리별 템플릿 목록
INSERT INTO REPORT_CTGR_TMPL (CTGR_SN, ICON, LABEL, SORT_ORD)
SELECT c.CTGR_SN, v.ICON, v.LABEL, v.SORT_ORD
FROM REPORT_ANALYSIS_CTGR c, (VALUES
('OIL', '🔬', '예측보고서', 1), ('OIL', '📋', '초기보고서', 2), ('OIL', '📊', '지휘부 보고', 3), ('OIL', '📑', '종합보고서', 4),
('HNS', '🧪', 'HNS 예측보고서', 1), ('HNS', '📋', '초기보고서', 2), ('HNS', '📊', '지휘부 보고', 3), ('HNS', '🆘', 'EmS 대응보고', 4),
('RESCUE', '🚨', '긴급구난 상황보고', 1), ('RESCUE', '📋', '초기보고서', 2), ('RESCUE', '📊', '지휘부 보고', 3), ('RESCUE', '📑', '종합보고서', 4)
) AS v(CTGR_CD, ICON, LABEL, SORT_ORD)
WHERE c.CTGR_CD = v.CTGR_CD;
-- 카테고리별 섹션
INSERT INTO REPORT_CTGR_SECT (CTGR_SN, SECT_CD, SECT_NM, ICON, SECT_DC, DFLT_YN, SORT_ORD)
SELECT c.CTGR_SN, v.SECT_CD, v.SECT_NM, v.ICON, v.SECT_DC, v.DFLT_YN, v.SORT_ORD
FROM REPORT_ANALYSIS_CTGR c, (VALUES
-- OIL 섹션 (6개)
('OIL', 'oil-spread', '유출유 확산예측 결과', '🌊', 'KOSPS/OpenDrift/POSEIDON 예측 결과 및 비교', 'Y', 1),
('OIL', 'oil-pollution', '오염종합상황', '📊', '유출량, 풍화량, 해상잔유량, 오염면적', 'Y', 2),
('OIL', 'oil-sensitive', '민감자원 현황', '🐟', '어장정보, 양식업, 환경생태 자원', 'Y', 3),
('OIL', 'oil-coastal', '해안부착 현황', '🏖', '해안 부착 위치, 시간, 오염 길이', 'Y', 4),
('OIL', 'oil-defense', '방제전략·자원배치', '🛡', '방제방법, 방제자원 배치 계획', 'Y', 5),
('OIL', 'oil-tide', '조석·기상정보', '🌊', '사고 해역 조석·기상 현황', 'Y', 6),
-- HNS 섹션 (7개)
('HNS', 'hns-atm', '대기확산 예측 결과', '💨', 'ALOHA/WRF-Chem 모델 확산 결과', 'Y', 1),
('HNS', 'hns-hazard', '위험구역·방제거리', '⚠️', 'ERPGs 기준 위험구역 범위', 'Y', 2),
('HNS', 'hns-substance', 'HNS 물질정보', '🧬', '물질 특성, 독성, UN번호', 'Y', 3),
('HNS', 'hns-ppe', 'PPE·대응장비', '🛡', '개인보호장비, 대응장비 배치', 'Y', 4),
('HNS', 'hns-facility', '영향시설·대피현황', '🏫', '주변 시설, 인구, 대피 계획', 'Y', 5),
('HNS', 'hns-3d', '3D 공간분포', '📐', '수직·수평 농도 분포', 'N', 6),
('HNS', 'hns-weather', '기상·해상 조건', '🌊', '풍향·풍속, 대기안정도', 'Y', 7),
-- RESCUE 섹션 (6개)
('RESCUE', 'rescue-safety', '선박 안전성 평가', '🚢', 'GM, 경사각, 트림 분석', 'Y', 1),
('RESCUE', 'rescue-timeline', '사고 유형·경과', '', '사고 유형별 타임라인', 'Y', 2),
('RESCUE', 'rescue-casualty', '인명현황', '👥', '인원 현황, 구조 상태', 'Y', 3),
('RESCUE', 'rescue-resource', '구난 자원 현황', '🛟', '예인선, 헬기, 구난 장비 배치', 'Y', 4),
('RESCUE', 'rescue-grounding', '좌초위험 해역', '🗺', '좌초 위험 구역 분석', 'Y', 5),
('RESCUE', 'rescue-weather', '기상·해상 조건', '🌊', '파고, 풍속, 조류 현황', 'Y', 6)
) AS v(CTGR_CD, SECT_CD, SECT_NM, ICON, SECT_DC, DFLT_YN, SORT_ORD)
WHERE c.CTGR_CD = v.CTGR_CD
ON CONFLICT (CTGR_SN, SECT_CD) DO NOTHING;

파일 보기

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { saveReport } from '../services/reportsApi'
// ─── Data Types ─────────────────────────────────────────────
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
@ -141,32 +142,7 @@ export function createSampleReport(): OilSpillReportData {
}
}
// ─── localStorage helpers ───────────────────────────────────
const STORAGE_KEY = 'wing-reports'
// eslint-disable-next-line react-refresh/only-export-components
export function loadReports(): OilSpillReportData[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch { return [] }
}
// eslint-disable-next-line react-refresh/only-export-components
export function saveReportToStorage(report: OilSpillReportData) {
const list = loadReports()
const idx = list.findIndex(r => r.id === report.id)
report.updatedAt = new Date().toISOString()
if (idx >= 0) list[idx] = report
else list.unshift(report)
localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
}
// eslint-disable-next-line react-refresh/only-export-components
export function deleteReportFromStorage(id: string) {
const list = loadReports().filter(r => r.id !== id)
localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
}
// ─── localStorage helpers 제거됨 — reportsApi.ts 사용 ────────
// ─── Styles ─────────────────────────────────────────────────
const S = {
@ -544,14 +520,19 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
if (initialData) setData(initialData)
}, [initialData])
const handleSave = useCallback(() => {
const handleSave = useCallback(async () => {
let reportData = data
if (!data.title) {
const title = data.incident.name || `보고서 ${new Date().toLocaleDateString('ko-KR')}`
reportData = { ...data, title }
}
saveReportToStorage(reportData)
onSave?.(reportData)
try {
await saveReport(reportData)
onSave?.(reportData)
} catch (err) {
console.error('[reports] 저장 오류:', err)
alert('보고서 저장 중 오류가 발생했습니다.')
}
}, [data, onSave])
const pages = [

파일 보기

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import {
createEmptyReport,
saveReportToStorage,
} from './OilSpillReportTemplate';
import { consumeReportGenCategory } from '@common/hooks/useSubMenu';
import { saveReport } from '../services/reportsApi';
import {
CATEGORIES,
sampleOilData,
@ -51,7 +51,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
}))
}
const handleSave = () => {
const handleSave = async () => {
const report = createEmptyReport()
report.reportType = activeCat === 0 ? '예측보고서' : activeCat === 1 ? '종합보고서' : '초기보고서'
report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난'
@ -62,8 +62,13 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
report.incident.pollutant = sampleOilData.pollution.oilType
report.incident.spillAmount = sampleOilData.pollution.spillAmount
}
saveReportToStorage(report)
onSave()
try {
await saveReport(report)
onSave()
} catch (err) {
console.error('[reports] 저장 오류:', err)
alert('보고서 저장 중 오류가 발생했습니다.')
}
}
const handleDownload = () => {

파일 보기

@ -1,11 +1,10 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import {
OilSpillReportTemplate,
loadReports,
deleteReportFromStorage,
type OilSpillReportData,
type Jurisdiction,
} from './OilSpillReportTemplate'
import { loadReportsFromApi, loadReportDetail, deleteReportApi } from '../services/reportsApi'
import { useSubMenu } from '@common/hooks/useSubMenu'
import { templateTypes } from './reportTypes'
import {
@ -31,9 +30,16 @@ export function ReportsView() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [previewReport, setPreviewReport] = useState<OilSpillReportData | null>(null)
const refreshList = () => setReports(loadReports())
const refreshList = useCallback(async () => {
try {
const list = await loadReportsFromApi()
setReports(list)
} catch (err) {
console.error('[reports] 목록 조회 오류:', err)
}
}, [])
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refreshList() }, [])
useEffect(() => { refreshList() }, [refreshList])
// SubMenuBar 탭과 내부 view 동기화
useEffect(() => {
@ -46,15 +52,20 @@ export function ReportsView() {
} else if (activeSubTab === 'generate') {
setView({ screen: 'generate' })
}
}, [activeSubTab])
}, [activeSubTab, refreshList])
const handleSave = () => { refreshList(); setView({ screen: 'list' }); setActiveSubTab('report-list') }
const handleDelete = (id: string) => {
const handleDelete = async (id: string) => {
if (!confirm('이 보고서를 삭제하시겠습니까?')) return
deleteReportFromStorage(id)
refreshList()
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n })
try {
await deleteReportApi(parseInt(id, 10))
refreshList()
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n })
} catch (err) {
console.error('[reports] 삭제 오류:', err)
alert('보고서 삭제 중 오류가 발생했습니다.')
}
}
const formatDate = (iso: string) => {
@ -142,7 +153,7 @@ export function ReportsView() {
<tr key={report.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-3 py-3 text-center"><input type="checkbox" checked={selectedIds.has(report.id)} onChange={() => toggleSelect(report.id)} className="accent-[#06b6d4] w-3.5 h-3.5" /></td>
<td className="px-3 py-3 text-[12px] text-text-3 text-center font-mono">{idx + 1}</td>
<td className="px-4 py-3 truncate"><button onClick={() => setPreviewReport(report)} className="text-[12px] font-semibold text-primary-cyan hover:underline text-left font-korean truncate max-w-full block">{report.title || '제목 없음'}</button></td>
<td className="px-4 py-3 truncate"><button onClick={async () => { try { const detail = await loadReportDetail(parseInt(report.id, 10)); setPreviewReport(detail) } catch { setPreviewReport(report) } }} className="text-[12px] font-semibold text-primary-cyan hover:underline text-left font-korean truncate max-w-full block">{report.title || '제목 없음'}</button></td>
<td className="px-3 py-3 text-center"><span className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean" style={{ background: typeColors[report.reportType]?.bg || 'rgba(138,150,168,0.15)', color: typeColors[report.reportType]?.text || '#8a96a8' }}>{report.reportType}</span></td>
{(() => {
const cat = inferAnalysisCategory(report)
@ -164,7 +175,7 @@ export function ReportsView() {
<td className="px-3 py-3 text-[11px] text-text-2 text-center font-korean">{report.author || '-'}</td>
<td className="px-3 py-3 text-[11px] text-text-2 text-center font-korean">{report.jurisdiction}</td>
<td className="px-3 py-3 text-center"><span className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean" style={{ background: statusColors[report.status]?.bg, color: statusColors[report.status]?.text }}>{report.status}</span></td>
<td className="px-3 py-3 text-center"><button onClick={() => setView({ screen: 'edit', data: { ...report } })} className="text-[11px] text-primary-cyan hover:underline font-korean"></button></td>
<td className="px-3 py-3 text-center"><button onClick={async () => { try { const detail = await loadReportDetail(parseInt(report.id, 10)); setView({ screen: 'edit', data: detail }) } catch { setView({ screen: 'edit', data: { ...report } }) } }} className="text-[11px] text-primary-cyan hover:underline font-korean"></button></td>
<td className="px-3 py-3 text-center"><button className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded bg-[rgba(239,68,68,0.12)] text-[#ef4444] border border-[rgba(239,68,68,0.25)] hover:bg-[rgba(239,68,68,0.2)] transition-all">PDF</button></td>
<td className="px-3 py-3 text-center"><button onClick={() => handleDelete(report.id)} className="w-7 h-7 rounded flex items-center justify-center text-status-red hover:bg-[rgba(239,68,68,0.1)] transition-all text-sm">🗑</button></td>
</tr>

파일 보기

@ -1,12 +1,12 @@
import { useState } from 'react';
import {
createEmptyReport,
saveReportToStorage,
type ReportType,
type Jurisdiction,
} from './OilSpillReportTemplate';
import { templateTypes } from './reportTypes';
import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils';
import { saveReport } from '../services/reportsApi';
interface TemplateFormEditorProps {
onSave: () => void;
@ -41,7 +41,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
setFormData(p => ({ ...p, [key]: val }))
}
const handleSave = () => {
const handleSave = async () => {
const report = createEmptyReport()
report.reportType = selectedType
report.jurisdiction = reportMeta.jurisdiction
@ -57,8 +57,13 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
if (val) (report.incident as any)[f] = val
})
report.analysis = formData['spreadAnalysis'] || formData['initialResponse'] || formData['responseStatus'] || formData['responseDetail'] || ''
saveReportToStorage(report)
onSave()
try {
await saveReport(report)
onSave()
} catch (err) {
console.error('[reports] 저장 오류:', err)
alert('보고서 저장 중 오류가 발생했습니다.')
}
}
const doExport = (format: 'pdf' | 'hwp') => {

파일 보기

@ -0,0 +1,351 @@
import { api } from '@common/services/api';
import type { OilSpillReportData, ReportType, Jurisdiction, ReportStatus, AnalysisCategory } from '../components/OilSpillReportTemplate';
// ============================================================
// API 응답 타입
// ============================================================
export interface ApiTemplateSection {
sectCd: string;
sectNm: string;
fieldDef: { key: string; label: string; type: string; options?: string[] }[];
sortOrd: number;
}
export interface ApiTemplate {
tmplSn: number;
tmplCd: string;
tmplNm: string;
icon: string;
tmplDc: string;
sections: ApiTemplateSection[];
}
export interface ApiCategoryTemplate {
icon: string;
label: string;
}
export interface ApiCategorySection {
sectCd: string;
sectNm: string;
icon: string;
sectDc: string;
dfltYn: string;
sortOrd: number;
}
export interface ApiCategory {
ctgrSn: number;
ctgrCd: string;
ctgrNm: string;
icon: string;
ctgrDc: string;
colorCd: string;
borderColor: string;
bgActive: string;
reportNm: string;
templates: ApiCategoryTemplate[];
sections: ApiCategorySection[];
}
export interface ApiReportListItem {
reportSn: number;
tmplCd: string | null;
tmplNm: string | null;
ctgrCd: string | null;
ctgrNm: string | null;
title: string;
jrsdCd: string | null;
sttsCd: string;
authorId: string;
authorName: string;
regDtm: string;
mdfcnDtm: string | null;
}
export interface ApiReportSectionData {
sectCd: string;
includeYn: string;
sectData: unknown;
sortOrd: number;
}
export interface ApiReportDetail extends ApiReportListItem {
acdntSn: number | null;
sections: ApiReportSectionData[];
}
export interface ApiReportListResponse {
items: ApiReportListItem[];
totalCount: number;
page: number;
size: number;
}
// ============================================================
// 코드 매핑
// ============================================================
const STATUS_TO_CODE: Record<ReportStatus, string> = {
'완료': 'COMPLETED',
'수행중': 'IN_PROGRESS',
'테스트': 'DRAFT',
};
const CODE_TO_STATUS: Record<string, ReportStatus> = {
COMPLETED: '완료',
IN_PROGRESS: '수행중',
DRAFT: '테스트',
};
const TMPL_CODE_TO_TYPE: Record<string, ReportType> = {
INITIAL: '초기보고서',
COMMAND: '지휘부 보고',
FORECAST: '예측보고서',
COMPREHENSIVE: '종합보고서',
SPILL: '유출유 보고',
};
const TYPE_TO_TMPL_CODE: Record<ReportType, string> = {
'초기보고서': 'INITIAL',
'지휘부 보고': 'COMMAND',
'예측보고서': 'FORECAST',
'종합보고서': 'COMPREHENSIVE',
'유출유 보고': 'SPILL',
};
const CTGR_CODE_TO_CAT: Record<string, AnalysisCategory> = {
OIL: '유출유 확산예측',
HNS: 'HNS 대기확산',
RESCUE: '긴급구난',
};
const CAT_TO_CTGR_CODE: Record<string, string> = {
'유출유 확산예측': 'OIL',
'HNS 대기확산': 'HNS',
'긴급구난': 'RESCUE',
};
// ============================================================
// 캐시 (템플릿/카테고리 — 변경 빈도 낮음)
// ============================================================
let templatesCache: ApiTemplate[] | null = null;
let categoriesCache: ApiCategory[] | null = null;
// ============================================================
// API 함수
// ============================================================
export async function fetchTemplates(): Promise<ApiTemplate[]> {
if (templatesCache) return templatesCache;
const res = await api.get<ApiTemplate[]>('/reports/templates');
templatesCache = res.data;
return res.data;
}
export async function fetchCategories(): Promise<ApiCategory[]> {
if (categoriesCache) return categoriesCache;
const res = await api.get<ApiCategory[]>('/reports/categories');
categoriesCache = res.data;
return res.data;
}
export async function fetchReports(params?: {
jrsdCd?: string;
tmplCd?: string;
sttsCd?: string;
search?: string;
page?: number;
size?: number;
}): Promise<ApiReportListResponse> {
const res = await api.get<ApiReportListResponse>('/reports', { params });
return res.data;
}
export async function fetchReport(sn: number): Promise<ApiReportDetail> {
const res = await api.get<ApiReportDetail>(`/reports/${sn}`);
return res.data;
}
export async function createReportApi(input: {
tmplSn?: number;
ctgrSn?: number;
acdntSn?: number;
title: string;
jrsdCd?: string;
sttsCd?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<{ sn: number }> {
const res = await api.post<{ sn: number }>('/reports', input);
return res.data;
}
export async function updateReportApi(sn: number, input: {
title?: string;
jrsdCd?: string;
sttsCd?: string;
acdntSn?: number | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<void> {
await api.post(`/reports/${sn}/update`, input);
}
export async function deleteReportApi(sn: number): Promise<void> {
await api.post(`/reports/${sn}/delete`);
}
// ============================================================
// OilSpillReportData ↔ API 변환
// ============================================================
const SECTION_KEYS = [
'incident', 'tide', 'weather', 'spread', 'aquaculture',
'beaches', 'markets', 'esi', 'species', 'habitat',
'sensitivity', 'vessels', 'recovery', 'result',
] as const;
async function findTmplSn(reportType: ReportType): Promise<number | undefined> {
const tmpls = await fetchTemplates();
const code = TYPE_TO_TMPL_CODE[reportType];
return tmpls.find(t => t.tmplCd === code)?.tmplSn;
}
async function findCtgrSn(category: AnalysisCategory): Promise<number | undefined> {
if (!category) return undefined;
const cats = await fetchCategories();
const code = CAT_TO_CTGR_CODE[category];
return cats.find(c => c.ctgrCd === code)?.ctgrSn;
}
export async function saveReport(data: OilSpillReportData): Promise<number> {
const tmplSn = await findTmplSn(data.reportType);
const ctgrSn = data.analysisCategory ? await findCtgrSn(data.analysisCategory) : undefined;
const sttsCd = STATUS_TO_CODE[data.status] || 'DRAFT';
const sections: { sectCd: string; sectData: unknown; sortOrd: number }[] = [];
let sortOrd = 0;
for (const key of SECTION_KEYS) {
sections.push({ sectCd: key, sectData: data[key], sortOrd: sortOrd++ });
}
// analysis + etcEquipment 합산
sections.push({ sectCd: 'analysis', sectData: { analysis: data.analysis, etcEquipment: data.etcEquipment }, sortOrd: sortOrd++ });
// reportSn이 있으면 update, 없으면 create
const existingSn = (data as OilSpillReportData & { reportSn?: number }).reportSn;
if (existingSn) {
await updateReportApi(existingSn, {
title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction,
sttsCd,
sections,
});
return existingSn;
}
const result = await createReportApi({
tmplSn,
ctgrSn,
title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction,
sttsCd,
sections,
});
return result.sn;
}
export function apiListItemToReportData(item: ApiReportListItem): OilSpillReportData {
return {
id: String(item.reportSn),
title: item.title,
createdAt: item.regDtm,
updatedAt: item.mdfcnDtm || item.regDtm,
author: item.authorName || '',
reportType: (item.tmplCd ? TMPL_CODE_TO_TYPE[item.tmplCd] : '초기보고서') || '초기보고서',
analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '',
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
// 목록에서는 섹션 데이터 없음 — 빈 기본값
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
tide: [], weather: [], spread: [],
analysis: '', aquaculture: [], beaches: [], markets: [],
esi: [], species: [], habitat: [], sensitivity: [],
vessels: [], etcEquipment: '', recovery: [],
result: { spillTotal: '', weatheredTotal: '', recoveredTotal: '', seaRemainTotal: '', coastAttachTotal: '' },
};
}
export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportData & { reportSn: number } {
const base = apiListItemToReportData(detail);
const reportData = { ...base, reportSn: detail.reportSn } as OilSpillReportData & { reportSn: number };
// 섹션 데이터 복원
for (const sect of detail.sections) {
const d = sect.sectData as Record<string, unknown>;
if (!d) continue;
switch (sect.sectCd) {
case 'incident':
reportData.incident = d as OilSpillReportData['incident'];
break;
case 'tide':
reportData.tide = d as OilSpillReportData['tide'];
break;
case 'weather':
reportData.weather = d as OilSpillReportData['weather'];
break;
case 'spread':
reportData.spread = d as OilSpillReportData['spread'];
break;
case 'analysis':
if (typeof d === 'object' && d !== null) {
reportData.analysis = (d as { analysis?: string }).analysis || '';
reportData.etcEquipment = (d as { etcEquipment?: string }).etcEquipment || '';
}
break;
case 'aquaculture':
reportData.aquaculture = d as OilSpillReportData['aquaculture'];
break;
case 'beaches':
reportData.beaches = d as OilSpillReportData['beaches'];
break;
case 'markets':
reportData.markets = d as OilSpillReportData['markets'];
break;
case 'esi':
reportData.esi = d as OilSpillReportData['esi'];
break;
case 'species':
reportData.species = d as OilSpillReportData['species'];
break;
case 'habitat':
reportData.habitat = d as OilSpillReportData['habitat'];
break;
case 'sensitivity':
reportData.sensitivity = d as OilSpillReportData['sensitivity'];
break;
case 'vessels':
reportData.vessels = d as OilSpillReportData['vessels'];
break;
case 'recovery':
reportData.recovery = d as OilSpillReportData['recovery'];
break;
case 'result':
reportData.result = d as OilSpillReportData['result'];
break;
}
}
return reportData;
}
export async function loadReportsFromApi(): Promise<OilSpillReportData[]> {
const res = await fetchReports({ size: 100 });
return res.items.map(apiListItemToReportData);
}
export async function loadReportDetail(sn: number): Promise<OilSpillReportData & { reportSn: number }> {
const detail = await fetchReport(sn);
return apiDetailToReportData(detail);
}