feat: refactor expense and category management components to utilize custom hooks and modularize code for improved maintainability

This commit is contained in:
Yoga Pangestu 2026-04-17 21:55:55 +07:00
parent 431bd1c0c2
commit 9af62ff47c
15 changed files with 1221 additions and 866 deletions

View File

@ -0,0 +1,90 @@
import { useState } from 'react';
import { Expense } from '@/types';
import { router } from '@inertiajs/react';
import expenseRoutes from '@/routes/expense';
import { toast } from 'sonner';
export function useExpenseIndex() {
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedExpense, setSelectedExpense] = useState<Expense | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [expenseToDelete, setExpenseToDelete] = useState<Expense | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const [selectedProof, setSelectedProof] = useState<string | null>(null);
const onAdd = () => {
setSelectedExpense(null);
setIsFormOpen(true);
};
const onEdit = (expense: Expense) => {
setSelectedExpense(expense);
setIsFormOpen(true);
};
const onDelete = (expense: Expense) => {
setExpenseToDelete(expense);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (expenseToDelete) {
router.delete(expenseRoutes.destroy(expenseToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setExpenseToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(expenseRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
const closeForm = () => {
setIsFormOpen(false);
setTimeout(() => setSelectedExpense(null), 200);
};
const onPreviewImage = (url: string) => {
setSelectedProof(url);
};
return {
isFormOpen,
selectedExpense,
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
expenseToDelete,
rowsToDelete,
rowSelection,
selectedProof,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
setSelectedProof,
onAdd,
onEdit,
onDelete,
confirmDelete,
confirmBulkDelete,
closeForm,
onPreviewImage,
};
}

View File

@ -1,26 +1,9 @@
import { Head, useForm, router } from '@inertiajs/react';
import { Head } from '@inertiajs/react';
import type { Expense } from '@/types';
import { ColumnDef } from '@tanstack/react-table';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2, Pencil, ImagePlus, X } from 'lucide-react';
import { Trash2, X } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useState, useRef } from 'react';
import { toast } from 'sonner';
import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip';
import { Tooltip } from '@/components/ui/tooltip';
import expenseRoutes from '@/routes/expense';
import {
AlertDialog,
AlertDialogAction,
@ -32,232 +15,37 @@ import {
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { NumericFormat } from 'react-number-format';
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { useExpenseIndex } from './hooks/use-expense-index';
import { getColumns } from './partials/columns';
import { ExpenseFormModal } from './partials/expense-form-modal';
export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [selectedExpense, setSelectedExpense] = useState<Expense | null>(null);
const {
isFormOpen,
selectedExpense,
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
expenseToDelete,
rowsToDelete,
rowSelection,
selectedProof,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
setSelectedProof,
onAdd,
onEdit,
onDelete,
confirmDelete,
confirmBulkDelete,
closeForm,
onPreviewImage,
} = useExpenseIndex();
const { data, setData, post, patch, processing, errors, reset, clearErrors } = useForm<{
name: string;
amount: string;
image: File | null;
}>({
name: '',
amount: '',
image: null,
});
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [selectedProof, setSelectedProof] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [expenseToDelete, setExpenseToDelete] = useState<Expense | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const onEdit = (expense: Expense) => {
setIsEditing(true);
setSelectedExpense(expense);
setData({
name: expense.name,
amount: expense.amount.toString(),
image: null,
});
setImagePreview(expense.proof_url || null);
setIsOpen(true);
};
const onDelete = (expense: Expense) => {
setExpenseToDelete(expense);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (expenseToDelete) {
router.delete(expenseRoutes.destroy(expenseToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setExpenseToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(expenseRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
const closeModal = () => {
setIsOpen(false);
setTimeout(() => {
setIsEditing(false);
setSelectedExpense(null);
setImagePreview(null);
reset();
clearErrors();
}, 200);
};
const onImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setData('image', file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const removeImage = () => {
setData('image', null);
setImagePreview(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEditing && selectedExpense) {
router.post(expenseRoutes.update(selectedExpense.id).url, {
...data,
_method: 'PATCH',
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
closeModal();
},
});
} else {
post(expenseRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
closeModal();
},
});
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
};
const columns: ColumnDef<Expense>[] = [
{
accessorKey: "proof_url",
header: "Bukti",
cell: ({ row }) => {
const url = row.original.proof_url;
return url ? (
<button onClick={() => setSelectedProof(url)} className="block w-fit">
<img src={url} alt="Proof" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
</button>
) : (
<div className="h-10 w-10 flex items-center justify-center bg-muted rounded-md border">
<ImagePlus className="h-4 w-4 text-muted-foreground" />
</div>
);
},
meta: { title: "Bukti" },
},
{
accessorKey: "user.name",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Pegawai" />
)
},
cell: ({ row }) => row.original.user?.name,
meta: { title: "Pegawai" },
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Nama" />
)
},
meta: { title: "Nama" },
},
{
accessorKey: "amount",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Nominal" />
)
},
cell: ({ row }) => formatCurrency(row.original.amount),
meta: { title: "Nominal" },
},
{
accessorKey: "created_at",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Tanggal" />
)
},
cell: ({ row }) => new Date(row.original.created_at).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric'
}),
meta: { title: "Tanggal" },
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const expense = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="icon" className='text-yellow-600' onClick={() => onEdit(expense)}>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600' onClick={() => onDelete(expense)}>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];
const columns = getColumns({ onEdit, onDelete, onPreviewImage });
return (
<div className="flex flex-col gap-6 p-6">
@ -267,98 +55,16 @@ export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
<div>
<h1 className="text-3xl font-bold tracking-tight">Pengeluaran</h1>
</div>
<Button onClick={() => setIsOpen(true)}>
<Button onClick={onAdd}>
Tambah
</Button>
</div>
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditing ? 'Ubah Pengeluaran' : 'Tambah Pengeluaran'}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<Label htmlFor="name" required>Nama</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
autoComplete='off'
placeholder='Contoh: Bayar Listrik'
maxLength={100}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field>
<Field>
<Label htmlFor="amount" required>Nominal</Label>
<NumericFormat
id="amount"
customInput={Input}
thousandSeparator="."
decimalSeparator=","
prefix="Rp "
value={data.amount}
onValueChange={(values) => {
setData('amount', values.value)
}}
placeholder="Rp 0"
autoComplete='off'
/>
{errors.amount && <p className="text-xs text-red-500">{errors.amount}</p>}
</Field>
<Field>
<Label>Bukti</Label>
<div className="mt-2">
{imagePreview ? (
<div className="relative inline-block">
<img
src={imagePreview}
alt="Preview"
className="object-cover rounded-lg border shadow-sm"
/>
<button
type="button"
onClick={removeImage}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 shadow-md hover:bg-red-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className="h-40 w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
>
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground font-medium">Klik untuk upload bukti</span>
<span className="text-xs text-muted-foreground mt-1">PNG, JPG up to 5MB</span>
</div>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={onImageChange}
/>
</div>
{errors.image && <p className="text-xs text-red-500 mt-1">{errors.image}</p>}
</Field>
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={closeModal}>Batal</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<ExpenseFormModal
isOpen={isFormOpen}
onClose={closeForm}
expense={selectedExpense}
/>
<Dialog open={!!selectedProof} onOpenChange={() => setSelectedProof(null)}>
<DialogContent className="max-w-3xl p-0 overflow-hidden border-none bg-transparent shadow-none">
@ -400,6 +106,7 @@ export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
</CardContent>
</Card>
{/* Single Delete Confirmation */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
@ -418,6 +125,7 @@ export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>

View File

@ -0,0 +1,107 @@
import { ColumnDef } from '@tanstack/react-table';
import { Expense } from '@/types';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
interface ColumnProps {
onEdit: (expense: Expense) => void;
onDelete: (expense: Expense) => void;
onPreviewImage: (url: string) => void;
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
};
export const getColumns = ({ onEdit, onDelete, onPreviewImage }: ColumnProps): ColumnDef<Expense>[] => [
{
accessorKey: "proof_url",
header: "Bukti",
cell: ({ row }) => {
const url = row.original.proof_url;
return url ? (
<button onClick={() => onPreviewImage(url)} className="block w-fit">
<img src={url} alt="Proof" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
</button>
) : (
<div className="h-10 w-10 flex items-center justify-center bg-muted rounded-md border">
<ImagePlus className="h-4 w-4 text-muted-foreground" />
</div>
);
},
meta: { title: "Bukti" },
},
{
accessorKey: "user.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Pegawai" />
),
cell: ({ row }) => row.original.user?.name,
meta: { title: "Pegawai" },
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nama" />
),
meta: { title: "Nama" },
},
{
accessorKey: "amount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nominal" />
),
cell: ({ row }) => formatCurrency(row.original.amount),
meta: { title: "Nominal" },
},
{
accessorKey: "created_at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Tanggal" />
),
cell: ({ row }) => new Date(row.original.created_at).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric'
}),
meta: { title: "Tanggal" },
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const expense = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-yellow-600' onClick={() => onEdit(expense)}>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600' onClick={() => onDelete(expense)}>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];

View File

@ -0,0 +1,195 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Expense } from '@/types';
import { useForm, router } from '@inertiajs/react';
import { useEffect, useState, useRef } from 'react';
import expenseRoutes from '@/routes/expense';
import { toast } from 'sonner';
import { NumericFormat } from 'react-number-format';
import { ImagePlus, X } from 'lucide-react';
interface ExpenseFormModalProps {
isOpen: boolean;
onClose: () => void;
expense: Expense | null;
}
export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalProps) {
const isEditing = !!expense;
const fileInputRef = useRef<HTMLInputElement>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const { data, setData, post, processing, errors, reset, clearErrors } = useForm<{
name: string;
amount: string;
image: File | null;
}>({
name: '',
amount: '',
image: null,
});
useEffect(() => {
if (expense) {
setData({
name: expense.name,
amount: expense.amount.toString(),
image: null,
});
setImagePreview(expense.proof_url || null);
} else {
reset();
setImagePreview(null);
}
}, [expense]);
const handleClose = () => {
onClose();
setTimeout(() => {
reset();
setImagePreview(null);
clearErrors();
}, 200);
};
const onImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setData('image', file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const removeImage = () => {
setData('image', null);
setImagePreview(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEditing && expense) {
router.post(expenseRoutes.update(expense.id).url, {
...data,
_method: 'PATCH',
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
handleClose();
},
});
} else {
post(expenseRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
handleClose();
},
});
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditing ? 'Ubah Pengeluaran' : 'Tambah Pengeluaran'}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<Label htmlFor="name" required>Nama</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
autoComplete='off'
placeholder='Contoh: Bayar Listrik'
maxLength={100}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field>
<Field>
<Label htmlFor="amount" required>Nominal</Label>
<NumericFormat
id="amount"
customInput={Input}
thousandSeparator="."
decimalSeparator=","
prefix="Rp "
value={data.amount}
onValueChange={(values) => {
setData('amount', values.value)
}}
placeholder="Rp 0"
autoComplete='off'
/>
{errors.amount && <p className="text-xs text-red-500">{errors.amount}</p>}
</Field>
<Field>
<Label>Bukti</Label>
<div className="mt-2">
{imagePreview ? (
<div className="relative inline-block">
<img
src={imagePreview}
alt="Preview"
className="object-cover rounded-lg border shadow-sm"
/>
<button
type="button"
onClick={removeImage}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 shadow-md hover:bg-red-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className="h-40 w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
>
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground font-medium">Klik untuk upload bukti</span>
<span className="text-xs text-muted-foreground mt-1">PNG, JPG up to 5MB</span>
</div>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={onImageChange}
/>
</div>
{errors.image && <p className="text-xs text-red-500 mt-1">{errors.image}</p>}
</Field>
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={handleClose}>Batal</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,89 @@
import { useState } from 'react';
import { Category } from '@/types';
import { router } from '@inertiajs/react';
import categoryRoutes from '@/routes/category';
import { toast } from 'sonner';
export function useCategoryIndex() {
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const onAdd = () => {
setSelectedCategory(null);
setIsFormOpen(true);
};
const onEdit = (category: Category) => {
setSelectedCategory(category);
setIsFormOpen(true);
};
const onDelete = (category: Category) => {
setCategoryToDelete(category);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (categoryToDelete) {
router.delete(categoryRoutes.destroy(categoryToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setCategoryToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(categoryRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
const onToggleStatus = (id: number) => {
router.patch(categoryRoutes.toggleStatus(id).url, {}, {
onSuccess: (response: any) => toast.success(response.props.flash.success),
});
};
const closeForm = () => {
setIsFormOpen(false);
setTimeout(() => setSelectedCategory(null), 200);
};
return {
isFormOpen,
selectedCategory,
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
categoryToDelete,
rowsToDelete,
rowSelection,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
onAdd,
onEdit,
onDelete,
confirmDelete,
confirmBulkDelete,
onToggleStatus,
closeForm,
};
}

View File

@ -1,28 +1,9 @@
import { Head, useForm, router, usePage } from '@inertiajs/react';
import { Head } from '@inertiajs/react';
import type { Category } from '@/types';
import { ColumnDef } from '@tanstack/react-table';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Trash2, Pencil } from 'lucide-react';
import { Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useState } from 'react';
import { toast } from 'sonner';
import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip';
import { Tooltip } from '@/components/ui/tooltip';
import { Switch } from '@/components/ui/switch';
import categoryRoutes from '@/routes/category';
import {
AlertDialog,
AlertDialogAction,
@ -35,154 +16,33 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useCategoryIndex } from './hooks/use-category-index';
import { getColumns } from './partials/columns';
import { CategoryFormModal } from './partials/category-form-modal';
export default function CategoryIndex({ categories }: { categories: Category[] }) {
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const {
isFormOpen,
selectedCategory,
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
categoryToDelete,
rowsToDelete,
rowSelection,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
onAdd,
onEdit,
onDelete,
confirmDelete,
confirmBulkDelete,
onToggleStatus,
closeForm,
} = useCategoryIndex();
const { data, setData, post, patch, processing, errors, reset, clearErrors } = useForm({
name: '',
});
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const onEdit = (category: Category) => {
setIsEditing(true);
setSelectedCategory(category);
setData({
name: category.name,
});
setIsOpen(true);
};
const onDelete = (category: Category) => {
setCategoryToDelete(category);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (categoryToDelete) {
router.delete(categoryRoutes.destroy(categoryToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setCategoryToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(categoryRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
const onToggleStatus = (id: number) => {
router.patch(categoryRoutes.toggleStatus(id).url, {}, {
onSuccess: (response: any) => toast.success(response.props.flash.success),
});
};
const closeModal = () => {
setIsOpen(false);
setTimeout(() => {
setIsEditing(false);
setSelectedCategory(null);
reset();
clearErrors();
}, 200);
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEditing && selectedCategory) {
patch(categoryRoutes.update(selectedCategory.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
closeModal();
},
});
} else {
post(categoryRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
closeModal();
},
});
}
};
const columns: ColumnDef<Category>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Nama" />
)
},
meta: { title: "Nama" },
},
{
accessorKey: "is_active",
header: "Status",
meta: { title: "Status" },
cell: ({ row }) => {
const category = row.original;
return (
<Switch
checked={category.is_active}
onCheckedChange={() => onToggleStatus(category.id)}
/>
);
}
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const category = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="icon" className='text-yellow-600' onClick={() => onEdit(category)}>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600' onClick={() => onDelete(category)}>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];
const columns = getColumns({ onEdit, onDelete, onToggleStatus });
return (
<div className="flex flex-col gap-6 p-6">
@ -192,41 +52,16 @@ export default function CategoryIndex({ categories }: { categories: Category[] }
<div>
<h1 className="text-3xl font-bold tracking-tight">Kategori</h1>
</div>
<Button onClick={() => setIsOpen(true)}>
<Button onClick={onAdd}>
Tambah
</Button>
</div>
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditing ? 'Ubah Kategori' : 'Tambah Kategori'}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<Label htmlFor="name" required>Nama</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
autoComplete='off'
placeholder='Contoh: Gamis'
maxLength={50}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field>
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={closeModal}>Batal</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<CategoryFormModal
isOpen={isFormOpen}
onClose={closeForm}
category={selectedCategory}
/>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
<CardContent className="p-0">
@ -260,6 +95,7 @@ export default function CategoryIndex({ categories }: { categories: Category[] }
</CardContent>
</Card>
{/* Single Delete Confirmation */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
@ -278,6 +114,7 @@ export default function CategoryIndex({ categories }: { categories: Category[] }
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>

View File

@ -0,0 +1,100 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Category } from '@/types';
import { useForm } from '@inertiajs/react';
import { useEffect } from 'react';
import categoryRoutes from '@/routes/category';
import { toast } from 'sonner';
interface CategoryFormModalProps {
isOpen: boolean;
onClose: () => void;
category: Category | null;
}
export function CategoryFormModal({ isOpen, onClose, category }: CategoryFormModalProps) {
const isEditing = !!category;
const { data, setData, post, patch, processing, errors, reset, clearErrors } = useForm({
name: '',
});
useEffect(() => {
if (category) {
setData({
name: category.name,
});
} else {
reset();
}
}, [category]);
const handleClose = () => {
onClose();
setTimeout(() => {
reset();
clearErrors();
}, 200);
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEditing && category) {
patch(categoryRoutes.update(category.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
handleClose();
},
});
} else {
post(categoryRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
handleClose();
},
});
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditing ? 'Ubah Kategori' : 'Tambah Kategori'}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<Label htmlFor="name" required>Nama</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
autoComplete='off'
placeholder='Contoh: Gamis'
maxLength={50}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field>
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={handleClose}>Batal</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,79 @@
import { ColumnDef } from '@tanstack/react-table';
import { Category } from '@/types';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Pencil, Trash2 } from 'lucide-react';
interface ColumnProps {
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
onToggleStatus: (id: number) => void;
}
export const getColumns = ({ onEdit, onDelete, onToggleStatus }: ColumnProps): ColumnDef<Category>[] => [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nama" />
),
meta: { title: "Nama" },
},
{
accessorKey: "is_active",
header: "Status",
meta: { title: "Status" },
cell: ({ row }) => {
const category = row.original;
return (
<Switch
checked={category.is_active}
onCheckedChange={() => onToggleStatus(category.id)}
/>
);
}
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const category = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className='text-yellow-600'
onClick={() => onEdit(category)}
>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className='text-red-600'
onClick={() => onDelete(category)}
>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];

View File

@ -0,0 +1,79 @@
import { useState } from 'react';
import { Product } from '@/types';
import { router } from '@inertiajs/react';
import productRoutes from '@/routes/product';
import { toast } from 'sonner';
export function useProductIndex() {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [productToDelete, setProductToDelete] = useState<Product | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const onDelete = (product: Product) => {
setProductToDelete(product);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (productToDelete) {
router.delete(productRoutes.destroy(productToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setProductToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(productRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
const onToggleStatus = (id: number) => {
router.patch(productRoutes.toggleStatus(id).url, {}, {
onSuccess: (response: any) => toast.success(response.props.flash.success),
});
};
const onPreviewImage = (url: string) => {
setSelectedImage(url);
};
const closeImagePreview = () => {
setSelectedImage(null);
};
return {
selectedImage,
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
productToDelete,
rowsToDelete,
rowSelection,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
onDelete,
confirmDelete,
confirmBulkDelete,
onToggleStatus,
onPreviewImage,
closeImagePreview,
};
}

View File

@ -1,18 +1,9 @@
import { Head, router, Link } from '@inertiajs/react';
import { Head, Link } from '@inertiajs/react';
import type { Product, Category } from '@/types';
import { ColumnDef } from '@tanstack/react-table';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Trash2, Pencil, Plus, ImagePlus } from 'lucide-react';
import { Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { useState } from 'react';
import { toast } from 'sonner';
import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip';
import { Tooltip } from '@/components/ui/tooltip';
import { Switch } from '@/components/ui/switch';
import productRoutes from '@/routes/product';
import {
AlertDialog,
AlertDialogAction,
@ -24,171 +15,33 @@ import {
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { X } from 'lucide-react';
import productRoutes from '@/routes/product';
import { useProductIndex } from './hooks/use-product-index';
import { getColumns } from './partials/columns';
import { ImagePreviewDialog } from './partials/image-preview-dialog';
export default function ProductIndex({ products, categories }: { products: Product[], categories: Category[] }) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [productToDelete, setProductToDelete] = useState<Product | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const {
selectedImage,
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
productToDelete,
rowsToDelete,
rowSelection,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
onDelete,
confirmDelete,
confirmBulkDelete,
onToggleStatus,
onPreviewImage,
closeImagePreview,
} = useProductIndex();
const onDelete = (product: Product) => {
setProductToDelete(product);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (productToDelete) {
router.delete(productRoutes.destroy(productToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setProductToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(productRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
const onToggleStatus = (id: number) => {
router.patch(productRoutes.toggleStatus(id).url, {}, {
onSuccess: (response: any) => toast.success(response.props.flash.success),
});
};
const columns: ColumnDef<Product>[] = [
{
accessorKey: "thumbnail_url",
header: "Thumbnail",
meta: { title: "Thumbnail" },
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex items-center">
{product.thumbnail_url ? (
<button
onClick={() => setSelectedImage(product.thumbnail_url || null)}
className="h-12 w-12 rounded-lg overflow-hidden border border-border/50 shadow-sm hover:opacity-80 transition-opacity"
>
<img
src={product.thumbnail_url}
alt={product.name}
className="h-full w-full object-cover"
/>
</button>
) : (
<div className="h-10 w-10 flex items-center justify-center bg-muted rounded-md border">
<ImagePlus className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Nama" />
)
},
meta: { title: "Nama" },
},
{
accessorKey: "categories",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Kategori" />
)
},
meta: { title: "Kategori" },
filterFn: (row, id, value) => {
const categories = row.getValue(id) as Category[];
if (!categories) return false;
return categories.some(cat => String(cat.id) === String(value));
},
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex flex-wrap gap-1">
{product.categories?.map((category) => (
<Badge key={category.id} variant="secondary" className='font-medium'>
{category.name}
</Badge>
))}
{(!product.categories || product.categories.length === 0) && (
<span className="text-muted-foreground text-xs italic">Tanpa Kategori</span>
)}
</div>
);
}
},
{
accessorKey: "is_active",
header: "Status",
meta: { title: "Status" },
cell: ({ row }) => {
const product = row.original;
return (
<Switch
checked={product.is_active}
onCheckedChange={() => onToggleStatus(product.id)}
/>
);
}
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Link href={productRoutes.edit(product.id).url}>
<Button variant="ghost" size="icon" className='text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-950/20'>
<Pencil className="size-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20' onClick={() => onDelete(product)}>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];
const columns = getColumns({ onDelete, onToggleStatus, onPreviewImage });
return (
<div className="flex flex-col gap-6 p-6">
@ -245,6 +98,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
</CardContent>
</Card>
{/* Single Delete Confirmation */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
@ -263,6 +117,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
@ -286,23 +141,10 @@ export default function ProductIndex({ products, categories }: { products: Produ
</AlertDialogContent>
</AlertDialog>
<Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>
<DialogContent className="max-w-3xl p-0 overflow-hidden border-none bg-transparent shadow-none">
<div className="relative group">
<img
src={selectedImage || ''}
alt="Preview"
className="w-full h-auto max-h-[80vh] object-contain rounded-lg"
/>
<button
onClick={() => setSelectedImage(null)}
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 backdrop-blur-sm transition-all shadow-xl"
>
<X className="size-5" />
</button>
</div>
</DialogContent>
</Dialog>
<ImagePreviewDialog
imageUrl={selectedImage}
onClose={closeImagePreview}
/>
</div>
);
}

View File

@ -0,0 +1,130 @@
import { ColumnDef } from '@tanstack/react-table';
import { Product, Category } from '@/types';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
import { Link } from '@inertiajs/react';
import productRoutes from '@/routes/product';
interface ColumnProps {
onEdit?: (product: Product) => void;
onDelete: (product: Product) => void;
onToggleStatus: (id: number) => void;
onPreviewImage: (url: string) => void;
}
export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnProps): ColumnDef<Product>[] => [
{
accessorKey: "thumbnail_url",
header: "Thumbnail",
meta: { title: "Thumbnail" },
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex items-center">
{product.thumbnail_url ? (
<button
onClick={() => onPreviewImage(product.thumbnail_url || '')}
className="h-12 w-12 rounded-lg overflow-hidden border border-border/50 shadow-sm hover:opacity-80 transition-opacity"
>
<img
src={product.thumbnail_url}
alt={product.name}
className="h-full w-full object-cover"
/>
</button>
) : (
<div className="h-10 w-10 flex items-center justify-center bg-muted rounded-md border">
<ImagePlus className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
);
}
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nama" />
),
meta: { title: "Nama" },
},
{
accessorKey: "categories",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Kategori" />
),
meta: { title: "Kategori" },
filterFn: (row, id, value) => {
const categories = row.getValue(id) as Category[];
if (!categories) return false;
return categories.some(cat => String(cat.id) === String(value));
},
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex flex-wrap gap-1">
{product.categories?.map((category) => (
<Badge key={category.id} variant="secondary" className='font-medium'>
{category.name}
</Badge>
))}
{(!product.categories || product.categories.length === 0) && (
<span className="text-muted-foreground text-xs italic">Tanpa Kategori</span>
)}
</div>
);
}
},
{
accessorKey: "is_active",
header: "Status",
meta: { title: "Status" },
cell: ({ row }) => {
const product = row.original;
return (
<Switch
checked={product.is_active}
onCheckedChange={() => onToggleStatus(product.id)}
/>
);
}
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Link href={productRoutes.edit(product.id).url}>
<Button variant="ghost" size="icon" className='text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-950/20'>
<Pencil className="size-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20' onClick={() => onDelete(product)}>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];

View File

@ -0,0 +1,29 @@
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { X } from 'lucide-react';
interface ImagePreviewDialogProps {
imageUrl: string | null;
onClose: () => void;
}
export function ImagePreviewDialog({ imageUrl, onClose }: ImagePreviewDialogProps) {
return (
<Dialog open={!!imageUrl} onOpenChange={() => onClose()}>
<DialogContent className="max-w-3xl p-0 overflow-hidden border-none bg-transparent shadow-none">
<div className="relative group">
<img
src={imageUrl || ''}
alt="Preview"
className="w-full h-auto max-h-[80vh] object-contain rounded-lg"
/>
<button
onClick={onClose}
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 backdrop-blur-sm transition-all shadow-xl"
>
<X className="size-5" />
</button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,84 @@
import { useState } from 'react';
import { User } from '@/types';
import { router } from '@inertiajs/react';
import userRoutes from '@/routes/user';
import { toast } from 'sonner';
export function useUserIndex() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [isResetPasswordOpen, setIsResetPasswordOpen] = useState(false);
const [userToReset, setUserToReset] = useState<User | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const onResetPassword = (user: User) => {
setUserToReset(user);
setIsResetPasswordOpen(true);
};
const confirmResetPassword = () => {
if (userToReset) {
router.patch(userRoutes.resetPassword(userToReset.id).url, {}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsResetPasswordOpen(false);
setUserToReset(null);
},
});
}
};
const onDelete = (user: User) => {
setUserToDelete(user);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (userToDelete) {
router.delete(userRoutes.destroy(userToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setUserToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(userRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
return {
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
userToDelete,
isResetPasswordOpen,
userToReset,
rowsToDelete,
rowSelection,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
setIsResetPasswordOpen,
onResetPassword,
confirmResetPassword,
onDelete,
confirmDelete,
confirmBulkDelete,
};
}

View File

@ -1,17 +1,9 @@
import { Head, router, Link } from '@inertiajs/react';
import { Head, Link } from '@inertiajs/react';
import type { User } from '@/types';
import { ColumnDef } from '@tanstack/react-table';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2, Pencil, Plus, KeyRound } from 'lucide-react';
import { Trash2, KeyRound } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { useState } from 'react';
import { toast } from 'sonner';
import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip';
import { Tooltip } from '@/components/ui/tooltip';
import userRoutes from '@/routes/user';
import { UserInfo } from '@/components/user-info';
import {
AlertDialog,
AlertDialogAction,
@ -24,146 +16,32 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import userRoutes from '@/routes/user';
import { useUserIndex } from './hooks/use-user-index';
import { getColumns } from './partials/columns';
export default function UserIndex({ users, defaultPassword }: { users: User[], defaultPassword: string }) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [isResetPasswordOpen, setIsResetPasswordOpen] = useState(false);
const [userToReset, setUserToReset] = useState<User | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const {
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
userToDelete,
isResetPasswordOpen,
userToReset,
rowsToDelete,
rowSelection,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
setIsResetPasswordOpen,
onResetPassword,
confirmResetPassword,
onDelete,
confirmDelete,
confirmBulkDelete,
} = useUserIndex();
const onResetPassword = (user: User) => {
setUserToReset(user);
setIsResetPasswordOpen(true);
};
const confirmResetPassword = () => {
if (userToReset) {
router.patch(userRoutes.resetPassword(userToReset.id).url, {}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsResetPasswordOpen(false);
setUserToReset(null);
},
});
}
};
const onDelete = (user: User) => {
setUserToDelete(user);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (userToDelete) {
router.delete(userRoutes.destroy(userToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setUserToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(userRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
const columns: ColumnDef<User>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="User" />
)
},
meta: { title: "User" },
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-3">
<UserInfo user={user} showEmail={true} />
</div>
);
}
},
{
accessorKey: "username",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Nama Pengguna" />
)
},
meta: { title: "Nama Pengguna" },
},
{
accessorFn: (row) => row.profile?.phone_number,
id: "phone_number",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Nomor Telepon" />
)
},
meta: { title: "Nomor Telepon" },
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/20' onClick={() => onResetPassword(user)}>
<KeyRound className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Reset Kata Sandi</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link href={userRoutes.edit(user.id).url}>
<Button variant="ghost" size="icon" className='text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-950/20'>
<Pencil className="size-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20' onClick={() => onDelete(user)}>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];
const columns = getColumns({ onResetPassword, onDelete });
return (
<div className="flex flex-col gap-6 p-6">
@ -202,6 +80,7 @@ export default function UserIndex({ users, defaultPassword }: { users: User[], d
</CardContent>
</Card>
{/* Single Delete Confirmation */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
@ -220,6 +99,7 @@ export default function UserIndex({ users, defaultPassword }: { users: User[], d
</AlertDialogContent>
</AlertDialog>
{/* Reset Password Confirmation */}
<AlertDialog open={isResetPasswordOpen} onOpenChange={setIsResetPasswordOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
@ -238,6 +118,7 @@ export default function UserIndex({ users, defaultPassword }: { users: User[], d
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>

View File

@ -0,0 +1,105 @@
import { ColumnDef } from '@tanstack/react-table';
import { User } from '@/types';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { UserInfo } from '@/components/user-info';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
import { Link } from '@inertiajs/react';
import userRoutes from '@/routes/user';
interface ColumnProps {
onResetPassword: (user: User) => void;
onDelete: (user: User) => void;
}
export const getColumns = ({ onResetPassword, onDelete }: ColumnProps): ColumnDef<User>[] => [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="User" />
),
meta: { title: "User" },
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-3">
<UserInfo user={user} showEmail={true} />
</div>
);
}
},
{
accessorKey: "username",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nama Pengguna" />
),
meta: { title: "Nama Pengguna" },
},
{
accessorFn: (row) => row.profile?.phone_number,
id: "phone_number",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nomor Telepon" />
),
meta: { title: "Nomor Telepon" },
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className='text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/20'
onClick={() => onResetPassword(user)}
>
<KeyRound className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Reset Kata Sandi</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link href={userRoutes.edit(user.id).url}>
<Button
variant="ghost"
size="icon"
className='text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-950/20'
>
<Pencil className="size-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className='text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20'
onClick={() => onDelete(user)}
>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];