Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts
T
eomsangdonandClaude Sonnet 5.5 59610fecf1 feat(시간 표시): 화면 시각 = 보는 사람 PC 시간대 · 서버 응답 UTC Z · 서버가 만드는 파일 글 = 한국 시각 (PLAN 593-1 · 요구 456)
- 공용 `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>
2026-10-30 10:03:35 +09:00

221 lines
8.1 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_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;
}