Files
sam-react-prod/src/components/settings/AccountInfoManagement/index.tsx
유병철 269b901e64 refactor: UI 컴포넌트 추상화 및 입금/출금 등록 버튼 추가
- 입금관리, 출금관리 리스트에 등록 버튼 추가
- skeleton, confirm-dialog, empty-state, status-badge UI 컴포넌트 추가
- document-system 컴포넌트 추상화 (ApprovalLine, DocumentHeader 등)
- 여러 페이지 컴포넌트 리팩토링 및 코드 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:21:42 +09:00

465 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { User } from 'lucide-react';
import { toast } from 'sonner';
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 { ImageUpload } from '@/components/ui/image-upload';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
import { ACCOUNT_STATUS_LABELS } from './types';
import { withdrawAccount, suspendTenant, updateAgreements, uploadProfileImage } from './actions';
// ===== Props 인터페이스 =====
interface AccountInfoClientProps {
initialAccountInfo: AccountInfo;
initialTermsAgreements: TermsAgreement[];
initialMarketingConsent: MarketingConsent;
error?: string;
}
export function AccountInfoClient({
initialAccountInfo,
initialTermsAgreements,
initialMarketingConsent,
error,
}: AccountInfoClientProps) {
const router = useRouter();
// ===== 상태 관리 =====
const [accountInfo] = useState<AccountInfo>(initialAccountInfo);
const [termsAgreements] = useState<TermsAgreement[]>(initialTermsAgreements);
const [marketingConsent, setMarketingConsent] = useState<MarketingConsent>(initialMarketingConsent);
const [profileImage, setProfileImage] = useState<string | undefined>(initialAccountInfo.profileImage);
const [isSavingMarketing, setIsSavingMarketing] = useState(false);
const [isUploadingImage, setIsUploadingImage] = useState(false);
// 다이얼로그 상태
const [showWithdrawDialog, setShowWithdrawDialog] = useState(false);
const [showSuspendDialog, setShowSuspendDialog] = useState(false);
const [isWithdrawing, setIsWithdrawing] = useState(false);
const [isSuspending, setIsSuspending] = useState(false);
const [withdrawPassword, setWithdrawPassword] = useState('');
// 에러 표시
useEffect(() => {
if (error) {
toast.error(error);
}
}, [error]);
// ===== 버튼 활성화 조건 =====
const canWithdraw = !accountInfo.isTenantMaster; // 테넌트 마스터가 아닌 경우만
const canSuspend = accountInfo.isTenantMaster; // 테넌트 마스터인 경우만
// ===== 핸들러 =====
const handleImageUpload = async (file: File) => {
// 미리보기 생성 (낙관적 업데이트)
const previousImage = profileImage;
const reader = new FileReader();
reader.onload = (event) => {
setProfileImage(event.target?.result as string);
};
reader.readAsDataURL(file);
// API 호출
setIsUploadingImage(true);
try {
const formData = new FormData();
formData.append('file', file);
const result = await uploadProfileImage(formData);
if (result.success) {
toast.success('프로필 이미지가 업로드되었습니다.');
} else {
// 실패 시 롤백
setProfileImage(previousImage);
toast.error(result.error || '이미지 업로드에 실패했습니다.');
}
} catch {
// 에러 시 롤백
setProfileImage(previousImage);
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsUploadingImage(false);
}
};
const handleRemoveImage = () => {
setProfileImage(undefined);
};
const handlePasswordChange = () => {
// 비밀번호 설정 화면으로 이동
router.push('/ko/settings/account-info/change-password');
};
const handleWithdraw = () => {
setShowWithdrawDialog(true);
};
const handleConfirmWithdraw = async () => {
if (!withdrawPassword) {
toast.error('비밀번호를 입력해주세요.');
return;
}
setIsWithdrawing(true);
try {
const result = await withdrawAccount(withdrawPassword);
if (result.success) {
toast.success('계정이 탈퇴되었습니다.');
setShowWithdrawDialog(false);
setWithdrawPassword('');
// 로그아웃 및 로그인 페이지로 이동
router.push('/ko/login');
} else {
toast.error(result.error || '계정 탈퇴에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsWithdrawing(false);
}
};
const handleSuspend = () => {
setShowSuspendDialog(true);
};
const handleConfirmSuspend = async () => {
setIsSuspending(true);
try {
const result = await suspendTenant();
if (result.success) {
toast.success('테넌트 사용이 중지되었습니다.');
setShowSuspendDialog(false);
} else {
toast.error(result.error || '사용 중지에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSuspending(false);
}
};
const handleEdit = () => {
// 수정 모드로 전환 또는 별도 수정 페이지로 이동
router.push('/ko/settings/account-info?mode=edit');
};
const handleMarketingChange = async (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('.', '');
// 낙관적 업데이트 (UI 먼저 변경)
setMarketingConsent(prev => ({
...prev,
[type]: {
agreed: checked,
...(checked ? { agreedAt: now } : { withdrawnAt: now }),
},
}));
// API 호출
setIsSavingMarketing(true);
try {
const result = await updateAgreements([{ type, agreed: checked }]);
if (result.success) {
toast.success(checked ? '수신 동의되었습니다.' : '수신 동의가 철회되었습니다.');
} else {
// 실패 시 롤백
setMarketingConsent(prev => ({
...prev,
[type]: {
agreed: !checked,
...(checked ? { withdrawnAt: now } : { agreedAt: now }),
},
}));
toast.error(result.error || '변경에 실패했습니다.');
}
} catch {
// 에러 시 롤백
setMarketingConsent(prev => ({
...prev,
[type]: {
agreed: !checked,
...(checked ? { withdrawnAt: now } : { agreedAt: now }),
},
}));
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSavingMarketing(false);
}
};
// ===== 헤더 액션 버튼 =====
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>
<ImageUpload
value={profileImage}
onChange={handleImageUpload}
onRemove={handleRemoveImage}
disabled={isUploadingImage}
size="lg"
maxSize={10}
hint="1250 X 250px, 10MB 이하의 PNG, JPEG, GIF"
/>
</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)}
disabled={isSavingMarketing}
/>
<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)}
disabled={isSavingMarketing}
/>
<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={(open) => {
setShowWithdrawDialog(open);
if (!open) setWithdrawPassword('');
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p>
?
<br />
<span className="text-muted-foreground text-sm">
, SAM .
</span>
</p>
<div className="space-y-2">
<Label htmlFor="withdraw-password"> </Label>
<Input
id="withdraw-password"
type="password"
placeholder="비밀번호를 입력하세요"
value={withdrawPassword}
onChange={(e) => setWithdrawPassword(e.target.value)}
disabled={isWithdrawing}
/>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWithdrawing}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmWithdraw}
className="bg-red-600 hover:bg-red-700"
disabled={isWithdrawing || !withdrawPassword}
>
{isWithdrawing ? '처리 중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 사용중지 확인 다이얼로그 */}
<ConfirmDialog
open={showSuspendDialog}
onOpenChange={setShowSuspendDialog}
onConfirm={handleConfirmSuspend}
title="계정 사용중지"
description={
<>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
variant="warning"
loading={isSuspending}
/>
</>
);
}