diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 46e87fa0..3b9332cc 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -47,6 +47,7 @@ export interface ProjectItem { client_org?: string | null; project_number?: string | null; work_amount?: string | null; + design_date?: string | null; owner_name?: string | null; workflow_stage: number; progress_percent: number; @@ -139,6 +140,7 @@ export interface UpdateProjectRequest { client_org?: string | null; project_number?: string | null; work_amount?: string | null; + design_date?: string | null; } export interface AdminUpdateUserRequest extends UpdateUserRequest { diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index a8f73bc6..920f9004 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -149,7 +149,7 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]: await cursor.execute( """SELECT id, company_id, name, region, road_type, project_year, estimated_length_m, memo, status, updated_at, created_at, - client_org, project_number, work_amount + client_org, project_number, work_amount, design_date FROM projects WHERE user_id = %s AND deleted_at IS NULL ORDER BY updated_at DESC, created_at DESC""", (user_id,), @@ -171,7 +171,7 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]: await cursor.execute( """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at, - p.client_org, p.project_number, p.work_amount, + p.client_org, p.project_number, p.work_amount, p.design_date, u.name AS owner_name, u.email AS owner_email FROM projects p LEFT JOIN users u ON u.id = p.user_id WHERE p.company_id = %s AND p.deleted_at IS NULL @@ -195,7 +195,7 @@ async def list_all_projects() -> list[dict[str, Any]]: await cursor.execute( """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at, - p.client_org, p.project_number, p.work_amount, + p.client_org, p.project_number, p.work_amount, p.design_date, u.name AS owner_name, u.email AS owner_email FROM projects p LEFT JOIN users u ON u.id = p.user_id WHERE p.deleted_at IS NULL @@ -218,7 +218,7 @@ async def get_project(project_id: str) -> dict[str, Any] | None: await cursor.execute( """SELECT id, user_id, company_id, name, region, road_type, project_year, estimated_length_m, memo, status, - client_org, project_number, work_amount + client_org, project_number, work_amount, design_date FROM projects WHERE id = %s AND deleted_at IS NULL""", (project_id,), ) @@ -233,7 +233,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - """UPDATE projects SET name = %s, region = %s, road_type = %s, project_year = %s, estimated_length_m = %s, memo = %s, status = COALESCE(%s, status), - client_org = %s, project_number = %s, work_amount = %s + client_org = %s, project_number = %s, work_amount = %s, + design_date = %s WHERE id = %s AND deleted_at IS NULL""", ( data["name"], @@ -246,6 +247,7 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - data.get("client_org"), data.get("project_number"), data.get("work_amount"), + data.get("design_date"), project_id, ), ) diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index 7b5ef6d9..76fcf3bd 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -1,5 +1,7 @@ """B01_Dashboard 요청 스키마.""" +from datetime import date + from pydantic import BaseModel, Field @@ -50,6 +52,8 @@ class UpdateProjectRequest(BaseModel): client_org: str | None = Field(default=None, max_length=255) project_number: str | None = Field(default=None, max_length=100) work_amount: str | None = Field(default=None, max_length=100) + # 설계일자는 확정일 자동이 아니라 사용자 지정이다 (2026-09-02 사용자 확정). + design_date: date | None = None class AdminUpdateUserRequest(UpdateUserRequest): diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 96535b2e..1510d387 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -105,6 +105,12 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem): value: project.work_amount ?? "", placeholder: "예: L=2.14km", }); + // 설계일자는 확정일 자동이 아니라 사용자가 지정한다 (2026-09-02 사용자 확정). + const designDate = createInputField({ + label: "설계일자 (도면 표제란)", + type: "date", + value: (project.design_date ?? "").slice(0, 10), + }); if (isUserOnly) { name.input.disabled = true; @@ -116,6 +122,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem): clientOrg.input.disabled = true; projectNumber.input.disabled = true; workAmount.input.disabled = true; + designDate.input.disabled = true; } openModal( @@ -130,6 +137,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem): clientOrg.root, projectNumber.root, workAmount.root, + designDate.root, ], async () => { await updateProject(project.id, { @@ -143,6 +151,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem): client_org: clientOrg.input.value.trim() || null, project_number: projectNumber.input.value.trim() || null, work_amount: workAmount.input.value.trim() || null, + design_date: designDate.input.value || null, }); showToast(L("B01_Dashboard_Saved"), "success"); }, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index ecb09ad0..eb8dec65 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -62,6 +62,15 @@ def use_title_fields(fields: dict[str, str] | None) -> None: _title_fields.set(fields or {}) +def add_title_fields(extra: dict[str, str]) -> None: + """이미 세운 표제란 값에 몇 개를 덧붙인다. + + 도면번호처럼 **도면을 읽는 도중에야 아는 값**을 위해서다. 라우터가 DB 값을 먼저 세우고, + 작도 스레드가 여기서 나머지를 얹는다(`asyncio.to_thread` 가 문맥을 복사한다). + """ + _title_fields.set({**_title_fields.get(), **(extra or {})}) + + def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path: """회사 도각 파일 경로(없을 수도 있다).""" return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json" diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index edc3f355..3848ddab 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -123,17 +123,16 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: 아직 못 채우는 자리와 이유: - 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸. - - 설계일자 — "확정일"인데 도각은 **확정 전**에 그려져 저장본에 굳는다. - 채울 시점 정의가 미결이라 비워 둔다. - - 도면번호 — 단건 조회가 목록 순서를 모른다(목록을 다시 만들면 도면을 열 때마다 - 횡단 장 계획을 재계산하게 된다). + - 설계일자·도면번호는 여기서 담지 않는다 — 설계일자는 사용자가 넣은 `design_date` + 를 그대로 쓰고(확정일 자동이 아니다), 도면번호는 도면을 읽는 쪽이 manifest 에 + 적힌 목록 순번을 얹는다. """ pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( """ SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount, - c.name, c.logo_path, + p.design_date, c.name, c.logo_path, COALESCE(designer.name, owner.name), COALESCE(designer.signature_path, owner.signature_path), pm.name, lead.name @@ -156,6 +155,7 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: client_org, project_number, work_amount, + design_date, company, logo_path, designer, @@ -169,6 +169,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: "시행청": client_org, "연도기번": project_number, "사업량": work_amount, + # 도면 표기 관행대로 "2026. 09. 02." 꼴로 적는다. + "설계일자": f"{design_date:%Y. %m. %d.}" if design_date else None, "용역회사": company, "설계자": designer, "과업책임자": pm, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index d536da10..5d0396d8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -40,6 +40,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( QUANTITY_VALUE_KEYS, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) @@ -417,9 +418,29 @@ def _drawing_list( # 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다. for drawing_id, label in BLANK_DRAWINGS: drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label)) + _store_drawing_numbers(project_root, drawings) return drawings +def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]) -> None: + """도면번호(목록 순번)를 manifest 에 적어 둔다. + + 단건 조회는 목록 순서를 모른다 — 알려면 횡단 장 계획을 다시 계산해야 하고, 그것을 도면 + 열 때마다 하면 비싸다. 목록은 화면에 들어올 때 늘 먼저 뜨므로, 그때 매긴 번호를 적어 + 두고 단건 조회는 그것을 읽는다. 목록이 바뀌면 다음 조회에서 다시 적힌다. + """ + manifest = _read_manifest(project_root) + entries = manifest["drawings"] + changed = False + for number, item in enumerate(drawings, start=1): + entry = entries.setdefault(item.id, {}) + if entry.get("number") != number: + entry["number"] = number + changed = True + if changed: + _write_manifest(project_root, manifest) + + def _cross_sheet_plan( project_root: Path, longitudinal_path: Path, @@ -590,6 +611,9 @@ def _read_drawing( stored_design(없으면 기본값)에서 만든다. """ manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {}) + number = manifest_entry.get("number") + if isinstance(number, int): + add_title_fields({"도면번호": str(number)}) saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json" if manifest_entry.get("confirmed") and saved_path.is_file(): saved = _read_json(saved_path) diff --git a/db_management/013_company_assets.sql b/db_management/013_company_assets.sql new file mode 100644 index 00000000..333a7162 --- /dev/null +++ b/db_management/013_company_assets.sql @@ -0,0 +1,34 @@ +-- 013_company_assets.sql +-- 설계일자·도면 자산(로고·서명) 회사 공유 (2026-09-02 사용자 확정) +-- +-- ① 설계일자는 확정일 자동이 아니라 **사용자가 지정 입력**한다. +-- ② 로고·서명은 **회사 안에서 공유하는 목록**으로 관리한다. 사용자 계정에 물릴 수도 있고 +-- 아닐 수도 있다(회사 공용 직인 등). 회사 구성원과 회사 관리자가 작성·수정하고, +-- 시스템 관리자는 전체 권한을 가진다. +-- ③ 도면이 어느 로고·서명을 쓸지는 **프로젝트가 고른다** — 목록에서 골라 물린다. +-- +-- 011 의 `companies.logo_path`·`users.signature_path` 는 이 표가 정본이 되면서 읽지 않는다. +-- 컬럼은 남겨 둔다(4환경 공유 DB 라 지우는 쪽이 위험하다). + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS design_date DATE NULL COMMENT '설계일자 (사용자 지정)' AFTER work_amount, + ADD COLUMN IF NOT EXISTS logo_asset_id INT NULL COMMENT '도면에 쓸 회사 로고 (company_assets.id)', + ADD COLUMN IF NOT EXISTS signature_asset_id INT NULL COMMENT '도면에 쓸 서명 (company_assets.id)'; + +-- 회사 안에서 공유하는 도면 자산. 그림은 파일로 두고 경로만 담는다. +CREATE TABLE IF NOT EXISTS company_assets ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + kind ENUM('LOGO', 'SIGNATURE') NOT NULL, + label VARCHAR(100) NOT NULL COMMENT '목록에 보이는 이름', + file_path VARCHAR(500) NOT NULL COMMENT 'storage/ 기준 상대 경로', + user_id INT NULL COMMENT '이 자산의 주인 (없으면 회사 공용)', + created_by INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_company_assets_company (company_id, kind, deleted_at), + INDEX idx_company_assets_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 0c87f8a9..d50e9d3b 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -82,7 +82,7 @@ export interface InputFieldOptions { label?: string; /** placeholder (i18n 결과) */ placeholder?: string; - type?: "text" | "password" | "email" | "number" | "search" | "tel"; + type?: "text" | "password" | "email" | "number" | "search" | "tel" | "date"; value?: string; required?: boolean; /** number 타입 범위 (1차 유효성 검사용, frontend.md §4) */