feat(Z01): 기초단가 표 [저장] 방식 교체 · 공종 편집 화면 신설 — PLAN 1-4
- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(sessionStorage z01-draft:base:{kind})에 쌓고 [저장] 한 번에 보냄 · [고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] · 409 면 초안 지키고 알림 · 서버가 revision 을 줄 때만 고치기 단추 섬
- 옛 덮개 길(칸 단위 PUT · 고친 것 모아 보기 · 덮개 판정 표시)과 그 사전 글자 뺌 — 계약 1-0 에서 서버 쪽 삭제
- 공종 편집(Z01_MasterData_UI_WorkItem.ts · _WorkItem_Rows.ts): 왼쪽 목록(상태별 개수·검색) / 머리 / 구성 줄 표(갈래별 · 자원 찾기 = 기초단가 검색) / 품셈 원문 표+[주] 읽기 전용 / [저장]·[초기값으로]·[검수 완료] · 초안 열쇠 z01-draft:work_item:{key}
- 공종 API(sub_laptop_3 e1c71c3e) 모양에 맞춤 — 줄은 id + 계약 칸 열하나만 보냄(extra=forbid) · 저장·초기값 응답(새 GET 모양)으로 바로 다시 그림 · 저장된 고침(changed_columns·is_added) 표시
- 초안 순수 함수 Z01_MasterData_UI_Draft.ts + 시험 test_z01_master_draft.py
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
This commit is contained in:
@@ -2,17 +2,12 @@
|
||||
* Z01_MasterData_Api_Fetch.ts
|
||||
* 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만
|
||||
*
|
||||
* 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례.
|
||||
* 응답 모양은 PLAN 1-0 계약(2026-09-18 브레인) — 정본은 DB · 고치기는 [저장] 한 번에 보냄
|
||||
* (칸 나갈 때 즉시 저장하던 옛 길 `PUT …/{row_id}`·`/overrides*` 는 서버에서 지워짐).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type {
|
||||
MasterColumn,
|
||||
OverrideState,
|
||||
ServedKind,
|
||||
SortOrder,
|
||||
TableMeta,
|
||||
} from "./Z01_MasterData_UI_Cells";
|
||||
import type { MasterColumn, ServedKind, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells";
|
||||
|
||||
export interface MasterTable {
|
||||
id: string;
|
||||
@@ -38,11 +33,16 @@ export interface MasterGroup {
|
||||
files: MasterFile[];
|
||||
}
|
||||
|
||||
/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */
|
||||
/**
|
||||
* 기초단가 표만 editable·locked·formula·revision 을 실음 — 트리 표는 읽기 전용이라 안 옴.
|
||||
* 기초단가 줄은 `@id`(= DB row_key) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자가 넣은 줄)를 실음.
|
||||
*/
|
||||
export interface MasterRows extends TableMeta {
|
||||
columns: MasterColumn[];
|
||||
rows: Record<string, unknown>[];
|
||||
total: number;
|
||||
/** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */
|
||||
revision?: number;
|
||||
}
|
||||
|
||||
export interface RowsQuery {
|
||||
@@ -65,17 +65,34 @@ export function fetchBaseKinds(): Promise<ServedKind[]> {
|
||||
return request<{ kinds: ServedKind[] }>("/master-data/base-prices").then((data) => data.kinds);
|
||||
}
|
||||
|
||||
/** 실패 응답 — `status` 409 = 낡은 판(다른 곳에서 먼저 저장함). */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const data = (await response.json()) as { detail?: string } & T;
|
||||
if (!response.ok) throw new Error(data.detail ?? "Request failed");
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!response.ok) {
|
||||
// 검사 실패(422)는 detail 이 목록으로 옴 — 글자로 펴서 보임(「[object Object]」 금지).
|
||||
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
|
||||
throw new ApiError(detail ?? "Request failed", response.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const send = <T>(method: string, path: string, body: unknown): Promise<T> =>
|
||||
request<T>(path, { method, body: JSON.stringify(body) });
|
||||
|
||||
const pageParams = (query: RowsQuery): Record<string, string> => ({
|
||||
page: String(query.page),
|
||||
size: String(query.size),
|
||||
@@ -93,37 +110,142 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<
|
||||
return request<MasterRows>(`/master-data/base-prices/${kind}?${params}`);
|
||||
}
|
||||
|
||||
export interface OverrideItem {
|
||||
kind: BasePriceKind;
|
||||
row_id: string;
|
||||
row_label: string;
|
||||
column: string;
|
||||
column_label: string;
|
||||
original: unknown;
|
||||
current?: unknown;
|
||||
value: unknown;
|
||||
unit?: string;
|
||||
state: OverrideState;
|
||||
/** 기초단가 [저장] 한 번 — 전부 아니면 전무 · edits 500 상한 · 낡은 판이면 409. */
|
||||
export interface BaseSave {
|
||||
base_revision: number;
|
||||
edits: { row_key: string; column: string; value: unknown }[];
|
||||
added: { data: Record<string, unknown> }[];
|
||||
deleted: string[];
|
||||
}
|
||||
|
||||
/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */
|
||||
export function fetchOverrides(
|
||||
query: Pick<RowsQuery, "page" | "size">,
|
||||
): Promise<{ items: OverrideItem[]; total: number }> {
|
||||
const params = new URLSearchParams({ page: String(query.page), size: String(query.size) });
|
||||
return request(`/master-data/overrides?${params}`);
|
||||
export function saveBasePrices(kind: BasePriceKind, body: BaseSave): Promise<unknown> {
|
||||
return send("PUT", `/master-data/base-prices/${kind}`, body);
|
||||
}
|
||||
|
||||
/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */
|
||||
export function saveBasePrice(
|
||||
kind: BasePriceKind,
|
||||
rowId: string,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ values }),
|
||||
/** 고른 줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨. */
|
||||
export function resetBasePrices(kind: BasePriceKind, rowKeys: string[]): Promise<unknown> {
|
||||
return send("POST", `/master-data/base-prices/${kind}/reset`, { row_keys: rowKeys });
|
||||
}
|
||||
|
||||
/* ── 공종 마스터(PLAN 1-0) ── */
|
||||
|
||||
export type WorkItemAxis = "forest" | "const";
|
||||
/** 주입 때 한 번 판정 · `reviewed` 는 관리자만 찍음 */
|
||||
export type WorkItemStatus = "auto_ok" | "partial" | "needs_input" | "not_item" | "reviewed";
|
||||
export type ResourceKind = "labor" | "machine" | "material" | "etc";
|
||||
|
||||
export interface WorkItemListRow {
|
||||
work_item_key: string;
|
||||
number: string;
|
||||
path_name: string;
|
||||
status: WorkItemStatus;
|
||||
row_count: number;
|
||||
}
|
||||
|
||||
export interface WorkItemList {
|
||||
items: WorkItemListRow[];
|
||||
total: number;
|
||||
/** 상태별 개수(검색·상태 거르기 전 그 축 전체) */
|
||||
counts: Partial<Record<WorkItemStatus, number>>;
|
||||
}
|
||||
|
||||
export interface WorkItemListQuery {
|
||||
axis: WorkItemAxis;
|
||||
status: WorkItemStatus | "";
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function fetchWorkItems(query: WorkItemListQuery): Promise<WorkItemList> {
|
||||
const params = new URLSearchParams({
|
||||
axis: query.axis,
|
||||
q: query.q,
|
||||
page: String(query.page),
|
||||
size: String(query.size),
|
||||
});
|
||||
if (query.status) params.set("status", query.status);
|
||||
return request<WorkItemList>(`/master-data/work-items?${params}`);
|
||||
}
|
||||
|
||||
/** 머리 중 관리자가 고치는 칸 — PUT 에 이것만 보냄 */
|
||||
export interface WorkItemHead {
|
||||
name: string;
|
||||
basis_quantity: number | null;
|
||||
basis_unit: string | null;
|
||||
status: WorkItemStatus;
|
||||
}
|
||||
|
||||
export interface WorkItemItem extends WorkItemHead {
|
||||
work_item_key: string;
|
||||
axis: WorkItemAxis;
|
||||
code: string | null;
|
||||
number: string;
|
||||
path_name: string;
|
||||
parent_key: string | null;
|
||||
parent_mode: string | null;
|
||||
edition: string | null;
|
||||
revision: number;
|
||||
/** 머리 중 초기값과 다른 칸(저장된 고침) */
|
||||
changed_columns?: string[];
|
||||
}
|
||||
|
||||
/** 구성 줄 — id 없으면 새 줄 · resource_key 없으면 기초단가와 못 맞춘 줄(raw_cell 에 원문과 까닭) */
|
||||
export interface WorkItemRow {
|
||||
id?: number;
|
||||
variant: string;
|
||||
resource_kind: ResourceKind;
|
||||
resource_key: string | null;
|
||||
resource_name: string;
|
||||
resource_spec: string;
|
||||
amount: number | null;
|
||||
amount_unit: string;
|
||||
alternative_amount: number | null;
|
||||
group_ratio_pct: number | null;
|
||||
raw_cell: string | null;
|
||||
pum_table_id: string | null;
|
||||
/** GET 만 — 관리자가 넣은 줄 · 초기값과 다른 칸 */
|
||||
is_added?: boolean;
|
||||
changed_columns?: string[];
|
||||
}
|
||||
|
||||
/** 품셈 원문 표 한 벌 — condition_note = 머리 글자 · raw_row = 원문 칸(읽기 전용) */
|
||||
export interface WorkItemSource {
|
||||
pum_table_id: string;
|
||||
section?: string | null;
|
||||
pum_form?: string | null;
|
||||
condition_note?: unknown;
|
||||
raw_row?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkItemDetail {
|
||||
item: WorkItemItem;
|
||||
rows: WorkItemRow[];
|
||||
source: WorkItemSource[];
|
||||
notes: unknown[];
|
||||
}
|
||||
|
||||
/** 공종 [저장] — rows 는 전체 줄(id 있으면 수정 · 없으면 추가 · 빠지면 삭제) · 낡은 판이면 409 */
|
||||
export interface WorkItemSave {
|
||||
base_revision: number;
|
||||
item: WorkItemHead;
|
||||
rows: WorkItemRow[];
|
||||
}
|
||||
|
||||
const workItemPath = (key: string) => `/master-data/work-items/${encodeURIComponent(key)}`;
|
||||
|
||||
export function fetchWorkItem(key: string): Promise<WorkItemDetail> {
|
||||
return request<WorkItemDetail>(workItemPath(key));
|
||||
}
|
||||
|
||||
/** 응답은 새 GET 모양 그대로(다시 안 불러도 됨) · 모양 오류 422 · 낡은 판 409. */
|
||||
export function saveWorkItem(key: string, body: WorkItemSave): Promise<WorkItemDetail> {
|
||||
return send("PUT", workItemPath(key), body);
|
||||
}
|
||||
|
||||
/** 머리·줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨 · 응답은 새 GET 모양. */
|
||||
export function resetWorkItem(key: string): Promise<WorkItemDetail> {
|
||||
return send("POST", `${workItemPath(key)}/reset`, {});
|
||||
}
|
||||
|
||||
export async function fetchMasterTree(): Promise<MasterGroup[]> {
|
||||
|
||||
Reference in New Issue
Block a user