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 type { Expense } from '@/types';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { 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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -32,232 +15,37 @@ import {
|
|||||||
AlertDialogMedia,
|
AlertDialogMedia,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} 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[] }) {
|
export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
isFormOpen,
|
||||||
const [selectedExpense, setSelectedExpense] = useState<Expense | null>(null);
|
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<{
|
const columns = getColumns({ onEdit, onDelete, onPreviewImage });
|
||||||
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" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="flex flex-col gap-6 p-6">
|
||||||
@ -267,98 +55,16 @@ export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Pengeluaran</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Pengeluaran</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setIsOpen(true)}>
|
<Button onClick={onAdd}>
|
||||||
Tambah
|
Tambah
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
|
<ExpenseFormModal
|
||||||
<DialogContent>
|
isOpen={isFormOpen}
|
||||||
<DialogHeader>
|
onClose={closeForm}
|
||||||
<DialogTitle>{isEditing ? 'Ubah Pengeluaran' : 'Tambah Pengeluaran'}</DialogTitle>
|
expense={selectedExpense}
|
||||||
</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>
|
|
||||||
|
|
||||||
<Dialog open={!!selectedProof} onOpenChange={() => setSelectedProof(null)}>
|
<Dialog open={!!selectedProof} onOpenChange={() => setSelectedProof(null)}>
|
||||||
<DialogContent className="max-w-3xl p-0 overflow-hidden border-none bg-transparent shadow-none">
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Single Delete Confirmation */}
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@ -418,6 +125,7 @@ export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Bulk Delete Confirmation */}
|
||||||
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<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 type { Category } from '@/types';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { Trash2, Pencil } from 'lucide-react';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -35,154 +16,33 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} 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[] }) {
|
export default function CategoryIndex({ categories }: { categories: Category[] }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
isFormOpen,
|
||||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
|
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({
|
const columns = getColumns({ onEdit, onDelete, onToggleStatus });
|
||||||
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" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="flex flex-col gap-6 p-6">
|
||||||
@ -192,41 +52,16 @@ export default function CategoryIndex({ categories }: { categories: Category[] }
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Kategori</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Kategori</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setIsOpen(true)}>
|
<Button onClick={onAdd}>
|
||||||
Tambah
|
Tambah
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
|
<CategoryFormModal
|
||||||
<DialogContent>
|
isOpen={isFormOpen}
|
||||||
<DialogHeader>
|
onClose={closeForm}
|
||||||
<DialogTitle>{isEditing ? 'Ubah Kategori' : 'Tambah Kategori'}</DialogTitle>
|
category={selectedCategory}
|
||||||
</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>
|
|
||||||
|
|
||||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@ -260,6 +95,7 @@ export default function CategoryIndex({ categories }: { categories: Category[] }
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Single Delete Confirmation */}
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@ -278,6 +114,7 @@ export default function CategoryIndex({ categories }: { categories: Category[] }
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Bulk Delete Confirmation */}
|
||||||
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<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 type { Product, Category } from '@/types';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { Trash2, Pencil, Plus, ImagePlus } from 'lucide-react';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -24,171 +15,33 @@ import {
|
|||||||
AlertDialogMedia,
|
AlertDialogMedia,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} 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[] }) {
|
export default function ProductIndex({ products, categories }: { products: Product[], categories: Category[] }) {
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
selectedImage,
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
isDeleteDialogOpen,
|
||||||
const [productToDelete, setProductToDelete] = useState<Product | null>(null);
|
isBulkDeleteDialogOpen,
|
||||||
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
|
productToDelete,
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
rowsToDelete,
|
||||||
|
rowSelection,
|
||||||
|
setRowSelection,
|
||||||
|
setRowsToDelete,
|
||||||
|
setIsDeleteDialogOpen,
|
||||||
|
setIsBulkDeleteDialogOpen,
|
||||||
|
onDelete,
|
||||||
|
confirmDelete,
|
||||||
|
confirmBulkDelete,
|
||||||
|
onToggleStatus,
|
||||||
|
onPreviewImage,
|
||||||
|
closeImagePreview,
|
||||||
|
} = useProductIndex();
|
||||||
|
|
||||||
const onDelete = (product: Product) => {
|
const columns = getColumns({ onDelete, onToggleStatus, onPreviewImage });
|
||||||
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" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="flex flex-col gap-6 p-6">
|
||||||
@ -245,6 +98,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Single Delete Confirmation */}
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@ -263,6 +117,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Bulk Delete Confirmation */}
|
||||||
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@ -286,23 +141,10 @@ export default function ProductIndex({ products, categories }: { products: Produ
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>
|
<ImagePreviewDialog
|
||||||
<DialogContent className="max-w-3xl p-0 overflow-hidden border-none bg-transparent shadow-none">
|
imageUrl={selectedImage}
|
||||||
<div className="relative group">
|
onClose={closeImagePreview}
|
||||||
<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>
|
|
||||||
</div>
|
</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 type { User } from '@/types';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { 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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -24,146 +16,32 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} 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 }) {
|
export default function UserIndex({ users, defaultPassword }: { users: User[], defaultPassword: string }) {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const {
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
isDeleteDialogOpen,
|
||||||
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
isBulkDeleteDialogOpen,
|
||||||
const [isResetPasswordOpen, setIsResetPasswordOpen] = useState(false);
|
userToDelete,
|
||||||
const [userToReset, setUserToReset] = useState<User | null>(null);
|
isResetPasswordOpen,
|
||||||
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
|
userToReset,
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
rowsToDelete,
|
||||||
|
rowSelection,
|
||||||
|
setRowSelection,
|
||||||
|
setRowsToDelete,
|
||||||
|
setIsDeleteDialogOpen,
|
||||||
|
setIsBulkDeleteDialogOpen,
|
||||||
|
setIsResetPasswordOpen,
|
||||||
|
onResetPassword,
|
||||||
|
confirmResetPassword,
|
||||||
|
onDelete,
|
||||||
|
confirmDelete,
|
||||||
|
confirmBulkDelete,
|
||||||
|
} = useUserIndex();
|
||||||
|
|
||||||
const onResetPassword = (user: User) => {
|
const columns = getColumns({ onResetPassword, onDelete });
|
||||||
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" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="flex flex-col gap-6 p-6">
|
||||||
@ -202,6 +80,7 @@ export default function UserIndex({ users, defaultPassword }: { users: User[], d
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Single Delete Confirmation */}
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@ -220,6 +99,7 @@ export default function UserIndex({ users, defaultPassword }: { users: User[], d
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Reset Password Confirmation */}
|
||||||
<AlertDialog open={isResetPasswordOpen} onOpenChange={setIsResetPasswordOpen}>
|
<AlertDialog open={isResetPasswordOpen} onOpenChange={setIsResetPasswordOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@ -238,6 +118,7 @@ export default function UserIndex({ users, defaultPassword }: { users: User[], d
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Bulk Delete Confirmation */}
|
||||||
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<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