fix(WEB): 프로필 이미지 업로드 및 회사 로고 기능 수정

AccountInfoManagement:
- toAbsoluteUrl() 함수 추가 (상대경로 → 절대 URL 변환)
- getAccountInfo()에서 /api/v1/profiles/me 조회 추가 (이미지 새로고침 후 유지)
- uploadProfileImage() 구현 (2단계: 파일 업로드 → 프로필 업데이트)
- updateAgreements() 구현 (약관 동의 수정)
- withdrawAccount()에 password 파라미터 추가

CompanyInfoManagement:
- toAbsoluteUrl() 함수 추가 (로고 이미지 경로 변환)

fetch-wrapper:
- FormData 전송 시 Content-Type 헤더 제외 (브라우저 자동 설정)
This commit is contained in:
2025-12-30 22:54:24 +09:00
parent c885844a3a
commit d4e64c290c
4 changed files with 396 additions and 36 deletions

View File

@@ -3,6 +3,24 @@
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
/**
* 상대 경로를 절대 URL로 변환
* /storage/... 또는 1/temp/... → https://api.example.com/storage/tenants/...
*/
function toAbsoluteUrl(path: string | undefined): string | undefined {
if (!path) return undefined;
// 이미 절대 URL이면 그대로 반환
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
// /storage/로 시작하면 그대로, 아니면 /storage/tenants/ 붙이기
if (path.startsWith('/storage/')) {
return `${apiUrl}${path}`;
}
return `${apiUrl}/storage/tenants/${path}`;
}
// ===== 계정 정보 조회 =====
export async function getAccountInfo(): Promise<{
success: boolean;
@@ -15,6 +33,7 @@ export async function getAccountInfo(): Promise<{
__authError?: boolean;
}> {
try {
// 1. 사용자 기본 정보 조회
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/me`,
{
@@ -48,13 +67,35 @@ export async function getAccountInfo(): Promise<{
const user = result.data;
// 2. 프로필 정보 조회 (프로필 이미지 포함)
let profileImage: string | undefined;
try {
const { response: profileResponse } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/profiles/me`,
{
method: 'GET',
}
);
if (profileResponse?.ok) {
const profileResult = await profileResponse.json();
if (profileResult.success && profileResult.data) {
// profile_photo_path 필드에서 이미지 경로 가져오기
profileImage = toAbsoluteUrl(profileResult.data.profile_photo_path);
}
}
} catch {
// 프로필 조회 실패해도 계속 진행
console.warn('[getAccountInfo] Failed to fetch profile image');
}
return {
success: true,
data: {
accountInfo: {
id: user.id?.toString() || '',
email: user.email || '',
profileImage: user.profile_image || undefined,
profileImage, // 프로필 API에서 가져온 이미지
role: user.role?.name || user.role || '',
status: user.status || 'active',
isTenantMaster: user.is_tenant_master || false,
@@ -78,7 +119,7 @@ export async function getAccountInfo(): Promise<{
}
// ===== 계정 탈퇴 =====
export async function withdrawAccount(): Promise<{
export async function withdrawAccount(password: string): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
@@ -88,7 +129,7 @@ export async function withdrawAccount(): Promise<{
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/withdraw`,
{
method: 'POST',
body: JSON.stringify({}),
body: JSON.stringify({ password }),
}
);
@@ -174,3 +215,171 @@ export async function suspendTenant(): Promise<{
};
}
}
// ===== 약관 동의 수정 =====
export async function updateAgreements(
agreements: Array<{ type: string; agreed: boolean }>
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/account/agreements`,
{
method: 'PUT',
body: JSON.stringify({ agreements }),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '약관 동의 수정에 실패했습니다.',
};
}
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '약관 동의 수정에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
console.error('[AccountInfoActions] updateAgreements error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 프로필 이미지 업로드 =====
export async function uploadProfileImage(formData: FormData): Promise<{
success: boolean;
data?: { imageUrl: string };
error?: string;
__authError?: boolean;
}> {
try {
console.log('[uploadProfileImage] Starting upload...');
// 1. 먼저 파일 업로드 (일반 파일 업로드 엔드포인트 사용)
const { response: uploadResponse, error: uploadError } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/upload`,
{
method: 'POST',
body: formData,
}
);
console.log('[uploadProfileImage] Upload response status:', uploadResponse?.status);
if (uploadError) {
console.error('[uploadProfileImage] Upload error:', uploadError);
return {
success: false,
error: uploadError.message,
__authError: uploadError.code === 'UNAUTHORIZED',
};
}
if (!uploadResponse) {
console.error('[uploadProfileImage] No upload response');
return {
success: false,
error: '파일 업로드에 실패했습니다.',
};
}
const uploadResult = await uploadResponse.json();
console.log('[uploadProfileImage] Upload result:', JSON.stringify(uploadResult, null, 2));
if (!uploadResponse.ok || !uploadResult.success) {
return {
success: false,
error: uploadResult.message || '파일 업로드에 실패했습니다.',
};
}
// 업로드된 파일 경로 추출 (API 응답: file_path 필드)
const uploadedPath = uploadResult.data?.file_path || uploadResult.data?.path || uploadResult.data?.url;
console.log('[uploadProfileImage] Uploaded path:', uploadedPath);
if (!uploadedPath) {
console.error('[uploadProfileImage] No file path in response. Full data:', uploadResult.data);
return {
success: false,
error: '업로드된 파일 경로를 가져올 수 없습니다.',
};
}
// 2. 프로필 업데이트 (업로드된 파일 경로로)
console.log('[uploadProfileImage] Updating profile with path:', uploadedPath);
const { response: updateResponse, error: updateError } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/profiles/me`,
{
method: 'PATCH',
body: JSON.stringify({ profile_photo_path: uploadedPath }),
}
);
if (updateError) {
console.error('[uploadProfileImage] Profile update error:', updateError);
return {
success: false,
error: updateError.message,
__authError: updateError.code === 'UNAUTHORIZED',
};
}
if (!updateResponse) {
console.error('[uploadProfileImage] No profile update response');
return {
success: false,
error: '프로필 업데이트에 실패했습니다.',
};
}
const updateResult = await updateResponse.json();
console.log('[uploadProfileImage] Profile update result:', JSON.stringify(updateResult, null, 2));
if (!updateResponse.ok || !updateResult.success) {
return {
success: false,
error: updateResult.message || '프로필 업데이트에 실패했습니다.',
};
}
// /storage/tenants/ 경로로 변환 (tenant disk 파일은 이 경로로 접근 가능)
const storagePath = uploadedPath.startsWith('/storage/')
? uploadedPath
: `/storage/tenants/${uploadedPath}`;
return {
success: true,
data: {
imageUrl: toAbsoluteUrl(storagePath) || '',
},
};
} catch (error) {
console.error('[AccountInfoActions] uploadProfileImage error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -16,6 +16,7 @@ interface TenantApiData {
ceo_name?: string;
homepage?: string;
fax?: string;
logo?: string;
options?: {
business_type?: string;
business_category?: string;
@@ -127,6 +128,21 @@ export async function updateCompanyInfo(
}
}
/**
* 상대 경로를 절대 URL로 변환
* /storage/... → https://api.example.com/storage/...
*/
function toAbsoluteUrl(path: string | undefined): string | undefined {
if (!path) return undefined;
// 이미 절대 URL이면 그대로 반환
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 상대 경로면 API URL 붙이기
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${apiUrl}${path}`;
}
/**
* API 응답 → Frontend 변환
*
@@ -146,8 +162,8 @@ function transformApiToFrontend(apiData: TenantApiData): CompanyFormData & { ten
managerPhone: apiData.phone || '',
businessNumber: apiData.business_num || '',
address: apiData.address || '',
// 확장 필드 (options에서 읽어옴)
companyLogo: undefined,
// 로고 URL (상대 경로 → 절대 URL 변환)
companyLogo: toAbsoluteUrl(apiData.logo),
businessType: opts.business_type || '',
businessCategory: opts.business_category || '',
zipCode: opts.zip_code || '',
@@ -172,6 +188,15 @@ function transformFrontendToApi(
tenantId: number,
data: Partial<CompanyFormData>
): Record<string, unknown> {
// 로고 URL에서 상대 경로 추출 (API는 상대 경로 기대)
let logoPath: string | null = null;
if (data.companyLogo) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
logoPath = data.companyLogo.startsWith(apiUrl)
? data.companyLogo.replace(apiUrl, '')
: data.companyLogo;
}
return {
tenant_id: tenantId,
company_name: data.companyName,
@@ -179,6 +204,8 @@ function transformFrontendToApi(
email: data.email,
phone: data.managerPhone,
business_num: data.businessNumber,
// 로고 (삭제 시 null)
logo: logoPath,
// address: 우편번호 + 주소 + 상세주소 결합
address: [data.zipCode, data.address, data.addressDetail]
.filter(Boolean)
@@ -197,4 +224,54 @@ function transformFrontendToApi(
payment_day: data.paymentDay,
},
};
}
/**
* 회사 로고 업로드
* @param formData - FormData (클라이언트에서 생성, 'logo' 키로 파일 포함)
*/
export async function uploadCompanyLogo(formData: FormData): Promise<{
success: boolean;
data?: { logoUrl: string };
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants/logo`,
{
method: 'POST',
body: formData,
// FormData는 Content-Type을 자동으로 설정하므로 headers 제거
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '로고 업로드에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '로고 업로드에 실패했습니다.' };
}
return {
success: true,
data: {
logoUrl: toAbsoluteUrl(result.data?.logo_url) || '',
},
};
} catch (error) {
console.error('[uploadCompanyLogo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -2,7 +2,7 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { Building2, Plus, Save, Upload, X, Search, Loader2 } from 'lucide-react';
import { Building2, Plus, Save, Upload, X, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -19,7 +19,7 @@ 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 } from './actions';
import { getCompanyInfo, updateCompanyInfo, uploadCompanyLogo } from './actions';
import { toast } from 'sonner';
export function CompanyInfoManagement() {
@@ -55,11 +55,18 @@ export function CompanyInfoManagement() {
const logoInputRef = useRef<HTMLInputElement>(null);
const licenseInputRef = useRef<HTMLInputElement>(null);
// 로고 파일명
const [logoFileName, setLogoFileName] = useState<string>('');
// 로고 업로드 상태
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
// 로고 미리보기 URL (로컬 파일 미리보기용)
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
// 사업자등록증 파일명
const [licenseFileName, setLicenseFileName] = useState<string>('');
// 로고 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 }));
}, []);
@@ -68,27 +75,58 @@ export function CompanyInfoManagement() {
logoInputRef.current?.click();
};
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 파일 크기 체크 (10MB)
if (file.size > 10 * 1024 * 1024) {
alert('파일 크기는 10MB 이하여야 합니다.');
return;
}
// 파일 타입 체크
if (!['image/png', 'image/jpeg', 'image/gif'].includes(file.type)) {
alert('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
return;
}
setFormData(prev => ({ ...prev, companyLogo: file }));
setLogoFileName(file.name);
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 previousLogo = logoPreviewUrl;
// FileReader로 base64 미리보기 생성 (account-info 방식)
const reader = new FileReader();
reader.onload = (event) => {
setLogoPreviewUrl(event.target?.result as string);
};
reader.readAsDataURL(file);
// FormData 생성 (Server Action에 전달)
const formData = new FormData();
formData.append('logo', file);
// 즉시 업로드
setIsUploadingLogo(true);
const result = await uploadCompanyLogo(formData);
if (result.success && result.data) {
// 성공: 서버 URL도 저장 (다음 페이지 로드 시 사용)
setFormData(prev => ({ ...prev, companyLogo: result.data!.logoUrl }));
toast.success('로고가 업로드되었습니다.');
} else {
// 실패: 롤백
setLogoPreviewUrl(previousLogo);
toast.error(result.error || '로고 업로드에 실패했습니다.');
}
setIsUploadingLogo(false);
if (logoInputRef.current) {
logoInputRef.current.value = '';
}
};
const handleRemoveLogo = () => {
setLogoPreviewUrl(null);
setFormData(prev => ({ ...prev, companyLogo: undefined }));
setLogoFileName('');
if (logoInputRef.current) {
logoInputRef.current.value = '';
}
@@ -188,14 +226,22 @@ export function CompanyInfoManagement() {
<div className="space-y-2">
<Label> </Label>
<div className="flex items-start gap-4">
<div className="w-[200px] h-[67px] border rounded-lg flex items-center justify-center bg-muted/50 overflow-hidden">
{logoFileName ? (
<span className="text-sm text-muted-foreground truncate px-2">
{logoFileName}
</span>
<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">
@@ -204,11 +250,21 @@ export function CompanyInfoManagement() {
variant="outline"
size="sm"
onClick={handleLogoUpload}
disabled={isUploadingLogo}
>
<Upload className="w-4 h-4 mr-2" />
{isUploadingLogo ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
{logoFileName && (
{currentLogoUrl && !isUploadingLogo && (
<Button
type="button"
variant="ghost"
@@ -223,12 +279,12 @@ export function CompanyInfoManagement() {
)}
</div>
<p className="text-xs text-muted-foreground">
750 X 250px, 10MB PNG, JPEG, GIF
750 X 250px, 5MB PNG, JPEG, GIF, WEBP
</p>
<input
ref={logoInputRef}
type="file"
accept="image/png,image/jpeg,image/gif"
accept="image/png,image/jpeg,image/gif,image/webp"
className="hidden"
onChange={handleLogoChange}
/>

View File

@@ -83,7 +83,17 @@ export async function serverFetch(
const cookieStore = await cookies();
const refreshToken = cookieStore.get('refresh_token')?.value;
const headers = await getServerApiHeaders();
const baseHeaders = await getServerApiHeaders() as Record<string, string>;
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
const isFormData = options?.body instanceof FormData;
const headers: HeadersInit = isFormData
? {
Accept: baseHeaders.Accept,
Authorization: baseHeaders.Authorization,
'X-API-KEY': baseHeaders['X-API-KEY'],
}
: baseHeaders;
let response = await fetch(url, {
...options,
@@ -107,7 +117,15 @@ export async function serverFetch(
await setNewTokenCookies(refreshResult);
// 새 토큰으로 원래 요청 재시도
const newHeaders = await getServerApiHeaders(refreshResult.accessToken);
const newBaseHeaders = await getServerApiHeaders(refreshResult.accessToken) as Record<string, string>;
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
const newHeaders: HeadersInit = isFormData
? {
Accept: newBaseHeaders.Accept,
Authorization: newBaseHeaders.Authorization,
'X-API-KEY': newBaseHeaders['X-API-KEY'],
}
: newBaseHeaders;
response = await fetch(url, {
...options,
headers: {