feat(M02): 좌측 구조물 조합 안 하위 컨테이너 셋 · 「전체 항목」 길 없앰(45-2)
- 좌측 구조물 조합 = 층 단추 · 나무만 · 구조물 도면 · 일위대가 · 면적산출 하위와 수평선 지움 - 「전체 항목」 목록 화면 길(onAll · buildAllItems) 지움 · 상세는 카드 [수정]으로 - 쓰는 곳 없는 structureLabel · 로케일 키 다섯 · 하위 CSS 지움 - 낡은 글 찾기 시험을 지금 코드에 맞춤 · 화면 시나리오 ui_checks/45-2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemList.ts
|
||||
* 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나
|
||||
* 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
|
||||
* 구조물 항목 목록 하나(PLAN 39-5) — 복사 생성 · 항목 하나(도면 · 일위대가 · 면적산출) 복사가 같이 씀.
|
||||
* 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
|
||||
* (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음).
|
||||
* 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다.
|
||||
* 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`).
|
||||
@@ -142,22 +142,6 @@ export function pageOf<R>(
|
||||
export const loadItems = async (layer: Layer, owner: string | null): Promise<ItemRow[]> =>
|
||||
(await fetchTree(layer, owner)).items as ItemRow[];
|
||||
|
||||
/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */
|
||||
export function buildAllItems(
|
||||
kind: ListKind,
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
onOpen: (row: ItemRow) => void,
|
||||
): HTMLElement {
|
||||
const list = buildItemList();
|
||||
loadItems(layer, owner).then(
|
||||
(rows) => list.show(rows, ITEM_COLS[kind], onOpen),
|
||||
(error: unknown) =>
|
||||
list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
return list.root;
|
||||
}
|
||||
|
||||
export interface ItemListHandle {
|
||||
root: HTMLElement;
|
||||
/** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */
|
||||
|
||||
@@ -143,10 +143,6 @@ export function headRenameEdit(col: StructureColumn, level: number, name: string
|
||||
export const columnHead = (col: StructureColumn): string =>
|
||||
col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id;
|
||||
|
||||
/** 번호가 맨 앞 — 「SD010101 돌기슭막이 H=1.0」(번호가 없으면 이름만) */
|
||||
export const structureLabel = (number: string, col: StructureColumn): string =>
|
||||
[number, columnHead(col)].filter(Boolean).join(" ");
|
||||
|
||||
/** 면적산출 편집 화면 아래 알약 막대 — 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」
|
||||
* (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽).
|
||||
* 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */
|
||||
|
||||
@@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { navigateTo, type MountedPage } from "../A00_Common/router";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
accessFor,
|
||||
buildReadOnlyBar,
|
||||
@@ -40,8 +40,7 @@ import {
|
||||
seesOthers,
|
||||
} from "./M02_MasterTemplete_TreePanel_Roots";
|
||||
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side";
|
||||
import { buildAllItems } from "./M02_MasterTemplete_ItemList";
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
@@ -49,13 +48,6 @@ const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴
|
||||
const NEW_ASK =
|
||||
"{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름";
|
||||
|
||||
/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */
|
||||
const ALL_TITLE = {
|
||||
structure: "M02_KindStructure",
|
||||
labor: "M02_KindLabor",
|
||||
area: "M02_KindArea",
|
||||
} as const satisfies Record<AllKind, string>;
|
||||
|
||||
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
const entry = entryFromHash();
|
||||
@@ -97,9 +89,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
canEdit: canEdit(layer, isAdmin),
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: () => main.settle(),
|
||||
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호
|
||||
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표
|
||||
onChanged: async (colId) => {
|
||||
await Promise.all([main.openTable(layer, colId, true), side.refresh()]);
|
||||
await main.openTable(layer, colId, true);
|
||||
},
|
||||
});
|
||||
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
|
||||
@@ -110,7 +102,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
|
||||
);
|
||||
};
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌(하위 도면 · 일위대가 · 면적산출도 이 층)
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌
|
||||
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
|
||||
@@ -193,20 +185,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
// 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`)
|
||||
onAll: (kind) =>
|
||||
void main.showView(
|
||||
`${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`,
|
||||
buildAllItems(kind, layer, owner, (row) => {
|
||||
void main.open({
|
||||
layer,
|
||||
projectId: null,
|
||||
kind: kind as Kind,
|
||||
name: row.열,
|
||||
label: row.이름,
|
||||
});
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 ·
|
||||
* 면적산출 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
|
||||
* 서버가 미리 만들어 둠(도번 SD · 일위대가번 SU · 면적번 SA + 같은 뒷자리) ·
|
||||
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」).
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
|
||||
* ========================================================================== */
|
||||
@@ -22,23 +19,15 @@ import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
deleteTemplate,
|
||||
fetchStructureNumbers,
|
||||
listTemplates,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type StructureNumbers,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
STRUCTURE_TABLE,
|
||||
columnHead,
|
||||
isStructureColumn,
|
||||
structureLabel,
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
@@ -47,21 +36,6 @@ const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
{ kind: "drawing", label: () => L("M02_KindDrawing") },
|
||||
];
|
||||
/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 ·
|
||||
* `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */
|
||||
type ColumnKind = "structure" | "labor" | "area";
|
||||
const COLUMN_KINDS: {
|
||||
kind: ColumnKind;
|
||||
key: keyof StructureNumbers;
|
||||
label: () => string;
|
||||
all: boolean;
|
||||
}[] = [
|
||||
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true },
|
||||
{ kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true },
|
||||
{ kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true },
|
||||
];
|
||||
/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */
|
||||
export type AllKind = "structure" | "labor" | "area";
|
||||
/** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */
|
||||
const STUB_DRAWING = {
|
||||
format: 6,
|
||||
@@ -84,8 +58,6 @@ export interface SideOptions {
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */
|
||||
onAll?: (kind: AllKind) => void;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -111,10 +83,6 @@ const failed = (error: unknown): void =>
|
||||
export function buildSide(opt: SideOptions): SideHandle {
|
||||
const layer = opt.layer;
|
||||
let items: TemplateInfo[] = [];
|
||||
let columns: StructureColumn[] = [];
|
||||
let numbers: Record<string, StructureNumbers> = {};
|
||||
/** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */
|
||||
let allPicked: AllKind | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement =>
|
||||
@@ -125,9 +93,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const bodies = new Map<Kind, HTMLElement>(
|
||||
KINDS.map((k) => [k.kind, el("div", { className: "m02-side__items ui-sidebar-list" })]),
|
||||
);
|
||||
const columnBodies = COLUMN_KINDS.map(() =>
|
||||
el("div", { className: "m02-side__items ui-sidebar-list" }),
|
||||
);
|
||||
/** 새로 · 본떠 만들기 · 지우기 — 도면 양식 컨테이너 안(구조물 집계 양식은 집계표 하나라 없음) · 관리자만 */
|
||||
const editButtons = new Map<Kind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
|
||||
const actionRow = (kind: Kind): HTMLElement[] => {
|
||||
@@ -146,7 +111,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
isAdmin: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (practice?.clear(), (allPicked = null), paint()),
|
||||
onOpen: () => (practice?.clear(), paint()),
|
||||
});
|
||||
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
|
||||
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
|
||||
@@ -155,17 +120,13 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
});
|
||||
return sec;
|
||||
};
|
||||
const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i]));
|
||||
// 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) ·
|
||||
// 구조물 집계 양식 — 층 단추 · 나무 · 처음부터 펼침(집계표가 열려 있음) ·
|
||||
// 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6)
|
||||
const summary = section(
|
||||
L("M02_KindTable"),
|
||||
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
|
||||
el("hr", { className: "m02-side__rule" }),
|
||||
...columnSections,
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
summary.classList.add("m02-side__summary");
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
@@ -183,7 +144,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
? buildPracticeSection({
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (options.clear(), (allPicked = null), paint()),
|
||||
onOpen: () => (options.clear(), paint()),
|
||||
})
|
||||
: null;
|
||||
const listHost = el("div", {
|
||||
@@ -234,94 +195,27 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */
|
||||
function allRow(kind: AllKind): HTMLElement {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`,
|
||||
text: L("M02_ItemsAll"),
|
||||
attrs: { type: "button", "data-all": kind },
|
||||
});
|
||||
b.addEventListener("click", () => void selectAll(kind));
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */
|
||||
function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void {
|
||||
const { kind, key } = k;
|
||||
const head = k.all ? [allRow(kind as AllKind)] : [];
|
||||
body.replaceChildren(
|
||||
...head,
|
||||
...(columns.length
|
||||
? columns.map((col) => {
|
||||
const num = numbers[col.id]?.[key] || L("M02_StructureNone");
|
||||
const b = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${isPicked(kind, col.id) ? " is-active" : ""}`,
|
||||
text: structureLabel(num, col),
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
b.addEventListener("click", () => void selectColumn(col, kind));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
);
|
||||
}
|
||||
const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k));
|
||||
|
||||
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
|
||||
function paintButtons(): void {
|
||||
const cur = currentInfo();
|
||||
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
|
||||
}
|
||||
|
||||
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
/** 고름 표시는 여기서만 — 목록 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
|
||||
function paint(): void {
|
||||
if (opt.getOpen()) allPicked = null;
|
||||
paintList();
|
||||
paintColumns();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
}
|
||||
|
||||
async function selectAll(kind: AllKind): Promise<void> {
|
||||
if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;
|
||||
options.clear();
|
||||
practice?.clear();
|
||||
allPicked = kind;
|
||||
opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림
|
||||
paint();
|
||||
}
|
||||
|
||||
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
|
||||
async function select(info: TemplateInfo): Promise<void> {
|
||||
if (!isOpen(info) && !(await opt.confirmLeave())) return;
|
||||
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
|
||||
}
|
||||
|
||||
async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise<void> {
|
||||
if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return;
|
||||
// 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」)
|
||||
opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) });
|
||||
}
|
||||
|
||||
async function refreshStructure(): Promise<void> {
|
||||
try {
|
||||
const [table, nums] = await Promise.all([
|
||||
readTemplate(layer, "table", STRUCTURE_TABLE, null),
|
||||
fetchStructureNumbers(),
|
||||
]);
|
||||
const all = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[];
|
||||
columns = all.filter(isStructureColumn);
|
||||
numbers = nums;
|
||||
} catch (error) {
|
||||
columns = [];
|
||||
numbers = {};
|
||||
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
paintColumns();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
paintButtons();
|
||||
@@ -334,7 +228,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await refreshStructure();
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
|
||||
@@ -10,18 +10,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 구조물 집계 양식 하위 컨테이너(구조물 도면 · 면적산출 · 재료산출 · 자재 조합) — 들여 씀 */
|
||||
.m02-side__summary > .ui-sidebar-card {
|
||||
margin-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */
|
||||
.m02-side__rule {
|
||||
margin: var(--spacing-8) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */
|
||||
.m02-practice {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user