nxtgauge-admin-solid/src/routes/admin/approval/[id].tsx
Ashwin Kumar 0ec64be905 feat: unify API paths and upgrade table UIs
- 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.
2026-04-07 22:12:52 +02:00

618 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}