- 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>
522 lines
14 KiB
TypeScript
522 lines
14 KiB
TypeScript
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]
|
|
);
|
|
}
|