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:
@@ -210,6 +210,8 @@ export const STATE_REGISTRY = {
|
||||
"route-draft": { bucket: "draft", scope: "project" },
|
||||
/** 노선 초안 전 상태(440-4) — 측점 열쇠 · 횡단 초안 칸 · 관 · 구조물 · 초안 둔 채 새로고침해도 버리면 이것으로. */
|
||||
"route-draft-hold": { bucket: "draft", scope: "project" },
|
||||
/** D04 연도별 지정 업체 목록(531-3) — [저장] 때 PUT plan/assignees. */
|
||||
"d04-assignees": { bucket: "draft", scope: "project" },
|
||||
|
||||
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
|
||||
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { fetchUploadOverview } from "./B03_FileInput_Api_Fetch";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { createInputGuide } from "./B03_FileInput_UI_Guide";
|
||||
import { createTempPicker } from "./B03_FileInput_UI_TempPicker";
|
||||
import { mountPlanImport } from "./B03_FileInput_UI_PlanImport";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
@@ -627,6 +628,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const routePanel = document.createElement("div");
|
||||
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
routePanel.append(routeGroup);
|
||||
mountPlanImport(routePanel, uploadControlPanel, () => void applyUploadOverview());
|
||||
|
||||
const terrainPanel = document.createElement("div");
|
||||
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -49,6 +49,7 @@ class AssigneeIn(BaseModel):
|
||||
|
||||
class AssigneesRequest(BaseModel):
|
||||
assignees: list[AssigneeIn]
|
||||
years: list[int] | None = None # 맞출 연도(업체를 다 뺀 연도도) — 비우면 보낸 업체의 연도
|
||||
|
||||
|
||||
class FinalizeRequest(BaseModel):
|
||||
@@ -129,11 +130,11 @@ async def put_plan_assignees(
|
||||
_require_forest(session)
|
||||
await _plan_project(project_id)
|
||||
known = {row["year"] for row in await repo.list_year_routes(str(project_id))}
|
||||
unknown = sorted({a.year for a in request.assignees} - known)
|
||||
years = {a.year for a in request.assignees} | set(request.years or [])
|
||||
unknown = sorted(years - known)
|
||||
if unknown:
|
||||
raise HTTPException(status_code=400, detail=f"예정노선 연도가 아닙니다: {unknown}")
|
||||
wanted = [await _resolve_assignee(item) for item in request.assignees]
|
||||
years = {a.year for a in request.assignees}
|
||||
existing = await repo.list_assignees(str(project_id))
|
||||
delete_ids, updates, inserts = plan_assignee_changes(existing, wanted, years)
|
||||
await repo.apply_assignee_changes(str(project_id), delete_ids, updates, inserts)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/* =============================================================================
|
||||
* 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 {
|
||||
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: row.confirmed_at ? row.confirmed_at.slice(0, 16) : "—" }),
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* =============================================================================
|
||||
* D04_PlanConfirm_UI_Style.css — D04 확정 · 업체 표(531-3) · 색은 테마 토큰만
|
||||
* ========================================================================== */
|
||||
|
||||
.d04-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-16);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.d04-confirm__table {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.d04-confirm__table th,
|
||||
.d04-confirm__table td {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.d04-confirm__table thead th {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.d04-confirm__table td[data-num] {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.d04-confirm__firm,
|
||||
.d04-confirm__add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.d04-confirm__input {
|
||||
min-width: 10rem;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.d04-confirm__dirty {
|
||||
color: var(--color-warning, var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.d04-confirm__picks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* D04_PlanConfirm_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = D04 확정 화면(531-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
@@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = {
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D04_PLAN_CONFIRM),
|
||||
load: async () => (await import("../D04_PlanConfirm_UI_Page")).renderD04PlanConfirm,
|
||||
};
|
||||
|
||||
@@ -387,3 +387,24 @@ def test_summary_year_rows(plan_repo):
|
||||
first["length_m"]
|
||||
)
|
||||
assert out["years"][1]["confirmed_at"] is None and len(out["assignees"]) == 2
|
||||
|
||||
|
||||
def test_put_assignees_years_clears_empty_year(plan_repo, monkeypatch):
|
||||
"""업체를 다 뺀 연도 — `years` 로 보내면 그 연도 행을 지움 · 안 보낸 연도는 그대로."""
|
||||
applied = {}
|
||||
|
||||
async def apply(_pid, delete_ids, updates, inserts):
|
||||
applied.update(delete=delete_ids, updates=updates, inserts=inserts)
|
||||
|
||||
monkeypatch.setattr(repo, "apply_assignee_changes", apply)
|
||||
forest = {"role": "SYSTEM_ADMIN", "email": "a@b.c", "company_id": 1}
|
||||
request = d04.AssigneesRequest(assignees=[], years=[2026])
|
||||
asyncio.run(d04.put_plan_assignees(PLAN_ID, request, forest))
|
||||
assert applied == {"delete": [7], "updates": [], "inserts": []}
|
||||
with pytest.raises(HTTPException) as err:
|
||||
asyncio.run(
|
||||
d04.put_plan_assignees(
|
||||
PLAN_ID, d04.AssigneesRequest(assignees=[], years=[2030]), forest
|
||||
)
|
||||
)
|
||||
assert err.value.status_code == 400
|
||||
|
||||
@@ -19,6 +19,43 @@ export const ui_locales_de = {
|
||||
DE_Step_StabReport: ["보고서", "Report"],
|
||||
DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."],
|
||||
|
||||
/* --- D04 확정 · 업체 · 내보내기 · B03 예정노선 가져오기(531-3) --- */
|
||||
D04_Col_Year: ["연도", "Year"],
|
||||
D04_Col_Length: ["연장 (m)", "Length (m)"],
|
||||
D04_Col_Start: ["시점 (위도, 경도)", "Start (lat, lon)"],
|
||||
D04_Col_End: ["종점 (위도, 경도)", "End (lat, lon)"],
|
||||
D04_Col_Chainage: ["누가 (m)", "Chainage (m)"],
|
||||
D04_Col_Confirmed: ["확정", "Confirmed"],
|
||||
D04_Col_Assignees: ["지정 업체", "Assigned firms"],
|
||||
D04_NotSaved: ["노선 분석 [저장] 전", "Route not saved"],
|
||||
D04_Company: ["업체(등록 회사 찾기 또는 이름)", "Firm (search or name)"],
|
||||
D04_Email: ["메일", "Email"],
|
||||
D04_Add: ["추가", "Add"],
|
||||
D04_NeedEmail: [
|
||||
"등록 회사가 아니면 업체명과 메일이 필요합니다.",
|
||||
"Name and email are required for unregistered firms.",
|
||||
],
|
||||
D04_Sent: ["메일 보냄", "Mailed"],
|
||||
D04_Linked: ["연결됨", "Linked"],
|
||||
D04_Dirty: ["저장 안 된 변경이 있습니다.", "Unsaved changes."],
|
||||
D04_Panel_Years: ["확정할 연도", "Years to confirm"],
|
||||
D04_Save: ["저장", "Save"],
|
||||
D04_Saved: ["지정 업체를 저장했습니다.", "Assignees saved."],
|
||||
D04_Finalize: ["확정", "Confirm"],
|
||||
D04_FinalizeAsk: [
|
||||
"고른 연도를 확정하고 지정 업체에 노선 묶음을 메일로 보냅니다. 진행할까요?",
|
||||
"Confirm the selected years and mail the route bundles to assigned firms?",
|
||||
],
|
||||
D04_FinalizePick: ["확정할 연도를 고르세요.", "Pick years to confirm."],
|
||||
D04_Finalized: ["확정 — 메일", "Confirmed — mail"],
|
||||
D04_Export: ["내보내기", "Export"],
|
||||
D04_Import_Btn: ["예정노선에서 가져오기", "Import planned route"],
|
||||
D04_Import_Title: ["지정받은 예정노선 연도 고르기", "Pick an assigned planned-route year"],
|
||||
D04_Import_Empty: ["우리 회사에 지정된 예정노선이 없습니다.", "No planned route assigned."],
|
||||
D04_Import_Done: ["예정노선을 노선 칸에 넣었습니다.", "Planned route imported."],
|
||||
D04_Update_Banner: ["예정노선이 바뀜", "Planned route changed"],
|
||||
D04_Update_Reimport: ["다시 가져오기", "Re-import"],
|
||||
|
||||
/* --- 대시보드 [+] 고르기(528-2) --- */
|
||||
DE_New_Title: ["새 프로젝트", "New Project"],
|
||||
DE_New_Plan: ["예정노선", "Planned Route"],
|
||||
|
||||
Reference in New Issue
Block a user