feat: integrate latest HMDesign design system updates

This commit is contained in:
DESKTOP-KPCP6AL\ㅅㅎ committed 2026-10-11 11:09:03 +09:00
1 parent 99faec189e
commit 1c4e0bb9a1
209 files changed
+8845 -552

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
import type { CSSProperties } from "react"
import {
countDashboardFilter,
dashboardFilterDefinitions,
dashboardFilterIds,
dashboardStatusDefinitions,
matchesDashboardFilter,
type DashboardFilterId,
type DashboardStatusId,
} from "@hm-design/ui"
// Recruitment sample data. Hiring progress maps onto the shared Dashboard status contract:
// active = 전형 진행, hold = 보류, done = 완료(최종 결정), issue = 독립 flag(서류 누락·일정 조율 등).
export type RecruitStatus = DashboardStatusId
export type RecruitFilter = DashboardFilterId
export type RecruitStage = "서류 심사" | "1차 면접" | "2차 면접" | "처우 협의" | "최종 합격"
export type Candidate = {
id: string
name: string
position: string
department: string
stage: RecruitStage
status: RecruitStatus
issue?: boolean
appliedAt: string
}
export const recruitStages: RecruitStage[] = ["서류 심사", "1차 면접", "2차 면접", "처우 협의", "최종 합격"]
export const recruitStatus = {
all: { label: dashboardFilterDefinitions.all.label, color: "var(--hm-sem-palette-yellow-accent)" },
active: { label: dashboardStatusDefinitions.active.label, color: "var(--hm-dashboard-status-active)" },
hold: { label: dashboardStatusDefinitions.hold.label, color: "var(--hm-dashboard-status-hold)" },
done: { label: dashboardStatusDefinitions.done.label, color: "var(--hm-dashboard-status-done)" },
issue: { label: dashboardFilterDefinitions.issue.label, color: "var(--hm-dashboard-status-issue)" },
} satisfies Record<RecruitFilter, { label: string; color: string }>
export const recruitFilters = dashboardFilterIds
export const recruitStatusStyle = (status: RecruitFilter): CSSProperties =>
({ "--dashboard-status-color": recruitStatus[status].color }) as CSSProperties
export const matchesRecruitStatus = (candidate: Candidate, filter: RecruitFilter) => matchesDashboardFilter(candidate, filter)
export const countRecruitFilter = (candidates: readonly Candidate[], filter: RecruitFilter) => countDashboardFilter(candidates, filter)
const surnames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임"]
const givenNames = ["민준", "서연", "지호", "하은", "도윤", "수아", "예준", "지우", "시우", "채원", "현우", "유진"]
const positions: { position: string; department: string }[] = [
{ position: "구조 설계 엔지니어", department: "설계팀" },
{ position: "현장 시공 관리자", department: "시공팀" },
{ position: "안전 관리자", department: "안전관리팀" },
{ position: "품질 검사원", department: "품질팀" },
{ position: "인사·총무 담당", department: "경영지원팀" },
]
// stage / status / issue are assigned from a fixed table so the data is deterministic and every combination appears.
const progress: { stage: RecruitStage; status: RecruitStatus }[] = [
{ stage: "서류 심사", status: "active" },
{ stage: "1차 면접", status: "active" },
{ stage: "2차 면접", status: "hold" },
{ stage: "처우 협의", status: "active" },
{ stage: "최종 합격", status: "done" },
{ stage: "서류 심사", status: "hold" },
{ stage: "1차 면접", status: "done" },
]
export const candidates: Candidate[] = Array.from({ length: 36 }, (_, i) => {
const flow = progress[i % progress.length]
const job = positions[(i * 3) % positions.length]
return {
id: `R-${String(i + 1).padStart(3, "0")}`,
name: `${surnames[i % surnames.length]}${givenNames[(i * 5) % givenNames.length]}`,
position: job.position,
department: job.department,
stage: flow.stage,
status: flow.status,
issue: i % 9 === 0,
appliedAt: new Date(2026, 8, 1 + (i % 30)).toISOString(),
}
})
+265
View File
@@ -0,0 +1,265 @@
import { useMemo, useState } from "react"
import "../list-screen.css"
import { MoreHorizontalIcon } from "lucide-react"
import {
Badge,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DashboardContent,
DashboardHeader,
DashboardLayout,
DashboardMain,
DataTable,
DataTableColumnHeader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FilterBar,
FilterBarActions,
FilterBarSearch,
PageHeader,
PageHeaderActions,
PageHeaderContent,
PageHeaderDescription,
PageHeaderTitle,
Progress,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
dataTableSelectColumn,
useDataTable,
type DataTableColumnDef,
} from "@hm-design/ui"
import { formatDate } from "@/patterns/sample-users"
import {
candidates as sampleCandidates,
countRecruitFilter,
matchesRecruitStatus,
recruitFilters,
recruitStages,
recruitStatus,
recruitStatusStyle,
type Candidate,
type RecruitFilter,
type RecruitStage,
} from "./recruit-data"
// Compact Dashboard Header and Dashboard Status → Badge mapping are the existing Dashboard composition styles (no new CSS).
import "@/patterns/dashboard-composition.css"
// DASHBOARD ONLY: 전용 Header(48px) + 업무 상태 필터 + Content. No GNB, LNB or Sidebar (docs/patterns/dashboard-layout.md).
// Selection reason: 채용 관리 is a status-driven operations screen (진행/보류/완료/이슈 counts first), not a menu-driven one.
const statusBadge = (status: Exclude<RecruitFilter, "all">) => (
<Badge variant="outline" data-status={status} data-issue={status === "issue" ? "true" : undefined} style={recruitStatusStyle(status)}>
{recruitStatus[status].label}
</Badge>
)
const columns: DataTableColumnDef<Candidate>[] = [
dataTableSelectColumn<Candidate>(),
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="지원자" /> },
{ accessorKey: "position", header: "지원 직무" },
{ accessorKey: "department", header: "부서" },
{ accessorKey: "stage", header: "전형 단계", filterFn: (row, _columnId, value: RecruitStage) => row.original.stage === value },
{
accessorKey: "status",
header: "상태",
enableSorting: false,
cell: ({ row }) => (
<span data-story-slot="work-badges">
{statusBadge(row.original.status)}
{row.original.issue ? statusBadge("issue") : null}
</span>
),
},
{
accessorKey: "appliedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="지원일" />,
cell: ({ row }) => formatDate(row.original.appliedAt),
},
]
function StatusHeader({ data, status, onStatusChange }: { data: Candidate[]; status: RecruitFilter; onStatusChange: (status: RecruitFilter) => void }) {
const filterButton = (id: RecruitFilter) => (
<Button key={id} type="button" variant="ghost" size="sm" data-filter={id} data-status={id} data-issue={id === "issue" ? "true" : undefined} style={recruitStatusStyle(id)} aria-pressed={status === id} aria-controls="recruit-results" onClick={() => onStatusChange(id)}>
{recruitStatus[id].label} <strong>{countRecruitFilter(data, id)}</strong>건
</Button>
)
const [all, ...rest] = recruitFilters
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">{rest.filter((id) => id !== "issue").map(filterButton)}</div>
<span data-story-slot="compact-divider" aria-hidden="true" />
{filterButton("issue")}
</div>
</DashboardHeader>
)
}
function StageSummary({ data }: { data: Candidate[] }) {
return (
<div className="grid gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 160px), 1fr))" }}>
{recruitStages.map((stage) => {
const count = data.filter((candidate) => candidate.stage === stage).length
const ratio = data.length ? Math.round((count / data.length) * 100) : 0
return (
<Card key={stage}>
<CardHeader>
<CardDescription>{stage}</CardDescription>
<CardAction>
<Badge variant="secondary">{ratio}%</Badge>
</CardAction>
</CardHeader>
<CardContent>
<div className="grid gap-3">
<p style={{ margin: 0, fontSize: 28, lineHeight: "36px", fontWeight: 700 }}>{count}명</p>
<Progress value={ratio} aria-label={`${stage} 비율`} />
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
function CandidateList({ data, status, onReset }: { data: Candidate[]; status: RecruitFilter; onReset: () => void }) {
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
const [search, setSearch] = useState("")
const [stage, setStage] = useState("all")
const total = table.getFilteredRowModel().rows.length
return (
<Card>
<CardHeader>
<CardTitle>지원자 목록</CardTitle>
<CardDescription role="status">
{recruitStatus[status].label} 지원자 {total}명
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<FilterBar className="hm-list-search-basic">
<Select
value={stage}
onValueChange={(value) => {
setStage(value)
}}
>
<SelectTrigger size="medium" aria-label="전형 단계">
<SelectValue placeholder="전형 단계" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체 단계</SelectItem>
{recruitStages.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<FilterBarSearch value={search} onValueChange={setSearch} placeholder="지원자·직무 검색" />
<FilterBarActions>
<Button type="button" variant="primary" appearance="solid" onClick={() => {
table.setGlobalFilter(search || undefined)
table.getColumn("stage")?.setFilterValue(stage === "all" ? undefined : stage)
}}>조회</Button>
<Button
type="button"
variant="cancel"
appearance="outline"
onClick={() => {
setSearch("")
setStage("all")
table.resetGlobalFilter(true)
table.resetColumnFilters(true)
onReset()
}}
>
초기화
</Button>
</FilterBarActions>
</FilterBar>
<DataTable table={table} emptyMessage="조건에 맞는 지원자가 없습니다." />
</div>
</CardContent>
</Card>
)
}
export function RecruitScreen({ data = sampleCandidates }: { data?: Candidate[] }) {
const [status, setStatus] = useState<RecruitFilter>("all")
const visible = useMemo(() => data.filter((candidate) => matchesRecruitStatus(candidate, status)), [data, status])
return (
<DashboardLayout className="dashboard-production dashboard-story-compact" header={<StatusHeader data={data} status={status} onStatusChange={setStatus} />}>
<DashboardMain id="recruit-results">
<PageHeader>
<PageHeaderContent>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#hr">인사</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>채용 관리</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PageHeaderTitle>채용 관리</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>
<StageSummary data={visible} />
<CandidateList data={visible} status={status} onReset={() => setStatus("all")} />
</DashboardContent>
</DashboardMain>
</DashboardLayout>
)
}