diff --git a/src/components/ui/AgentSourceLabel/AgentSourceLabel.module.css b/src/components/ui/AgentSourceLabel/AgentSourceLabel.module.css
new file mode 100644
index 0000000..d2b20da
--- /dev/null
+++ b/src/components/ui/AgentSourceLabel/AgentSourceLabel.module.css
@@ -0,0 +1,31 @@
+.label {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--fowoco-spacing-4) var(--fowoco-spacing-8);
+ border-radius: var(--fowoco-radius-999);
+ font-family: var(--font-sans);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 18px;
+ white-space: nowrap;
+}
+
+.rule {
+ background: var(--fowoco-teal-50);
+ color: var(--brand-primary);
+}
+
+.data {
+ background: var(--surface-subtle);
+ color: var(--text-secondary);
+}
+
+.draft {
+ background: var(--fowoco-violet-50);
+ color: var(--fowoco-violet-600);
+}
+
+.review {
+ background: var(--fowoco-amber-50);
+ color: var(--fowoco-amber-600);
+}
diff --git a/src/components/ui/AgentSourceLabel/AgentSourceLabel.test.tsx b/src/components/ui/AgentSourceLabel/AgentSourceLabel.test.tsx
new file mode 100644
index 0000000..acc2b72
--- /dev/null
+++ b/src/components/ui/AgentSourceLabel/AgentSourceLabel.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { AgentSourceLabel } from './AgentSourceLabel'
+
+describe('AgentSourceLabel', () => {
+ it('renders the Korean label for each source', () => {
+ render(
+ <>
+
+
+
+
+ >,
+ )
+
+ expect(screen.getByText('등록된 규칙')).toBeInTheDocument()
+ expect(screen.getByText('보유 데이터')).toBeInTheDocument()
+ expect(screen.getByText('Agent 초안')).toBeInTheDocument()
+ expect(screen.getByText('HR 확인')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/ui/AgentSourceLabel/AgentSourceLabel.tsx b/src/components/ui/AgentSourceLabel/AgentSourceLabel.tsx
new file mode 100644
index 0000000..952a247
--- /dev/null
+++ b/src/components/ui/AgentSourceLabel/AgentSourceLabel.tsx
@@ -0,0 +1,25 @@
+import styles from './AgentSourceLabel.module.css'
+
+export type AgentSource = 'rule' | 'data' | 'draft' | 'review'
+
+const SOURCE_LABEL: Record = {
+ rule: '등록된 규칙',
+ data: '보유 데이터',
+ draft: 'Agent 초안',
+ review: 'HR 확인',
+}
+
+const SOURCE_CLASS: Record = {
+ rule: styles.rule,
+ data: styles.data,
+ draft: styles.draft,
+ review: styles.review,
+}
+
+export interface AgentSourceLabelProps {
+ source?: AgentSource
+}
+
+export function AgentSourceLabel({ source = 'rule' }: AgentSourceLabelProps) {
+ return {SOURCE_LABEL[source]}
+}
diff --git a/src/components/ui/Button/Button.module.css b/src/components/ui/Button/Button.module.css
new file mode 100644
index 0000000..3750422
--- /dev/null
+++ b/src/components/ui/Button/Button.module.css
@@ -0,0 +1,30 @@
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 40px;
+ padding: var(--fowoco-spacing-8) var(--fowoco-spacing-16);
+ border-radius: var(--fowoco-radius-6);
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 22px;
+ cursor: pointer;
+}
+
+.primary {
+ background: var(--brand-primary);
+ color: var(--fowoco-white);
+ border: none;
+}
+
+.secondary {
+ background: var(--surface-default);
+ color: var(--text-primary);
+ border: 1px solid var(--border-default);
+}
+
+.button:disabled {
+ opacity: 0.42;
+ cursor: not-allowed;
+}
diff --git a/src/components/ui/Button/Button.test.tsx b/src/components/ui/Button/Button.test.tsx
new file mode 100644
index 0000000..21eeda5
--- /dev/null
+++ b/src/components/ui/Button/Button.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import userEvent from '@testing-library/user-event'
+import { Button } from './Button'
+
+describe('Button', () => {
+ it('renders its label and fires onClick', async () => {
+ const onClick = vi.fn()
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByRole('button', { name: '업무 생성' }))
+
+ expect(onClick).toHaveBeenCalledOnce()
+ })
+
+ it('is disabled when disabled prop is set', () => {
+ render()
+ expect(screen.getByRole('button', { name: '업무 생성' })).toBeDisabled()
+ })
+})
diff --git a/src/components/ui/Button/Button.tsx b/src/components/ui/Button/Button.tsx
new file mode 100644
index 0000000..f79bffa
--- /dev/null
+++ b/src/components/ui/Button/Button.tsx
@@ -0,0 +1,17 @@
+import type { ButtonHTMLAttributes } from 'react'
+import styles from './Button.module.css'
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: 'primary' | 'secondary'
+}
+
+export function Button({ variant = 'primary', className, type = 'button', ...rest }: ButtonProps) {
+ const variantClass = variant === 'primary' ? styles.primary : styles.secondary
+ return (
+
+ )
+}
diff --git a/src/components/ui/DecisionGate/DecisionGate.module.css b/src/components/ui/DecisionGate/DecisionGate.module.css
new file mode 100644
index 0000000..7941b07
--- /dev/null
+++ b/src/components/ui/DecisionGate/DecisionGate.module.css
@@ -0,0 +1,45 @@
+.gate {
+ display: flex;
+ flex-direction: column;
+ gap: var(--fowoco-spacing-8);
+ padding: var(--fowoco-spacing-16);
+ border-radius: var(--fowoco-radius-8);
+ font-family: var(--font-sans);
+}
+
+.locked {
+ background: var(--fowoco-red-50);
+}
+
+.ready {
+ background: var(--fowoco-amber-50);
+}
+
+.approved {
+ background: var(--fowoco-green-50);
+}
+
+.title {
+ font-size: 14px;
+ font-weight: 700;
+ line-height: 22px;
+}
+
+.titleLocked {
+ color: var(--fowoco-red-600);
+}
+
+.titleReady {
+ color: var(--fowoco-amber-600);
+}
+
+.titleApproved {
+ color: var(--fowoco-green-600);
+}
+
+.body {
+ font-size: 13px;
+ font-weight: 400;
+ line-height: 21px;
+ color: var(--text-secondary);
+}
diff --git a/src/components/ui/DecisionGate/DecisionGate.test.tsx b/src/components/ui/DecisionGate/DecisionGate.test.tsx
new file mode 100644
index 0000000..5c91587
--- /dev/null
+++ b/src/components/ui/DecisionGate/DecisionGate.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { DecisionGate } from './DecisionGate'
+
+describe('DecisionGate', () => {
+ it('renders the message for each state', () => {
+ render(
+ <>
+
+
+
+ >,
+ )
+
+ expect(screen.getByText('실행할 수 없습니다')).toBeInTheDocument()
+ expect(screen.getByText('HR 확인이 필요합니다')).toBeInTheDocument()
+ expect(screen.getByText('승인이 완료되었습니다')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/ui/DecisionGate/DecisionGate.tsx b/src/components/ui/DecisionGate/DecisionGate.tsx
new file mode 100644
index 0000000..486708b
--- /dev/null
+++ b/src/components/ui/DecisionGate/DecisionGate.tsx
@@ -0,0 +1,35 @@
+import styles from './DecisionGate.module.css'
+
+export type DecisionGateState = 'locked' | 'ready' | 'approved'
+
+const CONTENT: Record = {
+ locked: { title: '실행할 수 없습니다', body: '필수정보를 먼저 입력해 주세요.' },
+ ready: { title: 'HR 확인이 필요합니다', body: '확인 후 다음 단계가 활성화됩니다.' },
+ approved: { title: '승인이 완료되었습니다', body: '다음 실행을 진행할 수 있습니다.' },
+}
+
+const GATE_CLASS: Record = {
+ locked: styles.locked,
+ ready: styles.ready,
+ approved: styles.approved,
+}
+
+const TITLE_CLASS: Record = {
+ locked: styles.titleLocked,
+ ready: styles.titleReady,
+ approved: styles.titleApproved,
+}
+
+export interface DecisionGateProps {
+ state?: DecisionGateState
+}
+
+export function DecisionGate({ state = 'locked' }: DecisionGateProps) {
+ const content = CONTENT[state]
+ return (
+
+
{content.title}
+
{content.body}
+
+ )
+}
diff --git a/src/components/ui/EmptyState/EmptyState.module.css b/src/components/ui/EmptyState/EmptyState.module.css
new file mode 100644
index 0000000..2653b90
--- /dev/null
+++ b/src/components/ui/EmptyState/EmptyState.module.css
@@ -0,0 +1,43 @@
+.state {
+ display: flex;
+ flex-direction: column;
+ gap: var(--fowoco-spacing-12);
+ align-items: flex-start;
+ padding: var(--fowoco-spacing-24);
+ background: var(--surface-subtle);
+ border-radius: var(--fowoco-radius-8);
+ font-family: var(--font-sans);
+}
+
+.title {
+ font-size: 16px;
+ font-weight: 700;
+ line-height: 24px;
+ color: var(--text-primary);
+}
+
+.titleError {
+ color: var(--status-critical);
+}
+
+.body {
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 22px;
+ color: var(--text-secondary);
+}
+
+.action {
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 21px;
+ color: var(--brand-primary);
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+}
+
+.actionError {
+ color: var(--status-critical);
+}
diff --git a/src/components/ui/EmptyState/EmptyState.test.tsx b/src/components/ui/EmptyState/EmptyState.test.tsx
new file mode 100644
index 0000000..b237ced
--- /dev/null
+++ b/src/components/ui/EmptyState/EmptyState.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { EmptyState } from './EmptyState'
+
+describe('EmptyState', () => {
+ it('renders title, body, and action, and fires onAction', async () => {
+ const onAction = vi.fn()
+ const user = userEvent.setup()
+ render(
+ ,
+ )
+
+ expect(screen.getByText('처리할 업무가 없습니다')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: '업무 만들기' }))
+ expect(onAction).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/components/ui/EmptyState/EmptyState.tsx b/src/components/ui/EmptyState/EmptyState.tsx
new file mode 100644
index 0000000..5b2699c
--- /dev/null
+++ b/src/components/ui/EmptyState/EmptyState.tsx
@@ -0,0 +1,28 @@
+import styles from './EmptyState.module.css'
+
+export type EmptyStateKind = 'empty' | 'loading' | 'error'
+
+export interface EmptyStateProps {
+ kind?: EmptyStateKind
+ title: string
+ body: string
+ actionLabel: string
+ onAction?: () => void
+}
+
+export function EmptyState({ kind = 'empty', title, body, actionLabel, onAction }: EmptyStateProps) {
+ const isError = kind === 'error'
+ return (
+
+
{title}
+
{body}
+
+
+ )
+}
diff --git a/src/components/ui/StatusLabel/StatusLabel.module.css b/src/components/ui/StatusLabel/StatusLabel.module.css
new file mode 100644
index 0000000..bde94c4
--- /dev/null
+++ b/src/components/ui/StatusLabel/StatusLabel.module.css
@@ -0,0 +1,41 @@
+.label {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--fowoco-spacing-4) var(--fowoco-spacing-8);
+ border-radius: var(--fowoco-radius-999);
+ font-family: var(--font-sans);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 18px;
+ white-space: nowrap;
+}
+
+.neutral {
+ background: var(--surface-subtle);
+ color: var(--text-secondary);
+}
+
+.info {
+ background: var(--fowoco-teal-50);
+ color: var(--brand-primary);
+}
+
+.success {
+ background: var(--fowoco-green-50);
+ color: var(--fowoco-green-600);
+}
+
+.warning {
+ background: var(--fowoco-amber-50);
+ color: var(--fowoco-amber-600);
+}
+
+.critical {
+ background: var(--fowoco-red-50);
+ color: var(--fowoco-red-600);
+}
+
+.agent {
+ background: var(--fowoco-violet-50);
+ color: var(--fowoco-violet-600);
+}
diff --git a/src/components/ui/StatusLabel/StatusLabel.test.tsx b/src/components/ui/StatusLabel/StatusLabel.test.tsx
new file mode 100644
index 0000000..221be75
--- /dev/null
+++ b/src/components/ui/StatusLabel/StatusLabel.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { StatusLabel } from './StatusLabel'
+
+describe('StatusLabel', () => {
+ it('renders the Korean label for each tone', () => {
+ render(
+ <>
+
+
+
+
+ >,
+ )
+
+ expect(screen.getByText('진행 중')).toBeInTheDocument()
+ expect(screen.getByText('확인 필요')).toBeInTheDocument()
+ expect(screen.getByText('실행 차단')).toBeInTheDocument()
+ expect(screen.getByText('Agent 준비')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/ui/StatusLabel/StatusLabel.tsx b/src/components/ui/StatusLabel/StatusLabel.tsx
new file mode 100644
index 0000000..a2eb3c3
--- /dev/null
+++ b/src/components/ui/StatusLabel/StatusLabel.tsx
@@ -0,0 +1,29 @@
+import styles from './StatusLabel.module.css'
+
+export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'critical' | 'agent'
+
+const TONE_LABEL: Record = {
+ neutral: '진행 중',
+ info: '안내',
+ success: '완료',
+ warning: '확인 필요',
+ critical: '실행 차단',
+ agent: 'Agent 준비',
+}
+
+const TONE_CLASS: Record = {
+ neutral: styles.neutral,
+ info: styles.info,
+ success: styles.success,
+ warning: styles.warning,
+ critical: styles.critical,
+ agent: styles.agent,
+}
+
+export interface StatusLabelProps {
+ tone?: StatusTone
+}
+
+export function StatusLabel({ tone = 'neutral' }: StatusLabelProps) {
+ return {TONE_LABEL[tone]}
+}
diff --git a/src/components/ui/WorkflowStep/WorkflowStep.module.css b/src/components/ui/WorkflowStep/WorkflowStep.module.css
new file mode 100644
index 0000000..12e77af
--- /dev/null
+++ b/src/components/ui/WorkflowStep/WorkflowStep.module.css
@@ -0,0 +1,51 @@
+.step {
+ display: flex;
+ align-items: center;
+ gap: var(--fowoco-spacing-12);
+}
+
+.dot {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.dotPending {
+ background: var(--border-default);
+}
+
+.dotActive {
+ background: var(--brand-primary);
+}
+
+.dotDone {
+ background: var(--fowoco-green-600);
+}
+
+.dotBlocked {
+ background: var(--fowoco-red-600);
+}
+
+.label {
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 22px;
+}
+
+.labelPending {
+ color: var(--text-secondary);
+}
+
+.labelActive {
+ color: var(--brand-primary);
+}
+
+.labelDone {
+ color: var(--fowoco-green-600);
+}
+
+.labelBlocked {
+ color: var(--fowoco-red-600);
+}
diff --git a/src/components/ui/WorkflowStep/WorkflowStep.test.tsx b/src/components/ui/WorkflowStep/WorkflowStep.test.tsx
new file mode 100644
index 0000000..20f506c
--- /dev/null
+++ b/src/components/ui/WorkflowStep/WorkflowStep.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { WorkflowStep } from './WorkflowStep'
+
+describe('WorkflowStep', () => {
+ it('renders the step label for each state', () => {
+ render(
+ <>
+ 근로자에게 서류 요청
+ 서류 확인 완료
+ 승인이 필요합니다
+ >,
+ )
+
+ expect(screen.getByText('근로자에게 서류 요청')).toBeInTheDocument()
+ expect(screen.getByText('서류 확인 완료')).toBeInTheDocument()
+ expect(screen.getByText('승인이 필요합니다')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/ui/WorkflowStep/WorkflowStep.tsx b/src/components/ui/WorkflowStep/WorkflowStep.tsx
new file mode 100644
index 0000000..dbb1ed9
--- /dev/null
+++ b/src/components/ui/WorkflowStep/WorkflowStep.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from 'react'
+import styles from './WorkflowStep.module.css'
+
+export type WorkflowStepState = 'pending' | 'active' | 'done' | 'blocked'
+
+const DOT_CLASS: Record = {
+ pending: styles.dotPending,
+ active: styles.dotActive,
+ done: styles.dotDone,
+ blocked: styles.dotBlocked,
+}
+
+const LABEL_CLASS: Record = {
+ pending: styles.labelPending,
+ active: styles.labelActive,
+ done: styles.labelDone,
+ blocked: styles.labelBlocked,
+}
+
+export interface WorkflowStepProps {
+ state?: WorkflowStepState
+ children: ReactNode
+}
+
+export function WorkflowStep({ state = 'pending', children }: WorkflowStepProps) {
+ return (
+
+ )
+}
diff --git a/src/main.tsx b/src/main.tsx
index 4c3176a..ddefafc 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router-dom'
+import './styles/tokens.css'
import './index.css'
import { queryClient } from './lib/queryClient'
import { router } from './routes'
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
new file mode 100644
index 0000000..e513db9
--- /dev/null
+++ b/src/styles/tokens.css
@@ -0,0 +1,35 @@
+:root {
+ --brand-primary: #07847f;
+
+ --surface-default: #fff;
+ --surface-subtle: #eef4f4;
+ --border-default: #dce5e4;
+
+ --text-primary: #172b2d;
+ --text-secondary: #5f6f72;
+
+ --fowoco-white: #fff;
+ --fowoco-teal-50: #eef4f4;
+ --fowoco-violet-50: #f4f0ff;
+ --fowoco-violet-600: #7657c8;
+ --fowoco-red-50: #fff1f1;
+ --fowoco-red-600: #d84a4a;
+ --fowoco-amber-50: #fff7e8;
+ --fowoco-amber-600: #d79222;
+ --fowoco-green-50: #ecf8f1;
+ --fowoco-green-600: #2c8a5b;
+
+ --status-critical: #d84a4a;
+
+ --fowoco-spacing-4: 4px;
+ --fowoco-spacing-8: 8px;
+ --fowoco-spacing-12: 12px;
+ --fowoco-spacing-16: 16px;
+ --fowoco-spacing-24: 24px;
+
+ --fowoco-radius-6: 6px;
+ --fowoco-radius-8: 8px;
+ --fowoco-radius-999: 999px;
+
+ --font-sans: 'Noto Sans KR', system-ui, 'Segoe UI', Roboto, sans-serif;
+}