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>
This commit is contained in:
@@ -1,12 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
User,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { User } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -14,6 +10,7 @@ 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 { ImageUpload } from '@/components/ui/image-upload';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -45,7 +42,6 @@ export function AccountInfoClient({
|
||||
error,
|
||||
}: AccountInfoClientProps) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [accountInfo] = useState<AccountInfo>(initialAccountInfo);
|
||||
@@ -74,23 +70,7 @@ export function AccountInfoClient({
|
||||
const canSuspend = accountInfo.isTenantMaster; // 테넌트 마스터인 경우만
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 체크 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 타입 체크
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
// 미리보기 생성 (낙관적 업데이트)
|
||||
const previousImage = profileImage;
|
||||
const reader = new FileReader();
|
||||
@@ -119,17 +99,11 @@ export function AccountInfoClient({
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setProfileImage(undefined);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = () => {
|
||||
@@ -287,53 +261,15 @@ export function AccountInfoClient({
|
||||
{/* 프로필 사진 */}
|
||||
<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"
|
||||
disabled={isUploadingImage}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingImage}
|
||||
>
|
||||
{isUploadingImage ? '업로드 중...' : '이미지 업로드'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
1250 X 250px, 10MB 이하의 PNG, JPEG, GIF
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ImageUpload
|
||||
value={profileImage}
|
||||
onChange={handleImageUpload}
|
||||
onRemove={handleRemoveImage}
|
||||
disabled={isUploadingImage}
|
||||
size="lg"
|
||||
maxSize={10}
|
||||
hint="1250 X 250px, 10MB 이하의 PNG, JPEG, GIF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 아이디 & 비밀번호 */}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { Building2, Plus, Save, Upload, X, Loader2 } from 'lucide-react';
|
||||
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,
|
||||
@@ -53,16 +55,12 @@ export function CompanyInfoManagement() {
|
||||
loadCompanyInfo();
|
||||
}, []);
|
||||
|
||||
// 파일 input refs
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const licenseInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 로고 업로드 상태
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||
// 로고 미리보기 URL (로컬 파일 미리보기용)
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||
// 사업자등록증 파일명
|
||||
const [licenseFileName, setLicenseFileName] = useState<string>('');
|
||||
// 사업자등록증 파일
|
||||
const [licenseFile, setLicenseFile] = useState<File | null>(null);
|
||||
|
||||
// 로고 URL 계산 (서버 URL 또는 로컬 미리보기)
|
||||
const currentLogoUrl = logoPreviewUrl || (
|
||||
@@ -73,29 +71,11 @@ export function CompanyInfoManagement() {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleLogoUpload = () => {
|
||||
logoInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 체크 (5MB - API 제한에 맞춤)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 5MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
// 파일 타입 체크
|
||||
if (!['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(file.type)) {
|
||||
toast.error('PNG, JPEG, GIF, WEBP 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleLogoChange = async (file: File) => {
|
||||
// 이전 이미지 저장 (롤백용)
|
||||
const previousLogo = logoPreviewUrl;
|
||||
|
||||
// FileReader로 base64 미리보기 생성 (account-info 방식)
|
||||
// FileReader로 base64 미리보기 생성
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setLogoPreviewUrl(event.target?.result as string);
|
||||
@@ -103,12 +83,12 @@ export function CompanyInfoManagement() {
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// FormData 생성 (Server Action에 전달)
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
const uploadData = new FormData();
|
||||
uploadData.append('logo', file);
|
||||
|
||||
// 즉시 업로드
|
||||
setIsUploadingLogo(true);
|
||||
const result = await uploadCompanyLogo(formData);
|
||||
const result = await uploadCompanyLogo(uploadData);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 성공: 서버 URL도 저장 (다음 페이지 로드 시 사용)
|
||||
@@ -121,37 +101,21 @@ export function CompanyInfoManagement() {
|
||||
}
|
||||
|
||||
setIsUploadingLogo(false);
|
||||
if (logoInputRef.current) {
|
||||
logoInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLogo = () => {
|
||||
setLogoPreviewUrl(null);
|
||||
setFormData(prev => ({ ...prev, companyLogo: undefined }));
|
||||
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 handleLicenseChange = (file: File) => {
|
||||
setFormData(prev => ({ ...prev, businessLicense: file }));
|
||||
setLicenseFile(file);
|
||||
};
|
||||
|
||||
const handleRemoveLicense = () => {
|
||||
setFormData(prev => ({ ...prev, businessLicense: undefined }));
|
||||
setLicenseFileName('');
|
||||
if (licenseInputRef.current) {
|
||||
licenseInputRef.current.value = '';
|
||||
}
|
||||
setLicenseFile(null);
|
||||
};
|
||||
|
||||
// Daum 우편번호 서비스
|
||||
@@ -227,69 +191,24 @@ export function CompanyInfoManagement() {
|
||||
{/* 회사 로고 */}
|
||||
<div className="space-y-2">
|
||||
<Label>회사 로고</Label>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative w-[200px] h-[67px] border rounded-lg flex items-center justify-center bg-muted/50 overflow-hidden">
|
||||
{currentLogoUrl ? (
|
||||
<img
|
||||
src={currentLogoUrl}
|
||||
alt="회사 로고"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">IMG</span>
|
||||
)}
|
||||
{/* 업로드 중 오버레이 */}
|
||||
{isUploadingLogo && (
|
||||
<div className="absolute inset-0 bg-background/80 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogoUpload}
|
||||
disabled={isUploadingLogo}
|
||||
>
|
||||
{isUploadingLogo ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
업로드
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{currentLogoUrl && !isUploadingLogo && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveLogo}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
750 X 250px, 5MB 이하의 PNG, JPEG, GIF, WEBP
|
||||
</p>
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 회사명 / 대표자명 */}
|
||||
@@ -423,37 +342,14 @@ export function CompanyInfoManagement() {
|
||||
<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"
|
||||
<FileInput
|
||||
value={licenseFile}
|
||||
onFileSelect={handleLicenseChange}
|
||||
onFileRemove={handleRemoveLicense}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
className="hidden"
|
||||
onChange={handleLicenseChange}
|
||||
disabled={!isEditMode}
|
||||
buttonText="찾기"
|
||||
placeholder="파일을 선택하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user