Files
Aislo/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 22e3797ab8 feat(531-4): 대시보드 받은 예정노선 [연결] 모달 · 새 임도 설계속도 → 단계 2 params · 확정 시각 KST
- B01 받은 예정노선 [연결] = 모달(새 임도 프로젝트로 · 기존 프로젝트에 연결) → link API · 409 확인 → 다시 · 끝나면 B03 이동 · 확정(KST) 열
- 연결 new 때 예정노선 설계속도(20/30/40)를 단계 2 params options.design_speed_kph 에 · existing 은 안 건드림
- B03 자동설계 체인이 단계 2 params 의 설계속도를 이어 넘김(없으면 None = 그대로 · 브레인 허락)
- 받은 예정노선 목록에 confirmed_at · D04 표 확정 시각 KST 표시(서버 UTC 그대로)
- 시험: new 연결 설계속도 기록 · existing · 범위 밖 값 안 넣음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:55:45 +09:00

325 lines
11 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 { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { mountGroupStep } from "../A00_Common/group_workflow";
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);
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: [] };
const draft = (): Assignee[] | null => readState<Assignee[]>("d04-assignees", projectId);
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);
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
? `${fmt(row.start_chainage_m)} ~ ${fmt(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: [...firms, addForm(row.year)] }),
],
});
});
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 {
await load();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}