Files
Aislo/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts
T
eomsangdonandClaude Sonnet 5.5 852af20206 feat(D01·D04): 예정노선 측점 표기 No.+거리(61+14.5 꼴) — D01 입력 옆 표기 · D04 표 · xlsx 측점 칸
- D01 시작 · 종료 칸 이름 측점 말로 · 입력 옆 즉시 `= 61+14.5` (저장값 m 그대로)
- D04 표 측점 열 · xlsx 시점 · 종점 측점 칸 덧(m 칸 · shp 숫자 칸 유지)
- 파이썬 station_label ↔ stationLabel 거울 시험 · 도움말 D01 · D04 글 · 그림 열여섯 장 다시 찍음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:48:33 +09:00

337 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* D04_PlanConfirm_UI_Page.ts
* D04 확정 · 업체 · 내보내기(요구서 345 3-4 · PLAN 531-3) — B05~B09 틀(group_workflow).
* 본문 = 연도 표(연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 연도마다 업체 여럿
* (등록 회사 찾기 = 가입 화면 회사 검색 · 아니면 업체명 + 메일 수기).
* 좌측 = 확정할 연도 고르기 + [내보내기] [확정] [저장].
* 조작 = 캐시 `d04-assignees` 즉시 · [저장] = PUT …/plan/assignees(연도 전부 맞춤) → 캐시 비움 ·
* [확정] = (캐시 있으면 저장 먼저) POST …/plan/finalize → 잠금판 · [내보내기] = GET …/plan/export zip.
* 산림청이 아니면(지정 회사 = 보기만) 업체 고침 · 연도 고르기 · [확정] · [저장] 없음 · [내보내기]만.
* ========================================================================== */
import "./D04_PlanConfirm_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { kstText } from "../common_util/common_util_time_kst";
import {
createButton,
createTag,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createPanelCard } from "@ui/ui_template_collapsible";
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 { 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;
}
interface Assignee {
year: number;
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[];
assignees: Assignee[];
}
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
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.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<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
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] });
mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, { mainContent: main, leftPanel: panel });
let summary: Summary = { name: "", years: [], assignees: [] };
let editable = false; // 산림청만 고침
const draft = (): Assignee[] | null =>
editable ? readState<Assignee[]>("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<void> {
summary = await api<Summary>(`${base}/summary`);
draw();
}
async function save(): Promise<void> {
const body = {
years: summary.years.map((row) => row.year),
assignees: current().map(({ year, company_id, company_name, email }) => ({
year,
company_id,
company_name,
email,
})),
};
await api(`${base}/assignees`, { method: "PUT", body: JSON.stringify(body) });
clearState("d04-assignees", projectId);
}
async function locked(work: () => Promise<void>): Promise<void> {
showLoadingOverlay();
try {
await work();
await load();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
} finally {
hideLoadingOverlay();
}
}
function addForm(year: number): HTMLElement {
const listId = `d04-companies-${year}`;
const options = el("datalist", { attrs: { id: listId } });
const found = new Map<string, number>();
const firm = el("input", {
className: "d04-confirm__input",
attrs: { list: listId, placeholder: t("D04_Company") },
});
const mail = el("input", {
className: "d04-confirm__input",
attrs: { type: "email", placeholder: t("D04_Email") },
});
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, 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 head = el("tr", {
children: [
"D04_Col_Year",
"D04_Col_Length",
"D04_Col_Start",
"D04_Col_End",
"D04_Col_Chainage",
"D04_Col_Confirmed",
"D04_Col_Assignees",
].map((key) => el("th", { text: t(key as Parameters<typeof t>[0]) })),
});
const rows = summary.years.map((row) => {
const firms = list.filter((item) => item.year === row.year).map(firmChip);
const chain = row.saved
? `${station(row.start_chainage_m)} ~ ${station(row.end_chainage_m)}`
: t("D04_NotSaved");
return el("tr", {
attrs: { "data-year": String(row.year) },
children: [
el("td", { text: String(row.year) }),
el("td", { text: fmt(row.length_m), attrs: { "data-num": "" } }),
el("td", { text: where(row.start) }),
el("td", { text: where(row.end) }),
el("td", { text: chain }),
el("td", { text: kstText(row.confirmed_at) }),
el("td", { children: editable ? [...firms, addForm(row.year)] : firms }),
],
});
});
const table = el("table", {
className: "d04-confirm__table",
children: [el("thead", { children: [head] }), el("tbody", { children: rows })],
});
const notes = draft()
? [el("p", { className: "d04-confirm__dirty", text: t("D04_Dirty") })]
: [];
main.replaceChildren(el("h2", { text: summary.name }), ...notes, table);
pickList.replaceChildren(
...summary.years
.filter((row) => row.saved)
.map((row) => {
const box = el("input", { attrs: { type: "checkbox", value: String(row.year) } });
box.checked = !row.confirmed_at;
return el("label", { className: "d04-confirm__pick", children: [box, ` ${row.year}`] });
}),
);
}
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<HTMLInputElement>("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<string, number>();
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");
}
}