refactor: replace tooltip buttons with row actions component in supplier and role columns
feat: implement delete confirmation dialog component for better UX refactor: update supplier and role index pages to utilize new form dialog and delete confirm dialog components feat: add reusable form dialog component for creating and editing entities feat: introduce filter popover component for enhanced filtering options in data tables feat: create image preview button component for displaying images with modal preview feat: add page header component for consistent page layout feat: implement row actions component for handling actions on table rows feat: add status badge component for displaying entity statuses feat: create toggle status component for easily toggling entity states feat: implement draft save hook for auto-saving form data feat: add server table hook for managing server-side pagination and filtering feat: create utility functions for formatting dates and numbers feat: add constants for month names and measurement units
This commit is contained in:
parent
cf0e772159
commit
d91ec8869e
39
resources/js/components/delete-confirm-dialog.tsx
Normal file
39
resources/js/components/delete-confirm-dialog.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
|
||||
type DeleteConfirmDialogProps<T> = {
|
||||
target: T | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string | ((target: T) => string);
|
||||
confirmLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function DeleteConfirmDialog<T>({
|
||||
target,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Hapus',
|
||||
variant,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps<T>) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={target !== null}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={
|
||||
typeof description === 'function' && target
|
||||
? description(target)
|
||||
: typeof description === 'string'
|
||||
? description
|
||||
: ''
|
||||
}
|
||||
confirmLabel={confirmLabel}
|
||||
variant={variant}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
resources/js/components/filter-popover.tsx
Normal file
61
resources/js/components/filter-popover.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
|
||||
type FilterPopoverProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filters: Record<string, string | undefined>;
|
||||
hasActiveFilters: boolean;
|
||||
onClear: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function FilterPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
filters,
|
||||
hasActiveFilters,
|
||||
onClear,
|
||||
children,
|
||||
}: FilterPopoverProps) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{Object.values(filters).filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onClear}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Hapus Semua
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
81
resources/js/components/form-dialog.tsx
Normal file
81
resources/js/components/form-dialog.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { Form } from '@inertiajs/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
type FormDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
action: React.ComponentProps<typeof Form>['action'];
|
||||
resetOnSuccess?: boolean;
|
||||
onSuccess?: () => void;
|
||||
submitDisabled?: boolean;
|
||||
submitLabel?: ReactNode;
|
||||
submittingLabel?: ReactNode;
|
||||
children:
|
||||
| ReactNode
|
||||
| ((ctx: {
|
||||
errors: Record<string, string>;
|
||||
processing: boolean;
|
||||
}) => ReactNode);
|
||||
};
|
||||
|
||||
export function FormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
action,
|
||||
resetOnSuccess,
|
||||
onSuccess,
|
||||
submitDisabled = false,
|
||||
submitLabel = 'Simpan',
|
||||
submittingLabel = 'Menyimpan...',
|
||||
children,
|
||||
}: FormDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={action}
|
||||
resetOnSuccess={resetOnSuccess}
|
||||
onSuccess={onSuccess}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{typeof children === 'function'
|
||||
? children({ errors, processing })
|
||||
: children}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || submitDisabled}
|
||||
>
|
||||
{processing ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
46
resources/js/components/image-preview-button.tsx
Normal file
46
resources/js/components/image-preview-button.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
|
||||
type ImagePreviewButtonProps = {
|
||||
srcs: string[];
|
||||
title?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ImagePreviewButton({
|
||||
srcs,
|
||||
title,
|
||||
alt,
|
||||
className = 'h-10 w-10',
|
||||
}: ImagePreviewButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={`relative block overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80 ${className}`}
|
||||
>
|
||||
<img
|
||||
src={srcs[0]}
|
||||
alt={alt ?? title ?? 'Preview'}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{srcs.length > 1 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
{srcs.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={srcs[0]}
|
||||
sources={srcs}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
resources/js/components/page-header.tsx
Normal file
21
resources/js/components/page-header.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{description}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
resources/js/components/row-actions.tsx
Normal file
67
resources/js/components/row-actions.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
export type RowAction = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
iconClassName?: string;
|
||||
show?: boolean;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type RowActionsProps = {
|
||||
actions: RowAction[];
|
||||
wrapperClassName?: string;
|
||||
};
|
||||
|
||||
export function RowActions({
|
||||
actions,
|
||||
wrapperClassName = 'flex items-center justify-center gap-1',
|
||||
}: RowActionsProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={wrapperClassName}>
|
||||
{actions
|
||||
.filter((action) => action.show !== false)
|
||||
.map((action) => (
|
||||
<Tooltip key={action.label}>
|
||||
<TooltipTrigger asChild>
|
||||
{action.href ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
className={action.iconClassName}
|
||||
>
|
||||
<Link href={action.href}>
|
||||
{action.icon}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={action.onClick}
|
||||
className={action.iconClassName}
|
||||
>
|
||||
{action.icon}
|
||||
</Button>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{action.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
27
resources/js/components/status-badge.tsx
Normal file
27
resources/js/components/status-badge.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
type StatusBadgeConfig = Record<string, { label: string; className: string }>;
|
||||
|
||||
type StatusBadgeProps = {
|
||||
status: string;
|
||||
config: StatusBadgeConfig;
|
||||
fallbackKey?: string;
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
config,
|
||||
fallbackKey = 'pending',
|
||||
}: StatusBadgeProps) {
|
||||
const resolved = config[status] ?? config[fallbackKey];
|
||||
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className={resolved.className}>
|
||||
{resolved.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
40
resources/js/components/toggle-status.tsx
Normal file
40
resources/js/components/toggle-status.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
type ToggleStatusProps = {
|
||||
url: string;
|
||||
checked: boolean;
|
||||
onToggle?: () => void;
|
||||
label?: string;
|
||||
wrapperClassName?: string;
|
||||
};
|
||||
|
||||
export function ToggleStatus({
|
||||
url,
|
||||
checked,
|
||||
onToggle,
|
||||
label,
|
||||
wrapperClassName = 'flex items-center gap-2',
|
||||
}: ToggleStatusProps) {
|
||||
function handleToggle() {
|
||||
onToggle?.();
|
||||
router.post(url, {}, { preserveScroll: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
{label && (
|
||||
<span
|
||||
className={`text-xs font-medium ${checked ? 'text-green-700' : 'text-red-700'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
resources/js/hooks/use-draft-save.ts
Normal file
76
resources/js/hooks/use-draft-save.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { DraftStore, DraftType } from '@/lib/draft-store';
|
||||
|
||||
type UseDraftSaveOptions<D> = {
|
||||
type: DraftType;
|
||||
data: D;
|
||||
userId?: number;
|
||||
extraId?: number;
|
||||
delay?: number;
|
||||
store: Pick<DraftStore<D>, 'save' | 'clear'>;
|
||||
};
|
||||
|
||||
export function useDraftSave<D>({
|
||||
type,
|
||||
data,
|
||||
userId,
|
||||
extraId,
|
||||
delay = 500,
|
||||
store,
|
||||
}: UseDraftSaveOptions<D>) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const offBefore = router.on('before', (event) => {
|
||||
if (event.detail.visit.method !== 'get') {
|
||||
submittedRef.current = true;
|
||||
}
|
||||
});
|
||||
const offError = router.on('error', () => {
|
||||
submittedRef.current = false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
offBefore();
|
||||
offError();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!submittedRef.current) {
|
||||
store.save(type, dataRef.current, userId, extraId);
|
||||
}
|
||||
|
||||
timeoutRef.current = null;
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [data, type, userId, extraId, delay, store]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
if (submittedRef.current) {
|
||||
store.clear(type, userId, extraId);
|
||||
} else {
|
||||
store.save(type, dataRef.current, userId, extraId);
|
||||
}
|
||||
};
|
||||
}, [type, userId, extraId, store]);
|
||||
}
|
||||
@ -1,10 +1,6 @@
|
||||
import {
|
||||
clearProductDraft,
|
||||
saveProductDraft,
|
||||
type ProductDraftData,
|
||||
} from '@/lib/product-draft';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||
import { clearProductDraft, saveProductDraft } from '@/lib/product-draft';
|
||||
import type { ProductDraftData } from '@/lib/product-draft';
|
||||
|
||||
type DraftType = 'create' | 'edit';
|
||||
|
||||
@ -15,55 +11,12 @@ export function useProductDraftSave(
|
||||
productId?: number,
|
||||
delay = 500,
|
||||
) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const offBefore = router.on('before', (event) => {
|
||||
if (event.detail.visit.method !== 'get') {
|
||||
submittedRef.current = true;
|
||||
}
|
||||
});
|
||||
const offError = router.on('error', () => {
|
||||
submittedRef.current = false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
offBefore();
|
||||
offError();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!submittedRef.current) {
|
||||
saveProductDraft(type, dataRef.current, userId, productId);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [data, type, userId, productId, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (submittedRef.current) {
|
||||
clearProductDraft(type, userId, productId);
|
||||
} else {
|
||||
saveProductDraft(type, dataRef.current, userId, productId);
|
||||
}
|
||||
};
|
||||
}, [type, userId, productId]);
|
||||
return useDraftSave({
|
||||
type,
|
||||
data,
|
||||
userId,
|
||||
extraId: productId,
|
||||
delay,
|
||||
store: { save: saveProductDraft, clear: clearProductDraft },
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import {
|
||||
clearPurchaseDraft,
|
||||
savePurchaseDraft,
|
||||
type PurchaseDraftData,
|
||||
} from '@/lib/purchase-draft';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||
import { clearPurchaseDraft, savePurchaseDraft } from '@/lib/purchase-draft';
|
||||
import type { PurchaseDraftData } from '@/lib/purchase-draft';
|
||||
|
||||
type DraftType = 'create' | 'edit';
|
||||
|
||||
@ -14,55 +10,11 @@ export function usePurchaseDraftSave(
|
||||
userId?: number,
|
||||
delay = 500,
|
||||
) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const offBefore = router.on('before', (event) => {
|
||||
if (event.detail.visit.method !== 'get') {
|
||||
submittedRef.current = true;
|
||||
}
|
||||
});
|
||||
const offError = router.on('error', () => {
|
||||
submittedRef.current = false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
offBefore();
|
||||
offError();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!submittedRef.current) {
|
||||
savePurchaseDraft(type, dataRef.current, userId);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [data, type, userId, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (submittedRef.current) {
|
||||
clearPurchaseDraft(type, userId);
|
||||
} else {
|
||||
savePurchaseDraft(type, dataRef.current, userId);
|
||||
}
|
||||
};
|
||||
}, [type, userId]);
|
||||
return useDraftSave({
|
||||
type,
|
||||
data,
|
||||
userId,
|
||||
delay,
|
||||
store: { save: savePurchaseDraft, clear: clearPurchaseDraft },
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||
import {
|
||||
clearRawMaterialDraft,
|
||||
saveRawMaterialDraft,
|
||||
type RawMaterialDraftData,
|
||||
} from '@/lib/raw-material-draft';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { RawMaterialDraftData } from '@/lib/raw-material-draft';
|
||||
|
||||
type DraftType = 'create' | 'edit';
|
||||
|
||||
@ -15,54 +14,12 @@ export function useRawMaterialDraftSave(
|
||||
rawMaterialId?: number,
|
||||
delay = 500,
|
||||
) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const offBefore = router.on('before', (event) => {
|
||||
if (event.detail.visit.method !== 'get') {
|
||||
submittedRef.current = true;
|
||||
}
|
||||
});
|
||||
const offError = router.on('error', () => {
|
||||
submittedRef.current = false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
offBefore();
|
||||
offError();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!submittedRef.current) {
|
||||
saveRawMaterialDraft(type, dataRef.current, userId, rawMaterialId);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [data, type, userId, rawMaterialId, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (submittedRef.current) {
|
||||
clearRawMaterialDraft(type, userId, rawMaterialId);
|
||||
} else {
|
||||
saveRawMaterialDraft(type, dataRef.current, userId, rawMaterialId);
|
||||
}
|
||||
};
|
||||
}, [type, userId, rawMaterialId]);
|
||||
return useDraftSave({
|
||||
type,
|
||||
data,
|
||||
userId,
|
||||
extraId: rawMaterialId,
|
||||
delay,
|
||||
store: { save: saveRawMaterialDraft, clear: clearRawMaterialDraft },
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
clearRestockDraft,
|
||||
saveRestockDraft
|
||||
|
||||
} from '@/lib/restock-draft';
|
||||
import type {RestockDraftData} from '@/lib/restock-draft';
|
||||
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||
import { clearRestockDraft, saveRestockDraft } from '@/lib/restock-draft';
|
||||
import type { RestockDraftData } from '@/lib/restock-draft';
|
||||
|
||||
type DraftType = 'create' | 'edit';
|
||||
|
||||
@ -15,61 +10,11 @@ export function useRestockDraftSave(
|
||||
userId?: number,
|
||||
delay = 500,
|
||||
) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dataRef = useRef(data);
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const offBefore = router.on('before', (event) => {
|
||||
if (event.detail.visit.method !== 'get') {
|
||||
submittedRef.current = true;
|
||||
}
|
||||
});
|
||||
const offError = router.on('error', () => {
|
||||
submittedRef.current = false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
offBefore();
|
||||
offError();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!submittedRef.current) {
|
||||
saveRestockDraft(type, dataRef.current, userId);
|
||||
}
|
||||
|
||||
timeoutRef.current = null;
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [data, type, userId, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
if (submittedRef.current) {
|
||||
clearRestockDraft(type, userId);
|
||||
} else {
|
||||
saveRestockDraft(type, dataRef.current, userId);
|
||||
}
|
||||
};
|
||||
}, [type, userId]);
|
||||
return useDraftSave({
|
||||
type,
|
||||
data,
|
||||
userId,
|
||||
delay,
|
||||
store: { save: saveRestockDraft, clear: clearRestockDraft },
|
||||
});
|
||||
}
|
||||
|
||||
118
resources/js/hooks/use-server-table.ts
Normal file
118
resources/js/hooks/use-server-table.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
|
||||
type UseServerTableOptions = {
|
||||
route: () => string;
|
||||
pagination: PaginationState;
|
||||
filters?: Record<string, string | undefined>;
|
||||
filterWithParams?: boolean;
|
||||
};
|
||||
|
||||
export function useServerTable({
|
||||
route,
|
||||
pagination,
|
||||
filters,
|
||||
filterWithParams = true,
|
||||
}: UseServerTableOptions) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
router.get(
|
||||
route(),
|
||||
{
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[route, filters, pagination.per_page, search],
|
||||
);
|
||||
|
||||
const handlePerPageChange = useCallback(
|
||||
(perPage: number) => {
|
||||
router.get(
|
||||
route(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[route, filters, search],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
route(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[route, filters, pagination.per_page],
|
||||
);
|
||||
|
||||
function applyFilter(key: string, value: string) {
|
||||
const newFilters = { ...filters };
|
||||
|
||||
if (value === '' || value === 'all') {
|
||||
delete newFilters[key];
|
||||
} else {
|
||||
newFilters[key] = value;
|
||||
}
|
||||
|
||||
router.get(
|
||||
route(),
|
||||
filterWithParams
|
||||
? {
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
}
|
||||
: newFilters,
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(
|
||||
route(),
|
||||
filterWithParams
|
||||
? {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
}
|
||||
: {},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
return {
|
||||
search,
|
||||
filterOpen,
|
||||
setFilterOpen,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
applyFilter,
|
||||
clearFilters,
|
||||
};
|
||||
}
|
||||
21
resources/js/lib/constants.ts
Normal file
21
resources/js/lib/constants.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export const MONTH_NAMES = [
|
||||
'',
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember',
|
||||
];
|
||||
|
||||
export const UNITS = [
|
||||
{ value: 'kg', label: 'Kilogram' },
|
||||
{ value: 'meter', label: 'Meter' },
|
||||
{ value: 'yard', label: 'Yard' },
|
||||
];
|
||||
62
resources/js/lib/draft-store.ts
Normal file
62
resources/js/lib/draft-store.ts
Normal file
@ -0,0 +1,62 @@
|
||||
export type DraftType = 'create' | 'edit';
|
||||
|
||||
export type DraftStore<D> = {
|
||||
save(type: DraftType, data: D, userId?: number, extraId?: number): boolean;
|
||||
load(type: DraftType, userId?: number, extraId?: number): D | null;
|
||||
clear(type: DraftType, userId?: number, extraId?: number): void;
|
||||
};
|
||||
|
||||
export function createDraftStore<D>(prefix: string): DraftStore<D> {
|
||||
function getKey(
|
||||
type: DraftType,
|
||||
userId?: number,
|
||||
extraId?: number,
|
||||
): string {
|
||||
if (type === 'edit' && extraId) {
|
||||
return `${prefix}-edit-${userId ?? 'anon'}-${extraId}`;
|
||||
}
|
||||
|
||||
return `${prefix}-create-${userId ?? 'anon'}`;
|
||||
}
|
||||
|
||||
return {
|
||||
save(type, data, userId, extraId) {
|
||||
if (type !== 'create') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getKey(type, userId, extraId);
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
load(type, userId, extraId) {
|
||||
try {
|
||||
const key = getKey(type, userId, extraId);
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as D;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clear(type, userId, extraId) {
|
||||
try {
|
||||
const key = getKey(type, userId, extraId);
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
45
resources/js/lib/format.ts
Normal file
45
resources/js/lib/format.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export function formatNumber(
|
||||
num: number,
|
||||
options?: Intl.NumberFormatOptions,
|
||||
): string {
|
||||
return new Intl.NumberFormat('id-ID', options).format(num);
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return (
|
||||
date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function formatShortDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
const DRAFT_PREFIX = 'product-draft';
|
||||
import { createDraftStore } from '@/lib/draft-store';
|
||||
|
||||
export type ProductDraftData = {
|
||||
productName: string;
|
||||
@ -19,17 +19,8 @@ export type ProductDraftData = {
|
||||
}>;
|
||||
};
|
||||
|
||||
function getKey(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
productId?: number,
|
||||
): string {
|
||||
if (type === 'edit' && productId) {
|
||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${productId}`;
|
||||
}
|
||||
|
||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
||||
}
|
||||
export const productDraftStore =
|
||||
createDraftStore<ProductDraftData>('product-draft');
|
||||
|
||||
export function saveProductDraft(
|
||||
type: 'create' | 'edit',
|
||||
@ -37,18 +28,7 @@ export function saveProductDraft(
|
||||
userId?: number,
|
||||
productId?: number,
|
||||
): boolean {
|
||||
if (type !== 'create') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getKey(type, userId, productId);
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return productDraftStore.save(type, data, userId, productId);
|
||||
}
|
||||
|
||||
export function loadProductDraft(
|
||||
@ -56,18 +36,7 @@ export function loadProductDraft(
|
||||
userId?: number,
|
||||
productId?: number,
|
||||
): ProductDraftData | null {
|
||||
try {
|
||||
const key = getKey(type, userId, productId);
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as ProductDraftData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return productDraftStore.load(type, userId, productId);
|
||||
}
|
||||
|
||||
export function clearProductDraft(
|
||||
@ -75,10 +44,5 @@ export function clearProductDraft(
|
||||
userId?: number,
|
||||
productId?: number,
|
||||
): void {
|
||||
try {
|
||||
const key = getKey(type, userId, productId);
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
productDraftStore.clear(type, userId, productId);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const DRAFT_PREFIX = 'purchase-draft';
|
||||
import { createDraftStore } from '@/lib/draft-store';
|
||||
|
||||
export type PurchaseDraftData = {
|
||||
name: string;
|
||||
@ -19,60 +19,27 @@ export type PurchaseDraftData = {
|
||||
photo?: string;
|
||||
};
|
||||
|
||||
function getKey(type: 'create' | 'edit', userId?: number): string {
|
||||
if (type === 'edit') {
|
||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}`;
|
||||
}
|
||||
|
||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
||||
}
|
||||
export const purchaseDraftStore =
|
||||
createDraftStore<PurchaseDraftData>('purchase-draft');
|
||||
|
||||
export function savePurchaseDraft(
|
||||
type: 'create' | 'edit',
|
||||
data: PurchaseDraftData,
|
||||
userId?: number,
|
||||
): boolean {
|
||||
if (type !== 'create') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return purchaseDraftStore.save(type, data, userId);
|
||||
}
|
||||
|
||||
export function loadPurchaseDraft(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
): PurchaseDraftData | null {
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as PurchaseDraftData;
|
||||
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return purchaseDraftStore.load(type, userId);
|
||||
}
|
||||
|
||||
export function clearPurchaseDraft(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
): void {
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
purchaseDraftStore.clear(type, userId);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const DRAFT_PREFIX = 'raw-material-draft';
|
||||
import { createDraftStore } from '@/lib/draft-store';
|
||||
|
||||
export type RawMaterialDraftData = {
|
||||
name: string;
|
||||
@ -13,17 +13,8 @@ export type RawMaterialDraftData = {
|
||||
}>;
|
||||
};
|
||||
|
||||
function getKey(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
rawMaterialId?: number,
|
||||
): string {
|
||||
if (type === 'edit' && rawMaterialId) {
|
||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${rawMaterialId}`;
|
||||
}
|
||||
|
||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
||||
}
|
||||
export const rawMaterialDraftStore =
|
||||
createDraftStore<RawMaterialDraftData>('raw-material-draft');
|
||||
|
||||
export function saveRawMaterialDraft(
|
||||
type: 'create' | 'edit',
|
||||
@ -31,18 +22,7 @@ export function saveRawMaterialDraft(
|
||||
userId?: number,
|
||||
rawMaterialId?: number,
|
||||
): boolean {
|
||||
if (type !== 'create') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getKey(type, userId, rawMaterialId);
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return rawMaterialDraftStore.save(type, data, userId, rawMaterialId);
|
||||
}
|
||||
|
||||
export function loadRawMaterialDraft(
|
||||
@ -50,18 +30,7 @@ export function loadRawMaterialDraft(
|
||||
userId?: number,
|
||||
rawMaterialId?: number,
|
||||
): RawMaterialDraftData | null {
|
||||
try {
|
||||
const key = getKey(type, userId, rawMaterialId);
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as RawMaterialDraftData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return rawMaterialDraftStore.load(type, userId, rawMaterialId);
|
||||
}
|
||||
|
||||
export function clearRawMaterialDraft(
|
||||
@ -69,10 +38,5 @@ export function clearRawMaterialDraft(
|
||||
userId?: number,
|
||||
rawMaterialId?: number,
|
||||
): void {
|
||||
try {
|
||||
const key = getKey(type, userId, rawMaterialId);
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
rawMaterialDraftStore.clear(type, userId, rawMaterialId);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const DRAFT_PREFIX = 'restock-draft';
|
||||
import { createDraftStore } from '@/lib/draft-store';
|
||||
|
||||
export type RestockDraftData = {
|
||||
stockType: 'good' | 'reject';
|
||||
@ -8,60 +8,27 @@ export type RestockDraftData = {
|
||||
photo?: string;
|
||||
};
|
||||
|
||||
function getKey(type: 'create' | 'edit', userId?: number): string {
|
||||
if (type === 'edit') {
|
||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}`;
|
||||
}
|
||||
|
||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
||||
}
|
||||
export const restockDraftStore =
|
||||
createDraftStore<RestockDraftData>('restock-draft');
|
||||
|
||||
export function saveRestockDraft(
|
||||
type: 'create' | 'edit',
|
||||
data: RestockDraftData,
|
||||
userId?: number,
|
||||
): boolean {
|
||||
if (type !== 'create') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return restockDraftStore.save(type, data, userId);
|
||||
}
|
||||
|
||||
export function loadRestockDraft(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
): RestockDraftData | null {
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as RestockDraftData;
|
||||
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return restockDraftStore.load(type, userId);
|
||||
}
|
||||
|
||||
export function clearRestockDraft(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
): void {
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
restockDraftStore.clear(type, userId);
|
||||
}
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export type CashAccount = {
|
||||
@ -55,39 +49,22 @@ export function createCashAccountColumns(
|
||||
const cashAccount = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(cashAccount)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(cashAccount)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(cashAccount),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(cashAccount),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,34 +1,19 @@
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Filter,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { ArrowDownToLine, ArrowUpFromLine, Wallet } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FileUpload } from '@/components/file-upload';
|
||||
import { FilterPopover } from '@/components/filter-popover';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { RupiahInput } from '@/components/rupiah-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -36,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import {
|
||||
index as cashAccountIndex,
|
||||
@ -78,7 +64,6 @@ export default function CashAccountIndex({
|
||||
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<CashTransaction | null>(null);
|
||||
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(
|
||||
null,
|
||||
@ -105,7 +90,6 @@ export default function CashAccountIndex({
|
||||
type: string;
|
||||
} | null>(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const pagination: PaginationState = {
|
||||
current_page: transactions.current_page,
|
||||
last_page: transactions.last_page,
|
||||
@ -113,41 +97,20 @@ export default function CashAccountIndex({
|
||||
total: transactions.total,
|
||||
};
|
||||
|
||||
const hasActiveFilters = filters.type;
|
||||
|
||||
function applyFilter(key: string, value: string) {
|
||||
const newFilters = { ...filters };
|
||||
|
||||
if (value === '' || value === 'all') {
|
||||
delete newFilters[key as keyof typeof newFilters];
|
||||
} else {
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
const {
|
||||
search,
|
||||
filterOpen,
|
||||
setFilterOpen,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
applyFilter,
|
||||
clearFilters,
|
||||
} = useServerTable({
|
||||
route: () => cashAccountIndex.url(),
|
||||
pagination,
|
||||
filters,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -159,49 +122,6 @@ export default function CashAccountIndex({
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, filters],
|
||||
);
|
||||
|
||||
const columns = createTransactionColumns({
|
||||
handleEdit: (transaction) => {
|
||||
setEditing(transaction);
|
||||
@ -211,66 +131,34 @@ export default function CashAccountIndex({
|
||||
});
|
||||
|
||||
const filterToolbar = (
|
||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{Object.values(filters).filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Hapus Semua
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Tipe Sumber
|
||||
</label>
|
||||
<Select
|
||||
value={filters.type ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('type', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Tipe" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Tipe</SelectItem>
|
||||
<SelectItem value="deposit">Deposit</SelectItem>
|
||||
<SelectItem value="withdrawal">
|
||||
Withdrawal
|
||||
</SelectItem>
|
||||
<SelectItem value="expense">
|
||||
Pengeluaran
|
||||
</SelectItem>
|
||||
<SelectItem value="transfer">
|
||||
Transfer
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FilterPopover
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
filters={filters}
|
||||
hasActiveFilters={Boolean(filters.type)}
|
||||
onClear={clearFilters}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Tipe Sumber
|
||||
</label>
|
||||
<Select
|
||||
value={filters.type ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('type', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Tipe" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Tipe</SelectItem>
|
||||
<SelectItem value="deposit">Deposit</SelectItem>
|
||||
<SelectItem value="withdrawal">Withdrawal</SelectItem>
|
||||
<SelectItem value="expense">Pengeluaran</SelectItem>
|
||||
<SelectItem value="transfer">Transfer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FilterPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -278,29 +166,27 @@ export default function CashAccountIndex({
|
||||
<Head title="Kas Toko" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Kas Toko
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDepositOpen(true)}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4" />
|
||||
Deposit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setWithdrawalOpen(true)}
|
||||
>
|
||||
<ArrowUpFromLine className="h-4 w-4" />
|
||||
Withdrawal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Kas Toko"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDepositOpen(true)}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4" />
|
||||
Deposit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setWithdrawalOpen(true)}
|
||||
>
|
||||
<ArrowUpFromLine className="h-4 w-4" />
|
||||
Withdrawal
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@ -330,7 +216,7 @@ export default function CashAccountIndex({
|
||||
toolbar={filterToolbar}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
<FormDialog
|
||||
open={depositOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDepositOpen(open);
|
||||
@ -340,123 +226,72 @@ export default function CashAccountIndex({
|
||||
setDepositFileMeta(null);
|
||||
}
|
||||
}}
|
||||
title="Deposit"
|
||||
action={deposit()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setDepositOpen(false);
|
||||
setDepositReceiptKey(null);
|
||||
setDepositFileMeta(null);
|
||||
}}
|
||||
submitDisabled={depositUploading}
|
||||
submitLabel={depositUploading ? 'Mengunggah...' : 'Simpan'}
|
||||
>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={deposit()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setDepositOpen(false);
|
||||
setDepositReceiptKey(null);
|
||||
setDepositFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deposit</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={depositReceiptKey ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={
|
||||
depositFileMeta?.size ?? ''
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={
|
||||
depositFileMeta?.type ?? ''
|
||||
}
|
||||
/>
|
||||
<FileUpload
|
||||
value={depositReceiptKey}
|
||||
onChange={setDepositReceiptKey}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={
|
||||
setDepositUploading
|
||||
}
|
||||
onFileMeta={setDepositFileMeta}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.receipt_key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDepositOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing || depositUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: depositUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{({ errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={depositReceiptKey ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={depositFileMeta?.size ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={depositFileMeta?.type ?? ''}
|
||||
/>
|
||||
<FileUpload
|
||||
value={depositReceiptKey}
|
||||
onChange={setDepositReceiptKey}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={setDepositUploading}
|
||||
onFileMeta={setDepositFileMeta}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<Dialog
|
||||
<FormDialog
|
||||
open={withdrawalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setWithdrawalOpen(open);
|
||||
@ -466,132 +301,74 @@ export default function CashAccountIndex({
|
||||
setWithdrawalFileMeta(null);
|
||||
}
|
||||
}}
|
||||
title="Withdrawal"
|
||||
action={withdrawal()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setWithdrawalOpen(false);
|
||||
setWithdrawalReceiptKey(null);
|
||||
setWithdrawalFileMeta(null);
|
||||
}}
|
||||
submitDisabled={withdrawalUploading}
|
||||
submitLabel={
|
||||
withdrawalUploading ? 'Mengunggah...' : 'Simpan'
|
||||
}
|
||||
>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={withdrawal()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setWithdrawalOpen(false);
|
||||
setWithdrawalReceiptKey(null);
|
||||
setWithdrawalFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Withdrawal</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={
|
||||
withdrawalReceiptKey ?? ''
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={
|
||||
withdrawalFileMeta?.size ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={
|
||||
withdrawalFileMeta?.type ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<FileUpload
|
||||
value={withdrawalReceiptKey}
|
||||
onChange={
|
||||
setWithdrawalReceiptKey
|
||||
}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={
|
||||
setWithdrawalUploading
|
||||
}
|
||||
onFileMeta={
|
||||
setWithdrawalFileMeta
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.receipt_key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setWithdrawalOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing ||
|
||||
withdrawalUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: withdrawalUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{({ errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={withdrawalReceiptKey ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={withdrawalFileMeta?.size ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={withdrawalFileMeta?.type ?? ''}
|
||||
/>
|
||||
<FileUpload
|
||||
value={withdrawalReceiptKey}
|
||||
onChange={setWithdrawalReceiptKey}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={setWithdrawalUploading}
|
||||
onFileMeta={setWithdrawalFileMeta}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<Dialog
|
||||
<FormDialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@ -600,143 +377,96 @@ export default function CashAccountIndex({
|
||||
setEditFileMeta(null);
|
||||
}
|
||||
}}
|
||||
title="Edit Transaksi"
|
||||
action={editing ? updateTransaction(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}}
|
||||
submitDisabled={editUploading}
|
||||
submitLabel={editUploading ? 'Mengunggah...' : 'Simpan'}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form
|
||||
action={updateTransaction(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit Transaksi
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={
|
||||
editing.amount
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={
|
||||
editing.description
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={editReceiptKey ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={
|
||||
editFileMeta?.size ?? ''
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={
|
||||
editFileMeta?.type ?? ''
|
||||
}
|
||||
/>
|
||||
<FileUpload
|
||||
value={editReceiptKey}
|
||||
onChange={setEditReceiptKey}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={
|
||||
setEditUploading
|
||||
}
|
||||
existingUrl={
|
||||
editing.receipt_url
|
||||
}
|
||||
onFileMeta={setEditFileMeta}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.receipt_key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing || editUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: editUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={editing.amount}
|
||||
min={1}
|
||||
/>
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={editing.description}
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={editReceiptKey ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={editFileMeta?.size ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={editFileMeta?.type ?? ''}
|
||||
/>
|
||||
<FileUpload
|
||||
value={editReceiptKey}
|
||||
onChange={setEditReceiptKey}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={setEditUploading}
|
||||
existingUrl={editing.receipt_url}
|
||||
onFileMeta={setEditFileMeta}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Transaksi"
|
||||
description={`Apakah Anda yakin ingin menghapus transaksi "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(transaction) =>
|
||||
`Apakah Anda yakin ingin menghapus transaksi "${transaction.description}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export type CashTransaction = {
|
||||
id: number;
|
||||
@ -30,23 +24,6 @@ export type CashTransaction = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return (
|
||||
date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
deposit: 'Deposit',
|
||||
@ -69,31 +46,6 @@ function getReferenceLabel(type: string): string {
|
||||
return labels[type] ?? '-';
|
||||
}
|
||||
|
||||
function ReceiptPreview({ url, title }: { url: string; title: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={url}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (transaction: CashTransaction) => void;
|
||||
handleDeleteClick: (transaction: CashTransaction) => void;
|
||||
@ -182,8 +134,8 @@ export function createTransactionColumns(
|
||||
}
|
||||
|
||||
return (
|
||||
<ReceiptPreview
|
||||
url={receiptUrl}
|
||||
<ImagePreviewButton
|
||||
srcs={[receiptUrl]}
|
||||
title={row.original.description}
|
||||
/>
|
||||
);
|
||||
@ -216,39 +168,22 @@ export function createTransactionColumns(
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(transaction)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(transaction)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(transaction),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(transaction),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatDate, formatShortDate } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export type EmployeeAdvance = {
|
||||
@ -27,33 +22,6 @@ export type EmployeeAdvance = {
|
||||
};
|
||||
};
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return (
|
||||
date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function formatShortDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
pending: {
|
||||
@ -165,79 +133,39 @@ export function createEmployeeAdvanceColumns(
|
||||
const employeeAdvance = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{employeeAdvance.status === 'pending' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleApprove(employeeAdvance)
|
||||
}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Setujui
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{employeeAdvance.status === 'approved' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handlePay(employeeAdvance)
|
||||
}
|
||||
>
|
||||
<CircleDollarSign className="h-4 w-4 text-blue-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Bayar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(employeeAdvance)
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(employeeAdvance)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Setujui',
|
||||
icon: (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
),
|
||||
show: employeeAdvance.status === 'pending',
|
||||
onClick: () => handleApprove(employeeAdvance),
|
||||
},
|
||||
{
|
||||
label: 'Bayar',
|
||||
icon: (
|
||||
<CircleDollarSign className="h-4 w-4 text-blue-600" />
|
||||
),
|
||||
show: employeeAdvance.status === 'approved',
|
||||
onClick: () => handlePay(employeeAdvance),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(employeeAdvance),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () =>
|
||||
handleDeleteClick(employeeAdvance),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,22 +1,18 @@
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DatePicker } from '@/components/date-picker';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { RupiahInput } from '@/components/rupiah-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
index as employeeAdvanceIndex,
|
||||
@ -48,7 +44,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: employeeAdvances.current_page,
|
||||
last_page: employeeAdvances.last_page,
|
||||
@ -56,6 +52,16 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
total: employeeAdvances.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => employeeAdvanceIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setEditingDueDate(new Date(editing.due_date));
|
||||
@ -102,46 +108,6 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
employeeAdvanceIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
employeeAdvanceIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
employeeAdvanceIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
|
||||
const columns = createEmployeeAdvanceColumns({
|
||||
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
|
||||
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
|
||||
@ -154,22 +120,9 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
<Head title="Kasbon" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Kasbon
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setDueDate(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PageHeader
|
||||
title="Kasbon"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -179,113 +132,72 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Tambah Kasbon
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.description
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="due_date">
|
||||
Jatuh Tempo{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="due_date"
|
||||
value={
|
||||
dueDate
|
||||
? dueDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
placeholder="Pilih jatuh tempo"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.due_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setDueDate(undefined);
|
||||
}
|
||||
}}
|
||||
title="Tambah Kasbon"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="due_date">
|
||||
Jatuh Tempo{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="due_date"
|
||||
value={
|
||||
dueDate
|
||||
? dueDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
placeholder="Pilih jatuh tempo"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError message={errors.due_date} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -300,7 +212,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
<FormDialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@ -308,162 +220,117 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
setEditingDueDate(undefined);
|
||||
}
|
||||
}}
|
||||
title="Edit Kasbon"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditingDueDate(undefined);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditingDueDate(undefined);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit Kasbon
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={
|
||||
editing.amount
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={
|
||||
editing.description
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.description
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-due_date">
|
||||
Jatuh Tempo{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="due_date"
|
||||
value={
|
||||
editingDueDate
|
||||
? editingDueDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingDueDate}
|
||||
onChange={
|
||||
setEditingDueDate
|
||||
}
|
||||
placeholder="Pilih jatuh tempo"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.due_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={editing.amount}
|
||||
min={1}
|
||||
/>
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={editing.description}
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-due_date">
|
||||
Jatuh Tempo{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="due_date"
|
||||
value={
|
||||
editingDueDate
|
||||
? editingDueDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingDueDate}
|
||||
onChange={setEditingDueDate}
|
||||
placeholder="Pilih jatuh tempo"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError message={errors.due_date} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Kasbon"
|
||||
description={`Apakah Anda yakin ingin menghapus kasbon "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(advance) =>
|
||||
`Apakah Anda yakin ingin menghapus kasbon "${advance.description}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={approving !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={approving}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setApproving(null);
|
||||
}
|
||||
}}
|
||||
title="Setujui Kasbon"
|
||||
description={`Apakah Anda yakin ingin menyetujui kasbon "${approving?.description}"?`}
|
||||
description={(advance) =>
|
||||
`Apakah Anda yakin ingin menyetujui kasbon "${advance.description}"?`
|
||||
}
|
||||
confirmLabel="Setujui"
|
||||
onConfirm={handleApprove}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={paying !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={paying}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPaying(null);
|
||||
}
|
||||
}}
|
||||
title="Bayar Kasbon"
|
||||
description={`Apakah Anda yakin ingin membayar kasbon "${paying?.description}" sebesar ${paying?.amount}? Saldo kas akan dikembalikan.`}
|
||||
description={(advance) =>
|
||||
`Apakah Anda yakin ingin membayar kasbon "${advance.description}" sebesar ${advance.amount}? Saldo kas akan dikembalikan.`
|
||||
}
|
||||
confirmLabel="Bayar"
|
||||
onConfirm={handlePay}
|
||||
/>
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export type Expense = {
|
||||
id: number;
|
||||
@ -25,53 +19,11 @@ export type Expense = {
|
||||
};
|
||||
};
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return (
|
||||
date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (expense: Expense) => void;
|
||||
handleDeleteClick: (expense: Expense) => void;
|
||||
};
|
||||
|
||||
function ReceiptPreview({ url, title }: { url: string; title: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={url}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function createExpenseColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Expense>[] {
|
||||
@ -105,8 +57,8 @@ export function createExpenseColumns(
|
||||
}
|
||||
|
||||
return (
|
||||
<ReceiptPreview
|
||||
url={receiptUrl}
|
||||
<ImagePreviewButton
|
||||
srcs={[receiptUrl]}
|
||||
title={row.original.description}
|
||||
/>
|
||||
);
|
||||
@ -141,39 +93,22 @@ export function createExpenseColumns(
|
||||
const expense = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(expense)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(expense)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(expense),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(expense),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,22 +1,18 @@
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FileUpload } from '@/components/file-upload';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { RupiahInput } from '@/components/rupiah-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
index as expenseIndex,
|
||||
@ -54,7 +50,7 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
size: number;
|
||||
type: string;
|
||||
} | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: expenses.current_page,
|
||||
last_page: expenses.last_page,
|
||||
@ -62,45 +58,15 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
total: expenses.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
expenseIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
expenseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
expenseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => expenseIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -125,23 +91,9 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
<Head title="Pengeluaran" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Pengeluaran
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setCreateReceiptKey(null);
|
||||
setCreateFileMeta(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PageHeader
|
||||
title="Pengeluaran"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -151,140 +103,173 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateReceiptKey(null);
|
||||
setCreateFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Tambah Pengeluaran
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.description
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={
|
||||
createReceiptKey ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={
|
||||
createFileMeta?.size ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={
|
||||
createFileMeta?.type ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<FileUpload
|
||||
value={createReceiptKey}
|
||||
onChange={
|
||||
setCreateReceiptKey
|
||||
}
|
||||
folder="expense"
|
||||
onUploadingChange={
|
||||
setCreateUploading
|
||||
}
|
||||
onFileMeta={
|
||||
setCreateFileMeta
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.receipt_key
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing ||
|
||||
createUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: createUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setCreateReceiptKey(null);
|
||||
setCreateFileMeta(null);
|
||||
}
|
||||
}}
|
||||
title="Tambah Pengeluaran"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateReceiptKey(null);
|
||||
setCreateFileMeta(null);
|
||||
}}
|
||||
submitDisabled={createUploading}
|
||||
submitLabel={createUploading ? 'Mengunggah...' : 'Simpan'}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={createReceiptKey ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={createFileMeta?.size ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={createFileMeta?.type ?? ''}
|
||||
/>
|
||||
<FileUpload
|
||||
value={createReceiptKey}
|
||||
onChange={setCreateReceiptKey}
|
||||
folder="expense"
|
||||
onUploadingChange={setCreateUploading}
|
||||
onFileMeta={setCreateFileMeta}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}
|
||||
}}
|
||||
title="Edit Pengeluaran"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}}
|
||||
submitDisabled={editUploading}
|
||||
submitLabel={editUploading ? 'Mengunggah...' : 'Simpan'}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={editing.amount}
|
||||
min={1}
|
||||
/>
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={editing.description}
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={editReceiptKey ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={editFileMeta?.size ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={editFileMeta?.type ?? ''}
|
||||
/>
|
||||
<FileUpload
|
||||
value={editReceiptKey}
|
||||
onChange={setEditReceiptKey}
|
||||
folder="expense"
|
||||
onUploadingChange={setEditUploading}
|
||||
existingUrl={editing.receipt_url}
|
||||
onFileMeta={setEditFileMeta}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -299,170 +284,17 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit Pengeluaran
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={
|
||||
editing.amount
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={
|
||||
editing.description
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.description
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="receipt_key"
|
||||
value={
|
||||
editReceiptKey ?? ''
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_size"
|
||||
value={
|
||||
editFileMeta?.size ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="file_mime_type"
|
||||
value={
|
||||
editFileMeta?.type ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<FileUpload
|
||||
value={editReceiptKey}
|
||||
onChange={
|
||||
setEditReceiptKey
|
||||
}
|
||||
folder="expense"
|
||||
onUploadingChange={
|
||||
setEditUploading
|
||||
}
|
||||
existingUrl={
|
||||
editing.receipt_url
|
||||
}
|
||||
onFileMeta={
|
||||
setEditFileMeta
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.receipt_key
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing ||
|
||||
editUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: editUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Pengeluaran"
|
||||
description={`Apakah Anda yakin ingin menghapus pengeluaran "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(expense) =>
|
||||
`Apakah Anda yakin ingin menghapus pengeluaran "${expense.description}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Eye, Lock, Unlock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { MONTH_NAMES } from '@/lib/constants';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { Link } from '@inertiajs/react';
|
||||
|
||||
export type PayrollPeriod = {
|
||||
id: number;
|
||||
@ -26,22 +20,6 @@ export type PayrollPeriod = {
|
||||
payrolls_sum_deduction_amount: number | null;
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'',
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember',
|
||||
];
|
||||
|
||||
function formatPeriod(period: PayrollPeriod): string {
|
||||
return `${MONTH_NAMES[period.month]} ${period.year}`;
|
||||
}
|
||||
@ -201,56 +179,31 @@ export function createPayrollPeriodColumns(
|
||||
const period = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={showUrl(period.id)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Lihat Detail
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{period.status === 'open' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleClose(period)}
|
||||
>
|
||||
<Lock className="h-4 w-4 text-orange-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Tutup Periode
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{period.status === 'closed' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleReopen(period)}
|
||||
>
|
||||
<Unlock className="h-4 w-4 text-blue-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Buka Periode
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Lihat Detail',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
href: showUrl(period.id),
|
||||
},
|
||||
{
|
||||
label: 'Tutup Periode',
|
||||
icon: (
|
||||
<Lock className="h-4 w-4 text-orange-600" />
|
||||
),
|
||||
show: period.status === 'open',
|
||||
onClick: () => handleClose(period),
|
||||
},
|
||||
{
|
||||
label: 'Buka Periode',
|
||||
icon: (
|
||||
<Unlock className="h-4 w-4 text-blue-600" />
|
||||
),
|
||||
show: period.status === 'closed',
|
||||
onClick: () => handleReopen(period),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,19 +1,17 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import { MONTH_NAMES } from '@/lib/constants';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
||||
import { show as payrollPeriodShow } from '@/routes/admin/finance/payroll-periods';
|
||||
import { close, reopen } from '@/routes/admin/finance/payroll-periods';
|
||||
index as payrollPeriodsIndex,
|
||||
show as payrollPeriodShow,
|
||||
close,
|
||||
reopen,
|
||||
} from '@/routes/admin/finance/payroll-periods';
|
||||
import { createPayrollPeriodColumns } from './columns';
|
||||
import type { PayrollPeriod } from './columns';
|
||||
|
||||
@ -27,26 +25,10 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'',
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember',
|
||||
];
|
||||
|
||||
export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
|
||||
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: payrollPeriods.current_page,
|
||||
last_page: payrollPeriods.last_page,
|
||||
@ -54,6 +36,16 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||
total: payrollPeriods.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => payrollPeriodsIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
if (!closing) {
|
||||
return;
|
||||
@ -82,46 +74,6 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
payrollPeriodsIndex(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
payrollPeriodsIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
payrollPeriodsIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
|
||||
const columns = createPayrollPeriodColumns({
|
||||
showUrl: (id) => payrollPeriodShow(id).url,
|
||||
handleClose: (period) => setClosing(period),
|
||||
@ -133,13 +85,7 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||
<Head title="Gaji" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Gaji
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Gaji" />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -154,28 +100,32 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={closing !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={closing}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setClosing(null);
|
||||
}
|
||||
}}
|
||||
title="Tutup Periode Gaji"
|
||||
description={`Apakah Anda yakin ingin menutup periode gaji ${closing ? `${MONTH_NAMES[closing.month]} ${closing.year}` : ''}? Semua gaji harus sudah dibayar sebelum periode ditutup.`}
|
||||
description={(period) =>
|
||||
`Apakah Anda yakin ingin menutup periode gaji ${MONTH_NAMES[period.month]} ${period.year}? Semua gaji harus sudah dibayar sebelum periode ditutup.`
|
||||
}
|
||||
confirmLabel="Tutup"
|
||||
onConfirm={handleClose}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={reopening !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={reopening}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setReopening(null);
|
||||
}
|
||||
}}
|
||||
title="Buka Periode Gaji"
|
||||
description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`}
|
||||
description={(period) =>
|
||||
`Apakah Anda yakin ingin membuka kembali periode gaji ${MONTH_NAMES[period.month]} ${period.year}?`
|
||||
}
|
||||
confirmLabel="Buka"
|
||||
onConfirm={handleReopen}
|
||||
/>
|
||||
|
||||
@ -1,13 +1,7 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { CircleDollarSign, Pencil, Trash2, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export type Payroll = {
|
||||
@ -215,64 +209,32 @@ export function createPayrollColumns(
|
||||
const payroll = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{payroll.status === 'unpaid' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleAddAdjustment(payroll)
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Tambah Adjustment
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handlePay(payroll)
|
||||
}
|
||||
>
|
||||
<CircleDollarSign className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Tandai Dibayar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleCancel(payroll)
|
||||
}
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Batalkan
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Tambah Adjustment',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
show: payroll.status === 'unpaid',
|
||||
onClick: () => handleAddAdjustment(payroll),
|
||||
},
|
||||
{
|
||||
label: 'Tandai Dibayar',
|
||||
icon: (
|
||||
<CircleDollarSign className="h-4 w-4 text-green-600" />
|
||||
),
|
||||
show: payroll.status === 'unpaid',
|
||||
onClick: () => handlePay(payroll),
|
||||
},
|
||||
{
|
||||
label: 'Batalkan',
|
||||
icon: (
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
show: payroll.status === 'unpaid',
|
||||
onClick: () => handleCancel(payroll),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import InputError from '@/components/input-error';
|
||||
@ -13,6 +16,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { MONTH_NAMES } from '@/lib/constants';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
|
||||
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
||||
@ -21,9 +25,6 @@ import {
|
||||
pay as payrollPay,
|
||||
} from '@/routes/admin/finance/payrolls';
|
||||
import { store as adjustmentStore } from '@/routes/admin/finance/payrolls/adjustments';
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { Payroll, PayrollAdjustment } from './show-columns';
|
||||
import { createPayrollColumns } from './show-columns';
|
||||
|
||||
@ -37,22 +38,6 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'',
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember',
|
||||
];
|
||||
|
||||
export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
const [paying, setPaying] = useState<Payroll | null>(null);
|
||||
const [cancelling, setCancelling] = useState<Payroll | null>(null);
|
||||
@ -66,7 +51,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
|
||||
|
||||
function handlePay() {
|
||||
if (!paying) return;
|
||||
if (!paying) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(
|
||||
payrollPay(paying.id),
|
||||
@ -78,7 +65,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!cancelling) return;
|
||||
if (!cancelling) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(
|
||||
payrollCancel(cancelling.id),
|
||||
@ -90,7 +79,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
}
|
||||
|
||||
function handleDeleteAdjustment() {
|
||||
if (!deletingAdjustment) return;
|
||||
if (!deletingAdjustment) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(adjustmentDestroy(deletingAdjustment.adjustment.id), {
|
||||
onSuccess: () => setDeletingAdjustment(null),
|
||||
@ -336,7 +327,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
<ConfirmDialog
|
||||
open={paying !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPaying(null);
|
||||
if (!open) {
|
||||
setPaying(null);
|
||||
}
|
||||
}}
|
||||
title="Tandai Dibayar"
|
||||
description={`Apakah Anda yakin ingin menandai gaji "${paying?.employee?.user?.user_profile?.full_name}" sebesar ${formatCurrency(paying?.total_amount ?? 0)} sebagai sudah dibayar? Penyesuaian tidak dapat ditambahkan setelah dibayar.`}
|
||||
@ -348,7 +341,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
<ConfirmDialog
|
||||
open={cancelling !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCancelling(null);
|
||||
if (!open) {
|
||||
setCancelling(null);
|
||||
}
|
||||
}}
|
||||
title="Batalkan Gaji"
|
||||
description={`Apakah Anda yakin ingin membatalkan gaji "${cancelling?.employee?.user?.user_profile?.full_name}"?`}
|
||||
@ -360,7 +355,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
<ConfirmDialog
|
||||
open={deletingAdjustment !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeletingAdjustment(null);
|
||||
if (!open) {
|
||||
setDeletingAdjustment(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Penyesuaian"
|
||||
description={`Apakah Anda yakin ingin menghapus penyesuaian "${deletingAdjustment?.adjustment.description}" sebesar ${formatCurrency(deletingAdjustment?.adjustment.amount ?? 0)}?`}
|
||||
|
||||
@ -1,14 +1,8 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { ToggleStatus } from '@/components/toggle-status';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export type Employee = {
|
||||
id: number;
|
||||
@ -38,14 +32,6 @@ function getEmploymentStatusLabel(status: string): string {
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (employee: Employee) => void;
|
||||
handleDeleteClick: (employee: Employee) => void;
|
||||
@ -132,18 +118,10 @@ export function createEmployeeColumns(
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Switch
|
||||
size="sm"
|
||||
<ToggleStatus
|
||||
url={toggleActiveUrl(employee.id)}
|
||||
checked={employee.is_active}
|
||||
onCheckedChange={() => {
|
||||
router.post(
|
||||
toggleActiveUrl(employee.id),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
wrapperClassName="flex items-center justify-center gap-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -160,56 +138,29 @@ export function createEmployeeColumns(
|
||||
const employee = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(employee)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleResetPassword(employee)
|
||||
}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Reset Kata Sandi
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(employee)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(employee),
|
||||
},
|
||||
{
|
||||
label: 'Reset Kata Sandi',
|
||||
icon: (
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
),
|
||||
onClick: () => handleResetPassword(employee),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(employee),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Filter, Plus, X } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FilterPopover } from '@/components/filter-popover';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -17,6 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
create as employeeCreate,
|
||||
@ -47,8 +45,7 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
const [deleting, setDeleting] = useState<Employee | null>(null);
|
||||
const [resetPasswordTarget, setResetPasswordTarget] =
|
||||
useState<Employee | null>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: employees.current_page,
|
||||
last_page: employees.last_page,
|
||||
@ -56,84 +53,20 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
total: employees.total,
|
||||
};
|
||||
|
||||
const hasActiveFilters = filters.employment_status || filters.is_active;
|
||||
|
||||
function applyFilter(key: string, value: string) {
|
||||
const newFilters = { ...filters };
|
||||
|
||||
if (value === '' || value === 'all') {
|
||||
delete newFilters[key as keyof typeof newFilters];
|
||||
} else {
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, filters],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
filterOpen,
|
||||
setFilterOpen,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
applyFilter,
|
||||
clearFilters,
|
||||
} = useServerTable({
|
||||
route: () => employeeIndex.url(),
|
||||
pagination,
|
||||
filters,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -169,115 +102,77 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
});
|
||||
|
||||
const filterToolbar = (
|
||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{Object.values(filters).filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Hapus Semua
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FilterPopover
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
filters={filters}
|
||||
hasActiveFilters={Boolean(
|
||||
filters.employment_status || filters.is_active,
|
||||
)}
|
||||
onClear={clearFilters}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Status Karyawan
|
||||
</label>
|
||||
<Select
|
||||
value={filters.employment_status ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('employment_status', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="full_time">Full Time</SelectItem>
|
||||
<SelectItem value="part_time">Part Time</SelectItem>
|
||||
<SelectItem value="contract">Kontrak</SelectItem>
|
||||
<SelectItem value="internship">Magang</SelectItem>
|
||||
<SelectItem value="resigned">Keluar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Status Karyawan
|
||||
</label>
|
||||
<Select
|
||||
value={filters.employment_status ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('employment_status', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
Semua Status
|
||||
</SelectItem>
|
||||
<SelectItem value="full_time">
|
||||
Full Time
|
||||
</SelectItem>
|
||||
<SelectItem value="part_time">
|
||||
Part Time
|
||||
</SelectItem>
|
||||
<SelectItem value="contract">
|
||||
Kontrak
|
||||
</SelectItem>
|
||||
<SelectItem value="internship">
|
||||
Magang
|
||||
</SelectItem>
|
||||
<SelectItem value="resigned">Keluar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Status Aktif
|
||||
</label>
|
||||
<Select
|
||||
value={filters.is_active ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('is_active', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua</SelectItem>
|
||||
<SelectItem value="1">Aktif</SelectItem>
|
||||
<SelectItem value="0">Tidak Aktif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Status Aktif
|
||||
</label>
|
||||
<Select
|
||||
value={filters.is_active ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('is_active', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua</SelectItem>
|
||||
<SelectItem value="1">Aktif</SelectItem>
|
||||
<SelectItem value="0">Tidak Aktif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Jenis Kelamin
|
||||
</label>
|
||||
<Select
|
||||
value={filters.gender ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('gender', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua</SelectItem>
|
||||
<SelectItem value="male">Laki-laki</SelectItem>
|
||||
<SelectItem value="female">
|
||||
Perempuan
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Jenis Kelamin
|
||||
</label>
|
||||
<Select
|
||||
value={filters.gender ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('gender', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua</SelectItem>
|
||||
<SelectItem value="male">Laki-laki</SelectItem>
|
||||
<SelectItem value="female">Perempuan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FilterPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -285,19 +180,17 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
<Head title="Pegawai" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Pegawai
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={employeeCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Pegawai"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<a href={employeeCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -313,28 +206,31 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
toolbar={filterToolbar}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Pegawai"
|
||||
description={`Apakah Anda yakin ingin menghapus pegawai "${deleting?.user_profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(employee) =>
|
||||
`Apakah Anda yakin ingin menghapus pegawai "${employee.user_profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetPasswordTarget !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={resetPasswordTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setResetPasswordTarget(null);
|
||||
}
|
||||
}}
|
||||
title="Reset Kata Sandi"
|
||||
description={`Apakah Anda yakin ingin mereset kata sandi pegawai "${resetPasswordTarget?.user_profile?.full_name}" ke kata sandi default?`}
|
||||
description={(employee) =>
|
||||
`Apakah Anda yakin ingin mereset kata sandi pegawai "${employee.user_profile?.full_name}" ke kata sandi default?`
|
||||
}
|
||||
confirmLabel="Reset"
|
||||
variant="default"
|
||||
onConfirm={handleResetPassword}
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { CheckCircle, Pencil, Trash2, XCircle } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatShortDate } from '@/lib/format';
|
||||
|
||||
export type LeaveRequest = {
|
||||
id: number;
|
||||
@ -25,16 +20,6 @@ export type LeaveRequest = {
|
||||
};
|
||||
};
|
||||
|
||||
function formatShortDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
pending: {
|
||||
@ -136,77 +121,38 @@ export function createLeaveRequestColumns(
|
||||
const leaveRequest = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{leaveRequest.status === 'pending' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleApprove(leaveRequest)
|
||||
}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Setujui
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleReject(leaveRequest)
|
||||
}
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Tolak
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(leaveRequest)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(leaveRequest)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Setujui',
|
||||
icon: (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
),
|
||||
show: leaveRequest.status === 'pending',
|
||||
onClick: () => handleApprove(leaveRequest),
|
||||
},
|
||||
{
|
||||
label: 'Tolak',
|
||||
icon: (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
),
|
||||
show: leaveRequest.status === 'pending',
|
||||
onClick: () => handleReject(leaveRequest),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(leaveRequest),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(leaveRequest),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,25 +1,16 @@
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { Filter, Plus, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DatePicker } from '@/components/date-picker';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FilterPopover } from '@/components/filter-popover';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -27,6 +18,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
approve,
|
||||
destroy,
|
||||
@ -65,8 +57,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: leaveRequests.current_page,
|
||||
last_page: leaveRequests.last_page,
|
||||
@ -74,7 +65,20 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
total: leaveRequests.total,
|
||||
};
|
||||
|
||||
const hasActiveFilters = filters.status;
|
||||
const {
|
||||
search,
|
||||
filterOpen,
|
||||
setFilterOpen,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
applyFilter,
|
||||
clearFilters,
|
||||
} = useServerTable({
|
||||
route: () => leaveRequestIndex.url(),
|
||||
pagination,
|
||||
filters,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
@ -86,40 +90,6 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
function applyFilter(key: string, value: string) {
|
||||
const newFilters = { ...filters };
|
||||
|
||||
if (value === '' || value === 'all') {
|
||||
delete newFilters[key as keyof typeof newFilters];
|
||||
} else {
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
@ -158,49 +128,6 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, filters],
|
||||
);
|
||||
|
||||
const columns = createLeaveRequestColumns({
|
||||
handleEdit: (leaveRequest) => setEditing(leaveRequest),
|
||||
handleDeleteClick: (leaveRequest) => setDeleting(leaveRequest),
|
||||
@ -209,70 +136,32 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
});
|
||||
|
||||
const filterToolbar = (
|
||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{Object.values(filters).filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Hapus Semua
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
value={filters.status ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('status', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
Semua Status
|
||||
</SelectItem>
|
||||
<SelectItem value="pending">
|
||||
Menunggu
|
||||
</SelectItem>
|
||||
<SelectItem value="approved">
|
||||
Disetujui
|
||||
</SelectItem>
|
||||
<SelectItem value="rejected">
|
||||
Ditolak
|
||||
</SelectItem>
|
||||
<SelectItem value="cancelled">
|
||||
Dibatalkan
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FilterPopover
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
filters={filters}
|
||||
hasActiveFilters={Boolean(filters.status)}
|
||||
onClear={clearFilters}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">Status</label>
|
||||
<Select
|
||||
value={filters.status ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('status', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="pending">Menunggu</SelectItem>
|
||||
<SelectItem value="approved">Disetujui</SelectItem>
|
||||
<SelectItem value="rejected">Ditolak</SelectItem>
|
||||
<SelectItem value="cancelled">Dibatalkan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FilterPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -280,23 +169,9 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
<Head title="Cuti" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Cuti
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PageHeader
|
||||
title="Cuti"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -306,112 +181,77 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Tambah Permohonan Cuti
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Mulai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="start_date"
|
||||
value={
|
||||
startDate
|
||||
? startDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
placeholder="Pilih tanggal mulai"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.start_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Selesai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="end_date"
|
||||
value={
|
||||
endDate
|
||||
? endDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
placeholder="Pilih tanggal selesai"
|
||||
min={startDate}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.end_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
}
|
||||
}}
|
||||
title="Tambah Permohonan Cuti"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Mulai{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="start_date"
|
||||
value={
|
||||
startDate
|
||||
? startDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
placeholder="Pilih tanggal mulai"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError message={errors.start_date} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Selesai{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="end_date"
|
||||
value={
|
||||
endDate
|
||||
? endDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
placeholder="Pilih tanggal selesai"
|
||||
min={startDate}
|
||||
/>
|
||||
<InputError message={errors.end_date} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -427,7 +267,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
toolbar={filterToolbar}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
<FormDialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@ -436,125 +276,77 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
setEditingEndDate(undefined);
|
||||
}
|
||||
}}
|
||||
title="Edit Permohonan Cuti"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditingStartDate(undefined);
|
||||
setEditingEndDate(undefined);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditingStartDate(undefined);
|
||||
setEditingEndDate(undefined);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit Permohonan Cuti
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Mulai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="start_date"
|
||||
value={
|
||||
editingStartDate
|
||||
? editingStartDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingStartDate}
|
||||
onChange={
|
||||
setEditingStartDate
|
||||
}
|
||||
placeholder="Pilih tanggal mulai"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.start_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Selesai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="end_date"
|
||||
value={
|
||||
editingEndDate
|
||||
? editingEndDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingEndDate}
|
||||
onChange={
|
||||
setEditingEndDate
|
||||
}
|
||||
placeholder="Pilih tanggal selesai"
|
||||
min={editingStartDate}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.end_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Mulai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="start_date"
|
||||
value={
|
||||
editingStartDate
|
||||
? editingStartDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingStartDate}
|
||||
onChange={setEditingStartDate}
|
||||
placeholder="Pilih tanggal mulai"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError message={errors.start_date} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Selesai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="end_date"
|
||||
value={
|
||||
editingEndDate
|
||||
? editingEndDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingEndDate}
|
||||
onChange={setEditingEndDate}
|
||||
placeholder="Pilih tanggal selesai"
|
||||
min={editingStartDate}
|
||||
/>
|
||||
<InputError message={errors.end_date} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
@ -562,12 +354,11 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
}}
|
||||
title="Hapus Permohonan Cuti"
|
||||
description={`Apakah Anda yakin ingin menghapus permohonan cuti ini? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={approving !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={approving}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setApproving(null);
|
||||
@ -579,8 +370,8 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
onConfirm={handleApprove}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={rejecting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={rejecting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRejecting(null);
|
||||
|
||||
@ -41,18 +41,14 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { usePurchaseDraftSave } from '@/hooks/use-purchase-draft';
|
||||
import { UNITS } from '@/lib/constants';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { loadPurchaseDraft } from '@/lib/purchase-draft';
|
||||
import { getTemporaryUrl } from '@/lib/upload';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases';
|
||||
import type { PurchaseCreateData } from './columns';
|
||||
|
||||
const UNITS = [
|
||||
{ value: 'kg', label: 'Kilogram' },
|
||||
{ value: 'meter', label: 'Meter' },
|
||||
{ value: 'yard', label: 'Yard' },
|
||||
];
|
||||
|
||||
type VariantState = {
|
||||
variant: string;
|
||||
price: number;
|
||||
@ -383,9 +379,7 @@ export default function PurchaseCreate({ data }: Props) {
|
||||
})();
|
||||
|
||||
function formatQuantity(value: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(value);
|
||||
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function getPayload() {
|
||||
@ -806,9 +800,9 @@ export default function PurchaseCreate({ data }: Props) {
|
||||
</Label>
|
||||
<Combobox
|
||||
items={rawMaterials}
|
||||
itemToStringLabel={(m) =>
|
||||
m.name
|
||||
}
|
||||
itemToStringLabel={(
|
||||
m,
|
||||
) => m.name}
|
||||
value={selectedMaterial}
|
||||
onValueChange={(
|
||||
value,
|
||||
@ -834,10 +828,14 @@ export default function PurchaseCreate({ data }: Props) {
|
||||
key={
|
||||
m.id
|
||||
}
|
||||
value={m}
|
||||
value={
|
||||
m
|
||||
}
|
||||
>
|
||||
{m.name}{' '}
|
||||
({m.unit})
|
||||
(
|
||||
{m.unit}
|
||||
)
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
@ -932,7 +930,9 @@ export default function PurchaseCreate({ data }: Props) {
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
min={0}
|
||||
min={
|
||||
0
|
||||
}
|
||||
className="w-24 text-center"
|
||||
value={
|
||||
quantities[
|
||||
@ -997,9 +997,7 @@ export default function PurchaseCreate({ data }: Props) {
|
||||
onValueChange={(value) =>
|
||||
setSupplierId(
|
||||
value
|
||||
? String(
|
||||
value.id,
|
||||
)
|
||||
? String(value.id)
|
||||
: '',
|
||||
)
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { getTemporaryUrl } from '@/lib/upload';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import {
|
||||
@ -100,9 +101,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [mode, setMode] = useState<'new' | 'existing'>(
|
||||
purchase.default_mode,
|
||||
);
|
||||
const [mode, setMode] = useState<'new' | 'existing'>(purchase.default_mode);
|
||||
const [selectedMaterialName, setSelectedMaterialName] = useState(
|
||||
purchase.existing_material_name ?? '',
|
||||
);
|
||||
@ -321,9 +320,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
||||
})();
|
||||
|
||||
function formatQuantity(value: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(value);
|
||||
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function getPayload() {
|
||||
@ -704,9 +701,9 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
||||
</Label>
|
||||
<Combobox
|
||||
items={rawMaterials}
|
||||
itemToStringLabel={(m) =>
|
||||
m.name
|
||||
}
|
||||
itemToStringLabel={(
|
||||
m,
|
||||
) => m.name}
|
||||
value={selectedMaterial}
|
||||
onValueChange={(
|
||||
value,
|
||||
@ -732,10 +729,14 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
||||
key={
|
||||
m.id
|
||||
}
|
||||
value={m}
|
||||
value={
|
||||
m
|
||||
}
|
||||
>
|
||||
{m.name}{' '}
|
||||
({m.unit})
|
||||
(
|
||||
{m.unit}
|
||||
)
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
@ -830,7 +831,9 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
min={0}
|
||||
min={
|
||||
0
|
||||
}
|
||||
className="w-24 text-center"
|
||||
value={
|
||||
quantities[
|
||||
@ -895,9 +898,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
||||
onValueChange={(value) =>
|
||||
setSupplierId(
|
||||
value
|
||||
? String(
|
||||
value.id,
|
||||
)
|
||||
? String(value.id)
|
||||
: '',
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { CardTable } from '@/components/card-table';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
create as purchaseCreate,
|
||||
@ -27,7 +29,6 @@ type Props = {
|
||||
|
||||
export default function PurchaseIndex({ purchases }: Props) {
|
||||
const [deleting, setDeleting] = useState<Purchase | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const expand = useCardTableExpand(true);
|
||||
|
||||
const pagination = {
|
||||
@ -37,45 +38,15 @@ export default function PurchaseIndex({ purchases }: Props) {
|
||||
total: purchases.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
purchaseIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
purchaseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
purchaseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => purchaseIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -92,19 +63,17 @@ export default function PurchaseIndex({ purchases }: Props) {
|
||||
<Head title="Belanja" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Belanja
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={purchaseCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Belanja"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<a href={purchaseCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardTable
|
||||
data={purchases.data}
|
||||
@ -127,7 +96,7 @@ export default function PurchaseIndex({ purchases }: Props) {
|
||||
purchase={item}
|
||||
index={
|
||||
(pagination.current_page - 1) *
|
||||
pagination.per_page +
|
||||
pagination.per_page +
|
||||
index +
|
||||
1
|
||||
}
|
||||
@ -144,16 +113,17 @@ export default function PurchaseIndex({ purchases }: Props) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Belanja"
|
||||
description={`Apakah Anda yakin ingin menghapus belanja dari "${deleting?.supplier?.name}"? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(purchase) =>
|
||||
`Apakah Anda yakin ingin menghapus belanja dari "${purchase.supplier?.name}"? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,41 +1,12 @@
|
||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { Purchase } from './columns';
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export type PurchaseCardRowParams = {
|
||||
purchase: Purchase;
|
||||
index: number;
|
||||
@ -57,10 +28,11 @@ export function PurchaseCardRow({
|
||||
const variantCount = items.length;
|
||||
const rawMaterialName =
|
||||
items[0]?.raw_material_price?.raw_material?.name ?? '-';
|
||||
const unit =
|
||||
items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||
const totalQty = items.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||
const [photoPreviewOpen, setPhotoPreviewOpen] = useState(false);
|
||||
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||
const totalQty = items.reduce(
|
||||
(sum, item) => sum + Number(item.quantity),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -110,86 +82,68 @@ export function PurchaseCardRow({
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
<span>
|
||||
<span className="text-muted-foreground">Qty: </span>
|
||||
<span className="text-muted-foreground">
|
||||
Qty:{' '}
|
||||
</span>
|
||||
{formatNumber(totalQty)} {unit}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Sub: </span>
|
||||
<span className="text-muted-foreground">
|
||||
Sub:{' '}
|
||||
</span>
|
||||
{formatCurrency(purchase.subtotal)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Disc: </span>
|
||||
<span className="text-muted-foreground">
|
||||
Disc:{' '}
|
||||
</span>
|
||||
{formatCurrency(purchase.discount)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Ongkir: </span>
|
||||
<span className="text-muted-foreground">
|
||||
Ongkir:{' '}
|
||||
</span>
|
||||
{formatCurrency(purchase.shipping_cost)}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
<span className="text-muted-foreground font-normal">Total: </span>
|
||||
<span className="font-normal text-muted-foreground">
|
||||
Total:{' '}
|
||||
</span>
|
||||
{formatCurrency(purchase.total)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{purchase.photo_url && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhotoPreviewOpen(true)}
|
||||
className="block h-16 w-16 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={purchase.photo_url}
|
||||
alt="Foto belanja"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<ImagePreviewButton
|
||||
srcs={[purchase.photo_url]}
|
||||
title="Foto Belanja"
|
||||
className="h-16 w-16"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(purchase)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(purchase)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => onEdit(purchase),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => onDelete(purchase),
|
||||
},
|
||||
]}
|
||||
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{purchase.photo_url && (
|
||||
<ImagePreviewModal
|
||||
open={photoPreviewOpen}
|
||||
onOpenChange={setPhotoPreviewOpen}
|
||||
src={purchase.photo_url}
|
||||
title="Foto Belanja"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -8,67 +7,13 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { Purchase } from './columns';
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="block h-10 w-10 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={url}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PurchaseItemSubRow({
|
||||
purchase,
|
||||
}: {
|
||||
purchase: Purchase;
|
||||
}) {
|
||||
export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
||||
const items = purchase.purchase_items ?? [];
|
||||
const unit =
|
||||
items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4 overflow-x-auto">
|
||||
@ -103,9 +48,14 @@ export function PurchaseItemSubRow({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.raw_material_price?.photo_url ? (
|
||||
<VariantPhotoPreview
|
||||
url={item.raw_material_price.photo_url}
|
||||
title={item.raw_material_price.variant}
|
||||
<ImagePreviewButton
|
||||
srcs={[
|
||||
item.raw_material_price
|
||||
.photo_url,
|
||||
]}
|
||||
title={
|
||||
item.raw_material_price.variant
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useRestockDraftSave } from '@/hooks/use-restock-draft';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { loadRestockDraft } from '@/lib/restock-draft';
|
||||
import { getTemporaryUrl } from '@/lib/upload';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
@ -107,8 +108,7 @@ export default function RestockCreate({ data }: Props) {
|
||||
}, [quantities]);
|
||||
|
||||
const selectedProduct = useMemo(
|
||||
() =>
|
||||
products.find((p) => String(p.id) === selectedProductId) ?? null,
|
||||
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
|
||||
[products, selectedProductId],
|
||||
);
|
||||
|
||||
@ -178,9 +178,7 @@ export default function RestockCreate({ data }: Props) {
|
||||
})();
|
||||
|
||||
function formatQuantity(value: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(value);
|
||||
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function getPayload() {
|
||||
@ -292,8 +290,7 @@ export default function RestockCreate({ data }: Props) {
|
||||
(quantities[
|
||||
variant
|
||||
.id
|
||||
] ??
|
||||
0) > 0
|
||||
] ?? 0) > 0
|
||||
? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
|
||||
: 'flex items-center justify-between gap-3 rounded-lg border p-3'
|
||||
}
|
||||
@ -329,11 +326,9 @@ export default function RestockCreate({ data }: Props) {
|
||||
)}{' '}
|
||||
pcs
|
||||
·{' '}
|
||||
{
|
||||
formatCurrency(
|
||||
variant.capital_price,
|
||||
)
|
||||
}
|
||||
{formatCurrency(
|
||||
variant.capital_price,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -416,8 +411,7 @@ export default function RestockCreate({ data }: Props) {
|
||||
onValueChange={(value) =>
|
||||
setStockType(
|
||||
value as
|
||||
| 'good'
|
||||
| 'reject',
|
||||
'good' | 'reject',
|
||||
)
|
||||
}
|
||||
className="flex flex-wrap gap-4"
|
||||
@ -507,9 +501,7 @@ export default function RestockCreate({ data }: Props) {
|
||||
}}
|
||||
folder="restock"
|
||||
existingUrl={photoUrl}
|
||||
onUploadingChange={
|
||||
setUploading
|
||||
}
|
||||
onUploadingChange={setUploading}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.photo_key}
|
||||
@ -523,9 +515,9 @@ export default function RestockCreate({ data }: Props) {
|
||||
processing ||
|
||||
uploading ||
|
||||
!selectedProductId ||
|
||||
Object.values(
|
||||
quantities,
|
||||
).every((q) => q <= 0)
|
||||
Object.values(quantities).every(
|
||||
(q) => q <= 0,
|
||||
)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
@ -648,8 +640,7 @@ export default function RestockCreate({ data }: Props) {
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(
|
||||
item.price *
|
||||
item.quantity,
|
||||
item.price * item.quantity,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -20,12 +20,10 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { getTemporaryUrl } from '@/lib/upload';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import {
|
||||
index as restockIndex,
|
||||
update,
|
||||
} from '@/routes/admin/manage/restocks';
|
||||
import { index as restockIndex, update } from '@/routes/admin/manage/restocks';
|
||||
import type { RestockCreateData, RestockForEdit } from './columns';
|
||||
|
||||
type CartLine = {
|
||||
@ -71,9 +69,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
);
|
||||
const [notes, setNotes] = useState(restock.notes ?? '');
|
||||
const [photo, setPhoto] = useState<string | null>(restock.photo_key);
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(
|
||||
restock.photo_url,
|
||||
);
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(restock.photo_url);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||
@ -86,8 +82,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
}, [quantities]);
|
||||
|
||||
const selectedProduct = useMemo(
|
||||
() =>
|
||||
products.find((p) => String(p.id) === selectedProductId) ?? null,
|
||||
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
|
||||
[products, selectedProductId],
|
||||
);
|
||||
|
||||
@ -157,9 +152,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
})();
|
||||
|
||||
function formatQuantity(value: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(value);
|
||||
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function getPayload() {
|
||||
@ -225,8 +218,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
(quantities[
|
||||
variant
|
||||
.id
|
||||
] ??
|
||||
0) > 0
|
||||
] ?? 0) > 0
|
||||
? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
|
||||
: 'flex items-center justify-between gap-3 rounded-lg border p-3'
|
||||
}
|
||||
@ -262,11 +254,9 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
)}{' '}
|
||||
pcs
|
||||
·{' '}
|
||||
{
|
||||
formatCurrency(
|
||||
variant.capital_price,
|
||||
)
|
||||
}
|
||||
{formatCurrency(
|
||||
variant.capital_price,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -354,8 +344,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
onValueChange={(value) =>
|
||||
setStockType(
|
||||
value as
|
||||
| 'good'
|
||||
| 'reject',
|
||||
'good' | 'reject',
|
||||
)
|
||||
}
|
||||
className="flex flex-wrap gap-4"
|
||||
@ -445,9 +434,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
}}
|
||||
folder="restock"
|
||||
existingUrl={photoUrl}
|
||||
onUploadingChange={
|
||||
setUploading
|
||||
}
|
||||
onUploadingChange={setUploading}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.photo_key}
|
||||
@ -460,9 +447,9 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
disabled={
|
||||
processing ||
|
||||
uploading ||
|
||||
Object.values(
|
||||
quantities,
|
||||
).every((q) => q <= 0)
|
||||
Object.values(quantities).every(
|
||||
(q) => q <= 0,
|
||||
)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
@ -585,8 +572,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(
|
||||
item.price *
|
||||
item.quantity,
|
||||
item.price * item.quantity,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { CardTable } from '@/components/card-table';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
create as restockCreate,
|
||||
@ -27,7 +29,6 @@ type Props = {
|
||||
|
||||
export default function RestockIndex({ restocks }: Props) {
|
||||
const [deleting, setDeleting] = useState<Restock | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const expand = useCardTableExpand(true);
|
||||
|
||||
const pagination = {
|
||||
@ -37,45 +38,15 @@ export default function RestockIndex({ restocks }: Props) {
|
||||
total: restocks.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
restockIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
restockIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
restockIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => restockIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -92,19 +63,17 @@ export default function RestockIndex({ restocks }: Props) {
|
||||
<Head title="Restock" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Restock
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={restockCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Restock"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<a href={restockCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardTable
|
||||
data={restocks.data}
|
||||
@ -127,7 +96,7 @@ export default function RestockIndex({ restocks }: Props) {
|
||||
restock={item}
|
||||
index={
|
||||
(pagination.current_page - 1) *
|
||||
pagination.per_page +
|
||||
pagination.per_page +
|
||||
index +
|
||||
1
|
||||
}
|
||||
@ -144,8 +113,8 @@ export default function RestockIndex({ restocks }: Props) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
@ -153,7 +122,6 @@ export default function RestockIndex({ restocks }: Props) {
|
||||
}}
|
||||
title="Hapus Restock"
|
||||
description="Apakah Anda yakin ingin menghapus restock ini? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan."
|
||||
confirmLabel="Hapus"
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,41 +1,12 @@
|
||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { Restock, RestockStockType } from './columns';
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
const STOCK_TYPE_CONFIG: Record<
|
||||
RestockStockType,
|
||||
{ label: string; className: string }
|
||||
@ -71,7 +42,9 @@ export function RestockCardRow({
|
||||
const variantCount = items.length;
|
||||
const productNames = [
|
||||
...new Set(
|
||||
items.map((item) => item.product_variant?.product?.name).filter(Boolean),
|
||||
items
|
||||
.map((item) => item.product_variant?.product?.name)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
const totalQty = items.reduce(
|
||||
@ -152,7 +125,7 @@ export function RestockCardRow({
|
||||
{formatCurrency(restock.subtotal)}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
<span className="text-muted-foreground font-normal">
|
||||
<span className="font-normal text-muted-foreground">
|
||||
Total:{' '}
|
||||
</span>
|
||||
{formatCurrency(restock.total)}
|
||||
@ -160,36 +133,23 @@ export function RestockCardRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(restock)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(restock)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => onEdit(restock),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => onDelete(restock),
|
||||
},
|
||||
]}
|
||||
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -8,48 +7,10 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { Restock } from './columns';
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="block h-10 w-10 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={url}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
||||
const items = restock.restock_items ?? [];
|
||||
|
||||
@ -89,8 +50,10 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.product_variant?.photo_url ? (
|
||||
<VariantPhotoPreview
|
||||
url={item.product_variant.photo_url}
|
||||
<ImagePreviewButton
|
||||
srcs={[
|
||||
item.product_variant.photo_url,
|
||||
]}
|
||||
title={item.product_variant.name}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
|
||||
export type Category = {
|
||||
id: number;
|
||||
@ -44,39 +38,22 @@ export function createCategoryColumns(
|
||||
const category = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(category)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(category),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(category),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
index as categoryIndex,
|
||||
@ -39,7 +35,6 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
const [deleting, setDeleting] = useState<Category | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: categories.current_page,
|
||||
@ -48,54 +43,15 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
||||
total: categories.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
categoryIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
categoryIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
categoryIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => categoryIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -117,12 +73,10 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
||||
<Head title="Kategori" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Kategori
|
||||
</h2>
|
||||
{highlight && (
|
||||
<PageHeader
|
||||
title="Kategori"
|
||||
description={
|
||||
highlight && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Menampilkan kategori dari notifikasi.
|
||||
<button
|
||||
@ -141,9 +95,9 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
||||
Tampilkan semua
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -153,64 +107,62 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Tambah Kategori
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama kategori"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
title="Tambah Kategori"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama kategori"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
title="Edit Kategori"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama kategori"
|
||||
defaultValue={editing.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -225,87 +177,17 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit Kategori
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama kategori"
|
||||
defaultValue={
|
||||
editing.name
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Kategori"
|
||||
description={`Apakah Anda yakin ingin menghapus kategori "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(category) =>
|
||||
`Apakah Anda yakin ingin menghapus kategori "${category.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
|
||||
export type Customer = {
|
||||
id: number;
|
||||
@ -62,39 +56,22 @@ export function createCustomerColumns(
|
||||
const customer = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(customer)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(customer)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(customer),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(customer),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,27 +1,23 @@
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
index as customerIndex,
|
||||
destroy,
|
||||
index as customerIndex,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/master/customers';
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Customer } from './columns';
|
||||
import { createCustomerColumns } from './columns';
|
||||
|
||||
@ -39,7 +35,7 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Customer | null>(null);
|
||||
const [deleting, setDeleting] = useState<Customer | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: customers.current_page,
|
||||
last_page: customers.last_page,
|
||||
@ -47,54 +43,15 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
total: customers.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
customerIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
customerIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
customerIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => customerIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -116,13 +73,9 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
<Head title="Customer" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Customer
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<PageHeader
|
||||
title="Customer"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -132,88 +85,105 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Tambah Customer
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama customer"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number" />
|
||||
<InputError
|
||||
message={
|
||||
errors.phone_number
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address">
|
||||
Alamat
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
title="Tambah Customer"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama customer"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number" />
|
||||
<InputError message={errors.phone_number} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address">Alamat</Label>
|
||||
<Input
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
title="Edit Customer"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama customer"
|
||||
defaultValue={editing.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput
|
||||
name="phone_number"
|
||||
defaultValue={editing.phone_number}
|
||||
/>
|
||||
<InputError message={errors.phone_number} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-address">Alamat</Label>
|
||||
<Input
|
||||
id="edit-address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
defaultValue={editing.address ?? ''}
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -228,115 +198,17 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit Customer
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama customer"
|
||||
defaultValue={
|
||||
editing.name
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number" defaultValue={editing.phone_number} />
|
||||
<InputError
|
||||
message={
|
||||
errors.phone_number
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-address">
|
||||
Alamat
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
defaultValue={
|
||||
editing.address ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Customer"
|
||||
description={`Apakah Anda yakin ingin menghapus customer "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(customer) =>
|
||||
`Apakah Anda yakin ingin menghapus customer "${customer.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { router } from '@inertiajs/react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ChevronRight, Pencil, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export type ProductVariant = {
|
||||
id: number;
|
||||
@ -58,23 +55,12 @@ function getStatusVariant(status: string): string {
|
||||
return variants[status] ?? 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID').format(num);
|
||||
}
|
||||
|
||||
function getFilteredVariants(
|
||||
allVariants: ProductVariant[],
|
||||
searchValue: string,
|
||||
): ProductVariant[] {
|
||||
const query = searchValue.toLowerCase().trim();
|
||||
|
||||
return query
|
||||
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
|
||||
: allVariants;
|
||||
@ -361,39 +347,22 @@ export function createProductColumns(
|
||||
const product = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(product)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(product)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(product),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(product),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Filter, Plus, X } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CardTable } from '@/components/card-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FilterPopover } from '@/components/filter-popover';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Combobox,
|
||||
@ -13,11 +15,6 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from '@/components/ui/combobox';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -25,6 +22,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
create as productCreate,
|
||||
@ -66,11 +64,7 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
product: Product;
|
||||
variant: ProductVariant;
|
||||
} | null>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const expand = useCardTableExpand(true);
|
||||
const hasActiveFilters =
|
||||
filters.status || filters.name || filters.stock || filters.category;
|
||||
|
||||
const pagination = {
|
||||
current_page: products.current_page,
|
||||
@ -79,87 +73,33 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
total: products.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
filterOpen,
|
||||
setFilterOpen,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
applyFilter,
|
||||
clearFilters,
|
||||
} = useServerTable({
|
||||
route: () => productIndex.url(),
|
||||
pagination,
|
||||
filters,
|
||||
filterWithParams: false,
|
||||
});
|
||||
|
||||
const productNames = useMemo(() => {
|
||||
const names = products.data.map((p) => p.name);
|
||||
|
||||
return [...new Set(names)].sort();
|
||||
}, [products.data]);
|
||||
|
||||
const selectedCategory = useMemo(
|
||||
() =>
|
||||
categories.find((c) => String(c.id) === filters.category) ?? null,
|
||||
() => categories.find((c) => String(c.id) === filters.category) ?? null,
|
||||
[categories, filters.category],
|
||||
);
|
||||
|
||||
function applyFilter(key: string, value: string) {
|
||||
const newFilters = { ...filters };
|
||||
|
||||
if (value === '' || value === 'all') {
|
||||
delete newFilters[key as keyof typeof newFilters];
|
||||
} else {
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(productIndex(), newFilters, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(
|
||||
productIndex(),
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
productIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
productIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
productIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, filters],
|
||||
);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
@ -187,153 +127,117 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
}
|
||||
|
||||
const filterToolbar = (
|
||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{Object.values(filters).filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Hapus Semua
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FilterPopover
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
filters={filters}
|
||||
hasActiveFilters={Boolean(
|
||||
filters.status ||
|
||||
filters.name ||
|
||||
filters.stock ||
|
||||
filters.category,
|
||||
)}
|
||||
onClear={clearFilters}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Nama Produk
|
||||
</label>
|
||||
<Combobox
|
||||
items={productNames}
|
||||
value={filters.name ?? ''}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('name', (value as string) ?? '')
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Pilih produk..."
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
Tidak ada produk ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(name) => (
|
||||
<ComboboxItem value={name}>{name}</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Nama Produk
|
||||
</label>
|
||||
<Combobox
|
||||
items={productNames}
|
||||
value={filters.name ?? ''}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('name', (value as string) ?? '')
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Pilih produk..."
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
Tidak ada produk ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(name) => (
|
||||
<ComboboxItem value={name}>
|
||||
{name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">Status</label>
|
||||
<Select
|
||||
value={filters.status ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('status', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="active">Aktif</SelectItem>
|
||||
<SelectItem value="inactive">Non Aktif</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
value={filters.status ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('status', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
Semua Status
|
||||
</SelectItem>
|
||||
<SelectItem value="active">Aktif</SelectItem>
|
||||
<SelectItem value="inactive">
|
||||
Non Aktif
|
||||
</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">Stok</label>
|
||||
<span className="-mt-1 text-[0.65rem] text-muted-foreground/70">
|
||||
Berdasarkan stok bagus
|
||||
</span>
|
||||
<Select
|
||||
value={filters.stock ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('stock', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Stok" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Stok</SelectItem>
|
||||
<SelectItem value="empty">Habis</SelectItem>
|
||||
<SelectItem value="low">
|
||||
Menipis (di bawah 10)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Stok
|
||||
</label>
|
||||
<span className="-mt-1 text-[0.65rem] text-muted-foreground/70">
|
||||
Berdasarkan stok bagus
|
||||
</span>
|
||||
<Select
|
||||
value={filters.stock ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('stock', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Stok" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Stok</SelectItem>
|
||||
<SelectItem value="empty">Habis</SelectItem>
|
||||
<SelectItem value="low">
|
||||
Menipis (di bawah 10)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Kategori
|
||||
</label>
|
||||
<Combobox
|
||||
items={categories}
|
||||
itemToStringLabel={(cat) => cat.name}
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) =>
|
||||
applyFilter(
|
||||
'category',
|
||||
value ? String(value.id) : '',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Pilih kategori..."
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
Tidak ada kategori ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(cat) => (
|
||||
<ComboboxItem value={cat}>
|
||||
{cat.name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Kategori
|
||||
</label>
|
||||
<Combobox
|
||||
items={categories}
|
||||
itemToStringLabel={(cat) => cat.name}
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('category', value ? String(value.id) : '')
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Pilih kategori..."
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
Tidak ada kategori ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(cat) => (
|
||||
<ComboboxItem value={cat}>
|
||||
{cat.name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</FilterPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -341,19 +245,17 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
<Head title="Produk" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Produk
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={productCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Produk"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<a href={productCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardTable
|
||||
data={products.data}
|
||||
@ -406,29 +308,31 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Produk"
|
||||
description={`Apakah Anda yakin ingin menghapus produk "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(product) =>
|
||||
`Apakah Anda yakin ingin menghapus produk "${product.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deletingVariant !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deletingVariant}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeletingVariant(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Varian"
|
||||
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.name}" dari produk "${deletingVariant?.product.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(target) =>
|
||||
`Apakah Anda yakin ingin menghapus varian "${target.variant.name}" dari produk "${target.product.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDeleteVariant}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,26 +1,18 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { ToggleStatus } from '@/components/toggle-status';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import type { Product } from './columns';
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID').format(num);
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
active: 'Aktif',
|
||||
inactive: 'Non Aktif',
|
||||
draft: 'Draft',
|
||||
};
|
||||
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
@ -30,6 +22,7 @@ function getStatusVariant(status: string): string {
|
||||
inactive: 'bg-red-100 text-red-800',
|
||||
draft: 'bg-yellow-100 text-yellow-800',
|
||||
};
|
||||
|
||||
return variants[status] ?? 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
@ -68,10 +61,6 @@ export function ProductCardRow({
|
||||
product.status === 'active' || product.status === 'inactive';
|
||||
const isChecked = product.status === 'active';
|
||||
|
||||
function handleToggle() {
|
||||
router.post(toggleStatusUrl(product.id), {}, { preserveScroll: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
@ -138,18 +127,11 @@ export function ProductCardRow({
|
||||
|
||||
<div className="mt-2">
|
||||
{isToggleable ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}
|
||||
>
|
||||
{getStatusLabel(product.status)}
|
||||
</span>
|
||||
</div>
|
||||
<ToggleStatus
|
||||
url={toggleStatusUrl(product.id)}
|
||||
checked={isChecked}
|
||||
label={getStatusLabel(product.status)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}
|
||||
@ -160,36 +142,23 @@ export function ProductCardRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(product)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(product)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => onEdit(product),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => onDelete(product),
|
||||
},
|
||||
]}
|
||||
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
|
||||
import { stockMutations } from '@/routes/admin/master/products/variants';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowDown,
|
||||
@ -13,8 +9,12 @@ import {
|
||||
Pencil,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { index as productIndex } from '@/routes/admin/master/products';
|
||||
import { stockMutations } from '@/routes/admin/master/products/variants';
|
||||
|
||||
type Mutation = {
|
||||
id: number;
|
||||
@ -47,10 +47,6 @@ type Props = {
|
||||
mutations: PaginatedMutations;
|
||||
};
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID').format(num);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Intl.DateTimeFormat('id-ID', {
|
||||
day: 'numeric',
|
||||
@ -67,6 +63,7 @@ function getQualityLabel(quality: string): string {
|
||||
reject: 'Reject',
|
||||
retail: 'Ecer',
|
||||
};
|
||||
|
||||
return labels[quality] ?? quality;
|
||||
}
|
||||
|
||||
@ -76,6 +73,7 @@ function getQualityColor(quality: string): string {
|
||||
reject: 'bg-red-100 text-red-800',
|
||||
retail: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
|
||||
return colors[quality] ?? 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
@ -83,14 +81,27 @@ function getTypeLabel(type: string, quantity: number): string {
|
||||
if (type === 'in') {
|
||||
return quantity >= 0 ? 'Penambahan' : 'Pengurangan';
|
||||
}
|
||||
|
||||
return quantity < 0 ? 'Pengurangan' : 'Penambahan';
|
||||
}
|
||||
|
||||
function getMutationTitle(description: string | null): string {
|
||||
if (!description) return 'Perubahan Stok';
|
||||
if (description.includes('Transfer stok')) return 'Transfer Stok';
|
||||
if (description.includes('Stok awal')) return 'Stok Awal';
|
||||
if (description.includes('Penyesuaian stok')) return 'Edit Varian';
|
||||
if (!description) {
|
||||
return 'Perubahan Stok';
|
||||
}
|
||||
|
||||
if (description.includes('Transfer stok')) {
|
||||
return 'Transfer Stok';
|
||||
}
|
||||
|
||||
if (description.includes('Stok awal')) {
|
||||
return 'Stok Awal';
|
||||
}
|
||||
|
||||
if (description.includes('Penyesuaian stok')) {
|
||||
return 'Edit Varian';
|
||||
}
|
||||
|
||||
return 'Perubahan Stok';
|
||||
}
|
||||
|
||||
@ -98,12 +109,15 @@ function getMutationIcon(description: string | null): React.ReactNode {
|
||||
if (description?.includes('Transfer stok')) {
|
||||
return <ArrowRightLeft className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (description?.includes('Stok awal')) {
|
||||
return <Package className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (description?.includes('Penyesuaian stok')) {
|
||||
return <Pencil className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
return <ScrollText className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
@ -114,7 +128,10 @@ export default function StockMutationsPage({
|
||||
}: Props) {
|
||||
const { items, loading, hasNextPage, sentinelRef } = useInfiniteScroll({
|
||||
initialData: mutations,
|
||||
fetchUrl: stockMutations.url({ product: product.id, variant: variant.id }),
|
||||
fetchUrl: stockMutations.url({
|
||||
product: product.id,
|
||||
variant: variant.id,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
@ -152,13 +169,17 @@ export default function StockMutationsPage({
|
||||
) : (
|
||||
items.map((mutation) => {
|
||||
const isPositive = mutation.quantity > 0;
|
||||
const qualityColor = getQualityColor(mutation.stock_quality);
|
||||
const qualityColor = getQualityColor(
|
||||
mutation.stock_quality,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={mutation.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${isPositive ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
|
||||
<div
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${isPositive ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
) : (
|
||||
@ -169,28 +190,54 @@ export default function StockMutationsPage({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{getMutationTitle(mutation.description)}
|
||||
{getMutationTitle(
|
||||
mutation.description,
|
||||
)}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${qualityColor}`}>
|
||||
{getQualityLabel(mutation.stock_quality)}
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${qualityColor}`}
|
||||
>
|
||||
{getQualityLabel(
|
||||
mutation.stock_quality,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{formatDate(mutation.created_at)}
|
||||
{formatDate(
|
||||
mutation.created_at,
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{mutation.user?.full_name ?? mutation.user?.username}
|
||||
{mutation.user
|
||||
?.full_name ??
|
||||
mutation.user
|
||||
?.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<span className={isPositive ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
|
||||
{isPositive ? '+' : ''}{formatNumber(mutation.quantity)}
|
||||
<span
|
||||
className={
|
||||
isPositive
|
||||
? 'font-medium text-green-600'
|
||||
: 'font-medium text-red-600'
|
||||
}
|
||||
>
|
||||
{isPositive ? '+' : ''}
|
||||
{formatNumber(
|
||||
mutation.quantity,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatNumber(mutation.stock_before)} → {formatNumber(mutation.stock_after)}
|
||||
{formatNumber(
|
||||
mutation.stock_before,
|
||||
)}{' '}
|
||||
→{' '}
|
||||
{formatNumber(
|
||||
mutation.stock_after,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -208,7 +255,10 @@ export default function StockMutationsPage({
|
||||
)}
|
||||
|
||||
{hasNextPage && (
|
||||
<div ref={sentinelRef} className="flex items-center justify-center py-4">
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
className="flex items-center justify-center py-4"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -10,59 +10,12 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { Product, ProductVariant } from '../columns';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { stockMutations } from '@/routes/admin/master/products/variants';
|
||||
import type { Product, ProductVariant } from '../columns';
|
||||
import { TransferStockDialog } from './transfer-stock-dialog';
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID').format(num);
|
||||
}
|
||||
|
||||
function VariantPhotoPreview({ urls, title }: { urls: string[]; title: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="relative block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={urls[0]}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
{urls.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={urls[0]}
|
||||
sources={urls}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VariantSubRow({
|
||||
product,
|
||||
onEditVariant,
|
||||
@ -119,8 +72,8 @@ export function VariantSubRow({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{variant.photo_urls?.length > 0 ? (
|
||||
<VariantPhotoPreview
|
||||
urls={variant.photo_urls}
|
||||
<ImagePreviewButton
|
||||
srcs={variant.photo_urls}
|
||||
title={variant.name}
|
||||
/>
|
||||
) : (
|
||||
@ -161,86 +114,56 @@ export function VariantSubRow({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setTransferVariant({
|
||||
product,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Transfer Stok
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
window.location.href = stockMutations.url({
|
||||
product: product.id,
|
||||
variant: variant.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ScrollText className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Mutasi Stok
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
onEditVariant(
|
||||
product,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
onDeleteVariantClick(
|
||||
product,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Transfer Stok',
|
||||
icon: (
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
),
|
||||
onClick: () =>
|
||||
setTransferVariant({
|
||||
product,
|
||||
variant,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Mutasi Stok',
|
||||
icon: (
|
||||
<ScrollText className="h-4 w-4" />
|
||||
),
|
||||
onClick: () => {
|
||||
window.location.href =
|
||||
stockMutations.url({
|
||||
product: product.id,
|
||||
variant: variant.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: (
|
||||
<Pencil className="h-4 w-4" />
|
||||
),
|
||||
onClick: () =>
|
||||
onEditVariant(
|
||||
product,
|
||||
variant,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () =>
|
||||
onDeleteVariantClick(
|
||||
product,
|
||||
variant,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@ -1,3 +1,13 @@
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { FileUpload } from '@/components/file-upload';
|
||||
import InputError from '@/components/input-error';
|
||||
@ -9,25 +19,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
|
||||
import { UNITS } from '@/lib/constants';
|
||||
import { loadRawMaterialDraft } from '@/lib/raw-material-draft';
|
||||
import { getTemporaryUrl } from '@/lib/upload';
|
||||
import { index as rawMaterialIndex, store } from '@/routes/admin/master/raw-materials';
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const UNITS = [
|
||||
{ value: 'kg', label: 'Kilogram' },
|
||||
{ value: 'meter', label: 'Meter' },
|
||||
{ value: 'yard', label: 'Yard' },
|
||||
];
|
||||
index as rawMaterialIndex,
|
||||
store,
|
||||
} from '@/routes/admin/master/raw-materials';
|
||||
|
||||
type VariantState = {
|
||||
variant: string;
|
||||
@ -110,6 +108,7 @@ export default function RawMaterialCreate() {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
@ -118,7 +117,9 @@ export default function RawMaterialCreate() {
|
||||
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(null);
|
||||
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const confirmRemoveVariant = useCallback((index: number) => {
|
||||
setDeleteVariantIndex(index);
|
||||
@ -131,6 +132,7 @@ export default function RawMaterialCreate() {
|
||||
navigator.clipboard.writeText(String(price));
|
||||
setCopiedIndex(variantIndex);
|
||||
setTimeout(() => setCopiedIndex(null), 1500);
|
||||
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
@ -139,10 +141,15 @@ export default function RawMaterialCreate() {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
try {
|
||||
const price = Number(text);
|
||||
|
||||
if (!isNaN(price)) {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = { ...updated[variantIndex], price };
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
price,
|
||||
};
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@ -155,6 +162,7 @@ export default function RawMaterialCreate() {
|
||||
const applyToAll = useCallback((variantIndex: number) => {
|
||||
setVariants((prev) => {
|
||||
const sourcePrice = prev[variantIndex].price;
|
||||
|
||||
return prev.map((v, i) =>
|
||||
i === variantIndex ? v : { ...v, price: sourcePrice },
|
||||
);
|
||||
@ -204,26 +212,35 @@ export default function RawMaterialCreate() {
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Bahan Baku</CardTitle>
|
||||
<CardTitle>
|
||||
Informasi Bahan Baku
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Bahan Baku{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
placeholder="Masukkan nama bahan baku"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Satuan <span className="text-destructive">*</span>
|
||||
Satuan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RadioGroup
|
||||
name="unit"
|
||||
@ -232,9 +249,18 @@ export default function RawMaterialCreate() {
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
{UNITS.map((u) => (
|
||||
<div key={u.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={u.value} id={`unit-${u.value}`} />
|
||||
<Label htmlFor={`unit-${u.value}`} className="font-normal">
|
||||
<div
|
||||
key={u.value}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={u.value}
|
||||
id={`unit-${u.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`unit-${u.value}`}
|
||||
className="font-normal"
|
||||
>
|
||||
{u.label}
|
||||
</Label>
|
||||
</div>
|
||||
@ -242,7 +268,6 @@ export default function RawMaterialCreate() {
|
||||
</RadioGroup>
|
||||
<InputError message={errors.unit} />
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -251,136 +276,224 @@ export default function RawMaterialCreate() {
|
||||
<CardTitle>Varian Bahan Baku</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{variants.map((variant, variantIndex) => (
|
||||
<div
|
||||
key={variantIndex}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 className="font-medium">
|
||||
Varian {variantIndex + 1}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => copyPrice(variantIndex)}
|
||||
>
|
||||
{copiedIndex === variantIndex ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
Salin Harga
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => pastePrice(variantIndex)}
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
Tempel Harga
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => applyToAll(variantIndex)}
|
||||
>
|
||||
Terapkan ke Semua
|
||||
</Button>
|
||||
{variantIndex > 0 && (
|
||||
{variants.map(
|
||||
(variant, variantIndex) => (
|
||||
<div
|
||||
key={variantIndex}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 className="font-medium">
|
||||
Varian{' '}
|
||||
{variantIndex + 1}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => confirmRemoveVariant(variantIndex)}
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() =>
|
||||
copyPrice(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
{copiedIndex ===
|
||||
variantIndex ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
Salin Harga
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() =>
|
||||
pastePrice(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
Tempel Harga
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() =>
|
||||
applyToAll(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
Terapkan ke
|
||||
Semua
|
||||
</Button>
|
||||
{variantIndex >
|
||||
0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
confirmRemoveVariant(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Varian{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={
|
||||
variant.variant
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'variant',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
placeholder="Contoh: Ukuran L, Warna Merah"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`variants.${variantIndex}.variant`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Harga{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
value={
|
||||
variant.price
|
||||
}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'price',
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`variants.${variantIndex}.price`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Stok{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<NumberInput
|
||||
min={0}
|
||||
value={
|
||||
variant.stock
|
||||
}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'stock',
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`variants.${variantIndex}.stock`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Varian{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
Foto Varian{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={variant.variant}
|
||||
onChange={(e) =>
|
||||
updateVariant(variantIndex, 'variant', e.target.value)
|
||||
<FileUpload
|
||||
value={
|
||||
variant.photo
|
||||
}
|
||||
placeholder="Contoh: Ukuran L, Warna Merah"
|
||||
/>
|
||||
<InputError
|
||||
message={errors[`variants.${variantIndex}.variant`]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Harga <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
value={variant.price}
|
||||
onValueChange={(val) =>
|
||||
updateVariant(variantIndex, 'price', val)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors[`variants.${variantIndex}.price`]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Stok <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<NumberInput
|
||||
min={0}
|
||||
value={variant.stock}
|
||||
onValueChange={(val) =>
|
||||
onChange={(key) => {
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'stock',
|
||||
val,
|
||||
'photo',
|
||||
key,
|
||||
);
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'photoUrl',
|
||||
key
|
||||
? getTemporaryUrl(
|
||||
key,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
folder="raw-material-variant"
|
||||
existingUrl={
|
||||
variant.photoUrl
|
||||
}
|
||||
onUploadingChange={(
|
||||
uploading,
|
||||
) =>
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'uploading',
|
||||
uploading,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors[`variants.${variantIndex}.stock`]}
|
||||
message={
|
||||
errors[
|
||||
`variants.${variantIndex}.photo_key`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Foto Varian <span className="text-destructive">*</span></Label>
|
||||
<FileUpload
|
||||
value={variant.photo}
|
||||
onChange={(key) => {
|
||||
updateVariant(variantIndex, 'photo', key);
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'photoUrl',
|
||||
key ? getTemporaryUrl(key) : null,
|
||||
);
|
||||
}}
|
||||
folder="raw-material-variant"
|
||||
existingUrl={variant.photoUrl}
|
||||
onUploadingChange={(uploading) =>
|
||||
updateVariant(variantIndex, 'uploading', uploading)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors[`variants.${variantIndex}.photo_key`]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
@ -397,7 +510,10 @@ export default function RawMaterialCreate() {
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || variants.some((v) => v.uploading)}
|
||||
disabled={
|
||||
processing ||
|
||||
variants.some((v) => v.uploading)
|
||||
}
|
||||
>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
@ -421,6 +537,7 @@ export default function RawMaterialCreate() {
|
||||
if (deleteVariantIndex !== null) {
|
||||
removeVariant(deleteVariantIndex);
|
||||
}
|
||||
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteVariantIndex(null);
|
||||
}}
|
||||
|
||||
@ -1,3 +1,13 @@
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { FileUpload } from '@/components/file-upload';
|
||||
import InputError from '@/components/input-error';
|
||||
@ -9,27 +19,15 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
|
||||
import { UNITS } from '@/lib/constants';
|
||||
import { clearRawMaterialDraft } from '@/lib/raw-material-draft';
|
||||
import { getTemporaryUrl } from '@/lib/upload';
|
||||
import { index as rawMaterialIndex, update } from '@/routes/admin/master/raw-materials';
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
index as rawMaterialIndex,
|
||||
update,
|
||||
} from '@/routes/admin/master/raw-materials';
|
||||
import type { RawMaterialForEdit, RawMaterialVariantForEdit } from './columns';
|
||||
|
||||
const UNITS = [
|
||||
{ value: 'kg', label: 'Kilogram' },
|
||||
{ value: 'meter', label: 'Meter' },
|
||||
{ value: 'yard', label: 'Yard' },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
rawMaterial: RawMaterialForEdit;
|
||||
};
|
||||
@ -124,6 +122,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
@ -132,7 +131,9 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(null);
|
||||
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const confirmRemoveVariant = useCallback((index: number) => {
|
||||
setDeleteVariantIndex(index);
|
||||
@ -145,6 +146,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
navigator.clipboard.writeText(String(price));
|
||||
setCopiedIndex(variantIndex);
|
||||
setTimeout(() => setCopiedIndex(null), 1500);
|
||||
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
@ -153,10 +155,15 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
try {
|
||||
const price = Number(text);
|
||||
|
||||
if (!isNaN(price)) {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = { ...updated[variantIndex], price };
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
price,
|
||||
};
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@ -169,6 +176,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
const applyToAll = useCallback((variantIndex: number) => {
|
||||
setVariants((prev) => {
|
||||
const sourcePrice = prev[variantIndex].price;
|
||||
|
||||
return prev.map((v, i) =>
|
||||
i === variantIndex ? v : { ...v, price: sourcePrice },
|
||||
);
|
||||
@ -220,26 +228,35 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Bahan Baku</CardTitle>
|
||||
<CardTitle>
|
||||
Informasi Bahan Baku
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Bahan Baku{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
placeholder="Masukkan nama bahan baku"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Satuan <span className="text-destructive">*</span>
|
||||
Satuan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RadioGroup
|
||||
name="unit"
|
||||
@ -248,9 +265,18 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
{UNITS.map((u) => (
|
||||
<div key={u.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={u.value} id={`unit-${u.value}`} />
|
||||
<Label htmlFor={`unit-${u.value}`} className="font-normal">
|
||||
<div
|
||||
key={u.value}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={u.value}
|
||||
id={`unit-${u.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`unit-${u.value}`}
|
||||
className="font-normal"
|
||||
>
|
||||
{u.label}
|
||||
</Label>
|
||||
</div>
|
||||
@ -258,7 +284,6 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
</RadioGroup>
|
||||
<InputError message={errors.unit} />
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -267,141 +292,226 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
<CardTitle>Varian Bahan Baku</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{variants.map((variant, variantIndex) => (
|
||||
<div
|
||||
key={variantIndex}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 className="font-medium">
|
||||
Varian {variantIndex + 1}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => copyPrice(variantIndex)}
|
||||
>
|
||||
{copiedIndex === variantIndex ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
Salin Harga
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => pastePrice(variantIndex)}
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
Tempel Harga
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => applyToAll(variantIndex)}
|
||||
>
|
||||
Terapkan ke Semua
|
||||
</Button>
|
||||
{variantIndex > 0 && (
|
||||
{variants.map(
|
||||
(variant, variantIndex) => (
|
||||
<div
|
||||
key={variantIndex}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 className="font-medium">
|
||||
Varian{' '}
|
||||
{variantIndex + 1}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => confirmRemoveVariant(variantIndex)}
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() =>
|
||||
copyPrice(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
{copiedIndex ===
|
||||
variantIndex ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
Salin Harga
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() =>
|
||||
pastePrice(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
Tempel Harga
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() =>
|
||||
applyToAll(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
Terapkan ke
|
||||
Semua
|
||||
</Button>
|
||||
{variantIndex >
|
||||
0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
confirmRemoveVariant(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Varian{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={variant.variant}
|
||||
onChange={(e) =>
|
||||
updateVariant(variantIndex, 'variant', e.target.value)
|
||||
}
|
||||
placeholder="Contoh: Ukuran L, Warna Merah"
|
||||
/>
|
||||
<InputError
|
||||
message={errors[`variants.${variantIndex}.variant`]}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Varian{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={
|
||||
variant.variant
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'variant',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
placeholder="Contoh: Ukuran L, Warna Merah"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`variants.${variantIndex}.variant`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Harga{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
value={
|
||||
variant.price
|
||||
}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'price',
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`variants.${variantIndex}.price`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Stok{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<NumberInput
|
||||
min={0}
|
||||
value={
|
||||
Number(
|
||||
variant.stock,
|
||||
) || 0
|
||||
}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'stock',
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`variants.${variantIndex}.stock`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Harga <span className="text-destructive">*</span>
|
||||
Foto Varian{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
value={variant.price}
|
||||
onValueChange={(val) =>
|
||||
updateVariant(variantIndex, 'price', val)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors[`variants.${variantIndex}.price`]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Stok <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<NumberInput
|
||||
min={0}
|
||||
<FileUpload
|
||||
value={
|
||||
Number(variant.stock) ||
|
||||
0
|
||||
variant.photo
|
||||
}
|
||||
onValueChange={(val) =>
|
||||
onChange={(key) => {
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'stock',
|
||||
val,
|
||||
'photo',
|
||||
key,
|
||||
);
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'photoUrl',
|
||||
key
|
||||
? getTemporaryUrl(
|
||||
key,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
folder="raw-material-variant"
|
||||
existingUrl={
|
||||
variant.photoUrl
|
||||
}
|
||||
onUploadingChange={(
|
||||
uploading,
|
||||
) =>
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'uploading',
|
||||
uploading,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors[`variants.${variantIndex}.stock`]}
|
||||
message={
|
||||
errors[
|
||||
`variants.${variantIndex}.photo_key`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Foto Varian <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<FileUpload
|
||||
value={variant.photo}
|
||||
onChange={(key) => {
|
||||
updateVariant(variantIndex, 'photo', key);
|
||||
updateVariant(
|
||||
variantIndex,
|
||||
'photoUrl',
|
||||
key ? getTemporaryUrl(key) : null,
|
||||
);
|
||||
}}
|
||||
folder="raw-material-variant"
|
||||
existingUrl={variant.photoUrl}
|
||||
onUploadingChange={(uploading) =>
|
||||
updateVariant(variantIndex, 'uploading', uploading)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors[`variants.${variantIndex}.photo_key`]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
@ -418,7 +528,10 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || variants.some((v) => v.uploading)}
|
||||
disabled={
|
||||
processing ||
|
||||
variants.some((v) => v.uploading)
|
||||
}
|
||||
>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
@ -442,6 +555,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
||||
if (deleteVariantIndex !== null) {
|
||||
removeVariant(deleteVariantIndex);
|
||||
}
|
||||
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteVariantIndex(null);
|
||||
}}
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Filter, Plus, X } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CardTable } from '@/components/card-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FilterPopover } from '@/components/filter-popover';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -17,6 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
create as rawMaterialCreate,
|
||||
@ -52,10 +50,7 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
||||
rawMaterial: RawMaterial;
|
||||
variant: RawMaterialVariant;
|
||||
} | null>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const expand = useCardTableExpand(true);
|
||||
const hasActiveFilters = filters.is_active || filters.stock;
|
||||
|
||||
const pagination = {
|
||||
current_page: rawMaterials.current_page,
|
||||
@ -64,78 +59,26 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
||||
total: rawMaterials.total,
|
||||
};
|
||||
|
||||
function applyFilter(key: string, value: string) {
|
||||
const newFilters = { ...filters };
|
||||
|
||||
if (value === '' || value === 'all') {
|
||||
delete newFilters[key as keyof typeof newFilters];
|
||||
} else {
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(rawMaterialIndex(), newFilters, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(
|
||||
rawMaterialIndex(),
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
rawMaterialIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
rawMaterialIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
rawMaterialIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, filters],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
filterOpen,
|
||||
setFilterOpen,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
applyFilter,
|
||||
clearFilters,
|
||||
} = useServerTable({
|
||||
route: () => rawMaterialIndex.url(),
|
||||
pagination,
|
||||
filters,
|
||||
filterWithParams: false,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) return;
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy.url(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
@ -143,7 +86,9 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
||||
}
|
||||
|
||||
function handleDeleteVariant() {
|
||||
if (!deletingVariant) return;
|
||||
if (!deletingVariant) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(
|
||||
variantDestroy.url({
|
||||
@ -157,75 +102,49 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
||||
}
|
||||
|
||||
const filterToolbar = (
|
||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{Object.values(filters).filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Hapus Semua
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FilterPopover
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
filters={filters}
|
||||
hasActiveFilters={Boolean(filters.is_active || filters.stock)}
|
||||
onClear={clearFilters}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">Status</label>
|
||||
<Select
|
||||
value={filters.is_active ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('is_active', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="true">Aktif</SelectItem>
|
||||
<SelectItem value="false">Non Aktif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
value={filters.is_active ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('is_active', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="true">Aktif</SelectItem>
|
||||
<SelectItem value="false">Non Aktif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Stok
|
||||
</label>
|
||||
<Select
|
||||
value={filters.stock ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('stock', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Stok" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Stok</SelectItem>
|
||||
<SelectItem value="empty">Habis</SelectItem>
|
||||
<SelectItem value="low">Menipis (di bawah 10)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">Stok</label>
|
||||
<Select
|
||||
value={filters.stock ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('stock', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Stok" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Stok</SelectItem>
|
||||
<SelectItem value="empty">Habis</SelectItem>
|
||||
<SelectItem value="low">
|
||||
Menipis (di bawah 10)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FilterPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -233,19 +152,17 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
||||
<Head title="Bahan Baku" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Bahan Baku
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={rawMaterialCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Bahan Baku"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<a href={rawMaterialCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardTable
|
||||
data={rawMaterials.data}
|
||||
@ -276,7 +193,9 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onEdit={(rm) => {
|
||||
window.location.href = rawMaterialEdit.url(rm.id);
|
||||
window.location.href = rawMaterialEdit.url(
|
||||
rm.id,
|
||||
);
|
||||
}}
|
||||
onDelete={(rm) => setDeleting(rm)}
|
||||
toggleStatusUrl={(id) => toggleStatus.url(id)}
|
||||
@ -287,29 +206,31 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Bahan Baku"
|
||||
description={`Apakah Anda yakin ingin menghapus bahan baku "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(rawMaterial) =>
|
||||
`Apakah Anda yakin ingin menghapus bahan baku "${rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deletingVariant !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deletingVariant}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeletingVariant(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Varian"
|
||||
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.variant}" dari bahan baku "${deletingVariant?.rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(target) =>
|
||||
`Apakah Anda yakin ingin menghapus varian "${target.variant.variant}" dari bahan baku "${target.rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDeleteVariant}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,28 +1,12 @@
|
||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { ToggleStatus } from '@/components/toggle-status';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { RawMaterial } from './columns';
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', { maximumFractionDigits: 4 }).format(num);
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export type RawMaterialCardRowParams = {
|
||||
rawMaterial: RawMaterial;
|
||||
index: number;
|
||||
@ -43,12 +27,14 @@ export function RawMaterialCardRow({
|
||||
toggleStatusUrl,
|
||||
}: RawMaterialCardRowParams) {
|
||||
const variants = rawMaterial.raw_material_prices ?? [];
|
||||
const totalStock = variants.reduce((sum, v) => sum + (Number(v.stock) || 0), 0);
|
||||
const totalValue = variants.reduce((sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0), 0);
|
||||
|
||||
function handleToggle() {
|
||||
router.post(toggleStatusUrl(rawMaterial.id), {}, { preserveScroll: true });
|
||||
}
|
||||
const totalStock = variants.reduce(
|
||||
(sum, v) => sum + (Number(v.stock) || 0),
|
||||
0,
|
||||
);
|
||||
const totalValue = variants.reduce(
|
||||
(sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
@ -94,51 +80,35 @@ export function RawMaterialCardRow({
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={rawMaterial.is_active}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${rawMaterial.is_active ? 'text-green-700' : 'text-red-700'}`}
|
||||
>
|
||||
{rawMaterial.is_active ? 'Aktif' : 'Non Aktif'}
|
||||
</span>
|
||||
</div>
|
||||
<ToggleStatus
|
||||
url={toggleStatusUrl(rawMaterial.id)}
|
||||
checked={rawMaterial.is_active}
|
||||
label={
|
||||
rawMaterial.is_active
|
||||
? 'Aktif'
|
||||
: 'Non Aktif'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(rawMaterial)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(rawMaterial)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => onEdit(rawMaterial),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => onDelete(rawMaterial),
|
||||
},
|
||||
]}
|
||||
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -2,8 +2,8 @@ import { router } from '@inertiajs/react';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -12,52 +12,13 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
edit as variantEdit,
|
||||
destroy as variantDestroy,
|
||||
} from '@/routes/admin/master/raw-materials/variants';
|
||||
import type { RawMaterial, RawMaterialVariant } from '../columns';
|
||||
import { edit as variantEdit, destroy as variantDestroy } from '@/routes/admin/master/raw-materials/variants';
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', { maximumFractionDigits: 4 }).format(num);
|
||||
}
|
||||
|
||||
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="relative block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={url}
|
||||
sources={[url]}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawMaterialVariantSubRow({
|
||||
rawMaterial,
|
||||
@ -65,10 +26,13 @@ export function RawMaterialVariantSubRow({
|
||||
rawMaterial: RawMaterial;
|
||||
}) {
|
||||
const variants = rawMaterial.raw_material_prices ?? [];
|
||||
const [deletingVariant, setDeletingVariant] = useState<RawMaterialVariant | null>(null);
|
||||
const [deletingVariant, setDeletingVariant] =
|
||||
useState<RawMaterialVariant | null>(null);
|
||||
|
||||
function handleDeleteVariant() {
|
||||
if (!deletingVariant) return;
|
||||
if (!deletingVariant) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(
|
||||
variantDestroy.url({
|
||||
@ -116,8 +80,8 @@ export function RawMaterialVariantSubRow({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{variant.photo_url ? (
|
||||
<VariantPhotoPreview
|
||||
url={variant.photo_url}
|
||||
<ImagePreviewButton
|
||||
srcs={[variant.photo_url]}
|
||||
title={variant.variant}
|
||||
/>
|
||||
) : (
|
||||
@ -136,45 +100,32 @@ export function RawMaterialVariantSubRow({
|
||||
{formatNumber(variant.stock)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
window.location.href = variantEdit.url({
|
||||
rawMaterial: rawMaterial.id,
|
||||
variant: variant.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setDeletingVariant(variant)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: (
|
||||
<Pencil className="h-4 w-4" />
|
||||
),
|
||||
onClick: () => {
|
||||
window.location.href =
|
||||
variantEdit.url({
|
||||
rawMaterial:
|
||||
rawMaterial.id,
|
||||
variant: variant.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () =>
|
||||
setDeletingVariant(variant),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
|
||||
export type Supplier = {
|
||||
id: number;
|
||||
@ -62,39 +56,22 @@ export function createSupplierColumns(
|
||||
const supplier = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(supplier)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(supplier)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(supplier),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(supplier),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,27 +1,23 @@
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
store,
|
||||
index as supplierIndex,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/master/suppliers';
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Supplier } from './columns';
|
||||
import { createSupplierColumns } from './columns';
|
||||
|
||||
@ -39,7 +35,7 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Supplier | null>(null);
|
||||
const [deleting, setDeleting] = useState<Supplier | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: suppliers.current_page,
|
||||
last_page: suppliers.last_page,
|
||||
@ -47,54 +43,15 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
total: suppliers.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
supplierIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
supplierIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
supplierIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => supplierIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -116,13 +73,9 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
<Head title="Supplier" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Supplier
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<PageHeader
|
||||
title="Supplier"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -132,88 +85,105 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Tambah Supplier
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama supplier"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number" />
|
||||
<InputError
|
||||
message={
|
||||
errors.phone_number
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address">
|
||||
Alamat
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
title="Tambah Supplier"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama supplier"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number" />
|
||||
<InputError message={errors.phone_number} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address">Alamat</Label>
|
||||
<Input
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
<FormDialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
title="Edit Supplier"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama supplier"
|
||||
defaultValue={editing.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput
|
||||
name="phone_number"
|
||||
defaultValue={editing.phone_number}
|
||||
/>
|
||||
<InputError message={errors.phone_number} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-address">Alamat</Label>
|
||||
<Input
|
||||
id="edit-address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
defaultValue={editing.address ?? ''}
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -228,115 +198,17 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit Supplier
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama supplier"
|
||||
defaultValue={
|
||||
editing.name
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number" defaultValue={editing.phone_number} />
|
||||
<InputError
|
||||
message={
|
||||
errors.phone_number
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-address">
|
||||
Alamat
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
defaultValue={
|
||||
editing.address ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Supplier"
|
||||
description={`Apakah Anda yakin ingin menghapus supplier "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(supplier) =>
|
||||
`Apakah Anda yakin ingin menghapus supplier "${supplier.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
|
||||
export type Role = {
|
||||
id: number;
|
||||
@ -60,37 +54,22 @@ export function createRoleColumns(
|
||||
const role = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(role)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(role)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(role),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(role),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
index as rolesIndex,
|
||||
create as roleCreate,
|
||||
@ -26,7 +28,7 @@ type Props = {
|
||||
|
||||
export default function RoleIndex({ roles }: Props) {
|
||||
const [deleting, setDeleting] = useState<Role | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: roles.current_page,
|
||||
last_page: roles.last_page,
|
||||
@ -34,45 +36,15 @@ export default function RoleIndex({ roles }: Props) {
|
||||
total: roles.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
rolesIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
rolesIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
rolesIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => rolesIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
@ -96,19 +68,17 @@ export default function RoleIndex({ roles }: Props) {
|
||||
<Head title="Role & Permission" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Role & Permission
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={roleCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Role & Permission"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<a href={roleCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@ -123,16 +93,17 @@ export default function RoleIndex({ roles }: Props) {
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Role"
|
||||
description={`Apakah Anda yakin ingin menghapus role "${deleting?.name}"? Semua user dengan role ini akan kehilangan permission terkait.`}
|
||||
confirmLabel="Hapus"
|
||||
description={(role) =>
|
||||
`Apakah Anda yakin ingin menghapus role "${role.name}"? Semua user dengan role ini akan kehilangan permission terkait.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user