Merge pull request 'Finishline' (#113) from finishline into main

Reviewed-on: #113
This commit was merged in pull request #113.
This commit is contained in:
2026-09-27 14:36:16 +02:00
5 changed files with 233 additions and 295 deletions
@@ -155,22 +155,43 @@ const ToastCard: React.FC<{
)} )}
</div> </div>
{/* Display errors from ProblemDetails comma-delimited detail or errors list */} {/* Display comma-separated error items from detail property one under another */}
{toast.errorsList && toast.errorsList.length > 1 ? ( {toast.errorsList && toast.errorsList.length > 0 ? (
<div className="mt-1.5 space-y-1"> <div className="mt-2 space-y-1.5">
<ul className="space-y-1"> <ul className="space-y-1.5">
{toast.errorsList.map((err, idx) => ( {toast.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5"> <li
<span className="text-rose-400 mt-0.5 font-bold">•</span> key={idx}
<span className="leading-snug break-words">{err}</span> className="text-xs text-rose-200/95 flex items-start gap-2 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
>
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
<span className="leading-snug break-words font-medium">{err}</span>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
) : toast.message ? ( ) : toast.message ? (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words"> <div className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message} {toast.message.includes('\n') ? (
</p> <ul className="space-y-1.5 mt-1">
{toast.message
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((line, idx) => (
<li
key={idx}
className="flex items-start gap-2 text-rose-200/95 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
>
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
<span className="leading-snug break-words font-medium">{line.replace(/^[•\-*]\s*/, '')}</span>
</li>
))}
</ul>
) : (
<p>{toast.message}</p>
)}
</div>
) : null} ) : null}
{/* Optional collapsible technical reference */} {/* Optional collapsible technical reference */}
@@ -310,14 +331,32 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
// Parse friendly non-technical error with ProblemDetails awareness // Parse friendly non-technical error with ProblemDetails awareness
const parsed = parseFriendlyError(rawError, actionTitle); const parsed = parseFriendlyError(rawError, actionTitle);
addToast('error', parsed.title, parsed.message, { let finalMessage = parsed.message;
if (
typeof messageOrError === 'string' &&
messageOrError.trim() &&
!messageOrError.includes('Request failed with status code') &&
(!parsed.hasReturnObject || !parsed.errorsList?.length)
) {
finalMessage = messageOrError.trim();
}
let finalErrorsList = parsed.errorsList || options?.errorsList;
if (!finalErrorsList && typeof finalMessage === 'string' && finalMessage.includes(',')) {
const parts = finalMessage.split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length > 1) {
finalErrorsList = parts;
}
}
addToast('error', parsed.title, finalMessage, {
duration: options?.duration || DEFAULT_DURATIONS.error, duration: options?.duration || DEFAULT_DURATIONS.error,
statusCode: parsed.statusCode || options?.statusCode, statusCode: parsed.statusCode || options?.statusCode,
category: options?.category || parsed.category, category: options?.category || parsed.category,
categoryLabel: options?.categoryLabel || parsed.categoryLabel, categoryLabel: options?.categoryLabel || parsed.categoryLabel,
technicalDetails: parsed.technicalDetails || options?.technicalDetails, technicalDetails: parsed.technicalDetails || options?.technicalDetails,
fieldErrors: parsed.fieldErrors || options?.fieldErrors, fieldErrors: parsed.fieldErrors || options?.fieldErrors,
errorsList: parsed.errorsList || options?.errorsList, errorsList: finalErrorsList,
}); });
}; };
@@ -163,7 +163,7 @@ export const BeneficiariesPage: React.FC = () => {
benForm.reset(); benForm.reset();
success('Beneficiary Registered', 'New project beneficiary profile successfully created.'); success('Beneficiary Registered', 'New project beneficiary profile successfully created.');
}, },
onError: (err: any) => toastError('Registration Failed', err.message || 'Unable to save beneficiary.'), onError: (err: any) => toastError('Registration Failed', err),
}); });
const updateBenMutation = useMutation({ const updateBenMutation = useMutation({
@@ -185,7 +185,7 @@ export const BeneficiariesPage: React.FC = () => {
benForm.reset(); benForm.reset();
success('Beneficiary Updated', 'Beneficiary details and targets updated.'); success('Beneficiary Updated', 'Beneficiary details and targets updated.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update beneficiary.'), onError: (err: any) => toastError('Update Failed', err),
}); });
const createCompMutation = useMutation({ const createCompMutation = useMutation({
@@ -199,7 +199,7 @@ export const BeneficiariesPage: React.FC = () => {
compForm.reset(); compForm.reset();
success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.'); success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.');
}, },
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to record compliance item.'), onError: (err: any) => toastError('Creation Failed', err),
}); });
const updateCompMutation = useMutation({ const updateCompMutation = useMutation({
@@ -219,7 +219,7 @@ export const BeneficiariesPage: React.FC = () => {
compForm.reset(); compForm.reset();
success('Compliance Item Updated', 'Status and requirement record updated.'); success('Compliance Item Updated', 'Status and requirement record updated.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update compliance item.'), onError: (err: any) => toastError('Update Failed', err),
}); });
const createOutcomeMutation = useMutation({ const createOutcomeMutation = useMutation({
@@ -233,7 +233,7 @@ export const BeneficiariesPage: React.FC = () => {
outcomeForm.reset(); outcomeForm.reset();
success('Outcome Logged', 'Beneficiary outcome and impact metric registered.'); success('Outcome Logged', 'Beneficiary outcome and impact metric registered.');
}, },
onError: (err: any) => toastError('Failed to Record Outcome', err.message || 'Unable to save outcome.'), onError: (err: any) => toastError('Failed to Record Outcome', err),
}); });
// Filtered & Sorted Beneficiaries // Filtered & Sorted Beneficiaries
@@ -235,7 +235,7 @@ export const ChangeManagementPage: React.FC = () => {
issueForm.reset(); issueForm.reset();
success('Issue Logged', 'New project issue created and assigned.'); success('Issue Logged', 'New project issue created and assigned.');
}, },
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to log issue.'), onError: (err: any) => toastError('Creation Failed', err),
}); });
const updateIssueMutation = useMutation({ const updateIssueMutation = useMutation({
@@ -270,7 +270,7 @@ export const ChangeManagementPage: React.FC = () => {
editIssueForm.reset(); editIssueForm.reset();
success('Issue Updated', 'Issue record and resolution status saved successfully.'); success('Issue Updated', 'Issue record and resolution status saved successfully.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update issue.'), onError: (err: any) => toastError('Update Failed', err),
}); });
const resolveIssueMutation = useMutation({ const resolveIssueMutation = useMutation({
@@ -294,7 +294,7 @@ export const ChangeManagementPage: React.FC = () => {
resolveIssueForm.reset(); resolveIssueForm.reset();
success('Issue Resolved', 'Resolution recorded and issue status updated.'); success('Issue Resolved', 'Resolution recorded and issue status updated.');
}, },
onError: (err: any) => toastError('Resolution Failed', err.message || 'Unable to resolve issue.'), onError: (err: any) => toastError('Resolution Failed', err),
}); });
const createChangeMutation = useMutation({ const createChangeMutation = useMutation({
@@ -314,7 +314,7 @@ export const ChangeManagementPage: React.FC = () => {
changeForm.reset(); changeForm.reset();
success('Change Request Submitted', 'Formal change request submitted for review.'); success('Change Request Submitted', 'Formal change request submitted for review.');
}, },
onError: (err: any) => toastError('Submission Failed', err.message || 'Unable to submit change request.'), onError: (err: any) => toastError('Submission Failed', err),
}); });
const resolveChangeMutation = useMutation({ const resolveChangeMutation = useMutation({
@@ -335,7 +335,7 @@ export const ChangeManagementPage: React.FC = () => {
resolveChangeForm.reset(); resolveChangeForm.reset();
success('Change Decision Applied', 'Change request decision successfully resolved.'); success('Change Decision Applied', 'Change request decision successfully resolved.');
}, },
onError: (err: any) => toastError('Decision Failed', err.message || 'Unable to resolve change request.'), onError: (err: any) => toastError('Decision Failed', err),
}); });
const updateChangeMutation = useMutation({ const updateChangeMutation = useMutation({
@@ -356,7 +356,7 @@ export const ChangeManagementPage: React.FC = () => {
editChangeForm.reset(); editChangeForm.reset();
success('Change Request Updated', 'Change proposal and governance status saved.'); success('Change Request Updated', 'Change proposal and governance status saved.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update change request.'), onError: (err: any) => toastError('Update Failed', err),
}); });
// Filtered & Sorted Issues // Filtered & Sorted Issues
@@ -783,15 +783,26 @@ export const ChangeManagementPage: React.FC = () => {
</td> </td>
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2"> <td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
<button <button
onClick={() => openResolveIssue(iss)} onClick={() => !isResolved && openResolveIssue(iss)}
className="px-2.5 py-1 text-xs font-medium text-amber-400 hover:text-amber-300 bg-amber-950/40 hover:bg-amber-900/40 rounded-lg border border-amber-800/40 transition cursor-pointer" disabled={isResolved}
className={`px-2.5 py-1 text-xs font-medium rounded-lg border transition ${
isResolved
? 'opacity-40 cursor-not-allowed text-slate-500 bg-slate-900/40 border-slate-800 pointer-events-none'
: 'text-amber-400 hover:text-amber-300 bg-amber-950/40 hover:bg-amber-900/40 border-amber-800/40 cursor-pointer'
}`}
title={isResolved ? 'Issue is already resolved' : 'Resolve issue'}
> >
Resolve Resolve
</button> </button>
<button <button
onClick={() => openEditIssue(iss)} onClick={() => !isResolved && openEditIssue(iss)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer" disabled={isResolved}
title="Edit issue" className={`p-1 rounded-lg transition inline-flex items-center justify-center ${
isResolved
? 'opacity-40 cursor-not-allowed text-slate-600 pointer-events-none'
: 'text-slate-400 hover:text-white hover:bg-slate-800 cursor-pointer'
}`}
title={isResolved ? 'Resolved issues cannot be edited' : 'Edit issue'}
> >
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
</button> </button>
@@ -54,7 +54,6 @@ export const SiteVisitsPage: React.FC = () => {
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all'); const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'visits' | 'feedback'>('visits');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
@@ -112,7 +111,7 @@ export const SiteVisitsPage: React.FC = () => {
visitForm.reset(); visitForm.reset();
success('Site Visit Logged', 'Field inspection recorded with inspector findings.'); success('Site Visit Logged', 'Field inspection recorded with inspector findings.');
}, },
onError: (err: any) => toastError('Logging Failed', err.message || 'Unable to save site visit.'), onError: (err: any) => toastError('Logging Failed', err),
}); });
const updateVisitMutation = useMutation({ const updateVisitMutation = useMutation({
@@ -133,7 +132,7 @@ export const SiteVisitsPage: React.FC = () => {
visitForm.reset(); visitForm.reset();
success('Site Visit Updated', 'Inspection report and verification status updated.'); success('Site Visit Updated', 'Inspection report and verification status updated.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update site visit.'), onError: (err: any) => toastError('Update Failed', err),
}); });
// Filtered & Sorted Visits // Filtered & Sorted Visits
@@ -250,38 +249,9 @@ export const SiteVisitsPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Tabs */} {/* Site Visits Register View */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex border-b border-slate-800 bg-[#0d121f]/80 px-2 sm:px-4 rounded-2xl backdrop-blur-sm overflow-x-auto gap-2"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3 bg-[#0d121f]/60 p-3.5 rounded-2xl border border-slate-800/80">
<button
onClick={() => setActiveTab('visits')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
activeTab === 'visits'
? 'border-emerald-500 text-emerald-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<MapPin className="w-4 h-4" />
<span>Site Visit Reports ({siteVisits.length})</span>
</button>
<button
onClick={() => setActiveTab('feedback')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
activeTab === 'feedback'
? 'border-emerald-500 text-emerald-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<MessageSquare className="w-4 h-4" />
<span>Field Observations & Feedback ({siteVisits.length})</span>
</button>
</div>
{/* Tab 1: Visits Table View */}
{activeTab === 'visits' && (
<div className="space-y-4">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3 bg-[#0d121f]/60 p-3.5 rounded-2xl border border-slate-800/80">
<div className="flex items-center gap-2.5 flex-wrap flex-1"> <div className="flex items-center gap-2.5 flex-wrap flex-1">
{/* Search */} {/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm"> <div className="relative flex-1 min-w-[200px] max-w-sm">
@@ -449,174 +419,7 @@ export const SiteVisitsPage: React.FC = () => {
</table> </table>
</div> </div>
</div> </div>
</div> </div>
)}
{/* Tab 2: Field Observations & Feedback Cards */}
{activeTab === 'feedback' && (
<div className="space-y-4">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3 bg-[#0d121f]/60 p-3.5 rounded-2xl border border-slate-800/80">
<div className="flex items-center gap-2.5 flex-wrap flex-1">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
<input
type="text"
placeholder="Search inspector name or findings..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500 shadow-xs"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500"
>
<option value="all">All Verification Statuses</option>
<option value="approved">Approved & Verified</option>
<option value="pending">Pending / Under Review</option>
</select>
</div>
{/* Project Selector */}
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<FolderKanban className="w-3.5 h-3.5 text-emerald-400" />
<select
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500 max-w-[180px] truncate"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
{/* Actions: Refresh, Export, Log Site Visit */}
<div className="flex items-center gap-2 flex-wrap justify-end">
<button
type="button"
onClick={() => {
refetchVisits();
success('Refreshed', 'Site visit logs updated from API.');
}}
disabled={fetchingVisits}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-900 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800 transition cursor-pointer disabled:opacity-50"
title="Force refresh site visits"
>
<RefreshCw className={`w-3.5 h-3.5 text-emerald-400 ${fetchingVisits ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
data={sortedVisits}
columns={visitExportCols}
filename={`site_visits_${new Date().toISOString().split('T')[0]}`}
title="Site Visits Register"
/>
<button
onClick={() => {
setEditingVisit(null);
visitForm.reset({
inspectorName: '',
visitedAt: new Date().toISOString().split('T')[0],
findings: '',
status: ApprovalStatus.Pending,
});
setIsVisitModalOpen(true);
}}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-md shadow-emerald-600/25 transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Log Site Visit</span>
</button>
</div>
</div>
{loadingVisits ? (
<div className="py-12 text-center text-slate-400 bg-[#0f1523]/80 rounded-2xl border border-slate-800">
<div className="flex flex-col items-center gap-2">
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
<span>Loading observation cards...</span>
</div>
</div>
) : sortedVisits.length === 0 ? (
<div className="py-12 text-center text-slate-400 bg-[#0f1523]/80 rounded-2xl border border-slate-800">
No site visit observations or feedback found matching criteria.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sortedVisits.map((v) => (
<div
key={v.id}
className="bg-[#0f1523]/90 rounded-2xl border border-slate-800/80 p-5 shadow-xl hover:border-emerald-500/40 transition flex flex-col justify-between gap-4 relative overflow-hidden group"
>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="h-9 w-9 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
<User className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-bold text-white group-hover:text-emerald-300 transition">
{v.inspectorName || 'Field Auditor'}
</h4>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
<Calendar className="w-3 h-3 text-slate-500" />
<span>{formatDate(v.visitedAt)}</span>
</div>
</div>
</div>
<div>
<ApprovalStatusBadge status={v.status} />
</div>
</div>
<div className="bg-slate-900/70 p-3.5 rounded-xl border border-slate-800/60 text-xs text-slate-300 leading-relaxed max-h-36 overflow-y-auto">
<p className="font-semibold text-slate-400 text-[10px] uppercase tracking-wider mb-1 flex items-center gap-1.5">
<FileText className="w-3 h-3 text-emerald-400" /> Field Observations & Findings
</p>
{v.findings || 'No detailed findings recorded.'}
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-800/60 text-xs">
<span className="text-[11px] text-slate-500 font-mono">ID #{v.id}</span>
<div className="flex items-center gap-2">
<button
onClick={() => {
setSelectedVisitForDetail(v);
setIsDetailModalOpen(true);
}}
className="px-3 py-1 text-xs font-semibold text-emerald-400 hover:text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/50 border border-emerald-800/50 rounded-lg transition cursor-pointer flex items-center gap-1"
>
<MessageSquare className="w-3 h-3" />
<span>Detailed Review</span>
</button>
<button
onClick={() => handleOpenEdit(v)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
title="Edit site visit"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Modal: Create/Edit Site Visit */} {/* Modal: Create/Edit Site Visit */}
<Modal <Modal
@@ -29,6 +29,8 @@ export interface ProblemDetailsResponse {
title?: string | null; title?: string | null;
status?: number | null; status?: number | null;
detail?: string | null; detail?: string | null;
details?: string | null;
Details?: string | null;
instance?: string | null; instance?: string | null;
errors?: Record<string, string[] | string> | any; errors?: Record<string, string[] | string> | any;
[key: string]: any; [key: string]: any;
@@ -124,7 +126,17 @@ export function inspectErrorResponseObject(error: unknown): ProblemDetailsRespon
// 2. Direct object inspection (e.g. if response.data was passed directly) // 2. Direct object inspection (e.g. if response.data was passed directly)
if (typeof error === 'object' && error !== null) { if (typeof error === 'object' && error !== null) {
if ('detail' in anyErr || 'status' in anyErr || 'title' in anyErr || 'type' in anyErr) { if (
'details' in anyErr ||
'Details' in anyErr ||
'detail' in anyErr ||
'Detail' in anyErr ||
'status' in anyErr ||
'title' in anyErr ||
'type' in anyErr ||
'errors' in anyErr ||
'Errors' in anyErr
) {
return anyErr as ProblemDetailsResponse; return anyErr as ProblemDetailsResponse;
} }
} }
@@ -138,8 +150,11 @@ export function inspectErrorResponseObject(error: unknown): ProblemDetailsRespon
export function isProblemDetailsObject(data: any): data is ProblemDetails { export function isProblemDetailsObject(data: any): data is ProblemDetails {
if (!data || typeof data !== 'object') return false; if (!data || typeof data !== 'object') return false;
return ( return (
'details' in data ||
'Details' in data ||
'title' in data || 'title' in data ||
'detail' in data || 'detail' in data ||
'Detail' in data ||
'status' in data || 'status' in data ||
'type' in data || 'type' in data ||
'errors' in data || 'errors' in data ||
@@ -163,9 +178,13 @@ export function parseCommaDelimitedErrors(detail?: string | null): string[] {
const errors: string[] = []; const errors: string[] = [];
for (const part of rawParts) { for (const part of rawParts) {
const cleaned = cleanTechnicalJargon(part.trim()); const pTrim = part.trim();
if (!pTrim) continue;
const cleaned = cleanTechnicalJargon(pTrim);
if (cleaned && cleaned.length > 0) { if (cleaned && cleaned.length > 0) {
errors.push(cleaned); errors.push(cleaned);
} else if (pTrim.length > 0 && pTrim.toLowerCase() !== 'error') {
errors.push(pTrim);
} }
} }
@@ -301,7 +320,16 @@ export function parseFriendlyError(
// 3. Special handling for Auth (401) and Forbid (403) // 3. Special handling for Auth (401) and Forbid (403)
if (statusCode === 401) { if (statusCode === 401) {
const rawDetail = returnObject?.detail || (error as any)?.message; const rawDetail =
returnObject?.details ??
returnObject?.Details ??
returnObject?.detail ??
returnObject?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.response?.data?.detail ??
anyErr?.details ??
anyErr?.message;
const cleanDetail = cleanTechnicalJargon(rawDetail || ''); const cleanDetail = cleanTechnicalJargon(rawDetail || '');
return { return {
title: 'Authentication Required', title: 'Authentication Required',
@@ -316,7 +344,16 @@ export function parseFriendlyError(
} }
if (statusCode === 403) { if (statusCode === 403) {
const rawDetail = returnObject?.detail || (error as any)?.message; const rawDetail =
returnObject?.details ??
returnObject?.Details ??
returnObject?.detail ??
returnObject?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.response?.data?.detail ??
anyErr?.details ??
anyErr?.message;
const detailErrors = parseCommaDelimitedErrors(rawDetail); const detailErrors = parseCommaDelimitedErrors(rawDetail);
const friendlyMessage = const friendlyMessage =
detailErrors.length > 0 detailErrors.length > 0
@@ -340,16 +377,38 @@ export function parseFriendlyError(
// 4. Case A: Response has a valid return object (e.g. RFC 9110 ProblemDetails) // 4. Case A: Response has a valid return object (e.g. RFC 9110 ProblemDetails)
if (hasReturnObject && returnObject) { if (hasReturnObject && returnObject) {
// Priority: 'detail' property from response, then 'details', etc.
const rawDetail = const rawDetail =
returnObject.detail ?? returnObject.detail ??
returnObject.Detail ?? returnObject.Detail ??
returnObject.details ??
returnObject.Details ??
anyErr?.response?.data?.detail ??
anyErr?.response?.data?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.detail ??
anyErr?.Detail ??
anyErr?.details ??
anyErr?.Details ??
returnObject.message ?? returnObject.message ??
returnObject.error; returnObject.Message ??
returnObject.error ??
returnObject.Error;
// Parse comma-delimited errors from detail property // Parse errors list from details property
const errorsList = parseCommaDelimitedErrors(rawDetail); const errorsList: string[] = [];
// If detail was empty, check if errors array/dictionary exists if (Array.isArray(rawDetail)) {
rawDetail.forEach((e: any) => {
const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e));
if (cleaned) errorsList.push(cleaned);
});
} else if (typeof rawDetail === 'string' && rawDetail.trim()) {
errorsList.push(...parseCommaDelimitedErrors(rawDetail));
}
// If details was empty, check if errors array/dictionary exists
if (errorsList.length === 0 && returnObject.errors) { if (errorsList.length === 0 && returnObject.errors) {
if (Array.isArray(returnObject.errors)) { if (Array.isArray(returnObject.errors)) {
returnObject.errors.forEach((e: any) => { returnObject.errors.forEach((e: any) => {
@@ -406,7 +465,7 @@ export function parseFriendlyError(
} else if (errorsList.length === 1) { } else if (errorsList.length === 1) {
friendlyMessage = errorsList[0]; friendlyMessage = errorsList[0];
} else { } else {
// Fallback message if detail was empty string // Fallback message ONLY if details property was empty
friendlyMessage = friendlyMessage =
operationIntent === 'creation' operationIntent === 'creation'
? 'Failed to create record. Please verify the submitted parameters.' ? 'Failed to create record. Please verify the submitted parameters.'
@@ -440,77 +499,103 @@ export function parseFriendlyError(
let category: ErrorCategory = 'general'; let category: ErrorCategory = 'general';
let categoryLabel: string = 'Error'; let categoryLabel: string = 'Error';
switch (statusCode) { // Check if error or response data has a detail or details property
case 400: const directDetail =
category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation'; anyErr?.response?.data?.detail ??
categoryLabel = 'Bad Request'; anyErr?.response?.data?.Detail ??
message = anyErr?.response?.data?.details ??
operationIntent === 'creation' anyErr?.response?.data?.Details ??
? 'The new record could not be created. Please review the submitted parameters.' anyErr?.detail ??
: operationIntent === 'update' anyErr?.Detail ??
? 'The record could not be updated. Please verify the submitted parameters.' anyErr?.details ??
: 'The request could not be processed due to invalid parameters.'; anyErr?.Details;
break;
case 404: const fallbackErrorsList: string[] = [];
title = 'Record Not Found';
category = 'notFound';
categoryLabel = 'Not Found';
message = 'The requested resource could not be found (HTTP 404). It may have been archived, removed, or the link is outdated.';
break;
case 409: if (directDetail) {
title = 'Resource Conflict'; if (typeof directDetail === 'string' && directDetail.trim()) {
category = 'conflict'; fallbackErrorsList.push(...parseCommaDelimitedErrors(directDetail.trim()));
categoryLabel = 'Conflict'; message = fallbackErrorsList.join('\n');
message = 'This operation conflicts with the current status of the resource (HTTP 409). Please refresh the page and try again.'; } else if (Array.isArray(directDetail)) {
break; fallbackErrorsList.push(...directDetail.map(String));
message = fallbackErrorsList.join('\n');
}
}
case 422: if (!message) {
category = 'validation'; switch (statusCode) {
categoryLabel = 'Incomplete Data'; case 400:
message = 'The submitted information could not be processed by the server (HTTP 422). Please verify all required fields.'; category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation';
break; categoryLabel = 'Bad Request';
message =
operationIntent === 'creation'
? 'The new record could not be created. Please review the submitted parameters.'
: operationIntent === 'update'
? 'The record could not be updated. Please verify the submitted parameters.'
: 'The request could not be processed due to invalid parameters.';
break;
case 429: case 404:
title = 'Rate Limit Exceeded'; title = 'Record Not Found';
category = 'server'; category = 'notFound';
categoryLabel = 'Rate Limit'; categoryLabel = 'Not Found';
message = 'The system received too many requests in a short time (HTTP 429). Please pause for a moment before trying again.'; message = 'The requested resource could not be found (HTTP 404). It may have been archived, removed, or the link is outdated.';
break; break;
case 500: case 409:
case 502: title = 'Resource Conflict';
case 503: category = 'conflict';
case 504: categoryLabel = 'Conflict';
title = 'Service Temporarily Unavailable'; message = 'This operation conflicts with the current status of the resource (HTTP 409). Please refresh the page and try again.';
category = 'server'; break;
categoryLabel = 'Service Issue';
message = `The server encountered an error while processing your request (HTTP ${statusCode}). Please try again in a few moments.`;
break;
default: case 422:
if (typeof error === 'string' && error.trim()) { category = 'validation';
message = cleanTechnicalJargon(error); categoryLabel = 'Incomplete Data';
} else if (error instanceof Error && error.message) { message = 'The submitted information could not be processed by the server (HTTP 422). Please verify all required fields.';
message = cleanTechnicalJargon(error.message); break;
}
if (!message) { case 429:
message = statusCode title = 'Rate Limit Exceeded';
? `An unexpected server issue occurred (HTTP ${statusCode}). Please try again or contact support.` category = 'server';
: 'An unexpected issue occurred while processing this request. Please try again.'; categoryLabel = 'Rate Limit';
} message = 'The system received too many requests in a short time (HTTP 429). Please pause for a moment before trying again.';
break; break;
case 500:
case 502:
case 503:
case 504:
title = 'Service Temporarily Unavailable';
category = 'server';
categoryLabel = 'Service Issue';
message = `The server encountered an error while processing your request (HTTP ${statusCode}). Please try again in a few moments.`;
break;
default:
if (typeof error === 'string' && error.trim()) {
message = cleanTechnicalJargon(error);
} else if (error instanceof Error && error.message) {
message = cleanTechnicalJargon(error.message);
}
if (!message) {
message = statusCode
? `An unexpected server issue occurred (HTTP ${statusCode}). Please try again or contact support.`
: 'An unexpected issue occurred while processing this request. Please try again.';
}
break;
}
} }
return { return {
title, title,
message, message,
statusCode, statusCode,
hasReturnObject: false, hasReturnObject: fallbackErrorsList.length > 0,
isProblemDetails: false, isProblemDetails: false,
category, category,
categoryLabel, categoryLabel,
errorsList: fallbackErrorsList.length > 0 ? fallbackErrorsList : undefined,
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined), technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
}; };
} }