feat: integrate latest HMDesign design system updates
This commit is contained in:
1 parent
99faec189e
commit
1c4e0bb9a1
209 files changed
+8845
-552
No files matched your search
@@ -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(),
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user