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