- Replace all /api/gateway/* with /api/* to match gateway routing - Fix AdminShell.tsx: update UGC route to singular and fix logout URL - Remove Applications and Responses from sidebar (unused) - Move conflicting route files into folders (company, approval, verification, users, jobs, kb, leads, photographer) as index.tsx to avoid catch-all interference - Upgrade ProfessionAdminListPage to match Department Management UI: • Dark headers with white text • Icons on Sort/Filters/Export buttons • Pagination UI • Improved empty state with Create button • Hover effects and consistent spacing - Update all pages using ProfessionAdminListPage to benefit from new UI - Fix jobs admin endpoint to use /api/admin/companies/jobs with auth - Add authentication headers to jobs and leads fetch calls These changes unify the API architecture and bring a consistent, professional look to all management tables.
618 lines
30 KiB
TypeScript
618 lines
30 KiB
TypeScript
import { A, useParams, useSearchParams } from '@solidjs/router';
|
||
import { createMemo, createResource, createSignal, For, Show, createEffect } from 'solid-js';
|
||
|
||
const API = '';
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────
|
||
|
||
type RoleType =
|
||
| 'COMPANY' | 'CANDIDATE' | 'CUSTOMER' | 'PHOTOGRAPHER' | 'MAKEUP_ARTIST'
|
||
| 'TUTOR' | 'DEVELOPER' | 'VIDEO_EDITOR' | 'GRAPHIC_DESIGNER'
|
||
| 'SOCIAL_MEDIA_MANAGER' | 'FITNESS_TRAINER' | 'CATERING_SERVICE'
|
||
| 'ADMIN' | 'UNKNOWN';
|
||
|
||
interface SubmissionData {
|
||
user: {
|
||
id: string;
|
||
name?: string;
|
||
email: string;
|
||
phone?: string;
|
||
status: string;
|
||
email_verified: boolean;
|
||
created_at: string;
|
||
};
|
||
role_key?: string;
|
||
onboarding?: {
|
||
status: string;
|
||
progress_json: Record<string, unknown>;
|
||
completed_at?: string;
|
||
updated_at: string;
|
||
} | null;
|
||
}
|
||
|
||
interface AdminRemark {
|
||
type: 'INFO' | 'CHANGES_REQUESTED' | 'MORE_DOCUMENTS_REQUESTED' | 'REJECTED';
|
||
comment: string;
|
||
fields?: string[];
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────
|
||
|
||
function inferRoleType(roleKey?: string, name?: string): RoleType {
|
||
const raw = [roleKey, name].filter(Boolean).join(' ').toLowerCase();
|
||
if (raw.includes('photographer')) return 'PHOTOGRAPHER';
|
||
if (raw.includes('makeup')) return 'MAKEUP_ARTIST';
|
||
if (raw.includes('tutor')) return 'TUTOR';
|
||
if (raw.includes('developer')) return 'DEVELOPER';
|
||
if (raw.includes('video') || raw.includes('video_editor')) return 'VIDEO_EDITOR';
|
||
if (raw.includes('graphic') || raw.includes('graphic_designer')) return 'GRAPHIC_DESIGNER';
|
||
if (raw.includes('social') || raw.includes('social_media')) return 'SOCIAL_MEDIA_MANAGER';
|
||
if (raw.includes('fitness') || raw.includes('fitness_trainer')) return 'FITNESS_TRAINER';
|
||
if (raw.includes('catering')) return 'CATERING_SERVICE';
|
||
if (raw.includes('customer')) return 'CUSTOMER';
|
||
if (raw.includes('job_seeker') || raw.includes('candidate')) return 'CANDIDATE';
|
||
if (raw.includes('company')) return 'COMPANY';
|
||
if (raw.includes('admin') || raw.includes('employee')) return 'ADMIN';
|
||
return 'UNKNOWN';
|
||
}
|
||
|
||
function managementDest(roleType: RoleType): { label: string; href: string } {
|
||
const map: Record<RoleType, { label: string; href: string }> = {
|
||
COMPANY: { label: 'Company Management', href: '/admin/company' },
|
||
CANDIDATE: { label: 'Candidate Management', href: '/admin/candidate' },
|
||
CUSTOMER: { label: 'Customer Management', href: '/admin/customer' },
|
||
PHOTOGRAPHER: { label: 'Photographer Management', href: '/admin/photographer' },
|
||
MAKEUP_ARTIST: { label: 'Makeup Artist Management', href: '/admin/makeup-artist' },
|
||
TUTOR: { label: 'Tutors Management', href: '/admin/tutors' },
|
||
DEVELOPER: { label: 'Developers Management', href: '/admin/developers' },
|
||
VIDEO_EDITOR: { label: 'Video Editor Management', href: '/admin/video-editors' },
|
||
GRAPHIC_DESIGNER: { label: 'Graphics Designer Management', href: '/admin/graphic-designers' },
|
||
SOCIAL_MEDIA_MANAGER: { label: 'Social Media Manager Management', href: '/admin/social-media-managers' },
|
||
FITNESS_TRAINER: { label: 'Fitness Trainer Management', href: '/admin/fitness-trainers' },
|
||
CATERING_SERVICE: { label: 'Catering Services Management', href: '/admin/catering-services' },
|
||
ADMIN: { label: 'Employee Management', href: '/admin/employees' },
|
||
UNKNOWN: { label: 'Users Management', href: '/admin/users' },
|
||
};
|
||
return map[roleType] ?? map.UNKNOWN;
|
||
}
|
||
|
||
/** Flatten nested JSON into key→value pairs for display */
|
||
function flattenFields(obj: Record<string, unknown>, prefix = ''): Array<{ key: string; value: string }> {
|
||
const result: Array<{ key: string; value: string }> = [];
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
const label = prefix ? `${prefix}.${k}` : k;
|
||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||
result.push(...flattenFields(v as Record<string, unknown>, label));
|
||
} else if (Array.isArray(v)) {
|
||
result.push({ key: label, value: v.join(', ') });
|
||
} else {
|
||
result.push({ key: label, value: String(v ?? '—') });
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** Skip internal tracking fields — only show what the user actually submitted */
|
||
const SKIP_KEYS = new Set(['step', 'total', 'currentStep', '__version', '__schema']);
|
||
|
||
function isSubmittedField(key: string): boolean {
|
||
return !SKIP_KEYS.has(key) && !key.startsWith('_');
|
||
}
|
||
|
||
const ROLE_COLORS: Record<RoleType, string> = {
|
||
COMPANY: 'background:#dbeafe;color:#1d4ed8',
|
||
CANDIDATE: 'background:#e0e7ff;color:#4338ca',
|
||
CUSTOMER: 'background:#cffafe;color:#0e7490',
|
||
PHOTOGRAPHER: 'background:#ede9fe;color:#6d28d9',
|
||
MAKEUP_ARTIST: 'background:#fce7f3;color:#9d174d',
|
||
TUTOR: 'background:#d1fae5;color:#065f46',
|
||
DEVELOPER: 'background:#e0f2fe;color:#0369a1',
|
||
VIDEO_EDITOR: 'background:#fef3c7;color:#92400e',
|
||
GRAPHIC_DESIGNER: 'background:#f0fdf4;color:#166534',
|
||
SOCIAL_MEDIA_MANAGER: 'background:#fdf2f8;color:#86198f',
|
||
FITNESS_TRAINER: 'background:#fff7ed;color:#9a3412',
|
||
CATERING_SERVICE: 'background:#fefce8;color:#854d0e',
|
||
ADMIN: 'background:#f1f5f9;color:#334155',
|
||
UNKNOWN: 'background:#f8fafc;color:#64748b',
|
||
};
|
||
|
||
// ── Field type detection ──────────────────────────────────────────────────────────
|
||
|
||
type FieldKind = 'image' | 'pdf' | 'document' | 'url' | 'text';
|
||
|
||
function detectKind(key: string, value: string): FieldKind {
|
||
const k = key.toLowerCase();
|
||
const v = (value || '').toLowerCase();
|
||
|
||
// Image: key hints OR common image extensions
|
||
if (
|
||
/photo|image|picture|avatar|selfie|headshot|thumbnail|profile_pic/i.test(k) ||
|
||
/\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|$)/i.test(v)
|
||
) return 'image';
|
||
|
||
// PDF
|
||
if (/\.(pdf)(\?|$)/i.test(v) || /pdf|resume|cv\b/i.test(k)) return 'pdf';
|
||
|
||
// Generic document/upload (not image/pdf)
|
||
if (
|
||
/upload|document|file|attachment|certificate|license|govt_id|aadhaar|pan|passport|degree|transcript|portfolio|id_proof/i.test(k) ||
|
||
/\.(doc|docx|xls|xlsx|ppt|pptx|zip|rar)(\?|$)/i.test(v)
|
||
) return 'document';
|
||
|
||
// URL that isn't a file
|
||
if (value.startsWith('http') || value.startsWith('/')) return 'url';
|
||
|
||
return 'text';
|
||
}
|
||
|
||
// ── Data loaders ──────────────────────────────────────────────────────────
|
||
|
||
async function loadSubmission(args: { userId: string; roleKey: string }): Promise<SubmissionData | null> {
|
||
if (!args.userId) return null;
|
||
try {
|
||
const qs = args.roleKey ? `?roleKey=${encodeURIComponent(args.roleKey)}` : '';
|
||
const res = await fetch(`${API}/api/admin/approvals/submission/${args.userId}${qs}`);
|
||
if (!res.ok) return null;
|
||
return res.json();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────
|
||
|
||
export default function ApprovalDetailPage() {
|
||
const params = useParams();
|
||
const [searchParams] = useSearchParams();
|
||
|
||
// params.id can be either:
|
||
// - a user UUID → we load the submission directly
|
||
// - an old approval request ID → shown as legacy fallback
|
||
const userId = () => params.id;
|
||
const roleKey = () => (searchParams.roleKey as string) || '';
|
||
|
||
const [data] = createResource(
|
||
() => ({ userId: userId(), roleKey: roleKey() }),
|
||
loadSubmission,
|
||
);
|
||
|
||
const [acting, setActing] = createSignal('');
|
||
const [actionError, setActionError] = createSignal('');
|
||
const [actionDone, setActionDone] = createSignal('');
|
||
|
||
const roleType = createMemo(() => inferRoleType(data()?.role_key, undefined));
|
||
const dest = createMemo(() => managementDest(roleType()));
|
||
|
||
// Flatten progress_json into displayable rows
|
||
const submittedRows = createMemo(() => {
|
||
const pj = data()?.onboarding?.progress_json;
|
||
if (!pj || typeof pj !== 'object') return [];
|
||
return flattenFields(pj as Record<string, unknown>)
|
||
.filter((f) => isSubmittedField(f.key));
|
||
});
|
||
|
||
// ── Approve / Reject ──
|
||
// Routes: POST /api/admin/approvals/profiles/professional/{role_key}/{user_id}/approve
|
||
// POST /api/admin/approvals/profiles/company/{user_id}/approve
|
||
// POST /api/admin/approvals/profiles/customer/{user_id}/approve
|
||
|
||
const getApprovalPath = (action: 'approve' | 'reject') => {
|
||
const rk = (roleKey() || '').toUpperCase();
|
||
const uid = userId();
|
||
if (rk === 'COMPANY') return `/api/admin/approvals/profiles/company/${uid}/${action}`;
|
||
if (rk === 'CUSTOMER') return `/api/admin/approvals/profiles/customer/${uid}/${action}`;
|
||
if (rk) return `/api/admin/approvals/profiles/professional/${rk}/${uid}/${action}`;
|
||
return null;
|
||
};
|
||
|
||
const handleApprove = async () => {
|
||
if (!confirm('Approve this profile submission?')) return;
|
||
const path = getApprovalPath('approve');
|
||
if (!path) { setActionError('Cannot resolve approval endpoint — roleKey missing in URL'); return; }
|
||
try {
|
||
setActing('APPROVE');
|
||
setActionError('');
|
||
const res = await fetch(`${API}${path}`, { method: 'POST' });
|
||
if (!res.ok) throw new Error('Failed to approve');
|
||
setActionDone('APPROVED');
|
||
} catch (err: any) {
|
||
setActionError(err.message || 'Failed to approve');
|
||
} finally {
|
||
setActing('');
|
||
}
|
||
};
|
||
|
||
const handleReject = async () => {
|
||
const reason = prompt('Rejection reason (required):');
|
||
if (!reason?.trim()) return;
|
||
const path = getApprovalPath('reject');
|
||
if (!path) { setActionError('Cannot resolve rejection endpoint — roleKey missing in URL'); return; }
|
||
try {
|
||
setActing('REJECT');
|
||
setActionError('');
|
||
const res = await fetch(`${API}${path}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ reason: reason.trim() }),
|
||
});
|
||
if (!res.ok) throw new Error('Failed to reject');
|
||
setActionDone('REJECTED');
|
||
} catch (err: any) {
|
||
setActionError(err.message || 'Failed to reject');
|
||
} finally {
|
||
setActing('');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||
<div>
|
||
<h1 class="text-xl font-semibold text-gray-900">Submission Review</h1>
|
||
<p class="text-sm text-gray-500 mt-0.5">Review a user's onboarding form submission and take action.</p>
|
||
</div>
|
||
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/approval">← Back to Approvals</A>
|
||
</div>
|
||
<div class="p-6 flex-1">
|
||
|
||
<Show when={actionError()}>
|
||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{actionError()}</div>
|
||
</Show>
|
||
|
||
<Show when={actionDone()}>
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-bottom:12px;border-left:4px solid #22c55e;padding:12px 16px">
|
||
<p style="margin:0;font-weight:600;color:#166534">
|
||
{actionDone() === 'APPROVED' ? '✓ Profile approved successfully.' : '✕ Profile rejected.'}
|
||
</p>
|
||
<A href={dest().href} style="font-size:13px;color:#15803d;text-decoration:underline">
|
||
Open {dest().label} →
|
||
</A>
|
||
</div>
|
||
</Show>
|
||
|
||
<Show when={data.loading}>
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading submission...</p></div>
|
||
</Show>
|
||
|
||
<Show when={!data.loading && !data()}>
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||
<p class="notice">Submission not found or user does not have an onboarding record for this role.</p>
|
||
<p style="font-size:13px;color:#64748b;margin-top:8px">
|
||
Make sure the URL includes <code>?roleKey=PHOTOGRAPHER</code> (or the relevant role key).
|
||
</p>
|
||
</div>
|
||
</Show>
|
||
|
||
<Show when={data()}>
|
||
{/* ── Action bar ── */}
|
||
<Show when={!actionDone()}>
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:16px;padding:12px 16px">
|
||
<span style={`display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;${ROLE_COLORS[roleType()]}`}>
|
||
{(roleKey() || 'UNKNOWN').replace(/_/g, ' ')}
|
||
</span>
|
||
<span style="font-size:13px;color:#64748b">
|
||
Onboarding: <strong style={`color:${data()!.onboarding?.status === 'COMPLETED' ? '#15803d' : '#c2410c'}`}>
|
||
{data()!.onboarding?.status ?? 'NO DATA'}
|
||
</strong>
|
||
</span>
|
||
<div style="flex:1" />
|
||
<Show when={data()!.onboarding?.status === 'COMPLETED' && !actionDone()}>
|
||
<button
|
||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||
style="background:#f0fdf4;color:#15803d;border-color:#bbf7d0"
|
||
disabled={!!acting()}
|
||
onClick={handleApprove}
|
||
>
|
||
{acting() === 'APPROVE' ? 'Approving...' : '✓ Approve Profile'}
|
||
</button>
|
||
<button
|
||
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
||
disabled={!!acting()}
|
||
onClick={handleReject}
|
||
>
|
||
{acting() === 'REJECT' ? 'Rejecting...' : '✕ Reject Profile'}
|
||
</button>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
|
||
{/* User info */}
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600;color:#0f172a">User Info</h3>
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<tbody>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0;white-space:nowrap;width:40%">Name</td><td style="font-weight:500">{data()!.user.name || '—'}</td></tr>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Email</td><td style="color:#475569">{data()!.user.email}</td></tr>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Phone</td><td style="color:#475569">{data()!.user.phone || '—'}</td></tr>
|
||
<tr>
|
||
<td style="color:#64748b;padding:5px 10px 5px 0">Account Status</td>
|
||
<td>
|
||
<span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600${data()!.user.status === 'ACTIVE' ? ' active' : ''}`}>
|
||
{data()!.user.status}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="color:#64748b;padding:5px 10px 5px 0">Email Verified</td>
|
||
<td style={`font-weight:500;color:${data()!.user.email_verified ? '#15803d' : '#b91c1c'}`}>
|
||
{data()!.user.email_verified ? '✓ Yes' : '✕ No'}
|
||
</td>
|
||
</tr>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Registered</td><td style="color:#475569">{new Date(data()!.user.created_at).toLocaleDateString()}</td></tr>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0">User ID</td><td style="font-family:ui-monospace,monospace;font-size:11px;color:#94a3b8">{data()!.user.id}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Submission status */}
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600;color:#0f172a">Submission Info</h3>
|
||
<Show when={data()!.onboarding} fallback={
|
||
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:14px">
|
||
<p style="margin:0;color:#b91c1c;font-weight:500">No onboarding data found</p>
|
||
<p style="margin:6px 0 0;font-size:13px;color:#7f1d1d">
|
||
This user has not started or submitted the onboarding form for role: <strong>{roleKey() || 'unknown'}</strong>
|
||
</p>
|
||
</div>
|
||
}>
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<tbody>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0;white-space:nowrap;width:40%">Role</td><td style="font-weight:500">{(roleKey() || '—').replace(/_/g, ' ')}</td></tr>
|
||
<tr>
|
||
<td style="color:#64748b;padding:5px 10px 5px 0">Status</td>
|
||
<td>
|
||
<span style={`display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;${data()!.onboarding!.status === 'COMPLETED' ? 'background:#dcfce7;color:#166534' : 'background:#fef9c3;color:#713f12'}`}>
|
||
{data()!.onboarding!.status}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Submitted</td><td style="color:#475569">{data()!.onboarding!.completed_at ? new Date(data()!.onboarding!.completed_at!).toLocaleString() : '—'}</td></tr>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Last Updated</td><td style="color:#475569">{new Date(data()!.onboarding!.updated_at).toLocaleString()}</td></tr>
|
||
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Fields</td><td style="color:#475569">{submittedRows().length} fields submitted</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Submitted form answers + media viewer ── */}
|
||
<Show when={submittedRows().length > 0}>
|
||
<SubmissionViewer rows={submittedRows()} />
|
||
</Show>
|
||
|
||
{/* ── No form data fallback ── */}
|
||
<Show when={data()!.onboarding && submittedRows().length === 0}>
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-bottom:16px">
|
||
<h3 style="margin:0 0 10px;font-size:15px;font-weight:600;color:#0f172a">Submitted Form Answers</h3>
|
||
<p class="notice">Onboarding state is present but contains no displayable field data.</p>
|
||
</div>
|
||
</Show>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ────────────────────────────── SubmissionViewer ──────────────────────────────
|
||
|
||
function SubmissionViewer(props: { rows: Array<{ key: string; value: string }> }) {
|
||
const [lightbox, setLightbox] = createSignal<{ src: string; label: string } | null>(null);
|
||
const [pdfViewer, setPdfViewer] = createSignal<{ src: string; label: string } | null>(null);
|
||
|
||
// Split rows into text fields vs media
|
||
const textFields = createMemo(() =>
|
||
props.rows.filter((r) => {
|
||
const kind = detectKind(r.key, r.value);
|
||
return kind === 'text' || kind === 'url';
|
||
})
|
||
);
|
||
const mediaFields = createMemo(() =>
|
||
props.rows.filter((r) => {
|
||
const kind = detectKind(r.key, r.value);
|
||
return kind === 'image' || kind === 'pdf' || kind === 'document';
|
||
})
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{/* ── Text / data fields ── */}
|
||
<Show when={textFields().length > 0}>
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-bottom:16px">
|
||
<h3 style="margin:0 0 14px;font-size:15px;font-weight:600;color:#0f172a">
|
||
Submitted Form Data
|
||
<span style="margin-left:8px;font-size:12px;font-weight:400;color:#64748b">{textFields().length} fields</span>
|
||
</h3>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px">
|
||
<For each={textFields()}>
|
||
{(field) => {
|
||
const kind = detectKind(field.key, field.value);
|
||
return (
|
||
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px">
|
||
<div style="font-size:10px;color:#94a3b8;margin-bottom:4px;font-weight:700;letter-spacing:0.05em">
|
||
{field.key.replace(/_/g, ' ').replace(/\./g, ' › ').toUpperCase()}
|
||
</div>
|
||
<Show when={kind === 'url'} fallback={
|
||
<div style="font-size:13px;color:#0f172a;word-break:break-all;line-height:1.5">{field.value || '—'}</div>
|
||
}>
|
||
<a href={field.value} target="_blank" rel="noreferrer"
|
||
style="font-size:13px;color:#2563eb;word-break:break-all">
|
||
🔗 {field.value}
|
||
</a>
|
||
</Show>
|
||
</div>
|
||
);
|
||
}}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* ── Documents & Images ── */}
|
||
<Show when={mediaFields().length > 0}>
|
||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-bottom:16px">
|
||
<h3 style="margin:0 0 14px;font-size:15px;font-weight:600;color:#0f172a">
|
||
Documents & Media
|
||
<span style="margin-left:8px;font-size:12px;font-weight:400;color:#64748b">{mediaFields().length} file{mediaFields().length !== 1 ? 's' : ''}</span>
|
||
</h3>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px">
|
||
<For each={mediaFields()}>
|
||
{(field) => {
|
||
const kind = detectKind(field.key, field.value);
|
||
const label = field.key.replace(/_/g, ' ').replace(/\./g, ' › ');
|
||
return (
|
||
<div style="border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;background:#fff">
|
||
{/* Preview area */}
|
||
<Show when={kind === 'image'}>
|
||
<div
|
||
style="background:#f1f5f9;cursor:pointer;position:relative;height:140px;display:flex;align-items:center;justify-content:center;overflow:hidden"
|
||
onClick={() => setLightbox({ src: field.value, label })}
|
||
>
|
||
<img
|
||
src={field.value}
|
||
alt={label}
|
||
style="max-width:100%;max-height:140px;object-fit:contain"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
((e.target as HTMLImageElement).nextElementSibling as HTMLElement)!.style.display = 'flex';
|
||
}}
|
||
/>
|
||
<div style="display:none;width:100%;height:100%;align-items:center;justify-content:center;flex-direction:column;gap:4px;color:#94a3b8">
|
||
<span style="font-size:32px">🖼</span>
|
||
<span style="font-size:11px">Preview unavailable</span>
|
||
</div>
|
||
<div style="position:absolute;top:6px;right:6px;background:rgba(0,0,0,.5);color:#fff;border-radius:4px;font-size:10px;padding:2px 6px">
|
||
🔍 Click to enlarge
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
<Show when={kind === 'pdf'}>
|
||
<div
|
||
style="background:#fef2f2;cursor:pointer;height:140px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px"
|
||
onClick={() => setPdfViewer({ src: field.value, label })}
|
||
>
|
||
<span style="font-size:40px">📄</span>
|
||
<span style="font-size:12px;color:#b91c1c;font-weight:600">PDF Document</span>
|
||
<span style="font-size:11px;color:#94a3b8">Click to view</span>
|
||
</div>
|
||
</Show>
|
||
|
||
<Show when={kind === 'document'}>
|
||
<div style="background:#eff6ff;height:140px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px">
|
||
<span style="font-size:40px">📎</span>
|
||
<span style="font-size:12px;color:#1d4ed8;font-weight:600">Document</span>
|
||
<a href={field.value} target="_blank" rel="noreferrer"
|
||
style="font-size:11px;color:#2563eb;text-decoration:underline"
|
||
onClick={(e) => e.stopPropagation()}>
|
||
Download
|
||
</a>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Label + open link */}
|
||
<div style="padding:8px 10px;border-top:1px solid #e2e8f0">
|
||
<div style="font-size:11px;color:#0f172a;font-weight:600;margin-bottom:4px;text-transform:capitalize">
|
||
{label}
|
||
</div>
|
||
<div style="display:flex;gap:6px">
|
||
<Show when={kind === 'image'}>
|
||
<button
|
||
type="button"
|
||
style="font-size:11px;color:#2563eb;background:none;border:none;padding:0;cursor:pointer;text-decoration:underline"
|
||
onClick={() => setLightbox({ src: field.value, label })}
|
||
>
|
||
🔍 View Full
|
||
</button>
|
||
</Show>
|
||
<Show when={kind === 'pdf'}>
|
||
<button
|
||
type="button"
|
||
style="font-size:11px;color:#b91c1c;background:none;border:none;padding:0;cursor:pointer;text-decoration:underline"
|
||
onClick={() => setPdfViewer({ src: field.value, label })}
|
||
>
|
||
📄 Open PDF
|
||
</button>
|
||
</Show>
|
||
<a href={field.value} target="_blank" rel="noreferrer"
|
||
style="font-size:11px;color:#64748b;text-decoration:underline">
|
||
↗ Download
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* ── Image Lightbox ── */}
|
||
<Show when={lightbox()}>
|
||
<div
|
||
style="position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px"
|
||
onClick={() => setLightbox(null)}
|
||
>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;max-width:900px;margin-bottom:12px">
|
||
<span style="color:#f8fafc;font-size:14px;font-weight:600">{lightbox()!.label}</span>
|
||
<div style="display:flex;gap:10px">
|
||
<a href={lightbox()!.src} target="_blank" rel="noreferrer"
|
||
style="color:#93c5fd;font-size:13px;text-decoration:underline"
|
||
onClick={(e) => e.stopPropagation()}>
|
||
↗ Open original
|
||
</a>
|
||
<button
|
||
type="button"
|
||
style="color:#f8fafc;background:none;border:none;font-size:22px;cursor:pointer;line-height:1"
|
||
onClick={() => setLightbox(null)}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<img
|
||
src={lightbox()!.src}
|
||
alt={lightbox()!.label}
|
||
style="max-width:900px;max-height:80vh;object-fit:contain;border-radius:8px;box-shadow:0 0 40px rgba(0,0,0,.6)"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* ── PDF Viewer Modal ── */}
|
||
<Show when={pdfViewer()}>
|
||
<div
|
||
style="position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px"
|
||
>
|
||
<div style="background:#fff;border-radius:12px;overflow:hidden;width:100%;max-width:900px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 25px 60px rgba(0,0,0,.5)">
|
||
{/* Header */}
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #e2e8f0;background:#f8fafc">
|
||
<span style="font-size:14px;font-weight:600;color:#0f172a">📄 {pdfViewer()!.label}</span>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<a href={pdfViewer()!.src} target="_blank" rel="noreferrer"
|
||
style="font-size:12px;color:#2563eb;text-decoration:underline">
|
||
↗ Open in new tab
|
||
</a>
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||
style="padding:4px 10px;font-size:13px"
|
||
onClick={() => setPdfViewer(null)}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/* PDF embed */}
|
||
<iframe
|
||
src={`${pdfViewer()!.src}#toolbar=1&view=FitH`}
|
||
style="flex:1;min-height:600px;width:100%;border:none"
|
||
title={pdfViewer()!.label}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</>
|
||
);
|
||
}
|