Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Home.ts
T

259 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B01_Dashboard_UI_Home.ts
* 대시보드 홈(PLAN 547-2 · 요구 369~374) — 진행 중 프로젝트 수에 따라 꼴이 저절로 바뀜
* 1 = 큰 카드 · 2~3 = 중간 카드 · 4~8 = 한 줄 목록 + 팀원별 · 단계 분포 · 9 넘음 = 요약 · 그래프
* 오른쪽 = 처리할 일 · 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음이면 세 걸음
* 꼴 고정(자동 · 작게 · 크게) = localStorage 기억
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { fetchHome, type HomeData, type ProjectSummary } from "./B01_Dashboard_Api_Home";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import { bigCard, midCard, rowCard, type Go } from "./B01_Dashboard_UI_Home_Cards";
import { manyView, memberPane, pane, stagePane } from "./B01_Dashboard_UI_Home_Many";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { homeLayout, type HomeFixed, type HomeLayout } from "./logic/B01_Dashboard_Home_Layout";
import "./B01_Dashboard_UI_Style_Home.css";
const FIXED_KEY = "aislo.b01.homeLayout";
const LAYOUT_TEXT: Record<HomeLayout, string> = {
empty: "처음 시작",
one: "진행 중 1 개 → 큰 카드",
few: "진행 중 2~3 개 → 중간 카드",
list: "진행 중 4~8 개 → 한 줄 목록",
many: "진행 중 9 개 넘음 → 요약 · 그래프",
};
function readFixed(): HomeFixed {
const saved = localStorage.getItem(FIXED_KEY);
return saved === "small" || saved === "large" ? saved : "auto";
}
function fixedSwitch(current: HomeFixed, onPick: (next: HomeFixed) => void): HTMLElement {
const seg = el("div", {
className: "b01-home__seg",
attrs: { role: "group", "aria-label": "꼴" },
});
for (const [value, label] of [
["auto", "자동"],
["small", "작게"],
["large", "크게"],
] as const) {
const b = el("button", {
className: value === current ? "on" : "",
text: label,
attrs: { type: "button", "aria-pressed": String(value === current) },
});
b.addEventListener("click", () => onPick(value));
seg.append(b);
}
return seg;
}
function todoRow(dot: string, text: string, action: string, onClick: () => void): HTMLElement {
return el("div", {
className: "b01-home__todo",
children: [
el("span", { className: `b01-home__dot b01-home__dot--${dot}` }),
el("span", { text }),
createButton({ label: action, variant: "ghost", size: "sm", onClick }),
],
});
}
/** 처리할 일 — 없으면 「처리할 일 없음」 */
function todoPane(data: HomeData, go: Go): HTMLElement {
const t = data.todos;
const rows: HTMLElement[] = [];
for (const p of t.new_plans)
rows.push(
todoRow("plan", `새 예정노선 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
);
for (const p of t.updated_plans)
rows.push(
todoRow("warn", `업데이트 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
);
if (t.join_requests)
rows.push(todoRow("alert", `가입 요청 ${t.join_requests} 건`, "처리", () => go("people")));
for (const b of t.expiring_temp)
rows.push(
todoRow("warn", `보관 파일 ${b.name} · ${b.days_left} 일 남음`, "열기", () => go("temp")),
);
if (!rows.length) rows.push(el("div", { className: "b01-home__muted", text: "처리할 일 없음" }));
return pane("처리할 일", ...rows);
}
function sidePanes(data: HomeData, go: Go): HTMLElement[] {
const panes = [todoPane(data, go)];
panes.push(
pane(
"임시 보관함",
el("div", { className: "b01-home__muted", text: `보관 묶음 ${data.todos.temp_count} 개` }),
createButton({ label: "보관함 ›", variant: "ghost", size: "sm", onClick: () => go("temp") }),
),
);
if (data.company) {
const c = data.company;
panes.push(
pane(
c.name ?? "회사",
el("div", {
className: "b01-home__muted",
text: `팀원 ${c.member_count ?? 0} 명 · 진행 중 ${data.projects.length} 개`,
}),
createButton({
label: "사람 ›",
variant: "ghost",
size: "sm",
onClick: () => go("people"),
}),
),
);
}
if (data.forest) {
const f = data.forest;
panes.push(
pane(
"예정노선",
el("div", {
className: "b01-home__muted",
text: `분석 전 ${f.plan_status.draft} · 분석됨 ${f.plan_status.analyzed} · 확정 ${f.plan_status.confirmed}`,
}),
el("div", {
className: f.assign_waiting ? "b01-home__stale" : "b01-home__muted",
text: `업체 지정 대기 ${f.assign_waiting}`,
}),
),
);
}
return panes;
}
/** 처음 — 세 걸음(내 정보 · 파일 · 첫 프로젝트) */
function firstSteps(ctx: ViewCtx, data: HomeData): HTMLElement {
const me = ctx.me;
const steps: [boolean, string, string, () => void][] = [
[
Boolean(me.position || me.department || me.phone),
"내 정보 채우기",
"내 정보 ›",
() => ctx.go("me"),
],
[data.todos.temp_count > 0, "측량 파일 올려 두기", "보관함 ›", () => ctx.go("temp")],
[false, "첫 프로젝트 만들기", "+ 새 프로젝트", () => void openNewProjectPicker()],
];
return pane(
"처음 오셨네요 — 세 걸음",
...steps.map(([done, text, action, onClick], i) =>
el("div", {
className: `b01-home__step${done ? " b01-home__step--done" : ""}`,
children: [
el("span", { className: "b01-home__step-no", text: done ? "✓" : String(i + 1) }),
el("span", { text }),
createButton({ label: action, variant: done ? "ghost" : "filled", size: "sm", onClick }),
],
}),
),
);
}
function mainArea(layout: HomeLayout, ctx: ViewCtx, data: HomeData): HTMLElement {
const cards: ProjectSummary[] = data.projects;
const go = ctx.go;
if (layout === "empty")
return data.project_total
? pane(
"진행 중인 프로젝트 없음",
el("div", { className: "b01-home__muted", text: "모두 끝났습니다." }),
)
: firstSteps(ctx, data);
if (layout === "one") return bigCard(cards[0], go);
if (layout === "few") {
const mids = el("div", {
className: "b01-home__mids",
children: cards.slice(0, 8).map((c, i) => midCard(c, go, i === 0)),
});
if (cards.length <= 8) return mids;
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
return el("div", { className: "b01-home__many", children: [mids, all] });
}
if (layout === "list")
return el("div", {
className: "b01-home__split b01-home__split--list",
children: [
el("div", {
children: [
el("div", {
className: "b01-home__muted",
text: `진행 중 ${cards.length} · 최근 작업 순`,
}),
el("div", { className: "b01-home__rows", children: cards.map((c) => rowCard(c, go)) }),
],
}),
el("div", { children: [memberPane(cards), stagePane(cards)] }),
],
});
return manyView(data, go);
}
export function mountHome(host: HTMLElement, ctx: ViewCtx): () => void {
let alive = true;
let data: HomeData | null = null;
const root = el("div", { className: "b01-home" });
host.append(root);
const draw = (): void => {
if (!data) return;
const fixed = readFixed();
const layout = homeLayout(data.projects.length, fixed);
root.dataset.layout = layout;
const head = el("div", {
className: "b01-home__head",
children: [
el("h2", { text: "홈" }),
el("span", {
className: "b01-home__muted",
text:
fixed === "auto" || layout === "empty"
? LAYOUT_TEXT[layout]
: `진행 중 ${data.projects.length} 개 · ${fixed === "small" ? "작게" : "크게"} 고정`,
}),
el("span", { className: "b01-home__push" }),
fixedSwitch(fixed, (next) => {
if (next === "auto") localStorage.removeItem(FIXED_KEY);
else localStorage.setItem(FIXED_KEY, next);
draw();
}),
createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }),
],
});
const body = el("div", {
className: "b01-home__body",
children: [
el("div", { className: "b01-home__main", children: [mainArea(layout, ctx, data)] }),
el("aside", { className: "b01-home__side", children: sidePanes(data, ctx.go) }),
],
});
root.replaceChildren(head, body);
};
root.append(el("div", { className: "b01-home__muted", text: "불러오는 중…" }));
fetchHome()
.then((loaded) => {
if (!alive) return;
data = loaded;
draw();
})
.catch((error: unknown) => {
if (!alive) return;
root.replaceChildren(
el("div", { className: "b01-home__muted", text: "홈을 불러오지 못했습니다." }),
);
showToast(error instanceof Error ? error.message : String(error), "error");
});
return () => {
alive = false;
};
}