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:
@@ -589,8 +589,8 @@ export const ImportInspectionDocument = ({
|
|||||||
>
|
>
|
||||||
{opt.isSelected ? 'V' : ''}
|
{opt.isSelected ? 'V' : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="whitespace-pre-line">{opt.label}</span>
|
<span className="whitespace-nowrap">{opt.label}</span>
|
||||||
<span className="ml-auto whitespace-pre-line">{opt.tolerance}</span>
|
<span className="ml-auto whitespace-nowrap">{opt.tolerance.replace(/\n/g, ' ')}</span>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
@@ -794,7 +794,7 @@ export const ImportInspectionDocument = ({
|
|||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<div className="border border-gray-400">
|
<div className="border border-gray-400">
|
||||||
<div className="bg-gray-100 px-4 py-1 text-center text-xs font-medium border-b border-gray-400">종합판정</div>
|
<div className="bg-gray-100 px-4 py-1 text-center text-xs font-medium border-b border-gray-400">종합판정</div>
|
||||||
<div className={`px-8 py-2 text-center text-sm font-bold ${
|
<div className={`px-8 py-2 text-center text-sm font-bold whitespace-nowrap ${
|
||||||
overallResult === '합격' ? 'text-blue-600' :
|
overallResult === '합격' ? 'text-blue-600' :
|
||||||
overallResult === '불합격' ? 'text-red-600' :
|
overallResult === '불합격' ? 'text-red-600' :
|
||||||
'text-gray-400'
|
'text-gray-400'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { refreshAccessToken } from '@/lib/api/refresh-token';
|
import { authenticatedFetch } from '@/lib/api/authenticated-fetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
|
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
|
||||||
@@ -25,44 +25,11 @@ import { refreshAccessToken } from '@/lib/api/refresh-token';
|
|||||||
* - Frontend: fetch('/api/proxy/item-master/init')
|
* - Frontend: fetch('/api/proxy/item-master/init')
|
||||||
* - Backend: GET https://api.codebridge-x.com/api/v1/item-master/init
|
* - Backend: GET https://api.codebridge-x.com/api/v1/item-master/init
|
||||||
*
|
*
|
||||||
* ⚠️ 주의:
|
* 🔄 인증 처리:
|
||||||
* - 로그아웃 API(/api/auth/logout)와 동일한 패턴
|
* - 401 감지 → refresh → retry 는 authenticatedFetch 게이트웨이에 위임
|
||||||
* - 모든 HTTP 메서드 지원 (GET, POST, PUT, DELETE)
|
* - PROXY는 쿠키 읽기/설정/삭제만 담당
|
||||||
* - 쿼리 파라미터와 요청 바디 모두 전달
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* 백엔드 API 요청 실행 함수
|
|
||||||
*
|
|
||||||
* @param isFormData - true인 경우 Content-Type 헤더를 생략 (브라우저가 boundary 자동 설정)
|
|
||||||
*/
|
|
||||||
async function executeBackendRequest(
|
|
||||||
url: URL,
|
|
||||||
method: string,
|
|
||||||
token: string | undefined,
|
|
||||||
body: string | FormData | undefined,
|
|
||||||
contentType: string,
|
|
||||||
isFormData: boolean = false
|
|
||||||
): Promise<Response> {
|
|
||||||
// FormData인 경우 Content-Type을 생략해야 브라우저가 boundary를 자동 설정
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-API-KEY': process.env.API_KEY || '',
|
|
||||||
'Authorization': token ? `Bearer ${token}` : '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// FormData가 아닌 경우에만 Content-Type 설정
|
|
||||||
if (!isFormData) {
|
|
||||||
headers['Content-Type'] = contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(url.toString(), {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 쿠키 생성 헬퍼 함수
|
* 쿠키 생성 헬퍼 함수
|
||||||
*/
|
*/
|
||||||
@@ -74,13 +41,13 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin
|
|||||||
cookies.push([
|
cookies.push([
|
||||||
`access_token=${tokens.accessToken}`,
|
`access_token=${tokens.accessToken}`,
|
||||||
'HttpOnly',
|
'HttpOnly',
|
||||||
...(isProduction ? ['Secure'] : []), // HTTPS only in production
|
...(isProduction ? ['Secure'] : []),
|
||||||
'SameSite=Lax', // Lax for better compatibility (matches login route)
|
'SameSite=Lax',
|
||||||
'Path=/',
|
'Path=/',
|
||||||
`Max-Age=${tokens.expiresIn || 7200}`,
|
`Max-Age=${tokens.expiresIn || 7200}`,
|
||||||
].join('; '));
|
].join('; '));
|
||||||
|
|
||||||
// ✅ FCM 등에서 인증 상태 확인용 (non-HttpOnly)
|
// FCM 등에서 인증 상태 확인용 (non-HttpOnly)
|
||||||
cookies.push([
|
cookies.push([
|
||||||
'is_authenticated=true',
|
'is_authenticated=true',
|
||||||
...(isProduction ? ['Secure'] : []),
|
...(isProduction ? ['Secure'] : []),
|
||||||
@@ -94,8 +61,8 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin
|
|||||||
cookies.push([
|
cookies.push([
|
||||||
`refresh_token=${tokens.refreshToken}`,
|
`refresh_token=${tokens.refreshToken}`,
|
||||||
'HttpOnly',
|
'HttpOnly',
|
||||||
...(isProduction ? ['Secure'] : []), // HTTPS only in production
|
...(isProduction ? ['Secure'] : []),
|
||||||
'SameSite=Lax', // Lax for better compatibility (matches login route)
|
'SameSite=Lax',
|
||||||
'Path=/',
|
'Path=/',
|
||||||
'Max-Age=604800', // 7 days
|
'Max-Age=604800', // 7 days
|
||||||
].join('; '));
|
].join('; '));
|
||||||
@@ -119,12 +86,6 @@ function createClearTokenCookies(): string[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Catch-all proxy handler for all HTTP methods
|
* Catch-all proxy handler for all HTTP methods
|
||||||
*
|
|
||||||
* 🔄 토큰 갱신 로직:
|
|
||||||
* 1. 현재 access_token으로 백엔드 요청
|
|
||||||
* 2. 401 응답 시 → refresh_token으로 새 토큰 발급
|
|
||||||
* 3. 새 토큰으로 원래 요청 재시도
|
|
||||||
* 4. 재시도도 실패하면 → 쿠키 삭제 후 401 반환
|
|
||||||
*/
|
*/
|
||||||
async function proxyRequest(
|
async function proxyRequest(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -132,20 +93,9 @@ async function proxyRequest(
|
|||||||
method: string
|
method: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// 1. 🆕 미들웨어에서 전달한 새 토큰 먼저 확인
|
// 1. HttpOnly 쿠키에서 토큰 읽기
|
||||||
// Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음
|
const token = request.cookies.get('access_token')?.value;
|
||||||
// 따라서 request headers로 전달된 새 토큰을 먼저 사용
|
const refreshToken = request.cookies.get('refresh_token')?.value;
|
||||||
const refreshedAccessToken = request.headers.get('x-refreshed-access-token');
|
|
||||||
const refreshedRefreshToken = request.headers.get('x-refreshed-refresh-token');
|
|
||||||
|
|
||||||
// 2. HttpOnly 쿠키에서 토큰 읽기 (서버에서만 가능!)
|
|
||||||
let token = refreshedAccessToken || request.cookies.get('access_token')?.value;
|
|
||||||
const refreshToken = refreshedRefreshToken || request.cookies.get('refresh_token')?.value;
|
|
||||||
|
|
||||||
// 디버깅: 어떤 토큰을 사용하는지 로그
|
|
||||||
if (refreshedAccessToken) {
|
|
||||||
console.log('🔵 [PROXY] Using refreshed token from middleware headers');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 백엔드 URL 구성
|
// 2. 백엔드 URL 구성
|
||||||
const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`;
|
const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`;
|
||||||
@@ -163,26 +113,19 @@ async function proxyRequest(
|
|||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('application/json')) {
|
||||||
body = await request.text();
|
body = await request.text();
|
||||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||||
console.log('🔵 [PROXY] Request Body:', body); // 디버깅용
|
console.log('🔵 [PROXY] Request Body:', body);
|
||||||
} else if (contentType.includes('multipart/form-data')) {
|
} else if (contentType.includes('multipart/form-data')) {
|
||||||
// multipart/form-data 처리: FormData를 그대로 전달
|
|
||||||
console.log('📎 [PROXY] Processing multipart/form-data request');
|
console.log('📎 [PROXY] Processing multipart/form-data request');
|
||||||
isFormData = true;
|
isFormData = true;
|
||||||
|
|
||||||
// 원본 요청의 FormData 읽기
|
|
||||||
const originalFormData = await request.formData();
|
const originalFormData = await request.formData();
|
||||||
|
|
||||||
// 새 FormData 생성 (백엔드 전송용)
|
|
||||||
const newFormData = new FormData();
|
const newFormData = new FormData();
|
||||||
|
|
||||||
// 모든 필드 복사
|
|
||||||
for (const [key, value] of originalFormData.entries()) {
|
for (const [key, value] of originalFormData.entries()) {
|
||||||
if (value instanceof File) {
|
if (value instanceof File) {
|
||||||
// File 객체는 그대로 추가
|
|
||||||
newFormData.append(key, value, value.name);
|
newFormData.append(key, value, value.name);
|
||||||
console.log(`📎 [PROXY] File field: ${key} = ${value.name} (${value.size} bytes)`);
|
console.log(`📎 [PROXY] File field: ${key} = ${value.name} (${value.size} bytes)`);
|
||||||
} else {
|
} else {
|
||||||
// 일반 필드
|
|
||||||
newFormData.append(key, value);
|
newFormData.append(key, value);
|
||||||
console.log(`📎 [PROXY] Form field: ${key} = ${value}`);
|
console.log(`📎 [PROXY] Form field: ${key} = ${value}`);
|
||||||
}
|
}
|
||||||
@@ -195,69 +138,42 @@ async function proxyRequest(
|
|||||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 백엔드로 프록시 요청
|
// 4. 헤더 구성
|
||||||
let backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData);
|
const headers: Record<string, string> = {
|
||||||
let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null;
|
'Accept': 'application/json',
|
||||||
|
'X-API-KEY': process.env.API_KEY || '',
|
||||||
// 5. 🔄 401 응답 시 토큰 갱신 후 재시도
|
'Authorization': token ? `Bearer ${token}` : '',
|
||||||
if (backendResponse.status === 401 && refreshToken) {
|
};
|
||||||
console.log('🔄 [PROXY] Got 401, attempting token refresh...');
|
if (!isFormData) {
|
||||||
|
headers['Content-Type'] = contentType;
|
||||||
const refreshResult = await refreshAccessToken(refreshToken, 'PROXY');
|
|
||||||
|
|
||||||
if (refreshResult.success && refreshResult.accessToken) {
|
|
||||||
console.log('✅ [PROXY] Token refreshed, retrying original request...');
|
|
||||||
|
|
||||||
// 새 토큰으로 원래 요청 재시도
|
|
||||||
token = refreshResult.accessToken;
|
|
||||||
newTokens = refreshResult;
|
|
||||||
backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData);
|
|
||||||
|
|
||||||
console.log('🔵 [PROXY] Retry response status:', backendResponse.status);
|
|
||||||
} else {
|
|
||||||
// 🔄 리프레시 실패 → 다른 요청이 동시에 refresh 중일 수 있음
|
|
||||||
// 짧은 딜레이 후 한 번 더 refresh 시도
|
|
||||||
console.log('🔄 [PROXY] Refresh failed, waiting and retrying...');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500)); // 500ms 대기
|
|
||||||
|
|
||||||
// 다시 refresh 시도 (다른 요청이 새 refresh_token을 발급받았을 수 있음)
|
|
||||||
const latestRefreshToken = request.cookies.get('refresh_token')?.value;
|
|
||||||
if (latestRefreshToken) {
|
|
||||||
const retryResult = await refreshAccessToken(latestRefreshToken, 'PROXY');
|
|
||||||
|
|
||||||
if (retryResult.success && retryResult.accessToken) {
|
|
||||||
console.log('✅ [PROXY] Retry refresh succeeded!');
|
|
||||||
token = retryResult.accessToken;
|
|
||||||
newTokens = retryResult;
|
|
||||||
backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData);
|
|
||||||
console.log('🔵 [PROXY] Retry response status:', backendResponse.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 여전히 401이면 쿠키 삭제하고 401 반환
|
|
||||||
if (backendResponse.status === 401) {
|
|
||||||
console.warn('🔴 [PROXY] Token refresh failed after retry, clearing cookies...');
|
|
||||||
|
|
||||||
const clearResponse = NextResponse.json(
|
|
||||||
{ error: 'Authentication failed', needsReauth: true },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
|
|
||||||
createClearTokenCookies().forEach(cookie => {
|
|
||||||
clearResponse.headers.append('Set-Cookie', cookie);
|
|
||||||
});
|
|
||||||
|
|
||||||
return clearResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 응답 데이터 읽기
|
// 5. authenticatedFetch 게이트웨이로 요청 실행
|
||||||
|
// 401 감지 → refresh → retry 모두 게이트웨이가 처리
|
||||||
|
const { response: backendResponse, newTokens, authFailed } = await authenticatedFetch(
|
||||||
|
url.toString(),
|
||||||
|
{ method, headers, body },
|
||||||
|
refreshToken,
|
||||||
|
'PROXY'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 인증 실패 → 쿠키 삭제 + 401 반환
|
||||||
|
if (authFailed) {
|
||||||
|
console.warn('🔴 [PROXY] Auth failed, clearing cookies...');
|
||||||
|
const clearResponse = NextResponse.json(
|
||||||
|
{ error: 'Authentication failed', needsReauth: true },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
createClearTokenCookies().forEach(cookie => {
|
||||||
|
clearResponse.headers.append('Set-Cookie', cookie);
|
||||||
|
});
|
||||||
|
return clearResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 응답 처리 (바이너리 vs 텍스트/JSON)
|
||||||
console.log('🔵 [PROXY] Response status:', backendResponse.status);
|
console.log('🔵 [PROXY] Response status:', backendResponse.status);
|
||||||
const responseContentType = backendResponse.headers.get('content-type') || 'application/json';
|
const responseContentType = backendResponse.headers.get('content-type') || 'application/json';
|
||||||
|
|
||||||
// 7. 바이너리 파일 vs 텍스트/JSON 구분
|
|
||||||
// 파일 다운로드 (PDF, 이미지, 등)는 바이너리로 처리해야 손상되지 않음
|
|
||||||
const isBinaryResponse =
|
const isBinaryResponse =
|
||||||
responseContentType.includes('application/pdf') ||
|
responseContentType.includes('application/pdf') ||
|
||||||
responseContentType.includes('application/octet-stream') ||
|
responseContentType.includes('application/octet-stream') ||
|
||||||
@@ -270,7 +186,6 @@ async function proxyRequest(
|
|||||||
let clientResponse: NextResponse;
|
let clientResponse: NextResponse;
|
||||||
|
|
||||||
if (isBinaryResponse) {
|
if (isBinaryResponse) {
|
||||||
// 바이너리 파일: arrayBuffer로 읽어서 그대로 전달
|
|
||||||
console.log('📄 [PROXY] Binary response detected:', responseContentType);
|
console.log('📄 [PROXY] Binary response detected:', responseContentType);
|
||||||
const binaryData = await backendResponse.arrayBuffer();
|
const binaryData = await backendResponse.arrayBuffer();
|
||||||
|
|
||||||
@@ -283,7 +198,6 @@ async function proxyRequest(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// JSON/텍스트: text로 읽어서 전달
|
|
||||||
const responseData = await backendResponse.text();
|
const responseData = await backendResponse.text();
|
||||||
|
|
||||||
clientResponse = new NextResponse(responseData, {
|
clientResponse = new NextResponse(responseData, {
|
||||||
@@ -295,7 +209,7 @@ async function proxyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. 토큰이 갱신되었으면 새 쿠키 설정
|
// 8. 토큰이 갱신되었으면 새 쿠키 설정
|
||||||
if (newTokens && newTokens.accessToken) {
|
if (newTokens?.accessToken) {
|
||||||
createTokenCookies(newTokens).forEach(cookie => {
|
createTokenCookies(newTokens).forEach(cookie => {
|
||||||
clientResponse.headers.append('Set-Cookie', cookie);
|
clientResponse.headers.append('Set-Cookie', cookie);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export const mockData: CEODashboardData = {
|
|||||||
dailyReport: {
|
dailyReport: {
|
||||||
date: '2026년 1월 5일 월요일',
|
date: '2026년 1월 5일 월요일',
|
||||||
cards: [
|
cards: [
|
||||||
{ id: 'dr1', label: '현금성 자산 합계', amount: 3050000000 },
|
{ id: 'dr1', label: '현금성 자산 합계', amount: 3050000000, changeRate: '+5.2%', changeDirection: 'up' },
|
||||||
{ id: 'dr2', label: '외국환(USD) 합계', amount: 11123000, currency: 'USD' },
|
{ id: 'dr2', label: '외국환(USD) 합계', amount: 11123000, currency: 'USD', changeRate: '+2.1%', changeDirection: 'up' },
|
||||||
{ id: 'dr3', label: '입금 합계', amount: 1020000000 },
|
{ id: 'dr3', label: '입금 합계', amount: 1020000000, changeRate: '+12.0%', changeDirection: 'up' },
|
||||||
{ id: 'dr4', label: '출금 합계', amount: 350000000 },
|
{ id: 'dr4', label: '출금 합계', amount: 350000000, changeRate: '-8.0%', changeDirection: 'down' },
|
||||||
],
|
],
|
||||||
checkPoints: [
|
checkPoints: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,8 +104,21 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
|
|||||||
{data.cards[0]?.label || '현금성 자산 합계'}
|
{data.cards[0]?.label || '현금성 자산 합계'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
<div className="flex items-end gap-2">
|
||||||
{formatBillion(data.cards[0]?.amount || 0)}
|
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||||
|
{formatBillion(data.cards[0]?.amount || 0)}
|
||||||
|
</span>
|
||||||
|
{data.cards[0]?.changeRate && (
|
||||||
|
<span
|
||||||
|
style={{ color: data.cards[0].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
|
||||||
|
className="flex items-center text-xs font-medium mb-1"
|
||||||
|
>
|
||||||
|
{data.cards[0].changeDirection === 'up'
|
||||||
|
? <ArrowUpRight className="h-3 w-3" />
|
||||||
|
: <ArrowDownRight className="h-3 w-3" />}
|
||||||
|
{data.cards[0].changeRate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,10 +136,23 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
|
|||||||
{data.cards[1]?.label || '외국환(USD) 합계'}
|
{data.cards[1]?.label || '외국환(USD) 합계'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
<div className="flex items-end gap-2">
|
||||||
{data.cards[1]?.currency === 'USD'
|
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||||
? formatUSD(data.cards[1]?.amount || 0)
|
{data.cards[1]?.currency === 'USD'
|
||||||
: formatBillion(data.cards[1]?.amount || 0)}
|
? formatUSD(data.cards[1]?.amount || 0)
|
||||||
|
: formatBillion(data.cards[1]?.amount || 0)}
|
||||||
|
</span>
|
||||||
|
{data.cards[1]?.changeRate && (
|
||||||
|
<span
|
||||||
|
style={{ color: data.cards[1].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
|
||||||
|
className="flex items-center text-xs font-medium mb-1"
|
||||||
|
>
|
||||||
|
{data.cards[1].changeDirection === 'up'
|
||||||
|
? <ArrowUpRight className="h-3 w-3" />
|
||||||
|
: <ArrowDownRight className="h-3 w-3" />}
|
||||||
|
{data.cards[1].changeRate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,10 +174,17 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
|
|||||||
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||||
{formatBillion(data.cards[2]?.amount || 0)}
|
{formatBillion(data.cards[2]?.amount || 0)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: '#16a34a' }} className="flex items-center text-xs font-medium mb-1">
|
{data.cards[2]?.changeRate && (
|
||||||
<ArrowUpRight className="h-3 w-3" />
|
<span
|
||||||
+12%
|
style={{ color: data.cards[2].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
|
||||||
</span>
|
className="flex items-center text-xs font-medium mb-1"
|
||||||
|
>
|
||||||
|
{data.cards[2].changeDirection === 'up'
|
||||||
|
? <ArrowUpRight className="h-3 w-3" />
|
||||||
|
: <ArrowDownRight className="h-3 w-3" />}
|
||||||
|
{data.cards[2].changeRate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,10 +206,17 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
|
|||||||
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
|
||||||
{formatBillion(data.cards[3]?.amount || 0)}
|
{formatBillion(data.cards[3]?.amount || 0)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: '#e11d48' }} className="flex items-center text-xs font-medium mb-1">
|
{data.cards[3]?.changeRate && (
|
||||||
<ArrowDownRight className="h-3 w-3" />
|
<span
|
||||||
-8%
|
style={{ color: data.cards[3].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
|
||||||
</span>
|
className="flex items-center text-xs font-medium mb-1"
|
||||||
|
>
|
||||||
|
{data.cards[3].changeDirection === 'up'
|
||||||
|
? <ArrowUpRight className="h-3 w-3" />
|
||||||
|
: <ArrowDownRight className="h-3 w-3" />}
|
||||||
|
{data.cards[3].changeRate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface AmountCard {
|
|||||||
unit?: string; // 건, 원 등
|
unit?: string; // 건, 원 등
|
||||||
currency?: 'KRW' | 'USD'; // 통화 (기본: KRW)
|
currency?: 'KRW' | 'USD'; // 통화 (기본: KRW)
|
||||||
isHighlighted?: boolean; // 빨간색 강조
|
isHighlighted?: boolean; // 빨간색 강조
|
||||||
|
changeRate?: string; // 어제 대비 변동률 (예: "+5.2%", "-3.1%")
|
||||||
|
changeDirection?: 'up' | 'down'; // 변동 방향
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오늘의 이슈 항목 (카드 형태 - 현황판용)
|
// 오늘의 이슈 항목 (카드 형태 - 현황판용)
|
||||||
|
|||||||
@@ -150,15 +150,15 @@ export function ApprovalLine({
|
|||||||
{/* 부서 행 (선택적) */}
|
{/* 부서 행 (선택적) */}
|
||||||
{showDepartment && (
|
{showDepartment && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
|
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px] whitespace-nowrap">
|
||||||
{departmentLabels.writer}
|
{departmentLabels.writer}
|
||||||
</td>
|
</td>
|
||||||
{is4Col && (
|
{is4Col && (
|
||||||
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
|
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px] whitespace-nowrap">
|
||||||
{departmentLabels.reviewer}
|
{departmentLabels.reviewer}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
|
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px] whitespace-nowrap">
|
||||||
{departmentLabels.approver}
|
{departmentLabels.approver}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -96,16 +96,16 @@ export function ConstructionApprovalTable({
|
|||||||
|
|
||||||
{/* 부서 행 */}
|
{/* 부서 행 */}
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
|
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||||
{approvers.writer?.department || '부서명'}
|
{approvers.writer?.department || '부서명'}
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
|
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||||
{approvers.approver1?.department || '부서명'}
|
{approvers.approver1?.department || '부서명'}
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
|
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||||
{approvers.approver2?.department || '부서명'}
|
{approvers.approver2?.department || '부서명'}
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
|
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||||
{approvers.approver3?.department || '부서명'}
|
{approvers.approver3?.department || '부서명'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export function DocumentHeader({
|
|||||||
<span className="text-2xl font-bold">{logo.text}</span>
|
<span className="text-2xl font-bold">{logo.text}</span>
|
||||||
)}
|
)}
|
||||||
{logo.subtext && (
|
{logo.subtext && (
|
||||||
<span className="text-xs text-gray-500">{logo.subtext}</span>
|
<span className="text-xs text-gray-500 whitespace-nowrap">{logo.subtext}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function QualityApprovalTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{reportDate && (
|
{reportDate && (
|
||||||
<div className="text-xs text-right mt-1">접고일자: {reportDate}</div>
|
<div className="text-xs text-right mt-1 whitespace-nowrap">접고일자: {reportDate}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
FileOutput,
|
FileOutput,
|
||||||
Mail,
|
Mail,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -41,6 +42,9 @@ interface DocumentToolbarProps {
|
|||||||
onFax?: () => void;
|
onFax?: () => void;
|
||||||
onKakao?: () => void;
|
onKakao?: () => void;
|
||||||
toolbarExtra?: ReactNode;
|
toolbarExtra?: ReactNode;
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
loadingAction?: ActionType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,8 +69,10 @@ export function DocumentToolbar({
|
|||||||
onFax,
|
onFax,
|
||||||
onKakao,
|
onKakao,
|
||||||
toolbarExtra,
|
toolbarExtra,
|
||||||
|
loadingAction,
|
||||||
}: DocumentToolbarProps) {
|
}: DocumentToolbarProps) {
|
||||||
const showZoomControls = features.zoom !== false;
|
const showZoomControls = features.zoom !== false;
|
||||||
|
const isAnyLoading = !!loadingAction;
|
||||||
|
|
||||||
// 액션 버튼 렌더링
|
// 액션 버튼 렌더링
|
||||||
const renderActionButton = (action: ActionType) => {
|
const renderActionButton = (action: ActionType) => {
|
||||||
@@ -80,9 +86,14 @@ export function DocumentToolbar({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||||
onClick={onPrint}
|
onClick={onPrint}
|
||||||
|
disabled={isAnyLoading}
|
||||||
>
|
>
|
||||||
<Printer size={14} />
|
{loadingAction === 'print' ? (
|
||||||
<span className="hidden sm:inline">인쇄</span>
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Printer size={14} />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{loadingAction === 'print' ? '인쇄 중...' : '인쇄'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,9 +105,14 @@ export function DocumentToolbar({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs px-2 sm:px-3"
|
className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs px-2 sm:px-3"
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
|
disabled={isAnyLoading}
|
||||||
>
|
>
|
||||||
<Download size={14} />
|
{loadingAction === 'download' ? (
|
||||||
<span className="hidden sm:inline">다운로드</span>
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download size={14} />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{loadingAction === 'download' ? '다운로드 중...' : '다운로드'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -196,9 +212,14 @@ export function DocumentToolbar({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 bg-red-600 hover:bg-red-700 text-white gap-1 text-xs px-2 sm:px-3"
|
className="h-8 bg-red-600 hover:bg-red-700 text-white gap-1 text-xs px-2 sm:px-3"
|
||||||
onClick={onPdf}
|
onClick={onPdf}
|
||||||
|
disabled={isAnyLoading}
|
||||||
>
|
>
|
||||||
<Download size={14} />
|
{loadingAction === 'pdf' ? (
|
||||||
<span className="hidden sm:inline">PDF</span>
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download size={14} />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{loadingAction === 'pdf' ? 'PDF 생성 중...' : 'PDF'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, ReactNode, useCallback } from 'react';
|
import React, { useEffect, useState, ReactNode, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -102,11 +102,23 @@ export function DocumentViewer({
|
|||||||
const features: DocumentFeatures = merged.features;
|
const features: DocumentFeatures = merged.features;
|
||||||
const actions: ActionType[] = merged.actions;
|
const actions: ActionType[] = merged.actions;
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
const [loadingAction, setLoadingAction] = useState<ActionType | null>(null);
|
||||||
|
|
||||||
// 줌 훅
|
// 줌 훅
|
||||||
const zoom = useZoom();
|
const zoomBase = useZoom();
|
||||||
|
|
||||||
// 드래그 훅
|
// 드래그 훅
|
||||||
const drag = useDrag({ enabled: features.drag !== false && zoom.zoom > 100 });
|
const drag = useDrag({ enabled: features.drag !== false && zoomBase.zoom > 100 });
|
||||||
|
|
||||||
|
// 맞춤 클릭 시 드래그 위치도 함께 리셋
|
||||||
|
const zoom = {
|
||||||
|
...zoomBase,
|
||||||
|
zoomReset: () => {
|
||||||
|
zoomBase.zoomReset();
|
||||||
|
drag.resetPosition();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// 모달 열릴 때 상태 초기화
|
// 모달 열릴 때 상태 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,6 +194,7 @@ export function DocumentViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setLoadingAction('pdf');
|
||||||
toast.loading('PDF 생성 중...', { id: 'pdf-generating' });
|
toast.loading('PDF 생성 중...', { id: 'pdf-generating' });
|
||||||
|
|
||||||
// print-area 영역 찾기
|
// print-area 영역 찾기
|
||||||
@@ -234,6 +247,8 @@ export function DocumentViewer({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PDF 생성 오류:', error);
|
console.error('PDF 생성 오류:', error);
|
||||||
toast.error('PDF 생성 중 오류가 발생했습니다.', { id: 'pdf-generating' });
|
toast.error('PDF 생성 중 오류가 발생했습니다.', { id: 'pdf-generating' });
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(null);
|
||||||
}
|
}
|
||||||
}, [onPdf, title, pdfMeta]);
|
}, [onPdf, title, pdfMeta]);
|
||||||
|
|
||||||
@@ -301,6 +316,7 @@ export function DocumentViewer({
|
|||||||
onFax={onFax}
|
onFax={onFax}
|
||||||
onKakao={onKakao}
|
onKakao={onKakao}
|
||||||
toolbarExtra={toolbarExtra}
|
toolbarExtra={toolbarExtra}
|
||||||
|
loadingAction={loadingAction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 콘텐츠 */}
|
{/* 콘텐츠 */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronRight, Circle } from 'lucide-react';
|
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
|
||||||
import type { MenuItem } from '@/store/menuStore';
|
import type { MenuItem } from '@/store/menuStore';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ interface SidebarProps {
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
onMenuClick: (menuId: string, path: string) => void;
|
onMenuClick: (menuId: string, path: string) => void;
|
||||||
onToggleSubmenu: (menuId: string) => void;
|
onToggleSubmenu: (menuId: string) => void;
|
||||||
|
onToggleAll?: () => void;
|
||||||
onCloseMobileSidebar?: () => void;
|
onCloseMobileSidebar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +246,7 @@ export default function Sidebar({
|
|||||||
isMobile,
|
isMobile,
|
||||||
onMenuClick,
|
onMenuClick,
|
||||||
onToggleSubmenu,
|
onToggleSubmenu,
|
||||||
|
onToggleAll,
|
||||||
onCloseMobileSidebar,
|
onCloseMobileSidebar,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
// 활성 메뉴 자동 스크롤을 위한 ref
|
// 활성 메뉴 자동 스크롤을 위한 ref
|
||||||
@@ -274,8 +276,27 @@ export default function Sidebar({
|
|||||||
sidebarCollapsed ? 'px-2 py-2' : 'px-3 py-4 md:px-4 md:py-4'
|
sidebarCollapsed ? 'px-2 py-2' : 'px-3 py-4 md:px-4 md:py-4'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{/* 전체 열기/닫기 토글 버튼 - 사이드바 펼침 상태에서만 표시 */}
|
||||||
|
{!sidebarCollapsed && onToggleAll && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleAll}
|
||||||
|
className="w-full flex items-center space-x-2 px-3 py-2 rounded-lg text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-all duration-200"
|
||||||
|
>
|
||||||
|
{expandedMenus.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ChevronsDownUp className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>모두 접기</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronsUpDown className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>모두 펼치기</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className={`transition-all duration-300 ${
|
<div className={`transition-all duration-300 ${
|
||||||
sidebarCollapsed ? 'space-y-1 mt-2' : 'space-y-1.5 mt-3'
|
sidebarCollapsed ? 'space-y-1 mt-2' : 'space-y-1.5 mt-1'
|
||||||
}`}>
|
}`}>
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<MenuItemComponent
|
<MenuItemComponent
|
||||||
|
|||||||
@@ -191,6 +191,20 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
|||||||
}),
|
}),
|
||||||
}), [products, inadequateContent, overallResult]);
|
}), [products, inadequateContent, overallResult]);
|
||||||
|
|
||||||
|
// PDF 호환 체크박스 렌더
|
||||||
|
const renderCheckbox = (checked: boolean, onClick: () => void) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
|
||||||
|
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => !readOnly && onClick()}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
>
|
||||||
|
{checked ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
|
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
|
||||||
|
|
||||||
// 전체 행 수 계산 (간격 포인트 수 합계)
|
// 전체 행 수 계산 (간격 포인트 수 합계)
|
||||||
@@ -202,7 +216,7 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
|||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">중간검사성적서 (절곡)</h1>
|
<h1 className="text-2xl font-bold">중간검사성적서 (절곡)</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,10 +238,10 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
|||||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -426,24 +440,12 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
|||||||
{/* 절곡상태 - 양호/불량 체크 */}
|
{/* 절곡상태 - 양호/불량 체크 */}
|
||||||
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
|
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||||
<input
|
{renderCheckbox(product.bendingStatus === '양호', () => handleStatusChange(product.id, product.bendingStatus === '양호' ? null : '양호'))}
|
||||||
type="checkbox"
|
|
||||||
checked={product.bendingStatus === '양호'}
|
|
||||||
onChange={() => handleStatusChange(product.id, product.bendingStatus === '양호' ? null : '양호')}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-3 h-3"
|
|
||||||
/>
|
|
||||||
양호
|
양호
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||||
<input
|
{renderCheckbox(product.bendingStatus === '불량', () => handleStatusChange(product.id, product.bendingStatus === '불량' ? null : '불량'))}
|
||||||
type="checkbox"
|
|
||||||
checked={product.bendingStatus === '불량'}
|
|
||||||
onChange={() => handleStatusChange(product.id, product.bendingStatus === '불량' ? null : '불량')}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-3 h-3"
|
|
||||||
/>
|
|
||||||
불량
|
불량
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,16 +486,13 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
|||||||
<table className="w-full border-collapse text-xs">
|
<table className="w-full border-collapse text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top">부적합 내용</td>
|
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center">부적합 내용</td>
|
||||||
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
|
<td className="border border-gray-400 px-3 py-2">
|
||||||
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
||||||
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
||||||
</tr>
|
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
|
||||||
<tr>
|
|
||||||
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
|
|
||||||
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
|
|
||||||
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
||||||
}`}>
|
}`}>
|
||||||
{overallResult || '합격'}
|
{overallResult || '합격'}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp
|
|||||||
{/* 좌측: 제목 + 문서번호 */}
|
{/* 좌측: 제목 + 문서번호 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">작업일지 (절곡)</h1>
|
<h1 className="text-2xl font-bold">작업일지 (절곡)</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,10 +75,10 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp
|
|||||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export function InspectionReportModal({
|
|||||||
) : (
|
) : (
|
||||||
<Save className="w-4 h-4 mr-1.5" />
|
<Save className="w-4 h-4 mr-1.5" />
|
||||||
)}
|
)}
|
||||||
저장
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -83,10 +83,19 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
|||||||
));
|
));
|
||||||
}, [readOnly]);
|
}, [readOnly]);
|
||||||
|
|
||||||
|
// 숫자 콤마 포맷
|
||||||
|
const formatNumberWithComma = (value: string): string => {
|
||||||
|
const num = value.replace(/[^\d]/g, '');
|
||||||
|
if (!num) return '';
|
||||||
|
return Number(num).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = useCallback((rowId: number, field: 'lengthMeasured' | 'widthMeasured', value: string) => {
|
const handleInputChange = useCallback((rowId: number, field: 'lengthMeasured' | 'widthMeasured', value: string) => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
|
// 숫자만 저장 (콤마 제거)
|
||||||
|
const numOnly = value.replace(/[^\d]/g, '');
|
||||||
setRows(prev => prev.map(row =>
|
setRows(prev => prev.map(row =>
|
||||||
row.id === rowId ? { ...row, [field]: value } : row
|
row.id === rowId ? { ...row, [field]: numOnly } : row
|
||||||
));
|
));
|
||||||
}, [readOnly]);
|
}, [readOnly]);
|
||||||
|
|
||||||
@@ -135,28 +144,29 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
|||||||
}),
|
}),
|
||||||
}), [rows, inadequateContent, overallResult]);
|
}), [rows, inadequateContent, overallResult]);
|
||||||
|
|
||||||
// 체크박스 렌더 (양호/불량)
|
// PDF 호환 체크박스 렌더 (양호/불량)
|
||||||
|
const renderCheckbox = (checked: boolean, onClick: () => void) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
|
||||||
|
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => !readOnly && onClick()}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
>
|
||||||
|
{checked ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => (
|
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => (
|
||||||
<td className="border border-gray-400 p-1">
|
<td className="border border-gray-400 p-1">
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||||
<input
|
{renderCheckbox(value === '양호', () => handleStatusChange(rowId, field, value === '양호' ? null : '양호'))}
|
||||||
type="checkbox"
|
|
||||||
checked={value === '양호'}
|
|
||||||
onChange={() => handleStatusChange(rowId, field, value === '양호' ? null : '양호')}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-3 h-3"
|
|
||||||
/>
|
|
||||||
양호
|
양호
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||||
<input
|
{renderCheckbox(value === '불량', () => handleStatusChange(rowId, field, value === '불량' ? null : '불량'))}
|
||||||
type="checkbox"
|
|
||||||
checked={value === '불량'}
|
|
||||||
onChange={() => handleStatusChange(rowId, field, value === '불량' ? null : '불량')}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-3 h-3"
|
|
||||||
/>
|
|
||||||
불량
|
불량
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +181,7 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
|||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">중간검사성적서 (스크린)</h1>
|
<h1 className="text-2xl font-bold">중간검사성적서 (스크린)</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,10 +203,10 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
|||||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -332,35 +342,23 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
|||||||
{/* 길이 - 도면치수 표시 + 측정값 입력 */}
|
{/* 길이 - 도면치수 표시 + 측정값 입력 */}
|
||||||
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign}</td>
|
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign}</td>
|
||||||
<td className="border border-gray-400 p-1">
|
<td className="border border-gray-400 p-1">
|
||||||
<input type="text" value={row.lengthMeasured} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
<input type="text" value={formatNumberWithComma(row.lengthMeasured)} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||||
</td>
|
</td>
|
||||||
{/* 나비 - 도면치수 표시 + 측정값 입력 */}
|
{/* 나비 - 도면치수 표시 + 측정값 입력 */}
|
||||||
<td className="border border-gray-400 p-1 text-center">{row.widthDesign}</td>
|
<td className="border border-gray-400 p-1 text-center">{row.widthDesign}</td>
|
||||||
<td className="border border-gray-400 p-1">
|
<td className="border border-gray-400 p-1">
|
||||||
<input type="text" value={row.widthMeasured} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
<input type="text" value={formatNumberWithComma(row.widthMeasured)} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||||
</td>
|
</td>
|
||||||
{/* 간격 - 기준치 표시 + OK/NG 선택 */}
|
{/* 간격 - 기준치 표시 + OK/NG 선택 */}
|
||||||
<td className="border border-gray-400 p-1 text-center">{row.gapStandard}</td>
|
<td className="border border-gray-400 p-1 text-center">{row.gapStandard}</td>
|
||||||
<td className="border border-gray-400 p-1">
|
<td className="border border-gray-400 p-1">
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
||||||
<input
|
{renderCheckbox(row.gapResult === 'OK', () => handleGapChange(row.id, row.gapResult === 'OK' ? null : 'OK'))}
|
||||||
type="checkbox"
|
|
||||||
checked={row.gapResult === 'OK'}
|
|
||||||
onChange={() => handleGapChange(row.id, row.gapResult === 'OK' ? null : 'OK')}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-3 h-3"
|
|
||||||
/>
|
|
||||||
OK
|
OK
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
||||||
<input
|
{renderCheckbox(row.gapResult === 'NG', () => handleGapChange(row.id, row.gapResult === 'NG' ? null : 'NG'))}
|
||||||
type="checkbox"
|
|
||||||
checked={row.gapResult === 'NG'}
|
|
||||||
onChange={() => handleGapChange(row.id, row.gapResult === 'NG' ? null : 'NG')}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-3 h-3"
|
|
||||||
/>
|
|
||||||
NG
|
NG
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,16 +379,13 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
|
|||||||
<table className="w-full border-collapse text-xs">
|
<table className="w-full border-collapse text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top">부적합 내용</td>
|
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center">부적합 내용</td>
|
||||||
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
|
<td className="border border-gray-400 px-3 py-2">
|
||||||
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
||||||
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
||||||
</tr>
|
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
|
||||||
<tr>
|
|
||||||
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
|
|
||||||
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
|
|
||||||
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
||||||
}`}>
|
}`}>
|
||||||
{overallResult || '합격'}
|
{overallResult || '합격'}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps)
|
|||||||
{/* 좌측: 제목 + 문서번호 */}
|
{/* 좌측: 제목 + 문서번호 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">작업일지 (스크린)</h1>
|
<h1 className="text-2xl font-bold">작업일지 (스크린)</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,10 +77,10 @@ export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps)
|
|||||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -83,8 +83,10 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
|
|||||||
|
|
||||||
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
|
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
|
// 숫자 + 소수점만 허용
|
||||||
|
const filtered = value.replace(/[^\d.]/g, '');
|
||||||
setRows(prev => prev.map(row =>
|
setRows(prev => prev.map(row =>
|
||||||
row.id === rowId ? { ...row, [field]: value } : row
|
row.id === rowId ? { ...row, [field]: filtered } : row
|
||||||
));
|
));
|
||||||
}, [readOnly]);
|
}, [readOnly]);
|
||||||
|
|
||||||
@@ -119,28 +121,29 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
|
|||||||
}),
|
}),
|
||||||
}), [rows, inadequateContent, overallResult]);
|
}), [rows, inadequateContent, overallResult]);
|
||||||
|
|
||||||
// 체크박스 렌더 (양호/불량)
|
// PDF 호환 체크박스 렌더 (양호/불량)
|
||||||
|
const renderCheckbox = (checked: boolean, onClick: () => void) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
|
||||||
|
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => !readOnly && onClick()}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
>
|
||||||
|
{checked ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => (
|
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => (
|
||||||
<td className="border border-gray-400 p-1">
|
<td className="border border-gray-400 p-1">
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||||
<input
|
{renderCheckbox(value === '양호', () => handleStatusChange(rowId, field, value === '양호' ? null : '양호'))}
|
||||||
type="checkbox"
|
|
||||||
checked={value === '양호'}
|
|
||||||
onChange={() => handleStatusChange(rowId, field, value === '양호' ? null : '양호')}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-3 h-3"
|
|
||||||
/>
|
|
||||||
양호
|
양호
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
|
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||||
<input
|
{renderCheckbox(value === '불량', () => handleStatusChange(rowId, field, value === '불량' ? null : '불량'))}
|
||||||
type="checkbox"
|
|
||||||
checked={value === '불량'}
|
|
||||||
onChange={() => handleStatusChange(rowId, field, value === '불량' ? null : '불량')}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-3 h-3"
|
|
||||||
/>
|
|
||||||
불량
|
불량
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +158,7 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
|
|||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">중간검사성적서 (슬랫)</h1>
|
<h1 className="text-2xl font-bold">중간검사성적서 (슬랫)</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,10 +180,10 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
|
|||||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -339,16 +342,13 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
|
|||||||
<table className="w-full border-collapse text-xs">
|
<table className="w-full border-collapse text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top">부적합 내용</td>
|
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center">부적합 내용</td>
|
||||||
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
|
<td className="border border-gray-400 px-3 py-2">
|
||||||
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
||||||
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
||||||
</tr>
|
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
|
||||||
<tr>
|
|
||||||
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
|
|
||||||
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
|
|
||||||
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
||||||
}`}>
|
}`}>
|
||||||
{overallResult || '합격'}
|
{overallResult || '합격'}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) {
|
|||||||
{/* 좌측: 제목 + 문서번호 */}
|
{/* 좌측: 제목 + 문서번호 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">작업일지 (슬랫)</h1>
|
<h1 className="text-2xl font-bold">작업일지 (슬랫)</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,10 +73,10 @@ export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) {
|
|||||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
<td className="border border-gray-400 px-6 py-1 text-center">부서명</td>
|
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useMenuStore } from '@/store/menuStore';
|
||||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
|
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -188,6 +189,7 @@ const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean
|
|||||||
|
|
||||||
export default function WorkerScreen() {
|
export default function WorkerScreen() {
|
||||||
// ===== 상태 관리 =====
|
// ===== 상태 관리 =====
|
||||||
|
const { sidebarCollapsed } = useMenuStore();
|
||||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
|
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
|
||||||
@@ -509,7 +511,7 @@ export default function WorkerScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 pb-20">
|
||||||
{/* 완료 토스트 */}
|
{/* 완료 토스트 */}
|
||||||
{toastInfo && <CompletionToast info={toastInfo} />}
|
{toastInfo && <CompletionToast info={toastInfo} />}
|
||||||
|
|
||||||
@@ -656,8 +658,8 @@ export default function WorkerScreen() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단 고정 버튼 */}
|
{/* 하단 고정 버튼 - DetailActions 패턴 적용 */}
|
||||||
<div className="sticky bottom-0 border-t border-gray-200 pt-4 pb-2 z-10">
|
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'}`}>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -472,6 +472,20 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 전체 메뉴 열기/닫기 토글 함수
|
||||||
|
const toggleAllMenus = useCallback(() => {
|
||||||
|
if (expandedMenus.length > 0) {
|
||||||
|
// 하나라도 열려있으면 전부 닫기
|
||||||
|
setExpandedMenus([]);
|
||||||
|
} else {
|
||||||
|
// 전부 닫혀있으면 children 있는 메뉴 전부 열기
|
||||||
|
const menusWithChildren = menuItems
|
||||||
|
.filter(item => item.children && item.children.length > 0)
|
||||||
|
.map(item => item.id);
|
||||||
|
setExpandedMenus(menusWithChildren);
|
||||||
|
}
|
||||||
|
}, [expandedMenus, menuItems]);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
// AuthContext의 logout() 호출 (완전한 캐시 정리 수행)
|
// AuthContext의 logout() 호출 (완전한 캐시 정리 수행)
|
||||||
@@ -653,6 +667,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
isMobile={true}
|
isMobile={true}
|
||||||
onMenuClick={handleMenuClick}
|
onMenuClick={handleMenuClick}
|
||||||
onToggleSubmenu={toggleSubmenu}
|
onToggleSubmenu={toggleSubmenu}
|
||||||
|
onToggleAll={toggleAllMenus}
|
||||||
onCloseMobileSidebar={() => setIsMobileSidebarOpen(false)}
|
onCloseMobileSidebar={() => setIsMobileSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -1167,6 +1182,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
isMobile={false}
|
isMobile={false}
|
||||||
onMenuClick={handleMenuClick}
|
onMenuClick={handleMenuClick}
|
||||||
onToggleSubmenu={toggleSubmenu}
|
onToggleSubmenu={toggleSubmenu}
|
||||||
|
onToggleAll={toggleAllMenus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
95
src/lib/api/authenticated-fetch.ts
Normal file
95
src/lib/api/authenticated-fetch.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Authenticated Fetch Gateway
|
||||||
|
*
|
||||||
|
* 인증이 필요한 백엔드 API 호출의 유일한 게이트웨이.
|
||||||
|
* 401 감지 → refresh (globalThis 캐시) → retry 를 한 곳에서 처리.
|
||||||
|
*
|
||||||
|
* 이 모듈이 담당하는 것:
|
||||||
|
* - 요청 실행
|
||||||
|
* - 401 감지 → refreshAccessToken (globalThis 캐시로 프로세스 내 중복 방지)
|
||||||
|
* - 새 토큰으로 재시도
|
||||||
|
*
|
||||||
|
* 이 모듈이 담당하지 않는 것 (호출자가 처리):
|
||||||
|
* - 쿠키 읽기 (PROXY: request.cookies / Server Actions: cookies() API)
|
||||||
|
* - 쿠키 설정 (PROXY: Set-Cookie 헤더 / Server Actions: cookies() API)
|
||||||
|
* - 리다이렉트 (Server Actions: redirect('/login'))
|
||||||
|
* - 헤더 구성 (각 호출자가 자기 방식으로)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { refreshAccessToken, type RefreshResult } from './refresh-token';
|
||||||
|
|
||||||
|
export type AuthenticatedFetchResult = {
|
||||||
|
/** 백엔드 응답 (성공이든 실패든) */
|
||||||
|
response: Response;
|
||||||
|
/** refresh 성공 시 새 토큰 (호출자가 쿠키에 저장) */
|
||||||
|
newTokens?: RefreshResult;
|
||||||
|
/** true면 인증 실패 (호출자가 쿠키 삭제 + 리다이렉트 처리) */
|
||||||
|
authFailed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증된 백엔드 요청 실행
|
||||||
|
*
|
||||||
|
* 반환값 상태:
|
||||||
|
* - { response } → 정상 (401 아님)
|
||||||
|
* - { response, newTokens } → 401 → refresh 성공 → 재시도 성공
|
||||||
|
* - { response, authFailed: true } → 인증 실패 (refresh 불가/실패/재시도 실패)
|
||||||
|
*
|
||||||
|
* @param url 백엔드 API URL
|
||||||
|
* @param options fetch 옵션 (호출자가 Authorization 등 헤더 포함)
|
||||||
|
* @param refreshToken refresh_token (없으면 401 시 바로 실패 반환)
|
||||||
|
* @param caller 호출자 이름 (로그용: 'PROXY' | 'serverFetch' | 'ServerApiClient')
|
||||||
|
*/
|
||||||
|
export async function authenticatedFetch(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
refreshToken: string | undefined,
|
||||||
|
caller: string = 'unknown'
|
||||||
|
): Promise<AuthenticatedFetchResult> {
|
||||||
|
// 1. 요청 실행 (호출자가 이미 모든 헤더 설정)
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
// 2. 401이 아니면 그대로 반환
|
||||||
|
if (response.status !== 401) {
|
||||||
|
return { response };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 401이지만 refresh_token 없음 → 인증 실패
|
||||||
|
if (!refreshToken) {
|
||||||
|
console.warn(`🔴 [${caller}] 401 (no refresh token)`);
|
||||||
|
return { response, authFailed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 401 + refresh_token 있음 → 갱신 시도 (globalThis 캐시로 중복 방지)
|
||||||
|
console.log(`🔄 [${caller}] Got 401, attempting token refresh...`);
|
||||||
|
const refreshResult = await refreshAccessToken(refreshToken, caller);
|
||||||
|
|
||||||
|
if (!refreshResult.success || !refreshResult.accessToken) {
|
||||||
|
console.warn(`🔴 [${caller}] Token refresh failed`);
|
||||||
|
return { response, authFailed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 새 토큰으로 재시도
|
||||||
|
console.log(`✅ [${caller}] Token refreshed, retrying...`);
|
||||||
|
const retryHeaders = new Headers(options.headers || {});
|
||||||
|
retryHeaders.set('Authorization', `Bearer ${refreshResult.accessToken}`);
|
||||||
|
|
||||||
|
const retryResponse = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: retryHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🔵 [${caller}] Retry status: ${retryResponse.status}`);
|
||||||
|
|
||||||
|
// 6. 재시도도 401 → 인증 실패
|
||||||
|
if (retryResponse.status === 401) {
|
||||||
|
console.warn(`🔴 [${caller}] Retry still 401, auth failed`);
|
||||||
|
return { response: retryResponse, authFailed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 재시도 성공 → 새 토큰과 함께 반환 (호출자가 쿠키 설정)
|
||||||
|
return {
|
||||||
|
response: retryResponse,
|
||||||
|
newTokens: refreshResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -239,10 +239,33 @@ function generateDailyReportCheckPoints(api: DailyReportApiResponse): CheckPoint
|
|||||||
return checkPoints;
|
return checkPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 변동률 → changeRate/changeDirection 변환 헬퍼
|
||||||
|
*/
|
||||||
|
function toChangeFields(rate?: number): { changeRate?: string; changeDirection?: 'up' | 'down' } {
|
||||||
|
if (rate === undefined || rate === null) return {};
|
||||||
|
const direction = rate >= 0 ? 'up' as const : 'down' as const;
|
||||||
|
const sign = rate >= 0 ? '+' : '';
|
||||||
|
return {
|
||||||
|
changeRate: `${sign}${rate.toFixed(1)}%`,
|
||||||
|
changeDirection: direction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DailyReport API 응답 → Frontend 타입 변환
|
* DailyReport API 응답 → Frontend 타입 변환
|
||||||
*/
|
*/
|
||||||
export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData {
|
export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData {
|
||||||
|
const change = api.daily_change;
|
||||||
|
|
||||||
|
// TODO: 백엔드 daily_change 필드 제공 시 더미값 제거
|
||||||
|
const FALLBACK_CHANGES = {
|
||||||
|
cash_asset: { changeRate: '+5.2%', changeDirection: 'up' as const },
|
||||||
|
foreign_currency: { changeRate: '+2.1%', changeDirection: 'up' as const },
|
||||||
|
income: { changeRate: '+12.0%', changeDirection: 'up' as const },
|
||||||
|
expense: { changeRate: '-8.0%', changeDirection: 'down' as const },
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: formatDate(api.date, api.day_of_week),
|
date: formatDate(api.date, api.day_of_week),
|
||||||
cards: [
|
cards: [
|
||||||
@@ -250,22 +273,34 @@ export function transformDailyReportResponse(api: DailyReportApiResponse): Daily
|
|||||||
id: 'dr1',
|
id: 'dr1',
|
||||||
label: '현금성 자산 합계',
|
label: '현금성 자산 합계',
|
||||||
amount: api.cash_asset_total,
|
amount: api.cash_asset_total,
|
||||||
|
...(change?.cash_asset_change_rate !== undefined
|
||||||
|
? toChangeFields(change.cash_asset_change_rate)
|
||||||
|
: FALLBACK_CHANGES.cash_asset),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dr2',
|
id: 'dr2',
|
||||||
label: '외국환(USD) 합계',
|
label: '외국환(USD) 합계',
|
||||||
amount: api.foreign_currency_total,
|
amount: api.foreign_currency_total,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
|
...(change?.foreign_currency_change_rate !== undefined
|
||||||
|
? toChangeFields(change.foreign_currency_change_rate)
|
||||||
|
: FALLBACK_CHANGES.foreign_currency),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dr3',
|
id: 'dr3',
|
||||||
label: '입금 합계',
|
label: '입금 합계',
|
||||||
amount: api.krw_totals.income,
|
amount: api.krw_totals.income,
|
||||||
|
...(change?.income_change_rate !== undefined
|
||||||
|
? toChangeFields(change.income_change_rate)
|
||||||
|
: FALLBACK_CHANGES.income),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dr4',
|
id: 'dr4',
|
||||||
label: '출금 합계',
|
label: '출금 합계',
|
||||||
amount: api.krw_totals.expense,
|
amount: api.krw_totals.expense,
|
||||||
|
...(change?.expense_change_rate !== undefined
|
||||||
|
? toChangeFields(change.expense_change_rate)
|
||||||
|
: FALLBACK_CHANGES.expense),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkPoints: generateDailyReportCheckPoints(api),
|
checkPoints: generateDailyReportCheckPoints(api),
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ export interface CurrencyTotals {
|
|||||||
/** 운영자금 안정성 상태 */
|
/** 운영자금 안정성 상태 */
|
||||||
export type OperatingStability = 'stable' | 'caution' | 'warning' | 'unknown';
|
export type OperatingStability = 'stable' | 'caution' | 'warning' | 'unknown';
|
||||||
|
|
||||||
|
/** 어제 대비 변동률 */
|
||||||
|
export interface DailyChangeRate {
|
||||||
|
cash_asset_change_rate?: number; // 현금성 자산 변동률 (%)
|
||||||
|
foreign_currency_change_rate?: number; // 외국환 변동률 (%)
|
||||||
|
income_change_rate?: number; // 입금 변동률 (%)
|
||||||
|
expense_change_rate?: number; // 출금 변동률 (%)
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/proxy/daily-report/summary 응답 */
|
/** GET /api/proxy/daily-report/summary 응답 */
|
||||||
export interface DailyReportApiResponse {
|
export interface DailyReportApiResponse {
|
||||||
date: string; // "2026-01-20"
|
date: string; // "2026-01-20"
|
||||||
@@ -32,6 +40,8 @@ export interface DailyReportApiResponse {
|
|||||||
monthly_operating_expense: number; // 월 운영비 (직전 3개월 평균)
|
monthly_operating_expense: number; // 월 운영비 (직전 3개월 평균)
|
||||||
operating_months: number | null; // 운영 가능 개월 수
|
operating_months: number | null; // 운영 가능 개월 수
|
||||||
operating_stability: OperatingStability; // 안정성 상태
|
operating_stability: OperatingStability; // 안정성 상태
|
||||||
|
// 어제 대비 변동률 (optional - 백엔드에서 제공 시)
|
||||||
|
daily_change?: DailyChangeRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
* 전역 Fetch Wrapper
|
* 전역 Fetch Wrapper
|
||||||
*
|
*
|
||||||
* 모든 Server Actions에서 사용할 공통 fetch 함수
|
* 모든 Server Actions에서 사용할 공통 fetch 함수
|
||||||
* - 401 에러 자동 감지 및 토큰 자동 갱신
|
* - 401 에러 자동 감지 및 토큰 자동 갱신 (authenticatedFetch 게이트웨이 위임)
|
||||||
* - 일관된 에러 처리
|
* - 일관된 에러 처리
|
||||||
* - 헤더 자동 설정
|
* - 헤더 자동 설정
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { cookies, headers } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
||||||
import { refreshAccessToken } from './refresh-token';
|
import { authenticatedFetch } from './authenticated-fetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 토큰 쿠키 삭제 (리프레시 실패 또는 인증 만료 시)
|
* 토큰 쿠키 삭제 (리프레시 실패 또는 인증 만료 시)
|
||||||
@@ -22,13 +22,12 @@ import { refreshAccessToken } from './refresh-token';
|
|||||||
async function clearTokenCookies() {
|
async function clearTokenCookies() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
// 토큰 쿠키 삭제
|
|
||||||
cookieStore.delete('access_token');
|
cookieStore.delete('access_token');
|
||||||
cookieStore.delete('refresh_token');
|
cookieStore.delete('refresh_token');
|
||||||
cookieStore.delete('token_refreshed_at');
|
cookieStore.delete('token_refreshed_at');
|
||||||
cookieStore.delete('is_authenticated');
|
cookieStore.delete('is_authenticated');
|
||||||
|
|
||||||
console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료 (무한 루프 방지)');
|
console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,8 +50,7 @@ async function setNewTokenCookies(tokens: {
|
|||||||
maxAge: tokens.expiresIn || 7200,
|
maxAge: tokens.expiresIn || 7200,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔔 토큰 갱신 신호 쿠키 설정 (클라이언트에서 감지용)
|
// 토큰 갱신 신호 쿠키 (클라이언트 useMenuPolling 감지용)
|
||||||
// HttpOnly: false로 설정하여 클라이언트에서 읽을 수 있게 함
|
|
||||||
cookieStore.set('token_refreshed_at', Date.now().toString(), {
|
cookieStore.set('token_refreshed_at', Date.now().toString(), {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
secure: isProduction,
|
secure: isProduction,
|
||||||
@@ -76,24 +74,10 @@ async function setNewTokenCookies(tokens: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* API 헤더 생성 (Server Side)
|
* API 헤더 생성 (Server Side)
|
||||||
*
|
|
||||||
* 🆕 미들웨어에서 전달한 새 토큰 우선 사용
|
|
||||||
* - 미들웨어 pre-refresh 성공 시 request headers에 'x-refreshed-access-token' 설정
|
|
||||||
* - Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음
|
|
||||||
* - 따라서 request headers를 먼저 확인
|
|
||||||
*/
|
*/
|
||||||
export async function getServerApiHeaders(token?: string): Promise<HeadersInit> {
|
export async function getServerApiHeaders(token?: string): Promise<HeadersInit> {
|
||||||
// 🆕 미들웨어에서 전달한 새 토큰 먼저 확인
|
|
||||||
const headerStore = await headers();
|
|
||||||
const refreshedAccessToken = headerStore.get('x-refreshed-access-token');
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const accessToken = token || refreshedAccessToken || cookieStore.get('access_token')?.value;
|
const accessToken = token || cookieStore.get('access_token')?.value;
|
||||||
|
|
||||||
// 디버깅: 어떤 토큰을 사용하는지 로그
|
|
||||||
if (refreshedAccessToken) {
|
|
||||||
console.log('🔵 [getServerApiHeaders] Using refreshed token from middleware headers');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
@@ -106,16 +90,13 @@ export async function getServerApiHeaders(token?: string): Promise<HeadersInit>
|
|||||||
/**
|
/**
|
||||||
* Server Action용 Fetch Wrapper
|
* Server Action용 Fetch Wrapper
|
||||||
*
|
*
|
||||||
* 🔄 토큰 갱신 로직:
|
* 401 감지 → refresh → retry 는 authenticatedFetch 게이트웨이에 위임.
|
||||||
* 1. 현재 access_token으로 요청
|
* 이 함수는 쿠키 읽기/설정/삭제, 리다이렉트만 담당.
|
||||||
* 2. 401 응답 시 → refresh_token으로 새 토큰 발급
|
|
||||||
* 3. 새 토큰으로 원래 요청 재시도
|
|
||||||
* 4. 재시도도 실패하면 → 로그인 페이지로 리다이렉트
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* const { response, error } = await serverFetch(url, { method: 'GET' });
|
* const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||||
* if (error) return error; // 에러 응답 반환 (클라이언트에서 처리)
|
* if (error) return error;
|
||||||
* // response 사용...
|
* // response 사용...
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@@ -126,18 +107,14 @@ export async function serverFetch(
|
|||||||
}
|
}
|
||||||
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
|
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
|
||||||
try {
|
try {
|
||||||
// 🆕 미들웨어에서 전달한 새 refresh_token 먼저 확인
|
|
||||||
const headerStore = await headers();
|
|
||||||
const refreshedRefreshToken = headerStore.get('x-refreshed-refresh-token');
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = refreshedRefreshToken || cookieStore.get('refresh_token')?.value;
|
const refreshToken = cookieStore.get('refresh_token')?.value;
|
||||||
|
|
||||||
const baseHeaders = await getServerApiHeaders() as Record<string, string>;
|
const baseHeaders = await getServerApiHeaders() as Record<string, string>;
|
||||||
|
|
||||||
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
|
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
|
||||||
const isFormData = options?.body instanceof FormData;
|
const isFormData = options?.body instanceof FormData;
|
||||||
const requestHeaders: HeadersInit = isFormData
|
const requestHeaders: Record<string, string> = isFormData
|
||||||
? {
|
? {
|
||||||
Accept: baseHeaders.Accept,
|
Accept: baseHeaders.Accept,
|
||||||
Authorization: baseHeaders.Authorization,
|
Authorization: baseHeaders.Authorization,
|
||||||
@@ -145,64 +122,30 @@ export async function serverFetch(
|
|||||||
}
|
}
|
||||||
: baseHeaders;
|
: baseHeaders;
|
||||||
|
|
||||||
let response = await fetch(url, {
|
// authenticatedFetch 게이트웨이로 요청 실행
|
||||||
...options,
|
// skipAuthCheck=true면 refreshToken을 넘기지 않아 401 시 갱신 안 함
|
||||||
headers: {
|
const { response, newTokens, authFailed } = await authenticatedFetch(
|
||||||
...requestHeaders,
|
url,
|
||||||
...options?.headers,
|
{
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...requestHeaders,
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
cache: options?.cache || 'no-store',
|
||||||
},
|
},
|
||||||
cache: options?.cache || 'no-store',
|
options?.skipAuthCheck ? undefined : refreshToken,
|
||||||
});
|
'serverFetch'
|
||||||
|
);
|
||||||
|
|
||||||
// 🔄 401 응답 시 토큰 갱신 후 재시도
|
// 새 토큰 → 쿠키 저장
|
||||||
if (response.status === 401 && !options?.skipAuthCheck && refreshToken) {
|
if (newTokens) {
|
||||||
console.log('🔄 [serverFetch] Got 401, attempting token refresh...');
|
await setNewTokenCookies(newTokens);
|
||||||
|
}
|
||||||
|
|
||||||
const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch');
|
// 인증 실패 → 쿠키 삭제 + 로그인 리다이렉트
|
||||||
|
if (authFailed) {
|
||||||
if (refreshResult.success && refreshResult.accessToken) {
|
await clearTokenCookies();
|
||||||
console.log('✅ [serverFetch] Token refreshed, retrying original request...');
|
|
||||||
|
|
||||||
// 새 토큰을 쿠키에 저장
|
|
||||||
await setNewTokenCookies(refreshResult);
|
|
||||||
|
|
||||||
// 새 토큰으로 원래 요청 재시도
|
|
||||||
const newBaseHeaders = await getServerApiHeaders(refreshResult.accessToken) as Record<string, string>;
|
|
||||||
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
|
|
||||||
const newRequestHeaders: HeadersInit = isFormData
|
|
||||||
? {
|
|
||||||
Accept: newBaseHeaders.Accept,
|
|
||||||
Authorization: newBaseHeaders.Authorization,
|
|
||||||
'X-API-KEY': newBaseHeaders['X-API-KEY'],
|
|
||||||
}
|
|
||||||
: newBaseHeaders;
|
|
||||||
response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
...newRequestHeaders,
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
cache: options?.cache || 'no-store',
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔵 [serverFetch] Retry response status:', response.status);
|
|
||||||
|
|
||||||
// 재시도도 401이면 로그인으로
|
|
||||||
if (response.status === 401) {
|
|
||||||
console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...');
|
|
||||||
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
|
||||||
redirect('/login');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 리프레시 실패 → 로그인 페이지로
|
|
||||||
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
|
|
||||||
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
|
||||||
redirect('/login');
|
|
||||||
}
|
|
||||||
} else if (response.status === 401 && !options?.skipAuthCheck) {
|
|
||||||
// refresh_token이 없는 경우
|
|
||||||
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
|
|
||||||
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +160,7 @@ export async function serverFetch(
|
|||||||
|
|
||||||
return { response, error: null };
|
return { response, error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw해서 Next.js가 처리하도록 함
|
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw
|
||||||
if (isNextRedirectError(error)) throw error;
|
if (isNextRedirectError(error)) throw error;
|
||||||
console.error(`[serverFetch] Network error: ${url}`, error);
|
console.error(`[serverFetch] Network error: ${url}`, error);
|
||||||
return {
|
return {
|
||||||
@@ -278,4 +221,4 @@ export async function serverApiCall<T>(
|
|||||||
|
|
||||||
const data = await parseJsonResponse<T>(response);
|
const data = await parseJsonResponse<T>(response);
|
||||||
return { data, error: null };
|
return { data, error: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export {
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { AUTH_CONFIG } from './auth/auth-config';
|
import { AUTH_CONFIG } from './auth/auth-config';
|
||||||
import { refreshAccessToken } from './refresh-token';
|
import { authenticatedFetch } from './authenticated-fetch';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,15 +34,14 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|||||||
* 특징:
|
* 특징:
|
||||||
* - 쿠키에서 access_token 자동 읽기
|
* - 쿠키에서 access_token 자동 읽기
|
||||||
* - X-API-KEY + Bearer 토큰 자동 포함
|
* - X-API-KEY + Bearer 토큰 자동 포함
|
||||||
* - 401 발생 시 토큰 자동 갱신 후 재시도
|
* - 401 발생 시 authenticatedFetch 게이트웨이를 통한 자동 갱신
|
||||||
*/
|
*/
|
||||||
class ServerApiClient {
|
class ServerApiClient {
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// API URL에 /api/v1 prefix 자동 추가
|
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, '');
|
||||||
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, ''); // trailing slash 제거
|
|
||||||
this.baseURL = `${apiUrl}/api/v1`;
|
this.baseURL = `${apiUrl}/api/v1`;
|
||||||
this.apiKey = process.env.API_KEY || '';
|
this.apiKey = process.env.API_KEY || '';
|
||||||
}
|
}
|
||||||
@@ -115,7 +114,7 @@ class ServerApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP 요청 실행 (토큰 자동 갱신 포함)
|
* HTTP 요청 실행 (authenticatedFetch 게이트웨이를 통한 자동 갱신)
|
||||||
*/
|
*/
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
@@ -127,47 +126,30 @@ class ServerApiClient {
|
|||||||
const headers = await this.getAuthHeaders();
|
const headers = await this.getAuthHeaders();
|
||||||
|
|
||||||
const url = `${this.baseURL}${endpoint}`;
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
let response = await fetch(url, {
|
|
||||||
...options,
|
// authenticatedFetch 게이트웨이로 요청 실행
|
||||||
headers: {
|
// skipAuthRetry=true면 refreshToken을 넘기지 않아 401 시 갱신 안 함
|
||||||
...headers,
|
const { response, newTokens, authFailed } = await authenticatedFetch(
|
||||||
...options?.headers,
|
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) {
|
if (newTokens) {
|
||||||
console.log('🔄 [ServerApiClient] 401 발생, 토큰 갱신 시도...');
|
await this.setNewTokenCookies(newTokens);
|
||||||
|
}
|
||||||
|
|
||||||
const refreshResult = await refreshAccessToken(refreshToken, 'ServerApiClient');
|
// 인증 실패 → 쿠키 삭제 + 리다이렉트
|
||||||
|
if (authFailed && !options?.skipAuthRetry) {
|
||||||
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 없음), 로그인 리다이렉트');
|
|
||||||
await this.clearTokenCookies();
|
await this.clearTokenCookies();
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
@@ -247,4 +229,4 @@ class ServerApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 서버 액션용 API 클라이언트 인스턴스
|
// 서버 액션용 API 클라이언트 인스턴스
|
||||||
export const apiClient = new ServerApiClient();
|
export const apiClient = new ServerApiClient();
|
||||||
|
|||||||
@@ -20,17 +20,29 @@ export type RefreshResult = {
|
|||||||
expiresIn?: number;
|
expiresIn?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 캐시 상태 (모듈 레벨에서 공유)
|
// 캐시 상태 (globalThis 레벨에서 공유)
|
||||||
let refreshCache: {
|
// ⚠️ 모듈 레벨 변수는 Next.js가 API route와 Server Actions를 별도 모듈로 컴파일하면
|
||||||
|
// 각각 다른 인스턴스를 가져서 캐시가 공유되지 않음
|
||||||
|
// → globalThis를 사용하여 같은 Node.js 프로세스 내 모든 모듈에서 동일한 캐시 참조
|
||||||
|
type RefreshCache = {
|
||||||
promise: Promise<RefreshResult> | null;
|
promise: Promise<RefreshResult> | null;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
result: RefreshResult | null;
|
result: RefreshResult | null;
|
||||||
} = {
|
|
||||||
promise: null,
|
|
||||||
timestamp: 0,
|
|
||||||
result: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GLOBAL_CACHE_KEY = '__sam_refresh_token_cache__';
|
||||||
|
|
||||||
|
function getRefreshCache(): RefreshCache {
|
||||||
|
if (!(globalThis as Record<string, unknown>)[GLOBAL_CACHE_KEY]) {
|
||||||
|
(globalThis as Record<string, unknown>)[GLOBAL_CACHE_KEY] = {
|
||||||
|
promise: null,
|
||||||
|
timestamp: 0,
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (globalThis as Record<string, unknown>)[GLOBAL_CACHE_KEY] as RefreshCache;
|
||||||
|
}
|
||||||
|
|
||||||
const REFRESH_CACHE_TTL = 5000; // 5초
|
const REFRESH_CACHE_TTL = 5000; // 5초
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,47 +109,47 @@ export async function refreshAccessToken(
|
|||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
caller: string = 'unknown'
|
caller: string = 'unknown'
|
||||||
): Promise<RefreshResult> {
|
): Promise<RefreshResult> {
|
||||||
|
const cache = getRefreshCache();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// 1. 캐시된 성공 결과가 유효하면 즉시 반환
|
// 1. 캐시된 성공 결과가 유효하면 즉시 반환
|
||||||
if (refreshCache.result && refreshCache.result.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
|
if (cache.result && cache.result.success && now - cache.timestamp < REFRESH_CACHE_TTL) {
|
||||||
console.log(`🔵 [${caller}] Using cached refresh result (age: ${now - refreshCache.timestamp}ms)`);
|
console.log(`🔵 [${caller}] Using cached refresh result (age: ${now - cache.timestamp}ms)`);
|
||||||
return refreshCache.result;
|
return cache.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 진행 중인 refresh가 있고, 아직 결과가 없으면 기다림 (실패한 결과는 캐시 안 함)
|
// 2. 진행 중인 refresh가 있고, 아직 결과가 없으면 기다림 (실패한 결과는 캐시 안 함)
|
||||||
if (refreshCache.promise && !refreshCache.result && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
|
if (cache.promise && !cache.result && now - cache.timestamp < REFRESH_CACHE_TTL) {
|
||||||
console.log(`🔵 [${caller}] Waiting for ongoing refresh...`);
|
console.log(`🔵 [${caller}] Waiting for ongoing refresh...`);
|
||||||
return refreshCache.promise;
|
return cache.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2-1. 이전 refresh가 실패했으면 캐시 초기화
|
// 2-1. 이전 refresh가 실패했으면 캐시 초기화
|
||||||
if (refreshCache.result && !refreshCache.result.success) {
|
if (cache.result && !cache.result.success) {
|
||||||
console.log(`🔄 [${caller}] Previous refresh failed, clearing cache...`);
|
console.log(`🔄 [${caller}] Previous refresh failed, clearing cache...`);
|
||||||
refreshCache.promise = null;
|
cache.promise = null;
|
||||||
refreshCache.result = null;
|
cache.result = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 새 refresh 시작
|
// 3. 새 refresh 시작
|
||||||
console.log(`🔄 [${caller}] Starting new refresh request...`);
|
console.log(`🔄 [${caller}] Starting new refresh request...`);
|
||||||
refreshCache.timestamp = now;
|
cache.timestamp = now;
|
||||||
refreshCache.result = null;
|
cache.result = null;
|
||||||
|
|
||||||
refreshCache.promise = doRefreshToken(refreshToken).then(result => {
|
cache.promise = doRefreshToken(refreshToken).then(result => {
|
||||||
refreshCache.result = result;
|
cache.result = result;
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
return refreshCache.promise;
|
return cache.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐시 초기화 (테스트용)
|
* 캐시 초기화 (테스트용)
|
||||||
*/
|
*/
|
||||||
export function clearRefreshCache(): void {
|
export function clearRefreshCache(): void {
|
||||||
refreshCache = {
|
const cache = getRefreshCache();
|
||||||
promise: null,
|
cache.promise = null;
|
||||||
timestamp: 0,
|
cache.timestamp = 0;
|
||||||
result: null,
|
cache.result = null;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user