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() }],
};
+147 -95
View File
@@ -1,117 +1,169 @@
import { Form, Head } from '@inertiajs/react';
import { Form, Head, Link } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { register } from '@/routes';
import { Button } from '@/components/site/button';
import Card from '@/components/site/card';
import { TextInput } from '@/components/site/form-field';
import Glow, { GridPattern } from '@/components/site/glow';
import Icon from '@/components/site/icon';
import LanguageToggle from '@/components/site/language-toggle';
import ThemeToggle from '@/components/site/theme-toggle';
import { common } from '@/content/common';
import { company } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { home } from '@/routes';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
import PasskeyVerify from '@/components/passkey-verify';
type Props = {
status?: string;
canResetPassword: boolean;
};
/**
* Admin panel sign-in. Public registration is disabled; admin accounts are
* created with `php artisan admin:create`.
*/
export default function Login({ status, canResetPassword }: Props) {
const t = useContent(common).login;
return (
<>
<Head title="Log in" />
<div className="probindo-site bg-surface-container-lowest text-on-surface relative flex min-h-svh flex-col items-center justify-center overflow-hidden p-6 font-sans antialiased">
<Head title={t.pageTitle} />
<GridPattern />
<Glow className="-top-40 -left-20 h-[500px] w-[640px]" />
<Glow className="bg-primary/15 -right-20 bottom-0 h-[420px] w-[520px]" />
<PasskeyVerify />
<div className="absolute top-5 right-5 flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
<Form
{...store.form()}
resetOnSuccess={['password']}
className="flex flex-col gap-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
required
autoFocus
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="relative flex w-full max-w-md flex-col gap-6">
<Link
href={home()}
className="flex flex-col items-center gap-3 self-center"
>
<div className="border-outline-variant/50 dark:bg-surface-container-low rounded-2xl border bg-white p-3">
<img
src={company.logo}
alt={company.name}
className="h-12 w-auto object-contain"
/>
</div>
<span className="text-on-surface-variant font-mono text-xs font-medium">
{company.name}
</span>
</Link>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
{canResetPassword && (
<TextLink
href={request()}
className="ml-auto text-sm"
tabIndex={5}
>
Forgot your password?
</TextLink>
)}
<Card padding="lg" className="shadow-xl">
<div className="mb-6 flex flex-col gap-1 text-center">
<h1 className="font-display text-on-surface text-2xl font-extrabold">
{t.title}
</h1>
<p className="text-on-surface-variant text-sm">
{t.description}
</p>
</div>
{status && (
<div
role="status"
className="border-primary/40 text-on-surface mb-4 rounded-lg border p-3 text-center text-sm"
>
{status}
</div>
)}
<Form
{...store.form()}
resetOnSuccess={['password']}
className="flex flex-col gap-5"
>
{({ processing, errors }) => (
<>
<div className="flex flex-col gap-1.5">
<label
htmlFor="email"
className="text-on-surface font-mono text-xs font-medium"
>
{t.email}
</label>
<TextInput
id="email"
type="email"
name="email"
required
autoFocus
autoComplete="email"
placeholder={t.emailPlaceholder}
/>
<InputError message={errors.email} />
</div>
<PasswordInput
id="password"
name="password"
required
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center space-x-3">
<Checkbox
id="remember"
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Remember me</Label>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="text-on-surface font-mono text-xs font-medium"
>
{t.password}
</label>
{canResetPassword && (
<Link
href={request()}
className="text-primary text-xs font-semibold hover:underline"
>
{t.forgot}
</Link>
)}
</div>
<PasswordInput
id="password"
name="password"
required
autoComplete="current-password"
placeholder={t.passwordPlaceholder}
className="border-outline-variant/60 bg-surface-container-lowest text-on-surface focus-visible:border-primary h-auto rounded-xl px-4 py-2.5 text-sm"
/>
<InputError message={errors.password} />
</div>
<Button
type="submit"
className="mt-4 w-full"
tabIndex={4}
disabled={processing}
data-test="login-button"
>
{processing && <Spinner />}
Log in
</Button>
</div>
<label className="text-on-surface-variant flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="remember"
className="h-4 w-4"
/>
{t.remember}
</label>
<div className="text-muted-foreground text-center text-sm">
Don't have an account?{' '}
<TextLink href={register()} tabIndex={5}>
Sign up
</TextLink>
</div>
</>
)}
</Form>
<Button
type="submit"
size="block"
icon={
processing
? 'progress_activity'
: 'login'
}
disabled={processing}
data-test="login-button"
>
{t.submit}
</Button>
</>
)}
</Form>
</Card>
{status && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
{status}
</div>
)}
</>
<Link
href={home()}
className="text-on-surface-variant hover:text-primary flex items-center gap-1.5 self-center text-sm"
>
<Icon name="arrow_back" className="text-base" />
{t.back}
</Link>
</div>
</div>
);
}
Login.layout = {
title: 'Log in to your account',
description: 'Enter your email and password below to log in',
};
-120
View File
@@ -1,120 +0,0 @@
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { login } from '@/routes';
import { store } from '@/routes/register';
type Props = {
passwordRules: string;
};
export default function Register({ passwordRules }: Props) {
return (
<>
<Head title="Register" />
<Form
{...store.form()}
resetOnSuccess={['password', 'password_confirmation']}
disableWhileProcessing
className="flex flex-col gap-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
required
autoFocus
tabIndex={1}
autoComplete="name"
name="name"
placeholder="Full name"
/>
<InputError
message={errors.name}
className="mt-2"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
required
tabIndex={2}
autoComplete="email"
name="email"
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
required
tabIndex={3}
autoComplete="new-password"
name="password"
placeholder="Password"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
required
tabIndex={4}
autoComplete="new-password"
name="password_confirmation"
placeholder="Confirm password"
passwordrules={passwordRules}
/>
<InputError
message={errors.password_confirmation}
/>
</div>
<Button
type="submit"
className="mt-2 w-full"
tabIndex={5}
data-test="register-user-button"
>
{processing && <Spinner />}
Create account
</Button>
</div>
<div className="text-muted-foreground text-center text-sm">
Already have an account?{' '}
<TextLink href={login()} tabIndex={6}>
Log in
</TextLink>
</div>
</>
)}
</Form>
</>
);
}
Register.layout = {
title: 'Create an account',
description: 'Enter your details below to create your account',
};
+285 -16
View File
@@ -1,25 +1,294 @@
import { Head } from '@inertiajs/react';
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import { dashboard } from '@/routes';
import { Head, Link, usePage } from '@inertiajs/react';
import {
ArrowUpRight,
Building2,
FolderKanban,
Inbox,
Layers,
Users,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { portfolio, yearRange } from '@/data/projects';
import {
formatDate,
sectorLabels,
statusBadgeClasses,
statusLabels,
} from '@/lib/admin-labels';
import { about, clients, contact, dashboard, home, services } from '@/routes';
import { index as consultationsIndex } from '@/routes/admin/consultations';
import { index as projectsIndex } from '@/routes/admin/projects';
import type { Consultation, Project, ProjectSector } from '@/types';
type DashboardProps = {
projects: Project[];
newConsultations: number;
latestConsultations: Pick<
Consultation,
'id' | 'name' | 'company' | 'service' | 'status' | 'created_at'
>[];
};
const sitePages = [
{ title: 'Beranda', href: home() },
{ title: 'Tentang Kami', href: about() },
{ title: 'Jasa', href: services() },
{ title: 'Klien & Portofolio', href: clients() },
{ title: 'Kontak', href: contact() },
];
function StatCard({
icon: Icon,
label,
value,
note,
}: {
icon: LucideIcon;
label: string;
value: string | number;
note: string;
}) {
return (
<div className="bg-card border-sidebar-border/70 flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-mono text-xs font-semibold tracking-wider uppercase">
{label}
</span>
<span className="bg-accent text-accent-foreground flex size-9 items-center justify-center rounded-lg">
<Icon className="size-4" />
</span>
</div>
<span className="font-display text-3xl font-extrabold">
{value}
</span>
<span className="text-muted-foreground text-xs">{note}</span>
</div>
);
}
export default function Dashboard({
projects,
newConsultations,
latestConsultations,
}: DashboardProps) {
const { auth } = usePage().props;
const {
clientCount,
clientsInSector,
projectsBySector,
projectsInProgram,
} = portfolio(projects);
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
const erpProjects = projectsInProgram('erp');
const latest = projects.slice(0, 5);
export default function Dashboard() {
return (
<>
<Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="from-primary relative overflow-hidden rounded-xl bg-gradient-to-r to-[#c81e2a] p-6 text-white md:p-8">
<div className="pointer-events-none absolute -top-16 -right-16 size-56 rounded-full bg-white/10 blur-2xl" />
<div className="relative flex flex-col gap-2">
<span className="font-mono text-xs font-semibold tracking-wider text-white/80 uppercase">
Admin Panel · PT Probindo Artika Jaya
</span>
<h1 className="font-display text-2xl font-extrabold md:text-3xl">
Selamat datang, {auth.user?.name}
</h1>
<p className="max-w-2xl text-sm text-white/85">
Ringkasan permintaan konsultasi dan rekam jejak
proyek yang ditampilkan di website.
</p>
</div>
</div>
<div className="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border md:min-h-min">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={FolderKanban}
label="Total Proyek"
value={projects.length}
note={`Periode ${yearRange(projects)}`}
/>
<StatCard
icon={Users}
label="Total Klien"
value={clientCount}
note="Pemerintahan, BUMN & swasta"
/>
<StatCard
icon={Layers}
label="Pengalaman ERP"
value={erpProjects.length}
note={`Periode ${yearRange(erpProjects)}`}
/>
<StatCard
icon={Inbox}
label="Konsultasi Baru"
value={newConsultations}
note="Belum ditindaklanjuti"
/>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-display text-base font-bold">
Konsultasi Terbaru
</h2>
<Link
href={consultationsIndex()}
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
>
Buka inbox
<ArrowUpRight className="size-3.5" />
</Link>
</div>
{latestConsultations.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Belum ada permintaan konsultasi.
</p>
) : (
<ul className="divide-border divide-y">
{latestConsultations.map((item) => (
<li
key={item.id}
className="flex items-center gap-4 py-3"
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold">
{item.name}
{item.company &&
` · ${item.company}`}
</span>
<span className="text-muted-foreground truncate text-xs">
{formatDate(
item.created_at,
)}
{item.service &&
` · ${item.service}`}
</span>
</div>
<Badge
className={
statusBadgeClasses[
item.status
]
}
>
{statusLabels[item.status]}
</Badge>
</li>
))}
</ul>
)}
</div>
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-display text-base font-bold">
Proyek Terbaru
</h2>
<Link
href={projectsIndex()}
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
>
Kelola portofolio
<ArrowUpRight className="size-3.5" />
</Link>
</div>
<ul className="divide-border divide-y">
{latest.map((project) => (
<li
key={project.id}
className="flex items-start gap-4 py-3"
>
<span className="bg-accent text-accent-foreground shrink-0 rounded-md px-2 py-0.5 font-mono text-xs font-bold">
{project.year}
</span>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold">
{project.title}
</span>
<span className="text-muted-foreground truncate text-xs">
{project.client} ·{' '}
{sectorLabels[project.sector]}
</span>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
<h2 className="font-display mb-4 text-base font-bold">
Proyek per Sektor
</h2>
<div className="flex flex-col gap-4">
{sectors.map((sector) => {
const count =
projectsBySector(sector).length;
const percent = projects.length
? Math.round(
(count / projects.length) * 100,
)
: 0;
return (
<div
key={sector}
className="flex flex-col gap-1.5"
>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 font-medium">
<Building2 className="text-primary size-4" />
{sectorLabels[sector]}
</span>
<span className="text-muted-foreground font-mono text-xs">
{count} proyek ·{' '}
{
clientsInSector(sector)
.length
}{' '}
klien
</span>
</div>
<div className="bg-muted h-2 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full"
style={{
width: `${percent}%`,
}}
/>
</div>
</div>
);
})}
</div>
</div>
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
<h2 className="font-display mb-3 text-base font-bold">
Halaman Website
</h2>
<div className="flex flex-col">
{sitePages.map((page) => (
<a
key={page.title}
href={page.href.url}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-accent hover:text-accent-foreground flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors"
>
{page.title}
<ArrowUpRight className="size-4 opacity-60" />
</a>
))}
</div>
</div>
</div>
</div>
</div>
</>
+10 -5
View File
@@ -14,13 +14,15 @@ import SectionHeader from '@/components/site/section-header';
import CtaBanner from '@/components/site/sections/cta-banner';
import StatCard from '@/components/site/stat-card';
import { aboutContent } from '@/content/about';
import { clientsInSector, projectsBySector } from '@/data/projects';
import { fillCounts } from '@/data/projects';
import { consultationUrl } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { clients, services } from '@/routes';
export default function About() {
const t = useContent(aboutContent);
const stats = usePortfolio();
const t = fillCounts(useContent(aboutContent), stats);
return (
<>
@@ -178,7 +180,9 @@ export default function About() {
/>
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{t.industries.items.map((item) => {
const clientNames = clientsInSector(item.sector);
const clientNames = stats.clientsInSector(
item.sector,
);
return (
<Card
@@ -196,8 +200,9 @@ export default function About() {
{clientNames.length}{' '}
{t.industries.clients} ·{' '}
{
projectsBySector(item.sector)
.length
stats.projectsBySector(
item.sector,
).length
}{' '}
{t.industries.projects}
</span>
+40 -27
View File
@@ -1,4 +1,4 @@
import { Head } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { Button, ButtonLink } from '@/components/site/button';
import Card from '@/components/site/card';
@@ -13,21 +13,22 @@ import Section from '@/components/site/section';
import SectionHeader from '@/components/site/section-header';
import CtaBanner from '@/components/site/sections/cta-banner';
import { clientsContent } from '@/content/clients';
import type { ProjectSector } from '@/data/projects';
import {
projectsBySector,
projectsInProgram,
yearRange,
} from '@/data/projects';
import { company, consultationUrl, officeAddress, offices } from '@/data/site';
import type { Portfolio } from '@/data/projects';
import { fillCounts, yearRange } from '@/data/projects';
import { company, consultationUrl, officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
import { contact } from '@/routes';
import type { LocalizedTestimonial, ProjectSector } from '@/types';
type Content = typeof clientsContent.id;
export default function Clients() {
const t = useContent(clientsContent);
const stats = usePortfolio();
const t = fillCounts(useContent(clientsContent), stats);
const site = useSiteInfo();
return (
<>
@@ -94,9 +95,9 @@ export default function Clients() {
</div>
</Section>
<SectorPortfolio t={t} />
<SectorPortfolio t={t} stats={stats} />
<FeaturedPrograms t={t} />
<FeaturedPrograms t={t} stats={stats} />
<Section tone="muted">
<div className="flex flex-col gap-12">
@@ -194,28 +195,28 @@ export default function Clients() {
{t.office.description}
</p>
<div className="text-on-surface mt-2 flex flex-col gap-2 font-mono text-sm">
{offices.map((office) => (
{site.offices.map((office) => (
<a
key={office.name}
href={office.phone.href}
href={office.phoneHref}
className="hover:text-primary flex items-center gap-2"
>
<Icon
name="call"
className="text-primary text-base"
/>
{office.phone.label}
{office.phone}
</a>
))}
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary flex items-center gap-2"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
</div>
@@ -227,7 +228,7 @@ export default function Clients() {
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="relative z-10 grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="bg-surface-container-lowest/90 rounded-xl p-4 shadow-lg backdrop-blur-md"
@@ -250,11 +251,11 @@ export default function Clients() {
const PREVIEW_COUNT = 8;
function SectorPortfolio({ t }: { t: Content }) {
function SectorPortfolio({ t, stats }: { t: Content; stats: Portfolio }) {
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
const [sector, setSector] = useState<ProjectSector>('government');
const [expanded, setExpanded] = useState(false);
const items = projectsBySector(sector);
const items = stats.projectsBySector(sector);
const visible = expanded ? items : items.slice(0, PREVIEW_COUNT);
const selectSector = (next: ProjectSector) => {
@@ -289,7 +290,7 @@ function SectorPortfolio({ t }: { t: Content }) {
)}
>
{t.portfolio.sectors[option]} (
{projectsBySector(option).length})
{stats.projectsBySector(option).length})
</button>
))}
</div>
@@ -360,7 +361,7 @@ function SectorPortfolio({ t }: { t: Content }) {
const RELATED_PREVIEW = 4;
function FeaturedPrograms({ t }: { t: Content }) {
function FeaturedPrograms({ t, stats }: { t: Content; stats: Portfolio }) {
return (
<Section>
<div className="flex flex-col gap-12">
@@ -371,7 +372,7 @@ function FeaturedPrograms({ t }: { t: Content }) {
/>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{t.cases.items.map((item, index) => {
const related = projectsInProgram(item.program);
const related = stats.projectsInProgram(item.program);
const remaining = related.length - RELATED_PREVIEW;
return (
@@ -456,9 +457,13 @@ function FeaturedPrograms({ t }: { t: Content }) {
);
}
/** Hidden until real client testimonials are added to the content file. */
/** Shown only once published testimonials exist (admin → Testimoni). */
function Testimonials({ t }: { t: Content }) {
if (t.testimonials.items.length === 0) {
const { testimonials } = usePage<{
testimonials: LocalizedTestimonial[];
}>().props;
if (testimonials.length === 0) {
return null;
}
@@ -472,9 +477,9 @@ function Testimonials({ t }: { t: Content }) {
description={t.testimonials.description}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{t.testimonials.items.map((testimonial) => (
{testimonials.map((testimonial) => (
<Card
key={testimonial.initials}
key={testimonial.id}
className="flex flex-col justify-between gap-6"
>
<div className="space-y-4">
@@ -492,7 +497,7 @@ function Testimonials({ t }: { t: Content }) {
</div>
<div className="flex items-center gap-3">
<div className="bg-surface-container text-primary flex h-10 w-10 items-center justify-center rounded-full font-mono font-bold">
{testimonial.initials}
{initials(testimonial.role)}
</div>
<div>
<div className="text-on-surface text-sm font-bold">
@@ -511,6 +516,14 @@ function Testimonials({ t }: { t: Content }) {
);
}
const initials = (text: string) =>
text
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join('');
function RetentionGauge({ t }: { t: Content }) {
return (
<div className="bg-surface-container flex w-full flex-col items-center justify-center rounded-xl p-6 lg:w-96">
+237 -182
View File
@@ -1,11 +1,11 @@
import { Head } from '@inertiajs/react';
import { Form, Head } from '@inertiajs/react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Button, ButtonLink } from '@/components/site/button';
import Card from '@/components/site/card';
import Eyebrow from '@/components/site/eyebrow';
import {
Field,
Honeypot,
SelectInput,
TextArea,
TextInput,
@@ -19,13 +19,16 @@ import SectionHeader from '@/components/site/section-header';
import StepCard from '@/components/site/step-card';
import { common, serviceOptions } from '@/content/common';
import { contactContent } from '@/content/contact';
import { company, officeAddress, offices } from '@/data/site';
import { officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
import { store } from '@/routes/consultations';
type Content = typeof contactContent.id;
export default function Contact() {
const site = useSiteInfo();
const t = useContent(contactContent);
return (
@@ -186,19 +189,19 @@ export default function Contact() {
</p>
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
<ButtonLink
href={company.whatsapp.href}
href={site.whatsapp.href}
size="sm"
icon="chat"
>
{t.faq.whatsapp} ({company.whatsapp.label})
{t.faq.whatsapp} ({site.whatsapp.label})
</ButtonLink>
<ButtonLink
href={company.phone.href}
href={site.phone.href}
variant="secondary"
size="sm"
icon="call"
>
{t.faq.phone} ({company.phone.label})
{t.faq.phone} ({site.phone.label})
</ButtonLink>
</div>
</div>
@@ -217,6 +220,7 @@ function CardLabel({ children }: { children: string }) {
}
function OfficeCard({ t }: { t: Content }) {
const site = useSiteInfo();
return (
<Card padding="lg">
<div className="pb-4">
@@ -226,7 +230,7 @@ function OfficeCard({ t }: { t: Content }) {
</h3>
</div>
<div className="flex flex-col gap-3">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="bg-surface-container flex items-start gap-3 rounded-lg p-3.5"
@@ -240,10 +244,10 @@ function OfficeCard({ t }: { t: Content }) {
{officeAddress(office)}
</span>
<a
href={office.phone.href}
href={office.phoneHref}
className="text-on-surface hover:text-primary font-mono font-semibold"
>
{t.offices.tel} {office.phone.label}
{t.offices.tel} {office.phone}
</a>
</div>
</div>
@@ -275,31 +279,32 @@ function OfficeCard({ t }: { t: Content }) {
}
function ChannelsCard({ t }: { t: Content }) {
const site = useSiteInfo();
const channels = [
{
icon: 'call',
label: t.channels.phone,
value: company.phone.label,
href: company.phone.href,
value: site.phone.label,
href: site.phone.href,
},
{
icon: 'chat',
label: t.channels.whatsapp,
value: company.whatsapp.label,
href: `${company.whatsapp.href}?text=${encodeURIComponent(t.channels.whatsappMessage)}`,
value: site.whatsapp.label,
href: `${site.whatsapp.href}?text=${encodeURIComponent(t.channels.whatsappMessage)}`,
badge: t.channels.whatsappBadge,
},
{
icon: 'mail',
label: t.channels.email,
value: company.email.busdev,
href: `mailto:${company.email.busdev}`,
value: site.email.busdev,
href: `mailto:${site.email.busdev}`,
},
{
icon: 'headset_mic',
label: t.channels.support,
value: company.email.support,
href: `mailto:${company.email.support}`,
value: site.email.support,
href: `mailto:${site.email.support}`,
badge: t.channels.supportBadge,
},
];
@@ -398,12 +403,6 @@ function ConsultationRequestForm({ t }: { t: Content }) {
const [submitted, setSubmitted] = useState(false);
const form = t.form;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(true);
event.currentTarget.reset();
};
return (
<Card padding="lg" className="shadow-xl lg:p-10">
<div className="border-outline-variant/30 border-b pb-6">
@@ -416,162 +415,216 @@ function ConsultationRequestForm({ t }: { t: Content }) {
</p>
</div>
<form className="flex flex-col gap-6 pt-6" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field id="full-name" label={form.name} required>
<TextInput
id="full-name"
name="name"
placeholder={form.namePlaceholder}
required
/>
</Field>
<Field id="company-name" label={form.company} required>
<TextInput
id="company-name"
name="company"
placeholder={form.companyPlaceholder}
required
/>
</Field>
<Field id="job-title" label={form.jobTitle} required>
<TextInput
id="job-title"
name="job_title"
placeholder={form.jobTitlePlaceholder}
required
/>
</Field>
<Field id="work-email" label={form.email} required>
<TextInput
id="work-email"
name="email"
type="email"
placeholder={form.emailPlaceholder}
required
/>
</Field>
<Field id="phone-number" label={form.phone} required>
<TextInput
id="phone-number"
name="phone"
type="tel"
placeholder="+62 812 XXXX XXXX"
required
/>
</Field>
<Field id="industry-sector" label={form.industry} required>
<SelectInput
id="industry-sector"
name="industry"
placeholder={form.industryPlaceholder}
options={form.industries}
required
/>
</Field>
</div>
<Field id="service-focus" label={form.service} required>
<SelectInput
id="service-focus"
name="service"
placeholder={form.servicePlaceholder}
options={serviceOptions(shared)}
required
/>
</Field>
<fieldset className="flex flex-col gap-2">
<legend className="text-on-surface pb-2 font-mono text-xs font-medium">
{form.timeline}
</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{form.timelines.map((timeline, index) => (
<label
key={timeline.value}
className={cn(
'border-outline-variant/60 bg-surface-container-lowest text-on-surface hover:bg-surface-container-high flex cursor-pointer flex-col rounded-lg border p-3 transition-colors',
'has-checked:border-primary-container has-checked:bg-primary-container has-checked:text-white',
'has-focus-visible:ring-primary has-focus-visible:ring-2',
)}
>
<input
type="radio"
name="timeline"
value={timeline.value}
defaultChecked={index === 0}
className="sr-only"
<Form
{...store.form()}
options={{ preserveScroll: true }}
resetOnSuccess
onSuccess={() => setSubmitted(true)}
className="relative flex flex-col gap-6 pt-6"
>
{({ processing, errors }) => (
<>
<input type="hidden" name="source" value="contact" />
<Honeypot />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field id="full-name" label={form.name} required>
<TextInput
id="full-name"
name="name"
placeholder={form.namePlaceholder}
required
/>
<span className="text-xs font-bold">
{timeline.label}
</span>
<span className="text-sm opacity-80">
{timeline.note}
</span>
</label>
))}
</div>
</fieldset>
<Field id="project-details" label={form.details}>
<TextArea
id="project-details"
name="details"
rows={4}
placeholder={form.detailsPlaceholder}
/>
</Field>
<div className="bg-surface-container-lowest flex items-start gap-3 rounded-lg p-3.5">
<input
id="nda-consent"
name="nda"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0"
/>
<label
htmlFor="nda-consent"
className="text-on-surface-variant cursor-pointer text-sm"
>
<RichText text={form.nda} />
</label>
</div>
<Button type="submit" size="block" trailingIcon="arrow_forward">
{form.submit}
</Button>
{submitted && (
<div
role="status"
className="border-primary/40 bg-surface-container-high text-on-surface flex items-center gap-3 rounded-lg border p-4"
>
<Icon
name="check_circle"
className="text-primary text-2xl"
/>
<div>
<span className="font-bold">
{form.successTitle}
</span>
<p className="text-on-surface-variant text-sm">
{form.successMessage}
</p>
</Field>
<Field
id="company-name"
label={form.company}
required
>
<TextInput
id="company-name"
name="company"
placeholder={form.companyPlaceholder}
required
/>
</Field>
<Field
id="job-title"
label={form.jobTitle}
required
>
<TextInput
id="job-title"
name="job_title"
placeholder={form.jobTitlePlaceholder}
required
/>
</Field>
<Field id="work-email" label={form.email} required>
<TextInput
id="work-email"
name="email"
type="email"
placeholder={form.emailPlaceholder}
required
/>
</Field>
<Field
id="phone-number"
label={form.phone}
required
>
<TextInput
id="phone-number"
name="phone"
type="tel"
placeholder="+62 812 XXXX XXXX"
required
/>
</Field>
<Field
id="industry-sector"
label={form.industry}
required
>
<SelectInput
id="industry-sector"
name="industry"
placeholder={form.industryPlaceholder}
options={form.industries.map(
({ label }) => ({
value: label,
label,
}),
)}
required
/>
</Field>
</div>
</div>
)}
<div className="text-on-surface-variant flex items-center justify-center gap-2 pt-2 text-center font-mono text-[11px]">
<Icon name="lock" className="text-primary text-sm" />
{form.privacy}
</div>
</form>
<Field id="service-focus" label={form.service} required>
<SelectInput
id="service-focus"
name="service"
placeholder={form.servicePlaceholder}
options={serviceOptions(shared)}
required
/>
</Field>
<fieldset className="flex flex-col gap-2">
<legend className="text-on-surface pb-2 font-mono text-xs font-medium">
{form.timeline}
</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{form.timelines.map((timeline, index) => (
<label
key={timeline.value}
className={cn(
'border-outline-variant/60 bg-surface-container-lowest text-on-surface hover:bg-surface-container-high flex cursor-pointer flex-col rounded-lg border p-3 transition-colors',
'has-checked:border-primary-container has-checked:bg-primary-container has-checked:text-white',
'has-focus-visible:ring-primary has-focus-visible:ring-2',
)}
>
<input
type="radio"
name="timeline"
value={timeline.value}
defaultChecked={index === 0}
className="sr-only"
/>
<span className="text-xs font-bold">
{timeline.label}
</span>
<span className="text-sm opacity-80">
{timeline.note}
</span>
</label>
))}
</div>
</fieldset>
<Field id="project-details" label={form.details}>
<TextArea
id="project-details"
name="details"
rows={4}
placeholder={form.detailsPlaceholder}
/>
</Field>
<div className="bg-surface-container-lowest flex items-start gap-3 rounded-lg p-3.5">
<input
id="nda-consent"
name="nda_agreed"
value="1"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0"
/>
<label
htmlFor="nda-consent"
className="text-on-surface-variant cursor-pointer text-sm"
>
<RichText text={form.nda} />
</label>
</div>
{Object.keys(errors).length > 0 && (
<ul
role="alert"
className="border-primary/40 text-primary flex flex-col gap-1 rounded-lg border p-3 text-sm"
>
{Object.values(errors).map((message) => (
<li key={message}>{message}</li>
))}
</ul>
)}
<Button
type="submit"
size="block"
trailingIcon="arrow_forward"
disabled={processing}
>
{form.submit}
</Button>
{submitted && (
<div
role="status"
className="border-primary/40 bg-surface-container-high text-on-surface flex items-center gap-3 rounded-lg border p-4"
>
<Icon
name="check_circle"
className="text-primary text-2xl"
/>
<div>
<span className="font-bold">
{form.successTitle}
</span>
<p className="text-on-surface-variant text-sm">
{form.successMessage}
</p>
</div>
</div>
)}
<div className="text-on-surface-variant flex items-center justify-center gap-2 pt-2 text-center font-mono text-[11px]">
<Icon
name="lock"
className="text-primary text-sm"
/>
{form.privacy}
</div>
</>
)}
</Form>
</Card>
);
}
function OfficeMap({ t }: { t: Content }) {
const site = useSiteInfo();
return (
<div className="bg-surface-container relative flex min-h-96 w-full flex-col justify-end overflow-hidden rounded-2xl p-4 shadow-xl sm:p-6 lg:min-h-[480px]">
<img
@@ -582,7 +635,7 @@ function OfficeMap({ t }: { t: Content }) {
/>
<div className="from-surface via-surface/40 absolute inset-0 bg-gradient-to-t to-transparent" />
<div className="relative mt-40 grid grid-cols-1 gap-4 md:grid-cols-2">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="border-outline-variant/40 bg-surface-container/95 flex flex-col gap-3 rounded-xl border p-5 shadow-2xl backdrop-blur-md"
@@ -601,15 +654,17 @@ function OfficeMap({ t }: { t: Content }) {
<p className="text-on-surface-variant text-sm leading-relaxed">
{officeAddress(office)}
</p>
<ButtonLink
href={office.mapsUrl}
variant="secondary"
size="sm"
icon="directions"
className="self-start"
>
{t.location.directions}
</ButtonLink>
{office.maps_url && (
<ButtonLink
href={office.maps_url}
variant="secondary"
size="sm"
icon="directions"
className="self-start"
>
{t.location.directions}
</ButtonLink>
)}
</div>
))}
</div>
+6 -3
View File
@@ -14,14 +14,16 @@ import ConsultationSection from '@/components/site/sections/consultation-section
import ServiceGrid from '@/components/site/sections/service-grid';
import StatCard from '@/components/site/stat-card';
import { homeContent } from '@/content/home';
import { topClients } from '@/data/projects';
import { fillCounts } from '@/data/projects';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { about, clients, services } from '@/routes';
type Content = typeof homeContent.id;
export default function Home() {
const t = useContent(homeContent);
const stats = usePortfolio();
const t = fillCounts(useContent(homeContent), stats);
return (
<>
@@ -48,7 +50,7 @@ export default function Home() {
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{topClients(10).map((client) => (
{stats.topClients(10).map((client) => (
<div
key={client}
className="border-outline-variant/40 bg-surface-container text-on-surface hover:border-primary/50 hover:bg-surface-container-high flex min-h-16 items-center justify-center rounded-xl border px-4 py-3 text-center font-mono text-xs font-medium transition-all"
@@ -140,6 +142,7 @@ export default function Home() {
<ConsultationSection
id="konsultasi"
source="home"
title={t.consultation.title}
description={t.consultation.description}
/>
+1
View File
@@ -185,6 +185,7 @@ export default function Services() {
<ConsultationSection
id="konsultasi"
source="services"
title={t.consultation.title}
description={t.consultation.description}
/>