Files
Aislo/B01_Dashboard/B01_Dashboard_UI_ProjectList.ts
T
eomsangdonandClaude Opus 5.5 c6d93ee880 feat(B01): 프로젝트 목록 · 상세 API 와 목록 화면 초안 (PLAN 548)
- 새 API /api/dashboard/overview/projects(내 것 · 회사 전체 · 칸반 칸 board · 오래 멈춤)
  · /projects/{id}(노선 위경도 · 핵심 숫자 · 연결 · 산출물 · 최근 변경 — 있는 것만)
- 칸반 칸 = 단계 상태로 저절로(시작 전 · 설계 중 · 검토(STALE · FAILED) · 확정 대기 · 완료)
- 목록 화면(카드 · 표 줄 끝 ⋯ · 칸반 10 개부터) · 공용 조각 · 화면 CSS — 등록부 연결은 셸 뒤

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

232 lines
7.7 KiB
TypeScript

/* =============================================================================
* 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<string, string>) => 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<T extends string>(
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<void> {
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;
};
}