/* ============================================================================= * B01_Dashboard_UI_ProjectList.ts * 대시보드 「프로젝트」 세부 페이지(요구 369~374 · PLAN 548-1) — 툴바(찾기 · 종류 칩 · 담당 · 보기 · * 카드/표/칸반 · [+ 새 프로젝트]) + 고른 보기. 고른 보기는 계정 취향(`aislo:pref:`)으로 기억. * 칸반은 9 개 넘을 때만 · 칸은 서버가 단계 상태로 나눔(끌기 없음). * ========================================================================== */ import "./B01_Dashboard_UI_Style_Projects.css"; import { createButton, showToast } from "@ui/ui_template_elements"; import { el } from "@ui/ui_template_elements_base"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; import { fetchOverviewProjects, type OverviewProject } from "./B01_Dashboard_Api_Projects"; import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject"; import { KINDS } from "./B01_Dashboard_UI_ProjectList_Parts"; import { cardGrid, kanbanBoard, projectRows } from "./B01_Dashboard_UI_ProjectList_Views"; /** 등록부 ctx(546) 가운데 이 페이지가 쓰는 것 */ export interface ProjectViewCtx { me: DashboardUser; companyId: number | null; go: (view: string, params?: Record) => void; } type ViewMode = "card" | "table" | "kanban"; type Scope = "mine" | "company"; /** 칸반이 열리는 수 — 넘으면 [칸반] 단추 */ export const KANBAN_MIN = 10; const PREF_VIEW = "aislo:pref:b01.projects.view"; const PREF_SCOPE = "aislo:pref:b01.projects.scope"; interface ListState { scope: Scope; view: ViewMode; kind: string; manager: string; query: string; projects: OverviewProject[]; } /** 거르기 — 종류 · 담당 · 찾기(이름 · 지역) */ export function filterProjects( projects: OverviewProject[], kind: string, manager: string, query: string, ): OverviewProject[] { const q = query.trim().toLowerCase(); return projects.filter( (p) => (!kind || (p.project_kind ?? "ROAD") === kind) && (!manager || p.manager_name === manager) && (!q || `${p.name} ${p.region ?? ""}`.toLowerCase().includes(q)), ); } function segment( items: { key: T; label: string }[], active: T, onPick: (key: T) => void, ): HTMLElement { const seg = el("span", { className: "b01-proj-seg", attrs: { role: "group" } }); for (const item of items) { const button = el("button", { className: item.key === active ? "is-on" : "", text: item.label, attrs: { type: "button", "data-key": item.key }, }); button.addEventListener("click", () => onPick(item.key)); seg.append(button); } return seg; } function chip(label: string, on: boolean, onClick?: () => void, soon = false): HTMLElement { const button = el("button", { className: `b01-proj-chip${on ? " is-on" : ""}${soon ? " is-soon" : ""}`, text: label, attrs: { type: "button" }, }); button.addEventListener("click", () => (soon ? showToast(label) : onClick?.())); return button; } /** 「프로젝트」 페이지 — 등록부 mount */ export function mountProjectList(host: HTMLElement, ctx: ProjectViewCtx): () => void { let alive = true; const state: ListState = { scope: readByKey(PREF_SCOPE) === "mine" ? "mine" : "company", view: (readByKey(PREF_VIEW) as ViewMode | null) ?? "card", kind: "", manager: "", query: "", projects: [], }; const root = el("div", { className: "b01-proj" }); const head = el("div", { className: "b01-proj-head" }); const tools = el("div", { className: "b01-proj-tools" }); const body = el("div", { className: "b01-proj-body" }); root.append(head, tools, body); host.replaceChildren(root); const search = el("input", { className: "b01-proj-search", attrs: { type: "search", placeholder: "이름 · 지역 찾기" }, }); search.addEventListener("input", () => { state.query = search.value; paintBody(); }); const open = (project: OverviewProject): void => ctx.go("project", { id: project.id }); function paintHead(): void { const kanbanOk = state.projects.length >= KANBAN_MIN; const views: { key: ViewMode; label: string }[] = [ { key: "card", label: "카드" }, { key: "table", label: "표" }, ...(kanbanOk ? [{ key: "kanban" as ViewMode, label: "칸반" }] : []), ]; head.replaceChildren( el("h2", { text: "프로젝트" }), el("span", { className: "b01-proj-count", text: String(state.projects.length) }), el("span", { className: "b01-proj-sp" }), segment(views, shownView(), (key) => { state.view = key; writeByKey(PREF_VIEW, key); paintHead(); paintBody(); }), createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }), ); } /** 기억한 보기가 칸반인데 9 개 이하 = 카드 */ const shownView = (): ViewMode => state.view === "kanban" && state.projects.length < KANBAN_MIN ? "card" : state.view; function paintTools(): void { const count = (kind: string): number => state.projects.filter((p) => (p.project_kind ?? "ROAD") === kind).length; const chips = [ chip(`전체 ${state.projects.length}`, !state.kind, () => pickKind("")), ...KINDS.map((k) => "soon" in k ? chip(`${k.label()} · 준비 중`, false, undefined, true) : chip(`${k.label()} ${count(k.key)}`, state.kind === k.key, () => pickKind(k.key)), ), ]; const managers = [...new Set(state.projects.map((p) => p.manager_name).filter(Boolean))]; const select = el("select", { className: "b01-proj-select", attrs: { "aria-label": "담당" } }); select.append(new Option("담당 : 전체", ""), ...managers.map((m) => new Option(m!, m!))); select.value = state.manager; select.addEventListener("change", () => { state.manager = select.value; paintBody(); }); const scope = segment( [ { key: "mine" as Scope, label: "내 것" }, { key: "company" as Scope, label: "회사 전체" }, ], state.scope, (key) => { state.scope = key; writeByKey(PREF_SCOPE, key); void load(); }, ); tools.replaceChildren( search, ...chips, el("span", { className: "b01-proj-sp" }), select, scope, ); } function pickKind(kind: string): void { state.kind = kind; paintTools(); paintBody(); } function paintBody(): void { const shown = filterProjects(state.projects, state.kind, state.manager, state.query); if (!shown.length) { body.replaceChildren( el("p", { className: "b01-proj-empty", text: state.projects.length ? "조건에 맞는 프로젝트가 없습니다." : "아직 프로젝트가 없습니다. [+ 새 프로젝트]로 시작합니다.", }), ); return; } const view = shownView(); body.replaceChildren( view === "table" ? projectRows(shown, ctx.me, open) : view === "kanban" ? kanbanBoard(shown, open) : cardGrid(shown, open), ); } async function load(): Promise { body.replaceChildren(el("p", { className: "b01-proj-empty", text: "불러오는 중…" })); try { const projects = await fetchOverviewProjects(state.scope, ctx.companyId); if (!alive) return; state.projects = projects; } catch (error) { if (!alive) return; state.projects = []; showToast(error instanceof Error ? error.message : String(error), "error"); } paintHead(); paintTools(); paintBody(); } paintHead(); paintTools(); void load(); return () => { alive = false; }; }