diff --git a/backend/src/reports/reportsRouter.ts b/backend/src/reports/reportsRouter.ts new file mode 100644 index 0000000..5455dd1 --- /dev/null +++ b/backend/src/reports/reportsRouter.ts @@ -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; diff --git a/backend/src/reports/reportsService.ts b/backend/src/reports/reportsService.ts new file mode 100644 index 0000000..63437e6 --- /dev/null +++ b/backend/src/reports/reportsService.ts @@ -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 { + 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(); + 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 { + 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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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] + ); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 21bb1ba..95bb231 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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) => { diff --git a/database/migration/007_reports.sql b/database/migration/007_reports.sql new file mode 100644 index 0000000..76cfb5a --- /dev/null +++ b/database/migration/007_reports.sql @@ -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; diff --git a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx index 39a443e..9a607ba 100755 --- a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx +++ b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx @@ -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 = [ diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index 60e32c7..01c7e95 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -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 = () => { diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx index efd6a6e..4dbefd2 100755 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -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>(new Set()) const [previewReport, setPreviewReport] = useState(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() { toggleSelect(report.id)} className="accent-[#06b6d4] w-3.5 h-3.5" /> {idx + 1} - + {report.reportType} {(() => { const cat = inferAnalysisCategory(report) @@ -164,7 +175,7 @@ export function ReportsView() { {report.author || '-'} {report.jurisdiction} {report.status} - + diff --git a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx b/frontend/src/tabs/reports/components/TemplateFormEditor.tsx index 9942014..12c9d29 100644 --- a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx +++ b/frontend/src/tabs/reports/components/TemplateFormEditor.tsx @@ -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') => { diff --git a/frontend/src/tabs/reports/services/reportsApi.ts b/frontend/src/tabs/reports/services/reportsApi.ts new file mode 100644 index 0000000..33ee933 --- /dev/null +++ b/frontend/src/tabs/reports/services/reportsApi.ts @@ -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 = { + '완료': 'COMPLETED', + '수행중': 'IN_PROGRESS', + '테스트': 'DRAFT', +}; + +const CODE_TO_STATUS: Record = { + COMPLETED: '완료', + IN_PROGRESS: '수행중', + DRAFT: '테스트', +}; + +const TMPL_CODE_TO_TYPE: Record = { + INITIAL: '초기보고서', + COMMAND: '지휘부 보고', + FORECAST: '예측보고서', + COMPREHENSIVE: '종합보고서', + SPILL: '유출유 보고', +}; + +const TYPE_TO_TMPL_CODE: Record = { + '초기보고서': 'INITIAL', + '지휘부 보고': 'COMMAND', + '예측보고서': 'FORECAST', + '종합보고서': 'COMPREHENSIVE', + '유출유 보고': 'SPILL', +}; + +const CTGR_CODE_TO_CAT: Record = { + OIL: '유출유 확산예측', + HNS: 'HNS 대기확산', + RESCUE: '긴급구난', +}; + +const CAT_TO_CTGR_CODE: Record = { + '유출유 확산예측': 'OIL', + 'HNS 대기확산': 'HNS', + '긴급구난': 'RESCUE', +}; + +// ============================================================ +// 캐시 (템플릿/카테고리 — 변경 빈도 낮음) +// ============================================================ + +let templatesCache: ApiTemplate[] | null = null; +let categoriesCache: ApiCategory[] | null = null; + +// ============================================================ +// API 함수 +// ============================================================ + +export async function fetchTemplates(): Promise { + if (templatesCache) return templatesCache; + const res = await api.get('/reports/templates'); + templatesCache = res.data; + return res.data; +} + +export async function fetchCategories(): Promise { + if (categoriesCache) return categoriesCache; + const res = await api.get('/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 { + const res = await api.get('/reports', { params }); + return res.data; +} + +export async function fetchReport(sn: number): Promise { + const res = await api.get(`/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 { + await api.post(`/reports/${sn}/update`, input); +} + +export async function deleteReportApi(sn: number): Promise { + 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 { + 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 { + 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 { + 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; + 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 { + const res = await fetchReports({ size: 100 }); + return res.items.map(apiListItemToReportData); +} + +export async function loadReportDetail(sn: number): Promise { + const detail = await fetchReport(sn); + return apiDetailToReportData(detail); +}