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
+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() }],
};