Files
Aislo/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 c3fbcb13f1 feat(D · E): 떠남 = 공용 물음 [취소 · 폐기 · 저장] · 캐시 = 등록표 · 프로젝트 = 탭 currentProjectId (PLAN 572-3 · 요구 400~403)
- D02 · D03 · E02 말없이 저장 → mountGroupStep {dirty, save, discard} 공용 물음
- D04 · E05 · E06 가드 새로 · E03 = 대표 단면 별 저장 진행 중일 때만 물음
- D02 · E02~E07 세션 직접 읽기 · 쓰기 → 등록표 d02-points · e02-ground · e03-prob · e04-settings · e05-report · e06-rock · e07-settings
- 아홉 페이지 localStorage 지금 프로젝트 직접 읽기 → currentProjectId() (다른 탭이 바꿔도 이 탭 그대로)
- test_560_4 D · E 줄 넓힘(옛 beforeLeave 단언 → dirty · save) · test_401 PENDING 에서 D · E 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:09:08 +09:00

375 lines
14 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 · 요구 391 · PLAN 531-3 · 563-2) — B05~B09 틀(group_workflow).
* 본문 = 조각 표(연도 · 선 · 연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 조각마다 업체 여럿
* (조각 없는 옛 프로젝트 = 연도 한 줄 · 옛 연도 지정 업체는 그 연도 첫 줄에 보임)
* (등록 회사 찾기 = 가입 화면 회사 검색 · 아니면 업체명 + 메일 수기).
* 좌측 = 확정할 연도 고르기 + [내보내기] [확정] [저장].
* 조작 = 캐시 `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) · 옛 연도(piece_id null) */
interface PieceRow extends YearRow {
piece_id: string | null;
line_name: string | null;
}
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<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.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<void> {
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<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, 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<void>): Promise<void> {
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<string, number>();
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<typeof t>[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.line_name ? `${row.year} · ${row.line_name}` : String(row.year),
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");
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<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");
}
}