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
+267
View File
@@ -0,0 +1,267 @@
import { Form, Head } from '@inertiajs/react';
import { ExternalLink, MapPin, Pencil, Plus, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
PageHeader,
} from '@/components/admin/form-controls';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { officeAddress } from '@/data/site';
import { edit } from '@/routes/admin/company';
import { update as updateContact } from '@/routes/admin/company/contact';
import { destroy, store, update } from '@/routes/admin/offices';
import type { Office, SiteInfo } from '@/types';
export default function CompanyEdit({ contact, offices }: SiteInfo) {
return (
<>
<Head title="Info Perusahaan" />
<div className="flex flex-col gap-8 p-4 md:p-6">
<PageHeader
title="Info Perusahaan"
description="Kontak dan alamat kantor yang tampil di header, footer, dan halaman Kontak."
/>
<section className="bg-card flex flex-col gap-6 rounded-xl border p-5">
<Heading
variant="small"
title="Kontak"
description="Email busdev juga menjadi tujuan notifikasi konsultasi baru."
/>
<Form
{...updateContact.form()}
options={{ preserveScroll: true }}
className="grid gap-4 sm:grid-cols-2"
>
{({ processing, errors }) => (
<>
<FormField
id="phone"
label="Telepon kantor"
error={errors.phone}
>
<Input
id="phone"
name="phone"
defaultValue={contact.phone}
required
/>
</FormField>
<FormField
id="whatsapp"
label="WhatsApp konsultasi"
error={errors.whatsapp}
>
<Input
id="whatsapp"
name="whatsapp"
defaultValue={contact.whatsapp}
required
/>
</FormField>
<FormField
id="email_busdev"
label="Email business development"
error={errors.email_busdev}
>
<Input
id="email_busdev"
name="email_busdev"
type="email"
defaultValue={contact.email_busdev}
required
/>
</FormField>
<FormField
id="email_support"
label="Email support"
error={errors.email_support}
>
<Input
id="email_support"
name="email_support"
type="email"
defaultValue={contact.email_support}
required
/>
</FormField>
<div className="flex justify-end sm:col-span-2">
<Button type="submit" disabled={processing}>
Simpan Kontak
</Button>
</div>
</>
)}
</Form>
</section>
<section className="flex flex-col gap-4">
<div className="flex items-end justify-between gap-3">
<Heading
variant="small"
title="Kantor"
description="Diurutkan berdasarkan nomor urut."
/>
<OfficeDialog
nextOrder={offices.length + 1}
trigger={
<Button>
<Plus /> Tambah Kantor
</Button>
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{offices.map((office) => (
<div
key={office.id}
className="bg-card flex flex-col gap-3 rounded-xl border p-5"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col">
<span className="font-display font-bold">
{office.name}
</span>
<span className="text-muted-foreground text-xs">
{office.district} · {office.phone}
</span>
</div>
<div className="flex gap-1">
<OfficeDialog
office={office}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus kantor ini?"
description={`${office.name} tidak akan tampil lagi di website.`}
form={destroy.form(office.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</div>
<p className="flex gap-2 text-sm">
<MapPin className="text-primary mt-0.5 size-4 shrink-0" />
{officeAddress(office)}
</p>
{office.maps_url && (
<a
href={office.maps_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
>
Buka di Google Maps
<ExternalLink className="size-3.5" />
</a>
)}
</div>
))}
</div>
</section>
</div>
</>
);
}
function OfficeDialog({
office,
nextOrder = 1,
trigger,
}: {
office?: Office;
nextOrder?: number;
trigger: ReactNode;
}) {
const id = (field: string) => `office-${office?.id ?? 'new'}-${field}`;
const text = (
field: keyof Office,
label: string,
errors: Partial<Record<string, string>>,
extra: { placeholder?: string; required?: boolean } = {
required: true,
},
) => (
<FormField id={id(field)} label={label} error={errors[field]}>
<Input
id={id(field)}
name={field}
defaultValue={office?.[field] ?? ''}
{...extra}
/>
</FormField>
);
return (
<FormDialog
trigger={trigger}
title={office ? 'Ubah Kantor' : 'Tambah Kantor'}
form={office ? update.form(office.id) : store.form()}
>
{(errors) => (
<>
{text('name', 'Nama kantor', errors)}
{text('building', 'Gedung / nomor', errors)}
{text('street', 'Jalan', errors)}
<div className="grid gap-4 sm:grid-cols-2">
{text('area', 'Kelurahan / kecamatan', errors)}
{text('city', 'Kota & kode pos', errors)}
{text('district', 'Label wilayah', errors, {
placeholder: 'Mis. Bintaro',
required: true,
})}
{text('phone', 'Telepon', errors)}
</div>
{text('maps_url', 'Link Google Maps (opsional)', errors, {
placeholder: 'https://maps.app.goo.gl/…',
})}
<div className="grid gap-4 sm:grid-cols-2">
{text('icon', 'Ikon (Material Symbols)', errors, {
placeholder: 'apartment',
required: true,
})}
<FormField
id={id('sort_order')}
label="Nomor urut"
error={errors.sort_order}
>
<Input
id={id('sort_order')}
name="sort_order"
type="number"
min={0}
defaultValue={office?.sort_order ?? nextOrder}
required
/>
</FormField>
</div>
</>
)}
</FormDialog>
);
}
CompanyEdit.layout = {
breadcrumbs: [{ title: 'Info Perusahaan', href: edit() }],
};
@@ -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() }],
};
+290
View File
@@ -0,0 +1,290 @@
import { Head } from '@inertiajs/react';
import { Pencil, Plus, Search, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
NativeSelect,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { portfolio } from '@/data/projects';
import { optionsOf, programLabels, sectorLabels } from '@/lib/admin-labels';
import { destroy, index, store, update } from '@/routes/admin/projects';
import type { Project, ProjectSector } from '@/types';
export default function ProjectsIndex({ projects }: { projects: Project[] }) {
const [query, setQuery] = useState('');
const [sector, setSector] = useState<ProjectSector | ''>('');
const stats = portfolio(projects);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
return projects.filter(
(project) =>
(!sector || project.sector === sector) &&
(!needle ||
`${project.title} ${project.client} ${project.year}`
.toLowerCase()
.includes(needle)),
);
}, [projects, query, sector]);
return (
<>
<Head title="Portofolio" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Portofolio Proyek"
description={`${stats.projectCount} proyek dari ${stats.clientCount} klien. Angka di website dihitung otomatis dari daftar ini.`}
action={
<ProjectDialog
trigger={
<Button>
<Plus /> Tambah Proyek
</Button>
}
/>
}
/>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Cari judul, klien, atau tahun…"
className="pl-9"
/>
</div>
<NativeSelect
value={sector}
onChange={(event) =>
setSector(event.target.value as ProjectSector | '')
}
options={[
{ value: '', label: 'Semua sektor' },
...optionsOf(sectorLabels),
]}
className="sm:w-48"
/>
</div>
<div className="bg-card overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left text-xs">
<tr>
<th className="px-4 py-3 font-medium">Tahun</th>
<th className="px-4 py-3 font-medium">
Proyek
</th>
<th className="px-4 py-3 font-medium">
Sektor
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-border divide-y">
{visible.map((project) => (
<tr key={project.id} className="align-top">
<td className="px-4 py-3 font-mono text-xs font-bold">
{project.year}
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span className="font-semibold">
{project.title}
</span>
<span className="text-muted-foreground text-xs">
{project.client}
</span>
{project.scope && (
<span className="text-muted-foreground text-xs italic">
{project.scope}
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
<Badge variant="outline">
{sectorLabels[project.sector]}
</Badge>
{project.program && (
<Badge variant="secondary">
{
programLabels[
project.program
]
}
</Badge>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<ProjectDialog
project={project}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus proyek ini?"
description={`"${project.title}" akan dihapus dari portofolio website.`}
form={destroy.form(project.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td
colSpan={4}
className="text-muted-foreground px-4 py-12 text-center"
>
Tidak ada proyek yang cocok.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
function ProjectDialog({
project,
trigger,
}: {
project?: Project;
trigger: ReactNode;
}) {
const id = (field: string) => `project-${project?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={project ? 'Ubah Proyek' : 'Tambah Proyek'}
form={project ? update.form(project.id) : store.form()}
>
{(errors) => (
<>
<FormField
id={id('title')}
label="Judul proyek"
error={errors.title}
>
<Textarea
id={id('title')}
name="title"
rows={2}
defaultValue={project?.title}
required
/>
</FormField>
<FormField
id={id('client')}
label="Klien"
error={errors.client}
>
<Input
id={id('client')}
name="client"
defaultValue={project?.client}
required
/>
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('year')}
label="Tahun"
error={errors.year}
>
<Input
id={id('year')}
name="year"
type="number"
min={1985}
max={new Date().getFullYear() + 1}
defaultValue={
project?.year ?? new Date().getFullYear()
}
required
/>
</FormField>
<FormField
id={id('sector')}
label="Sektor"
error={errors.sector}
>
<NativeSelect
id={id('sector')}
name="sector"
defaultValue={project?.sector ?? 'government'}
options={optionsOf(sectorLabels)}
/>
</FormField>
</div>
<FormField
id={id('program')}
label="Program unggulan (opsional)"
error={errors.program}
>
<NativeSelect
id={id('program')}
name="program"
defaultValue={project?.program ?? ''}
options={[
{ value: '', label: '— Tidak ada —' },
...optionsOf(programLabels),
]}
/>
</FormField>
<FormField
id={id('scope')}
label="Cakupan (opsional)"
error={errors.scope}
>
<Input
id={id('scope')}
name="scope"
defaultValue={project?.scope ?? ''}
placeholder="Mis. Odoo Enterprise → POS, Penjualan → Custom Development"
/>
</FormField>
</>
)}
</FormDialog>
);
}
ProjectsIndex.layout = {
breadcrumbs: [{ title: 'Portofolio', href: index() }],
};
@@ -0,0 +1,244 @@
import { Head } from '@inertiajs/react';
import { Pencil, Plus, Quote, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { destroy, index, store, update } from '@/routes/admin/testimonials';
import type { Testimonial } from '@/types';
export default function TestimonialsIndex({
testimonials,
}: {
testimonials: Testimonial[];
}) {
return (
<>
<Head title="Testimoni" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Testimoni"
description="Bagian testimoni di halaman Klien hanya muncul jika ada testimoni yang dipublikasikan. Gunakan hanya testimoni asli yang sudah disetujui klien."
action={
<TestimonialDialog
nextOrder={testimonials.length + 1}
trigger={
<Button>
<Plus /> Tambah Testimoni
</Button>
}
/>
}
/>
{testimonials.length === 0 ? (
<div className="text-muted-foreground rounded-xl border border-dashed p-12 text-center text-sm">
Belum ada testimoni. Bagian testimoni sedang
disembunyikan di website.
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{testimonials.map((testimonial) => (
<div
key={testimonial.id}
className="bg-card flex flex-col gap-3 rounded-xl border p-5"
>
<div className="flex items-start justify-between gap-2">
<Quote className="text-primary size-5" />
<div className="flex items-center gap-1">
<Badge
variant={
testimonial.is_published
? 'default'
: 'secondary'
}
>
{testimonial.is_published
? 'Tayang'
: 'Draf'}
</Badge>
<TestimonialDialog
testimonial={testimonial}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus testimoni ini?"
description={`Testimoni dari ${testimonial.organization_id} akan dihapus permanen.`}
form={destroy.form(testimonial.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</div>
<p className="text-sm italic">
“{testimonial.quote_id}”
</p>
<span className="text-muted-foreground text-xs">
{testimonial.role_id} ·{' '}
{testimonial.organization_id}
</span>
</div>
))}
</div>
)}
</div>
</>
);
}
function TestimonialDialog({
testimonial,
nextOrder = 1,
trigger,
}: {
testimonial?: Testimonial;
nextOrder?: number;
trigger: ReactNode;
}) {
const id = (field: string) =>
`testimonial-${testimonial?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={testimonial ? 'Ubah Testimoni' : 'Tambah Testimoni'}
description="Isi versi Bahasa Indonesia dan English; website menampilkan sesuai bahasa pengunjung."
form={testimonial ? update.form(testimonial.id) : store.form()}
className="sm:max-w-2xl"
>
{(errors) => (
<>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('quote_id')}
label="Kutipan (ID)"
error={errors.quote_id}
>
<Textarea
id={id('quote_id')}
name="quote_id"
defaultValue={testimonial?.quote_id}
required
/>
</FormField>
<FormField
id={id('quote_en')}
label="Quote (EN)"
error={errors.quote_en}
>
<Textarea
id={id('quote_en')}
name="quote_en"
defaultValue={testimonial?.quote_en}
required
/>
</FormField>
<FormField
id={id('role_id')}
label="Jabatan (ID)"
error={errors.role_id}
>
<Input
id={id('role_id')}
name="role_id"
defaultValue={testimonial?.role_id}
required
/>
</FormField>
<FormField
id={id('role_en')}
label="Role (EN)"
error={errors.role_en}
>
<Input
id={id('role_en')}
name="role_en"
defaultValue={testimonial?.role_en}
required
/>
</FormField>
<FormField
id={id('organization_id')}
label="Organisasi (ID)"
error={errors.organization_id}
>
<Input
id={id('organization_id')}
name="organization_id"
defaultValue={testimonial?.organization_id}
required
/>
</FormField>
<FormField
id={id('organization_en')}
label="Organization (EN)"
error={errors.organization_en}
>
<Input
id={id('organization_en')}
name="organization_en"
defaultValue={testimonial?.organization_en}
required
/>
</FormField>
<FormField
id={id('sort_order')}
label="Nomor urut"
error={errors.sort_order}
>
<Input
id={id('sort_order')}
name="sort_order"
type="number"
min={0}
defaultValue={
testimonial?.sort_order ?? nextOrder
}
required
/>
</FormField>
<label className="flex items-center gap-2 self-end pb-2 text-sm font-medium">
<input
type="checkbox"
name="is_published"
value="1"
defaultChecked={
testimonial?.is_published ?? true
}
className="accent-primary size-4"
/>
Tampilkan di website
</label>
</div>
</>
)}
</FormDialog>
);
}
TestimonialsIndex.layout = {
breadcrumbs: [{ title: 'Testimoni', href: index() }],
};
+266
View File
@@ -0,0 +1,266 @@
import { Head, usePage } from '@inertiajs/react';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
NativeSelect,
PageHeader,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { formatDate, optionsOf, roleLabels } from '@/lib/admin-labels';
import { destroy, index, store, update } from '@/routes/admin/users';
import type { UserRole } from '@/types';
type PanelUser = {
id: number;
name: string;
email: string;
role: UserRole;
created_at: string;
};
const roleDescriptions: Record<UserRole, string> = {
admin: 'Semua menu, termasuk kelola pengguna.',
editor: 'Konten website dan inbox konsultasi.',
};
export default function UsersIndex({ users }: { users: PanelUser[] }) {
const { auth } = usePage().props;
return (
<>
<Head title="Pengguna" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Pengguna"
description="Akun yang dapat masuk ke admin panel. Registrasi publik dinonaktifkan."
action={
<UserDialog
trigger={
<Button>
<Plus /> Tambah Pengguna
</Button>
}
/>
}
/>
<div className="bg-card overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left text-xs">
<tr>
<th className="px-4 py-3 font-medium">Nama</th>
<th className="px-4 py-3 font-medium">Peran</th>
<th className="px-4 py-3 font-medium">
Dibuat
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-border divide-y">
{users.map((user) => {
const isSelf = user.id === auth.user?.id;
return (
<tr key={user.id}>
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold">
{user.name}
{isSelf && (
<span className="text-muted-foreground font-normal">
{' '}
(Anda)
</span>
)}
</span>
<span className="text-muted-foreground text-xs">
{user.email}
</span>
</div>
</td>
<td className="px-4 py-3">
<Badge
variant={
user.role === 'admin'
? 'default'
: 'secondary'
}
>
{roleLabels[user.role]}
</Badge>
</td>
<td className="text-muted-foreground px-4 py-3 text-xs">
{formatDate(user.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<UserDialog
user={user}
isSelf={isSelf}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
{!isSelf && (
<ConfirmDelete
title="Hapus pengguna ini?"
description={`${user.name} tidak akan bisa masuk ke admin panel lagi.`}
form={destroy.form(
user.id,
)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{optionsOf(roleLabels).map(({ value, label }) => (
<div
key={value}
className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm"
>
<span className="text-foreground font-semibold">
{label}:
</span>{' '}
{roleDescriptions[value]}
</div>
))}
</div>
</div>
</>
);
}
function UserDialog({
user,
isSelf = false,
trigger,
}: {
user?: PanelUser;
isSelf?: boolean;
trigger: ReactNode;
}) {
const id = (field: string) => `user-${user?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={user ? 'Ubah Pengguna' : 'Tambah Pengguna'}
form={user ? update.form(user.id) : store.form()}
>
{(errors) => (
<>
<FormField id={id('name')} label="Nama" error={errors.name}>
<Input
id={id('name')}
name="name"
defaultValue={user?.name}
required
/>
</FormField>
<FormField
id={id('email')}
label="Email"
error={errors.email}
>
<Input
id={id('email')}
name="email"
type="email"
defaultValue={user?.email}
required
/>
</FormField>
<FormField
id={id('role')}
label="Peran"
error={errors.role}
>
<NativeSelect
id={id('role')}
name="role"
defaultValue={user?.role ?? 'editor'}
options={optionsOf(roleLabels)}
disabled={isSelf}
/>
{isSelf && (
<>
<input
type="hidden"
name="role"
value={user?.role}
/>
<p className="text-muted-foreground text-xs">
Anda tidak dapat mengubah peran akun
sendiri.
</p>
</>
)}
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('password')}
label={user ? 'Password baru' : 'Password'}
error={errors.password}
>
<Input
id={id('password')}
name="password"
type="password"
autoComplete="new-password"
placeholder={
user ? 'Kosongkan jika tidak diubah' : ''
}
required={!user}
/>
</FormField>
<FormField
id={id('password_confirmation')}
label="Ulangi password"
error={errors.password_confirmation}
>
<Input
id={id('password_confirmation')}
name="password_confirmation"
type="password"
autoComplete="new-password"
required={!user}
/>
</FormField>
</div>
</>
)}
</FormDialog>
);
}
UsersIndex.layout = {
breadcrumbs: [{ title: 'Pengguna', href: index() }],
};