291 lines
13 KiB
TypeScript
291 lines
13 KiB
TypeScript
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() }],
|
|
};
|