diff --git a/src/components/settings/AccountInfoManagement/actions.ts b/src/components/settings/AccountInfoManagement/actions.ts index ec414759..d993398f 100644 --- a/src/components/settings/AccountInfoManagement/actions.ts +++ b/src/components/settings/AccountInfoManagement/actions.ts @@ -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: '서버 오류가 발생했습니다.', + }; + } +} diff --git a/src/components/settings/CompanyInfoManagement/actions.ts b/src/components/settings/CompanyInfoManagement/actions.ts index 3d6e965a..4c54e4bf 100644 --- a/src/components/settings/CompanyInfoManagement/actions.ts +++ b/src/components/settings/CompanyInfoManagement/actions.ts @@ -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 ): Record { + // 로고 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: '서버 오류가 발생했습니다.' }; + } } \ No newline at end of file diff --git a/src/components/settings/CompanyInfoManagement/index.tsx b/src/components/settings/CompanyInfoManagement/index.tsx index ad2206a4..c0f97b4b 100644 --- a/src/components/settings/CompanyInfoManagement/index.tsx +++ b/src/components/settings/CompanyInfoManagement/index.tsx @@ -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(null); const licenseInputRef = useRef(null); - // 로고 파일명 - const [logoFileName, setLogoFileName] = useState(''); + // 로고 업로드 상태 + const [isUploadingLogo, setIsUploadingLogo] = useState(false); + // 로고 미리보기 URL (로컬 파일 미리보기용) + const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); // 사업자등록증 파일명 const [licenseFileName, setLicenseFileName] = useState(''); + // 로고 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) => { + const handleLogoChange = async (e: React.ChangeEvent) => { 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() {
-
- {logoFileName ? ( - - {logoFileName} - +
+ {currentLogoUrl ? ( + 회사 로고 ) : ( IMG )} + {/* 업로드 중 오버레이 */} + {isUploadingLogo && ( +
+ +
+ )}
{isEditMode && (
@@ -204,11 +250,21 @@ export function CompanyInfoManagement() { variant="outline" size="sm" onClick={handleLogoUpload} + disabled={isUploadingLogo} > - - 업로드 + {isUploadingLogo ? ( + <> + + 업로드 중... + + ) : ( + <> + + 업로드 + + )} - {logoFileName && ( + {currentLogoUrl && !isUploadingLogo && (