feat(732-1): M02 실무문서 칸 · 복사 탭 · 서버 길 걷음
- 왼쪽 「실무문서」 칸(Practice.ts) · 일위대가 가져오기 「실무문서」 탭 · 복사 원본 고르기 · 낱 CSS · locale 열쇠 걷음 - 서버 `from_layer=practice`(_copy_practice) · `DELETE /api/m02/practice`(drop_practice) · 조합 목록 · 키 하나 읽기 길 걷음 · 조합 길 시스템 관리자 문 걷음(담을 로직 읽기는 그대로) - 옛 마스터 조합 파일 `일위대가조합.json`(UA 37) = 쓰는 곳 있음(구조물집계표 일위대가 칸 · labor 본뜬키 · 프로젝트 초기값 사본 · 불러오기 맞춤 씨앗) → 파일 · 읽기 길 유지 · 지우지 않음 - 낡은 시험: test_m02_practice 지움(화면 · 길 없음) · 조합 길 읽던 시험(combo · combo_inputs · combo_layers)은 저장소 함수로 · 실무문서 복사 시험(labor_copy · pair_copy) 줄 지움 · 탭 · 차례 단정(layer_screen · structure_options · structure_panel · 720 · precombo_scope · ui_loading) 고침 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -324,7 +324,7 @@ def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def from_combo(key: str) -> dict[str, Any]:
|
||||
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
|
||||
"""옛 일위대가 조합(마스터 UA · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
|
||||
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
|
||||
없는 조합은 404."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
수량 null · 자재 []) — 여러 열이 쓰던 조합도 열마다 따로 복사
|
||||
- 키 없는 열 = 빈 일위대가 · 번호 SU 는 저장소가 줌(도번과 같은 뒷자리)
|
||||
- 몇 번 돌려도 같음 — 이미 그 키를 본뜬 문서 · 사람이 채운 문서(담은로직 · 자재가 있음)는 그대로
|
||||
옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음(실무문서 · 은퇴는 39-1b B9).
|
||||
옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음.
|
||||
|
||||
./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py # 시스템 층
|
||||
./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py --storage # 회사 · 개인 층
|
||||
|
||||
@@ -312,13 +312,12 @@ export const fetchStructureCombos = (): Promise<StructureCombos> => scoped("/str
|
||||
|
||||
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
|
||||
/** 항목 하나 복사해 가져오기(39-5 · 39-1b) — 받는 열(`column`)에 덮어씀 · 번호 · 열은 그대로 ·
|
||||
* `source` = 원본 열 id(실무문서면 조합 키) · `from` 없음 = 지금 층 · 층 · 주인 = 다른 층(원본 읽기 권한) ·
|
||||
* `practice` = 실무문서 조합 → 일위대가(시스템 관리자만) */
|
||||
* `source` = 원본 열 id · `from` 없음 = 지금 층 · 층 · 주인 = 다른 층(원본 읽기 권한) */
|
||||
export const copyPartFrom = async (
|
||||
kind: "structure" | "labor" | "area",
|
||||
column: string,
|
||||
source: string,
|
||||
from: { layer: Layer | "practice"; owner: string | null } | null,
|
||||
from: { layer: Layer; owner: string | null } | null,
|
||||
pair = false,
|
||||
): Promise<void> => {
|
||||
const q = new URLSearchParams({ from: source });
|
||||
@@ -333,11 +332,11 @@ export const copyPartFrom = async (
|
||||
|
||||
/** 일위대가 · 면적산출 함께 가져오기(43-9a · 카드 [가져오기]) — 원본 열의 면적산출(시트 · 알약 연결)과
|
||||
* 일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 ·
|
||||
* 원본에 면적산출이 없으면(실무문서) 일위대가만 · 인자는 `copyPartFrom` 과 같음 */
|
||||
* 원본에 면적산출이 없으면 일위대가만 · 인자는 `copyPartFrom` 과 같음 */
|
||||
export const copyLaborAreaFrom = (
|
||||
column: string,
|
||||
source: string,
|
||||
from: { layer: Layer | "practice"; owner: string | null } | null,
|
||||
from: { layer: Layer; owner: string | null } | null,
|
||||
): Promise<void> => copyPartFrom("labor", column, source, from, true);
|
||||
|
||||
/** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */
|
||||
|
||||
@@ -277,21 +277,6 @@ def edit(key: str, row: Any, version: str) -> dict[str, Any]:
|
||||
return {"키": key, "판": _save(doc, version), "combo": fixed}
|
||||
|
||||
|
||||
def drop_practice(version: str) -> dict[str, Any]:
|
||||
"""실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 ·
|
||||
구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만."""
|
||||
if heads()[-1] != MASTER:
|
||||
raise store.StoreError(400, "실무문서는 마스터 층에만 있음")
|
||||
doc, have = _load()
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
keys = [str(r.get("키")) for r in doc.get("줄") or []]
|
||||
_refuse_used(keys)
|
||||
if keys:
|
||||
_m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have)
|
||||
return {**listing(), "지운조합": keys}
|
||||
|
||||
|
||||
def delete(key: str, version: str) -> dict[str, Any]:
|
||||
"""지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음)."""
|
||||
if heads()[-1] == MASTER:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemPick.ts
|
||||
* 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`).
|
||||
* 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람) + 항목 목록(`ItemList`).
|
||||
* 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀.
|
||||
* 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈
|
||||
* (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로.
|
||||
* 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만).
|
||||
* 다른 사람 = 사람 고르기 하나(`fetchOwners`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
@@ -16,11 +16,9 @@ import {
|
||||
ITEM_COLS,
|
||||
loadItems,
|
||||
missingPart,
|
||||
type Col,
|
||||
type ItemRow,
|
||||
type ListKind,
|
||||
} from "./M02_MasterTemplete_ItemList";
|
||||
import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api";
|
||||
import {
|
||||
entryFromHash,
|
||||
ownOwner,
|
||||
@@ -42,28 +40,17 @@ const TEXT = {
|
||||
makeCopy: "복사 생성",
|
||||
};
|
||||
|
||||
/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */
|
||||
/** 고른 곳 */
|
||||
export interface From {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
label: string;
|
||||
practice: boolean;
|
||||
}
|
||||
|
||||
export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary };
|
||||
export type Picked = { from: From; row: ItemRow };
|
||||
|
||||
/** 고른 것 이름 — 확인 글에 씀 */
|
||||
export const pickedName = (p: Picked): string =>
|
||||
"row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`;
|
||||
|
||||
const PRACTICE_COLS: Col<ComboSummary>[] = [
|
||||
{ head: "키", get: (c) => c.키 },
|
||||
{ head: "이름", get: (c) => c.이름 },
|
||||
{ head: "구분", get: (c) => c.구분 ?? "" },
|
||||
{ head: "상세구분", get: (c) => c.상세구분 ?? "" },
|
||||
{ head: "단위", get: (c) => c.단위 ?? "" },
|
||||
{ head: "단가산출 기초 수", get: (c) => String(c.count) },
|
||||
];
|
||||
export const pickedName = (p: Picked): string => leafName(p.row);
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
/** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */
|
||||
@@ -73,7 +60,7 @@ export interface PickerOptions {
|
||||
user: SessionUser | null;
|
||||
/** 볼 회사(`viewCompany`) */
|
||||
company: string | null;
|
||||
/** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */
|
||||
/** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가 */
|
||||
kind: ListKind;
|
||||
/** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */
|
||||
skip?: (from: From, row: ItemRow) => boolean;
|
||||
@@ -82,9 +69,7 @@ export interface PickerOptions {
|
||||
|
||||
/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */
|
||||
export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
|
||||
const { tabs, first } = readableSources(opt.user, opt.company, {
|
||||
practice: opt.kind === "labor",
|
||||
});
|
||||
const { tabs, first } = readableSources(opt.user, opt.company);
|
||||
const list = buildItemList();
|
||||
const people = createSelectField({ options: [], compact: true, label: TEXT.person });
|
||||
people.root.hidden = true;
|
||||
@@ -109,12 +94,6 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
|
||||
people.root.hidden = tab.id !== "people";
|
||||
list.message();
|
||||
try {
|
||||
if (tab.id === "practice") {
|
||||
const combos = await fetchCombos();
|
||||
if (mine !== seq) return;
|
||||
const from = { layer: tab.layer, owner: null, label: tab.label, practice: true };
|
||||
return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo }));
|
||||
}
|
||||
let owner = tab.owner;
|
||||
let label = tab.label;
|
||||
if (tab.id === "people") {
|
||||
@@ -139,7 +118,7 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
|
||||
}
|
||||
const rows = await loadItems(tab.layer, owner);
|
||||
if (mine !== seq) return;
|
||||
const from = { layer: tab.layer, owner, label, practice: false };
|
||||
const from = { layer: tab.layer, owner, label };
|
||||
list.show(
|
||||
rows.filter((row) => !opt.skip?.(from, row)),
|
||||
ITEM_COLS[opt.kind],
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Api.ts
|
||||
* 일위대가 조합 서버 길 — 층(마스터 UA · 회사 UC · 개인 UP) 조합 표 `일위대가조합.json`
|
||||
* 길 = `/api/m02/layers/{layer}/combos`(PLAN 27-2a) · 키 하나 = `/api/m02/combos/key/{키}` ·
|
||||
* 담을 로직 읽기 = `/api/m02/logic-lib/…` · 모양 = `_화면_계약.md` 9장
|
||||
* 일위대가 서버 길 — 담을 로직 읽기 = `/api/m02/logic-lib/…` · 모양 = `_화면_계약.md` 9장
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -21,61 +19,6 @@ import {
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */
|
||||
export interface ComboLogic {
|
||||
로직: string;
|
||||
메모: string | null;
|
||||
}
|
||||
/** 조합 한 줄 통째 — `_틀.md` 10장 · 키·소유는 서버 것이 이김 */
|
||||
export interface Combo {
|
||||
키: string;
|
||||
이름: string;
|
||||
구분: string | null;
|
||||
상세구분: string | null;
|
||||
단위: string | null;
|
||||
담은로직: ComboLogic[];
|
||||
출처: string | null;
|
||||
소유: string | null;
|
||||
/** 이 조합을 본떠 만든 원본 키 — 비면 원본(M02 가 갈라 낸 조합에 채워짐) */
|
||||
본뜬키: string | null;
|
||||
비고: string | null;
|
||||
}
|
||||
export interface ComboSummary extends Combo {
|
||||
count: number;
|
||||
blocked: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
/** `GET /combo` 의 `줄` — 담은 로직에 이름을 붙인 것 */
|
||||
export interface ComboLine {
|
||||
차례: number;
|
||||
로직: string;
|
||||
메모: string | null;
|
||||
ok: boolean;
|
||||
까닭?: string;
|
||||
이름?: string;
|
||||
구분?: string;
|
||||
상세구분?: string;
|
||||
결과단위?: string;
|
||||
}
|
||||
export interface ComboOne {
|
||||
file: string;
|
||||
version: string;
|
||||
combo: Combo;
|
||||
줄: ComboLine[];
|
||||
}
|
||||
/** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */
|
||||
export interface LaborScope {
|
||||
layer: Exclude<Layer, "project">;
|
||||
owner: string | null;
|
||||
}
|
||||
export const SYSTEM_SCOPE: LaborScope = { layer: "system", owner: null };
|
||||
|
||||
/** 폴더 = [구분] · [구분, 상세구분] — 깊이 2(PLAN 27-2a) */
|
||||
export interface ComboList {
|
||||
판: string;
|
||||
폴더: string[][];
|
||||
줄: ComboSummary[];
|
||||
}
|
||||
async function request<T>(url: string, method = "GET", body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02${url}`, {
|
||||
method,
|
||||
@@ -89,25 +32,6 @@ async function request<T>(url: string, method = "GET", body?: unknown): Promise<
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
/** 층 길 — `path` 뒤에 주인 · 더 붙일 물음 */
|
||||
const at = (scope: LaborScope, path = "", more: Record<string, string> = {}): string => {
|
||||
const params = new URLSearchParams(more);
|
||||
if (scope.owner) params.set("owner", scope.owner);
|
||||
const q = params.toString();
|
||||
return `/layers/${scope.layer}/combos${path}${q ? `?${q}` : ""}`;
|
||||
};
|
||||
|
||||
export const fetchComboList = (scope: LaborScope = SYSTEM_SCOPE): Promise<ComboList> =>
|
||||
request(at(scope));
|
||||
|
||||
export const fetchCombos = (scope: LaborScope = SYSTEM_SCOPE): Promise<ComboSummary[]> =>
|
||||
fetchComboList(scope).then((d) => d.줄);
|
||||
|
||||
/** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */
|
||||
export const fetchCombo = (key: string): Promise<ComboOne & LaborScope> =>
|
||||
request<Omit<ComboOne, "file" | "version"> & LaborScope & { 판: string }>(
|
||||
`/combos/key/${enc(key)}`,
|
||||
).then((d) => ({ ...d, file: "일위대가조합.json", version: d.판 }));
|
||||
|
||||
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
|
||||
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
|
||||
|
||||
@@ -131,19 +131,18 @@ export function manages(company: string | null, user: SessionUser | null): boole
|
||||
|
||||
/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */
|
||||
export interface Source {
|
||||
id: "master" | "company" | "personal" | "people" | "practice";
|
||||
id: "master" | "company" | "personal" | "people";
|
||||
layer: ManageLayer;
|
||||
/** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */
|
||||
/** 층 주인 — 자기 층 · 마스터 · 다른 사람 = null */
|
||||
owner: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭.
|
||||
* 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */
|
||||
* 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) */
|
||||
export function readableSources(
|
||||
user: SessionUser | null,
|
||||
company: string | null,
|
||||
opt: { practice?: boolean } = {},
|
||||
): { tabs: Source[]; first: Source["id"] } {
|
||||
const isSystem = user?.role === "SYSTEM_ADMIN";
|
||||
const own = ownOwner("personal", user);
|
||||
@@ -179,16 +178,6 @@ export function readableSources(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(opt.practice && isSystem
|
||||
? [
|
||||
{
|
||||
id: "practice" as const,
|
||||
layer: "system" as const,
|
||||
owner: null,
|
||||
label: L("M02_SourcePractice"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return { tabs, first: isSystem || !own ? "master" : "personal" };
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Practice.ts
|
||||
* 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) —
|
||||
* 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) ·
|
||||
* [전체 삭제](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인).
|
||||
* 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
fetchCombo,
|
||||
fetchComboList,
|
||||
type ComboList,
|
||||
type ComboOne,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
|
||||
export interface PracticeSectionOptions {
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 화면을 띄움 */
|
||||
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface PracticeSection {
|
||||
title: string;
|
||||
body: HTMLElement;
|
||||
/** 다른 화면을 열었음 — 고름 표시만 지움 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
/** 서버 까닭 글 — `{errors}` · 판 다름 · 글 */
|
||||
function reasonOf(detail: unknown, status: number): string {
|
||||
if (typeof detail === "string") return detail;
|
||||
const d = detail as { errors?: string[]; stale?: unknown } | null;
|
||||
if (d?.errors?.length) return d.errors.join(" · ");
|
||||
return d?.stale ? L("M02_PracticeStale") : `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** 통째 지우기 — 줄을 모두 비움(판 = 읽은 표 판) */
|
||||
async function dropPractice(판: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/m02/practice?판=${encodeURIComponent(판)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) return;
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
|
||||
throw new Error(reasonOf(data.detail, res.status));
|
||||
}
|
||||
|
||||
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-practice__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const cells = (values: string[]): HTMLElement[] =>
|
||||
values.map((v) => el("td", { text: v, attrs: { title: v } }));
|
||||
|
||||
export function buildPracticeSection(opt: PracticeSectionOptions): PracticeSection {
|
||||
const row = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: L("M02_PracticeRow"),
|
||||
attrs: { type: "button", "data-practice-row": "" },
|
||||
});
|
||||
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
|
||||
const view = el("div", { className: "m02-practice" });
|
||||
let opened = false;
|
||||
let nav = 0; // 늦게 온 응답 버림
|
||||
const mark = (on: boolean): void => {
|
||||
opened = on;
|
||||
row.classList.toggle("is-active", on);
|
||||
};
|
||||
row.addEventListener("click", () => void open());
|
||||
|
||||
async function open(): Promise<void> {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
opt.show(L("M02_PracticeTitle"), view);
|
||||
opt.onOpen();
|
||||
mark(true);
|
||||
await drawList();
|
||||
}
|
||||
|
||||
async function drawList(): Promise<void> {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view);
|
||||
let list: ComboList;
|
||||
try {
|
||||
list = await fetchComboList();
|
||||
} catch (error) {
|
||||
stop();
|
||||
if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawList()));
|
||||
return;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
if (my !== nav) return;
|
||||
const drop = createButton({ label: L("M02_PracticeDrop"), variant: "danger", size: "sm" });
|
||||
drop.disabled = !list.줄.length;
|
||||
drop.addEventListener("click", () => void dropAll(list, drop));
|
||||
const rows = list.줄.map((c) => {
|
||||
const tr = el("tr", {
|
||||
className: "m02-practice__row",
|
||||
attrs: { "data-practice-key": c.키 },
|
||||
children: cells([c.키, c.이름, c.구분 ?? "", c.단위 ?? "", String(c.count)]),
|
||||
});
|
||||
tr.addEventListener("click", () => void drawOne(c.키));
|
||||
return tr;
|
||||
});
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-toolbar m02-practice__bar",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: L("M02_PracticeCount").replace("{n}", String(list.줄.length)),
|
||||
}),
|
||||
drop,
|
||||
],
|
||||
}),
|
||||
rows.length
|
||||
? table(
|
||||
[
|
||||
L("M02_PracticeKey"),
|
||||
L("M02_Name"),
|
||||
L("M02_PracticeGroup"),
|
||||
L("M02_PracticeUnit"),
|
||||
L("M02_PracticeLogics"),
|
||||
],
|
||||
rows,
|
||||
)
|
||||
: createEmpty({ text: L("M02_PracticeEmpty"), small: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 상세 — 읽기만(이름 · 키 · 구분 · 단위 · 비고 + 담은 로직 표) */
|
||||
async function drawOne(key: string): Promise<void> {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view);
|
||||
let one: ComboOne;
|
||||
try {
|
||||
one = await fetchCombo(key);
|
||||
} catch (error) {
|
||||
stop();
|
||||
if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawOne(key)));
|
||||
return;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
if (my !== nav) return;
|
||||
const back = createButton({ label: L("M02_PracticeBack"), variant: "ghost", size: "sm" });
|
||||
back.addEventListener("click", () => void drawList());
|
||||
const c = one.combo;
|
||||
const info = [
|
||||
[L("M02_PracticeKey"), c.키],
|
||||
[L("M02_PracticeGroup"), c.구분 ?? ""],
|
||||
[L("M02_PracticeUnit"), c.단위 ?? ""],
|
||||
].map(([k, v]) =>
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: `${k} ${v}`,
|
||||
}),
|
||||
);
|
||||
const lines = one.줄.map((x) =>
|
||||
el("tr", {
|
||||
children: cells([
|
||||
String(x.차례),
|
||||
x.로직,
|
||||
x.ok ? (x.이름 ?? "") : (x.까닭 ?? ""),
|
||||
x.결과단위 ?? "",
|
||||
x.메모 ?? "",
|
||||
]),
|
||||
}),
|
||||
);
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-toolbar m02-practice__bar",
|
||||
children: [back, el("strong", { text: c.이름 }), ...info],
|
||||
}),
|
||||
...(c.비고 ? [el("p", { className: "m02-practice__memo", text: c.비고 })] : []),
|
||||
lines.length
|
||||
? table(
|
||||
[
|
||||
L("M02_PracticeOrder"),
|
||||
L("M02_PracticeKey"),
|
||||
L("M02_Name"),
|
||||
L("M02_PracticeUnit"),
|
||||
L("M02_PracticeMemo"),
|
||||
],
|
||||
lines,
|
||||
)
|
||||
: createEmpty({ text: L("M02_PracticeNoLogic"), small: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 통째 지우기 — 물음 두 번 · 서버가 쓰는 곳 · 판을 다시 봄 */
|
||||
async function dropAll(list: ComboList, button: HTMLButtonElement): Promise<void> {
|
||||
const n = String(list.줄.length);
|
||||
if (!(await showConfirmDialog(L("M02_PracticeDropAsk").replace("{n}", n), L("M02_Delete"))))
|
||||
return;
|
||||
if (!(await showConfirmDialog(L("M02_PracticeDropAsk2"), L("M02_PracticeDrop")))) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await dropPractice(list.판);
|
||||
showToast(L("M02_PracticeDropped").replace("{n}", n), "success");
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
await drawList();
|
||||
}
|
||||
|
||||
return { title: L("M02_PracticeTitle"), body, clear: () => mark(false) };
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
"""M02 일위대가 조합 층 API(PLAN 27-2a) — `/api/m02/layers/{layer}/combos`(옛 `/api/m01/combo*`).
|
||||
"""M02 담을 로직 읽기 API — `/api/m02/logic-lib/…`(로그인만).
|
||||
|
||||
⚠ 등록은 `main.py`(로그인만) · 층 권한은 `M02_Layer_Access`(`Router_Items._root`) 한 규칙 ·
|
||||
읽기만(목록 · 키 하나) + 실무문서 통째 지우기 — 갈라 내기 · 키 적기 · 만들기 · 고치기 · 지우기 ·
|
||||
알약 길은 은퇴(39-1d · 정본 = 열마다 `labor/<열>.json`).
|
||||
저장소 = `M02_MasterTemplete_Combo_Store`.
|
||||
⚠ 등록은 `main.py`(로그인만). 조합 목록 · 키 하나 · 통째 지우기 길은 없앰(732-1).
|
||||
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
|
||||
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
|
||||
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
|
||||
85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째
|
||||
(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만.
|
||||
704 — 기준 목록(`/logic-lib/standard`) · 계산서(`/logic-lib/sheet`) 로그인만(`M02_Logic_Standard`).
|
||||
@@ -26,61 +22,14 @@ from common_util.common_util_http_cache import cached_file_response
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_Logic_Standard as standard
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
|
||||
def _door(request: Request, session: Session = Depends(verify_session)) -> None:
|
||||
"""일위대가 조합 = 실무문서(39-6) — 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만."""
|
||||
if "/logic-lib/" not in request.url.path and not access.is_system_admin(session):
|
||||
raise HTTPException(status_code=403, detail="실무문서(일위대가 조합)는 시스템 관리자만 봄.")
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)]
|
||||
)
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"])
|
||||
|
||||
if "pytest" not in sys.modules: # 기준 목록을 뒤에서 한 번 셈(저장본이 있으면 읽기만)
|
||||
standard.warm()
|
||||
|
||||
|
||||
def _where(key: str) -> tuple[str, str | None]:
|
||||
got = combos.where(key)
|
||||
if got is None:
|
||||
raise HTTPException(status_code=404, detail=f"없는 조합 「{key}」")
|
||||
return got
|
||||
|
||||
|
||||
@router.get("/combos/key/{key}")
|
||||
def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict:
|
||||
"""키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`."""
|
||||
layer, owner = _where(key)
|
||||
return {"layer": layer, "owner": owner, **_call(_root(session, layer, owner), combos.one, key)}
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/combos")
|
||||
def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
|
||||
return _call(_root(session, layer, owner), combos.listing)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/combos/{key}")
|
||||
def get_combo(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
):
|
||||
return _call(_root(session, layer, owner), combos.one, key)
|
||||
|
||||
|
||||
@router.delete("/practice")
|
||||
def delete_practice(판: str = "", session: Session = Depends(verify_session)) -> dict:
|
||||
"""실무문서 통째 지우기(PLAN 39-2) — 시스템 관리자만 · 마스터 조합 표 줄을 모두 비움 ·
|
||||
구조물이 쓰는 조합이 있으면 409."""
|
||||
if not access.is_system_admin(session):
|
||||
raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자만")
|
||||
return _call(_root(session, "system", None, write=True), combos.drop_practice, 판)
|
||||
|
||||
|
||||
# ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ──
|
||||
def _read(fn, *args):
|
||||
try:
|
||||
|
||||
@@ -301,15 +301,6 @@ def post_structure(
|
||||
return _call(_root(session, layer, owner, write=True), store.create_structure, column)
|
||||
|
||||
|
||||
def _copy_practice(column: str, key: str) -> dict[str, Any]:
|
||||
"""실무문서 조합(마스터 UA) → 이 열 일위대가 문서(입력값 빈 값 · 39-1b)."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
if not key.startswith("UA"):
|
||||
raise store.StoreError(400, f"실무문서 조합 키가 아님 「{key}」")
|
||||
return store.copy_from("labor", column, key, body=labor.from_combo(key))
|
||||
|
||||
|
||||
class AreaTableBody(BaseModel):
|
||||
"""면적산출 범위 → 도면 표(41-1) — `시트` = 시트 id · 이름 · `범위` · `이름`(104-1) 이 있으면
|
||||
그 이름이 지금 가리키는 범위로 품(연결표 다시 읽을 때) · `글자크기` 기본 2.5."""
|
||||
@@ -358,23 +349,16 @@ def post_copy(
|
||||
column: str,
|
||||
source: str = Query(alias="from"),
|
||||
owner: str | None = None,
|
||||
from_layer: Literal["system", "company", "personal", "practice"] | None = None,
|
||||
from_layer: Literal["system", "company", "personal"] | None = None,
|
||||
from_owner: str | None = None,
|
||||
pair: bool = False,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) ·
|
||||
다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만) ·
|
||||
`pair` = 일위대가 · 면적산출 함께(43-9a · 답 `{area?, labor}` · 실무문서는 일위대가만)."""
|
||||
다른 층(원본 읽기 권한) ·
|
||||
`pair` = 일위대가 · 면적산출 함께(43-9a · 답 `{area?, labor}`)."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
part = "structure" if kind == "structures" else kind
|
||||
if from_layer == "practice":
|
||||
if part not in (("labor", "area") if pair else ("labor",)) or not access.is_system_admin(
|
||||
session
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만")
|
||||
got = _call(folder, _copy_practice, column, source)
|
||||
return {"labor": got} if pair else got
|
||||
origin = _root(session, from_layer, from_owner) if from_layer else None
|
||||
if pair and part != "structure":
|
||||
return _call(folder, store.copy_pair, column, source, origin)
|
||||
|
||||
@@ -449,7 +449,7 @@ def copy_from(
|
||||
"""다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은
|
||||
따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂 ·
|
||||
`source_root` = 원본 층 폴더(없으면 지금 층 · 권한은 부르는 쪽) · `body` = 원본 문서를 바로 줌
|
||||
(실무문서 조합 → 일위대가 · 39-1b)."""
|
||||
(예: 문서를 만들어 바로 담음)."""
|
||||
if kind not in _KEY:
|
||||
raise StoreError(404, f"없는 종류 「{kind}」")
|
||||
target = read(kind, column)["문서"]
|
||||
@@ -472,18 +472,17 @@ def copy_pair(
|
||||
column: str,
|
||||
source: str,
|
||||
source_root: Path | None = None,
|
||||
labor_body: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""일위대가 ↔ 면적산출 함께 가져오기(43-9a) — 같은 원본 열의 면적산출(시트 · 알약 `연결`)과
|
||||
일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 ·
|
||||
원본에 면적산출이 없으면(실무문서 `labor_body` 포함) 일위대가만 · 면적 먼저(일위대가 계산값이
|
||||
원본에 면적산출이 없으면 일위대가만 · 면적 먼저(일위대가 계산값이
|
||||
새 면적 칸 값을 읽음 · 면적 계산이 막히면 일위대가도 안 씀)."""
|
||||
with at(source_root or root()):
|
||||
has_area = labor_body is None and _path("area", source).is_file()
|
||||
has_area = _path("area", source).is_file()
|
||||
out: dict[str, Any] = {}
|
||||
if has_area:
|
||||
out["area"] = copy_from("area", column, source, source_root)
|
||||
out["labor"] = copy_from("labor", column, source, source_root, labor_body)
|
||||
out["labor"] = copy_from("labor", column, source, source_root)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
|
||||
/** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 ·
|
||||
* 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「가져오기」가 씀 ·
|
||||
* 일위대가 · 면적산출은 함께(43-9b · 알약 연결이 이어져 있음 · 실무문서면 서버가 일위대가만) · 도면은 그 칸만 */
|
||||
* 일위대가 · 면적산출은 함께(43-9b · 알약 연결이 이어져 있음) · 도면은 그 칸만 */
|
||||
const pickCopy = async (kind: CardKind, to: StructureColumn | undefined): Promise<void> => {
|
||||
const pair = kind !== "structure";
|
||||
const word = PART_WORD[kind];
|
||||
@@ -335,13 +335,11 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
const copy = (source: string, from: Parameters<typeof copyPartFrom>[3]) =>
|
||||
pair ? copyLaborAreaFrom(to.id, source, from) : copyPartFrom(kind, to.id, source, from);
|
||||
try {
|
||||
// 같은 곳 = 다른 열 · 다른 층 · 사람 = 원본 층 · 실무문서 = 조합 키 → 일위대가(39-1b)
|
||||
if ("combo" in picked) await copy(picked.combo.키, { layer: "practice", owner: null });
|
||||
else
|
||||
await copy(
|
||||
picked.row.열,
|
||||
same(picked.from) ? null : { layer: picked.from.layer, owner: picked.from.owner },
|
||||
);
|
||||
// 같은 곳 = 다른 열 · 다른 층 · 사람 = 원본 층
|
||||
await copy(
|
||||
picked.row.열,
|
||||
same(picked.from) ? null : { layer: picked.from.layer, owner: picked.from.owner },
|
||||
);
|
||||
showToast(TEXT.copied, "success");
|
||||
if (column === to.id) void load(shown);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
|
||||
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서.
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 구조물 옵션 정의는 M01 로 옮김(716-1).
|
||||
* 도면 양식 · 실무문서 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
|
||||
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
@@ -55,7 +54,7 @@ const STUB_DRAWING = {
|
||||
};
|
||||
|
||||
export interface SideOptions {
|
||||
/** 시스템 관리자 — 도면 양식(시스템 층에만 있음) · 실무문서 칸을 늘 보임(720-4) */
|
||||
/** 시스템 관리자 — 도면 양식(시스템 층에만 있음)을 늘 보임(720-4) */
|
||||
isSystem: boolean;
|
||||
/** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) — 일위대가 불러오기 장부 */
|
||||
canWrite: () => boolean;
|
||||
@@ -163,20 +162,12 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
document.addEventListener(HOPYO_BUILT, onBuilt);
|
||||
// 도면 양식 칸 = 누구나 늘(읽기 · 고르기) · 새로 · 본떠 · 지우기 단추만 시스템 관리자
|
||||
const drawing = section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"));
|
||||
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 시스템 관리자만
|
||||
const practice = opt.isSystem
|
||||
? buildPracticeSection({
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: paint,
|
||||
})
|
||||
: null;
|
||||
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
|
||||
const hopyo = buildHopyoSection({
|
||||
canWrite: opt.canWrite,
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (practice?.clear(), paint()),
|
||||
onOpen: paint,
|
||||
});
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
@@ -187,7 +178,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
createDivider(),
|
||||
section(hopyo.title, hopyo.body),
|
||||
drawing,
|
||||
...(practice ? [section(practice.title, practice.body)] : []),
|
||||
],
|
||||
});
|
||||
attachCollapsible(listHost);
|
||||
@@ -236,15 +226,13 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
|
||||
}
|
||||
|
||||
/** 고름 표시는 여기서만 — 목록 · 실무문서 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 실무문서 고름은 지움) */
|
||||
/** 고름 표시는 여기서만 */
|
||||
function paint(): void {
|
||||
paintList();
|
||||
sangeun.paint();
|
||||
precombo.paint();
|
||||
hopyo.paint();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
}
|
||||
|
||||
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
|
||||
|
||||
@@ -10,40 +10,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */
|
||||
.m02-practice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-practice__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-practice__bar > button:last-child:not(:first-child) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m02-practice__memo {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.m02-practice__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-practice__row:hover {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.m02-side__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user