@@ -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() }],
|
||||
};
|
||||
Reference in New Issue
Block a user