/* ============================================================================= * D04_PlanConfirm_UI_Page.ts * D04 확정 · 업체 · 내보내기(요구서 345 3-4 · 요구 391 · PLAN 531-3 · 563-2) — B05~B09 틀(group_workflow). * 본문 = 세트 표(세트 「2027-1」 · 선 · 연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 세트마다 업체 여럿 * (끊긴 · 갈래 세트 = 줄에 「2 덩이」 · 「갈래」 + 확정 막힘 알림 · 서버 [확정]이 막음 · 638-3) * (조각 없는 옛 프로젝트 = 연도 한 줄 · 옛 연도 지정 업체는 그 연도 첫 줄에 보임) * (등록 회사 찾기 = 가입 화면 회사 검색 · 아니면 업체명 + 메일 수기). * 좌측 = 확정할 연도 고르기 + [내보내기] [확정] [저장]. * 조작 = 캐시 `d04-assignees` 즉시 · [저장] = PUT …/plan/assignees(연도 전부 맞춤 · 조각 id) → 캐시 비움 · * [확정] = (캐시 있으면 저장 먼저) POST …/plan/finalize → 잠금판 · [내보내기] = GET …/plan/export zip. * 산림청이 아니면(지정 회사 = 보기만) 업체 고침 · 연도 고르기 · [확정] · [저장] 없음 · [내보내기]만. * ========================================================================== */ import "./D04_PlanConfirm_UI_Style.css"; import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { kstText } from "../common_util/common_util_time_kst"; import { createButton, createInputField, createTag, el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createBanner } from "@ui/ui_template_banner"; import { createCheckbox } from "@ui/ui_template_checkbox"; import { createPanelCard } from "@ui/ui_template_collapsible"; import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { t } from "@ui/ui_template_locale"; import { stationLabel } from "../common_util/common_util_station"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { searchCompanies } from "../A07_Register/A07_Register_Api_Fetch"; interface EndPoint { x: number; y: number; lon: number; lat: number; } interface YearRow { year: number; saved: boolean; confirmed_at: string | null; length_m?: number; start_chainage_m?: number; end_chainage_m?: number; start?: EndPoint; end?: EndPoint; } /** 표 한 줄 — 세트(piece_id = 세트 열쇠 · label 「2027-1」) · 옛 연도(piece_id null) */ interface PieceRow extends YearRow { piece_id: string | null; line_name: string | null; label?: string | null; parts?: number; branch?: boolean; } /** 끊긴 · 갈래 세트 까닭 — 「2 덩이」 · 「갈래」(없으면 빈 글). */ const brokenText = (row: PieceRow): string => [(row.parts ?? 1) > 1 ? `${row.parts} ${t("D04_Parts")}` : "", row.branch ? t("D04_Branch") : ""] .filter(Boolean) .join(" · "); interface Assignee { year: number; piece_id?: string | null; company_id: number | null; company_name: string | null; email: string | null; notified_at?: string | null; linked_project_id?: string | null; } interface Summary { name: string; years: YearRow[]; pieces: PieceRow[]; assignees: Assignee[]; } async function api(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: init.body ? { "Content-Type": "application/json" } : undefined, ...init, }); const data = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; if (!response.ok) { const detail = typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`; throw new Error(detail); } return data; } const fmt = (value: number | undefined, digits = 1): string => value === undefined || value === null ? "—" : value.toFixed(digits); /** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */ const STATION_INTERVAL_M = 20; const station = (m: number | undefined): string => m === undefined || m === null ? "—" : stationLabel(m, STATION_INTERVAL_M, 1); const where = (p?: EndPoint): string => (p ? `${p.lat.toFixed(6)}, ${p.lon.toFixed(6)}` : "—"); /** 같은 업체 — 같은 조각(옛 = 연도)에서 등록 회사 = id · 수기 = 메일(서버 `_key` 와 같음) */ const sameFirm = (a: Assignee, b: Assignee): boolean => a.year === b.year && (a.piece_id ?? null) === (b.piece_id ?? null) && (a.company_id !== null ? a.company_id === b.company_id : b.company_id === null && (a.email ?? "").toLowerCase() === (b.email ?? "").toLowerCase()); export async function renderD04PlanConfirm(root: HTMLElement): Promise { const projectId = currentProjectId(); const base = `/projects/${projectId}/plan`; const main = el("div", { className: "d04-confirm" }); const pickList = el("div", { className: "d04-confirm__picks" }); const pickCard = createPanelCard(t("D04_Panel_Years"), false); pickCard.append(pickList); const actions = el("div", { className: "ui-sidebar-actions" }); const panel = el("div", { className: "d04-confirm__panel", children: [pickCard, actions] }); // 떠날 때 저장 안 한 지정 업체가 있으면 공용 물음 [취소][폐기][저장](572-3) mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, { mainContent: main, leftPanel: panel, dirty: () => draft() !== null, save: async () => { await locked(save); return draft() === null; // 실패 = 캐시 남음 = 머묾 }, discard: () => clearState("d04-assignees", projectId), }); let summary: Summary = { name: "", years: [], pieces: [], assignees: [] }; let editable = false; // 산림청만 고침 const draft = (): Assignee[] | null => editable ? readState("d04-assignees", projectId) : null; const current = (): Assignee[] => draft() ?? summary.assignees; const edit = (next: Assignee[]): void => { writeState("d04-assignees", next, projectId); draw(); }; async function load(): Promise { summary = await api(`${base}/summary`); draw(); } async function save(): Promise { const body = { years: summary.years.map((row) => row.year), assignees: current().map(({ year, piece_id, company_id, company_name, email }) => ({ year, piece_id: piece_id ?? null, company_id, company_name, email, })), }; await api(`${base}/assignees`, { method: "PUT", body: JSON.stringify(body) }); clearState("d04-assignees", projectId); } async function locked(work: () => Promise): Promise { showLoadingOverlay(); try { await work(); await load(); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } finally { hideLoadingOverlay(); } } function addForm(row: PieceRow): HTMLElement { const listId = `d04-companies-${row.piece_id ?? row.year}`; const options = el("datalist", { attrs: { id: listId } }); const found = new Map(); const firm = createInputField({ placeholder: t("D04_Company"), size: 28 }).input; firm.setAttribute("list", listId); const mail = createInputField({ type: "email", placeholder: t("D04_Email"), size: 28 }).input; firm.classList.add("d04-confirm__input"); mail.classList.add("d04-confirm__input"); firm.addEventListener("input", () => { const query = firm.value.trim(); if (!query) return; void searchCompanies(query) .then((companies) => { found.clear(); options.replaceChildren( ...companies.map((company) => { found.set(company.name, company.id); return el("option", { attrs: { value: company.name } }); }), ); }) .catch(() => undefined); }); const add = createButton({ label: t("D04_Add"), variant: "ghost", size: "sm", onClick: () => { const name = firm.value.trim(); const email = mail.value.trim() || null; const companyId = found.get(name) ?? null; if (companyId === null && (!name || !email?.includes("@"))) { showToast(t("D04_NeedEmail"), "warning"); return; } const next: Assignee = { year: row.year, piece_id: row.piece_id, company_id: companyId, company_name: name, email, }; const list = current(); if (list.some((item) => sameFirm(item, next))) return; edit([...list, next]); }, }); return el("div", { className: "d04-confirm__add", children: [firm, mail, add, options] }); } function firmChip(item: Assignee): HTMLElement { const label = [item.company_name, item.email].filter(Boolean).join(" · "); const remove = createButton({ label: "×", variant: "ghost", size: "sm", onClick: () => edit(current().filter((other) => other !== item && !sameFirm(other, item))), }); const tags = [ item.company_id !== null ? createTag("Aislo", "accent") : null, item.notified_at ? createTag(t("D04_Sent"), "success") : null, item.linked_project_id ? createTag(t("D04_Linked"), "success") : null, ].filter((tag): tag is HTMLSpanElement => tag !== null); remove.hidden = !editable; return el("div", { className: "d04-confirm__firm", children: [el("span", { text: label }), ...tags, remove], }); } function draw(): void { const list = current(); const columns: Column[] = [ "D04_Col_Year", "D04_Col_Length", "D04_Col_Start", "D04_Col_End", "D04_Col_Chainage", "D04_Col_Confirmed", "D04_Col_Assignees", ].map((key, i) => ({ label: t(key as Parameters[0]), ...(i === 1 ? { align: "end" as const } : {}), })); const { root: tableRoot, tbody } = tableShell(columns); tableRoot.querySelector("table")?.classList.add("d04-confirm__table"); const firstOfYear = new Set( summary.pieces.filter((row, i, all) => all.findIndex((o) => o.year === row.year) === i), ); // 조각 지정 = 그 조각 줄 · 옛 연도 지정(piece_id 없음) = 그 연도 첫 줄 const belongs = (item: Assignee, row: PieceRow): boolean => item.piece_id ? item.piece_id === row.piece_id : item.year === row.year && firstOfYear.has(row); for (const row of summary.pieces) { const firms = list.filter((item) => belongs(item, row)).map(firmChip); const chain = row.saved ? `${station(row.start_chainage_m)} ~ ${station(row.end_chainage_m)}` : t("D04_NotSaved"); const tr = tableRow(columns, [ [row.label ?? row.year, row.line_name, brokenText(row)].filter(Boolean).join(" · "), fmt(row.length_m), where(row.start), where(row.end), chain, kstText(row.confirmed_at), el("div", { children: editable ? [...firms, addForm(row)] : firms }), ]); tr.dataset.year = String(row.year); if (row.piece_id) tr.dataset.piece = row.piece_id; tbody.append(tr); } const notes = draft() ? [createBanner({ tone: "warning", text: t("D04_Dirty") })] : []; notes[0]?.classList.add("d04-confirm__dirty"); const broken = summary.pieces .filter((row) => brokenText(row)) .map((row) => `${row.label ?? row.year} ${brokenText(row)}`); if (broken.length) notes.push( createBanner({ tone: "warning", text: `${t("D04_Broken")} ${broken.join(" · ")}` }), ); main.replaceChildren(el("h2", { text: summary.name }), ...notes, tableRoot); pickList.replaceChildren( ...summary.years .filter((row) => row.saved) .map((row) => { const pick = createCheckbox(String(row.year), { checked: !row.confirmed_at, value: String(row.year), }); pick.root.classList.add("d04-confirm__pick"); return pick.root; }), ); } const exportButton = createButton({ label: t("D04_Export"), variant: "ghost", onClick: () => void (async () => { showLoadingOverlay(); try { const response = await fetch(`${API_BASE_URL}${base}/export`, { credentials: "include", }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const link = el("a", { attrs: { href: URL.createObjectURL(await response.blob()), download: `${summary.name}_예정노선.zip`, }, }); link.click(); URL.revokeObjectURL(link.href); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } finally { hideLoadingOverlay(); } })(), }); const finalizeButton = createButton({ label: t("D04_Finalize"), variant: "filled", onClick: () => void (async () => { const years = [...pickList.querySelectorAll("input:checked")].map((box) => Number(box.value), ); if (!years.length) { showToast(t("D04_FinalizePick"), "warning"); return; } if (!(await showConfirmDialog(t("D04_FinalizeAsk"), t("D04_Finalize")))) return; await locked(async () => { if (draft()) await save(); const result = await api<{ notifications: { state: string }[] }>(`${base}/finalize`, { method: "POST", body: JSON.stringify({ years }), }); const counts = new Map(); for (const item of result.notifications) { counts.set(item.state, (counts.get(item.state) ?? 0) + 1); } const text = [...counts].map(([state, n]) => `${state} ${n}`).join(" · "); showToast(`${t("D04_Finalized")} ${text || "0"}`, "success"); }); })(), }); const saveButton = createButton({ label: t("D04_Save"), variant: "filled", onClick: () => void locked(async () => { await save(); showToast(t("D04_Saved"), "success"); }), }); actions.append(exportButton, finalizeButton, saveButton); try { editable = !!(await fetchSessionUser())?.is_forest_service; pickCard.hidden = finalizeButton.hidden = saveButton.hidden = !editable; await load(); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } }