- 공용 `common_util_time.ts`(날짜 · 시각 · 오늘/어제/n 일 전 · Z 없는 옛 글 = UTC) · `common_util_time.py`(UTC Z 글 · 한국 시각) - 화면 바꿈: 대시보드(프로젝트 · 휴지통 · 시스템 로그 · 보관함 · 홈 카드) · B09 굳힘 · E03 · E05 · E07 · M02 호표 이력 · 목록 고친 날 · 지난 판 - 서버: 응답 datetime = UTC + Z(main.py) · 로컬 시각 저장 → UTC · 대시보드 경과일 = UTC 기준 · 엑셀 날짜 · 파일 이름 = 한국 시각 - 시험: test_593_time(서울 · 뉴욕 · Z 없는 옛 글 · 서버 글) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
221 lines
8.1 KiB
TypeScript
221 lines
8.1 KiB
TypeScript
/* =============================================================================
|
||
* B01_Dashboard_UI_Home_Cards.ts
|
||
* 홈 카드 부품(PLAN 547-2) — 썸네일 선 · 단계 점 · 큰 카드(1) · 중간 카드(2~3) · 한 줄(4~8)
|
||
* [이어서 하기] = 그 단계 화면 · [자세히] · 줄 누름 = go("project", { id })
|
||
* ========================================================================== */
|
||
|
||
import type { RoutePath } from "@config/config_frontend";
|
||
import { createButton, el } from "@ui/ui_template_elements";
|
||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||
import { groupFlowOf } from "../A00_Common/group_workflow";
|
||
import type { ProjectSummary } from "./B01_Dashboard_Api_Home";
|
||
import { calendarDaysAgo } from "../common_util/common_util_time";
|
||
import { L } from "./B01_Dashboard_UI_Common";
|
||
import { isStale } from "./logic/B01_Dashboard_Home_Layout";
|
||
|
||
export type Go = (view: string, params?: Record<string, string>) => void;
|
||
|
||
const SVG = "http://www.w3.org/2000/svg";
|
||
const KIND_LABEL = {
|
||
ROAD: "DE_New_Road",
|
||
PLAN: "DE_New_Plan",
|
||
STABILITY: "DE_New_Stability",
|
||
EROSION: "DE_New_Erosion",
|
||
} as const;
|
||
|
||
export const kindLabel = (kind: string): string =>
|
||
L(KIND_LABEL[kind as keyof typeof KIND_LABEL] ?? "DE_New_Road");
|
||
|
||
export const kindClass = (kind: string): string =>
|
||
`b01-home__kind--${(kind in KIND_LABEL ? kind : "ROAD").toLowerCase()}`;
|
||
|
||
/** 노선 중심선 썸네일 — 선이 없으면 빈 바탕 */
|
||
export function thumbSvg(card: ProjectSummary, className: string): SVGSVGElement {
|
||
const svg = document.createElementNS(SVG, "svg");
|
||
svg.setAttribute("class", `b01-home__thumb ${kindClass(card.kind)} ${className}`);
|
||
svg.setAttribute("viewBox", "-0.08 -0.08 1.16 1.16");
|
||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||
svg.setAttribute("aria-hidden", "true");
|
||
if (card.thumb) {
|
||
const line = document.createElementNS(SVG, "polyline");
|
||
line.setAttribute("points", card.thumb.map(([x, y]) => `${x},${y}`).join(" "));
|
||
line.setAttribute("vector-effect", "non-scaling-stroke");
|
||
svg.append(line);
|
||
}
|
||
return svg;
|
||
}
|
||
|
||
/** 단계 점 — 지난 단계 · 지금 단계 · 남은 단계 */
|
||
export function stagePips(card: ProjectSummary): HTMLElement {
|
||
const pips = el("div", { className: `b01-home__pips ${kindClass(card.kind)}` });
|
||
for (let i = 0; i < card.stage_total; i += 1) {
|
||
const state = i < card.stage_index ? "ok" : i === card.stage_index ? "cur" : "";
|
||
pips.append(el("i", { className: state }));
|
||
}
|
||
return pips;
|
||
}
|
||
|
||
export const stageText = (card: ProjectSummary): string =>
|
||
`${card.stage_index + 1} / ${card.stage_total} · ${card.stage_name}`;
|
||
|
||
/** 「n 일째 그대로」 · 오늘 · n 일 전 */
|
||
export function whenText(card: ProjectSummary): string {
|
||
const days = calendarDaysAgo(card.updated_at) ?? card.stale_days ?? null;
|
||
if (days === null) return "";
|
||
if (isStale(card)) return `${days} 일째 그대로`;
|
||
return days <= 0 ? "오늘" : `${days} 일 전`;
|
||
}
|
||
|
||
export function continueStage(card: ProjectSummary): void {
|
||
const routes: readonly RoutePath[] = groupFlowOf(card.kind)?.routes ?? WORKFLOW_STEP_ROUTES;
|
||
void goToWorkflowStage(card.id, routes[Math.min(card.stage_index, routes.length - 1)]);
|
||
}
|
||
|
||
function kindBadge(card: ProjectSummary): HTMLElement {
|
||
return el("span", {
|
||
className: `b01-home__badge ${kindClass(card.kind)}`,
|
||
text: kindLabel(card.kind),
|
||
});
|
||
}
|
||
|
||
function title(card: ProjectSummary): HTMLElement {
|
||
return el("div", {
|
||
className: "b01-home__title",
|
||
children: [el("b", { text: card.name }), kindBadge(card)],
|
||
});
|
||
}
|
||
|
||
function nextBox(card: ProjectSummary): HTMLElement {
|
||
return el("div", { className: "b01-home__next", text: `다음: ${card.stage_name}` });
|
||
}
|
||
|
||
function whenLine(card: ProjectSummary): HTMLElement {
|
||
const line = el("div", { className: "b01-home__muted" });
|
||
const owner = card.owner_names[0];
|
||
if (owner) line.append(`${owner} · `);
|
||
line.append(
|
||
el("span", { className: isStale(card) ? "b01-home__stale" : "", text: whenText(card) }),
|
||
);
|
||
return line;
|
||
}
|
||
|
||
const METRIC_ROWS: [keyof ProjectSummary["metrics"], string, (v: number) => string][] = [
|
||
["length_m", "연장", (v) => `${v.toLocaleString()} m`],
|
||
["stations", "측점", (v) => `${v}`],
|
||
["structures", "구조물", (v) => `${v}`],
|
||
];
|
||
|
||
/** 핵심 숫자 — 있는 값만 */
|
||
function metricStrip(card: ProjectSummary): HTMLElement | null {
|
||
const cells = METRIC_ROWS.filter(([key]) => card.metrics[key] !== undefined).map(
|
||
([key, label, fmt]) =>
|
||
el("div", {
|
||
children: [
|
||
el("b", { text: fmt(card.metrics[key] as number) }),
|
||
el("span", { text: label }),
|
||
],
|
||
}),
|
||
);
|
||
return cells.length ? el("div", { className: "b01-home__metrics", children: cells }) : null;
|
||
}
|
||
|
||
const continueButton = (card: ProjectSummary, label = "이어서 하기 ›"): HTMLButtonElement =>
|
||
createButton({ label, size: "sm", onClick: () => continueStage(card) });
|
||
|
||
const detailButton = (card: ProjectSummary, go: Go): HTMLButtonElement =>
|
||
createButton({
|
||
label: "자세히",
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => go("project", { id: card.id }),
|
||
});
|
||
|
||
/** 진행 중 1 — 큰 카드(썸네일 · 단계 점 · 다음 할 일 · 핵심 숫자) */
|
||
export function bigCard(card: ProjectSummary, go: Go): HTMLElement {
|
||
const info = el("div", {
|
||
className: "b01-home__big-info",
|
||
children: [
|
||
el("div", { className: "b01-home__muted", text: "지금 하는 프로젝트" }),
|
||
title(card),
|
||
el("b", { className: "b01-home__stage", text: stageText(card) }),
|
||
stagePips(card),
|
||
nextBox(card),
|
||
whenLine(card),
|
||
el("div", {
|
||
className: "b01-home__actions",
|
||
children: [continueButton(card), detailButton(card, go)],
|
||
}),
|
||
],
|
||
});
|
||
const top = el("div", {
|
||
className: "b01-home__big-top",
|
||
children: [thumbSvg(card, "b01-home__thumb--big") as unknown as HTMLElement, info],
|
||
});
|
||
const metrics = metricStrip(card);
|
||
return el("div", {
|
||
className: "b01-home__pane b01-home__big",
|
||
children: metrics ? [top, metrics] : [top],
|
||
});
|
||
}
|
||
|
||
/** 진행 중 2~3 — 중간 카드(맨 앞 = 최근 작업) */
|
||
export function midCard(card: ProjectSummary, go: Go, first: boolean): HTMLElement {
|
||
const node = el("div", {
|
||
className: `b01-home__mid${first ? " b01-home__mid--first" : ""}`,
|
||
children: [
|
||
thumbSvg(card, "b01-home__thumb--mid") as unknown as HTMLElement,
|
||
el("div", {
|
||
className: "b01-home__mid-body",
|
||
children: [
|
||
whenLine(card),
|
||
title(card),
|
||
el("div", { className: "b01-home__muted", text: stageText(card) }),
|
||
stagePips(card),
|
||
nextBox(card),
|
||
el("div", {
|
||
className: "b01-home__actions",
|
||
children: [
|
||
continueButton(card, first ? "이어서 하기 ›" : "이어서 ›"),
|
||
detailButton(card, go),
|
||
],
|
||
}),
|
||
],
|
||
}),
|
||
],
|
||
});
|
||
return node;
|
||
}
|
||
|
||
/** 진행 중 4~8 — 한 줄(누름 = 상세 · 14 일 넘게 그대로면 노랑) */
|
||
export function rowCard(card: ProjectSummary, go: Go): HTMLElement {
|
||
const open = createButton({ label: "열기 ›", variant: "ghost", size: "sm" });
|
||
open.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
continueStage(card);
|
||
});
|
||
const row = el("div", {
|
||
className: `b01-home__row${isStale(card) ? " b01-home__row--stale" : ""}`,
|
||
attrs: { role: "button", tabindex: "0" },
|
||
children: [
|
||
thumbSvg(card, "b01-home__thumb--row") as unknown as HTMLElement,
|
||
el("div", { className: "b01-home__row-name", children: [title(card), whenLine(card)] }),
|
||
el("div", {
|
||
className: "b01-home__row-stage",
|
||
children: [
|
||
stagePips(card),
|
||
el("div", {
|
||
className: "b01-home__muted",
|
||
text: `${card.stage_name} ${card.stage_index + 1}/${card.stage_total}`,
|
||
}),
|
||
],
|
||
}),
|
||
open,
|
||
],
|
||
});
|
||
row.addEventListener("click", () => go("project", { id: card.id }));
|
||
row.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") go("project", { id: card.id });
|
||
});
|
||
return row;
|
||
}
|