- 모달 컴포넌트에서 Content 분리하여 재사용성 향상 - EstimateDocumentContent, DirectConstructionContent 등 - WorkLogContent, QuotePreviewContent, ReceivingReceiptContent - 파일 입력 공통 UI 컴포넌트 추가 - file-dropzone, file-input, file-list, image-upload - 폼 컴포넌트 코드 정리 및 중복 제거 (-4,056줄) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
|
import { Building2, Plus, Save, X, Loader2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
|
import { AccountNumberInput } from '@/components/ui/account-number-input';
|
|
import { ImageUpload } from '@/components/ui/image-upload';
|
|
import { FileInput } from '@/components/ui/file-input';
|
|
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';
|
|
import { getCompanyInfo, updateCompanyInfo, uploadCompanyLogo } from './actions';
|
|
import { toast } from 'sonner';
|
|
|
|
export function CompanyInfoManagement() {
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
const [formData, setFormData] = useState<CompanyFormData>(INITIAL_FORM_DATA);
|
|
const [originalData, setOriginalData] = useState<CompanyFormData>(INITIAL_FORM_DATA);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [tenantId, setTenantId] = useState<number | null>(null);
|
|
|
|
// 초기 데이터 로드
|
|
useEffect(() => {
|
|
const loadCompanyInfo = async () => {
|
|
setIsLoading(true);
|
|
const result = await getCompanyInfo();
|
|
|
|
if (result.success && result.data) {
|
|
const { tenantId: id, ...formDataWithoutId } = result.data;
|
|
setFormData(formDataWithoutId);
|
|
setOriginalData(formDataWithoutId);
|
|
setTenantId(id);
|
|
} else {
|
|
toast.error(result.error || '회사 정보를 불러오는데 실패했습니다.');
|
|
}
|
|
setIsLoading(false);
|
|
};
|
|
|
|
loadCompanyInfo();
|
|
}, []);
|
|
|
|
// 로고 업로드 상태
|
|
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
|
// 로고 미리보기 URL (로컬 파일 미리보기용)
|
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
|
// 사업자등록증 파일
|
|
const [licenseFile, setLicenseFile] = useState<File | null>(null);
|
|
|
|
// 로고 URL 계산 (서버 URL 또는 로컬 미리보기)
|
|
const currentLogoUrl = logoPreviewUrl || (
|
|
typeof formData.companyLogo === 'string' ? formData.companyLogo : null
|
|
);
|
|
|
|
const handleChange = useCallback((field: keyof CompanyFormData, value: string) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
}, []);
|
|
|
|
const handleLogoChange = async (file: File) => {
|
|
// 이전 이미지 저장 (롤백용)
|
|
const previousLogo = logoPreviewUrl;
|
|
|
|
// FileReader로 base64 미리보기 생성
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
setLogoPreviewUrl(event.target?.result as string);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// FormData 생성 (Server Action에 전달)
|
|
const uploadData = new FormData();
|
|
uploadData.append('logo', file);
|
|
|
|
// 즉시 업로드
|
|
setIsUploadingLogo(true);
|
|
const result = await uploadCompanyLogo(uploadData);
|
|
|
|
if (result.success && result.data) {
|
|
// 성공: 서버 URL도 저장 (다음 페이지 로드 시 사용)
|
|
setFormData(prev => ({ ...prev, companyLogo: result.data!.logoUrl }));
|
|
toast.success('로고가 업로드되었습니다.');
|
|
} else {
|
|
// 실패: 롤백
|
|
setLogoPreviewUrl(previousLogo);
|
|
toast.error(result.error || '로고 업로드에 실패했습니다.');
|
|
}
|
|
|
|
setIsUploadingLogo(false);
|
|
};
|
|
|
|
const handleRemoveLogo = () => {
|
|
setLogoPreviewUrl(null);
|
|
setFormData(prev => ({ ...prev, companyLogo: undefined }));
|
|
};
|
|
|
|
const handleLicenseChange = (file: File) => {
|
|
setFormData(prev => ({ ...prev, businessLicense: file }));
|
|
setLicenseFile(file);
|
|
};
|
|
|
|
const handleRemoveLicense = () => {
|
|
setFormData(prev => ({ ...prev, businessLicense: undefined }));
|
|
setLicenseFile(null);
|
|
};
|
|
|
|
// Daum 우편번호 서비스
|
|
const { openPostcode } = useDaumPostcode({
|
|
onComplete: (result) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
zipCode: result.zonecode,
|
|
address: result.address,
|
|
}));
|
|
},
|
|
});
|
|
|
|
const handleAddressSearch = () => {
|
|
openPostcode();
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!tenantId) {
|
|
toast.error('테넌트 정보를 찾을 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
const result = await updateCompanyInfo(tenantId, formData);
|
|
|
|
if (result.success) {
|
|
toast.success('회사 정보가 저장되었습니다.');
|
|
if (result.data) {
|
|
setFormData(result.data);
|
|
setOriginalData(result.data);
|
|
}
|
|
setIsEditMode(false);
|
|
} else {
|
|
toast.error(result.error || '저장에 실패했습니다.');
|
|
}
|
|
setIsSaving(false);
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setFormData(originalData);
|
|
setIsEditMode(false);
|
|
};
|
|
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title="회사정보"
|
|
description="회사 정보를 관리합니다"
|
|
icon={Building2}
|
|
/>
|
|
|
|
{/* 헤더 액션 버튼 */}
|
|
<div className="flex justify-end gap-2 mb-4">
|
|
<Button onClick={() => setShowAddDialog(true)} disabled={isLoading}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
회사 추가
|
|
</Button>
|
|
{!isEditMode && (
|
|
<Button variant="outline" onClick={() => setIsEditMode(true)} disabled={isLoading}>
|
|
수정
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<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="relative">
|
|
<ImageUpload
|
|
value={currentLogoUrl}
|
|
onChange={handleLogoChange}
|
|
onRemove={handleRemoveLogo}
|
|
disabled={!isEditMode || isUploadingLogo}
|
|
aspectRatio="wide"
|
|
size="lg"
|
|
maxSize={5}
|
|
hint="750 X 250px, 5MB 이하의 PNG, JPEG, GIF, WEBP"
|
|
/>
|
|
{/* 업로드 중 오버레이 */}
|
|
{isUploadingLogo && (
|
|
<div className="absolute inset-0 bg-background/80 flex items-center justify-center rounded-lg">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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>
|
|
<FileInput
|
|
value={licenseFile}
|
|
onFileSelect={handleLicenseChange}
|
|
onFileRemove={handleRemoveLicense}
|
|
accept=".pdf,.jpg,.jpeg,.png"
|
|
disabled={!isEditMode}
|
|
buttonText="찾기"
|
|
placeholder="파일을 선택하세요"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="businessNumber">사업자등록번호</Label>
|
|
<BusinessNumberInput
|
|
id="businessNumber"
|
|
value={formData.businessNumber}
|
|
onChange={(value) => handleChange('businessNumber', 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>
|
|
<AccountNumberInput
|
|
id="paymentAccount"
|
|
value={formData.paymentAccount}
|
|
onChange={(value) => handleChange('paymentAccount', value)}
|
|
placeholder="0000-0000-0000-0000"
|
|
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} disabled={isSaving}>
|
|
<X className="w-4 h-4 mr-2" />
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
저장 중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
저장
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 회사 추가 다이얼로그 */}
|
|
<AddCompanyDialog
|
|
open={showAddDialog}
|
|
onOpenChange={setShowAddDialog}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
} |