feat(WEB): API 인프라 리팩토링, CEO 대시보드 현황판 개선 및 문서 시스템 강화

- API: fetch-wrapper/proxy/refresh-token 리팩토링, authenticated-fetch 신규 추가
- CEO 대시보드: EnhancedSections 현황판 기능 개선, dashboard transformers/types 확장
- 문서 시스템: ApprovalLine/DocumentHeader/DocumentToolbar/DocumentViewer 개선
- 작업지시서: 검사보고서/작업일지 문서 컴포넌트 개선 (벤딩/스크린/슬랫)
- 레이아웃: Sidebar/AuthenticatedLayout 수정
- 작업자화면: WorkerScreen 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-30 14:16:17 +09:00
parent a486977b80
commit 3ef9570f3b
27 changed files with 554 additions and 451 deletions

View File

@@ -25,7 +25,7 @@ export {
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { AUTH_CONFIG } from './auth/auth-config';
import { refreshAccessToken } from './refresh-token';
import { authenticatedFetch } from './authenticated-fetch';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
/**
@@ -34,15 +34,14 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
* 특징:
* - 쿠키에서 access_token 자동 읽기
* - X-API-KEY + Bearer 토큰 자동 포함
* - 401 발생 시 토큰 자동 갱신 후 재시도
* - 401 발생 시 authenticatedFetch 게이트웨이를 통한 자동 갱신
*/
class ServerApiClient {
private baseURL: string;
private apiKey: string;
constructor() {
// API URL에 /api/v1 prefix 자동 추가
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, ''); // trailing slash 제거
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, '');
this.baseURL = `${apiUrl}/api/v1`;
this.apiKey = process.env.API_KEY || '';
}
@@ -115,7 +114,7 @@ class ServerApiClient {
}
/**
* HTTP 요청 실행 (토큰 자동 갱신 포함)
* HTTP 요청 실행 (authenticatedFetch 게이트웨이를 통한 자동 갱신)
*/
private async request<T>(
endpoint: string,
@@ -127,47 +126,30 @@ class ServerApiClient {
const headers = await this.getAuthHeaders();
const url = `${this.baseURL}${endpoint}`;
let response = await fetch(url, {
...options,
headers: {
...headers,
...options?.headers,
// authenticatedFetch 게이트웨이로 요청 실행
// skipAuthRetry=true면 refreshToken을 넘기지 않아 401 시 갱신 안 함
const { response, newTokens, authFailed } = await authenticatedFetch(
url,
{
...options,
headers: {
...headers,
...options?.headers,
},
cache: 'no-store',
},
cache: 'no-store',
});
options?.skipAuthRetry ? undefined : refreshToken,
'ServerApiClient'
);
// 401 발생 시 토큰 갱신 후 재시도
if (response.status === 401 && !options?.skipAuthRetry && refreshToken) {
console.log('🔄 [ServerApiClient] 401 발생, 토큰 갱신 시도...');
// 새 토큰 → 쿠키 저장
if (newTokens) {
await this.setNewTokenCookies(newTokens);
}
const refreshResult = await refreshAccessToken(refreshToken, 'ServerApiClient');
if (refreshResult.success && refreshResult.accessToken) {
console.log('✅ [ServerApiClient] 토큰 갱신 성공, 재시도...');
await this.setNewTokenCookies(refreshResult);
const newHeaders = await this.getAuthHeaders(refreshResult.accessToken);
response = await fetch(url, {
...options,
headers: {
...newHeaders,
...options?.headers,
},
cache: 'no-store',
});
if (response.status === 401) {
console.warn('🔴 [ServerApiClient] 재시도 실패, 로그인 리다이렉트');
await this.clearTokenCookies();
redirect('/login');
}
} else {
console.warn('🔴 [ServerApiClient] 토큰 갱신 실패, 로그인 리다이렉트');
await this.clearTokenCookies();
redirect('/login');
}
} else if (response.status === 401 && !options?.skipAuthRetry) {
console.warn('🔴 [ServerApiClient] 401 (refresh token 없음), 로그인 리다이렉트');
// 인증 실패 → 쿠키 삭제 + 리다이렉트
if (authFailed && !options?.skipAuthRetry) {
await this.clearTokenCookies();
redirect('/login');
}
@@ -247,4 +229,4 @@ class ServerApiClient {
}
// 서버 액션용 API 클라이언트 인스턴스
export const apiClient = new ServerApiClient();
export const apiClient = new ServerApiClient();