feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬

- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결)
- frame-src에 'self' 추가
- 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto)
- HR 사원관리, 결재, 품목, 생산 등 다수 개선
- API 에러 핸들링 및 JSON 파싱 안정화
This commit is contained in:
유병철
2026-03-11 22:32:58 +09:00
parent e9ac2470e1
commit ea6ca335f1
24 changed files with 625 additions and 139 deletions

View File

@@ -43,7 +43,7 @@ import {
EMPLOYEE_STATUS_LABELS,
DEFAULT_FIELD_SETTINGS,
} from './types';
import { getPositions, getDepartments, uploadProfileImage, type PositionItem, type DepartmentItem } from './actions';
import { getPositions, getDepartments, type PositionItem, type DepartmentItem } from './actions';
import { extractDigits } from '@/lib/formatters';
// 부서 트리 구조 타입
@@ -578,19 +578,33 @@ export function EmployeeForm({
// 미리보기 즉시 표시
const previewUrl = URL.createObjectURL(file);
handleChange('profileImage', previewUrl);
// 서버에 업로드 (FormData로 감싸서 전송)
const uploadFormData = new FormData();
uploadFormData.append('file', file);
const result = await uploadProfileImage(uploadFormData);
if (result.success && result.data?.url) {
// 업로드 성공 시 서버 URL로 업데이트
URL.revokeObjectURL(previewUrl);
handleChange('profileImage', result.data.url);
} else {
// 업로드 실패 시 미리보기 제거 및 에러 표시
try {
// 프록시를 통해 직접 업로드 (서버 액션 경유 시 FormData File 손실 방지)
const uploadFormData = new FormData();
uploadFormData.append('file', file);
uploadFormData.append('directory', 'employees/profiles');
const response = await fetch('/api/proxy/files/upload', {
method: 'POST',
body: uploadFormData,
});
if (!response.ok) {
throw new Error(`업로드 실패: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
URL.revokeObjectURL(previewUrl);
const fileId = result.data.id;
const viewUrl = fileId
? `/api/proxy/files/${fileId}/view`
: result.data.file_path || '';
handleChange('profileImage', viewUrl);
} else {
throw new Error(result.message || '업로드 실패');
}
} catch (err) {
URL.revokeObjectURL(previewUrl);
handleChange('profileImage', '');
toast.error(result.error || '이미지 업로드에 실패했습니다.');
toast.error(err instanceof Error ? err.message : '이미지 업로드에 실패했습니다.');
}
}}
onRemove={() => handleChange('profileImage', '')}

View File

@@ -213,7 +213,7 @@ export async function getDepartments(): Promise<DepartmentItem[]> {
export async function uploadProfileImage(inputFormData: FormData): Promise<{
success: boolean;
data?: { url: string; path: string };
data?: { url: string; path: string; fileId?: number };
error?: string;
__authError?: boolean;
}> {
@@ -243,18 +243,21 @@ export async function uploadProfileImage(inputFormData: FormData): Promise<{
const result = await response.json();
if (!result.success) return { success: false, error: result.message || '파일 업로드에 실패했습니다.' };
const uploadedPath = result.data?.file_path || result.data?.path || result.data?.url;
if (!uploadedPath) return { success: false, error: '업로드된 파일 경로를 가져올 수 없습니다.' };
// R2 전환: /storage/ 직접 접근 불가 → /api/proxy/files/{id}/view 사용
const fileId = result.data?.id;
const uploadedPath = result.data?.file_path || result.data?.path || '';
const storagePath = uploadedPath.startsWith('/storage/')
? uploadedPath
: `/storage/tenants/${uploadedPath}`;
// file_id가 있으면 프록시 경로 사용, 없으면 fallback
const viewUrl = fileId
? `/api/proxy/files/${fileId}/view`
: uploadedPath;
return {
success: true,
data: {
url: `${API_URL}${storagePath}`,
url: viewUrl,
path: uploadedPath,
fileId,
},
};
} catch (error) {

View File

@@ -27,7 +27,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
* 프로필 이미지 경로를 전체 URL로 변환
* - 이미 전체 URL이면 그대로 반환
* - base64 data URL이면 그대로 반환
* - 상대 경로면 API URL + /storage/tenants/ 붙여서 반환
* - /api/proxy/files/ 경로면 그대로 반환
* - 상대 경로면 그대로 반환 (R2 전환 후 직접 접근 불가)
*/
export function getProfileImageUrl(path: string | null | undefined): string | undefined {
if (!path) return undefined;
@@ -42,8 +43,14 @@ export function getProfileImageUrl(path: string | null | undefined): string | un
return path;
}
// 상대 경로인 경우 API URL과 결합 (tenants 디렉토리 사용)
return `${API_URL}/storage/tenants/${path}`;
// 프록시 경로인 경우 (/api/proxy/files/{id}/view)
if (path.startsWith('/api/proxy/')) {
return path;
}
// 상대 경로인 경우 — R2 전환 후 /storage/tenants/ 직접 접근 불가
// 경로만 보존하고 표시는 프록시 통해서 처리
return path;
}
/**
@@ -65,13 +72,23 @@ export function extractRelativePath(path: string | null | undefined): string | n
return null;
}
// 프록시 경로인 경우 (/api/proxy/files/{id}/view) - 그대로 반환
if (path.startsWith('/api/proxy/')) {
return path;
}
// 전체 URL인 경우 상대 경로 추출
if (path.startsWith('http://') || path.startsWith('https://')) {
// /storage/tenants/ 이후의 경로 추출
// /storage/tenants/ 이후의 경로 추출 (레거시)
const match = path.match(/\/storage\/tenants\/(.+)$/);
if (match) {
return match[1];
}
// /api/proxy/files/ 경로 추출
const proxyMatch = path.match(/(\/api\/proxy\/files\/.+)$/);
if (proxyMatch) {
return proxyMatch[1];
}
// 매칭 실패 시 null 반환
return null;
}