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
7 changes: 7 additions & 0 deletions src/pages/SettingsPage/SettingsPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
border-bottom-color: var(--brand-primary);
}

.tabPanel {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 20px;
}

.cardRow {
display: grid;
grid-template-columns: 480px 1fr;
Expand Down
40 changes: 38 additions & 2 deletions src/pages/SettingsPage/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { SettingsPage } from './SettingsPage'
import { MEMBERS, SETTINGS_TABS } from './settingsData'
import {
COMPLETION_EVIDENCE_RULES,
DATA_LOG_SETTINGS,
MEMBERS,
PROCESS_STEP_RULES,
SECURITY_LINK_HISTORY,
SETTINGS_TABS,
} from './settingsData'

describe('SettingsPage', () => {
it('renders every member row', () => {
Expand All @@ -24,13 +31,42 @@ describe('SettingsPage', () => {
expect(toggle).toHaveAttribute('aria-checked', 'true')
})

it('switches the active settings tab', async () => {
it('switches to the security link tab and shows link history', async () => {
const user = userEvent.setup()
render(<SettingsPage />)

const securityTab = screen.getByRole('tab', { name: SETTINGS_TABS[1] })
await user.click(securityTab)

expect(securityTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText(SECURITY_LINK_HISTORY[0].workerName)).toBeInTheDocument()
expect(screen.queryByText('구성원과 승인 권한')).not.toBeInTheDocument()
})

it('switches to the completion evidence tab', async () => {
const user = userEvent.setup()
render(<SettingsPage />)

await user.click(screen.getByRole('tab', { name: SETTINGS_TABS[2] }))

expect(screen.getByText(COMPLETION_EVIDENCE_RULES[0].caseType)).toBeInTheDocument()
})

it('switches to the process steps tab', async () => {
const user = userEvent.setup()
render(<SettingsPage />)

await user.click(screen.getByRole('tab', { name: SETTINGS_TABS[3] }))

expect(screen.getByText(PROCESS_STEP_RULES[0].approvalSteps)).toBeInTheDocument()
})

it('switches to the data log tab', async () => {
const user = userEvent.setup()
render(<SettingsPage />)

await user.click(screen.getByRole('tab', { name: SETTINGS_TABS[4] }))

expect(screen.getByText(DATA_LOG_SETTINGS[0].value)).toBeInTheDocument()
})
})
222 changes: 152 additions & 70 deletions src/pages/SettingsPage/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import { DetailRow } from '../../components/ui/DetailRow/DetailRow'
import styles from './SettingsPage.module.css'
import {
APPROVAL_POLICY,
COMPLETION_EVIDENCE_RULES,
DATA_LOG_SETTINGS,
FOOTNOTE,
MEMBERS,
POLICY_SUMMARY,
PROCESS_STEP_RULES,
SECURITY_LINK_HISTORY,
SECURITY_LINK_POLICY,
SETTINGS_TABS,
type Member,
} from './settingsData'
Expand Down Expand Up @@ -54,85 +59,162 @@ export function SettingsPage() {
))}
</div>

<div className={styles.cardRow}>
<div className={styles.card}>
<div className={styles.cardHeader}>
<h2 className={styles.cardTitle}>승인 정책</h2>
<span className={styles.cardBadge}>{APPROVAL_POLICY.badge}</span>
</div>
<p className={styles.policyName}>{APPROVAL_POLICY.title}</p>
<p className={styles.policyDescription}>{APPROVAL_POLICY.description}</p>
{activeTab === SETTINGS_TABS[0] && (
<>
<div className={styles.cardRow}>
<div className={styles.card}>
<div className={styles.cardHeader}>
<h2 className={styles.cardTitle}>승인 정책</h2>
<span className={styles.cardBadge}>{APPROVAL_POLICY.badge}</span>
</div>
<p className={styles.policyName}>{APPROVAL_POLICY.title}</p>
<p className={styles.policyDescription}>{APPROVAL_POLICY.description}</p>

<div className={styles.modeRow}>
<span className={styles.modePill}>{APPROVAL_POLICY.mode}</span>
<span className={styles.modeNote}>{APPROVAL_POLICY.modeNote}</span>
</div>

<p className={styles.policyWarning}>{APPROVAL_POLICY.warning}</p>
</div>

<div className={styles.modeRow}>
<span className={styles.modePill}>{APPROVAL_POLICY.mode}</span>
<span className={styles.modeNote}>{APPROVAL_POLICY.modeNote}</span>
<div className={styles.card}>
<h2 className={styles.cardTitle}>{POLICY_SUMMARY.title}</h2>
{POLICY_SUMMARY.rows.map((row) => (
<DetailRow key={row.label} label={row.label} value={row.value} tone={row.tone} />
))}
</div>
</div>

<p className={styles.policyWarning}>{APPROVAL_POLICY.warning}</p>
</div>

<div className={styles.card}>
<h2 className={styles.cardTitle}>{POLICY_SUMMARY.title}</h2>
{POLICY_SUMMARY.rows.map((row) => (
<DetailRow key={row.label} label={row.label} value={row.value} tone={row.tone} />
))}
</div>
</div>

<div className={styles.membersCard}>
<h2 className={styles.cardTitle}>구성원과 승인 권한</h2>

<div className={styles.membersHeader}>
<span>이름 / 역할</span>
<span>승인 가능</span>
<span>상태</span>
</div>
<div className={styles.membersCard}>
<h2 className={styles.cardTitle}>구성원과 승인 권한</h2>

{members.map((member, index) => (
<div
key={member.id}
className={`${styles.memberRow} ${index % 2 === 0 ? styles.memberRowShaded : ''}`}
>
<div className={styles.memberIdentity}>
<p className={styles.memberName}>{member.name}</p>
<p className={styles.memberRole}>{member.role}</p>
<div className={styles.membersHeader}>
<span>이름 / 역할</span>
<span>승인 가능</span>
<span>상태</span>
</div>

<span
className={`${styles.memberApproval} ${
member.approval === 'canApprove' ? styles.memberApprovalActive : ''
}`}
>
{member.approval === 'canApprove' ? '승인 가능' : '승인 요청만'}
</span>

<button
type="button"
role="switch"
aria-checked={member.approval === 'canApprove'}
aria-label={`${member.name} 승인 권한`}
className={`${styles.toggle} ${
member.approval === 'canApprove' ? styles.toggleOn : ''
}`}
onClick={() => toggleApproval(member.id)}
>
<span className={styles.toggleThumb} />
{members.map((member, index) => (
<div
key={member.id}
className={`${styles.memberRow} ${index % 2 === 0 ? styles.memberRowShaded : ''}`}
>
<div className={styles.memberIdentity}>
<p className={styles.memberName}>{member.name}</p>
<p className={styles.memberRole}>{member.role}</p>
</div>

<span
className={`${styles.memberApproval} ${
member.approval === 'canApprove' ? styles.memberApprovalActive : ''
}`}
>
{member.approval === 'canApprove' ? '승인 가능' : '승인 요청만'}
</span>

<button
type="button"
role="switch"
aria-checked={member.approval === 'canApprove'}
aria-label={`${member.name} 승인 권한`}
className={`${styles.toggle} ${
member.approval === 'canApprove' ? styles.toggleOn : ''
}`}
onClick={() => toggleApproval(member.id)}
>
<span className={styles.toggleThumb} />
</button>

<span
className={`${styles.memberStatus} ${
member.status === '활성' ? styles.memberStatusActive : styles.memberStatusPending
}`}
>
{member.status}
</span>
</div>
))}

<button type="button" className={styles.inviteLink} onClick={handleInviteMember}>
+ 구성원 초대
</button>

<span
className={`${styles.memberStatus} ${
member.status === '활성' ? styles.memberStatusActive : styles.memberStatusPending
}`}
>
{member.status}
</span>
</div>
))}
</>
)}

{activeTab === SETTINGS_TABS[1] && (
<div className={styles.tabPanel}>
{/* TODO(backend): GET /api/settings/security-links -> SECURITY_LINK_POLICY, SECURITY_LINK_HISTORY 대체 */}
<div className={styles.card}>
<h2 className={styles.cardTitle}>{SECURITY_LINK_POLICY.title}</h2>
<p className={styles.policyDescription}>{SECURITY_LINK_POLICY.description}</p>
<DetailRow label="현재 만료 기간" value={SECURITY_LINK_POLICY.validity} />
</div>

<button type="button" className={styles.inviteLink} onClick={handleInviteMember}>
+ 구성원 초대
</button>
</div>
<div className={styles.membersCard}>
<h2 className={styles.cardTitle}>최근 발급 이력</h2>
<div className={styles.membersHeader}>
<span>근로자</span>
<span>발급 시각</span>
<span>상태</span>
</div>
{SECURITY_LINK_HISTORY.map((entry, index) => (
<div
key={entry.id}
className={`${styles.memberRow} ${index % 2 === 0 ? styles.memberRowShaded : ''}`}
>
<div className={styles.memberIdentity}>
<p className={styles.memberName}>{entry.workerName}</p>
</div>
<span className={styles.memberApproval}>{entry.issuedAt}</span>
<span className={styles.memberStatus}>{entry.status}</span>
</div>
))}
</div>
</div>
)}

{activeTab === SETTINGS_TABS[2] && (
<div className={styles.tabPanel}>
{/* TODO(backend): GET /api/settings/completion-evidence -> COMPLETION_EVIDENCE_RULES 대체 */}
<div className={styles.card}>
<h2 className={styles.cardTitle}>업무 유형별 완료 증빙</h2>
{COMPLETION_EVIDENCE_RULES.map((rule) => (
<DetailRow
key={rule.caseType}
label={rule.caseType}
value={rule.requirement}
tone={rule.tone}
/>
))}
</div>
</div>
)}

{activeTab === SETTINGS_TABS[3] && (
<div className={styles.tabPanel}>
{/* TODO(backend): GET /api/settings/process-steps -> PROCESS_STEP_RULES 대체 */}
<div className={styles.card}>
<h2 className={styles.cardTitle}>업무 유형별 승인 단계</h2>
{PROCESS_STEP_RULES.map((rule) => (
<DetailRow key={rule.caseType} label={rule.caseType} value={rule.approvalSteps} />
))}
</div>
</div>
)}

{activeTab === SETTINGS_TABS[4] && (
<div className={styles.tabPanel}>
{/* TODO(backend): GET /api/settings/data-log -> DATA_LOG_SETTINGS 대체 */}
<div className={styles.card}>
<h2 className={styles.cardTitle}>데이터·AI 로그 설정</h2>
{DATA_LOG_SETTINGS.map((setting) => (
<DetailRow key={setting.label} label={setting.label} value={setting.value} tone={setting.tone} />
))}
</div>
</div>
)}

<p className={styles.footnote}>{FOOTNOTE}</p>
</div>
Expand Down
58 changes: 58 additions & 0 deletions src/pages/SettingsPage/settingsData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { DetailRowTone } from '../../components/ui/DetailRow/DetailRow'

// TODO(backend): GET /api/settings -> SETTINGS_TABS 이하 상수 대체
// TODO(backend): PATCH /api/settings/members/:id { canApprove } -> 멤버 토글 반영

Expand Down Expand Up @@ -43,3 +45,59 @@ export const MEMBERS: Member[] = [

export const FOOTNOTE =
'MVP 운영정책은 출시 전 개인정보·노무·법률 검토가 필요합니다. 일반 HR 사용자는 보관기간을 무기한으로 변경할 수 없습니다.'

export const SECURITY_LINK_POLICY = {
title: '근로자 업로드 링크 만료 정책',
validity: '72시간',
description: 'LINK-001/002에서 발급되는 보안 링크는 발급 후 72시간이 지나면 자동 만료됩니다.',
}

export type SecurityLinkStatus = '사용 완료' | '만료' | '대기 중'

export interface SecurityLinkHistoryEntry {
id: string
workerName: string
issuedAt: string
status: SecurityLinkStatus
}

export const SECURITY_LINK_HISTORY: SecurityLinkHistoryEntry[] = [
{ id: 'LNK-1', workerName: '응웬반A', issuedAt: '07.19 14:02', status: '사용 완료' },
{ id: 'LNK-2', workerName: '쩐티B', issuedAt: '07.18 09:40', status: '만료' },
{ id: 'LNK-3', workerName: '수라즈C', issuedAt: '07.20 08:15', status: '대기 중' },
]

export interface CompletionEvidenceRule {
caseType: string
requirement: string
tone: DetailRowTone
}

export const COMPLETION_EVIDENCE_RULES: CompletionEvidenceRule[] = [
{ caseType: '일반 내부업무', requirement: '파일 첨부 불필요', tone: 'default' },
{ caseType: '외부기관 업무', requirement: '증빙 1개 이상', tone: 'warning' },
{ caseType: '체류·비자 업무', requirement: '접수번호 필수', tone: 'warning' },
]

export interface ProcessStepRule {
caseType: string
approvalSteps: string
}

export const PROCESS_STEP_RULES: ProcessStepRule[] = [
{ caseType: '일반 내부업무', approvalSteps: '담당자 1인 승인' },
{ caseType: '외부기관 업무', approvalSteps: '담당자 승인 + 증빙 확인' },
{ caseType: '체류·비자 업무', approvalSteps: 'HR 승인 + 접수번호 확인' },
]

export interface DataLogSetting {
label: string
value: string
tone: DetailRowTone
}

export const DATA_LOG_SETTINGS: DataLogSetting[] = [
{ label: 'Agent 접근 범위', value: '근로자 기본정보·업무 컨텍스트', tone: 'default' },
{ label: 'AI 원문 로그', value: '30일 후 삭제·비식별화', tone: 'warning' },
{ label: '비식별 품질 로그', value: '12개월 보관', tone: 'default' },
]