feat(531-3): D04 확정 화면 · B03 [예정노선에서 가져오기] · 예정노선 바뀜 띠

- D04 화면 — 연도 표(연장 · 시점 · 종점 · 누가 · 확정 · 지정 업체) · 업체 = 등록 회사 찾기 또는 이름 + 메일 · 캐시 d04-assignees · [저장] PUT · [확정] 연도 고름 → finalize(잠금판) · [내보내기] zip
- PUT plan/assignees 에 years — 업체를 다 뺀 연도도 맞춤
- B03 노선 칸 옆 단추 · 머리 띠(새 파일에서 끼움 · 기존 동작 그대로) — 연결 = existing 길 · 옛 결과 지울 때 확인 창

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:03:58 +09:00
co-authored by Claude Opus 5.5
parent d8c9c60beb
commit 80d363c3aa
9 changed files with 633 additions and 5 deletions
@@ -0,0 +1,181 @@
/* =============================================================================
* B03_FileInput_UI_PlanImport.ts
* 예정노선에서 가져오기(요구서 345 3-5 · PLAN 531-3) — 본보기 `B03_FileInput_UI_TempPicker.ts`.
* 노선 칸 옆 [예정노선에서 가져오기] = 우리 회사에 지정된 확정 연도 고르기 →
* POST /api/dashboard/plan-routes/{assignee}/link {mode: existing, project_id: 이 프로젝트}.
* 머리 띠 = 이어 받은 예정노선이 바뀌면 「예정노선이 바뀜 — [다시 가져오기]」(같은 길 · 자동 반영 없음).
* 옛 결과가 지워지면 서버가 409 confirm 으로 묻고 → 확인 창 → confirm_replace 로 다시.
* ========================================================================== */
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
interface AssignedRoute {
assignee_id: number;
project_name: string;
year: number;
linked_project_id: string | null;
linked_project_name: string | null;
update_available: boolean;
}
interface PlanSource {
linked: boolean;
assignee_id?: number;
plan_name?: string;
year?: number;
update_available?: boolean;
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as T;
}
/** 연결 — 지울 결과가 있으면 한 번 묻고 다시 · 성공 = 참 */
async function linkRoute(
assigneeId: number,
projectId: string,
lasFree: boolean,
): Promise<boolean> {
const send = (confirmReplace: boolean): Promise<Response> =>
fetch(`${API_BASE_URL}/dashboard/plan-routes/${assigneeId}/link`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "existing",
project_id: projectId,
confirm_replace: confirmReplace,
las_free: lasFree,
}),
});
showLoadingOverlay();
try {
let response = await send(false);
let data = (await response.json().catch(() => ({}))) as {
confirm?: string;
message?: string;
detail?: unknown;
};
if (response.status === 409 && data.confirm === "replace_outputs") {
hideLoadingOverlay();
const agreed = await showConfirmDialog(data.message ?? "", t("D04_Import_Btn"));
showLoadingOverlay();
if (!agreed) return false;
response = await send(true);
data = (await response.json().catch(() => ({}))) as typeof data;
}
if (!response.ok) {
throw new Error(typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`);
}
showToast(t("D04_Import_Done"), "success");
return true;
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
return false;
} finally {
hideLoadingOverlay();
}
}
/** 노선 칸에 단추 · 본문 위에 바뀜 띠를 끼움 — `onImported` = 카드 현황 다시 읽기.
* 프로젝트 · 「LAS 없이 설계」는 B03 화면과 같은 기억값(localStorage)을 읽음. */
export function mountPlanImport(
buttonHost: HTMLElement,
bannerHost: HTMLElement,
onImported: () => void,
): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
if (!projectId) return;
const lasFree = (): boolean => localStorage.getItem(`b03_las_free_${projectId}`) === "1";
const banner = el("div", { className: "b03-file__overview-pending b03-file__plan-update" });
banner.hidden = true;
bannerHost.prepend(banner);
const done = async (assigneeId: number): Promise<void> => {
if (!(await linkRoute(assigneeId, projectId, lasFree()))) return;
onImported();
void refreshBanner();
};
async function refreshBanner(): Promise<void> {
const source = await getJson<PlanSource>(`/projects/${projectId}/plan-source`).catch(
() => null,
);
banner.hidden = !source?.update_available;
if (!source?.update_available || source.assignee_id === undefined) return;
const assigneeId = source.assignee_id;
banner.replaceChildren(
el("span", { text: `${t("D04_Update_Banner")} (${source.plan_name} ${source.year}) — ` }),
createButton({
label: t("D04_Update_Reimport"),
variant: "ghost",
size: "sm",
onClick: () => void done(assigneeId),
}),
);
}
const openPicker = async (): Promise<void> => {
let routes: AssignedRoute[];
try {
routes = (await getJson<{ routes: AssignedRoute[] }>("/dashboard/plan-routes/assigned"))
.routes;
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
return;
}
const { close } = openModal({
title: t("D04_Import_Title"),
closeLabel: "×",
mount: (body) => {
if (!routes.length) {
body.append(el("p", { text: t("D04_Import_Empty") }));
return;
}
for (const route of routes) {
const linked = route.linked_project_name
? ` · ${t("D04_Linked")}: ${route.linked_project_name}`
: "";
body.append(
el("div", {
className: "b03-file__plan-pick",
children: [
el("span", { text: `${route.project_name} · ${route.year}${linked}` }),
createButton({
label: t("D04_Import_Btn"),
variant: "filled",
size: "sm",
onClick: () => {
close();
void done(route.assignee_id);
},
}),
],
}),
);
}
},
});
};
buttonHost.append(
createButton({
label: t("D04_Import_Btn"),
variant: "ghost",
onClick: () => void openPicker(),
}),
);
void refreshBanner();
}