Admin Panel
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-09-29 14:57:37 +07:00
parent 04fbddba13
commit 8c6dad2a31
94 changed files with 4864 additions and 1151 deletions
@@ -0,0 +1,327 @@
import { Form, Head, Link } from '@inertiajs/react';
import { Mail, MessageCircle, Phone, Trash2 } from 'lucide-react';
import { useState } from 'react';
import {
ConfirmDelete,
FormField,
NativeSelect,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { telHref, whatsappHref } from '@/data/site';
import {
formatDate,
optionsOf,
sourceLabels,
statusBadgeClasses,
statusLabels,
} from '@/lib/admin-labels';
import { cn } from '@/lib/utils';
import { destroy, index, update } from '@/routes/admin/consultations';
import type { Consultation, ConsultationStatus, Paginated } from '@/types';
type Props = {
consultations: Paginated<Consultation>;
status: ConsultationStatus | null;
counts: Partial<Record<ConsultationStatus, number>>;
};
const timelineLabels: Record<string, string> = {
immediate: 'Segera (< 1 bulan)',
quarter: '1–3 bulan',
midterm: '3–6 bulan',
feasibility: 'Masih studi kelayakan',
};
export default function ConsultationsIndex({
consultations,
status,
counts,
}: Props) {
const [selectedId, setSelectedId] = useState<number | null>(
consultations.data[0]?.id ?? null,
);
const selected =
consultations.data.find((item) => item.id === selectedId) ??
consultations.data[0];
const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
const filters: {
value: ConsultationStatus | null;
label: string;
count: number;
}[] = [
{ value: null, label: 'Semua', count: total },
...optionsOf(statusLabels).map(({ value, label }) => ({
value,
label,
count: counts[value] ?? 0,
})),
];
return (
<>
<Head title="Inbox Konsultasi" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Inbox Konsultasi"
description="Permintaan dari form konsultasi di website. Notifikasi juga dikirim ke email busdev."
/>
<div className="flex flex-wrap gap-2">
{filters.map((filter) => (
<Link
key={filter.label}
href={index({
query: filter.value
? { status: filter.value }
: {},
})}
preserveScroll
className={cn(
'flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors',
status === filter.value
? 'bg-primary text-primary-foreground border-transparent'
: 'hover:bg-accent',
)}
>
{filter.label}
<span className="font-mono text-xs opacity-75">
{filter.count}
</span>
</Link>
))}
</div>
{consultations.data.length === 0 ? (
<div className="text-muted-foreground rounded-xl border border-dashed p-12 text-center text-sm">
Belum ada permintaan konsultasi di sini.
</div>
) : (
<div className="grid gap-4 lg:grid-cols-5">
<div className="bg-card flex flex-col rounded-xl border lg:col-span-2">
<ul className="divide-border divide-y">
{consultations.data.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() =>
setSelectedId(item.id)
}
className={cn(
'flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors',
selected?.id === item.id
? 'bg-accent'
: 'hover:bg-accent/50',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold">
{item.name}
</span>
<Badge
className={
statusBadgeClasses[
item.status
]
}
>
{statusLabels[item.status]}
</Badge>
</div>
<span className="text-muted-foreground truncate text-xs">
{item.company ?? item.email}
</span>
<span className="text-muted-foreground text-xs">
{formatDate(
item.created_at,
true,
)}
{' · '}
{sourceLabels[item.source]}
</span>
</button>
</li>
))}
</ul>
{consultations.last_page > 1 && (
<div className="mt-auto flex items-center justify-between border-t px-4 py-3 text-sm">
<PageLink
href={consultations.prev_page_url}
label="Sebelumnya"
/>
<span className="text-muted-foreground text-xs">
Halaman {consultations.current_page} /{' '}
{consultations.last_page}
</span>
<PageLink
href={consultations.next_page_url}
label="Berikutnya"
/>
</div>
)}
</div>
{selected && (
<ConsultationDetail
key={selected.id}
consultation={selected}
/>
)}
</div>
)}
</div>
</>
);
}
function PageLink({ href, label }: { href: string | null; label: string }) {
if (!href) {
return <span className="text-muted-foreground/50">{label}</span>;
}
return (
<Link href={href} preserveScroll className="text-primary font-medium">
{label}
</Link>
);
}
function ConsultationDetail({ consultation }: { consultation: Consultation }) {
const details: [string, string | null][] = [
['Perusahaan', consultation.company],
['Jabatan', consultation.job_title],
['Industri', consultation.industry],
['Layanan', consultation.service],
[
'Target waktu',
consultation.timeline
? (timelineLabels[consultation.timeline] ??
consultation.timeline)
: null,
],
['Halaman asal', sourceLabels[consultation.source]],
['Bahasa', consultation.locale === 'en' ? 'English' : 'Indonesia'],
['Setuju NDA', consultation.nda_agreed ? 'Ya' : 'Tidak'],
];
return (
<div className="bg-card flex flex-col gap-6 rounded-xl border p-5 lg:col-span-3">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
Masuk {formatDate(consultation.created_at, true)}
</span>
<ConfirmDelete
title="Hapus permintaan ini?"
description={`Data konsultasi dari ${consultation.name} akan dihapus permanen.`}
form={destroy.form(consultation.id)}
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive"
>
<Trash2 /> Hapus
</Button>
}
/>
</div>
<h2 className="font-display text-lg font-bold">
{consultation.name}
</h2>
<div className="mt-2 flex flex-wrap gap-2">
<Button asChild size="sm" variant="outline">
<a href={`mailto:${consultation.email}`}>
<Mail /> {consultation.email}
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a href={telHref(consultation.phone)}>
<Phone /> {consultation.phone}
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a
href={whatsappHref(consultation.phone)}
target="_blank"
rel="noopener noreferrer"
>
<MessageCircle /> WhatsApp
</a>
</Button>
</div>
</div>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{details
.filter(([, value]) => value)
.map(([label, value]) => (
<div key={label} className="flex flex-col">
<dt className="text-muted-foreground text-xs">
{label}
</dt>
<dd className="text-sm font-medium">{value}</dd>
</div>
))}
</dl>
{consultation.details && (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">
Kebutuhan
</span>
<p className="bg-muted/50 rounded-lg p-3 text-sm whitespace-pre-line">
{consultation.details}
</p>
</div>
)}
<Form
{...update.form(consultation.id)}
options={{ preserveScroll: true }}
className="flex flex-col gap-4 border-t pt-5"
>
{({ processing, errors }) => (
<>
<FormField
id="status"
label="Status tindak lanjut"
error={errors.status}
>
<NativeSelect
id="status"
name="status"
defaultValue={consultation.status}
options={optionsOf(statusLabels)}
/>
</FormField>
<FormField
id="admin_notes"
label="Catatan internal"
error={errors.admin_notes}
>
<Textarea
id="admin_notes"
name="admin_notes"
defaultValue={consultation.admin_notes ?? ''}
placeholder="Mis. sudah ditelepon, jadwal meeting 3 Okt."
/>
</FormField>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
);
}
ConsultationsIndex.layout = {
breadcrumbs: [{ title: 'Inbox Konsultasi', href: index() }],
};