Files
Aislo/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts
T
eomsangdonandClaude Opus 5.5 070791854c feat(B01): 프로젝트 상세 화면 · 카드 요약 연결 · 지킴 시험 (PLAN 548-2)
- 상세 화면(view=project) — 머리 · [⋯] · [작업 열기 — 지금 단계 ›] · 단계 진행(누름 = 그 단계) ·
  노선 지도(배경 타일 + 선 · 시점 · 종점) · 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경(있는 칸만)
- 목록 · 상세 숫자 · 썸네일 = 547-1 카드 요약(project_summaries) 한 벌
- 색 = 셸 변수(--b01-*) 이름에 맞춤
- 시험 test_548_project_overview — 칸반 나눔 · 산출물 · 내 것 · 단계 수 짝

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:08:18 +09:00

178 lines
6.2 KiB
TypeScript

/* =============================================================================
* B01_Dashboard_UI_ProjectList_Views.ts
* 프로젝트 목록 보기 셋(PLAN 548-1) — 카드 · 표(줄 끝 ⋯) · 칸반(칸 = 서버 `board` · 완료 칸 접힘).
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { el } from "@ui/ui_template_elements_base";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import type { OverviewProject } from "./B01_Dashboard_Api_Projects";
import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper";
import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals";
import {
ago,
BOARD_COLUMNS,
boardLabel,
kindBadge,
progressBar,
stageDots,
stageText,
thumbSvg,
} from "./B01_Dashboard_UI_ProjectList_Parts";
type Open = (project: OverviewProject) => void;
/** 누름 = 상세 · Enter 도 같음 */
function clickable<T extends HTMLElement>(node: T, onOpen: () => void): T {
node.tabIndex = 0;
node.addEventListener("click", onOpen);
node.addEventListener("keydown", (event) => {
if (event.key === "Enter") onOpen();
});
return node;
}
function stalledMark(project: OverviewProject): HTMLElement[] {
return project.stalled
? [
el("span", {
className: "b01-proj-stalled",
text: `${project.idle_days} 일 그대로`,
attrs: { title: "14 일 넘게 고친 것이 없음" },
}),
]
: [];
}
function card(project: OverviewProject, onOpen: Open, compact = false): HTMLElement {
const box = el("div", {
className: `b01-proj-card b01-proj-card--${(project.project_kind ?? "ROAD").toLowerCase()}`,
attrs: { "data-project": project.id },
});
const info = el("div", {
className: "b01-proj-card__body",
children: [
el("b", { className: "b01-proj-card__name", text: project.name }),
el("div", {
className: "b01-proj-card__line",
children: [
kindBadge(project.project_kind),
el("span", { className: "b01-proj-mini", text: stageText(project) }),
],
}),
...(compact ? [progressBar(project)] : [stageDots(project)]),
el("div", {
className: "b01-proj-card__meta",
children: [
el("span", { text: project.manager_name ?? "-" }),
...stalledMark(project),
el("span", { text: ago(project) }),
],
}),
],
});
box.append(thumbSvg(project.thumb), info);
return clickable(box, () => onOpen(project));
}
export function cardGrid(projects: OverviewProject[], onOpen: Open): HTMLElement {
return el("div", {
className: "b01-proj-cards",
children: projects.map((p) => card(p, onOpen)),
});
}
/** 줄 끝 ⋯ — 지금 있는 동작만(수정 · 삭제) */
export function rowMenuItems(project: OverviewProject, me: DashboardUser): MapContextMenuItem[] {
const items: MapContextMenuItem[] = [];
if (canEditProject(me, project)) {
items.push(["수정", () => void openEditProjectModal(me, project)]);
}
if (canDeleteProject(me)) {
items.push("-", ["삭제", () => openDeleteProjectModal(me, project)]);
}
return items;
}
export function projectRows(
projects: OverviewProject[],
me: DashboardUser,
onOpen: Open,
): HTMLElement {
const wrap = el("div", { className: "b01-proj-table" });
const menu = createMapContextMenu("b01-proj-menu");
wrap.append(menu.element);
const head = ["이름", "종류", "지역", "진행", "상태", "담당", "수정일", ""];
const table = el("table");
const thead = el("tr", { children: head.map((h) => el("th", { text: h })) });
table.append(thead);
for (const project of projects) {
const more = el("button", {
className: "b01-proj-more",
text: "⋯",
attrs: { type: "button", "aria-label": "더 보기" },
});
const items = rowMenuItems(project, me);
if (!items.length) more.hidden = true;
more.addEventListener("click", (event) => {
event.stopPropagation();
const box = wrap.getBoundingClientRect();
const at = more.getBoundingClientRect();
menu.open(at.right - box.left - 120, at.bottom - box.top, items);
});
const row = el("tr", {
className: "b01-proj-row",
attrs: { "data-project": project.id },
children: [
el("td", { children: [el("b", { text: project.name }), ...stalledMark(project)] }),
el("td", { children: [kindBadge(project.project_kind)] }),
el("td", { text: project.region ?? "-" }),
el("td", {
className: "b01-proj-prog",
children: [progressBar(project), el("span", { text: stageText(project) })],
}),
el("td", { text: boardLabel(project.board) }),
el("td", { text: project.manager_name ?? "-" }),
el("td", { text: (project.updated_at ?? "").slice(0, 10) || "-" }),
el("td", { children: [more] }),
],
});
table.append(clickable(row, () => onOpen(project)));
}
wrap.append(table);
return wrap;
}
/** 칸반 — 칸 차례 고정 · 완료 칸은 접혀 시작(머리 누름 = 펼침) */
export function kanbanBoard(projects: OverviewProject[], onOpen: Open): HTMLElement {
const board = el("div", { className: "b01-proj-kanban" });
for (const column of BOARD_COLUMNS) {
const items = projects.filter((p) => p.board === column.key);
const col = el("section", {
className: "b01-proj-col",
attrs: { "data-board": column.key },
});
const title = el("h4", {
children: [
el("span", { text: column.label }),
el("span", { className: "b01-proj-mini", text: String(items.length) }),
],
});
const list = el("div", {
className: "b01-proj-col__list",
children: items.map((p) => card(p, onOpen, true)),
});
if (column.key === "done") {
col.classList.add("is-folded");
title.append(el("span", { className: "b01-proj-fold", text: "펼치기" }));
title.addEventListener("click", () => {
const folded = col.classList.toggle("is-folded");
title.lastElementChild!.textContent = folded ? "펼치기" : "접기";
});
}
col.append(title, list);
board.append(col);
}
return board;
}