refactor(WEB): 레이아웃 및 설정 관리 개선

- AuthenticatedLayout: FCM 통합 및 레이아웃 개선
- logout: 로그아웃 시 FCM 토큰 정리 로직 추가
- AccountInfoManagement: 계정 정보 관리 UI 개선
- not-found 페이지 스타일 개선
- 환경변수 예시 파일 업데이트
This commit is contained in:
2025-12-30 17:23:01 +09:00
parent ec0ad53837
commit 5d0e453a68
9 changed files with 247 additions and 103 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
User,
@@ -28,89 +28,100 @@ 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 } from './actions';
import { withdrawAccount, suspendTenant, updateAgreements, uploadProfileImage } from './actions';
// ===== Mock 데이터 =====
const mockAccountInfo: AccountInfo = {
id: 'user-1',
email: 'abc@email.com',
profileImage: undefined,
role: '권한명',
status: 'active',
isTenantMaster: false, // true로 변경하면 사용중지 버튼 활성화
createdAt: '2025-12-12T12:12:00.000Z',
updatedAt: '2025-12-12T12:12:00.000Z',
};
// ===== Props 인터페이스 =====
interface AccountInfoClientProps {
initialAccountInfo: AccountInfo;
initialTermsAgreements: TermsAgreement[];
initialMarketingConsent: MarketingConsent;
error?: string;
}
const mockTermsAgreements: TermsAgreement[] = [
{
type: 'required',
label: '서비스 이용약관 동의',
agreed: true,
agreedAt: '2025-12-12 12:12',
},
{
type: 'required',
label: '개인정보 취급방침',
agreed: true,
agreedAt: '2025-12-12 12:12',
},
];
const mockMarketingConsent: MarketingConsent = {
email: {
agreed: true,
agreedAt: '2025-12-12 12:12',
},
sms: {
agreed: false,
withdrawnAt: '2025-12-12 12:12',
},
};
export function AccountInfoClient() {
export function AccountInfoClient({
initialAccountInfo,
initialTermsAgreements,
initialMarketingConsent,
error,
}: AccountInfoClientProps) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
// ===== 상태 관리 =====
const [accountInfo] = useState<AccountInfo>(mockAccountInfo);
const [termsAgreements] = useState<TermsAgreement[]>(mockTermsAgreements);
const [marketingConsent, setMarketingConsent] = useState<MarketingConsent>(mockMarketingConsent);
const [profileImage, setProfileImage] = useState<string | undefined>(accountInfo.profileImage);
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 = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 파일 크기 체크 (10MB)
if (file.size > 10 * 1024 * 1024) {
alert('파일 크기는 10MB 이하여야 합니다.');
return;
}
if (!file) return;
// 파일 타입 체크
const validTypes = ['image/png', 'image/jpeg', 'image/gif'];
if (!validTypes.includes(file.type)) {
alert('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
return;
}
// 파일 크기 체크 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('파일 크기는 10MB 이하여야 합니다.');
return;
}
// 미리보기 생성
const reader = new FileReader();
reader.onload = (event) => {
setProfileImage(event.target?.result as string);
};
reader.readAsDataURL(file);
// 파일 타입 체크
const validTypes = ['image/png', 'image/jpeg', 'image/gif'];
if (!validTypes.includes(file.type)) {
toast.error('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
return;
}
// 미리보기 생성 (낙관적 업데이트)
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);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
@@ -131,12 +142,18 @@ export function AccountInfoClient() {
};
const handleConfirmWithdraw = async () => {
if (!withdrawPassword) {
toast.error('비밀번호를 입력해주세요.');
return;
}
setIsWithdrawing(true);
try {
const result = await withdrawAccount();
const result = await withdrawAccount(withdrawPassword);
if (result.success) {
toast.success('계정이 탈퇴되었습니다.');
setShowWithdrawDialog(false);
setWithdrawPassword('');
// 로그아웃 및 로그인 페이지로 이동
router.push('/ko/login');
} else {
@@ -175,7 +192,7 @@ export function AccountInfoClient() {
router.push('/ko/settings/account-info?mode=edit');
};
const handleMarketingChange = (type: 'email' | 'sms', checked: boolean) => {
const handleMarketingChange = async (type: 'email' | 'sms', checked: boolean) => {
const now = new Date().toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
@@ -184,6 +201,7 @@ export function AccountInfoClient() {
minute: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
// 낙관적 업데이트 (UI 먼저 변경)
setMarketingConsent(prev => ({
...prev,
[type]: {
@@ -191,6 +209,37 @@ export function AccountInfoClient() {
...(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);
}
};
// ===== 헤더 액션 버튼 =====
@@ -269,14 +318,16 @@ export function AccountInfoClient() {
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
@@ -371,6 +422,7 @@ export function AccountInfoClient() {
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">
.
@@ -388,6 +440,7 @@ export function AccountInfoClient() {
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 .
@@ -408,16 +461,34 @@ export function AccountInfoClient() {
</PageLayout>
{/* 탈퇴 확인 다이얼로그 */}
<AlertDialog open={showWithdrawDialog} onOpenChange={setShowWithdrawDialog}>
<AlertDialog open={showWithdrawDialog} onOpenChange={(open) => {
setShowWithdrawDialog(open);
if (!open) setWithdrawPassword('');
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
, SAM .
</span>
<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>
@@ -425,7 +496,7 @@ export function AccountInfoClient() {
<AlertDialogAction
onClick={handleConfirmWithdraw}
className="bg-red-600 hover:bg-red-700"
disabled={isWithdrawing}
disabled={isWithdrawing || !withdrawPassword}
>
{isWithdrawing ? '처리 중...' : '확인'}
</AlertDialogAction>