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,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: '매월 말일' },
];