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