321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
import { DataTable } from '@/components/data-table';
|
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { destroy, create as productCreate, index as productIndex, edit as productEdit, toggleStatus } from '@/routes/admin/master/products';
|
|
import { Head, router } from '@inertiajs/react';
|
|
import type { Row } from '@tanstack/react-table';
|
|
import { Filter, Plus, X } from 'lucide-react';
|
|
import { useMemo, useState } from 'react';
|
|
import type { Product } from './columns';
|
|
import { createProductColumns } from './columns';
|
|
|
|
type Props = {
|
|
products: Product[];
|
|
filters: {
|
|
status?: string;
|
|
name?: string;
|
|
};
|
|
};
|
|
|
|
function formatCurrency(amount: number): string {
|
|
return new Intl.NumberFormat('id-ID', {
|
|
style: 'currency',
|
|
currency: 'IDR',
|
|
minimumFractionDigits: 0,
|
|
}).format(amount);
|
|
}
|
|
|
|
function formatNumber(num: number): string {
|
|
return new Intl.NumberFormat('id-ID').format(num);
|
|
}
|
|
|
|
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
|
|
>
|
|
<img
|
|
src={url}
|
|
alt={title}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</button>
|
|
<ImagePreviewModal
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
src={url}
|
|
title={title}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?: string }) {
|
|
const allVariants = row.original.product_variants ?? [];
|
|
const query = (searchValue ?? '').toLowerCase().trim();
|
|
const variants = query
|
|
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
|
|
: allVariants;
|
|
|
|
return (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[60px]">Foto</TableHead>
|
|
<TableHead className="w-[200px]">Nama Varian</TableHead>
|
|
<TableHead className="text-center">Stok Bagus</TableHead>
|
|
<TableHead className="text-center">Stok Reject</TableHead>
|
|
<TableHead className="text-center">Stok Ecer</TableHead>
|
|
<TableHead>Harga</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{variants.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
|
Tidak ada varian.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
variants.map((variant) => (
|
|
<TableRow key={variant.id}>
|
|
<TableCell>
|
|
{variant.photo_url ? (
|
|
<VariantPhotoPreview
|
|
url={variant.photo_url}
|
|
title={variant.name}
|
|
/>
|
|
) : (
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
|
N/A
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="font-medium">{variant.name}</TableCell>
|
|
<TableCell className="text-center">{formatNumber(variant.stock)}</TableCell>
|
|
<TableCell className="text-center">{formatNumber(variant.reject_stock)}</TableCell>
|
|
<TableCell className="text-center">{formatNumber(variant.retail_stock)}</TableCell>
|
|
<TableCell>
|
|
{variant.product_prices?.length > 0 ? (
|
|
<div className="flex flex-col gap-0.5">
|
|
{variant.product_prices.map((p) => (
|
|
<span key={p.id} className="text-xs">
|
|
<span className="text-muted-foreground">{p.type_label}:</span>{' '}
|
|
{formatCurrency(p.price)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : '-'}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
);
|
|
}
|
|
|
|
export default function ProductIndex({ products, filters }: Props) {
|
|
const [deleting, setDeleting] = useState<Product | null>(null);
|
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
|
|
const hasActiveFilters = filters.status || filters.name;
|
|
|
|
const productNames = useMemo(() => {
|
|
const names = products.map((p) => p.name);
|
|
return [...new Set(names)].sort();
|
|
}, [products]);
|
|
|
|
function applyFilter(key: string, value: string) {
|
|
const newFilters = { ...filters };
|
|
|
|
if (value === '' || value === 'all') {
|
|
delete newFilters[key as keyof typeof newFilters];
|
|
} else {
|
|
newFilters[key as keyof typeof newFilters] = value;
|
|
}
|
|
|
|
router.get(productIndex(), newFilters, {
|
|
preserveState: true,
|
|
replace: true,
|
|
});
|
|
}
|
|
|
|
function clearFilters() {
|
|
router.get(productIndex(), {}, {
|
|
preserveState: true,
|
|
replace: true,
|
|
});
|
|
setFilterOpen(false);
|
|
}
|
|
|
|
function handleDelete() {
|
|
if (!deleting) {
|
|
return;
|
|
}
|
|
|
|
router.delete(destroy.url(deleting.id), {
|
|
onSuccess: () => setDeleting(null),
|
|
});
|
|
}
|
|
|
|
const columns = createProductColumns({
|
|
handleEdit: (product) => {
|
|
window.location.href = productEdit.url(product.id);
|
|
},
|
|
handleDeleteClick: (product) => setDeleting(product),
|
|
toggleStatusUrl: (id) => toggleStatus.url(id),
|
|
});
|
|
|
|
const filterToolbar = (
|
|
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<Filter className="h-4 w-4" />
|
|
Filter
|
|
{hasActiveFilters && (
|
|
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
|
{Object.values(filters).filter(Boolean).length}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64" align="end">
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Filter</span>
|
|
{hasActiveFilters && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
onClick={clearFilters}
|
|
>
|
|
<X className="mr-1 h-3 w-3" />
|
|
Hapus Semua
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-xs text-muted-foreground">
|
|
Nama Produk
|
|
</label>
|
|
<Combobox
|
|
value={filters.name ?? ''}
|
|
onValueChange={(value) => applyFilter('name', value as string)}
|
|
>
|
|
<ComboboxInput placeholder="Pilih produk..." className="w-full" />
|
|
<ComboboxContent>
|
|
<ComboboxEmpty>Tidak ada produk ditemukan.</ComboboxEmpty>
|
|
<ComboboxList>
|
|
{productNames.map((name) => (
|
|
<ComboboxItem key={name} value={name}>
|
|
{name}
|
|
</ComboboxItem>
|
|
))}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-xs text-muted-foreground">
|
|
Status
|
|
</label>
|
|
<Select
|
|
value={filters.status ?? 'all'}
|
|
onValueChange={(value) => applyFilter('status', value)}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Semua Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Semua Status</SelectItem>
|
|
<SelectItem value="active">Aktif</SelectItem>
|
|
<SelectItem value="inactive">Non Aktif</SelectItem>
|
|
<SelectItem value="draft">Draft</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Head title="Produk" />
|
|
|
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold tracking-tight">
|
|
Produk
|
|
</h2>
|
|
</div>
|
|
<Button asChild>
|
|
<a href={productCreate.url()}>
|
|
<Plus className="h-4 w-4" />
|
|
Tambah
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={products}
|
|
searchKey="variant_names"
|
|
searchPlaceholder="Cari varian..."
|
|
emptyText="Belum ada data produk."
|
|
renderSubRow={(row, searchValue) => <VariantSubRow row={row} searchValue={searchValue} />}
|
|
defaultExpanded
|
|
toolbar={filterToolbar}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={deleting !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleting(null);
|
|
}
|
|
}}
|
|
title="Hapus Produk"
|
|
description={`Apakah Anda yakin ingin menghapus produk "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
|
confirmLabel="Hapus"
|
|
onConfirm={handleDelete}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
ProductIndex.layout = {
|
|
breadcrumbs: [
|
|
{
|
|
title: 'Master',
|
|
href: productIndex.url(),
|
|
},
|
|
{
|
|
title: 'Produk',
|
|
href: productIndex.url(),
|
|
},
|
|
],
|
|
};
|