Drop page / section descriptions, state notes and repeated captions from the representative Stories of 44 components, the Patterns, Center Graphic and OrgChart pages. Value readouts, spec lists and captions hide only on the representative page; hidden verification Stories keep them. Tabs Default API and the Filter Bar draft / applied counts move to the hidden DefaultApi and DraftApplied Stories; Center Overview is hidden with its direct URL kept. Story IDs are unchanged. Align the component, pattern and OrgChart docs with the pages, point the quality reference check at the DraftApplied Story, and make the P8 styles check expect no section header on the preview-only Expanded page.
467 lines
20 KiB
TypeScript
467 lines
20 KiB
TypeScript
import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
|
|
import { DashboardScreen } from "./dashboard-composition"
|
|
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
import { ChartColumnIcon, ChevronRightIcon, LayoutGridIcon, MoreHorizontalIcon, SettingsIcon, type LucideIcon } from "lucide-react"
|
|
import {
|
|
Avatar,
|
|
AvatarFallback,
|
|
AvatarImage,
|
|
Badge,
|
|
Breadcrumb,
|
|
BreadcrumbItem,
|
|
BreadcrumbLink,
|
|
BreadcrumbList,
|
|
BreadcrumbPage,
|
|
BreadcrumbSeparator,
|
|
Button,
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
DashboardContent,
|
|
DashboardLayout,
|
|
DashboardHeader,
|
|
DashboardSidebar,
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
DashboardMain,
|
|
DataTable,
|
|
DataTableColumnHeader,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
FilterBar,
|
|
FilterBarActions,
|
|
FilterBarSearch,
|
|
PageHeader,
|
|
PageHeaderActions,
|
|
PageHeaderContent,
|
|
PageHeaderDescription,
|
|
PageHeaderTitle,
|
|
Progress,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Skeleton,
|
|
dataTableSelectColumn,
|
|
useDataTable,
|
|
type DataTableColumnDef,
|
|
} from "@hm-design/ui"
|
|
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
|
import { formatDate } from "./sample-users"
|
|
import { countDashboardWorkFilter, dashboardWorks, dashboardWorkFilters, dashboardWorkStatus, dashboardWorkStyle, matchesDashboardWorkStatus, type DashboardWork, type DashboardWorkFilter } from "./dashboard-work-status"
|
|
import "./dashboard-layout.stories.css"
|
|
|
|
// Dashboard Mode: internal navigation, Page Header, KPI cards, Filter Bar and Data Table.
|
|
// Menu and profile data are local to this Story. See docs/patterns/dashboard-layout.md.
|
|
|
|
const photo =
|
|
"data:image/svg+xml;utf8," +
|
|
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>')
|
|
|
|
const profileData = {
|
|
name: "김한맥",
|
|
description: "프로젝트 관리자",
|
|
photo,
|
|
fallback: "김",
|
|
}
|
|
|
|
const profileActions: { label: string; destructive?: boolean }[] = [
|
|
{ label: "프로필 설정" },
|
|
{ label: "로그아웃", destructive: true },
|
|
]
|
|
|
|
function ProfileAvatar() {
|
|
return (
|
|
<Avatar>
|
|
<AvatarImage src={profileData.photo} alt="" />
|
|
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
|
</Avatar>
|
|
)
|
|
}
|
|
|
|
function ProfileMenu({ trigger, side = "bottom", align = "start", modal, matchTrigger = false }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean }) {
|
|
return (
|
|
<DropdownMenu modal={modal}>
|
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
<DropdownMenuContent side={side} align={align} className={matchTrigger ? "min-w-(--radix-dropdown-menu-trigger-width)" : undefined}>
|
|
<DropdownMenuLabel>{profileData.name}</DropdownMenuLabel>
|
|
{profileActions.map((action) => (
|
|
<Fragment key={action.label}>
|
|
{action.destructive ? <DropdownMenuSeparator /> : null}
|
|
<DropdownMenuItem variant={action.destructive ? "destructive" : "default"}>{action.label}</DropdownMenuItem>
|
|
</Fragment>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
type Mode = "compact" | "sidebar" | "header-sidebar"
|
|
type StatusFilter = DashboardWorkFilter
|
|
const statusFilters = dashboardWorkFilters.map((id) => ({ id, ...dashboardWorkStatus[id] }))
|
|
|
|
// Variant C (ui_viz patterns-variant-c--all) composition: a 48px Compact Header with the title on the left and a floating 40px status
|
|
// filter bar centred on the header, followed immediately by the content surface. No GNB, LNB or profile. The structure mirrors the
|
|
// reference (header > brand + status bar [context | 전체 | group]); colours, type and controls are HM.
|
|
function CompactHeader({ data, status, onStatusChange }: { data: DashboardWork[]; status: StatusFilter; onStatusChange: (status: StatusFilter) => void }) {
|
|
const count = (id: StatusFilter) => countDashboardWorkFilter(data, id)
|
|
const filterButton = (filter: (typeof statusFilters)[number]) => (
|
|
<Button key={filter.id} type="button" variant="ghost" size="sm" data-filter={filter.id} data-status={filter.id} data-issue={filter.id === "issue" ? "true" : undefined} style={dashboardWorkStyle(filter.id)} aria-pressed={status === filter.id} aria-controls="dashboard-compact-results" onClick={() => onStatusChange(filter.id)}>
|
|
{filter.label} <strong>{count(filter.id)}</strong>건
|
|
</Button>
|
|
)
|
|
const [all, ...rest] = statusFilters
|
|
const workStates = rest.filter((filter) => filter.id !== "issue")
|
|
const issue = rest.find((filter) => filter.id === "issue")!
|
|
return (
|
|
<DashboardHeader data-story-slot="compact-header">
|
|
<div data-story-slot="compact-brand">
|
|
<strong>프로젝트 대시보드</strong>
|
|
</div>
|
|
<div data-story-slot="compact-status" role="group" aria-label="업무 상태 필터">
|
|
<span data-story-slot="compact-context">업무</span>
|
|
<span data-story-slot="compact-divider" aria-hidden="true" />
|
|
{filterButton(all)}
|
|
<span data-story-slot="compact-divider" aria-hidden="true" />
|
|
<div data-story-slot="compact-status-group">{workStates.map(filterButton)}</div>
|
|
<span data-story-slot="compact-divider" aria-hidden="true" />
|
|
{filterButton(issue)}
|
|
</div>
|
|
</DashboardHeader>
|
|
)
|
|
}
|
|
|
|
// Dashboard internal menu; kept at every viewport width.
|
|
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
|
|
|
|
const localNav = (thirdLevel?: boolean): LocalNode[] => [
|
|
{ id: "overview", label: "개요", icon: LayoutGridIcon },
|
|
{
|
|
id: "projects",
|
|
label: "업무 관리",
|
|
children: [
|
|
thirdLevel
|
|
? {
|
|
id: "projects/tasks",
|
|
label: "업무 현황",
|
|
children: [
|
|
{ id: "projects/tasks/all", label: "전체 업무" },
|
|
{ id: "projects/tasks/mine", label: "담당 업무" },
|
|
{ id: "projects/tasks/delayed", label: "지연 업무" },
|
|
],
|
|
}
|
|
: { id: "projects/tasks", label: "업무 현황" },
|
|
{ id: "projects/schedule", label: "일정" },
|
|
],
|
|
},
|
|
{ id: "reports", label: "리포트", icon: ChartColumnIcon },
|
|
{ id: "settings", label: "설정", icon: SettingsIcon },
|
|
]
|
|
|
|
const statusBadge = (status: Exclude<StatusFilter, "all">) => (
|
|
<Badge variant="outline" data-status={status} data-issue={status === "issue" ? "true" : undefined} data-work-status={status} style={dashboardWorkStyle(status)}>{dashboardWorkStatus[status].label}</Badge>
|
|
)
|
|
|
|
const columns: DataTableColumnDef<DashboardWork>[] = [
|
|
dataTableSelectColumn<DashboardWork>(),
|
|
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="업무명" /> },
|
|
{ accessorKey: "department", header: "부서" },
|
|
{ accessorKey: "status", header: "상태", cell: ({ row }) => <span data-story-slot="work-badges">{statusBadge(row.original.status)}{row.original.issue ? statusBadge("issue") : null}</span>, filterFn: (row, _columnId, value: StatusFilter) => matchesDashboardWorkStatus(row.original, value), enableSorting: false },
|
|
{
|
|
accessorKey: "createdAt",
|
|
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
|
|
cell: ({ row }) => formatDate(row.original.createdAt),
|
|
},
|
|
]
|
|
|
|
function TaskList({ data, loading, compact, selectedStatus = "all", onReset }: { data: DashboardWork[]; loading?: boolean; compact?: boolean; selectedStatus?: StatusFilter; onReset?: () => void }) {
|
|
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
|
|
const [search, setSearch] = useState("")
|
|
const [status, setStatus] = useState("all")
|
|
const total = table.getFilteredRowModel().rows.length
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>업무 목록</CardTitle>
|
|
<CardDescription role="status">{dashboardWorkStatus[compact ? selectedStatus : status as StatusFilter].label} 업무 {total}건</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4">
|
|
<FilterBar>
|
|
<FilterBarSearch
|
|
value={search}
|
|
onValueChange={(value) => {
|
|
setSearch(value)
|
|
table.setGlobalFilter(value || undefined)
|
|
}}
|
|
placeholder="업무명·부서 검색"
|
|
/>
|
|
{compact ? null : <Select
|
|
value={status}
|
|
onValueChange={(value) => {
|
|
setStatus(value)
|
|
table.getColumn("status")?.setFilterValue(value === "all" ? undefined : value)
|
|
}}
|
|
>
|
|
<SelectTrigger size="medium" aria-label="상태">
|
|
<SelectValue placeholder="상태" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{statusFilters.map((filter) => <SelectItem key={filter.id} value={filter.id}>{filter.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>}
|
|
<FilterBarActions>
|
|
<Button
|
|
type="button"
|
|
variant="cancel"
|
|
appearance="solid"
|
|
onClick={() => {
|
|
setSearch("")
|
|
setStatus("all")
|
|
table.resetGlobalFilter(true)
|
|
table.resetColumnFilters(true)
|
|
onReset?.()
|
|
}}
|
|
>
|
|
초기화
|
|
</Button>
|
|
</FilterBarActions>
|
|
</FilterBar>
|
|
<DataTable table={table} loading={loading} emptyMessage="조건에 맞는 업무가 없습니다." />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function Kpis({ data, loading }: { data: DashboardWork[]; loading?: boolean }) {
|
|
const kpis = statusFilters.map(({ id, label }) => {
|
|
const value = countDashboardWorkFilter(data, id)
|
|
const progress = data.length ? Math.round(value / data.length * 100) : 0
|
|
return { id, label, value, progress }
|
|
})
|
|
return (
|
|
<div className="grid gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 200px), 1fr))" }}>
|
|
{kpis.map((kpi) => (
|
|
<Card key={kpi.label}>
|
|
<CardHeader>
|
|
<CardDescription>{kpi.label}</CardDescription>
|
|
{loading ? null : (
|
|
<CardAction>
|
|
{kpi.id === "all" ? <Badge variant="secondary">전체</Badge> : statusBadge(kpi.id)}
|
|
</CardAction>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="grid gap-3">
|
|
<Skeleton style={{ height: 36, width: 80 }} />
|
|
<Skeleton style={{ height: 8 }} />
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-3">
|
|
<p style={{ margin: 0, fontSize: 28, lineHeight: "36px", fontWeight: 700 }}>{kpi.value}</p>
|
|
<Progress value={kpi.progress} aria-label={`${kpi.label} 비율`} />
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Screen({
|
|
mode = "compact",
|
|
data = dashboardWorks,
|
|
loading,
|
|
longTitle,
|
|
wide,
|
|
thirdLevel,
|
|
}: {
|
|
mode?: Mode
|
|
data?: DashboardWork[]
|
|
loading?: boolean
|
|
longTitle?: boolean
|
|
wide?: boolean
|
|
thirdLevel?: boolean
|
|
}) {
|
|
const [current, setCurrent] = useState(thirdLevel ? "projects/tasks/all" : "projects/tasks")
|
|
const [compactStatus, setCompactStatus] = useState<StatusFilter>("all")
|
|
const compact = mode === "compact"
|
|
const resultData = compact ? data.filter((row) => matchesDashboardWorkStatus(row, compactStatus)) : data
|
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
|
const go = (id: string) => (event: MouseEvent) => {
|
|
event.preventDefault()
|
|
setCurrent(id)
|
|
}
|
|
const isCurrent = (id: string) => current === id || current.startsWith(`${id}/`)
|
|
const nav = localNav(thirdLevel)
|
|
const dashboardMenu = (nodes: LocalNode[]): ReactNode =>
|
|
nodes.map((node) =>
|
|
node.children ? (
|
|
<Collapsible
|
|
key={node.id}
|
|
data-current={isCurrent(node.id) ? "" : undefined}
|
|
open={openGroups[node.id] ?? true}
|
|
onOpenChange={(value) => setOpenGroups((state) => ({ ...state, [node.id]: value }))}
|
|
>
|
|
<CollapsibleTrigger asChild><Button variant="ghost" type="button"><ChevronRightIcon aria-hidden="true" />{node.label}</Button></CollapsibleTrigger>
|
|
<CollapsibleContent className="pl-3">{dashboardMenu(node.children)}</CollapsibleContent>
|
|
</Collapsible>
|
|
) : (
|
|
<Button key={node.id} variant="ghost" asChild><a href={`#${node.id}`} aria-current={current === node.id ? "page" : undefined} onClick={go(node.id)}>
|
|
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
|
{node.label}
|
|
</a></Button>
|
|
)
|
|
)
|
|
const title = longTitle ? "전사 프로젝트 포트폴리오의 진행 현황과 담당 부서별 업무 상세 현황" : "업무 현황"
|
|
const sidebar = (
|
|
<DashboardSidebar>
|
|
<strong>프로젝트 대시보드</strong>
|
|
<nav aria-label="Dashboard 업무 메뉴">{dashboardMenu(nav)}</nav>
|
|
<ProfileMenu side="right" align="end" trigger={
|
|
<Button type="button" variant="ghost" aria-label={`${profileData.name} 사용자 메뉴`}>
|
|
<ProfileAvatar />{profileData.name}
|
|
</Button>
|
|
} />
|
|
</DashboardSidebar>
|
|
)
|
|
|
|
// Variant C content: heading, then the result card (Page Header's own 20px bottom spacing; no breadcrumb, actions or KPI cards in the reference).
|
|
const compactMain = (
|
|
<DashboardMain id="dashboard-compact-results">
|
|
<PageHeader>
|
|
<PageHeaderContent>
|
|
<PageHeaderTitle>업무 현황을 한눈에 확인하세요</PageHeaderTitle>
|
|
</PageHeaderContent>
|
|
</PageHeader>
|
|
<TaskList data={resultData} loading={loading} compact selectedStatus={compactStatus} onReset={() => setCompactStatus("all")} />
|
|
</DashboardMain>
|
|
)
|
|
|
|
const main = compact ? compactMain : (
|
|
<DashboardMain>
|
|
<PageHeader>
|
|
<PageHeaderContent>
|
|
<Breadcrumb>
|
|
<BreadcrumbList>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink href="#dashboard">홈</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
<BreadcrumbSeparator />
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
<BreadcrumbSeparator />
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage>업무 현황</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
<PageHeaderTitle>{title}</PageHeaderTitle>
|
|
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
|
|
</PageHeaderContent>
|
|
<PageHeaderActions>
|
|
<Button type="button" variant="cancel" appearance="solid">
|
|
다운로드
|
|
</Button>
|
|
<Button type="button" variant="primary">
|
|
신규 등록
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button type="button" variant="primary" appearance="outline" size="icon" aria-label="더보기">
|
|
<MoreHorizontalIcon />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem>일괄 수정</DropdownMenuItem>
|
|
<DropdownMenuItem>보관</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</PageHeaderActions>
|
|
</PageHeader>
|
|
<DashboardContent>
|
|
<Kpis data={data} loading={loading} />
|
|
<TaskList data={resultData} loading={loading} />
|
|
{wide ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>넓은 내용</CardTitle>
|
|
<CardDescription>페이지를 밀지 않고 카드 안에서 가로 스크롤합니다.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div style={{ overflowX: "auto" }}>
|
|
<div style={{ width: 1600, height: 80, background: "var(--hm-sem-bg-content)" }} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</DashboardContent>
|
|
</DashboardMain>
|
|
)
|
|
|
|
return (
|
|
<DashboardLayout
|
|
className={compact ? "dashboard-story dashboard-story-compact" : "dashboard-story"}
|
|
header={compact ? <CompactHeader data={data} status={compactStatus} onStatusChange={setCompactStatus} /> : mode === "header-sidebar" ? <DashboardHeader><strong>업무 현황</strong></DashboardHeader> : undefined}
|
|
sidebar={compact ? undefined : sidebar}
|
|
>
|
|
{main}
|
|
</DashboardLayout>
|
|
)
|
|
}
|
|
|
|
const frame = (height?: number): CSSProperties | undefined => (height ? ({ "--hm-dashboard-height": `${height}px` } as CSSProperties) : undefined)
|
|
|
|
// Shared by Storybook and the standalone Vite export. Keeping the representative
|
|
// story composition here prevents the export from becoming a second hand-written UI.
|
|
export function DashboardAll() {
|
|
return (
|
|
<StoryPage>
|
|
<StoryPageHeader title="Dashboard" />
|
|
<StorySection title="Overview">
|
|
<DashboardScreen mode="compact" />
|
|
</StorySection>
|
|
</StoryPage>
|
|
)
|
|
}
|
|
|
|
// Menu label is Dashboard; the fixed id keeps the existing story IDs (patterns-layout-dashboard-layout--*) used by links and QA inventory.
|
|
const meta = { id: "patterns-layout-dashboard-layout", title: "Patterns/Layout/Dashboard", excludeStories: ["DashboardAll"], parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
|
|
export default meta
|
|
type Story = StoryObj<typeof meta>
|
|
|
|
// Representative Story reproduces the ui_viz Variant C layout (48px Compact Header, centred status bar, content) with HM parts.
|
|
// Operating DASHBOARD Mode has no Sidebar; retained Sidebar examples are compatibility verification only.
|
|
// The page heading and Dashboard's native minimum-width scroll frame share the common Inner.
|
|
export const All: Story = {
|
|
name: "Dashboard",
|
|
render: () => <DashboardAll />,
|
|
}
|
|
// Keep existing Sidebar coverage and Story IDs; these are not operating references.
|
|
function SidebarVerification({ children }: { children: ReactNode }) {
|
|
return <>
|
|
<p role="note">VERIFICATION ONLY — 운영 사용 금지</p>
|
|
{children}
|
|
</>
|
|
}
|
|
export const WithSidebar: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Dashboard Header + Sidebar", render: () => <SidebarVerification><Screen mode="header-sidebar" /></SidebarVerification> }
|
|
export const ThirdLevel: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Third Level", render: () => <SidebarVerification><Screen mode="sidebar" thirdLevel /></SidebarVerification> }
|
|
export const SidebarOnly: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Dashboard Sidebar", render: () => <SidebarVerification><Screen mode="sidebar" /></SidebarVerification> }
|
|
export const LongContent: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Long Content", render: () => <SidebarVerification><Screen mode="sidebar" longTitle wide /></SidebarVerification> }
|
|
export const Loading: Story = { tags: ["verification"], render: () => <Screen loading /> }
|
|
export const Empty: Story = { tags: ["verification"], render: () => <Screen data={[]} /> }
|
|
export const Framed: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Framed", render: () => <SidebarVerification><div style={frame(640)}><Screen mode="sidebar" /></div></SidebarVerification> }
|