feat(563-2): D04 표 = 조각 줄(연도 · 선) · 조각마다 업체 · B03 가져오기 · B01 받은 예정노선 = 조각 이름

- D04 업체 추가 · 저장에 조각 id · 옛 연도 지정 업체 = 그 연도 첫 줄
- B03 [예정노선에서 가져오기] 줄 · B01 받은 예정노선 연도 칸 · [연결] 제목에 「본선 0+0~12+10」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:39:12 +09:00
co-authored by Claude Opus 5.5
parent 7c94294f85
commit f558f5d2ba
4 changed files with 55 additions and 19 deletions
@@ -1,6 +1,6 @@
/* =============================================================================
* B01_Dashboard_UI_AssignedPlans.ts
* 「받은 예정노선」 자료 읽기(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
* 「받은 예정노선」 자료 읽기(요구서 345 2-2 · PLAN 528-2 · 563-2) — 우리 회사에 지정된 예정노선 조각(옛 = 연도).
* 화면 = `B01_Dashboard_UI_View_Assigned.ts` · [연결] 모달 = `B01_Dashboard_UI_PlanLink`
* ========================================================================== */
@@ -11,6 +11,8 @@ export interface AssignedPlan {
project_id: string;
project_name: string;
year: number;
/** 조각 이름 「본선 0+0~12+10」 — 옛 연도 지정 = null */
piece_label: string | null;
linked_project_id: string | null;
/** 연도 확정 시각 — 서버 UTC */
confirmed_at: string | null;
@@ -24,7 +24,7 @@ import type { ViewCtx } from "./B01_Dashboard_UI_Views";
const COLUMNS: Column[] = [
{ label: L("DE_New_Plan") },
{ label: L("DE_Assigned_Year"), width: "90px" },
{ label: L("DE_Assigned_Year"), width: "220px" },
{ label: L("D04_Col_Confirmed"), width: "150px" },
{ label: L("DE_Assigned_State"), width: "220px" },
{ label: "", width: "44px" },
@@ -46,7 +46,7 @@ function planRow(plan: AssignedPlan): Cell[] {
const view = (): void => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE);
return [
nameCell(plan.project_name),
String(plan.year),
plan.piece_label ? `${plan.year} · ${plan.piece_label}` : String(plan.year),
kstText(plan.confirmed_at),
stateCell(plan),
rowMenu([
@@ -54,7 +54,10 @@ function planRow(plan: AssignedPlan): Cell[] {
{
label: L("DE_Assigned_Link"),
onClick: () =>
void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`),
void openPlanLinkModal(
plan.assignee_id,
[plan.project_name, plan.year, plan.piece_label].filter(Boolean).join(" "),
),
},
]),
];
+7 -2
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* 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 로 다시.
@@ -25,6 +25,8 @@ interface AssignedRoute {
assignee_id: number;
project_name: string;
year: number;
/** 조각 이름 「본선 0+0~12+10」 — 옛 연도 지정 = null */
piece_label: string | null;
linked_project_id: string | null;
linked_project_name: string | null;
update_available: boolean;
@@ -157,7 +159,10 @@ export function mountPlanImport(
: "";
body.append(
createListRow({
label: `${route.project_name} · ${route.year}${linked}`,
label: [route.project_name, route.year, route.piece_label]
.filter(Boolean)
.join(" · ")
.concat(linked),
trailing: createButton({
label: t("D04_Import_Btn"),
variant: "filled",
+39 -13
View File
@@ -1,10 +1,11 @@
/* =============================================================================
* D04_PlanConfirm_UI_Page.ts
* D04 확정 · 업체 · 내보내기(요구서 345 3-4 · PLAN 531-3) — B05~B09 틀(group_workflow).
* 본문 = 연도 표(연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 연도마다 업체 여럿
* D04 확정 · 업체 · 내보내기(요구서 345 3-4 · 요구 391 · PLAN 531-3 · 563-2) — B05~B09 틀(group_workflow).
* 본문 = 조각 표(연도 · 선 · 연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 조각마다 업체 여럿
* (조각 없는 옛 프로젝트 = 연도 한 줄 · 옛 연도 지정 업체는 그 연도 첫 줄에 보임)
* (등록 회사 찾기 = 가입 화면 회사 검색 · 아니면 업체명 + 메일 수기).
* 좌측 = 확정할 연도 고르기 + [내보내기] [확정] [저장].
* 조작 = 캐시 `d04-assignees` 즉시 · [저장] = PUT …/plan/assignees(연도 전부 맞춤) → 캐시 비움 ·
* 조작 = 캐시 `d04-assignees` 즉시 · [저장] = PUT …/plan/assignees(연도 전부 맞춤 · 조각 id) → 캐시 비움 ·
* [확정] = (캐시 있으면 저장 먼저) POST …/plan/finalize → 잠금판 · [내보내기] = GET …/plan/export zip.
* 산림청이 아니면(지정 회사 = 보기만) 업체 고침 · 연도 고르기 · [확정] · [저장] 없음 · [내보내기]만.
* ========================================================================== */
@@ -51,8 +52,15 @@ interface YearRow {
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;
@@ -63,6 +71,7 @@ interface Assignee {
interface Summary {
name: string;
years: YearRow[];
pieces: PieceRow[];
assignees: Assignee[];
}
@@ -87,9 +96,10 @@ 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` 와 같음) */
/** 같은 업체 — 같은 조각(옛 = 연도)에서 등록 회사 = 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());
@@ -105,7 +115,7 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
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: [] };
let summary: Summary = { name: "", years: [], pieces: [], assignees: [] };
let editable = false; // 산림청만 고침
const draft = (): Assignee[] | null =>
editable ? readState<Assignee[]>("d04-assignees", projectId) : null;
@@ -123,8 +133,9 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
async function save(): Promise<void> {
const body = {
years: summary.years.map((row) => row.year),
assignees: current().map(({ year, company_id, company_name, email }) => ({
assignees: current().map(({ year, piece_id, company_id, company_name, email }) => ({
year,
piece_id: piece_id ?? null,
company_id,
company_name,
email,
@@ -146,8 +157,8 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
}
}
function addForm(year: number): HTMLElement {
const listId = `d04-companies-${year}`;
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;
@@ -182,7 +193,13 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
showToast(t("D04_NeedEmail"), "warning");
return;
}
const next: Assignee = { year, company_id: companyId, company_name: name, email };
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]);
@@ -227,21 +244,30 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
}));
const { root: tableRoot, tbody } = tableShell(columns);
tableRoot.querySelector("table")?.classList.add("d04-confirm__table");
for (const row of summary.years) {
const firms = list.filter((item) => item.year === row.year).map(firmChip);
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, [
String(row.year),
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.year)] : firms }),
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") })] : [];