diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 6f95b8585..d186ad322 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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" }, /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 6f97ac3b6..f9de843c3 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -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 { 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"; diff --git a/B03_FileInput/B03_FileInput_UI_PlanImport.ts b/B03_FileInput/B03_FileInput_UI_PlanImport.ts new file mode 100644 index 000000000..9c26aac8b --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_PlanImport.ts @@ -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(path: string): Promise { + 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 { + const send = (confirmReplace: boolean): Promise => + 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 => { + if (!(await linkRoute(assigneeId, projectId, lasFree()))) return; + onImported(); + void refreshBanner(); + }; + + async function refreshBanner(): Promise { + const source = await getJson(`/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 => { + 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(); +} diff --git a/D04_PlanConfirm/D04_PlanConfirm_Router.py b/D04_PlanConfirm/D04_PlanConfirm_Router.py index 45ba05381..276478b99 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Router.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Router.py @@ -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) diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts new file mode 100644 index 000000000..16c52343b --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts @@ -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(path: string, init: RequestInit = {}): Promise { + 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 { + 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("d04-assignees", projectId); + const current = (): Assignee[] => draft() ?? summary.assignees; + const edit = (next: Assignee[]): void => { + writeState("d04-assignees", next, projectId); + draw(); + }; + + async function load(): Promise { + summary = await api(`${base}/summary`); + draw(); + } + + async function save(): Promise { + 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): Promise { + 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(); + 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[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("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(); + 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"); + } +} diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css b/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css new file mode 100644 index 000000000..05556bae3 --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css @@ -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); +} diff --git a/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts b/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts index c59fad866..9eefce73a 100644 --- a/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts +++ b/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts @@ -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, }; diff --git a/resources/tester/test_531_3_plan_link.py b/resources/tester/test_531_3_plan_link.py index 247f38edb..6cc662383 100644 --- a/resources/tester/test_531_3_plan_link.py +++ b/resources/tester/test_531_3_plan_link.py @@ -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 diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index 93d459f31..6bd4acd92 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -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"],