feat: 신규 페이지 구현 및 HR/설정 기능 개선

신규 페이지:
- 회계관리: 거래처, 예상비용, 청구서, 발주서
- 게시판: 공지사항, 자료실, 커뮤니티
- 고객센터: 문의/FAQ
- 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역
- 리포트 (차트 시각화)
- 개발자 테스트 URL 페이지

기능 개선:
- HR 직원관리/휴가관리/카드관리 강화
- IntegratedListTemplateV2 확장
- AuthenticatedLayout 패딩 표준화
- 로그인 페이지 UI 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-19 19:12:34 +09:00
parent d742c0ce26
commit c6b605200d
213 changed files with 32644 additions and 775 deletions

View File

@@ -0,0 +1,435 @@
'use client';
import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import {
User,
Upload,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { AccountInfo, AccountStatus, TermsAgreement, MarketingConsent } from './types';
import { ACCOUNT_STATUS_LABELS, ACCOUNT_STATUS_COLORS } from './types';
// ===== Mock 데이터 =====
const mockAccountInfo: AccountInfo = {
id: 'user-1',
email: 'abc@email.com',
profileImage: undefined,
role: '권한명',
status: 'active',
isTenantMaster: false, // true로 변경하면 사용중지 버튼 활성화
createdAt: '2025-12-12T12:12:00.000Z',
updatedAt: '2025-12-12T12:12:00.000Z',
};
const mockTermsAgreements: TermsAgreement[] = [
{
type: 'required',
label: '서비스 이용약관 동의',
agreed: true,
agreedAt: '2025-12-12 12:12',
},
{
type: 'required',
label: '개인정보 취급방침',
agreed: true,
agreedAt: '2025-12-12 12:12',
},
];
const mockMarketingConsent: MarketingConsent = {
email: {
agreed: true,
agreedAt: '2025-12-12 12:12',
},
sms: {
agreed: false,
withdrawnAt: '2025-12-12 12:12',
},
};
export function AccountInfoClient() {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
// ===== 상태 관리 =====
const [accountInfo] = useState<AccountInfo>(mockAccountInfo);
const [termsAgreements] = useState<TermsAgreement[]>(mockTermsAgreements);
const [marketingConsent, setMarketingConsent] = useState<MarketingConsent>(mockMarketingConsent);
const [profileImage, setProfileImage] = useState<string | undefined>(accountInfo.profileImage);
// 다이얼로그 상태
const [showWithdrawDialog, setShowWithdrawDialog] = useState(false);
const [showSuspendDialog, setShowSuspendDialog] = useState(false);
// ===== 버튼 활성화 조건 =====
const canWithdraw = !accountInfo.isTenantMaster; // 테넌트 마스터가 아닌 경우만
const canSuspend = accountInfo.isTenantMaster; // 테넌트 마스터인 경우만
// ===== 핸들러 =====
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 파일 크기 체크 (10MB)
if (file.size > 10 * 1024 * 1024) {
alert('파일 크기는 10MB 이하여야 합니다.');
return;
}
// 파일 타입 체크
const validTypes = ['image/png', 'image/jpeg', 'image/gif'];
if (!validTypes.includes(file.type)) {
alert('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
return;
}
// 미리보기 생성
const reader = new FileReader();
reader.onload = (event) => {
setProfileImage(event.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleRemoveImage = () => {
setProfileImage(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handlePasswordChange = () => {
// 비밀번호 설정 화면으로 이동
router.push('/ko/settings/account-info/change-password');
};
const handleWithdraw = () => {
setShowWithdrawDialog(true);
};
const handleConfirmWithdraw = () => {
// TODO: 탈퇴 API 연동
console.log('Account withdrawal confirmed');
setShowWithdrawDialog(false);
// 로그아웃 및 로그인 페이지로 이동
router.push('/ko/login');
};
const handleSuspend = () => {
setShowSuspendDialog(true);
};
const handleConfirmSuspend = () => {
// TODO: 사용중지 API 연동
console.log('Account suspension confirmed');
setShowSuspendDialog(false);
};
const handleEdit = () => {
// 수정 모드로 전환 또는 별도 수정 페이지로 이동
router.push('/ko/settings/account-info?mode=edit');
};
const handleMarketingChange = (type: 'email' | 'sms', checked: boolean) => {
const now = new Date().toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
setMarketingConsent(prev => ({
...prev,
[type]: {
agreed: checked,
...(checked ? { agreedAt: now } : { withdrawnAt: now }),
},
}));
};
// ===== 헤더 액션 버튼 =====
const headerActions = (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleWithdraw}
disabled={!canWithdraw}
className={!canWithdraw ? 'opacity-50 cursor-not-allowed' : 'border-red-300 text-red-600 hover:bg-red-50'}
>
</Button>
<Button
variant="outline"
onClick={handleSuspend}
disabled={!canSuspend}
className={!canSuspend ? 'opacity-50 cursor-not-allowed' : 'border-orange-300 text-orange-600 hover:bg-orange-50'}
>
</Button>
<Button onClick={handleEdit}>
</Button>
</div>
);
return (
<>
<PageLayout>
<PageHeader
title="계정정보"
description="계정 정보를 관리합니다"
icon={User}
actions={headerActions}
/>
<div className="space-y-6">
{/* 계정 정보 섹션 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 프로필 사진 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-start gap-4">
<div className="relative w-[250px] h-[250px] border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center bg-gray-50 overflow-hidden">
{profileImage ? (
<>
<img
src={profileImage}
alt="프로필"
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={handleRemoveImage}
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md hover:bg-gray-100"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</>
) : (
<div className="text-center">
<Upload className="w-8 h-8 mx-auto text-gray-400 mb-2" />
<span className="text-sm text-gray-500">IMG</span>
</div>
)}
</div>
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif"
onChange={handleImageUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
</Button>
<p className="text-xs text-muted-foreground">
1250 X 250px, 10MB PNG, JPEG, GIF
</p>
</div>
</div>
</div>
{/* 아이디 & 비밀번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label></Label>
<Input
value={accountInfo.email}
disabled
className="bg-gray-50"
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center">
<Button
variant="default"
size="default"
onClick={handlePasswordChange}
>
</Button>
</div>
</div>
</div>
{/* 권한 & 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label></Label>
<Input
value={accountInfo.role}
disabled
className="bg-gray-50"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={ACCOUNT_STATUS_LABELS[accountInfo.status]}
disabled
className="bg-gray-50"
/>
</div>
</div>
</CardContent>
</Card>
{/* 약관 동의 정보 섹션 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 필수 약관 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{termsAgreements.map((term, index) => (
<div key={index} className="space-y-2">
<Label className="flex items-center gap-1">
<Badge variant="secondary" className="text-xs">
</Badge>
{term.label}
</Label>
<div className="text-sm text-muted-foreground">
{term.agreedAt}
</div>
</div>
))}
</div>
{/* 선택 약관 - 마케팅 정보 수신 동의 */}
<div className="space-y-4">
<Label className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">
</Badge>
</Label>
<div className="space-y-3 pl-4">
{/* 이메일 수신 동의 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
id="email-consent"
checked={marketingConsent.email.agreed}
onCheckedChange={(checked) => handleMarketingChange('email', checked as boolean)}
/>
<Label htmlFor="email-consent" className="text-sm font-normal cursor-pointer">
.
</Label>
</div>
<span className="text-sm text-muted-foreground">
{marketingConsent.email.agreedAt || '-'}
</span>
</div>
{/* SMS 수신 동의 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
id="sms-consent"
checked={marketingConsent.sms.agreed}
onCheckedChange={(checked) => handleMarketingChange('sms', checked as boolean)}
/>
<Label htmlFor="sms-consent" className="text-sm font-normal cursor-pointer">
SMS .
</Label>
</div>
<span className="text-sm text-muted-foreground">
{marketingConsent.sms.agreed
? `동의일시 ${marketingConsent.sms.withdrawnAt || '-'}`
: `동의철회일시 ${marketingConsent.sms.withdrawnAt || '-'}`
}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
{/* 탈퇴 확인 다이얼로그 */}
<AlertDialog open={showWithdrawDialog} onOpenChange={setShowWithdrawDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
, SAM .
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmWithdraw}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 사용중지 확인 다이얼로그 */}
<AlertDialog open={showSuspendDialog} onOpenChange={setShowSuspendDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSuspend}
className="bg-orange-600 hover:bg-orange-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,44 @@
export interface AccountInfo {
id: string;
email: string;
profileImage?: string;
role: string;
status: AccountStatus;
isTenantMaster: boolean;
createdAt: string;
updatedAt: string;
}
export type AccountStatus = 'active' | 'inactive' | 'suspended';
export const ACCOUNT_STATUS_LABELS: Record<AccountStatus, string> = {
active: '정상',
inactive: '비활성',
suspended: '정지',
};
export const ACCOUNT_STATUS_COLORS: Record<AccountStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
suspended: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
};
export interface TermsAgreement {
type: 'required' | 'optional';
label: string;
subLabel?: string;
agreed: boolean;
agreedAt?: string;
withdrawnAt?: string;
}
export interface MarketingConsent {
email: {
agreed: boolean;
agreedAt?: string;
};
sms: {
agreed: boolean;
withdrawnAt?: string;
};
}

View File

@@ -0,0 +1,343 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Landmark, Save, Trash2, X, Edit, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { Account, AccountFormData, AccountStatus } from './types';
import {
BANK_OPTIONS,
BANK_LABELS,
ACCOUNT_STATUS_OPTIONS,
ACCOUNT_STATUS_LABELS,
ACCOUNT_STATUS_COLORS,
} from './types';
interface AccountDetailProps {
account?: Account;
mode: 'create' | 'view' | 'edit';
}
export function AccountDetail({ account, mode: initialMode }: AccountDetailProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [mode, setMode] = useState(initialMode);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// URL에서 mode 파라미터 확인
useEffect(() => {
const urlMode = searchParams.get('mode');
if (urlMode === 'edit' && account) {
setMode('edit');
}
}, [searchParams, account]);
// 폼 상태
const [formData, setFormData] = useState<AccountFormData>({
bankCode: account?.bankCode || '',
accountNumber: account?.accountNumber || '',
accountName: account?.accountName || '',
accountHolder: account?.accountHolder || '',
accountPassword: '',
status: account?.status || 'active',
});
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const isEditMode = mode === 'edit';
const handleChange = (field: keyof AccountFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleBack = () => {
router.push('/ko/settings/accounts');
};
const handleSubmit = async () => {
// TODO: API 연동
console.log('Form submitted:', formData);
// Mock: 성공 후 목록으로 이동
router.push('/ko/settings/accounts');
};
const handleDelete = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
// TODO: API 연동
console.log('Delete account:', account?.id);
router.push('/ko/settings/accounts');
};
const handleCancel = () => {
if (isCreateMode) {
router.push('/ko/settings/accounts');
} else {
setMode('view');
// 원래 데이터로 복원
if (account) {
setFormData({
bankCode: account.bankCode,
accountNumber: account.accountNumber,
accountName: account.accountName,
accountHolder: account.accountHolder,
accountPassword: '',
status: account.status,
});
}
}
};
const handleEdit = () => {
setMode('edit');
};
// 뷰 모드 렌더링
if (isViewMode) {
return (
<PageLayout>
<PageHeader
title="계좌 상세"
description="계좌 정보를 관리합니다"
icon={Landmark}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Badge className={ACCOUNT_STATUS_COLORS[formData.status]}>
{ACCOUNT_STATUS_LABELS[formData.status]}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{BANK_LABELS[formData.bankCode] || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1 font-mono">{formData.accountNumber || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{formData.accountHolder || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"> </dt>
<dd className="text-sm mt-1">****</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{formData.accountName || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">
<Badge className={ACCOUNT_STATUS_COLORS[formData.status]}>
{ACCOUNT_STATUS_LABELS[formData.status]}
</Badge>
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}
// 생성/수정 모드 렌더링
return (
<PageLayout>
<PageHeader
title={isCreateMode ? '계좌 등록' : '계좌 수정'}
description="계좌 정보를 관리합니다"
icon={Landmark}
/>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 은행 & 계좌번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="bankCode"></Label>
<Select
value={formData.bankCode}
onValueChange={(value) => handleChange('bankCode', value)}
>
<SelectTrigger>
<SelectValue placeholder="은행 선택" />
</SelectTrigger>
<SelectContent>
{BANK_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="accountNumber"></Label>
<Input
id="accountNumber"
value={formData.accountNumber}
onChange={(e) => handleChange('accountNumber', e.target.value)}
placeholder="1234-1234-1234-1234"
disabled={isEditMode} // 수정 시 계좌번호는 변경 불가
/>
</div>
</div>
{/* 예금주 & 계좌 비밀번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="accountHolder"></Label>
<Input
id="accountHolder"
value={formData.accountHolder}
onChange={(e) => handleChange('accountHolder', e.target.value)}
placeholder="예금주명"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accountPassword">
( )
</Label>
<Input
id="accountPassword"
type="password"
value={formData.accountPassword}
onChange={(e) => handleChange('accountPassword', e.target.value)}
placeholder="****"
/>
</div>
</div>
{/* 계좌명 & 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="accountName"></Label>
<Input
id="accountName"
value={formData.accountName}
onChange={(e) => handleChange('accountName', e.target.value)}
placeholder="계좌명을 입력해주세요"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(value) => handleChange('status', value as AccountStatus)}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleCancel}>
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSubmit}>
<Save className="w-4 h-4 mr-2" />
{isCreateMode ? '등록' : '저장'}
</Button>
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,383 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Landmark,
Pencil,
Trash2,
Plus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
IntegratedListTemplateV2,
type TableColumn,
} from '@/components/templates/IntegratedListTemplateV2';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import type { Account } from './types';
import {
BANK_LABELS,
ACCOUNT_STATUS_LABELS,
ACCOUNT_STATUS_COLORS,
} from './types';
// ===== 계좌번호 마스킹 함수 =====
const maskAccountNumber = (accountNumber: string): string => {
if (accountNumber.length <= 8) return accountNumber;
const parts = accountNumber.split('-');
if (parts.length >= 3) {
// 1234-****-****-1234 형태
return parts.map((part, idx) => {
if (idx === 0 || idx === parts.length - 1) return part;
return '****';
}).join('-');
}
// 단순 형태: 앞 4자리-****-뒤 4자리
const first = accountNumber.slice(0, 4);
const last = accountNumber.slice(-4);
return `${first}-****-****-${last}`;
};
// ===== Mock 데이터 생성 =====
const generateMockData = (): Account[] => {
const banks = ['shinhan', 'kb', 'woori', 'hana', 'nh', 'ibk'];
const statuses: ('active' | 'inactive')[] = ['active', 'inactive'];
return Array.from({ length: 10 }, (_, i) => ({
id: `account-${i + 1}`,
bankCode: banks[i % banks.length],
accountNumber: `1234-1234-1234-${String(1234 + i).padStart(4, '0')}`,
accountName: `운영계좌 ${i + 1}`,
accountHolder: `예금주${i + 1}`,
accountPassword: '****',
status: statuses[i % 2],
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
}));
};
export function AccountManagement() {
const router = useRouter();
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
// Mock 데이터
const [data, setData] = useState<Account[]>(generateMockData);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
return newSet;
});
}, []);
// ===== 필터링된 데이터 =====
const filteredData = useMemo(() => {
if (!searchQuery) return data;
const search = searchQuery.toLowerCase();
return data.filter(item =>
item.accountName.toLowerCase().includes(search) ||
item.accountNumber.includes(search) ||
item.accountHolder.toLowerCase().includes(search) ||
BANK_LABELS[item.bankCode]?.toLowerCase().includes(search)
);
}, [data, searchQuery]);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredData.slice(startIndex, startIndex + itemsPerPage);
}, [filteredData, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
// ===== 전체 선택 핸들러 =====
const toggleSelectAll = useCallback(() => {
if (selectedItems.size === filteredData.length && filteredData.length > 0) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(filteredData.map(item => item.id)));
}
}, [selectedItems.size, filteredData]);
// ===== 액션 핸들러 =====
const handleRowClick = useCallback((item: Account) => {
router.push(`/ko/settings/accounts/${item.id}`);
}, [router]);
const handleEdit = useCallback((item: Account) => {
router.push(`/ko/settings/accounts/${item.id}?mode=edit`);
}, [router]);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(() => {
if (deleteTargetId) {
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
}, [deleteTargetId]);
const handleBulkDelete = useCallback(() => {
if (selectedItems.size > 0) {
setShowBulkDeleteDialog(true);
}
}, [selectedItems.size]);
const handleConfirmBulkDelete = useCallback(() => {
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
setShowBulkDeleteDialog(false);
}, [selectedItems]);
const handleCreate = useCallback(() => {
router.push('/ko/settings/accounts/new');
}, [router]);
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'bank', label: '은행', className: 'min-w-[100px]' },
{ key: 'accountNumber', label: '계좌번호', className: 'min-w-[160px]' },
{ key: 'accountName', label: '계좌명', className: 'min-w-[120px]' },
{ key: 'accountHolder', label: '예금주', className: 'min-w-[80px]' },
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
], []);
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: Account, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
</TableCell>
<TableCell className="text-muted-foreground text-center">{globalIndex}</TableCell>
<TableCell>{BANK_LABELS[item.bankCode] || item.bankCode}</TableCell>
<TableCell className="font-mono">{maskAccountNumber(item.accountNumber)}</TableCell>
<TableCell>{item.accountName}</TableCell>
<TableCell>{item.accountHolder}</TableCell>
<TableCell>
<Badge className={ACCOUNT_STATUS_COLORS[item.status]}>
{ACCOUNT_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item)}
title="수정"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(item.id)}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection, handleRowClick, handleEdit, handleDeleteClick]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: Account,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<ListMobileCard
id={item.id}
title={item.accountName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">
#{globalIndex}
</Badge>
<span className="text-xs text-muted-foreground">
{BANK_LABELS[item.bankCode] || item.bankCode}
</span>
</div>
}
statusBadge={
<Badge className={ACCOUNT_STATUS_COLORS[item.status]}>
{ACCOUNT_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => handleRowClick(item)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="계좌번호" value={maskAccountNumber(item.accountNumber)} />
<InfoField label="예금주" value={item.accountHolder} />
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
>
<Pencil className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => { e.stopPropagation(); handleDeleteClick(item.id); }}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
}, [handleRowClick, handleEdit, handleDeleteClick]);
// ===== 헤더 액션 (카드관리와 동일한 패턴) =====
const headerActions = (
<Button onClick={handleCreate}>
<Plus className="w-4 h-4 mr-2" />
</Button>
);
return (
<>
<IntegratedListTemplateV2
title="계좌관리"
description="계좌 목록을 관리합니다"
icon={Landmark}
headerActions={headerActions}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="은행명, 계좌번호, 계좌명, 예금주 검색..."
tableColumns={tableColumns}
data={paginatedData}
totalCount={filteredData.length}
allData={filteredData}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item: Account) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 다중 삭제 확인 다이얼로그 */}
<AlertDialog open={showBulkDeleteDialog} onOpenChange={setShowBulkDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmBulkDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,80 @@
// ===== 계좌 상태 =====
export type AccountStatus = 'active' | 'inactive';
export const ACCOUNT_STATUS_LABELS: Record<AccountStatus, string> = {
active: '사용',
inactive: '정지',
};
export const ACCOUNT_STATUS_OPTIONS = [
{ value: 'active', label: '사용' },
{ value: 'inactive', label: '정지' },
];
export const ACCOUNT_STATUS_COLORS: Record<AccountStatus, string> = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-500',
};
// ===== 은행 목록 =====
export const BANK_OPTIONS = [
{ value: 'shinhan', label: '신한은행' },
{ value: 'kb', label: 'KB국민은행' },
{ value: 'woori', label: '우리은행' },
{ value: 'hana', label: '하나은행' },
{ value: 'nh', label: 'NH농협은행' },
{ value: 'ibk', label: 'IBK기업은행' },
{ value: 'kakao', label: '카카오뱅크' },
{ value: 'toss', label: '토스뱅크' },
{ value: 'sc', label: 'SC제일은행' },
{ value: 'citi', label: '씨티은행' },
{ value: 'kdb', label: 'KDB산업은행' },
{ value: 'busan', label: '부산은행' },
{ value: 'daegu', label: '대구은행' },
{ value: 'gwangju', label: '광주은행' },
{ value: 'jeonbuk', label: '전북은행' },
{ value: 'jeju', label: '제주은행' },
{ value: 'kbank', label: '케이뱅크' },
{ value: 'suhyup', label: '수협은행' },
{ value: 'post', label: '우체국' },
{ value: 'saemaul', label: '새마을금고' },
{ value: 'shinhyup', label: '신협' },
];
export const BANK_LABELS: Record<string, string> = BANK_OPTIONS.reduce(
(acc, opt) => ({ ...acc, [opt.value]: opt.label }),
{}
);
// ===== 정렬 옵션 =====
export type SortOption = 'latest' | 'oldest' | 'bankAsc' | 'bankDesc';
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'bankAsc', label: '은행명 오름차순' },
{ value: 'bankDesc', label: '은행명 내림차순' },
];
// ===== 계좌 인터페이스 =====
export interface Account {
id: string;
bankCode: string; // 은행 코드
accountNumber: string; // 계좌번호
accountName: string; // 계좌명
accountHolder: string; // 예금주
accountPassword?: string; // 계좌 비밀번호 (빠른 조회 서비스)
status: AccountStatus; // 상태 (사용/정지)
createdAt: string;
updatedAt: string;
}
// ===== 계좌 폼 데이터 =====
export interface AccountFormData {
bankCode: string;
accountNumber: string;
accountName: string;
accountHolder: string;
accountPassword: string;
status: AccountStatus;
}

View File

@@ -0,0 +1,226 @@
'use client';
/**
* 출퇴근관리 설정 페이지
*
* 이 설정값들이 영향을 주는 곳:
* ────────────────────────────────────────────────────────────────
* 1. 모바일 출퇴근 페이지 (/hr/attendance)
* - gpsEnabled: GPS 출퇴근 기능 활성화 여부
* - gpsDepartments: GPS 출퇴근 사용 가능한 부서 목록
* - allowedRadius: 출퇴근 허용 반경 (SITE_LOCATION.radius 값으로 사용)
*
* 2. 자동 출퇴근 배치 처리 (백엔드)
* - autoEnabled: 자동 출퇴근 기능 활성화 여부
* - autoDepartments: 자동 출퇴근 적용 부서 목록
* ────────────────────────────────────────────────────────────────
*
* TODO: API 연동 시 작업 사항
* - GET /api/settings/attendance: 설정값 조회
* - PUT /api/settings/attendance: 설정값 저장
* - 모바일 출퇴근 페이지에서 이 설정값을 조회하여 사용
*/
import { useState } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { MapPin, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import type { AttendanceSettings, AllowedRadius, Department } from './types';
import {
DEFAULT_ATTENDANCE_SETTINGS,
ALLOWED_RADIUS_OPTIONS,
MOCK_DEPARTMENTS,
} from './types';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
export function AttendanceSettingsManagement() {
const [settings, setSettings] = useState<AttendanceSettings>(DEFAULT_ATTENDANCE_SETTINGS);
const [departments] = useState<Department[]>(MOCK_DEPARTMENTS);
// GPS 출퇴근 사용 토글
const handleGpsToggle = (checked: boolean) => {
setSettings(prev => ({
...prev,
gpsEnabled: checked,
// 비활성화 시 연동 부서와 반경 초기화
...(checked ? {} : { gpsDepartments: [], allowedRadius: 100 as AllowedRadius }),
}));
};
// 자동 출퇴근 사용 토글
const handleAutoToggle = (checked: boolean) => {
setSettings(prev => ({
...prev,
autoEnabled: checked,
// 비활성화 시 연동 부서 초기화
...(checked ? {} : { autoDepartments: [] }),
}));
};
// GPS 연동 부서 변경
const handleGpsDepartmentsChange = (values: string[]) => {
setSettings(prev => ({ ...prev, gpsDepartments: values }));
};
// 자동 출퇴근 연동 부서 변경
const handleAutoDepartmentsChange = (values: string[]) => {
setSettings(prev => ({ ...prev, autoDepartments: values }));
};
// 허용 반경 변경
const handleRadiusChange = (value: string) => {
setSettings(prev => ({ ...prev, allowedRadius: Number(value) as AllowedRadius }));
};
// 저장
const handleSave = () => {
// TODO: API 호출로 설정 저장
console.log('저장할 출퇴근 설정:', settings);
toast.success('출퇴근 설정이 저장되었습니다.');
};
// 부서 옵션 변환
const departmentOptions = departments.map(dept => ({
value: dept.id,
label: dept.name,
}));
// 선택된 부서 표시 텍스트
const getSelectedDeptText = (selectedIds: string[]) => {
if (selectedIds.length === 0) return '부서 선택';
if (selectedIds.length === departments.length) return '전체';
const firstDept = departments.find(d => d.id === selectedIds[0]);
if (selectedIds.length === 1) return firstDept?.name || '';
return `${firstDept?.name}${selectedIds.length - 1}`;
};
return (
<PageLayout>
<PageHeader
title="출퇴근관리"
description="출퇴근 방법을 관리합니다."
icon={MapPin}
/>
<div className="space-y-6">
{/* GPS 출퇴근 */}
<Card>
<CardHeader>
<CardTitle className="text-lg">GPS </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* GPS 출퇴근 사용 + 연동 부서 */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 min-w-[200px]">
<span className="text-sm font-medium text-muted-foreground">GPS </span>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={settings.gpsEnabled}
onCheckedChange={handleGpsToggle}
/>
<span className="text-sm">GPS </span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground"> </span>
<MultiSelectCombobox
options={departmentOptions}
value={settings.gpsDepartments}
onChange={handleGpsDepartmentsChange}
placeholder={getSelectedDeptText(settings.gpsDepartments)}
searchPlaceholder="부서 검색..."
emptyText="검색 결과가 없습니다."
disabled={!settings.gpsEnabled}
className="w-[200px]"
/>
</div>
</div>
{/* 출퇴근 허용 반경 */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 min-w-[200px]">
<span className="text-sm font-medium text-muted-foreground"> </span>
</div>
<Select
value={String(settings.allowedRadius)}
onValueChange={handleRadiusChange}
disabled={!settings.gpsEnabled}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALLOWED_RADIUS_OPTIONS.map(option => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 자동 출퇴근 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 min-w-[200px]">
<span className="text-sm font-medium text-muted-foreground"> </span>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={settings.autoEnabled}
onCheckedChange={handleAutoToggle}
/>
<span className="text-sm"> </span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground"> </span>
<MultiSelectCombobox
options={departmentOptions}
value={settings.autoDepartments}
onChange={handleAutoDepartmentsChange}
placeholder={getSelectedDeptText(settings.autoDepartments)}
searchPlaceholder="부서 검색..."
emptyText="검색 결과가 없습니다."
disabled={!settings.autoEnabled}
className="w-[200px]"
/>
</div>
</div>
</CardContent>
</Card>
{/* 저장 버튼 */}
<div className="flex justify-end">
<Button onClick={handleSave} size="lg">
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 안내 문구 */}
<div className="text-sm text-muted-foreground space-y-1">
<p> GPS .</p>
<p> GPS 출퇴근: 설정된 GPS .</p>
<p> 출퇴근: 정시 .</p>
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,55 @@
// 출퇴근 관리 설정 타입
export interface AttendanceSettings {
// GPS 출퇴근
gpsEnabled: boolean;
gpsDepartments: string[]; // 연동 부서 ID 배열
allowedRadius: AllowedRadius;
// 자동 출퇴근
autoEnabled: boolean;
autoDepartments: string[]; // 연동 부서 ID 배열
}
// 허용 반경 옵션
export type AllowedRadius = 50 | 100 | 300 | 500;
export const ALLOWED_RADIUS_OPTIONS: { value: AllowedRadius; label: string }[] = [
{ value: 50, label: '50M' },
{ value: 100, label: '100M' },
{ value: 300, label: '300M' },
{ value: 500, label: '500M' },
];
// 기본 설정값
export const DEFAULT_ATTENDANCE_SETTINGS: AttendanceSettings = {
gpsEnabled: false,
gpsDepartments: [],
allowedRadius: 100,
autoEnabled: false,
autoDepartments: [],
};
// 부서 타입 (임시 - API 연동 시 교체)
export interface Department {
id: string;
name: string;
}
// Mock 부서 데이터 (API 연동 전까지 사용)
export const MOCK_DEPARTMENTS: Department[] = [
{ id: '1', name: 'M사장님' },
{ id: '2', name: '부사장님' },
{ id: '3', name: '영업부' },
{ id: '4', name: '개발부' },
{ id: '5', name: '인사부' },
{ id: '6', name: '경영지원부' },
{ id: '7', name: '마케팅부' },
{ id: '8', name: '재무부' },
{ id: '9', name: '생산부' },
{ id: '10', name: '품질관리부' },
{ id: '11', name: '물류부' },
{ id: '12', name: '고객지원부' },
{ id: '13', name: '연구개발부' },
{ id: '14', name: '기획부' },
];

View File

@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface AddCompanyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddCompanyDialog({ open, onOpenChange }: AddCompanyDialogProps) {
const [businessNumber, setBusinessNumber] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Alert 상태
const [alertOpen, setAlertOpen] = useState(false);
const [alertMessage, setAlertMessage] = useState('');
// 숫자만 입력 가능 (10자리 제한)
const handleBusinessNumberChange = (value: string) => {
const numbersOnly = value.replace(/[^0-9]/g, '');
if (numbersOnly.length <= 10) {
setBusinessNumber(numbersOnly);
}
};
const handleCancel = () => {
setBusinessNumber('');
onOpenChange(false);
};
const handleNext = async () => {
if (businessNumber.length !== 10) {
setAlertMessage('사업자등록번호는 10자리를 입력해주세요.');
setAlertOpen(true);
return;
}
setIsLoading(true);
try {
// TODO: 바로빌 API 연동
// 1) 사업자등록번호 조회
// 2) 휴폐업 상태 확인
// 3) 기존 등록 여부 확인
// Mock 로직 - 실제로는 API 응답에 따라 처리
await new Promise(resolve => setTimeout(resolve, 1000));
// 케이스별 처리
const mockCase = Math.floor(Math.random() * 3);
if (mockCase === 0) {
// 휴폐업 상태
setAlertMessage('휴폐업 상태인 사업자입니다.');
} else if (mockCase === 1) {
// 이미 등록된 번호
setAlertMessage('등록된 사업자등록번호 입니다.');
} else {
// 신규 등록 가능 - 매니저에게 알림
setAlertMessage('매니저에게 회사 추가 신청 알림을 발송했습니다. 연락을 기다려주세요.');
setBusinessNumber('');
onOpenChange(false);
}
setAlertOpen(true);
} catch (error) {
setAlertMessage('사업자등록번호 조회 중 오류가 발생했습니다.');
setAlertOpen(true);
} finally {
setIsLoading(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="businessNumber"></Label>
<Input
id="businessNumber"
value={businessNumber}
onChange={(e) => handleBusinessNumberChange(e.target.value)}
placeholder="숫자 10자리 입력"
maxLength={10}
/>
<p className="text-xs text-muted-foreground">
, 10
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={isLoading}
>
</Button>
<Button
onClick={handleNext}
disabled={isLoading || businessNumber.length !== 10}
>
{isLoading ? '조회 중...' : '다음'}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Alert 다이얼로그 */}
<AlertDialog open={alertOpen} onOpenChange={setAlertOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>{alertMessage}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,488 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import { Building2, Plus, Save, Upload, X, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { AddCompanyDialog } from './AddCompanyDialog';
import type { CompanyFormData } from './types';
import { INITIAL_FORM_DATA, PAYMENT_DAY_OPTIONS } from './types';
// Mock 데이터 (실제로는 API에서 가져옴)
const MOCK_COMPANY_DATA: CompanyFormData = {
companyLogo: undefined,
companyName: '주식회사 샘플',
representativeName: '홍길동',
businessType: '서비스업',
businessCategory: 'IT',
zipCode: '01234',
address: '서울시 강남구 테헤란로',
addressDetail: '123번지 샘플빌딩 5층',
email: 'sample@company.com',
taxInvoiceEmail: 'tax@company.com',
managerName: '김담당',
managerPhone: '010-1234-5678',
businessLicense: 'abc.pdf',
businessNumber: '123-12-12345',
paymentBank: '신한은행',
paymentAccount: '123-1231-23-123',
paymentAccountHolder: '홍길동',
paymentDay: '25',
};
export function CompanyInfoManagement() {
const [isEditMode, setIsEditMode] = useState(false);
const [showAddDialog, setShowAddDialog] = useState(false);
const [formData, setFormData] = useState<CompanyFormData>(MOCK_COMPANY_DATA);
// 파일 input refs
const logoInputRef = useRef<HTMLInputElement>(null);
const licenseInputRef = useRef<HTMLInputElement>(null);
// 로고 파일명
const [logoFileName, setLogoFileName] = useState<string>('');
// 사업자등록증 파일명
const [licenseFileName, setLicenseFileName] = useState<string>(
typeof MOCK_COMPANY_DATA.businessLicense === 'string'
? MOCK_COMPANY_DATA.businessLicense
: ''
);
const handleChange = useCallback((field: keyof CompanyFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
const handleLogoUpload = () => {
logoInputRef.current?.click();
};
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 파일 크기 체크 (10MB)
if (file.size > 10 * 1024 * 1024) {
alert('파일 크기는 10MB 이하여야 합니다.');
return;
}
// 파일 타입 체크
if (!['image/png', 'image/jpeg', 'image/gif'].includes(file.type)) {
alert('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
return;
}
setFormData(prev => ({ ...prev, companyLogo: file }));
setLogoFileName(file.name);
}
};
const handleRemoveLogo = () => {
setFormData(prev => ({ ...prev, companyLogo: undefined }));
setLogoFileName('');
if (logoInputRef.current) {
logoInputRef.current.value = '';
}
};
const handleLicenseUpload = () => {
licenseInputRef.current?.click();
};
const handleLicenseChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setFormData(prev => ({ ...prev, businessLicense: file }));
setLicenseFileName(file.name);
}
};
const handleRemoveLicense = () => {
setFormData(prev => ({ ...prev, businessLicense: undefined }));
setLicenseFileName('');
if (licenseInputRef.current) {
licenseInputRef.current.value = '';
}
};
const handleAddressSearch = () => {
// TODO: 다음 주소 API 연동
console.log('주소 검색');
};
const handleSave = async () => {
// TODO: API 연동
console.log('저장:', formData);
setIsEditMode(false);
};
const handleCancel = () => {
setFormData(MOCK_COMPANY_DATA);
setIsEditMode(false);
};
// 헤더 액션 버튼
const headerActions = (
<div className="flex items-center gap-2">
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="w-4 h-4 mr-2" />
</Button>
{!isEditMode && (
<Button variant="outline" onClick={() => setIsEditMode(true)}>
</Button>
)}
</div>
);
return (
<PageLayout>
<PageHeader
title="회사정보"
description="회사 정보를 관리합니다"
icon={Building2}
actions={headerActions}
/>
<div className="space-y-6">
{/* 회사 정보 섹션 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 회사 로고 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-start gap-4">
<div className="w-[200px] h-[67px] border rounded-lg flex items-center justify-center bg-muted/50 overflow-hidden">
{logoFileName ? (
<span className="text-sm text-muted-foreground truncate px-2">
{logoFileName}
</span>
) : (
<span className="text-sm text-muted-foreground">IMG</span>
)}
</div>
{isEditMode && (
<div className="flex flex-col gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleLogoUpload}
>
<Upload className="w-4 h-4 mr-2" />
</Button>
{logoFileName && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRemoveLogo}
>
<X className="w-4 h-4 mr-2" />
</Button>
)}
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
750 X 250px, 10MB PNG, JPEG, GIF
</p>
<input
ref={logoInputRef}
type="file"
accept="image/png,image/jpeg,image/gif"
className="hidden"
onChange={handleLogoChange}
/>
</div>
{/* 회사명 / 대표자명 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="companyName"></Label>
<Input
id="companyName"
value={formData.companyName}
onChange={(e) => handleChange('companyName', e.target.value)}
placeholder="회사명"
disabled={!isEditMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="representativeName"></Label>
<Input
id="representativeName"
value={formData.representativeName}
onChange={(e) => handleChange('representativeName', e.target.value)}
placeholder="대표자명"
disabled={!isEditMode}
/>
</div>
</div>
{/* 업태 / 업종 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="businessType"></Label>
<Input
id="businessType"
value={formData.businessType}
onChange={(e) => handleChange('businessType', e.target.value)}
placeholder="업태명"
disabled={!isEditMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="businessCategory"></Label>
<Input
id="businessCategory"
value={formData.businessCategory}
onChange={(e) => handleChange('businessCategory', e.target.value)}
placeholder="업종명"
disabled={!isEditMode}
/>
</div>
</div>
{/* 주소 */}
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleAddressSearch}
disabled={!isEditMode}
>
</Button>
<Input
value={formData.zipCode ? `${formData.zipCode} ${formData.address}` : ''}
placeholder="주소명"
disabled
className="flex-1"
/>
</div>
<Input
value={formData.addressDetail}
onChange={(e) => handleChange('addressDetail', e.target.value)}
placeholder="상세주소"
disabled={!isEditMode}
/>
</div>
</div>
{/* 이메일 / 세금계산서 이메일 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="email"> ()</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="abc@email.com"
disabled={!isEditMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="taxInvoiceEmail"> </Label>
<Input
id="taxInvoiceEmail"
type="email"
value={formData.taxInvoiceEmail}
onChange={(e) => handleChange('taxInvoiceEmail', e.target.value)}
placeholder="abc@email.com"
disabled={!isEditMode}
/>
</div>
</div>
{/* 담당자명 / 담당자 연락처 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="managerName"></Label>
<Input
id="managerName"
value={formData.managerName}
onChange={(e) => handleChange('managerName', e.target.value)}
placeholder="담당자명"
disabled={!isEditMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="managerPhone"> </Label>
<Input
id="managerPhone"
value={formData.managerPhone}
onChange={(e) => handleChange('managerPhone', e.target.value)}
placeholder="010-1234-1234"
disabled={!isEditMode}
/>
</div>
</div>
{/* 사업자등록증 / 사업자등록번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleLicenseUpload}
disabled={!isEditMode}
>
</Button>
{licenseFileName && (
<div className="flex items-center gap-2">
<span className="text-sm">{licenseFileName}</span>
{isEditMode && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRemoveLicense}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
<input
ref={licenseInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={handleLicenseChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="businessNumber"></Label>
<Input
id="businessNumber"
value={formData.businessNumber}
onChange={(e) => handleChange('businessNumber', e.target.value)}
placeholder="123-12-12345"
disabled={!isEditMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 결제 계좌 정보 섹션 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 결제 은행 / 계좌 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="paymentBank"> </Label>
<Input
id="paymentBank"
value={formData.paymentBank}
onChange={(e) => handleChange('paymentBank', e.target.value)}
placeholder="은행명"
disabled={!isEditMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="paymentAccount"></Label>
<Input
id="paymentAccount"
value={formData.paymentAccount}
onChange={(e) => handleChange('paymentAccount', e.target.value)}
placeholder="123-1231-23-123"
disabled={!isEditMode}
/>
</div>
</div>
{/* 예금주 / 결제일 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="paymentAccountHolder"></Label>
<Input
id="paymentAccountHolder"
value={formData.paymentAccountHolder}
onChange={(e) => handleChange('paymentAccountHolder', e.target.value)}
placeholder="예금주명"
disabled={!isEditMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="paymentDay"></Label>
{isEditMode ? (
<Select
value={formData.paymentDay}
onValueChange={(value) => handleChange('paymentDay', value)}
>
<SelectTrigger>
<SelectValue placeholder="결제일 선택" />
</SelectTrigger>
<SelectContent>
{PAYMENT_DAY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="paymentDay"
value={
PAYMENT_DAY_OPTIONS.find(o => o.value === formData.paymentDay)?.label ||
formData.paymentDay
}
disabled
/>
)}
</div>
</div>
</CardContent>
</Card>
{/* 수정 모드 버튼 */}
{isEditMode && (
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={handleCancel}>
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSave}>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</div>
{/* 회사 추가 다이얼로그 */}
<AddCompanyDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,84 @@
// ===== 회사 정보 타입 =====
export interface CompanyInfo {
id: string;
// 회사 정보
companyLogo?: string;
companyName: string;
representativeName: string;
businessType: string; // 업태
businessCategory: string; // 업종
// 주소
zipCode: string;
address: string;
addressDetail: string;
// 연락처
email: string;
taxInvoiceEmail: string;
managerName: string;
managerPhone: string;
// 사업자 정보
businessLicense?: string; // 사업자등록증 파일
businessNumber: string; // 사업자등록번호
// 결제 계좌 정보
paymentBank: string;
paymentAccount: string;
paymentAccountHolder: string;
paymentDay: string;
// 메타
createdAt: string;
updatedAt: string;
}
export interface CompanyFormData {
companyLogo?: File | string;
companyName: string;
representativeName: string;
businessType: string;
businessCategory: string;
zipCode: string;
address: string;
addressDetail: string;
email: string;
taxInvoiceEmail: string;
managerName: string;
managerPhone: string;
businessLicense?: File | string;
businessNumber: string;
paymentBank: string;
paymentAccount: string;
paymentAccountHolder: string;
paymentDay: string;
}
// ===== 초기 폼 데이터 =====
export const INITIAL_FORM_DATA: CompanyFormData = {
companyLogo: undefined,
companyName: '',
representativeName: '',
businessType: '',
businessCategory: '',
zipCode: '',
address: '',
addressDetail: '',
email: '',
taxInvoiceEmail: '',
managerName: '',
managerPhone: '',
businessLicense: undefined,
businessNumber: '',
paymentBank: '',
paymentAccount: '',
paymentAccountHolder: '',
paymentDay: '',
};
// ===== 결제일 옵션 =====
export const PAYMENT_DAY_OPTIONS = [
{ value: '1', label: '매월 1일' },
{ value: '5', label: '매월 5일' },
{ value: '10', label: '매월 10일' },
{ value: '15', label: '매월 15일' },
{ value: '20', label: '매월 20일' },
{ value: '25', label: '매월 25일' },
{ value: 'last', label: '매월 말일' },
];

View File

@@ -0,0 +1,452 @@
'use client';
/**
* 알림설정 페이지
*
* 각 알림 유형별로 ON/OFF 토글과 이메일 알림 체크박스를 제공합니다.
*
* TODO: API 연동 시 작업 사항
* - GET /api/settings/notifications: 설정값 조회
* - PUT /api/settings/notifications: 설정값 저장
*/
import { useState } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Bell, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import type { NotificationSettings, NotificationItem } from './types';
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
// 알림 항목 컴포넌트
interface NotificationItemRowProps {
label: string;
item: NotificationItem;
onChange: (item: NotificationItem) => void;
disabled?: boolean;
}
function NotificationItemRow({ label, item, onChange, disabled }: NotificationItemRowProps) {
return (
<div className="flex items-center justify-between py-3 border-b last:border-b-0">
<div className="flex items-center gap-4 flex-1">
<span className="text-sm min-w-[160px]">{label}</span>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={item.email}
onCheckedChange={(checked) =>
onChange({ ...item, email: checked === true })
}
disabled={disabled || !item.enabled}
/>
<span className="text-sm text-muted-foreground"></span>
</label>
</div>
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
onChange({ ...item, enabled: checked, email: checked ? item.email : false })
}
disabled={disabled}
/>
</div>
);
}
// 알림 섹션 컴포넌트
interface NotificationSectionProps {
title: string;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
children: React.ReactNode;
}
function NotificationSection({ title, enabled, onEnabledChange, children }: NotificationSectionProps) {
console.log(`[NotificationSection] ${title} enabled:`, enabled);
return (
<Card>
<div className="flex items-center justify-between px-6 pt-6 pb-3">
<CardTitle className="text-base font-medium">{title}</CardTitle>
<Switch
checked={enabled}
onCheckedChange={(checked) => {
console.log(`[Switch] ${title} clicked:`, checked);
onEnabledChange(checked);
}}
/>
</div>
<CardContent className="pt-0">
<div className="pl-4">
{children}
</div>
</CardContent>
</Card>
);
}
export function NotificationSettingsManagement() {
const [settings, setSettings] = useState<NotificationSettings>(DEFAULT_NOTIFICATION_SETTINGS);
// 공지 알림 핸들러
const handleNoticeEnabledChange = (enabled: boolean) => {
setSettings(prev => ({
...prev,
notice: {
...prev.notice,
enabled,
...(enabled ? {} : {
notice: { ...prev.notice.notice, enabled: false, email: false },
event: { ...prev.notice.event, enabled: false, email: false },
}),
},
}));
};
const handleNoticeItemChange = (key: 'notice' | 'event', item: NotificationItem) => {
setSettings(prev => ({
...prev,
notice: { ...prev.notice, [key]: item },
}));
};
// 일정 알림 핸들러
const handleScheduleEnabledChange = (enabled: boolean) => {
setSettings(prev => ({
...prev,
schedule: {
...prev.schedule,
enabled,
...(enabled ? {} : {
vatReport: { ...prev.schedule.vatReport, enabled: false, email: false },
incomeTaxReport: { ...prev.schedule.incomeTaxReport, enabled: false, email: false },
}),
},
}));
};
const handleScheduleItemChange = (key: 'vatReport' | 'incomeTaxReport', item: NotificationItem) => {
setSettings(prev => ({
...prev,
schedule: { ...prev.schedule, [key]: item },
}));
};
// 거래처 알림 핸들러
const handleVendorEnabledChange = (enabled: boolean) => {
setSettings(prev => ({
...prev,
vendor: {
...prev.vendor,
enabled,
...(enabled ? {} : {
newVendor: { ...prev.vendor.newVendor, enabled: false, email: false },
creditRating: { ...prev.vendor.creditRating, enabled: false, email: false },
}),
},
}));
};
const handleVendorItemChange = (key: 'newVendor' | 'creditRating', item: NotificationItem) => {
setSettings(prev => ({
...prev,
vendor: { ...prev.vendor, [key]: item },
}));
};
// 근태 알림 핸들러
const handleAttendanceEnabledChange = (enabled: boolean) => {
setSettings(prev => ({
...prev,
attendance: {
...prev.attendance,
enabled,
...(enabled ? {} : {
annualLeave: { ...prev.attendance.annualLeave, enabled: false, email: false },
clockIn: { ...prev.attendance.clockIn, enabled: false, email: false },
late: { ...prev.attendance.late, enabled: false, email: false },
absent: { ...prev.attendance.absent, enabled: false, email: false },
}),
},
}));
};
const handleAttendanceItemChange = (
key: 'annualLeave' | 'clockIn' | 'late' | 'absent',
item: NotificationItem
) => {
setSettings(prev => ({
...prev,
attendance: { ...prev.attendance, [key]: item },
}));
};
// 수주/발주 알림 핸들러
const handleOrderEnabledChange = (enabled: boolean) => {
setSettings(prev => ({
...prev,
order: {
...prev.order,
enabled,
...(enabled ? {} : {
salesOrder: { ...prev.order.salesOrder, enabled: false, email: false },
purchaseOrder: { ...prev.order.purchaseOrder, enabled: false, email: false },
}),
},
}));
};
const handleOrderItemChange = (key: 'salesOrder' | 'purchaseOrder', item: NotificationItem) => {
setSettings(prev => ({
...prev,
order: { ...prev.order, [key]: item },
}));
};
// 전자결재 알림 핸들러
const handleApprovalEnabledChange = (enabled: boolean) => {
setSettings(prev => ({
...prev,
approval: {
...prev.approval,
enabled,
...(enabled ? {} : {
approvalRequest: { ...prev.approval.approvalRequest, enabled: false, email: false },
draftApproved: { ...prev.approval.draftApproved, enabled: false, email: false },
draftRejected: { ...prev.approval.draftRejected, enabled: false, email: false },
draftCompleted: { ...prev.approval.draftCompleted, enabled: false, email: false },
}),
},
}));
};
const handleApprovalItemChange = (
key: 'approvalRequest' | 'draftApproved' | 'draftRejected' | 'draftCompleted',
item: NotificationItem
) => {
setSettings(prev => ({
...prev,
approval: { ...prev.approval, [key]: item },
}));
};
// 생산 알림 핸들러
const handleProductionEnabledChange = (enabled: boolean) => {
setSettings(prev => ({
...prev,
production: {
...prev.production,
enabled,
...(enabled ? {} : {
safetyStock: { ...prev.production.safetyStock, enabled: false, email: false },
productionComplete: { ...prev.production.productionComplete, enabled: false, email: false },
}),
},
}));
};
const handleProductionItemChange = (
key: 'safetyStock' | 'productionComplete',
item: NotificationItem
) => {
setSettings(prev => ({
...prev,
production: { ...prev.production, [key]: item },
}));
};
// 저장
const handleSave = () => {
// TODO: API 호출로 설정 저장
console.log('저장할 알림 설정:', settings);
toast.success('알림 설정이 저장되었습니다.');
};
return (
<PageLayout>
<PageHeader
title="알림설정"
description="알림 설정을 관리합니다."
icon={Bell}
/>
<div className="space-y-4">
{/* 공지 알림 */}
<NotificationSection
title="공지 알림"
enabled={settings.notice.enabled}
onEnabledChange={handleNoticeEnabledChange}
>
<NotificationItemRow
label="공지사항 알림"
item={settings.notice.notice}
onChange={(item) => handleNoticeItemChange('notice', item)}
disabled={!settings.notice.enabled}
/>
<NotificationItemRow
label="이벤트 알림"
item={settings.notice.event}
onChange={(item) => handleNoticeItemChange('event', item)}
disabled={!settings.notice.enabled}
/>
</NotificationSection>
{/* 일정 알림 */}
<NotificationSection
title="일정 알림"
enabled={settings.schedule.enabled}
onEnabledChange={handleScheduleEnabledChange}
>
<NotificationItemRow
label="부가세 신고 알림"
item={settings.schedule.vatReport}
onChange={(item) => handleScheduleItemChange('vatReport', item)}
disabled={!settings.schedule.enabled}
/>
<NotificationItemRow
label="종합소득세 신고 알림"
item={settings.schedule.incomeTaxReport}
onChange={(item) => handleScheduleItemChange('incomeTaxReport', item)}
disabled={!settings.schedule.enabled}
/>
</NotificationSection>
{/* 거래처 알림 */}
<NotificationSection
title="거래처 알림"
enabled={settings.vendor.enabled}
onEnabledChange={handleVendorEnabledChange}
>
<NotificationItemRow
label="신규 업체 등록 알림"
item={settings.vendor.newVendor}
onChange={(item) => handleVendorItemChange('newVendor', item)}
disabled={!settings.vendor.enabled}
/>
<NotificationItemRow
label="신용등급 등록 알림"
item={settings.vendor.creditRating}
onChange={(item) => handleVendorItemChange('creditRating', item)}
disabled={!settings.vendor.enabled}
/>
</NotificationSection>
{/* 근태 알림 */}
<NotificationSection
title="근태 알림"
enabled={settings.attendance.enabled}
onEnabledChange={handleAttendanceEnabledChange}
>
<NotificationItemRow
label="연차 알림"
item={settings.attendance.annualLeave}
onChange={(item) => handleAttendanceItemChange('annualLeave', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="출근 알림"
item={settings.attendance.clockIn}
onChange={(item) => handleAttendanceItemChange('clockIn', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="지각 알림"
item={settings.attendance.late}
onChange={(item) => handleAttendanceItemChange('late', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="결근 알림"
item={settings.attendance.absent}
onChange={(item) => handleAttendanceItemChange('absent', item)}
disabled={!settings.attendance.enabled}
/>
</NotificationSection>
{/* 수주/발주 알림 */}
<NotificationSection
title="수주/발주 알림"
enabled={settings.order.enabled}
onEnabledChange={handleOrderEnabledChange}
>
<NotificationItemRow
label="수주 등록 알림"
item={settings.order.salesOrder}
onChange={(item) => handleOrderItemChange('salesOrder', item)}
disabled={!settings.order.enabled}
/>
<NotificationItemRow
label="발주 알림"
item={settings.order.purchaseOrder}
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
disabled={!settings.order.enabled}
/>
</NotificationSection>
{/* 전자결재 알림 */}
<NotificationSection
title="전자결재 알림"
enabled={settings.approval.enabled}
onEnabledChange={handleApprovalEnabledChange}
>
<NotificationItemRow
label="결재요청 알림"
item={settings.approval.approvalRequest}
onChange={(item) => handleApprovalItemChange('approvalRequest', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 승인 알림"
item={settings.approval.draftApproved}
onChange={(item) => handleApprovalItemChange('draftApproved', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 반려 알림"
item={settings.approval.draftRejected}
onChange={(item) => handleApprovalItemChange('draftRejected', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 완료 알림"
item={settings.approval.draftCompleted}
onChange={(item) => handleApprovalItemChange('draftCompleted', item)}
disabled={!settings.approval.enabled}
/>
</NotificationSection>
{/* 생산 알림 */}
<NotificationSection
title="생산 알림"
enabled={settings.production.enabled}
onEnabledChange={handleProductionEnabledChange}
>
<NotificationItemRow
label="안전재고 알림"
item={settings.production.safetyStock}
onChange={(item) => handleProductionItemChange('safetyStock', item)}
disabled={!settings.production.enabled}
/>
<NotificationItemRow
label="생산완료 알림"
item={settings.production.productionComplete}
onChange={(item) => handleProductionItemChange('productionComplete', item)}
disabled={!settings.production.enabled}
/>
</NotificationSection>
{/* 저장 버튼 */}
<div className="flex justify-end pt-4">
<Button onClick={handleSave} size="lg">
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,121 @@
/**
* 알림 설정 타입 정의
*/
// 개별 알림 항목 설정
export interface NotificationItem {
enabled: boolean;
email: boolean;
}
// 공지 알림 설정
export interface NoticeNotificationSettings {
enabled: boolean; // 섹션 전체 토글
notice: NotificationItem; // 공지사항 알림
event: NotificationItem; // 이벤트 알림
}
// 일정 알림 설정
export interface ScheduleNotificationSettings {
enabled: boolean;
vatReport: NotificationItem; // 부가세 신고 알림
incomeTaxReport: NotificationItem; // 종합소득세 신고 알림
}
// 거래처 알림 설정
export interface VendorNotificationSettings {
enabled: boolean;
newVendor: NotificationItem; // 신규 업체 등록 알림
creditRating: NotificationItem; // 신용등급 등록 알림
}
// 근태 알림 설정
export interface AttendanceNotificationSettings {
enabled: boolean;
annualLeave: NotificationItem; // 연차 알림
clockIn: NotificationItem; // 출근 알림
late: NotificationItem; // 지각 알림
absent: NotificationItem; // 결근 알림
}
// 수주/발주 알림 설정
export interface OrderNotificationSettings {
enabled: boolean;
salesOrder: NotificationItem; // 수주 등록 알림
purchaseOrder: NotificationItem; // 발주 알림
}
// 전자결재 알림 설정
export interface ApprovalNotificationSettings {
enabled: boolean;
approvalRequest: NotificationItem; // 결재요청 알림
draftApproved: NotificationItem; // 기안 > 승인 알림
draftRejected: NotificationItem; // 기안 > 반려 알림
draftCompleted: NotificationItem; // 기안 > 완료 알림
}
// 생산 알림 설정
export interface ProductionNotificationSettings {
enabled: boolean;
safetyStock: NotificationItem; // 안전재고 알림
productionComplete: NotificationItem; // 생산완료 알림
}
// 전체 알림 설정
export interface NotificationSettings {
notice: NoticeNotificationSettings;
schedule: ScheduleNotificationSettings;
vendor: VendorNotificationSettings;
attendance: AttendanceNotificationSettings;
order: OrderNotificationSettings;
approval: ApprovalNotificationSettings;
production: ProductionNotificationSettings;
}
// 기본값
export const DEFAULT_NOTIFICATION_ITEM: NotificationItem = {
enabled: false,
email: false,
};
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
notice: {
enabled: false,
notice: { enabled: false, email: false },
event: { enabled: true, email: false },
},
schedule: {
enabled: false,
vatReport: { enabled: false, email: false },
incomeTaxReport: { enabled: true, email: false },
},
vendor: {
enabled: false,
newVendor: { enabled: false, email: false },
creditRating: { enabled: true, email: false },
},
attendance: {
enabled: false,
annualLeave: { enabled: false, email: false },
clockIn: { enabled: true, email: false },
late: { enabled: false, email: false },
absent: { enabled: true, email: false },
},
order: {
enabled: false,
salesOrder: { enabled: false, email: false },
purchaseOrder: { enabled: true, email: false },
},
approval: {
enabled: false,
approvalRequest: { enabled: false, email: false },
draftApproved: { enabled: true, email: false },
draftRejected: { enabled: false, email: false },
draftCompleted: { enabled: true, email: false },
},
production: {
enabled: false,
safetyStock: { enabled: false, email: false },
productionComplete: { enabled: true, email: false },
},
};

View File

@@ -0,0 +1,281 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { format, subMonths } from 'date-fns';
import { Receipt, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
IntegratedListTemplateV2,
type TableColumn,
} from '@/components/templates/IntegratedListTemplateV2';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import type { PaymentHistory, SortOption } from './types';
import { SORT_OPTIONS } from './types';
// ===== Mock 데이터 생성 =====
const generateMockData = (): PaymentHistory[] => {
const baseDate = new Date('2025-12-01');
return Array.from({ length: 7 }, (_, i) => {
const paymentDate = subMonths(baseDate, i);
const periodStart = paymentDate;
const periodEnd = subMonths(paymentDate, -1);
// periodEnd에서 하루 전으로 설정 (예: 12-01 ~ 12-31)
periodEnd.setDate(periodEnd.getDate() - 1);
return {
id: `payment-${i + 1}`,
paymentDate: format(paymentDate, 'yyyy-MM-dd'),
subscriptionName: '프리미엄',
paymentMethod: '국민은행 1234',
subscriptionPeriod: {
start: format(periodStart, 'yyyy-MM-dd'),
end: format(periodEnd, 'yyyy-MM-dd'),
},
amount: 500000,
canViewInvoice: true,
createdAt: baseDate.toISOString(),
updatedAt: baseDate.toISOString(),
};
});
};
export function PaymentHistoryManagement() {
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// Mock 데이터
const [data] = useState<PaymentHistory[]>(() => generateMockData());
// 거래명세서 팝업 상태
const [showInvoiceDialog, setShowInvoiceDialog] = useState(false);
// ===== 필터링된 데이터 =====
const filteredData = useMemo(() => {
let result = data.filter(item =>
item.subscriptionName.includes(searchQuery) ||
item.paymentMethod.includes(searchQuery) ||
item.paymentDate.includes(searchQuery)
);
// 정렬
switch (sortOption) {
case 'latest':
result.sort((a, b) => new Date(b.paymentDate).getTime() - new Date(a.paymentDate).getTime());
break;
case 'oldest':
result.sort((a, b) => new Date(a.paymentDate).getTime() - new Date(b.paymentDate).getTime());
break;
case 'amountHigh':
result.sort((a, b) => b.amount - a.amount);
break;
case 'amountLow':
result.sort((a, b) => a.amount - b.amount);
break;
}
return result;
}, [data, searchQuery, sortOption]);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredData.slice(startIndex, startIndex + itemsPerPage);
}, [filteredData, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
// ===== 거래명세서 버튼 클릭 =====
const handleViewInvoice = useCallback(() => {
setShowInvoiceDialog(true);
}, []);
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'paymentDate', label: '결제일' },
{ key: 'subscriptionName', label: '구독명' },
{ key: 'paymentMethod', label: '결제 수단' },
{ key: 'subscriptionPeriod', label: '구독 기간' },
{ key: 'amount', label: '금액', className: 'text-right' },
{ key: 'invoice', label: '거래명세서', className: 'text-center' },
], []);
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: PaymentHistory, index: number) => {
const isLatest = index === 0; // 최신 항목 (초록색 버튼)
return (
<TableRow
key={item.id}
className="hover:bg-muted/50"
>
{/* 결제일 */}
<TableCell>{item.paymentDate}</TableCell>
{/* 구독명 */}
<TableCell>{item.subscriptionName}</TableCell>
{/* 결제 수단 */}
<TableCell>{item.paymentMethod}</TableCell>
{/* 구독 기간 */}
<TableCell>
{item.subscriptionPeriod.start} ~ {item.subscriptionPeriod.end}
</TableCell>
{/* 금액 */}
<TableCell className="text-right font-medium">
{item.amount.toLocaleString()}
</TableCell>
{/* 거래명세서 */}
<TableCell className="text-center">
<Button
size="sm"
variant={isLatest ? 'default' : 'secondary'}
className={isLatest ? 'bg-emerald-600 hover:bg-emerald-700' : ''}
onClick={handleViewInvoice}
>
<FileText className="h-4 w-4 mr-1" />
</Button>
</TableCell>
</TableRow>
);
}, [handleViewInvoice]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: PaymentHistory,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
const isLatest = globalIndex === 1;
return (
<ListMobileCard
id={item.id}
title={`${item.subscriptionName} - ${item.paymentDate}`}
isSelected={false}
onToggleSelection={() => {}}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="결제일" value={item.paymentDate} />
<InfoField label="구독명" value={item.subscriptionName} />
<InfoField label="결제 수단" value={item.paymentMethod} />
<InfoField label="금액" value={`${item.amount.toLocaleString()}`} />
<div className="col-span-2">
<InfoField
label="구독 기간"
value={`${item.subscriptionPeriod.start} ~ ${item.subscriptionPeriod.end}`}
/>
</div>
</div>
}
actions={
<Button
size="sm"
variant={isLatest ? 'default' : 'secondary'}
className={isLatest ? 'bg-emerald-600 hover:bg-emerald-700' : ''}
onClick={handleViewInvoice}
>
<FileText className="h-4 w-4 mr-1" />
</Button>
}
/>
);
}, [handleViewInvoice]);
// ===== 테이블 헤더 액션 (주석처리: 필요시 활성화) =====
// const tableHeaderActions = (
// <div className="flex items-center gap-2 flex-wrap">
// {/* 정렬 */}
// <Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
// <SelectTrigger className="w-[120px]">
// <SelectValue placeholder="정렬" />
// </SelectTrigger>
// <SelectContent>
// {SORT_OPTIONS.map((option) => (
// <SelectItem key={option.value} value={option.value}>
// {option.label}
// </SelectItem>
// ))}
// </SelectContent>
// </Select>
// </div>
// );
return (
<>
<IntegratedListTemplateV2
title="결제내역"
description="결제 내역을 확인합니다"
icon={Receipt}
hideSearch={true}
// ===== 검색/필터 (주석처리: 필요시 활성화) =====
// searchValue={searchQuery}
// onSearchChange={setSearchQuery}
// searchPlaceholder="구독명, 결제 수단, 결제일 검색..."
// tableHeaderActions={tableHeaderActions}
tableColumns={tableColumns}
data={paginatedData}
totalCount={filteredData.length}
allData={filteredData}
selectedItems={new Set()}
onToggleSelection={() => {}}
onToggleSelectAll={() => {}}
getItemId={(item: PaymentHistory) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
showCheckbox={false}
showRowNumber={false}
pagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* ===== 거래명세서 안내 다이얼로그 ===== */}
<Dialog open={showInvoiceDialog} onOpenChange={setShowInvoiceDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-500" />
</DialogTitle>
<DialogDescription className="text-left pt-2">
MES .
<br />
<br />
MES , .
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setShowInvoiceDialog(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,25 @@
// ===== 결제 내역 타입 =====
export interface PaymentHistory {
id: string;
paymentDate: string; // 결제일
subscriptionName: string; // 구독명
paymentMethod: string; // 결제 수단
subscriptionPeriod: { // 구독 기간
start: string;
end: string;
};
amount: number; // 금액
canViewInvoice: boolean; // 거래명세서 조회 가능 여부
createdAt: string;
updatedAt: string;
}
// ===== 정렬 옵션 =====
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
{ value: 'amountHigh', label: '금액 높은순' },
{ value: 'amountLow', label: '금액 낮은순' },
];

View File

@@ -0,0 +1,124 @@
'use client';
/**
* 팝업 상세 컴포넌트
*
* 디자인 스펙:
* - 페이지 타이틀: 팝업 상세
* - 읽기 전용 모드로 팝업 정보 표시
* - 버튼 위치: 카드 아래 별도 영역 (좌: 목록, 우: 삭제/수정)
*/
import { useRouter } from 'next/navigation';
import { Megaphone, ArrowLeft, Edit, Trash2 } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { type Popup } from './types';
interface PopupDetailProps {
popup: Popup;
onEdit: () => void;
onDelete: () => void;
}
export function PopupDetail({ popup, onEdit, onDelete }: PopupDetailProps) {
const router = useRouter();
const handleBack = () => {
router.push('/ko/settings/popup-management');
};
// 대상 표시 텍스트
const getTargetDisplay = () => {
if (popup.target === 'all') {
return '전사';
}
return popup.targetName || '부서별';
};
return (
<PageLayout>
<PageHeader
title="팝업관리 상세"
description="팝업 목록을 관리합니다"
icon={Megaphone}
/>
<div className="space-y-6">
{/* 팝업 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Badge variant={popup.status === 'active' ? 'default' : 'secondary'}>
{popup.status === 'active' ? '사용함' : '사용안함'}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{getTargetDisplay()}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{popup.author}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{popup.title}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">
<Badge variant={popup.status === 'active' ? 'default' : 'secondary'}>
{popup.status === 'active' ? '사용함' : '사용안함'}
</Badge>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{popup.startDate} ~ {popup.endDate}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{popup.createdAt}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">
<div
className="border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: popup.content }}
/>
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
<Button onClick={onEdit}>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
</PageLayout>
);
}
export default PopupDetail;

View File

@@ -0,0 +1,291 @@
'use client';
/**
* 팝업 등록/수정 폼 컴포넌트
*
* 디자인 스펙:
* - 페이지 타이틀: 팝업 상세
* - 필드: 대상(Select), 기간(DateRange), 제목(Input), 내용(RichTextEditor), 상태(Radio), 작성자, 등록일시
*/
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { Megaphone, ArrowLeft, Save } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { RichTextEditor } from '@/components/board/RichTextEditor';
import {
type Popup,
type PopupTarget,
type PopupStatus,
TARGET_OPTIONS,
STATUS_OPTIONS,
} from './types';
interface PopupFormProps {
mode: 'create' | 'edit';
initialData?: Popup;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
};
export function PopupForm({ mode, initialData }: PopupFormProps) {
const router = useRouter();
// ===== 폼 상태 =====
const [target, setTarget] = useState<PopupTarget>(initialData?.target || 'all');
const [title, setTitle] = useState(initialData?.title || '');
const [content, setContent] = useState(initialData?.content || '');
const [status, setStatus] = useState<PopupStatus>(initialData?.status || 'inactive');
const [startDate, setStartDate] = useState(
initialData?.startDate || format(new Date(), 'yyyy-MM-dd')
);
const [endDate, setEndDate] = useState(
initialData?.endDate || format(new Date(), 'yyyy-MM-dd')
);
// 유효성 에러
const [errors, setErrors] = useState<Record<string, string>>({});
// ===== 유효성 검사 =====
const validate = useCallback(() => {
const newErrors: Record<string, string> = {};
if (!target) {
newErrors.target = '대상을 선택해주세요.';
}
if (!title.trim()) {
newErrors.title = '제목을 입력해주세요.';
}
if (!content.trim() || content === '<p></p>') {
newErrors.content = '내용을 입력해주세요.';
}
if (!startDate) {
newErrors.startDate = '시작일을 선택해주세요.';
}
if (!endDate) {
newErrors.endDate = '종료일을 선택해주세요.';
}
if (startDate && endDate && startDate > endDate) {
newErrors.endDate = '종료일은 시작일 이후여야 합니다.';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [target, title, content, startDate, endDate]);
// ===== 저장 핸들러 =====
const handleSubmit = useCallback(() => {
if (!validate()) return;
const formData = {
target,
title,
content,
status,
startDate,
endDate,
};
console.log('Submit:', mode, formData);
// TODO: API 호출
// 목록으로 이동
router.push('/ko/settings/popup-management');
}, [target, title, content, status, startDate, endDate, mode, router, validate]);
// ===== 취소 핸들러 =====
const handleCancel = useCallback(() => {
router.back();
}, [router]);
return (
<PageLayout>
{/* 헤더 */}
<PageHeader
title={mode === 'create' ? '팝업관리 상세' : '팝업관리 상세'}
description="팝업 목록을 관리합니다"
icon={Megaphone}
/>
<div className="space-y-6">
{/* 폼 카드 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-1">
<span className="text-red-500">*</span>
</CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 대상 + 기간 (같은 줄) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 대상 선택 */}
<div className="space-y-2">
<Label htmlFor="target">
<span className="text-red-500">*</span>
</Label>
<Select value={target} onValueChange={(v) => setTarget(v as PopupTarget)}>
<SelectTrigger className={errors.target ? 'border-red-500' : ''}>
<SelectValue placeholder="대상을 선택해주세요" />
</SelectTrigger>
<SelectContent>
{TARGET_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.target && <p className="text-sm text-red-500">{errors.target}</p>}
</div>
{/* 기간 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className={errors.startDate ? 'border-red-500' : ''}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className={errors.endDate ? 'border-red-500' : ''}
/>
</div>
{(errors.startDate || errors.endDate) && (
<p className="text-sm text-red-500">{errors.startDate || errors.endDate}</p>
)}
</div>
</div>
{/* 제목 */}
<div className="space-y-2">
<Label htmlFor="title">
<span className="text-red-500">*</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력해주세요"
className={errors.title ? 'border-red-500' : ''}
/>
{errors.title && <p className="text-sm text-red-500">{errors.title}</p>}
</div>
{/* 내용 (에디터) */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="내용을 입력해주세요"
minHeight="200px"
className={errors.content ? 'border-red-500' : ''}
/>
{errors.content && <p className="text-sm text-red-500">{errors.content}</p>}
</div>
{/* 상태 + 작성자 (같은 줄) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<RadioGroup
value={status}
onValueChange={(v) => setStatus(v as PopupStatus)}
className="flex gap-4"
>
{STATUS_OPTIONS.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`status-${option.value}`} />
<Label
htmlFor={`status-${option.value}`}
className="font-normal cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
{/* 작성자 (읽기 전용) */}
<div className="space-y-2">
<Label></Label>
<Input
value={initialData?.author || CURRENT_USER.name}
disabled
className="bg-gray-50"
/>
</div>
</div>
{/* 등록일시 */}
<div className="space-y-2">
<Label></Label>
<Input
value={
initialData?.createdAt
? format(new Date(initialData.createdAt), 'yyyy-MM-dd HH:mm')
: format(new Date(), 'yyyy-MM-dd HH:mm')
}
disabled
className="bg-gray-50"
/>
</div>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button type="button" variant="outline" onClick={handleCancel}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button type="button" onClick={handleSubmit}>
<Save className="w-4 h-4 mr-2" />
{mode === 'create' ? '등록' : '저장'}
</Button>
</div>
</div>
</PageLayout>
);
}
export default PopupForm;

View File

@@ -0,0 +1,329 @@
'use client';
/**
* 팝업관리 리스트 컴포넌트
*
* 디자인 스펙:
* - 페이지 타이틀: 팝업관리
* - 페이지 설명: 팝업 목록을 관리합니다.
* - 테이블 컬럼: 체크박스, No, 대상, 제목, 상태, 작성자, 등록일, 기간, 작업
*/
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Megaphone, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableCell, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
IntegratedListTemplateV2,
type TableColumn,
type PaginationConfig,
} from '@/components/templates/IntegratedListTemplateV2';
import { type Popup, MOCK_POPUPS } from './types';
const ITEMS_PER_PAGE = 10;
export function PopupList() {
const router = useRouter();
// ===== 상태 관리 =====
const [popups] = useState<Popup[]>(MOCK_POPUPS);
const [searchValue, setSearchValue] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
// 삭제 확인 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// ===== 필터링된 데이터 =====
const filteredData = useMemo(() => {
let result = [...popups];
// 검색 필터
if (searchValue) {
const searchLower = searchValue.toLowerCase();
result = result.filter(
(item) =>
item.title.toLowerCase().includes(searchLower) ||
item.author.toLowerCase().includes(searchLower)
);
}
return result;
}, [popups, searchValue]);
// ===== 페이지네이션 =====
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return filteredData.slice(startIndex, startIndex + ITEMS_PER_PAGE);
}, [filteredData, currentPage]);
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
// ===== 핸들러 =====
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback((item: Popup) => {
router.push(`/ko/settings/popup-management/${item.id}`);
}, [router]);
const handleEdit = useCallback(
(id: string) => {
router.push(`/ko/settings/popup-management/${id}/edit`);
},
[router]
);
const handleDelete = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(() => {
if (deleteTargetId) {
console.log('Delete popup:', deleteTargetId);
// TODO: API 호출
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
}, [deleteTargetId]);
const handleBulkDelete = useCallback(() => {
console.log('Bulk delete:', Array.from(selectedItems));
// TODO: API 호출
setSelectedItems(new Set());
}, [selectedItems]);
const handleCreate = useCallback(() => {
router.push('/ko/settings/popup-management/new');
}, [router]);
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'target', label: '대상', className: 'w-[80px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[150px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'author', label: '작성자', className: 'w-[100px] text-center' },
{ key: 'createdAt', label: '등록일', className: 'w-[110px] text-center' },
{ key: 'period', label: '기간', className: 'w-[180px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[180px] text-center' },
];
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback(
(item: Popup, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(item.id)}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell className="text-center">
{item.target === 'all' ? '전사' : item.targetName || '부서별'}
</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell className="text-center">
<Badge variant={item.status === 'active' ? 'default' : 'secondary'}>
{item.status === 'active' ? '사용함' : '사용안함'}
</Badge>
</TableCell>
<TableCell className="text-center">{item.author}</TableCell>
<TableCell className="text-center">{item.createdAt}</TableCell>
<TableCell className="text-center">
{item.startDate}~{item.endDate}
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.id)}
title="수정"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.id)}
title="삭제"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[
selectedItems,
handleRowClick,
handleToggleSelection,
handleEdit,
handleDelete,
]
);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback(
(
item: Popup,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<Card
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">#{globalIndex}</span>
<Badge variant={item.status === 'active' ? 'default' : 'secondary'}>
{item.status === 'active' ? '사용함' : '사용안함'}
</Badge>
</div>
<h3 className="font-medium">{item.title}</h3>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{item.target === 'all' ? '전사' : item.targetName || '부서별'}</span>
<span>|</span>
<span>{item.author}</span>
<span>|</span>
<span>{item.createdAt}</span>
</div>
<div className="text-sm text-muted-foreground">
: {item.startDate} ~ {item.endDate}
</div>
{isSelected && (
<div className="flex gap-2 pt-2" onClick={(e) => e.stopPropagation()}>
<Button variant="outline" size="sm" onClick={() => handleEdit(item.id)}>
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive"
onClick={() => handleDelete(item.id)}
>
</Button>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
},
[handleRowClick, handleEdit, handleDelete]
);
// ===== 페이지네이션 설정 =====
const pagination: PaginationConfig = {
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage: ITEMS_PER_PAGE,
onPageChange: setCurrentPage,
};
return (
<>
<IntegratedListTemplateV2
title="팝업관리"
description="팝업 목록을 관리합니다."
icon={Megaphone}
headerActions={
<Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
}
searchValue={searchValue}
onSearchChange={setSearchValue}
searchPlaceholder="제목, 작성자로 검색..."
tableColumns={tableColumns}
data={paginatedData}
allData={filteredData}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
getItemId={(item) => item.id}
onBulkDelete={handleBulkDelete}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={pagination}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export default PopupList;

View File

@@ -0,0 +1,4 @@
export { PopupList } from './PopupList';
export { PopupForm } from './PopupForm';
export { PopupDetail } from './PopupDetail';
export * from './types';

View File

@@ -0,0 +1,136 @@
/**
* 팝업관리 타입 정의
*/
// 팝업 대상
export type PopupTarget = 'all' | 'department';
// 팝업 상태
export type PopupStatus = 'active' | 'inactive';
// 팝업 데이터 인터페이스
export interface Popup {
id: string;
target: PopupTarget;
targetName?: string; // 부서명 (대상이 department인 경우)
title: string;
content: string;
status: PopupStatus;
author: string;
authorId: string;
createdAt: string;
startDate: string;
endDate: string;
}
// 팝업 폼 데이터
export interface PopupFormData {
target: PopupTarget;
targetDepartmentId?: string;
title: string;
content: string;
status: PopupStatus;
startDate: string;
endDate: string;
}
// 대상 옵션
export const TARGET_OPTIONS = [
{ value: 'all', label: '전사' },
{ value: 'department', label: '부서별' },
] as const;
// 상태 옵션
export const STATUS_OPTIONS = [
{ value: 'inactive', label: '사용안함' },
{ value: 'active', label: '사용함' },
] as const;
// Mock 데이터
export const MOCK_POPUPS: Popup[] = [
{
id: '1',
target: 'all',
title: '휴가',
content: '<p>전사 휴가 안내입니다.</p>',
status: 'active',
author: '홍길동',
authorId: 'user1',
createdAt: '2025-09-03',
startDate: '2025-09-30',
endDate: '2025-12-10',
},
{
id: '2',
target: 'all',
title: '휴직',
content: '<p>휴직 관련 안내입니다.</p>',
status: 'active',
author: '홍길동',
authorId: 'user1',
createdAt: '2025-09-03',
startDate: '2025-09-30',
endDate: '2025-12-10',
},
{
id: '3',
target: 'department',
targetName: '부서별',
title: '휴가',
content: '<p>부서별 휴가 안내입니다.</p>',
status: 'inactive',
author: '홍길동',
authorId: 'user1',
createdAt: '2025-09-03',
startDate: '2025-09-30',
endDate: '2025-12-10',
},
{
id: '4',
target: 'all',
title: '휴직',
content: '<p>휴직 안내입니다.</p>',
status: 'inactive',
author: '홍길동',
authorId: 'user1',
createdAt: '2025-09-03',
startDate: '2025-09-30',
endDate: '2025-12-10',
},
{
id: '5',
target: 'all',
title: '휴직',
content: '<p>전사 휴직 공지입니다.</p>',
status: 'active',
author: '홍길동',
authorId: 'user1',
createdAt: '2025-09-03',
startDate: '2025-09-30',
endDate: '2025-12-10',
},
{
id: '6',
target: 'all',
title: '일정',
content: '<p>전사 일정 안내입니다.</p>',
status: 'active',
author: '홍길동',
authorId: 'user1',
createdAt: '2025-09-03',
startDate: '2025-09-30',
endDate: '2025-12-10',
},
{
id: '7',
target: 'all',
title: '휴가',
content: '<p>휴가 일정 안내입니다.</p>',
status: 'active',
author: '홍길동',
authorId: 'user1',
createdAt: '2025-09-03',
startDate: '2025-09-30',
endDate: '2025-12-10',
},
];

View File

@@ -0,0 +1,216 @@
'use client';
import { useState, useCallback } from 'react';
import { CreditCard, Download, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { SubscriptionInfo } from './types';
import { PLAN_LABELS } from './types';
// ===== Mock 데이터 =====
const mockSubscription: SubscriptionInfo = {
lastPaymentDate: '2025-12-01',
nextPaymentDate: '2025-12-01',
subscriptionAmount: 500000,
plan: 'premium',
userCount: 100,
userLimit: null, // 무제한
storageUsed: 5.5,
storageLimit: 10,
apiCallsUsed: 8500,
apiCallsLimit: 10000,
};
// ===== 날짜 포맷 함수 =====
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}${month}${day}`;
};
// ===== 금액 포맷 함수 =====
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
};
export function SubscriptionManagement() {
const [subscription] = useState<SubscriptionInfo>(mockSubscription);
const [showCancelDialog, setShowCancelDialog] = useState(false);
// ===== 자료 내보내기 =====
const handleExportData = useCallback(() => {
// TODO: 실제 자료 다운로드 처리
console.log('자료 내보내기');
}, []);
// ===== 서비스 해지 =====
const handleCancelService = useCallback(() => {
// TODO: 실제 서비스 해지 처리
console.log('서비스 해지 처리');
setShowCancelDialog(false);
}, []);
// ===== Progress 계산 =====
const storageProgress = (subscription.storageUsed / subscription.storageLimit) * 100;
const apiProgress = (subscription.apiCallsUsed / subscription.apiCallsLimit) * 100;
return (
<>
<PageLayout>
{/* ===== 페이지 헤더 ===== */}
<PageHeader
title="구독관리"
description="구독 정보를 관리합니다"
icon={CreditCard}
actions={
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleExportData}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
onClick={() => setShowCancelDialog(true)}
>
</Button>
</div>
}
/>
<div className="space-y-6">
{/* ===== 구독 정보 카드 영역 ===== */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 최근 결제일시 */}
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold">
{formatDate(subscription.lastPaymentDate)}
</div>
</CardContent>
</Card>
{/* 다음 결제일시 */}
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold">
{formatDate(subscription.nextPaymentDate)}
</div>
</CardContent>
</Card>
{/* 구독금액 */}
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold">
{formatCurrency(subscription.subscriptionAmount)}
</div>
</CardContent>
</Card>
</div>
{/* ===== 구독 정보 영역 ===== */}
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground mb-2"> </div>
{/* 플랜명 */}
<h3 className="text-xl font-bold mb-6">
{PLAN_LABELS[subscription.plan]}
</h3>
{/* 사용량 정보 */}
<div className="space-y-6">
{/* 사용자 수 */}
<div className="flex items-center gap-4">
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
</div>
<div className="flex-1">
<Progress value={subscription.userLimit ? (subscription.userCount / subscription.userLimit) * 100 : 30} className="h-2" />
</div>
<div className="text-sm text-blue-600 min-w-[100px] text-right">
{subscription.userCount} / {subscription.userLimit ? `${subscription.userLimit}` : '무제한'}
</div>
</div>
{/* 저장 공간 */}
<div className="flex items-center gap-4">
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
</div>
<div className="flex-1">
<Progress value={storageProgress} className="h-2" />
</div>
<div className="text-sm text-blue-600 min-w-[100px] text-right">
{subscription.storageUsed} TB /{subscription.storageLimit} TB
</div>
</div>
{/* AI API 호출 */}
<div className="flex items-center gap-4">
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
AI API
</div>
<div className="flex-1">
<Progress value={apiProgress} className="h-2" />
</div>
<div className="text-sm text-blue-600 min-w-[100px] text-right">
{subscription.apiCallsUsed.toLocaleString()} /{subscription.apiCallsLimit.toLocaleString()}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
{/* ===== 서비스 해지 확인 다이얼로그 ===== */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
</AlertDialogTitle>
<AlertDialogDescription className="text-left">
.
<br />
<span className="font-medium text-red-600">
?
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelService}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,31 @@
export interface SubscriptionInfo {
// 결제 정보
lastPaymentDate: string;
nextPaymentDate: string;
subscriptionAmount: number;
// 구독 플랜 정보
plan: 'free' | 'basic' | 'premium' | 'enterprise';
// 사용량 정보
userCount: number;
userLimit: number | null; // null = 무제한
storageUsed: number; // TB 단위
storageLimit: number; // TB 단위
apiCallsUsed: number;
apiCallsLimit: number;
}
export const PLAN_LABELS: Record<SubscriptionInfo['plan'], string> = {
free: '무료',
basic: '베이직',
premium: '프리미엄',
enterprise: '엔터프라이즈',
};
export const PLAN_COLORS: Record<SubscriptionInfo['plan'], string> = {
free: 'bg-gray-100 text-gray-800',
basic: 'bg-blue-100 text-blue-800',
premium: 'bg-purple-100 text-purple-800',
enterprise: 'bg-amber-100 text-amber-800',
};