Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/components/ui/AgentSourceLabel/AgentSourceLabel.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
21 changes: 21 additions & 0 deletions src/components/ui/AgentSourceLabel/AgentSourceLabel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<>
<AgentSourceLabel source="rule" />
<AgentSourceLabel source="data" />
<AgentSourceLabel source="draft" />
<AgentSourceLabel source="review" />
</>,
)

expect(screen.getByText('등록된 규칙')).toBeInTheDocument()
expect(screen.getByText('보유 데이터')).toBeInTheDocument()
expect(screen.getByText('Agent 초안')).toBeInTheDocument()
expect(screen.getByText('HR 확인')).toBeInTheDocument()
})
})
25 changes: 25 additions & 0 deletions src/components/ui/AgentSourceLabel/AgentSourceLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import styles from './AgentSourceLabel.module.css'

export type AgentSource = 'rule' | 'data' | 'draft' | 'review'

const SOURCE_LABEL: Record<AgentSource, string> = {
rule: '등록된 규칙',
data: '보유 데이터',
draft: 'Agent 초안',
review: 'HR 확인',
}

const SOURCE_CLASS: Record<AgentSource, string> = {
rule: styles.rule,
data: styles.data,
draft: styles.draft,
review: styles.review,
}

export interface AgentSourceLabelProps {
source?: AgentSource
}

export function AgentSourceLabel({ source = 'rule' }: AgentSourceLabelProps) {
return <span className={`${styles.label} ${SOURCE_CLASS[source]}`}>{SOURCE_LABEL[source]}</span>
}
30 changes: 30 additions & 0 deletions src/components/ui/Button/Button.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions src/components/ui/Button/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Button onClick={onClick}>업무 생성</Button>)

await user.click(screen.getByRole('button', { name: '업무 생성' }))

expect(onClick).toHaveBeenCalledOnce()
})

it('is disabled when disabled prop is set', () => {
render(<Button disabled>업무 생성</Button>)
expect(screen.getByRole('button', { name: '업무 생성' })).toBeDisabled()
})
})
17 changes: 17 additions & 0 deletions src/components/ui/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ButtonHTMLAttributes } from 'react'
import styles from './Button.module.css'

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary'
}

export function Button({ variant = 'primary', className, type = 'button', ...rest }: ButtonProps) {
const variantClass = variant === 'primary' ? styles.primary : styles.secondary
return (
<button
type={type}
className={`${styles.button} ${variantClass} ${className ?? ''}`}
{...rest}
/>
)
}
45 changes: 45 additions & 0 deletions src/components/ui/DecisionGate/DecisionGate.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
19 changes: 19 additions & 0 deletions src/components/ui/DecisionGate/DecisionGate.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<>
<DecisionGate state="locked" />
<DecisionGate state="ready" />
<DecisionGate state="approved" />
</>,
)

expect(screen.getByText('실행할 수 없습니다')).toBeInTheDocument()
expect(screen.getByText('HR 확인이 필요합니다')).toBeInTheDocument()
expect(screen.getByText('승인이 완료되었습니다')).toBeInTheDocument()
})
})
35 changes: 35 additions & 0 deletions src/components/ui/DecisionGate/DecisionGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import styles from './DecisionGate.module.css'

export type DecisionGateState = 'locked' | 'ready' | 'approved'

const CONTENT: Record<DecisionGateState, { title: string; body: string }> = {
locked: { title: '실행할 수 없습니다', body: '필수정보를 먼저 입력해 주세요.' },
ready: { title: 'HR 확인이 필요합니다', body: '확인 후 다음 단계가 활성화됩니다.' },
approved: { title: '승인이 완료되었습니다', body: '다음 실행을 진행할 수 있습니다.' },
}

const GATE_CLASS: Record<DecisionGateState, string> = {
locked: styles.locked,
ready: styles.ready,
approved: styles.approved,
}

const TITLE_CLASS: Record<DecisionGateState, string> = {
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 (
<div className={`${styles.gate} ${GATE_CLASS[state]}`}>
<p className={`${styles.title} ${TITLE_CLASS[state]}`}>{content.title}</p>
<p className={styles.body}>{content.body}</p>
</div>
)
}
43 changes: 43 additions & 0 deletions src/components/ui/EmptyState/EmptyState.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
23 changes: 23 additions & 0 deletions src/components/ui/EmptyState/EmptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<EmptyState
title="처리할 업무가 없습니다"
body="새 요청을 입력하거나 파일을 가져와 업무를 만들어 보세요."
actionLabel="업무 만들기"
onAction={onAction}
/>,
)

expect(screen.getByText('처리할 업무가 없습니다')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '업무 만들기' }))
expect(onAction).toHaveBeenCalledOnce()
})
})
28 changes: 28 additions & 0 deletions src/components/ui/EmptyState/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.state}>
<p className={`${styles.title} ${isError ? styles.titleError : ''}`}>{title}</p>
<p className={styles.body}>{body}</p>
<button
type="button"
className={`${styles.action} ${isError ? styles.actionError : ''}`}
onClick={onAction}
>
{actionLabel}
</button>
</div>
)
}
41 changes: 41 additions & 0 deletions src/components/ui/StatusLabel/StatusLabel.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading