feat: refactor expense and category management components to utilize custom hooks and modularize code for improved maintainability
This commit is contained in:
parent
431bd1c0c2
commit
9af62ff47c
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
107
resources/js/pages/admin/finance/expense/partials/columns.tsx
Normal file
107
resources/js/pages/admin/finance/expense/partials/columns.tsx
Normal 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" },
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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" },
|
||||
},
|
||||
];
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
130
resources/js/pages/admin/master/product/partials/columns.tsx
Normal file
130
resources/js/pages/admin/master/product/partials/columns.tsx
Normal 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" },
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
84
resources/js/pages/admin/master/user/hooks/use-user-index.ts
Normal file
84
resources/js/pages/admin/master/user/hooks/use-user-index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
105
resources/js/pages/admin/master/user/partials/columns.tsx
Normal file
105
resources/js/pages/admin/master/user/partials/columns.tsx
Normal 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" },
|
||||
},
|
||||
];
|
||||
Loading…
Reference in New Issue
Block a user