690406:2310 Done Task BE-ERR-01
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
// File: frontend/components/common/error-display.tsx
|
||||
// ADR-007: Component แสดง Error พร้อม Recovery Actions สำหรับ User
|
||||
|
||||
import { AlertTriangle, XCircle, Info } from 'lucide-react';
|
||||
|
||||
// รูปแบบ Error Response จาก Backend (ADR-007)
|
||||
export interface ApiErrorPayload {
|
||||
type: string;
|
||||
code: string;
|
||||
message: string;
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
timestamp: string;
|
||||
statusCode?: number;
|
||||
recoveryActions?: string[];
|
||||
technicalMessage?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: ApiErrorPayload;
|
||||
}
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: ApiErrorResponse | ApiErrorPayload | null | undefined;
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// แปลง severity เป็น color class
|
||||
function getSeverityStyles(severity: string): {
|
||||
container: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
iconComponent: React.ElementType;
|
||||
} {
|
||||
switch (severity) {
|
||||
case 'LOW':
|
||||
return {
|
||||
container: 'border-yellow-200 bg-yellow-50',
|
||||
icon: 'text-yellow-400',
|
||||
title: 'text-yellow-800',
|
||||
iconComponent: Info,
|
||||
};
|
||||
case 'MEDIUM':
|
||||
return {
|
||||
container: 'border-orange-200 bg-orange-50',
|
||||
icon: 'text-orange-400',
|
||||
title: 'text-orange-800',
|
||||
iconComponent: AlertTriangle,
|
||||
};
|
||||
case 'HIGH':
|
||||
return {
|
||||
container: 'border-red-200 bg-red-50',
|
||||
icon: 'text-red-400',
|
||||
title: 'text-red-700',
|
||||
iconComponent: AlertTriangle,
|
||||
};
|
||||
case 'CRITICAL':
|
||||
return {
|
||||
container: 'border-red-300 bg-red-100',
|
||||
icon: 'text-red-500',
|
||||
title: 'text-red-900',
|
||||
iconComponent: XCircle,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
container: 'border-gray-200 bg-gray-50',
|
||||
icon: 'text-gray-400',
|
||||
title: 'text-gray-700',
|
||||
iconComponent: AlertTriangle,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ดึง ErrorPayload ออกจาก response ที่อาจซ้อนอยู่
|
||||
function extractErrorPayload(
|
||||
error: ApiErrorResponse | ApiErrorPayload | null | undefined
|
||||
): ApiErrorPayload | null {
|
||||
if (!error) return null;
|
||||
|
||||
// กรณีที่ error เป็น { error: { ... } }
|
||||
if ('error' in error && error.error && typeof error.error === 'object') {
|
||||
return error.error as ApiErrorPayload;
|
||||
}
|
||||
|
||||
// กรณีที่ error เป็น payload โดยตรง
|
||||
if ('type' in error && 'message' in error) {
|
||||
return error as ApiErrorPayload;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ErrorDisplay({ error, onRetry, className = '', compact = false }: ErrorDisplayProps) {
|
||||
const payload = extractErrorPayload(error);
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
const styles = getSeverityStyles(payload.severity);
|
||||
const IconComponent = styles.iconComponent;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`rounded-md border p-3 ${styles.container} ${className}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<IconComponent className={`mt-0.5 h-4 w-4 flex-shrink-0 ${styles.icon}`} />
|
||||
<p className={`text-sm ${styles.title}`}>{payload.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${styles.container} ${className}`} role="alert">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<IconComponent className={`h-5 w-5 ${styles.icon}`} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
{/* หัวข้อ Error */}
|
||||
<h3 className={`text-sm font-medium ${styles.title}`}>
|
||||
{payload.message}
|
||||
</h3>
|
||||
|
||||
{/* Recovery Actions */}
|
||||
{payload.recoveryActions && payload.recoveryActions.length > 0 && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-700">วิธีแก้ไข:</p>
|
||||
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
||||
{payload.recoveryActions.map((action, index) => (
|
||||
<li key={index}>{action}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Code (แสดงเฉพาะ Development) */}
|
||||
{process.env.NODE_ENV === 'development' && payload.technicalMessage && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-700">
|
||||
รายละเอียดทางเทคนิค (Development)
|
||||
</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded bg-gray-100 p-2 text-xs text-gray-600">
|
||||
{payload.technicalMessage}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open('mailto:support@np-dms.work', '_blank')}
|
||||
className="rounded-md bg-gray-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-1"
|
||||
>
|
||||
ติดต่อผู้ดูแลระบบ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: แปลง Axios/Fetch error เป็น ApiErrorResponse
|
||||
export function parseApiError(error: unknown): ApiErrorResponse {
|
||||
// กรณี error มาจาก Axios
|
||||
if (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'response' in error &&
|
||||
(error as { response?: { data?: unknown } }).response?.data
|
||||
) {
|
||||
const data = (error as { response: { data: unknown } }).response.data;
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'error' in data
|
||||
) {
|
||||
return data as ApiErrorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// กรณี error เป็น ApiErrorResponse อยู่แล้ว
|
||||
if (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'error' in error &&
|
||||
(error as ApiErrorResponse).error?.message
|
||||
) {
|
||||
return error as ApiErrorResponse;
|
||||
}
|
||||
|
||||
// กรณี Network Error หรือ Unknown
|
||||
return {
|
||||
error: {
|
||||
type: 'INTERNAL_ERROR',
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้',
|
||||
severity: 'HIGH',
|
||||
timestamp: new Date().toISOString(),
|
||||
recoveryActions: ['ตรวจสอบการเชื่อมต่ออินเทอร์เน็ต', 'ลองใหม่ภายหลัง'],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -90,6 +90,78 @@ apiClient.interceptors.request.use(
|
||||
// Response Interceptors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// รูปแบบ Error Response จาก Backend (ADR-007)
|
||||
export interface ApiErrorPayload {
|
||||
type: string;
|
||||
code: string;
|
||||
message: string;
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
timestamp: string;
|
||||
statusCode?: number;
|
||||
recoveryActions?: string[];
|
||||
technicalMessage?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: ApiErrorPayload;
|
||||
}
|
||||
|
||||
// แปลง Axios error เป็น Structured Error Response (ADR-007)
|
||||
export function parseApiError(axiosError: AxiosError): ApiErrorResponse {
|
||||
if (axiosError.response?.data) {
|
||||
const data = axiosError.response.data;
|
||||
// กรณีที่ backend ส่ง { error: { ... } } ตาม ADR-007
|
||||
if (typeof data === 'object' && data !== null && 'error' in data) {
|
||||
return data as ApiErrorResponse;
|
||||
}
|
||||
// กรณี NestJS validation error { message: [...], statusCode: 400 }
|
||||
if (typeof data === 'object' && data !== null && 'message' in data) {
|
||||
const status = axiosError.response.status;
|
||||
return {
|
||||
error: {
|
||||
type: 'VALIDATION',
|
||||
code: 'HTTP_ERROR',
|
||||
message: Array.isArray((data as Record<string, unknown>).message)
|
||||
? 'ข้อมูลที่กรอกไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่'
|
||||
: String((data as Record<string, unknown>).message),
|
||||
severity: status >= 500 ? 'HIGH' : 'MEDIUM',
|
||||
timestamp: new Date().toISOString(),
|
||||
statusCode: status,
|
||||
recoveryActions: ['ตรวจสอบข้อมูลที่กรอก', 'แก้ไขข้อมูลที่ผิดพลาด', 'ลองใหม่อีกครั้ง'],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// กรณี Network Error
|
||||
if (!axiosError.response) {
|
||||
return {
|
||||
error: {
|
||||
type: 'INFRASTRUCTURE',
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้',
|
||||
severity: 'HIGH',
|
||||
timestamp: new Date().toISOString(),
|
||||
recoveryActions: ['ตรวจสอบการเชื่อมต่ออินเทอร์เน็ต', 'ลองใหม่ภายหลัง'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
error: {
|
||||
type: 'INTERNAL_ERROR',
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่ภายหลัง',
|
||||
severity: 'HIGH',
|
||||
timestamp: new Date().toISOString(),
|
||||
statusCode: axiosError.response?.status,
|
||||
recoveryActions: ['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
@@ -107,7 +179,9 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
// แปลง error เป็น structured format ตาม ADR-007 ก่อน reject
|
||||
const structuredError = parseApiError(error);
|
||||
return Promise.reject(structuredError);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user