- 새 API /api/dashboard/overview/projects(내 것 · 회사 전체 · 칸반 칸 board · 오래 멈춤)
· /projects/{id}(노선 위경도 · 핵심 숫자 · 연결 · 산출물 · 최근 변경 — 있는 것만)
- 칸반 칸 = 단계 상태로 저절로(시작 전 · 설계 중 · 검토(STALE · FAILED) · 확정 대기 · 완료)
- 목록 화면(카드 · 표 줄 끝 ⋯ · 칸반 10 개부터) · 공용 조각 · 화면 CSS — 등록부 연결은 셸 뒤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
7.7 KiB
TypeScript
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;
|
|
};
|
|
}
|