Files
sam-react-prod/src/components/settings/CompanyInfoManagement/index.tsx
유병철 9464a368ba refactor: 모달 Content 컴포넌트 분리 및 파일 입력 UI 공통화
- 모달 컴포넌트에서 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>
2026-01-22 15:07:17 +09:00

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>
);
}