- 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>
375 lines
14 KiB
TypeScript
375 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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");
|
||
}
|
||
}
|