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 {
|
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||||
clearProductDraft,
|
import { clearProductDraft, saveProductDraft } from '@/lib/product-draft';
|
||||||
saveProductDraft,
|
import type { ProductDraftData } from '@/lib/product-draft';
|
||||||
type ProductDraftData,
|
|
||||||
} from '@/lib/product-draft';
|
|
||||||
import { router } from '@inertiajs/react';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
type DraftType = 'create' | 'edit';
|
type DraftType = 'create' | 'edit';
|
||||||
|
|
||||||
@ -15,55 +11,12 @@ export function useProductDraftSave(
|
|||||||
productId?: number,
|
productId?: number,
|
||||||
delay = 500,
|
delay = 500,
|
||||||
) {
|
) {
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
return useDraftSave({
|
||||||
const dataRef = useRef(data);
|
type,
|
||||||
dataRef.current = data;
|
data,
|
||||||
const submittedRef = useRef(false);
|
userId,
|
||||||
|
extraId: productId,
|
||||||
useEffect(() => {
|
delay,
|
||||||
const offBefore = router.on('before', (event) => {
|
store: { save: saveProductDraft, clear: clearProductDraft },
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import {
|
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||||
clearPurchaseDraft,
|
import { clearPurchaseDraft, savePurchaseDraft } from '@/lib/purchase-draft';
|
||||||
savePurchaseDraft,
|
import type { PurchaseDraftData } from '@/lib/purchase-draft';
|
||||||
type PurchaseDraftData,
|
|
||||||
} from '@/lib/purchase-draft';
|
|
||||||
import { router } from '@inertiajs/react';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
type DraftType = 'create' | 'edit';
|
type DraftType = 'create' | 'edit';
|
||||||
|
|
||||||
@ -14,55 +10,11 @@ export function usePurchaseDraftSave(
|
|||||||
userId?: number,
|
userId?: number,
|
||||||
delay = 500,
|
delay = 500,
|
||||||
) {
|
) {
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
return useDraftSave({
|
||||||
const dataRef = useRef(data);
|
type,
|
||||||
dataRef.current = data;
|
data,
|
||||||
const submittedRef = useRef(false);
|
userId,
|
||||||
|
delay,
|
||||||
useEffect(() => {
|
store: { save: savePurchaseDraft, clear: clearPurchaseDraft },
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
|
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||||
import {
|
import {
|
||||||
clearRawMaterialDraft,
|
clearRawMaterialDraft,
|
||||||
saveRawMaterialDraft,
|
saveRawMaterialDraft,
|
||||||
type RawMaterialDraftData,
|
|
||||||
} from '@/lib/raw-material-draft';
|
} from '@/lib/raw-material-draft';
|
||||||
import { router } from '@inertiajs/react';
|
import type { RawMaterialDraftData } from '@/lib/raw-material-draft';
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
type DraftType = 'create' | 'edit';
|
type DraftType = 'create' | 'edit';
|
||||||
|
|
||||||
@ -15,54 +14,12 @@ export function useRawMaterialDraftSave(
|
|||||||
rawMaterialId?: number,
|
rawMaterialId?: number,
|
||||||
delay = 500,
|
delay = 500,
|
||||||
) {
|
) {
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
return useDraftSave({
|
||||||
const dataRef = useRef(data);
|
type,
|
||||||
dataRef.current = data;
|
data,
|
||||||
const submittedRef = useRef(false);
|
userId,
|
||||||
|
extraId: rawMaterialId,
|
||||||
useEffect(() => {
|
delay,
|
||||||
const offBefore = router.on('before', (event) => {
|
store: { save: saveRawMaterialDraft, clear: clearRawMaterialDraft },
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
import { router } from '@inertiajs/react';
|
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||||
import { useEffect, useRef } from 'react';
|
import { clearRestockDraft, saveRestockDraft } from '@/lib/restock-draft';
|
||||||
import {
|
import type { RestockDraftData } from '@/lib/restock-draft';
|
||||||
clearRestockDraft,
|
|
||||||
saveRestockDraft
|
|
||||||
|
|
||||||
} from '@/lib/restock-draft';
|
|
||||||
import type {RestockDraftData} from '@/lib/restock-draft';
|
|
||||||
|
|
||||||
type DraftType = 'create' | 'edit';
|
type DraftType = 'create' | 'edit';
|
||||||
|
|
||||||
@ -15,61 +10,11 @@ export function useRestockDraftSave(
|
|||||||
userId?: number,
|
userId?: number,
|
||||||
delay = 500,
|
delay = 500,
|
||||||
) {
|
) {
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
return useDraftSave({
|
||||||
const dataRef = useRef(data);
|
type,
|
||||||
const submittedRef = useRef(false);
|
data,
|
||||||
|
userId,
|
||||||
useEffect(() => {
|
delay,
|
||||||
dataRef.current = data;
|
store: { save: saveRestockDraft, clear: clearRestockDraft },
|
||||||
}, [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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 = {
|
export type ProductDraftData = {
|
||||||
productName: string;
|
productName: string;
|
||||||
@ -19,17 +19,8 @@ export type ProductDraftData = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getKey(
|
export const productDraftStore =
|
||||||
type: 'create' | 'edit',
|
createDraftStore<ProductDraftData>('product-draft');
|
||||||
userId?: number,
|
|
||||||
productId?: number,
|
|
||||||
): string {
|
|
||||||
if (type === 'edit' && productId) {
|
|
||||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${productId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveProductDraft(
|
export function saveProductDraft(
|
||||||
type: 'create' | 'edit',
|
type: 'create' | 'edit',
|
||||||
@ -37,18 +28,7 @@ export function saveProductDraft(
|
|||||||
userId?: number,
|
userId?: number,
|
||||||
productId?: number,
|
productId?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (type !== 'create') {
|
return productDraftStore.save(type, data, userId, productId);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = getKey(type, userId, productId);
|
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadProductDraft(
|
export function loadProductDraft(
|
||||||
@ -56,18 +36,7 @@ export function loadProductDraft(
|
|||||||
userId?: number,
|
userId?: number,
|
||||||
productId?: number,
|
productId?: number,
|
||||||
): ProductDraftData | null {
|
): ProductDraftData | null {
|
||||||
try {
|
return productDraftStore.load(type, userId, productId);
|
||||||
const key = getKey(type, userId, productId);
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.parse(raw) as ProductDraftData;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearProductDraft(
|
export function clearProductDraft(
|
||||||
@ -75,10 +44,5 @@ export function clearProductDraft(
|
|||||||
userId?: number,
|
userId?: number,
|
||||||
productId?: number,
|
productId?: number,
|
||||||
): void {
|
): void {
|
||||||
try {
|
productDraftStore.clear(type, userId, productId);
|
||||||
const key = getKey(type, userId, productId);
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const DRAFT_PREFIX = 'purchase-draft';
|
import { createDraftStore } from '@/lib/draft-store';
|
||||||
|
|
||||||
export type PurchaseDraftData = {
|
export type PurchaseDraftData = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -19,60 +19,27 @@ export type PurchaseDraftData = {
|
|||||||
photo?: string;
|
photo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getKey(type: 'create' | 'edit', userId?: number): string {
|
export const purchaseDraftStore =
|
||||||
if (type === 'edit') {
|
createDraftStore<PurchaseDraftData>('purchase-draft');
|
||||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function savePurchaseDraft(
|
export function savePurchaseDraft(
|
||||||
type: 'create' | 'edit',
|
type: 'create' | 'edit',
|
||||||
data: PurchaseDraftData,
|
data: PurchaseDraftData,
|
||||||
userId?: number,
|
userId?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (type !== 'create') {
|
return purchaseDraftStore.save(type, data, userId);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = getKey(type, userId);
|
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadPurchaseDraft(
|
export function loadPurchaseDraft(
|
||||||
type: 'create' | 'edit',
|
type: 'create' | 'edit',
|
||||||
userId?: number,
|
userId?: number,
|
||||||
): PurchaseDraftData | null {
|
): PurchaseDraftData | null {
|
||||||
try {
|
return purchaseDraftStore.load(type, userId);
|
||||||
const key = getKey(type, userId);
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.parse(raw) as PurchaseDraftData;
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearPurchaseDraft(
|
export function clearPurchaseDraft(
|
||||||
type: 'create' | 'edit',
|
type: 'create' | 'edit',
|
||||||
userId?: number,
|
userId?: number,
|
||||||
): void {
|
): void {
|
||||||
try {
|
purchaseDraftStore.clear(type, userId);
|
||||||
const key = getKey(type, userId);
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const DRAFT_PREFIX = 'raw-material-draft';
|
import { createDraftStore } from '@/lib/draft-store';
|
||||||
|
|
||||||
export type RawMaterialDraftData = {
|
export type RawMaterialDraftData = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -13,17 +13,8 @@ export type RawMaterialDraftData = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getKey(
|
export const rawMaterialDraftStore =
|
||||||
type: 'create' | 'edit',
|
createDraftStore<RawMaterialDraftData>('raw-material-draft');
|
||||||
userId?: number,
|
|
||||||
rawMaterialId?: number,
|
|
||||||
): string {
|
|
||||||
if (type === 'edit' && rawMaterialId) {
|
|
||||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${rawMaterialId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveRawMaterialDraft(
|
export function saveRawMaterialDraft(
|
||||||
type: 'create' | 'edit',
|
type: 'create' | 'edit',
|
||||||
@ -31,18 +22,7 @@ export function saveRawMaterialDraft(
|
|||||||
userId?: number,
|
userId?: number,
|
||||||
rawMaterialId?: number,
|
rawMaterialId?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (type !== 'create') {
|
return rawMaterialDraftStore.save(type, data, userId, rawMaterialId);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = getKey(type, userId, rawMaterialId);
|
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadRawMaterialDraft(
|
export function loadRawMaterialDraft(
|
||||||
@ -50,18 +30,7 @@ export function loadRawMaterialDraft(
|
|||||||
userId?: number,
|
userId?: number,
|
||||||
rawMaterialId?: number,
|
rawMaterialId?: number,
|
||||||
): RawMaterialDraftData | null {
|
): RawMaterialDraftData | null {
|
||||||
try {
|
return rawMaterialDraftStore.load(type, userId, rawMaterialId);
|
||||||
const key = getKey(type, userId, rawMaterialId);
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.parse(raw) as RawMaterialDraftData;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearRawMaterialDraft(
|
export function clearRawMaterialDraft(
|
||||||
@ -69,10 +38,5 @@ export function clearRawMaterialDraft(
|
|||||||
userId?: number,
|
userId?: number,
|
||||||
rawMaterialId?: number,
|
rawMaterialId?: number,
|
||||||
): void {
|
): void {
|
||||||
try {
|
rawMaterialDraftStore.clear(type, userId, rawMaterialId);
|
||||||
const key = getKey(type, userId, rawMaterialId);
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const DRAFT_PREFIX = 'restock-draft';
|
import { createDraftStore } from '@/lib/draft-store';
|
||||||
|
|
||||||
export type RestockDraftData = {
|
export type RestockDraftData = {
|
||||||
stockType: 'good' | 'reject';
|
stockType: 'good' | 'reject';
|
||||||
@ -8,60 +8,27 @@ export type RestockDraftData = {
|
|||||||
photo?: string;
|
photo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getKey(type: 'create' | 'edit', userId?: number): string {
|
export const restockDraftStore =
|
||||||
if (type === 'edit') {
|
createDraftStore<RestockDraftData>('restock-draft');
|
||||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveRestockDraft(
|
export function saveRestockDraft(
|
||||||
type: 'create' | 'edit',
|
type: 'create' | 'edit',
|
||||||
data: RestockDraftData,
|
data: RestockDraftData,
|
||||||
userId?: number,
|
userId?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (type !== 'create') {
|
return restockDraftStore.save(type, data, userId);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = getKey(type, userId);
|
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadRestockDraft(
|
export function loadRestockDraft(
|
||||||
type: 'create' | 'edit',
|
type: 'create' | 'edit',
|
||||||
userId?: number,
|
userId?: number,
|
||||||
): RestockDraftData | null {
|
): RestockDraftData | null {
|
||||||
try {
|
return restockDraftStore.load(type, userId);
|
||||||
const key = getKey(type, userId);
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.parse(raw) as RestockDraftData;
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearRestockDraft(
|
export function clearRestockDraft(
|
||||||
type: 'create' | 'edit',
|
type: 'create' | 'edit',
|
||||||
userId?: number,
|
userId?: number,
|
||||||
): void {
|
): void {
|
||||||
try {
|
restockDraftStore.clear(type, userId);
|
||||||
const key = getKey(type, userId);
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
export type CashAccount = {
|
export type CashAccount = {
|
||||||
@ -55,39 +49,22 @@ export function createCashAccountColumns(
|
|||||||
const cashAccount = row.original;
|
const cashAccount = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(cashAccount),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(cashAccount)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleDeleteClick(cashAccount),
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,34 +1,19 @@
|
|||||||
import { Form, Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import {
|
import { ArrowDownToLine, ArrowUpFromLine, Wallet } from 'lucide-react';
|
||||||
ArrowDownToLine,
|
import { useState } from 'react';
|
||||||
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 type { PaginationState } from '@/components/data-table';
|
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 { FileUpload } from '@/components/file-upload';
|
||||||
|
import { FilterPopover } from '@/components/filter-popover';
|
||||||
|
import { FormDialog } from '@/components/form-dialog';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { RupiahInput } from '@/components/rupiah-input';
|
import { RupiahInput } from '@/components/rupiah-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -36,6 +21,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
index as cashAccountIndex,
|
index as cashAccountIndex,
|
||||||
@ -78,7 +64,6 @@ export default function CashAccountIndex({
|
|||||||
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<CashTransaction | null>(null);
|
const [editing, setEditing] = useState<CashTransaction | null>(null);
|
||||||
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
|
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
|
||||||
|
|
||||||
const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(
|
const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@ -105,7 +90,6 @@ export default function CashAccountIndex({
|
|||||||
type: string;
|
type: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: transactions.current_page,
|
current_page: transactions.current_page,
|
||||||
last_page: transactions.last_page,
|
last_page: transactions.last_page,
|
||||||
@ -113,41 +97,20 @@ export default function CashAccountIndex({
|
|||||||
total: transactions.total,
|
total: transactions.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = filters.type;
|
const {
|
||||||
|
search,
|
||||||
function applyFilter(key: string, value: string) {
|
filterOpen,
|
||||||
const newFilters = { ...filters };
|
setFilterOpen,
|
||||||
|
handlePageChange,
|
||||||
if (value === '' || value === 'all') {
|
handlePerPageChange,
|
||||||
delete newFilters[key as keyof typeof newFilters];
|
handleSearchChange,
|
||||||
} else {
|
applyFilter,
|
||||||
newFilters[key as keyof typeof newFilters] = value;
|
clearFilters,
|
||||||
}
|
} = useServerTable({
|
||||||
|
route: () => cashAccountIndex.url(),
|
||||||
router.get(
|
pagination,
|
||||||
cashAccountIndex(),
|
filters,
|
||||||
{
|
});
|
||||||
...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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
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({
|
const columns = createTransactionColumns({
|
||||||
handleEdit: (transaction) => {
|
handleEdit: (transaction) => {
|
||||||
setEditing(transaction);
|
setEditing(transaction);
|
||||||
@ -211,66 +131,34 @@ export default function CashAccountIndex({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filterToolbar = (
|
const filterToolbar = (
|
||||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
<FilterPopover
|
||||||
<PopoverTrigger asChild>
|
open={filterOpen}
|
||||||
<Button variant="outline" size="sm">
|
onOpenChange={setFilterOpen}
|
||||||
<Filter className="h-4 w-4" />
|
filters={filters}
|
||||||
Filter
|
hasActiveFilters={Boolean(filters.type)}
|
||||||
{hasActiveFilters && (
|
onClear={clearFilters}
|
||||||
<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}
|
<div className="flex flex-col gap-2">
|
||||||
</span>
|
<label className="text-xs text-muted-foreground">
|
||||||
)}
|
Tipe Sumber
|
||||||
</Button>
|
</label>
|
||||||
</PopoverTrigger>
|
<Select
|
||||||
<PopoverContent className="w-64" align="end">
|
value={filters.type ?? 'all'}
|
||||||
<div className="flex flex-col gap-4">
|
onValueChange={(value) => applyFilter('type', value)}
|
||||||
<div className="flex items-center justify-between">
|
>
|
||||||
<span className="text-sm font-medium">Filter</span>
|
<SelectTrigger className="w-full">
|
||||||
{hasActiveFilters && (
|
<SelectValue placeholder="Semua Tipe" />
|
||||||
<Button
|
</SelectTrigger>
|
||||||
variant="ghost"
|
<SelectContent>
|
||||||
size="sm"
|
<SelectItem value="all">Semua Tipe</SelectItem>
|
||||||
className="h-6 px-2 text-xs"
|
<SelectItem value="deposit">Deposit</SelectItem>
|
||||||
onClick={clearFilters}
|
<SelectItem value="withdrawal">Withdrawal</SelectItem>
|
||||||
>
|
<SelectItem value="expense">Pengeluaran</SelectItem>
|
||||||
<X className="mr-1 h-3 w-3" />
|
<SelectItem value="transfer">Transfer</SelectItem>
|
||||||
Hapus Semua
|
</SelectContent>
|
||||||
</Button>
|
</Select>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</FilterPopover>
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -278,29 +166,27 @@ export default function CashAccountIndex({
|
|||||||
<Head title="Kas Toko" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Kas Toko"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Kas Toko
|
<div className="flex items-center gap-2">
|
||||||
</h2>
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => setDepositOpen(true)}
|
||||||
<Button
|
>
|
||||||
variant="outline"
|
<ArrowDownToLine className="h-4 w-4" />
|
||||||
onClick={() => setDepositOpen(true)}
|
Deposit
|
||||||
>
|
</Button>
|
||||||
<ArrowDownToLine className="h-4 w-4" />
|
<Button
|
||||||
Deposit
|
variant="outline"
|
||||||
</Button>
|
onClick={() => setWithdrawalOpen(true)}
|
||||||
<Button
|
>
|
||||||
variant="outline"
|
<ArrowUpFromLine className="h-4 w-4" />
|
||||||
onClick={() => setWithdrawalOpen(true)}
|
Withdrawal
|
||||||
>
|
</Button>
|
||||||
<ArrowUpFromLine className="h-4 w-4" />
|
</div>
|
||||||
Withdrawal
|
}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
@ -330,7 +216,7 @@ export default function CashAccountIndex({
|
|||||||
toolbar={filterToolbar}
|
toolbar={filterToolbar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<FormDialog
|
||||||
open={depositOpen}
|
open={depositOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setDepositOpen(open);
|
setDepositOpen(open);
|
||||||
@ -340,123 +226,72 @@ export default function CashAccountIndex({
|
|||||||
setDepositFileMeta(null);
|
setDepositFileMeta(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
title="Deposit"
|
||||||
|
action={deposit()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => {
|
||||||
|
setDepositOpen(false);
|
||||||
|
setDepositReceiptKey(null);
|
||||||
|
setDepositFileMeta(null);
|
||||||
|
}}
|
||||||
|
submitDisabled={depositUploading}
|
||||||
|
submitLabel={depositUploading ? 'Mengunggah...' : 'Simpan'}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
{({ errors }) => (
|
||||||
<Form
|
<>
|
||||||
action={deposit()}
|
<div className="grid gap-2">
|
||||||
resetOnSuccess
|
<Label>
|
||||||
onSuccess={() => {
|
Jumlah{' '}
|
||||||
setDepositOpen(false);
|
<span className="text-destructive">*</span>
|
||||||
setDepositReceiptKey(null);
|
</Label>
|
||||||
setDepositFileMeta(null);
|
<RupiahInput name="amount" min={1} />
|
||||||
}}
|
<InputError message={errors.amount} />
|
||||||
>
|
</div>
|
||||||
{({ errors, processing }) => (
|
<div className="grid gap-2">
|
||||||
<>
|
<Label>
|
||||||
<DialogHeader>
|
Keterangan{' '}
|
||||||
<DialogTitle>Deposit</DialogTitle>
|
<span className="text-destructive">*</span>
|
||||||
</DialogHeader>
|
</Label>
|
||||||
<div className="grid gap-4 py-4">
|
<Input
|
||||||
<div className="grid gap-2">
|
name="description"
|
||||||
<Label>
|
placeholder="Masukkan keterangan"
|
||||||
Jumlah{' '}
|
/>
|
||||||
<span className="text-destructive">
|
<InputError message={errors.description} />
|
||||||
*
|
</div>
|
||||||
</span>
|
<div className="grid gap-2">
|
||||||
</Label>
|
<Label>
|
||||||
<RupiahInput
|
Bukti{' '}
|
||||||
name="amount"
|
<span className="text-destructive">*</span>
|
||||||
min={1}
|
</Label>
|
||||||
/>
|
<input
|
||||||
<InputError
|
type="hidden"
|
||||||
message={errors.amount}
|
name="receipt_key"
|
||||||
/>
|
value={depositReceiptKey ?? ''}
|
||||||
</div>
|
/>
|
||||||
<div className="grid gap-2">
|
<input
|
||||||
<Label>
|
type="hidden"
|
||||||
Keterangan{' '}
|
name="file_size"
|
||||||
<span className="text-destructive">
|
value={depositFileMeta?.size ?? ''}
|
||||||
*
|
/>
|
||||||
</span>
|
<input
|
||||||
</Label>
|
type="hidden"
|
||||||
<Input
|
name="file_mime_type"
|
||||||
name="description"
|
value={depositFileMeta?.type ?? ''}
|
||||||
placeholder="Masukkan keterangan"
|
/>
|
||||||
/>
|
<FileUpload
|
||||||
<InputError
|
value={depositReceiptKey}
|
||||||
message={errors.description}
|
onChange={setDepositReceiptKey}
|
||||||
/>
|
folder="cash-transaction"
|
||||||
</div>
|
onUploadingChange={setDepositUploading}
|
||||||
<div className="grid gap-2">
|
onFileMeta={setDepositFileMeta}
|
||||||
<Label>
|
/>
|
||||||
Bukti{' '}
|
<InputError message={errors.receipt_key} />
|
||||||
<span className="text-destructive">
|
</div>
|
||||||
*
|
</>
|
||||||
</span>
|
)}
|
||||||
</Label>
|
</FormDialog>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Dialog
|
<FormDialog
|
||||||
open={withdrawalOpen}
|
open={withdrawalOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setWithdrawalOpen(open);
|
setWithdrawalOpen(open);
|
||||||
@ -466,132 +301,74 @@ export default function CashAccountIndex({
|
|||||||
setWithdrawalFileMeta(null);
|
setWithdrawalFileMeta(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
title="Withdrawal"
|
||||||
|
action={withdrawal()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => {
|
||||||
|
setWithdrawalOpen(false);
|
||||||
|
setWithdrawalReceiptKey(null);
|
||||||
|
setWithdrawalFileMeta(null);
|
||||||
|
}}
|
||||||
|
submitDisabled={withdrawalUploading}
|
||||||
|
submitLabel={
|
||||||
|
withdrawalUploading ? 'Mengunggah...' : 'Simpan'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
{({ errors }) => (
|
||||||
<Form
|
<>
|
||||||
action={withdrawal()}
|
<div className="grid gap-2">
|
||||||
resetOnSuccess
|
<Label>
|
||||||
onSuccess={() => {
|
Jumlah{' '}
|
||||||
setWithdrawalOpen(false);
|
<span className="text-destructive">*</span>
|
||||||
setWithdrawalReceiptKey(null);
|
</Label>
|
||||||
setWithdrawalFileMeta(null);
|
<RupiahInput name="amount" min={1} />
|
||||||
}}
|
<InputError message={errors.amount} />
|
||||||
>
|
</div>
|
||||||
{({ errors, processing }) => (
|
<div className="grid gap-2">
|
||||||
<>
|
<Label>
|
||||||
<DialogHeader>
|
Keterangan{' '}
|
||||||
<DialogTitle>Withdrawal</DialogTitle>
|
<span className="text-destructive">*</span>
|
||||||
</DialogHeader>
|
</Label>
|
||||||
<div className="grid gap-4 py-4">
|
<Input
|
||||||
<div className="grid gap-2">
|
name="description"
|
||||||
<Label>
|
placeholder="Masukkan keterangan"
|
||||||
Jumlah{' '}
|
/>
|
||||||
<span className="text-destructive">
|
<InputError message={errors.description} />
|
||||||
*
|
</div>
|
||||||
</span>
|
<div className="grid gap-2">
|
||||||
</Label>
|
<Label>
|
||||||
<RupiahInput
|
Bukti{' '}
|
||||||
name="amount"
|
<span className="text-destructive">*</span>
|
||||||
min={1}
|
</Label>
|
||||||
/>
|
<input
|
||||||
<InputError
|
type="hidden"
|
||||||
message={errors.amount}
|
name="receipt_key"
|
||||||
/>
|
value={withdrawalReceiptKey ?? ''}
|
||||||
</div>
|
/>
|
||||||
<div className="grid gap-2">
|
<input
|
||||||
<Label>
|
type="hidden"
|
||||||
Keterangan{' '}
|
name="file_size"
|
||||||
<span className="text-destructive">
|
value={withdrawalFileMeta?.size ?? ''}
|
||||||
*
|
/>
|
||||||
</span>
|
<input
|
||||||
</Label>
|
type="hidden"
|
||||||
<Input
|
name="file_mime_type"
|
||||||
name="description"
|
value={withdrawalFileMeta?.type ?? ''}
|
||||||
placeholder="Masukkan keterangan"
|
/>
|
||||||
/>
|
<FileUpload
|
||||||
<InputError
|
value={withdrawalReceiptKey}
|
||||||
message={errors.description}
|
onChange={setWithdrawalReceiptKey}
|
||||||
/>
|
folder="cash-transaction"
|
||||||
</div>
|
onUploadingChange={setWithdrawalUploading}
|
||||||
<div className="grid gap-2">
|
onFileMeta={setWithdrawalFileMeta}
|
||||||
<Label>
|
/>
|
||||||
Bukti{' '}
|
<InputError message={errors.receipt_key} />
|
||||||
<span className="text-destructive">
|
</div>
|
||||||
*
|
</>
|
||||||
</span>
|
)}
|
||||||
</Label>
|
</FormDialog>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Dialog
|
<FormDialog
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -600,143 +377,96 @@ export default function CashAccountIndex({
|
|||||||
setEditFileMeta(null);
|
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>
|
{({ errors }) =>
|
||||||
{editing && (
|
editing && (
|
||||||
<Form
|
<>
|
||||||
action={updateTransaction(editing.id)}
|
<div className="grid gap-2">
|
||||||
resetOnSuccess
|
<Label>
|
||||||
onSuccess={() => {
|
Jumlah{' '}
|
||||||
setEditing(null);
|
<span className="text-destructive">
|
||||||
setEditReceiptKey(null);
|
*
|
||||||
setEditFileMeta(null);
|
</span>
|
||||||
}}
|
</Label>
|
||||||
>
|
<RupiahInput
|
||||||
{({ errors, processing }) => (
|
name="amount"
|
||||||
<>
|
defaultValue={editing.amount}
|
||||||
<DialogHeader>
|
min={1}
|
||||||
<DialogTitle>
|
/>
|
||||||
Edit Transaksi
|
<InputError message={errors.amount} />
|
||||||
</DialogTitle>
|
</div>
|
||||||
</DialogHeader>
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-4 py-4">
|
<Label>
|
||||||
<div className="grid gap-2">
|
Keterangan{' '}
|
||||||
<Label>
|
<span className="text-destructive">
|
||||||
Jumlah{' '}
|
*
|
||||||
<span className="text-destructive">
|
</span>
|
||||||
*
|
</Label>
|
||||||
</span>
|
<Input
|
||||||
</Label>
|
name="description"
|
||||||
<RupiahInput
|
placeholder="Masukkan keterangan"
|
||||||
name="amount"
|
defaultValue={editing.description}
|
||||||
defaultValue={
|
/>
|
||||||
editing.amount
|
<InputError message={errors.description} />
|
||||||
}
|
</div>
|
||||||
min={1}
|
<div className="grid gap-2">
|
||||||
/>
|
<Label>
|
||||||
<InputError
|
Bukti{' '}
|
||||||
message={errors.amount}
|
<span className="text-destructive">
|
||||||
/>
|
*
|
||||||
</div>
|
</span>
|
||||||
<div className="grid gap-2">
|
</Label>
|
||||||
<Label>
|
<input
|
||||||
Keterangan{' '}
|
type="hidden"
|
||||||
<span className="text-destructive">
|
name="receipt_key"
|
||||||
*
|
value={editReceiptKey ?? ''}
|
||||||
</span>
|
/>
|
||||||
</Label>
|
<input
|
||||||
<Input
|
type="hidden"
|
||||||
name="description"
|
name="file_size"
|
||||||
placeholder="Masukkan keterangan"
|
value={editFileMeta?.size ?? ''}
|
||||||
defaultValue={
|
/>
|
||||||
editing.description
|
<input
|
||||||
}
|
type="hidden"
|
||||||
/>
|
name="file_mime_type"
|
||||||
<InputError
|
value={editFileMeta?.type ?? ''}
|
||||||
message={errors.description}
|
/>
|
||||||
/>
|
<FileUpload
|
||||||
</div>
|
value={editReceiptKey}
|
||||||
<div className="grid gap-2">
|
onChange={setEditReceiptKey}
|
||||||
<Label>
|
folder="cash-transaction"
|
||||||
Bukti{' '}
|
onUploadingChange={setEditUploading}
|
||||||
<span className="text-destructive">
|
existingUrl={editing.receipt_url}
|
||||||
*
|
onFileMeta={setEditFileMeta}
|
||||||
</span>
|
/>
|
||||||
</Label>
|
<InputError message={errors.receipt_key} />
|
||||||
<input
|
</div>
|
||||||
type="hidden"
|
</>
|
||||||
name="receipt_key"
|
)
|
||||||
value={editReceiptKey ?? ''}
|
}
|
||||||
/>
|
</FormDialog>
|
||||||
<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>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Transaksi"
|
title="Hapus Transaksi"
|
||||||
description={`Apakah Anda yakin ingin menghapus transaksi "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(transaction) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus transaksi "${transaction.description}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
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 = {
|
export type CashTransaction = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -30,23 +24,6 @@ export type CashTransaction = {
|
|||||||
} | null;
|
} | 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 {
|
function getTypeLabel(type: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
deposit: 'Deposit',
|
deposit: 'Deposit',
|
||||||
@ -69,31 +46,6 @@ function getReferenceLabel(type: string): string {
|
|||||||
return labels[type] ?? '-';
|
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 = {
|
type CreateColumnsParams = {
|
||||||
handleEdit: (transaction: CashTransaction) => void;
|
handleEdit: (transaction: CashTransaction) => void;
|
||||||
handleDeleteClick: (transaction: CashTransaction) => void;
|
handleDeleteClick: (transaction: CashTransaction) => void;
|
||||||
@ -182,8 +134,8 @@ export function createTransactionColumns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReceiptPreview
|
<ImagePreviewButton
|
||||||
url={receiptUrl}
|
srcs={[receiptUrl]}
|
||||||
title={row.original.description}
|
title={row.original.description}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -216,39 +168,22 @@ export function createTransactionColumns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(transaction),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(transaction)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleDeleteClick(transaction),
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,13 +1,8 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import { formatDate, formatShortDate } from '@/lib/format';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
export type EmployeeAdvance = {
|
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) {
|
function getStatusBadge(status: string) {
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||||
pending: {
|
pending: {
|
||||||
@ -165,79 +133,39 @@ export function createEmployeeAdvanceColumns(
|
|||||||
const employeeAdvance = row.original;
|
const employeeAdvance = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
{employeeAdvance.status === 'pending' && (
|
{
|
||||||
<Tooltip>
|
label: 'Setujui',
|
||||||
<TooltipTrigger asChild>
|
icon: (
|
||||||
<Button
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
variant="ghost"
|
),
|
||||||
size="icon"
|
show: employeeAdvance.status === 'pending',
|
||||||
onClick={() =>
|
onClick: () => handleApprove(employeeAdvance),
|
||||||
handleApprove(employeeAdvance)
|
},
|
||||||
}
|
{
|
||||||
>
|
label: 'Bayar',
|
||||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
icon: (
|
||||||
</Button>
|
<CircleDollarSign className="h-4 w-4 text-blue-600" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">
|
show: employeeAdvance.status === 'approved',
|
||||||
Setujui
|
onClick: () => handlePay(employeeAdvance),
|
||||||
</TooltipContent>
|
},
|
||||||
</Tooltip>
|
{
|
||||||
)}
|
label: 'Edit',
|
||||||
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
{employeeAdvance.status === 'approved' && (
|
onClick: () => handleEdit(employeeAdvance),
|
||||||
<Tooltip>
|
},
|
||||||
<TooltipTrigger asChild>
|
{
|
||||||
<Button
|
label: 'Hapus',
|
||||||
variant="ghost"
|
icon: (
|
||||||
size="icon"
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
onClick={() =>
|
),
|
||||||
handlePay(employeeAdvance)
|
onClick: () =>
|
||||||
}
|
handleDeleteClick(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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,22 +1,18 @@
|
|||||||
import { Form, Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DatePicker } from '@/components/date-picker';
|
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 InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { RupiahInput } from '@/components/rupiah-input';
|
import { RupiahInput } from '@/components/rupiah-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
index as employeeAdvanceIndex,
|
index as employeeAdvanceIndex,
|
||||||
@ -48,7 +44,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
|
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: employeeAdvances.current_page,
|
current_page: employeeAdvances.current_page,
|
||||||
last_page: employeeAdvances.last_page,
|
last_page: employeeAdvances.last_page,
|
||||||
@ -56,6 +52,16 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
total: employeeAdvances.total,
|
total: employeeAdvances.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
handlePageChange,
|
||||||
|
handlePerPageChange,
|
||||||
|
handleSearchChange,
|
||||||
|
} = useServerTable({
|
||||||
|
route: () => employeeAdvanceIndex.url(),
|
||||||
|
pagination,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
setEditingDueDate(new Date(editing.due_date));
|
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({
|
const columns = createEmployeeAdvanceColumns({
|
||||||
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
|
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
|
||||||
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
|
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
|
||||||
@ -154,22 +120,9 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
<Head title="Kasbon" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Kasbon"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Kasbon
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setCreateOpen(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setDueDate(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -179,113 +132,72 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
Tambah
|
Tambah
|
||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
}
|
||||||
<Form
|
/>
|
||||||
action={store()}
|
|
||||||
resetOnSuccess
|
<FormDialog
|
||||||
onSuccess={() => setCreateOpen(false)}
|
open={createOpen}
|
||||||
>
|
onOpenChange={(open) => {
|
||||||
{({ errors, processing }) => {
|
setCreateOpen(open);
|
||||||
return (
|
|
||||||
<>
|
if (!open) {
|
||||||
<DialogHeader>
|
setDueDate(undefined);
|
||||||
<DialogTitle>
|
}
|
||||||
Tambah Kasbon
|
}}
|
||||||
</DialogTitle>
|
title="Tambah Kasbon"
|
||||||
</DialogHeader>
|
action={store()}
|
||||||
<div className="grid gap-4 py-4">
|
resetOnSuccess
|
||||||
<div className="grid gap-2">
|
onSuccess={() => setCreateOpen(false)}
|
||||||
<Label>
|
>
|
||||||
Jumlah{' '}
|
{({ errors }) => (
|
||||||
<span className="text-destructive">
|
<>
|
||||||
*
|
<div className="grid gap-2">
|
||||||
</span>
|
<Label>
|
||||||
</Label>
|
Jumlah{' '}
|
||||||
<RupiahInput
|
<span className="text-destructive">*</span>
|
||||||
name="amount"
|
</Label>
|
||||||
min={1}
|
<RupiahInput name="amount" min={1} />
|
||||||
/>
|
<InputError message={errors.amount} />
|
||||||
<InputError
|
</div>
|
||||||
message={errors.amount}
|
<div className="grid gap-2">
|
||||||
/>
|
<Label htmlFor="description">
|
||||||
</div>
|
Keterangan{' '}
|
||||||
<div className="grid gap-2">
|
<span className="text-destructive">*</span>
|
||||||
<Label htmlFor="description">
|
</Label>
|
||||||
Keterangan{' '}
|
<Input
|
||||||
<span className="text-destructive">
|
id="description"
|
||||||
*
|
name="description"
|
||||||
</span>
|
placeholder="Masukkan keterangan"
|
||||||
</Label>
|
/>
|
||||||
<Input
|
<InputError message={errors.description} />
|
||||||
id="description"
|
</div>
|
||||||
name="description"
|
<div className="grid gap-2">
|
||||||
placeholder="Masukkan keterangan"
|
<Label htmlFor="due_date">
|
||||||
/>
|
Jatuh Tempo{' '}
|
||||||
<InputError
|
<span className="text-destructive">*</span>
|
||||||
message={
|
</Label>
|
||||||
errors.description
|
<input
|
||||||
}
|
type="hidden"
|
||||||
/>
|
name="due_date"
|
||||||
</div>
|
value={
|
||||||
<div className="grid gap-2">
|
dueDate
|
||||||
<Label htmlFor="due_date">
|
? dueDate
|
||||||
Jatuh Tempo{' '}
|
.toISOString()
|
||||||
<span className="text-destructive">
|
.split('T')[0]
|
||||||
*
|
: ''
|
||||||
</span>
|
}
|
||||||
</Label>
|
/>
|
||||||
<input
|
<DatePicker
|
||||||
type="hidden"
|
value={dueDate}
|
||||||
name="due_date"
|
onChange={setDueDate}
|
||||||
value={
|
placeholder="Pilih jatuh tempo"
|
||||||
dueDate
|
min={new Date()}
|
||||||
? dueDate
|
/>
|
||||||
.toISOString()
|
<InputError message={errors.due_date} />
|
||||||
.split(
|
</div>
|
||||||
'T',
|
</>
|
||||||
)[0]
|
)}
|
||||||
: ''
|
</FormDialog>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -300,7 +212,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
searchValue={search}
|
searchValue={search}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<FormDialog
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -308,162 +220,117 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
setEditingDueDate(undefined);
|
setEditingDueDate(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
title="Edit Kasbon"
|
||||||
|
action={editing ? update(editing.id) : ''}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setEditingDueDate(undefined);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
{({ errors }) =>
|
||||||
{editing && (
|
editing && (
|
||||||
<Form
|
<>
|
||||||
action={update(editing.id)}
|
<div className="grid gap-2">
|
||||||
resetOnSuccess
|
<Label>
|
||||||
onSuccess={() => {
|
Jumlah{' '}
|
||||||
setEditing(null);
|
<span className="text-destructive">
|
||||||
setEditingDueDate(undefined);
|
*
|
||||||
}}
|
</span>
|
||||||
>
|
</Label>
|
||||||
{({ errors, processing }) => {
|
<RupiahInput
|
||||||
return (
|
name="amount"
|
||||||
<>
|
defaultValue={editing.amount}
|
||||||
<DialogHeader>
|
min={1}
|
||||||
<DialogTitle>
|
/>
|
||||||
Edit Kasbon
|
<InputError message={errors.amount} />
|
||||||
</DialogTitle>
|
</div>
|
||||||
</DialogHeader>
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-4 py-4">
|
<Label htmlFor="edit-description">
|
||||||
<div className="grid gap-2">
|
Keterangan{' '}
|
||||||
<Label>
|
<span className="text-destructive">
|
||||||
Jumlah{' '}
|
*
|
||||||
<span className="text-destructive">
|
</span>
|
||||||
*
|
</Label>
|
||||||
</span>
|
<Input
|
||||||
</Label>
|
id="edit-description"
|
||||||
<RupiahInput
|
name="description"
|
||||||
name="amount"
|
placeholder="Masukkan keterangan"
|
||||||
defaultValue={
|
defaultValue={editing.description}
|
||||||
editing.amount
|
/>
|
||||||
}
|
<InputError message={errors.description} />
|
||||||
min={1}
|
</div>
|
||||||
/>
|
<div className="grid gap-2">
|
||||||
<InputError
|
<Label htmlFor="edit-due_date">
|
||||||
message={errors.amount}
|
Jatuh Tempo{' '}
|
||||||
/>
|
<span className="text-destructive">
|
||||||
</div>
|
*
|
||||||
<div className="grid gap-2">
|
</span>
|
||||||
<Label htmlFor="edit-description">
|
</Label>
|
||||||
Keterangan{' '}
|
<input
|
||||||
<span className="text-destructive">
|
type="hidden"
|
||||||
*
|
name="due_date"
|
||||||
</span>
|
value={
|
||||||
</Label>
|
editingDueDate
|
||||||
<Input
|
? editingDueDate
|
||||||
id="edit-description"
|
.toISOString()
|
||||||
name="description"
|
.split('T')[0]
|
||||||
placeholder="Masukkan keterangan"
|
: ''
|
||||||
defaultValue={
|
}
|
||||||
editing.description
|
/>
|
||||||
}
|
<DatePicker
|
||||||
/>
|
value={editingDueDate}
|
||||||
<InputError
|
onChange={setEditingDueDate}
|
||||||
message={
|
placeholder="Pilih jatuh tempo"
|
||||||
errors.description
|
min={new Date()}
|
||||||
}
|
/>
|
||||||
/>
|
<InputError message={errors.due_date} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
</>
|
||||||
<Label htmlFor="edit-due_date">
|
)
|
||||||
Jatuh Tempo{' '}
|
}
|
||||||
<span className="text-destructive">
|
</FormDialog>
|
||||||
*
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Kasbon"
|
title="Hapus Kasbon"
|
||||||
description={`Apakah Anda yakin ingin menghapus kasbon "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(advance) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus kasbon "${advance.description}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={approving !== null}
|
target={approving}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setApproving(null);
|
setApproving(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Setujui Kasbon"
|
title="Setujui Kasbon"
|
||||||
description={`Apakah Anda yakin ingin menyetujui kasbon "${approving?.description}"?`}
|
description={(advance) =>
|
||||||
|
`Apakah Anda yakin ingin menyetujui kasbon "${advance.description}"?`
|
||||||
|
}
|
||||||
confirmLabel="Setujui"
|
confirmLabel="Setujui"
|
||||||
onConfirm={handleApprove}
|
onConfirm={handleApprove}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={paying !== null}
|
target={paying}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setPaying(null);
|
setPaying(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Bayar Kasbon"
|
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"
|
confirmLabel="Bayar"
|
||||||
onConfirm={handlePay}
|
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 type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
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 = {
|
export type Expense = {
|
||||||
id: number;
|
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 = {
|
type CreateColumnsParams = {
|
||||||
handleEdit: (expense: Expense) => void;
|
handleEdit: (expense: Expense) => void;
|
||||||
handleDeleteClick: (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(
|
export function createExpenseColumns(
|
||||||
params: CreateColumnsParams,
|
params: CreateColumnsParams,
|
||||||
): ColumnDef<Expense>[] {
|
): ColumnDef<Expense>[] {
|
||||||
@ -105,8 +57,8 @@ export function createExpenseColumns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReceiptPreview
|
<ImagePreviewButton
|
||||||
url={receiptUrl}
|
srcs={[receiptUrl]}
|
||||||
title={row.original.description}
|
title={row.original.description}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -141,39 +93,22 @@ export function createExpenseColumns(
|
|||||||
const expense = row.original;
|
const expense = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(expense),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(expense)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleDeleteClick(expense),
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,22 +1,18 @@
|
|||||||
import { Form, Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
|
import { FormDialog } from '@/components/form-dialog';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { RupiahInput } from '@/components/rupiah-input';
|
import { RupiahInput } from '@/components/rupiah-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
index as expenseIndex,
|
index as expenseIndex,
|
||||||
@ -54,7 +50,7 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
size: number;
|
size: number;
|
||||||
type: string;
|
type: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: expenses.current_page,
|
current_page: expenses.current_page,
|
||||||
last_page: expenses.last_page,
|
last_page: expenses.last_page,
|
||||||
@ -62,45 +58,15 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
total: expenses.total,
|
total: expenses.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
const {
|
||||||
router.get(
|
search,
|
||||||
expenseIndex.url(),
|
handlePageChange,
|
||||||
{
|
handlePerPageChange,
|
||||||
page,
|
handleSearchChange,
|
||||||
per_page: pagination.per_page,
|
} = useServerTable({
|
||||||
search,
|
route: () => expenseIndex.url(),
|
||||||
},
|
pagination,
|
||||||
{ 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
@ -125,23 +91,9 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
<Head title="Pengeluaran" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Pengeluaran"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Pengeluaran
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setCreateOpen(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setCreateReceiptKey(null);
|
|
||||||
setCreateFileMeta(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -151,140 +103,173 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
Tambah
|
Tambah
|
||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
}
|
||||||
<Form
|
/>
|
||||||
action={store()}
|
|
||||||
resetOnSuccess
|
<FormDialog
|
||||||
onSuccess={() => {
|
open={createOpen}
|
||||||
setCreateOpen(false);
|
onOpenChange={(open) => {
|
||||||
setCreateReceiptKey(null);
|
setCreateOpen(open);
|
||||||
setCreateFileMeta(null);
|
|
||||||
}}
|
if (!open) {
|
||||||
>
|
setCreateReceiptKey(null);
|
||||||
{({ errors, processing }) => {
|
setCreateFileMeta(null);
|
||||||
return (
|
}
|
||||||
<>
|
}}
|
||||||
<DialogHeader>
|
title="Tambah Pengeluaran"
|
||||||
<DialogTitle>
|
action={store()}
|
||||||
Tambah Pengeluaran
|
resetOnSuccess
|
||||||
</DialogTitle>
|
onSuccess={() => {
|
||||||
</DialogHeader>
|
setCreateOpen(false);
|
||||||
<div className="grid gap-4 py-4">
|
setCreateReceiptKey(null);
|
||||||
<div className="grid gap-2">
|
setCreateFileMeta(null);
|
||||||
<Label>
|
}}
|
||||||
Jumlah{' '}
|
submitDisabled={createUploading}
|
||||||
<span className="text-destructive">
|
submitLabel={createUploading ? 'Mengunggah...' : 'Simpan'}
|
||||||
*
|
>
|
||||||
</span>
|
{({ errors }) => (
|
||||||
</Label>
|
<>
|
||||||
<RupiahInput
|
<div className="grid gap-2">
|
||||||
name="amount"
|
<Label>
|
||||||
min={1}
|
Jumlah{' '}
|
||||||
/>
|
<span className="text-destructive">*</span>
|
||||||
<InputError
|
</Label>
|
||||||
message={errors.amount}
|
<RupiahInput name="amount" min={1} />
|
||||||
/>
|
<InputError message={errors.amount} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="description">
|
<Label htmlFor="description">
|
||||||
Keterangan{' '}
|
Keterangan{' '}
|
||||||
<span className="text-destructive">
|
<span className="text-destructive">*</span>
|
||||||
*
|
</Label>
|
||||||
</span>
|
<Input
|
||||||
</Label>
|
id="description"
|
||||||
<Input
|
name="description"
|
||||||
id="description"
|
placeholder="Masukkan keterangan"
|
||||||
name="description"
|
/>
|
||||||
placeholder="Masukkan keterangan"
|
<InputError message={errors.description} />
|
||||||
/>
|
</div>
|
||||||
<InputError
|
<div className="grid gap-2">
|
||||||
message={
|
<Label>
|
||||||
errors.description
|
Bukti{' '}
|
||||||
}
|
<span className="text-destructive">*</span>
|
||||||
/>
|
</Label>
|
||||||
</div>
|
<input
|
||||||
<div className="grid gap-2">
|
type="hidden"
|
||||||
<Label>
|
name="receipt_key"
|
||||||
Bukti{' '}
|
value={createReceiptKey ?? ''}
|
||||||
<span className="text-destructive">
|
/>
|
||||||
*
|
<input
|
||||||
</span>
|
type="hidden"
|
||||||
</Label>
|
name="file_size"
|
||||||
<input
|
value={createFileMeta?.size ?? ''}
|
||||||
type="hidden"
|
/>
|
||||||
name="receipt_key"
|
<input
|
||||||
value={
|
type="hidden"
|
||||||
createReceiptKey ??
|
name="file_mime_type"
|
||||||
''
|
value={createFileMeta?.type ?? ''}
|
||||||
}
|
/>
|
||||||
/>
|
<FileUpload
|
||||||
<input
|
value={createReceiptKey}
|
||||||
type="hidden"
|
onChange={setCreateReceiptKey}
|
||||||
name="file_size"
|
folder="expense"
|
||||||
value={
|
onUploadingChange={setCreateUploading}
|
||||||
createFileMeta?.size ??
|
onFileMeta={setCreateFileMeta}
|
||||||
''
|
/>
|
||||||
}
|
<InputError message={errors.receipt_key} />
|
||||||
/>
|
</div>
|
||||||
<input
|
</>
|
||||||
type="hidden"
|
)}
|
||||||
name="file_mime_type"
|
</FormDialog>
|
||||||
value={
|
|
||||||
createFileMeta?.type ??
|
<FormDialog
|
||||||
''
|
open={editing !== null}
|
||||||
}
|
onOpenChange={(open) => {
|
||||||
/>
|
if (!open) {
|
||||||
<FileUpload
|
setEditing(null);
|
||||||
value={createReceiptKey}
|
setEditReceiptKey(null);
|
||||||
onChange={
|
setEditFileMeta(null);
|
||||||
setCreateReceiptKey
|
}
|
||||||
}
|
}}
|
||||||
folder="expense"
|
title="Edit Pengeluaran"
|
||||||
onUploadingChange={
|
action={editing ? update(editing.id) : ''}
|
||||||
setCreateUploading
|
resetOnSuccess
|
||||||
}
|
onSuccess={() => {
|
||||||
onFileMeta={
|
setEditing(null);
|
||||||
setCreateFileMeta
|
setEditReceiptKey(null);
|
||||||
}
|
setEditFileMeta(null);
|
||||||
/>
|
}}
|
||||||
<InputError
|
submitDisabled={editUploading}
|
||||||
message={
|
submitLabel={editUploading ? 'Mengunggah...' : 'Simpan'}
|
||||||
errors.receipt_key
|
>
|
||||||
}
|
{({ errors }) =>
|
||||||
/>
|
editing && (
|
||||||
</div>
|
<>
|
||||||
</div>
|
<div className="grid gap-2">
|
||||||
<DialogFooter>
|
<Label>
|
||||||
<Button
|
Jumlah{' '}
|
||||||
type="button"
|
<span className="text-destructive">
|
||||||
variant="outline"
|
*
|
||||||
onClick={() =>
|
</span>
|
||||||
setCreateOpen(false)
|
</Label>
|
||||||
}
|
<RupiahInput
|
||||||
>
|
name="amount"
|
||||||
Batal
|
defaultValue={editing.amount}
|
||||||
</Button>
|
min={1}
|
||||||
<Button
|
/>
|
||||||
type="submit"
|
<InputError message={errors.amount} />
|
||||||
disabled={
|
</div>
|
||||||
processing ||
|
<div className="grid gap-2">
|
||||||
createUploading
|
<Label htmlFor="edit-description">
|
||||||
}
|
Keterangan{' '}
|
||||||
>
|
<span className="text-destructive">
|
||||||
{processing
|
*
|
||||||
? 'Menyimpan...'
|
</span>
|
||||||
: createUploading
|
</Label>
|
||||||
? 'Mengunggah...'
|
<Input
|
||||||
: 'Simpan'}
|
id="edit-description"
|
||||||
</Button>
|
name="description"
|
||||||
</DialogFooter>
|
placeholder="Masukkan keterangan"
|
||||||
</>
|
defaultValue={editing.description}
|
||||||
);
|
/>
|
||||||
}}
|
<InputError message={errors.description} />
|
||||||
</Form>
|
</div>
|
||||||
</DialogContent>
|
<div className="grid gap-2">
|
||||||
</Dialog>
|
<Label>
|
||||||
</div>
|
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
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -299,170 +284,17 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
searchValue={search}
|
searchValue={search}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<DeleteConfirmDialog
|
||||||
open={editing !== null}
|
target={deleting}
|
||||||
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}
|
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Pengeluaran"
|
title="Hapus Pengeluaran"
|
||||||
description={`Apakah Anda yakin ingin menghapus pengeluaran "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(expense) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus pengeluaran "${expense.description}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Eye, Lock, Unlock } from 'lucide-react';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import { MONTH_NAMES } from '@/lib/constants';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { Link } from '@inertiajs/react';
|
|
||||||
|
|
||||||
export type PayrollPeriod = {
|
export type PayrollPeriod = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -26,22 +20,6 @@ export type PayrollPeriod = {
|
|||||||
payrolls_sum_deduction_amount: number | null;
|
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 {
|
function formatPeriod(period: PayrollPeriod): string {
|
||||||
return `${MONTH_NAMES[period.month]} ${period.year}`;
|
return `${MONTH_NAMES[period.month]} ${period.year}`;
|
||||||
}
|
}
|
||||||
@ -201,56 +179,31 @@ export function createPayrollPeriodColumns(
|
|||||||
const period = row.original;
|
const period = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Lihat Detail',
|
||||||
<Button variant="ghost" size="icon" asChild>
|
icon: <Eye className="h-4 w-4" />,
|
||||||
<Link href={showUrl(period.id)}>
|
href: showUrl(period.id),
|
||||||
<Eye className="h-4 w-4" />
|
},
|
||||||
</Link>
|
{
|
||||||
</Button>
|
label: 'Tutup Periode',
|
||||||
</TooltipTrigger>
|
icon: (
|
||||||
<TooltipContent side="top">
|
<Lock className="h-4 w-4 text-orange-600" />
|
||||||
Lihat Detail
|
),
|
||||||
</TooltipContent>
|
show: period.status === 'open',
|
||||||
</Tooltip>
|
onClick: () => handleClose(period),
|
||||||
|
},
|
||||||
{period.status === 'open' && (
|
{
|
||||||
<Tooltip>
|
label: 'Buka Periode',
|
||||||
<TooltipTrigger asChild>
|
icon: (
|
||||||
<Button
|
<Unlock className="h-4 w-4 text-blue-600" />
|
||||||
variant="ghost"
|
),
|
||||||
size="icon"
|
show: period.status === 'closed',
|
||||||
onClick={() => handleClose(period)}
|
onClick: () => handleReopen(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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } 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 {
|
import {
|
||||||
Dialog,
|
index as payrollPeriodsIndex,
|
||||||
DialogContent,
|
show as payrollPeriodShow,
|
||||||
DialogFooter,
|
close,
|
||||||
DialogHeader,
|
reopen,
|
||||||
DialogTitle,
|
} from '@/routes/admin/finance/payroll-periods';
|
||||||
} 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';
|
|
||||||
import { createPayrollPeriodColumns } from './columns';
|
import { createPayrollPeriodColumns } from './columns';
|
||||||
import type { PayrollPeriod } 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) {
|
export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||||
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
|
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
|
||||||
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
|
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: payrollPeriods.current_page,
|
current_page: payrollPeriods.current_page,
|
||||||
last_page: payrollPeriods.last_page,
|
last_page: payrollPeriods.last_page,
|
||||||
@ -54,6 +36,16 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
|||||||
total: payrollPeriods.total,
|
total: payrollPeriods.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
handlePageChange,
|
||||||
|
handlePerPageChange,
|
||||||
|
handleSearchChange,
|
||||||
|
} = useServerTable({
|
||||||
|
route: () => payrollPeriodsIndex.url(),
|
||||||
|
pagination,
|
||||||
|
});
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
if (!closing) {
|
if (!closing) {
|
||||||
return;
|
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({
|
const columns = createPayrollPeriodColumns({
|
||||||
showUrl: (id) => payrollPeriodShow(id).url,
|
showUrl: (id) => payrollPeriodShow(id).url,
|
||||||
handleClose: (period) => setClosing(period),
|
handleClose: (period) => setClosing(period),
|
||||||
@ -133,13 +85,7 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
|||||||
<Head title="Gaji" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader title="Gaji" />
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
|
||||||
Gaji
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -154,28 +100,32 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
|||||||
searchValue={search}
|
searchValue={search}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={closing !== null}
|
target={closing}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setClosing(null);
|
setClosing(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Tutup Periode Gaji"
|
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"
|
confirmLabel="Tutup"
|
||||||
onConfirm={handleClose}
|
onConfirm={handleClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={reopening !== null}
|
target={reopening}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setReopening(null);
|
setReopening(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Buka Periode Gaji"
|
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"
|
confirmLabel="Buka"
|
||||||
onConfirm={handleReopen}
|
onConfirm={handleReopen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,13 +1,7 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { CircleDollarSign, Pencil, Trash2, XCircle } from 'lucide-react';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
export type Payroll = {
|
export type Payroll = {
|
||||||
@ -215,64 +209,32 @@ export function createPayrollColumns(
|
|||||||
const payroll = row.original;
|
const payroll = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
{payroll.status === 'unpaid' && (
|
{
|
||||||
<>
|
label: 'Tambah Adjustment',
|
||||||
<Tooltip>
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
<TooltipTrigger asChild>
|
show: payroll.status === 'unpaid',
|
||||||
<Button
|
onClick: () => handleAddAdjustment(payroll),
|
||||||
variant="ghost"
|
},
|
||||||
size="icon"
|
{
|
||||||
onClick={() =>
|
label: 'Tandai Dibayar',
|
||||||
handleAddAdjustment(payroll)
|
icon: (
|
||||||
}
|
<CircleDollarSign className="h-4 w-4 text-green-600" />
|
||||||
>
|
),
|
||||||
<Pencil className="h-4 w-4" />
|
show: payroll.status === 'unpaid',
|
||||||
</Button>
|
onClick: () => handlePay(payroll),
|
||||||
</TooltipTrigger>
|
},
|
||||||
<TooltipContent side="top">
|
{
|
||||||
Tambah Adjustment
|
label: 'Batalkan',
|
||||||
</TooltipContent>
|
icon: (
|
||||||
</Tooltip>
|
<XCircle className="h-4 w-4 text-destructive" />
|
||||||
|
),
|
||||||
<Tooltip>
|
show: payroll.status === 'unpaid',
|
||||||
<TooltipTrigger asChild>
|
onClick: () => handleCancel(payroll),
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
@ -13,6 +16,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { MONTH_NAMES } from '@/lib/constants';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
|
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
|
||||||
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
||||||
@ -21,9 +25,6 @@ import {
|
|||||||
pay as payrollPay,
|
pay as payrollPay,
|
||||||
} from '@/routes/admin/finance/payrolls';
|
} from '@/routes/admin/finance/payrolls';
|
||||||
import { store as adjustmentStore } from '@/routes/admin/finance/payrolls/adjustments';
|
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 type { Payroll, PayrollAdjustment } from './show-columns';
|
||||||
import { createPayrollColumns } 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) {
|
export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||||
const [paying, setPaying] = useState<Payroll | null>(null);
|
const [paying, setPaying] = useState<Payroll | null>(null);
|
||||||
const [cancelling, setCancelling] = 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');
|
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
|
||||||
|
|
||||||
function handlePay() {
|
function handlePay() {
|
||||||
if (!paying) return;
|
if (!paying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
payrollPay(paying.id),
|
payrollPay(paying.id),
|
||||||
@ -78,7 +65,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
if (!cancelling) return;
|
if (!cancelling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
payrollCancel(cancelling.id),
|
payrollCancel(cancelling.id),
|
||||||
@ -90,7 +79,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteAdjustment() {
|
function handleDeleteAdjustment() {
|
||||||
if (!deletingAdjustment) return;
|
if (!deletingAdjustment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.delete(adjustmentDestroy(deletingAdjustment.adjustment.id), {
|
router.delete(adjustmentDestroy(deletingAdjustment.adjustment.id), {
|
||||||
onSuccess: () => setDeletingAdjustment(null),
|
onSuccess: () => setDeletingAdjustment(null),
|
||||||
@ -336,7 +327,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={paying !== null}
|
open={paying !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setPaying(null);
|
if (!open) {
|
||||||
|
setPaying(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
title="Tandai Dibayar"
|
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.`}
|
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
|
<ConfirmDialog
|
||||||
open={cancelling !== null}
|
open={cancelling !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setCancelling(null);
|
if (!open) {
|
||||||
|
setCancelling(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
title="Batalkan Gaji"
|
title="Batalkan Gaji"
|
||||||
description={`Apakah Anda yakin ingin membatalkan gaji "${cancelling?.employee?.user?.user_profile?.full_name}"?`}
|
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
|
<ConfirmDialog
|
||||||
open={deletingAdjustment !== null}
|
open={deletingAdjustment !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setDeletingAdjustment(null);
|
if (!open) {
|
||||||
|
setDeletingAdjustment(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Penyesuaian"
|
title="Hapus Penyesuaian"
|
||||||
description={`Apakah Anda yakin ingin menghapus penyesuaian "${deletingAdjustment?.adjustment.description}" sebesar ${formatCurrency(deletingAdjustment?.adjustment.amount ?? 0)}?`}
|
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 type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { router } from '@inertiajs/react';
|
|
||||||
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { ToggleStatus } from '@/components/toggle-status';
|
||||||
import {
|
import { formatCurrency } from '@/lib/utils';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -38,14 +32,6 @@ function getEmploymentStatusLabel(status: string): string {
|
|||||||
return labels[status] ?? status;
|
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 = {
|
type CreateColumnsParams = {
|
||||||
handleEdit: (employee: Employee) => void;
|
handleEdit: (employee: Employee) => void;
|
||||||
handleDeleteClick: (employee: Employee) => void;
|
handleDeleteClick: (employee: Employee) => void;
|
||||||
@ -132,18 +118,10 @@ export function createEmployeeColumns(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Switch
|
<ToggleStatus
|
||||||
size="sm"
|
url={toggleActiveUrl(employee.id)}
|
||||||
checked={employee.is_active}
|
checked={employee.is_active}
|
||||||
onCheckedChange={() => {
|
wrapperClassName="flex items-center justify-center gap-1"
|
||||||
router.post(
|
|
||||||
toggleActiveUrl(employee.id),
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
preserveScroll: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -160,56 +138,29 @@ export function createEmployeeColumns(
|
|||||||
const employee = row.original;
|
const employee = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(employee),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(employee)}
|
{
|
||||||
>
|
label: 'Reset Kata Sandi',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleResetPassword(employee),
|
||||||
</Tooltip>
|
},
|
||||||
|
{
|
||||||
<Tooltip>
|
label: 'Hapus',
|
||||||
<TooltipTrigger asChild>
|
icon: (
|
||||||
<Button
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
variant="ghost"
|
),
|
||||||
size="icon"
|
onClick: () => handleDeleteClick(employee),
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Filter, Plus, X } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -17,6 +14,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
create as employeeCreate,
|
create as employeeCreate,
|
||||||
@ -47,8 +45,7 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
const [deleting, setDeleting] = useState<Employee | null>(null);
|
const [deleting, setDeleting] = useState<Employee | null>(null);
|
||||||
const [resetPasswordTarget, setResetPasswordTarget] =
|
const [resetPasswordTarget, setResetPasswordTarget] =
|
||||||
useState<Employee | null>(null);
|
useState<Employee | null>(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: employees.current_page,
|
current_page: employees.current_page,
|
||||||
last_page: employees.last_page,
|
last_page: employees.last_page,
|
||||||
@ -56,84 +53,20 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
total: employees.total,
|
total: employees.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = filters.employment_status || filters.is_active;
|
const {
|
||||||
|
search,
|
||||||
function applyFilter(key: string, value: string) {
|
filterOpen,
|
||||||
const newFilters = { ...filters };
|
setFilterOpen,
|
||||||
|
handlePageChange,
|
||||||
if (value === '' || value === 'all') {
|
handlePerPageChange,
|
||||||
delete newFilters[key as keyof typeof newFilters];
|
handleSearchChange,
|
||||||
} else {
|
applyFilter,
|
||||||
newFilters[key as keyof typeof newFilters] = value;
|
clearFilters,
|
||||||
}
|
} = useServerTable({
|
||||||
|
route: () => employeeIndex.url(),
|
||||||
router.get(
|
pagination,
|
||||||
employeeIndex.url(),
|
filters,
|
||||||
{
|
});
|
||||||
...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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
@ -169,115 +102,77 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filterToolbar = (
|
const filterToolbar = (
|
||||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
<FilterPopover
|
||||||
<PopoverTrigger asChild>
|
open={filterOpen}
|
||||||
<Button variant="outline" size="sm">
|
onOpenChange={setFilterOpen}
|
||||||
<Filter className="h-4 w-4" />
|
filters={filters}
|
||||||
Filter
|
hasActiveFilters={Boolean(
|
||||||
{hasActiveFilters && (
|
filters.employment_status || filters.is_active,
|
||||||
<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}
|
onClear={clearFilters}
|
||||||
</span>
|
>
|
||||||
)}
|
<div className="flex flex-col gap-2">
|
||||||
</Button>
|
<label className="text-xs text-muted-foreground">
|
||||||
</PopoverTrigger>
|
Status Karyawan
|
||||||
<PopoverContent className="w-64" align="end">
|
</label>
|
||||||
<div className="flex flex-col gap-4">
|
<Select
|
||||||
<div className="flex items-center justify-between">
|
value={filters.employment_status ?? 'all'}
|
||||||
<span className="text-sm font-medium">Filter</span>
|
onValueChange={(value) =>
|
||||||
{hasActiveFilters && (
|
applyFilter('employment_status', value)
|
||||||
<Button
|
}
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
<SelectTrigger className="w-full">
|
||||||
className="h-6 px-2 text-xs"
|
<SelectValue placeholder="Semua Status" />
|
||||||
onClick={clearFilters}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<X className="mr-1 h-3 w-3" />
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
Hapus Semua
|
<SelectItem value="full_time">Full Time</SelectItem>
|
||||||
</Button>
|
<SelectItem value="part_time">Part Time</SelectItem>
|
||||||
)}
|
<SelectItem value="contract">Kontrak</SelectItem>
|
||||||
</div>
|
<SelectItem value="internship">Magang</SelectItem>
|
||||||
|
<SelectItem value="resigned">Keluar</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">
|
||||||
Status Karyawan
|
Status Aktif
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.employment_status ?? 'all'}
|
value={filters.is_active ?? 'all'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => applyFilter('is_active', value)}
|
||||||
applyFilter('employment_status', value)
|
>
|
||||||
}
|
<SelectTrigger className="w-full">
|
||||||
>
|
<SelectValue placeholder="Semua" />
|
||||||
<SelectTrigger className="w-full">
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="Semua Status" />
|
<SelectContent>
|
||||||
</SelectTrigger>
|
<SelectItem value="all">Semua</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="1">Aktif</SelectItem>
|
||||||
<SelectItem value="all">
|
<SelectItem value="0">Tidak Aktif</SelectItem>
|
||||||
Semua Status
|
</SelectContent>
|
||||||
</SelectItem>
|
</Select>
|
||||||
<SelectItem value="full_time">
|
</div>
|
||||||
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">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">
|
||||||
Status Aktif
|
Jenis Kelamin
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.is_active ?? 'all'}
|
value={filters.gender ?? 'all'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => applyFilter('gender', value)}
|
||||||
applyFilter('is_active', value)
|
>
|
||||||
}
|
<SelectTrigger className="w-full">
|
||||||
>
|
<SelectValue placeholder="Semua" />
|
||||||
<SelectTrigger className="w-full">
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="Semua" />
|
<SelectContent>
|
||||||
</SelectTrigger>
|
<SelectItem value="all">Semua</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="male">Laki-laki</SelectItem>
|
||||||
<SelectItem value="all">Semua</SelectItem>
|
<SelectItem value="female">Perempuan</SelectItem>
|
||||||
<SelectItem value="1">Aktif</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="0">Tidak Aktif</SelectItem>
|
</Select>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
</FilterPopover>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -285,19 +180,17 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
<Head title="Pegawai" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Pegawai"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Pegawai
|
<Button asChild>
|
||||||
</h2>
|
<a href={employeeCreate.url()}>
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
<Button asChild>
|
Tambah
|
||||||
<a href={employeeCreate.url()}>
|
</a>
|
||||||
<Plus className="h-4 w-4" />
|
</Button>
|
||||||
Tambah
|
}
|
||||||
</a>
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -313,28 +206,31 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
toolbar={filterToolbar}
|
toolbar={filterToolbar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Pegawai"
|
title="Hapus Pegawai"
|
||||||
description={`Apakah Anda yakin ingin menghapus pegawai "${deleting?.user_profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(employee) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus pegawai "${employee.user_profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={resetPasswordTarget !== null}
|
target={resetPasswordTarget}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setResetPasswordTarget(null);
|
setResetPasswordTarget(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Reset Kata Sandi"
|
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"
|
confirmLabel="Reset"
|
||||||
variant="default"
|
variant="default"
|
||||||
onConfirm={handleResetPassword}
|
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 type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { CheckCircle, Pencil, Trash2, XCircle } from 'lucide-react';
|
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 = {
|
export type LeaveRequest = {
|
||||||
id: number;
|
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) {
|
function getStatusBadge(status: string) {
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||||
pending: {
|
pending: {
|
||||||
@ -136,77 +121,38 @@ export function createLeaveRequestColumns(
|
|||||||
const leaveRequest = row.original;
|
const leaveRequest = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
{leaveRequest.status === 'pending' && (
|
{
|
||||||
<>
|
label: 'Setujui',
|
||||||
<Tooltip>
|
icon: (
|
||||||
<TooltipTrigger asChild>
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
<Button
|
),
|
||||||
variant="ghost"
|
show: leaveRequest.status === 'pending',
|
||||||
size="icon"
|
onClick: () => handleApprove(leaveRequest),
|
||||||
onClick={() =>
|
},
|
||||||
handleApprove(leaveRequest)
|
{
|
||||||
}
|
label: 'Tolak',
|
||||||
>
|
icon: (
|
||||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
</Button>
|
),
|
||||||
</TooltipTrigger>
|
show: leaveRequest.status === 'pending',
|
||||||
<TooltipContent side="top">
|
onClick: () => handleReject(leaveRequest),
|
||||||
Setujui
|
},
|
||||||
</TooltipContent>
|
{
|
||||||
</Tooltip>
|
label: 'Edit',
|
||||||
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
<Tooltip>
|
onClick: () => handleEdit(leaveRequest),
|
||||||
<TooltipTrigger asChild>
|
},
|
||||||
<Button
|
{
|
||||||
variant="ghost"
|
label: 'Hapus',
|
||||||
size="icon"
|
icon: (
|
||||||
onClick={() =>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
handleReject(leaveRequest)
|
),
|
||||||
}
|
onClick: () => handleDeleteClick(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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,25 +1,16 @@
|
|||||||
import { Form, Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Filter, Plus, X } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DatePicker } from '@/components/date-picker';
|
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 InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -27,6 +18,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
approve,
|
approve,
|
||||||
destroy,
|
destroy,
|
||||||
@ -65,8 +57,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(
|
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: leaveRequests.current_page,
|
current_page: leaveRequests.current_page,
|
||||||
last_page: leaveRequests.last_page,
|
last_page: leaveRequests.last_page,
|
||||||
@ -74,7 +65,20 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
total: leaveRequests.total,
|
total: leaveRequests.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = filters.status;
|
const {
|
||||||
|
search,
|
||||||
|
filterOpen,
|
||||||
|
setFilterOpen,
|
||||||
|
handlePageChange,
|
||||||
|
handlePerPageChange,
|
||||||
|
handleSearchChange,
|
||||||
|
applyFilter,
|
||||||
|
clearFilters,
|
||||||
|
} = useServerTable({
|
||||||
|
route: () => leaveRequestIndex.url(),
|
||||||
|
pagination,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@ -86,40 +90,6 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
}
|
}
|
||||||
}, [editing]);
|
}, [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() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
return;
|
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({
|
const columns = createLeaveRequestColumns({
|
||||||
handleEdit: (leaveRequest) => setEditing(leaveRequest),
|
handleEdit: (leaveRequest) => setEditing(leaveRequest),
|
||||||
handleDeleteClick: (leaveRequest) => setDeleting(leaveRequest),
|
handleDeleteClick: (leaveRequest) => setDeleting(leaveRequest),
|
||||||
@ -209,70 +136,32 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filterToolbar = (
|
const filterToolbar = (
|
||||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
<FilterPopover
|
||||||
<PopoverTrigger asChild>
|
open={filterOpen}
|
||||||
<Button variant="outline" size="sm">
|
onOpenChange={setFilterOpen}
|
||||||
<Filter className="h-4 w-4" />
|
filters={filters}
|
||||||
Filter
|
hasActiveFilters={Boolean(filters.status)}
|
||||||
{hasActiveFilters && (
|
onClear={clearFilters}
|
||||||
<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}
|
<div className="flex flex-col gap-2">
|
||||||
</span>
|
<label className="text-xs text-muted-foreground">Status</label>
|
||||||
)}
|
<Select
|
||||||
</Button>
|
value={filters.status ?? 'all'}
|
||||||
</PopoverTrigger>
|
onValueChange={(value) => applyFilter('status', value)}
|
||||||
<PopoverContent className="w-64" align="end">
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<SelectTrigger className="w-full">
|
||||||
<div className="flex items-center justify-between">
|
<SelectValue placeholder="Semua Status" />
|
||||||
<span className="text-sm font-medium">Filter</span>
|
</SelectTrigger>
|
||||||
{hasActiveFilters && (
|
<SelectContent>
|
||||||
<Button
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
variant="ghost"
|
<SelectItem value="pending">Menunggu</SelectItem>
|
||||||
size="sm"
|
<SelectItem value="approved">Disetujui</SelectItem>
|
||||||
className="h-6 px-2 text-xs"
|
<SelectItem value="rejected">Ditolak</SelectItem>
|
||||||
onClick={clearFilters}
|
<SelectItem value="cancelled">Dibatalkan</SelectItem>
|
||||||
>
|
</SelectContent>
|
||||||
<X className="mr-1 h-3 w-3" />
|
</Select>
|
||||||
Hapus Semua
|
</div>
|
||||||
</Button>
|
</FilterPopover>
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -280,23 +169,9 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
<Head title="Cuti" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Cuti"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Cuti
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setCreateOpen(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setStartDate(undefined);
|
|
||||||
setEndDate(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -306,112 +181,77 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
Tambah
|
Tambah
|
||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
}
|
||||||
<Form
|
/>
|
||||||
action={store()}
|
|
||||||
resetOnSuccess
|
<FormDialog
|
||||||
onSuccess={() => setCreateOpen(false)}
|
open={createOpen}
|
||||||
>
|
onOpenChange={(open) => {
|
||||||
{({ errors, processing }) => {
|
setCreateOpen(open);
|
||||||
return (
|
|
||||||
<>
|
if (!open) {
|
||||||
<DialogHeader>
|
setStartDate(undefined);
|
||||||
<DialogTitle>
|
setEndDate(undefined);
|
||||||
Tambah Permohonan Cuti
|
}
|
||||||
</DialogTitle>
|
}}
|
||||||
</DialogHeader>
|
title="Tambah Permohonan Cuti"
|
||||||
<div className="grid gap-4 py-4">
|
action={store()}
|
||||||
<div className="grid gap-2">
|
resetOnSuccess
|
||||||
<Label>
|
onSuccess={() => setCreateOpen(false)}
|
||||||
Tanggal Mulai{' '}
|
>
|
||||||
<span className="text-destructive">
|
{({ errors }) => (
|
||||||
*
|
<>
|
||||||
</span>
|
<div className="grid gap-2">
|
||||||
</Label>
|
<Label>
|
||||||
<input
|
Tanggal Mulai{' '}
|
||||||
type="hidden"
|
<span className="text-destructive">*</span>
|
||||||
name="start_date"
|
</Label>
|
||||||
value={
|
<input
|
||||||
startDate
|
type="hidden"
|
||||||
? startDate
|
name="start_date"
|
||||||
.toISOString()
|
value={
|
||||||
.split(
|
startDate
|
||||||
'T',
|
? startDate
|
||||||
)[0]
|
.toISOString()
|
||||||
: ''
|
.split('T')[0]
|
||||||
}
|
: ''
|
||||||
/>
|
}
|
||||||
<DatePicker
|
/>
|
||||||
value={startDate}
|
<DatePicker
|
||||||
onChange={setStartDate}
|
value={startDate}
|
||||||
placeholder="Pilih tanggal mulai"
|
onChange={setStartDate}
|
||||||
min={new Date()}
|
placeholder="Pilih tanggal mulai"
|
||||||
/>
|
min={new Date()}
|
||||||
<InputError
|
/>
|
||||||
message={
|
<InputError message={errors.start_date} />
|
||||||
errors.start_date
|
</div>
|
||||||
}
|
<div className="grid gap-2">
|
||||||
/>
|
<Label>
|
||||||
</div>
|
Tanggal Selesai{' '}
|
||||||
<div className="grid gap-2">
|
<span className="text-destructive">*</span>
|
||||||
<Label>
|
</Label>
|
||||||
Tanggal Selesai{' '}
|
<input
|
||||||
<span className="text-destructive">
|
type="hidden"
|
||||||
*
|
name="end_date"
|
||||||
</span>
|
value={
|
||||||
</Label>
|
endDate
|
||||||
<input
|
? endDate
|
||||||
type="hidden"
|
.toISOString()
|
||||||
name="end_date"
|
.split('T')[0]
|
||||||
value={
|
: ''
|
||||||
endDate
|
}
|
||||||
? endDate
|
/>
|
||||||
.toISOString()
|
<DatePicker
|
||||||
.split(
|
value={endDate}
|
||||||
'T',
|
onChange={setEndDate}
|
||||||
)[0]
|
placeholder="Pilih tanggal selesai"
|
||||||
: ''
|
min={startDate}
|
||||||
}
|
/>
|
||||||
/>
|
<InputError message={errors.end_date} />
|
||||||
<DatePicker
|
</div>
|
||||||
value={endDate}
|
</>
|
||||||
onChange={setEndDate}
|
)}
|
||||||
placeholder="Pilih tanggal selesai"
|
</FormDialog>
|
||||||
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>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -427,7 +267,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
toolbar={filterToolbar}
|
toolbar={filterToolbar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<FormDialog
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -436,125 +276,77 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
setEditingEndDate(undefined);
|
setEditingEndDate(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
title="Edit Permohonan Cuti"
|
||||||
|
action={editing ? update(editing.id) : ''}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setEditingStartDate(undefined);
|
||||||
|
setEditingEndDate(undefined);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
{({ errors }) =>
|
||||||
{editing && (
|
editing && (
|
||||||
<Form
|
<>
|
||||||
action={update(editing.id)}
|
<div className="grid gap-2">
|
||||||
resetOnSuccess
|
<Label>
|
||||||
onSuccess={() => {
|
Tanggal Mulai{' '}
|
||||||
setEditing(null);
|
<span className="text-destructive">
|
||||||
setEditingStartDate(undefined);
|
*
|
||||||
setEditingEndDate(undefined);
|
</span>
|
||||||
}}
|
</Label>
|
||||||
>
|
<input
|
||||||
{({ errors, processing }) => {
|
type="hidden"
|
||||||
return (
|
name="start_date"
|
||||||
<>
|
value={
|
||||||
<DialogHeader>
|
editingStartDate
|
||||||
<DialogTitle>
|
? editingStartDate
|
||||||
Edit Permohonan Cuti
|
.toISOString()
|
||||||
</DialogTitle>
|
.split('T')[0]
|
||||||
</DialogHeader>
|
: ''
|
||||||
<div className="grid gap-4 py-4">
|
}
|
||||||
<div className="grid gap-2">
|
/>
|
||||||
<Label>
|
<DatePicker
|
||||||
Tanggal Mulai{' '}
|
value={editingStartDate}
|
||||||
<span className="text-destructive">
|
onChange={setEditingStartDate}
|
||||||
*
|
placeholder="Pilih tanggal mulai"
|
||||||
</span>
|
min={new Date()}
|
||||||
</Label>
|
/>
|
||||||
<input
|
<InputError message={errors.start_date} />
|
||||||
type="hidden"
|
</div>
|
||||||
name="start_date"
|
<div className="grid gap-2">
|
||||||
value={
|
<Label>
|
||||||
editingStartDate
|
Tanggal Selesai{' '}
|
||||||
? editingStartDate
|
<span className="text-destructive">
|
||||||
.toISOString()
|
*
|
||||||
.split(
|
</span>
|
||||||
'T',
|
</Label>
|
||||||
)[0]
|
<input
|
||||||
: ''
|
type="hidden"
|
||||||
}
|
name="end_date"
|
||||||
/>
|
value={
|
||||||
<DatePicker
|
editingEndDate
|
||||||
value={editingStartDate}
|
? editingEndDate
|
||||||
onChange={
|
.toISOString()
|
||||||
setEditingStartDate
|
.split('T')[0]
|
||||||
}
|
: ''
|
||||||
placeholder="Pilih tanggal mulai"
|
}
|
||||||
min={new Date()}
|
/>
|
||||||
/>
|
<DatePicker
|
||||||
<InputError
|
value={editingEndDate}
|
||||||
message={
|
onChange={setEditingEndDate}
|
||||||
errors.start_date
|
placeholder="Pilih tanggal selesai"
|
||||||
}
|
min={editingStartDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
<InputError message={errors.end_date} />
|
||||||
<div className="grid gap-2">
|
</div>
|
||||||
<Label>
|
</>
|
||||||
Tanggal Selesai{' '}
|
)
|
||||||
<span className="text-destructive">
|
}
|
||||||
*
|
</FormDialog>
|
||||||
</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>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
@ -562,12 +354,11 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
}}
|
}}
|
||||||
title="Hapus Permohonan Cuti"
|
title="Hapus Permohonan Cuti"
|
||||||
description={`Apakah Anda yakin ingin menghapus permohonan cuti ini? Tindakan ini tidak dapat dibatalkan.`}
|
description={`Apakah Anda yakin ingin menghapus permohonan cuti ini? Tindakan ini tidak dapat dibatalkan.`}
|
||||||
confirmLabel="Hapus"
|
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={approving !== null}
|
target={approving}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setApproving(null);
|
setApproving(null);
|
||||||
@ -579,8 +370,8 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
onConfirm={handleApprove}
|
onConfirm={handleApprove}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={rejecting !== null}
|
target={rejecting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setRejecting(null);
|
setRejecting(null);
|
||||||
|
|||||||
@ -41,18 +41,14 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { usePurchaseDraftSave } from '@/hooks/use-purchase-draft';
|
import { usePurchaseDraftSave } from '@/hooks/use-purchase-draft';
|
||||||
|
import { UNITS } from '@/lib/constants';
|
||||||
|
import { formatNumber } from '@/lib/format';
|
||||||
import { loadPurchaseDraft } from '@/lib/purchase-draft';
|
import { loadPurchaseDraft } from '@/lib/purchase-draft';
|
||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases';
|
import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases';
|
||||||
import type { PurchaseCreateData } from './columns';
|
import type { PurchaseCreateData } from './columns';
|
||||||
|
|
||||||
const UNITS = [
|
|
||||||
{ value: 'kg', label: 'Kilogram' },
|
|
||||||
{ value: 'meter', label: 'Meter' },
|
|
||||||
{ value: 'yard', label: 'Yard' },
|
|
||||||
];
|
|
||||||
|
|
||||||
type VariantState = {
|
type VariantState = {
|
||||||
variant: string;
|
variant: string;
|
||||||
price: number;
|
price: number;
|
||||||
@ -383,9 +379,7 @@ export default function PurchaseCreate({ data }: Props) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function formatQuantity(value: number): string {
|
function formatQuantity(value: number): string {
|
||||||
return new Intl.NumberFormat('id-ID', {
|
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||||
maximumFractionDigits: 4,
|
|
||||||
}).format(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPayload() {
|
function getPayload() {
|
||||||
@ -806,9 +800,9 @@ export default function PurchaseCreate({ data }: Props) {
|
|||||||
</Label>
|
</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
items={rawMaterials}
|
items={rawMaterials}
|
||||||
itemToStringLabel={(m) =>
|
itemToStringLabel={(
|
||||||
m.name
|
m,
|
||||||
}
|
) => m.name}
|
||||||
value={selectedMaterial}
|
value={selectedMaterial}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
value,
|
value,
|
||||||
@ -834,10 +828,14 @@ export default function PurchaseCreate({ data }: Props) {
|
|||||||
key={
|
key={
|
||||||
m.id
|
m.id
|
||||||
}
|
}
|
||||||
value={m}
|
value={
|
||||||
|
m
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{m.name}{' '}
|
{m.name}{' '}
|
||||||
({m.unit})
|
(
|
||||||
|
{m.unit}
|
||||||
|
)
|
||||||
</ComboboxItem>
|
</ComboboxItem>
|
||||||
)}
|
)}
|
||||||
</ComboboxList>
|
</ComboboxList>
|
||||||
@ -932,7 +930,9 @@ export default function PurchaseCreate({ data }: Props) {
|
|||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
min={0}
|
min={
|
||||||
|
0
|
||||||
|
}
|
||||||
className="w-24 text-center"
|
className="w-24 text-center"
|
||||||
value={
|
value={
|
||||||
quantities[
|
quantities[
|
||||||
@ -997,9 +997,7 @@ export default function PurchaseCreate({ data }: Props) {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setSupplierId(
|
setSupplierId(
|
||||||
value
|
value
|
||||||
? String(
|
? String(value.id)
|
||||||
value.id,
|
|
||||||
)
|
|
||||||
: '',
|
: '',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import {
|
|||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { formatNumber } from '@/lib/format';
|
||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@ -100,9 +101,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
|||||||
const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url);
|
const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const [mode, setMode] = useState<'new' | 'existing'>(
|
const [mode, setMode] = useState<'new' | 'existing'>(purchase.default_mode);
|
||||||
purchase.default_mode,
|
|
||||||
);
|
|
||||||
const [selectedMaterialName, setSelectedMaterialName] = useState(
|
const [selectedMaterialName, setSelectedMaterialName] = useState(
|
||||||
purchase.existing_material_name ?? '',
|
purchase.existing_material_name ?? '',
|
||||||
);
|
);
|
||||||
@ -321,9 +320,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function formatQuantity(value: number): string {
|
function formatQuantity(value: number): string {
|
||||||
return new Intl.NumberFormat('id-ID', {
|
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||||
maximumFractionDigits: 4,
|
|
||||||
}).format(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPayload() {
|
function getPayload() {
|
||||||
@ -704,9 +701,9 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
|||||||
</Label>
|
</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
items={rawMaterials}
|
items={rawMaterials}
|
||||||
itemToStringLabel={(m) =>
|
itemToStringLabel={(
|
||||||
m.name
|
m,
|
||||||
}
|
) => m.name}
|
||||||
value={selectedMaterial}
|
value={selectedMaterial}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
value,
|
value,
|
||||||
@ -732,10 +729,14 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
|||||||
key={
|
key={
|
||||||
m.id
|
m.id
|
||||||
}
|
}
|
||||||
value={m}
|
value={
|
||||||
|
m
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{m.name}{' '}
|
{m.name}{' '}
|
||||||
({m.unit})
|
(
|
||||||
|
{m.unit}
|
||||||
|
)
|
||||||
</ComboboxItem>
|
</ComboboxItem>
|
||||||
)}
|
)}
|
||||||
</ComboboxList>
|
</ComboboxList>
|
||||||
@ -830,7 +831,9 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
|||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
min={0}
|
min={
|
||||||
|
0
|
||||||
|
}
|
||||||
className="w-24 text-center"
|
className="w-24 text-center"
|
||||||
value={
|
value={
|
||||||
quantities[
|
quantities[
|
||||||
@ -895,9 +898,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setSupplierId(
|
setSupplierId(
|
||||||
value
|
value
|
||||||
? String(
|
? String(value.id)
|
||||||
value.id,
|
|
||||||
)
|
|
||||||
: '',
|
: '',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CardTable } from '@/components/card-table';
|
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 { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
create as purchaseCreate,
|
create as purchaseCreate,
|
||||||
@ -27,7 +29,6 @@ type Props = {
|
|||||||
|
|
||||||
export default function PurchaseIndex({ purchases }: Props) {
|
export default function PurchaseIndex({ purchases }: Props) {
|
||||||
const [deleting, setDeleting] = useState<Purchase | null>(null);
|
const [deleting, setDeleting] = useState<Purchase | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const expand = useCardTableExpand(true);
|
const expand = useCardTableExpand(true);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
@ -37,45 +38,15 @@ export default function PurchaseIndex({ purchases }: Props) {
|
|||||||
total: purchases.total,
|
total: purchases.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
const {
|
||||||
router.get(
|
search,
|
||||||
purchaseIndex.url(),
|
handlePageChange,
|
||||||
{
|
handlePerPageChange,
|
||||||
page,
|
handleSearchChange,
|
||||||
per_page: pagination.per_page,
|
} = useServerTable({
|
||||||
search,
|
route: () => purchaseIndex.url(),
|
||||||
},
|
pagination,
|
||||||
{ 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
@ -92,19 +63,17 @@ export default function PurchaseIndex({ purchases }: Props) {
|
|||||||
<Head title="Belanja" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Belanja"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Belanja
|
<Button asChild>
|
||||||
</h2>
|
<a href={purchaseCreate.url()}>
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
<Button asChild>
|
Tambah
|
||||||
<a href={purchaseCreate.url()}>
|
</a>
|
||||||
<Plus className="h-4 w-4" />
|
</Button>
|
||||||
Tambah
|
}
|
||||||
</a>
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
data={purchases.data}
|
data={purchases.data}
|
||||||
@ -127,7 +96,7 @@ export default function PurchaseIndex({ purchases }: Props) {
|
|||||||
purchase={item}
|
purchase={item}
|
||||||
index={
|
index={
|
||||||
(pagination.current_page - 1) *
|
(pagination.current_page - 1) *
|
||||||
pagination.per_page +
|
pagination.per_page +
|
||||||
index +
|
index +
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
@ -144,16 +113,17 @@ export default function PurchaseIndex({ purchases }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Belanja"
|
title="Hapus Belanja"
|
||||||
description={`Apakah Anda yakin ingin menghapus belanja dari "${deleting?.supplier?.name}"? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan.`}
|
description={(purchase) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus belanja dari "${purchase.supplier?.name}"? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,41 +1,12 @@
|
|||||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||||
import {
|
import { formatCurrency } from '@/lib/utils';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { Purchase } from './columns';
|
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 = {
|
export type PurchaseCardRowParams = {
|
||||||
purchase: Purchase;
|
purchase: Purchase;
|
||||||
index: number;
|
index: number;
|
||||||
@ -57,10 +28,11 @@ export function PurchaseCardRow({
|
|||||||
const variantCount = items.length;
|
const variantCount = items.length;
|
||||||
const rawMaterialName =
|
const rawMaterialName =
|
||||||
items[0]?.raw_material_price?.raw_material?.name ?? '-';
|
items[0]?.raw_material_price?.raw_material?.name ?? '-';
|
||||||
const unit =
|
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||||
items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
const totalQty = items.reduce(
|
||||||
const totalQty = items.reduce((sum, item) => sum + Number(item.quantity), 0);
|
(sum, item) => sum + Number(item.quantity),
|
||||||
const [photoPreviewOpen, setPhotoPreviewOpen] = useState(false);
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||||
<span>
|
<span>
|
||||||
<span className="text-muted-foreground">Qty: </span>
|
<span className="text-muted-foreground">
|
||||||
|
Qty:{' '}
|
||||||
|
</span>
|
||||||
{formatNumber(totalQty)} {unit}
|
{formatNumber(totalQty)} {unit}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="text-muted-foreground">Sub: </span>
|
<span className="text-muted-foreground">
|
||||||
|
Sub:{' '}
|
||||||
|
</span>
|
||||||
{formatCurrency(purchase.subtotal)}
|
{formatCurrency(purchase.subtotal)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="text-muted-foreground">Disc: </span>
|
<span className="text-muted-foreground">
|
||||||
|
Disc:{' '}
|
||||||
|
</span>
|
||||||
{formatCurrency(purchase.discount)}
|
{formatCurrency(purchase.discount)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="text-muted-foreground">Ongkir: </span>
|
<span className="text-muted-foreground">
|
||||||
|
Ongkir:{' '}
|
||||||
|
</span>
|
||||||
{formatCurrency(purchase.shipping_cost)}
|
{formatCurrency(purchase.shipping_cost)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold">
|
<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)}
|
{formatCurrency(purchase.total)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{purchase.photo_url && (
|
{purchase.photo_url && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<ImagePreviewButton
|
||||||
type="button"
|
srcs={[purchase.photo_url]}
|
||||||
onClick={() => setPhotoPreviewOpen(true)}
|
title="Foto Belanja"
|
||||||
className="block h-16 w-16 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
className="h-16 w-16"
|
||||||
>
|
/>
|
||||||
<img
|
|
||||||
src={purchase.photo_url}
|
|
||||||
alt="Foto belanja"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => onEdit(purchase),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => onEdit(purchase)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => onDelete(purchase),
|
||||||
</Tooltip>
|
},
|
||||||
<Tooltip>
|
]}
|
||||||
<TooltipTrigger asChild>
|
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { ImagePreviewButton } from '@/components/image-preview-button';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -8,67 +7,13 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { formatNumber } from '@/lib/format';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import type { Purchase } from './columns';
|
import type { Purchase } from './columns';
|
||||||
|
|
||||||
function formatCurrency(amount: number): string {
|
export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
||||||
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;
|
|
||||||
}) {
|
|
||||||
const items = purchase.purchase_items ?? [];
|
const items = purchase.purchase_items ?? [];
|
||||||
const unit =
|
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||||
items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-x-auto">
|
<div className="space-y-4 overflow-x-auto">
|
||||||
@ -103,9 +48,14 @@ export function PurchaseItemSubRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{item.raw_material_price?.photo_url ? (
|
{item.raw_material_price?.photo_url ? (
|
||||||
<VariantPhotoPreview
|
<ImagePreviewButton
|
||||||
url={item.raw_material_price.photo_url}
|
srcs={[
|
||||||
title={item.raw_material_price.variant}
|
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">
|
<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';
|
} from '@/components/ui/sheet';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useRestockDraftSave } from '@/hooks/use-restock-draft';
|
import { useRestockDraftSave } from '@/hooks/use-restock-draft';
|
||||||
|
import { formatNumber } from '@/lib/format';
|
||||||
import { loadRestockDraft } from '@/lib/restock-draft';
|
import { loadRestockDraft } from '@/lib/restock-draft';
|
||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
@ -107,8 +108,7 @@ export default function RestockCreate({ data }: Props) {
|
|||||||
}, [quantities]);
|
}, [quantities]);
|
||||||
|
|
||||||
const selectedProduct = useMemo(
|
const selectedProduct = useMemo(
|
||||||
() =>
|
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
|
||||||
products.find((p) => String(p.id) === selectedProductId) ?? null,
|
|
||||||
[products, selectedProductId],
|
[products, selectedProductId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -178,9 +178,7 @@ export default function RestockCreate({ data }: Props) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function formatQuantity(value: number): string {
|
function formatQuantity(value: number): string {
|
||||||
return new Intl.NumberFormat('id-ID', {
|
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||||
maximumFractionDigits: 4,
|
|
||||||
}).format(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPayload() {
|
function getPayload() {
|
||||||
@ -292,8 +290,7 @@ export default function RestockCreate({ data }: Props) {
|
|||||||
(quantities[
|
(quantities[
|
||||||
variant
|
variant
|
||||||
.id
|
.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 border-primary p-3'
|
||||||
: 'flex items-center justify-between gap-3 rounded-lg border 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
|
pcs
|
||||||
·{' '}
|
·{' '}
|
||||||
{
|
{formatCurrency(
|
||||||
formatCurrency(
|
variant.capital_price,
|
||||||
variant.capital_price,
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -416,8 +411,7 @@ export default function RestockCreate({ data }: Props) {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setStockType(
|
setStockType(
|
||||||
value as
|
value as
|
||||||
| 'good'
|
'good' | 'reject',
|
||||||
| 'reject',
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex flex-wrap gap-4"
|
className="flex flex-wrap gap-4"
|
||||||
@ -507,9 +501,7 @@ export default function RestockCreate({ data }: Props) {
|
|||||||
}}
|
}}
|
||||||
folder="restock"
|
folder="restock"
|
||||||
existingUrl={photoUrl}
|
existingUrl={photoUrl}
|
||||||
onUploadingChange={
|
onUploadingChange={setUploading}
|
||||||
setUploading
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.photo_key}
|
message={errors.photo_key}
|
||||||
@ -523,9 +515,9 @@ export default function RestockCreate({ data }: Props) {
|
|||||||
processing ||
|
processing ||
|
||||||
uploading ||
|
uploading ||
|
||||||
!selectedProductId ||
|
!selectedProductId ||
|
||||||
Object.values(
|
Object.values(quantities).every(
|
||||||
quantities,
|
(q) => q <= 0,
|
||||||
).every((q) => q <= 0)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
@ -648,8 +640,7 @@ export default function RestockCreate({ data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatCurrency(
|
{formatCurrency(
|
||||||
item.price *
|
item.price * item.quantity,
|
||||||
item.quantity,
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -20,12 +20,10 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { formatNumber } from '@/lib/format';
|
||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import {
|
import { index as restockIndex, update } from '@/routes/admin/manage/restocks';
|
||||||
index as restockIndex,
|
|
||||||
update,
|
|
||||||
} from '@/routes/admin/manage/restocks';
|
|
||||||
import type { RestockCreateData, RestockForEdit } from './columns';
|
import type { RestockCreateData, RestockForEdit } from './columns';
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
@ -71,9 +69,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
|||||||
);
|
);
|
||||||
const [notes, setNotes] = useState(restock.notes ?? '');
|
const [notes, setNotes] = useState(restock.notes ?? '');
|
||||||
const [photo, setPhoto] = useState<string | null>(restock.photo_key);
|
const [photo, setPhoto] = useState<string | null>(restock.photo_key);
|
||||||
const [photoUrl, setPhotoUrl] = useState<string | null>(
|
const [photoUrl, setPhotoUrl] = useState<string | null>(restock.photo_url);
|
||||||
restock.photo_url,
|
|
||||||
);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [cartOpen, setCartOpen] = useState(false);
|
const [cartOpen, setCartOpen] = useState(false);
|
||||||
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||||
@ -86,8 +82,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
|||||||
}, [quantities]);
|
}, [quantities]);
|
||||||
|
|
||||||
const selectedProduct = useMemo(
|
const selectedProduct = useMemo(
|
||||||
() =>
|
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
|
||||||
products.find((p) => String(p.id) === selectedProductId) ?? null,
|
|
||||||
[products, selectedProductId],
|
[products, selectedProductId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -157,9 +152,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function formatQuantity(value: number): string {
|
function formatQuantity(value: number): string {
|
||||||
return new Intl.NumberFormat('id-ID', {
|
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||||
maximumFractionDigits: 4,
|
|
||||||
}).format(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPayload() {
|
function getPayload() {
|
||||||
@ -225,8 +218,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
|||||||
(quantities[
|
(quantities[
|
||||||
variant
|
variant
|
||||||
.id
|
.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 border-primary p-3'
|
||||||
: 'flex items-center justify-between gap-3 rounded-lg border 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
|
pcs
|
||||||
·{' '}
|
·{' '}
|
||||||
{
|
{formatCurrency(
|
||||||
formatCurrency(
|
variant.capital_price,
|
||||||
variant.capital_price,
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -354,8 +344,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setStockType(
|
setStockType(
|
||||||
value as
|
value as
|
||||||
| 'good'
|
'good' | 'reject',
|
||||||
| 'reject',
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex flex-wrap gap-4"
|
className="flex flex-wrap gap-4"
|
||||||
@ -445,9 +434,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
|||||||
}}
|
}}
|
||||||
folder="restock"
|
folder="restock"
|
||||||
existingUrl={photoUrl}
|
existingUrl={photoUrl}
|
||||||
onUploadingChange={
|
onUploadingChange={setUploading}
|
||||||
setUploading
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.photo_key}
|
message={errors.photo_key}
|
||||||
@ -460,9 +447,9 @@ export default function RestockEdit({ restock, data }: Props) {
|
|||||||
disabled={
|
disabled={
|
||||||
processing ||
|
processing ||
|
||||||
uploading ||
|
uploading ||
|
||||||
Object.values(
|
Object.values(quantities).every(
|
||||||
quantities,
|
(q) => q <= 0,
|
||||||
).every((q) => q <= 0)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
@ -585,8 +572,7 @@ export default function RestockEdit({ restock, data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatCurrency(
|
{formatCurrency(
|
||||||
item.price *
|
item.price * item.quantity,
|
||||||
item.quantity,
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CardTable } from '@/components/card-table';
|
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 { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
create as restockCreate,
|
create as restockCreate,
|
||||||
@ -27,7 +29,6 @@ type Props = {
|
|||||||
|
|
||||||
export default function RestockIndex({ restocks }: Props) {
|
export default function RestockIndex({ restocks }: Props) {
|
||||||
const [deleting, setDeleting] = useState<Restock | null>(null);
|
const [deleting, setDeleting] = useState<Restock | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const expand = useCardTableExpand(true);
|
const expand = useCardTableExpand(true);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
@ -37,45 +38,15 @@ export default function RestockIndex({ restocks }: Props) {
|
|||||||
total: restocks.total,
|
total: restocks.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
const {
|
||||||
router.get(
|
search,
|
||||||
restockIndex.url(),
|
handlePageChange,
|
||||||
{
|
handlePerPageChange,
|
||||||
page,
|
handleSearchChange,
|
||||||
per_page: pagination.per_page,
|
} = useServerTable({
|
||||||
search,
|
route: () => restockIndex.url(),
|
||||||
},
|
pagination,
|
||||||
{ 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
@ -92,19 +63,17 @@ export default function RestockIndex({ restocks }: Props) {
|
|||||||
<Head title="Restock" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Restock"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Restock
|
<Button asChild>
|
||||||
</h2>
|
<a href={restockCreate.url()}>
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
<Button asChild>
|
Tambah
|
||||||
<a href={restockCreate.url()}>
|
</a>
|
||||||
<Plus className="h-4 w-4" />
|
</Button>
|
||||||
Tambah
|
}
|
||||||
</a>
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
data={restocks.data}
|
data={restocks.data}
|
||||||
@ -127,7 +96,7 @@ export default function RestockIndex({ restocks }: Props) {
|
|||||||
restock={item}
|
restock={item}
|
||||||
index={
|
index={
|
||||||
(pagination.current_page - 1) *
|
(pagination.current_page - 1) *
|
||||||
pagination.per_page +
|
pagination.per_page +
|
||||||
index +
|
index +
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
@ -144,8 +113,8 @@ export default function RestockIndex({ restocks }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
@ -153,7 +122,6 @@ export default function RestockIndex({ restocks }: Props) {
|
|||||||
}}
|
}}
|
||||||
title="Hapus Restock"
|
title="Hapus Restock"
|
||||||
description="Apakah Anda yakin ingin menghapus restock ini? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan."
|
description="Apakah Anda yakin ingin menghapus restock ini? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan."
|
||||||
confirmLabel="Hapus"
|
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,41 +1,12 @@
|
|||||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||||
Tooltip,
|
import { formatCurrency } from '@/lib/utils';
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { Restock, RestockStockType } from './columns';
|
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<
|
const STOCK_TYPE_CONFIG: Record<
|
||||||
RestockStockType,
|
RestockStockType,
|
||||||
{ label: string; className: string }
|
{ label: string; className: string }
|
||||||
@ -71,7 +42,9 @@ export function RestockCardRow({
|
|||||||
const variantCount = items.length;
|
const variantCount = items.length;
|
||||||
const productNames = [
|
const productNames = [
|
||||||
...new Set(
|
...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(
|
const totalQty = items.reduce(
|
||||||
@ -152,7 +125,7 @@ export function RestockCardRow({
|
|||||||
{formatCurrency(restock.subtotal)}
|
{formatCurrency(restock.subtotal)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
<span className="text-muted-foreground font-normal">
|
<span className="font-normal text-muted-foreground">
|
||||||
Total:{' '}
|
Total:{' '}
|
||||||
</span>
|
</span>
|
||||||
{formatCurrency(restock.total)}
|
{formatCurrency(restock.total)}
|
||||||
@ -160,36 +133,23 @@ export function RestockCardRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => onEdit(restock),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => onEdit(restock)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => onDelete(restock),
|
||||||
</Tooltip>
|
},
|
||||||
<Tooltip>
|
]}
|
||||||
<TooltipTrigger asChild>
|
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -8,48 +7,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { formatNumber } from '@/lib/format';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import type { Restock } from './columns';
|
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 }) {
|
export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
||||||
const items = restock.restock_items ?? [];
|
const items = restock.restock_items ?? [];
|
||||||
|
|
||||||
@ -89,8 +50,10 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{item.product_variant?.photo_url ? (
|
{item.product_variant?.photo_url ? (
|
||||||
<VariantPhotoPreview
|
<ImagePreviewButton
|
||||||
url={item.product_variant.photo_url}
|
srcs={[
|
||||||
|
item.product_variant.photo_url,
|
||||||
|
]}
|
||||||
title={item.product_variant.name}
|
title={item.product_variant.name}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
export type Category = {
|
export type Category = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -44,39 +38,22 @@ export function createCategoryColumns(
|
|||||||
const category = row.original;
|
const category = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(category),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(category)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleDeleteClick(category),
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,20 +1,16 @@
|
|||||||
import { Form, Head, router } from '@inertiajs/react';
|
import { Form, Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
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 InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
index as categoryIndex,
|
index as categoryIndex,
|
||||||
@ -39,7 +35,6 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Category | null>(null);
|
const [editing, setEditing] = useState<Category | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Category | null>(null);
|
const [deleting, setDeleting] = useState<Category | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: categories.current_page,
|
current_page: categories.current_page,
|
||||||
@ -48,54 +43,15 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
|||||||
total: categories.total,
|
total: categories.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
const {
|
||||||
router.get(
|
search,
|
||||||
categoryIndex.url(),
|
handlePageChange,
|
||||||
{
|
handlePerPageChange,
|
||||||
page,
|
handleSearchChange,
|
||||||
per_page: pagination.per_page,
|
} = useServerTable({
|
||||||
search,
|
route: () => categoryIndex.url(),
|
||||||
},
|
pagination,
|
||||||
{
|
});
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
@ -117,12 +73,10 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
|||||||
<Head title="Kategori" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Kategori"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
description={
|
||||||
Kategori
|
highlight && (
|
||||||
</h2>
|
|
||||||
{highlight && (
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Menampilkan kategori dari notifikasi.
|
Menampilkan kategori dari notifikasi.
|
||||||
<button
|
<button
|
||||||
@ -141,9 +95,9 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
|||||||
Tampilkan semua
|
Tampilkan semua
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
actions={
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -153,64 +107,62 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
|||||||
Tambah
|
Tambah
|
||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
}
|
||||||
<Form
|
/>
|
||||||
action={store()}
|
|
||||||
resetOnSuccess
|
<FormDialog
|
||||||
onSuccess={() => setCreateOpen(false)}
|
open={createOpen}
|
||||||
>
|
onOpenChange={setCreateOpen}
|
||||||
{({ errors, processing }) => {
|
title="Tambah Kategori"
|
||||||
return (
|
action={store()}
|
||||||
<>
|
resetOnSuccess
|
||||||
<DialogHeader>
|
onSuccess={() => setCreateOpen(false)}
|
||||||
<DialogTitle>
|
>
|
||||||
Tambah Kategori
|
{({ errors }) => (
|
||||||
</DialogTitle>
|
<div className="grid gap-2">
|
||||||
</DialogHeader>
|
<Label htmlFor="name">
|
||||||
<div className="grid gap-4 py-4">
|
Nama <span className="text-destructive">*</span>
|
||||||
<div className="grid gap-2">
|
</Label>
|
||||||
<Label htmlFor="name">
|
<Input
|
||||||
Nama{' '}
|
id="name"
|
||||||
<span className="text-destructive">
|
name="name"
|
||||||
*
|
placeholder="Masukkan nama kategori"
|
||||||
</span>
|
/>
|
||||||
</Label>
|
<InputError message={errors.name} />
|
||||||
<Input
|
</div>
|
||||||
id="name"
|
)}
|
||||||
name="name"
|
</FormDialog>
|
||||||
placeholder="Masukkan nama kategori"
|
|
||||||
/>
|
<FormDialog
|
||||||
<InputError
|
open={editing !== null}
|
||||||
message={errors.name}
|
onOpenChange={(open) => {
|
||||||
/>
|
if (!open) {
|
||||||
</div>
|
setEditing(null);
|
||||||
</div>
|
}
|
||||||
<DialogFooter>
|
}}
|
||||||
<Button
|
title="Edit Kategori"
|
||||||
type="button"
|
action={editing ? update(editing.id) : ''}
|
||||||
variant="outline"
|
resetOnSuccess
|
||||||
onClick={() =>
|
onSuccess={() => setEditing(null)}
|
||||||
setCreateOpen(false)
|
>
|
||||||
}
|
{({ errors }) =>
|
||||||
>
|
editing && (
|
||||||
Batal
|
<div className="grid gap-2">
|
||||||
</Button>
|
<Label htmlFor="edit-name">
|
||||||
<Button
|
Nama{' '}
|
||||||
type="submit"
|
<span className="text-destructive">*</span>
|
||||||
disabled={processing}
|
</Label>
|
||||||
>
|
<Input
|
||||||
{processing
|
id="edit-name"
|
||||||
? 'Menyimpan...'
|
name="name"
|
||||||
: 'Simpan'}
|
placeholder="Masukkan nama kategori"
|
||||||
</Button>
|
defaultValue={editing.name}
|
||||||
</DialogFooter>
|
/>
|
||||||
</>
|
<InputError message={errors.name} />
|
||||||
);
|
</div>
|
||||||
}}
|
)
|
||||||
</Form>
|
}
|
||||||
</DialogContent>
|
</FormDialog>
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -225,87 +177,17 @@ export default function CategoryIndex({ categories, highlight }: Props) {
|
|||||||
searchValue={search}
|
searchValue={search}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<DeleteConfirmDialog
|
||||||
open={editing !== null}
|
target={deleting}
|
||||||
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}
|
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Kategori"
|
title="Hapus Kategori"
|
||||||
description={`Apakah Anda yakin ingin menghapus kategori "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(category) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus kategori "${category.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
export type Customer = {
|
export type Customer = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -62,39 +56,22 @@ export function createCustomerColumns(
|
|||||||
const customer = row.original;
|
const customer = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(customer),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(customer)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleDeleteClick(customer),
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } 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 InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
index as customerIndex,
|
|
||||||
destroy,
|
destroy,
|
||||||
|
index as customerIndex,
|
||||||
store,
|
store,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/master/customers';
|
} 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 type { Customer } from './columns';
|
||||||
import { createCustomerColumns } from './columns';
|
import { createCustomerColumns } from './columns';
|
||||||
|
|
||||||
@ -39,7 +35,7 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Customer | null>(null);
|
const [editing, setEditing] = useState<Customer | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Customer | null>(null);
|
const [deleting, setDeleting] = useState<Customer | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: customers.current_page,
|
current_page: customers.current_page,
|
||||||
last_page: customers.last_page,
|
last_page: customers.last_page,
|
||||||
@ -47,54 +43,15 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
total: customers.total,
|
total: customers.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
const {
|
||||||
router.get(
|
search,
|
||||||
customerIndex.url(),
|
handlePageChange,
|
||||||
{
|
handlePerPageChange,
|
||||||
page,
|
handleSearchChange,
|
||||||
per_page: pagination.per_page,
|
} = useServerTable({
|
||||||
search,
|
route: () => customerIndex.url(),
|
||||||
},
|
pagination,
|
||||||
{
|
});
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
@ -116,13 +73,9 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
<Head title="Customer" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Customer"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Customer
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -132,88 +85,105 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
Tambah
|
Tambah
|
||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
}
|
||||||
<Form
|
/>
|
||||||
action={store()}
|
|
||||||
resetOnSuccess
|
<FormDialog
|
||||||
onSuccess={() => setCreateOpen(false)}
|
open={createOpen}
|
||||||
>
|
onOpenChange={setCreateOpen}
|
||||||
{({ errors, processing }) => {
|
title="Tambah Customer"
|
||||||
return (
|
action={store()}
|
||||||
<>
|
resetOnSuccess
|
||||||
<DialogHeader>
|
onSuccess={() => setCreateOpen(false)}
|
||||||
<DialogTitle>
|
>
|
||||||
Tambah Customer
|
{({ errors }) => (
|
||||||
</DialogTitle>
|
<div className="grid gap-4 py-4">
|
||||||
</DialogHeader>
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-4 py-4">
|
<Label htmlFor="name">
|
||||||
<div className="grid gap-2">
|
Nama{' '}
|
||||||
<Label htmlFor="name">
|
<span className="text-destructive">*</span>
|
||||||
Nama{' '}
|
</Label>
|
||||||
<span className="text-destructive">
|
<Input
|
||||||
*
|
id="name"
|
||||||
</span>
|
name="name"
|
||||||
</Label>
|
placeholder="Masukkan nama customer"
|
||||||
<Input
|
/>
|
||||||
id="name"
|
<InputError message={errors.name} />
|
||||||
name="name"
|
</div>
|
||||||
placeholder="Masukkan nama customer"
|
<div className="grid gap-2">
|
||||||
/>
|
<Label htmlFor="phone_number">
|
||||||
<InputError
|
No. Telepon
|
||||||
message={errors.name}
|
</Label>
|
||||||
/>
|
<PhoneNumberInput name="phone_number" />
|
||||||
</div>
|
<InputError message={errors.phone_number} />
|
||||||
<div className="grid gap-2">
|
</div>
|
||||||
<Label htmlFor="phone_number">
|
<div className="grid gap-2">
|
||||||
No. Telepon
|
<Label htmlFor="address">Alamat</Label>
|
||||||
</Label>
|
<Input
|
||||||
<PhoneNumberInput name="phone_number" />
|
id="address"
|
||||||
<InputError
|
name="address"
|
||||||
message={
|
placeholder="Masukkan alamat"
|
||||||
errors.phone_number
|
/>
|
||||||
}
|
<InputError message={errors.address} />
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
)}
|
||||||
<Label htmlFor="address">
|
</FormDialog>
|
||||||
Alamat
|
|
||||||
</Label>
|
<FormDialog
|
||||||
<Input
|
open={editing !== null}
|
||||||
id="address"
|
onOpenChange={(open) => {
|
||||||
name="address"
|
if (!open) {
|
||||||
placeholder="Masukkan alamat"
|
setEditing(null);
|
||||||
/>
|
}
|
||||||
<InputError
|
}}
|
||||||
message={errors.address}
|
title="Edit Customer"
|
||||||
/>
|
action={editing ? update(editing.id) : ''}
|
||||||
</div>
|
resetOnSuccess
|
||||||
</div>
|
onSuccess={() => setEditing(null)}
|
||||||
<DialogFooter>
|
>
|
||||||
<Button
|
{({ errors }) =>
|
||||||
type="button"
|
editing && (
|
||||||
variant="outline"
|
<div className="grid gap-4 py-4">
|
||||||
onClick={() =>
|
<div className="grid gap-2">
|
||||||
setCreateOpen(false)
|
<Label htmlFor="edit-name">
|
||||||
}
|
Nama{' '}
|
||||||
>
|
<span className="text-destructive">
|
||||||
Batal
|
*
|
||||||
</Button>
|
</span>
|
||||||
<Button
|
</Label>
|
||||||
type="submit"
|
<Input
|
||||||
disabled={processing}
|
id="edit-name"
|
||||||
>
|
name="name"
|
||||||
{processing
|
placeholder="Masukkan nama customer"
|
||||||
? 'Menyimpan...'
|
defaultValue={editing.name}
|
||||||
: 'Simpan'}
|
/>
|
||||||
</Button>
|
<InputError message={errors.name} />
|
||||||
</DialogFooter>
|
</div>
|
||||||
</>
|
<div className="grid gap-2">
|
||||||
);
|
<Label htmlFor="edit-phone_number">
|
||||||
}}
|
No. Telepon
|
||||||
</Form>
|
</Label>
|
||||||
</DialogContent>
|
<PhoneNumberInput
|
||||||
</Dialog>
|
name="phone_number"
|
||||||
</div>
|
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
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -228,115 +198,17 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
searchValue={search}
|
searchValue={search}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<DeleteConfirmDialog
|
||||||
open={editing !== null}
|
target={deleting}
|
||||||
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}
|
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Customer"
|
title="Hapus Customer"
|
||||||
description={`Apakah Anda yakin ingin menghapus customer "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(customer) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus customer "${customer.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { ChevronRight, Pencil, Trash2 } from 'lucide-react';
|
import { ChevronRight, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import { formatNumber } from '@/lib/format';
|
||||||
Tooltip,
|
import { formatCurrency } from '@/lib/utils';
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
export type ProductVariant = {
|
export type ProductVariant = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -58,23 +55,12 @@ function getStatusVariant(status: string): string {
|
|||||||
return variants[status] ?? 'bg-gray-100 text-gray-800';
|
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(
|
function getFilteredVariants(
|
||||||
allVariants: ProductVariant[],
|
allVariants: ProductVariant[],
|
||||||
searchValue: string,
|
searchValue: string,
|
||||||
): ProductVariant[] {
|
): ProductVariant[] {
|
||||||
const query = searchValue.toLowerCase().trim();
|
const query = searchValue.toLowerCase().trim();
|
||||||
|
|
||||||
return query
|
return query
|
||||||
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
|
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
|
||||||
: allVariants;
|
: allVariants;
|
||||||
@ -361,39 +347,22 @@ export function createProductColumns(
|
|||||||
const product = row.original;
|
const product = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(product),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(product)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleDeleteClick(product),
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Filter, Plus, X } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { CardTable } from '@/components/card-table';
|
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 { 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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
@ -13,11 +15,6 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxList,
|
ComboboxList,
|
||||||
} from '@/components/ui/combobox';
|
} from '@/components/ui/combobox';
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -25,6 +22,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
create as productCreate,
|
create as productCreate,
|
||||||
@ -66,11 +64,7 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
product: Product;
|
product: Product;
|
||||||
variant: ProductVariant;
|
variant: ProductVariant;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const expand = useCardTableExpand(true);
|
const expand = useCardTableExpand(true);
|
||||||
const hasActiveFilters =
|
|
||||||
filters.status || filters.name || filters.stock || filters.category;
|
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
current_page: products.current_page,
|
current_page: products.current_page,
|
||||||
@ -79,87 +73,33 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
total: products.total,
|
total: products.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
filterOpen,
|
||||||
|
setFilterOpen,
|
||||||
|
handlePageChange,
|
||||||
|
handlePerPageChange,
|
||||||
|
handleSearchChange,
|
||||||
|
applyFilter,
|
||||||
|
clearFilters,
|
||||||
|
} = useServerTable({
|
||||||
|
route: () => productIndex.url(),
|
||||||
|
pagination,
|
||||||
|
filters,
|
||||||
|
filterWithParams: false,
|
||||||
|
});
|
||||||
|
|
||||||
const productNames = useMemo(() => {
|
const productNames = useMemo(() => {
|
||||||
const names = products.data.map((p) => p.name);
|
const names = products.data.map((p) => p.name);
|
||||||
|
|
||||||
return [...new Set(names)].sort();
|
return [...new Set(names)].sort();
|
||||||
}, [products.data]);
|
}, [products.data]);
|
||||||
|
|
||||||
const selectedCategory = useMemo(
|
const selectedCategory = useMemo(
|
||||||
() =>
|
() => categories.find((c) => String(c.id) === filters.category) ?? null,
|
||||||
categories.find((c) => String(c.id) === filters.category) ?? null,
|
|
||||||
[categories, filters.category],
|
[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() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
return;
|
return;
|
||||||
@ -187,153 +127,117 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filterToolbar = (
|
const filterToolbar = (
|
||||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
<FilterPopover
|
||||||
<PopoverTrigger asChild>
|
open={filterOpen}
|
||||||
<Button variant="outline" size="sm">
|
onOpenChange={setFilterOpen}
|
||||||
<Filter className="h-4 w-4" />
|
filters={filters}
|
||||||
Filter
|
hasActiveFilters={Boolean(
|
||||||
{hasActiveFilters && (
|
filters.status ||
|
||||||
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
filters.name ||
|
||||||
{Object.values(filters).filter(Boolean).length}
|
filters.stock ||
|
||||||
</span>
|
filters.category,
|
||||||
)}
|
)}
|
||||||
</Button>
|
onClear={clearFilters}
|
||||||
</PopoverTrigger>
|
>
|
||||||
<PopoverContent className="w-64" align="end">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-4">
|
<label className="text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between">
|
Nama Produk
|
||||||
<span className="text-sm font-medium">Filter</span>
|
</label>
|
||||||
{hasActiveFilters && (
|
<Combobox
|
||||||
<Button
|
items={productNames}
|
||||||
variant="ghost"
|
value={filters.name ?? ''}
|
||||||
size="sm"
|
onValueChange={(value) =>
|
||||||
className="h-6 px-2 text-xs"
|
applyFilter('name', (value as string) ?? '')
|
||||||
onClick={clearFilters}
|
}
|
||||||
>
|
>
|
||||||
<X className="mr-1 h-3 w-3" />
|
<ComboboxInput
|
||||||
Hapus Semua
|
placeholder="Pilih produk..."
|
||||||
</Button>
|
className="w-full"
|
||||||
)}
|
/>
|
||||||
</div>
|
<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">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">Status</label>
|
||||||
Nama Produk
|
<Select
|
||||||
</label>
|
value={filters.status ?? 'all'}
|
||||||
<Combobox
|
onValueChange={(value) => applyFilter('status', value)}
|
||||||
items={productNames}
|
>
|
||||||
value={filters.name ?? ''}
|
<SelectTrigger className="w-full">
|
||||||
onValueChange={(value) =>
|
<SelectValue placeholder="Semua Status" />
|
||||||
applyFilter('name', (value as string) ?? '')
|
</SelectTrigger>
|
||||||
}
|
<SelectContent>
|
||||||
>
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
<ComboboxInput
|
<SelectItem value="active">Aktif</SelectItem>
|
||||||
placeholder="Pilih produk..."
|
<SelectItem value="inactive">Non Aktif</SelectItem>
|
||||||
className="w-full"
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
/>
|
</SelectContent>
|
||||||
<ComboboxContent>
|
</Select>
|
||||||
<ComboboxEmpty>
|
</div>
|
||||||
Tidak ada produk ditemukan.
|
|
||||||
</ComboboxEmpty>
|
|
||||||
<ComboboxList>
|
|
||||||
{(name) => (
|
|
||||||
<ComboboxItem value={name}>
|
|
||||||
{name}
|
|
||||||
</ComboboxItem>
|
|
||||||
)}
|
|
||||||
</ComboboxList>
|
|
||||||
</ComboboxContent>
|
|
||||||
</Combobox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">Stok</label>
|
||||||
Status
|
<span className="-mt-1 text-[0.65rem] text-muted-foreground/70">
|
||||||
</label>
|
Berdasarkan stok bagus
|
||||||
<Select
|
</span>
|
||||||
value={filters.status ?? 'all'}
|
<Select
|
||||||
onValueChange={(value) =>
|
value={filters.stock ?? 'all'}
|
||||||
applyFilter('status', value)
|
onValueChange={(value) => applyFilter('stock', value)}
|
||||||
}
|
>
|
||||||
>
|
<SelectTrigger className="w-full">
|
||||||
<SelectTrigger className="w-full">
|
<SelectValue placeholder="Semua Stok" />
|
||||||
<SelectValue placeholder="Semua Status" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value="all">Semua Stok</SelectItem>
|
||||||
<SelectItem value="all">
|
<SelectItem value="empty">Habis</SelectItem>
|
||||||
Semua Status
|
<SelectItem value="low">
|
||||||
</SelectItem>
|
Menipis (di bawah 10)
|
||||||
<SelectItem value="active">Aktif</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="inactive">
|
</SelectContent>
|
||||||
Non Aktif
|
</Select>
|
||||||
</SelectItem>
|
</div>
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">
|
||||||
Stok
|
Kategori
|
||||||
</label>
|
</label>
|
||||||
<span className="-mt-1 text-[0.65rem] text-muted-foreground/70">
|
<Combobox
|
||||||
Berdasarkan stok bagus
|
items={categories}
|
||||||
</span>
|
itemToStringLabel={(cat) => cat.name}
|
||||||
<Select
|
value={selectedCategory}
|
||||||
value={filters.stock ?? 'all'}
|
onValueChange={(value) =>
|
||||||
onValueChange={(value) =>
|
applyFilter('category', value ? String(value.id) : '')
|
||||||
applyFilter('stock', value)
|
}
|
||||||
}
|
>
|
||||||
>
|
<ComboboxInput
|
||||||
<SelectTrigger className="w-full">
|
placeholder="Pilih kategori..."
|
||||||
<SelectValue placeholder="Semua Stok" />
|
className="w-full"
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
<ComboboxContent>
|
||||||
<SelectItem value="all">Semua Stok</SelectItem>
|
<ComboboxEmpty>
|
||||||
<SelectItem value="empty">Habis</SelectItem>
|
Tidak ada kategori ditemukan.
|
||||||
<SelectItem value="low">
|
</ComboboxEmpty>
|
||||||
Menipis (di bawah 10)
|
<ComboboxList>
|
||||||
</SelectItem>
|
{(cat) => (
|
||||||
</SelectContent>
|
<ComboboxItem value={cat}>
|
||||||
</Select>
|
{cat.name}
|
||||||
</div>
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</ComboboxList>
|
||||||
<label className="text-xs text-muted-foreground">
|
</ComboboxContent>
|
||||||
Kategori
|
</Combobox>
|
||||||
</label>
|
</div>
|
||||||
<Combobox
|
</FilterPopover>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -341,19 +245,17 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
<Head title="Produk" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Produk"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Produk
|
<Button asChild>
|
||||||
</h2>
|
<a href={productCreate.url()}>
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
<Button asChild>
|
Tambah
|
||||||
<a href={productCreate.url()}>
|
</a>
|
||||||
<Plus className="h-4 w-4" />
|
</Button>
|
||||||
Tambah
|
}
|
||||||
</a>
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
data={products.data}
|
data={products.data}
|
||||||
@ -406,29 +308,31 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Produk"
|
title="Hapus Produk"
|
||||||
description={`Apakah Anda yakin ingin menghapus produk "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(product) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus produk "${product.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deletingVariant !== null}
|
target={deletingVariant}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeletingVariant(null);
|
setDeletingVariant(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Varian"
|
title="Hapus Varian"
|
||||||
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.name}" dari produk "${deletingVariant?.product.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(target) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus varian "${target.variant.name}" dari produk "${target.product.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDeleteVariant}
|
onConfirm={handleDeleteVariant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,26 +1,18 @@
|
|||||||
import { router } from '@inertiajs/react';
|
|
||||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { formatNumber } from '@/lib/format';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { Product } from './columns';
|
import type { Product } from './columns';
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
return new Intl.NumberFormat('id-ID').format(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(status: string): string {
|
function getStatusLabel(status: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
active: 'Aktif',
|
active: 'Aktif',
|
||||||
inactive: 'Non Aktif',
|
inactive: 'Non Aktif',
|
||||||
draft: 'Draft',
|
draft: 'Draft',
|
||||||
};
|
};
|
||||||
|
|
||||||
return labels[status] ?? status;
|
return labels[status] ?? status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +22,7 @@ function getStatusVariant(status: string): string {
|
|||||||
inactive: 'bg-red-100 text-red-800',
|
inactive: 'bg-red-100 text-red-800',
|
||||||
draft: 'bg-yellow-100 text-yellow-800',
|
draft: 'bg-yellow-100 text-yellow-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
return variants[status] ?? 'bg-gray-100 text-gray-800';
|
return variants[status] ?? 'bg-gray-100 text-gray-800';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,10 +61,6 @@ export function ProductCardRow({
|
|||||||
product.status === 'active' || product.status === 'inactive';
|
product.status === 'active' || product.status === 'inactive';
|
||||||
const isChecked = product.status === 'active';
|
const isChecked = product.status === 'active';
|
||||||
|
|
||||||
function handleToggle() {
|
|
||||||
router.post(toggleStatusUrl(product.id), {}, { preserveScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@ -138,18 +127,11 @@ export function ProductCardRow({
|
|||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{isToggleable ? (
|
{isToggleable ? (
|
||||||
<div className="flex items-center gap-2">
|
<ToggleStatus
|
||||||
<Switch
|
url={toggleStatusUrl(product.id)}
|
||||||
size="sm"
|
checked={isChecked}
|
||||||
checked={isChecked}
|
label={getStatusLabel(product.status)}
|
||||||
onCheckedChange={handleToggle}
|
/>
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}
|
|
||||||
>
|
|
||||||
{getStatusLabel(product.status)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => onEdit(product),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => onEdit(product)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => onDelete(product),
|
||||||
</Tooltip>
|
},
|
||||||
<Tooltip>
|
]}
|
||||||
<TooltipTrigger asChild>
|
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { Head } from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@ -13,8 +9,12 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
} from 'lucide-react';
|
} 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 { index as productIndex } from '@/routes/admin/master/products';
|
||||||
|
import { stockMutations } from '@/routes/admin/master/products/variants';
|
||||||
|
|
||||||
type Mutation = {
|
type Mutation = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -47,10 +47,6 @@ type Props = {
|
|||||||
mutations: PaginatedMutations;
|
mutations: PaginatedMutations;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
return new Intl.NumberFormat('id-ID').format(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Intl.DateTimeFormat('id-ID', {
|
return new Intl.DateTimeFormat('id-ID', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@ -67,6 +63,7 @@ function getQualityLabel(quality: string): string {
|
|||||||
reject: 'Reject',
|
reject: 'Reject',
|
||||||
retail: 'Ecer',
|
retail: 'Ecer',
|
||||||
};
|
};
|
||||||
|
|
||||||
return labels[quality] ?? quality;
|
return labels[quality] ?? quality;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +73,7 @@ function getQualityColor(quality: string): string {
|
|||||||
reject: 'bg-red-100 text-red-800',
|
reject: 'bg-red-100 text-red-800',
|
||||||
retail: 'bg-blue-100 text-blue-800',
|
retail: 'bg-blue-100 text-blue-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
return colors[quality] ?? 'bg-gray-100 text-gray-800';
|
return colors[quality] ?? 'bg-gray-100 text-gray-800';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,14 +81,27 @@ function getTypeLabel(type: string, quantity: number): string {
|
|||||||
if (type === 'in') {
|
if (type === 'in') {
|
||||||
return quantity >= 0 ? 'Penambahan' : 'Pengurangan';
|
return quantity >= 0 ? 'Penambahan' : 'Pengurangan';
|
||||||
}
|
}
|
||||||
|
|
||||||
return quantity < 0 ? 'Pengurangan' : 'Penambahan';
|
return quantity < 0 ? 'Pengurangan' : 'Penambahan';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMutationTitle(description: string | null): string {
|
function getMutationTitle(description: string | null): string {
|
||||||
if (!description) return 'Perubahan Stok';
|
if (!description) {
|
||||||
if (description.includes('Transfer stok')) return 'Transfer Stok';
|
return 'Perubahan Stok';
|
||||||
if (description.includes('Stok awal')) return 'Stok Awal';
|
}
|
||||||
if (description.includes('Penyesuaian stok')) return 'Edit Varian';
|
|
||||||
|
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';
|
return 'Perubahan Stok';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,12 +109,15 @@ function getMutationIcon(description: string | null): React.ReactNode {
|
|||||||
if (description?.includes('Transfer stok')) {
|
if (description?.includes('Transfer stok')) {
|
||||||
return <ArrowRightLeft className="h-4 w-4" />;
|
return <ArrowRightLeft className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (description?.includes('Stok awal')) {
|
if (description?.includes('Stok awal')) {
|
||||||
return <Package className="h-4 w-4" />;
|
return <Package className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (description?.includes('Penyesuaian stok')) {
|
if (description?.includes('Penyesuaian stok')) {
|
||||||
return <Pencil className="h-4 w-4" />;
|
return <Pencil className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ScrollText className="h-4 w-4" />;
|
return <ScrollText className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +128,10 @@ export default function StockMutationsPage({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { items, loading, hasNextPage, sentinelRef } = useInfiniteScroll({
|
const { items, loading, hasNextPage, sentinelRef } = useInfiniteScroll({
|
||||||
initialData: mutations,
|
initialData: mutations,
|
||||||
fetchUrl: stockMutations.url({ product: product.id, variant: variant.id }),
|
fetchUrl: stockMutations.url({
|
||||||
|
product: product.id,
|
||||||
|
variant: variant.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -152,13 +169,17 @@ export default function StockMutationsPage({
|
|||||||
) : (
|
) : (
|
||||||
items.map((mutation) => {
|
items.map((mutation) => {
|
||||||
const isPositive = mutation.quantity > 0;
|
const isPositive = mutation.quantity > 0;
|
||||||
const qualityColor = getQualityColor(mutation.stock_quality);
|
const qualityColor = getQualityColor(
|
||||||
|
mutation.stock_quality,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={mutation.id}>
|
<Card key={mutation.id}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start gap-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 ? (
|
{isPositive ? (
|
||||||
<ArrowUp className="h-5 w-5" />
|
<ArrowUp className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
@ -169,28 +190,54 @@ export default function StockMutationsPage({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{getMutationTitle(mutation.description)}
|
{getMutationTitle(
|
||||||
|
mutation.description,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${qualityColor}`}>
|
<span
|
||||||
{getQualityLabel(mutation.stock_quality)}
|
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${qualityColor}`}
|
||||||
|
>
|
||||||
|
{getQualityLabel(
|
||||||
|
mutation.stock_quality,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{formatDate(mutation.created_at)}
|
{formatDate(
|
||||||
|
mutation.created_at,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{mutation.user?.full_name ?? mutation.user?.username}
|
{mutation.user
|
||||||
|
?.full_name ??
|
||||||
|
mutation.user
|
||||||
|
?.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
<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'}>
|
<span
|
||||||
{isPositive ? '+' : ''}{formatNumber(mutation.quantity)}
|
className={
|
||||||
|
isPositive
|
||||||
|
? 'font-medium text-green-600'
|
||||||
|
: 'font-medium text-red-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPositive ? '+' : ''}
|
||||||
|
{formatNumber(
|
||||||
|
mutation.quantity,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{formatNumber(mutation.stock_before)} → {formatNumber(mutation.stock_after)}
|
{formatNumber(
|
||||||
|
mutation.stock_before,
|
||||||
|
)}{' '}
|
||||||
|
→{' '}
|
||||||
|
{formatNumber(
|
||||||
|
mutation.stock_after,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -208,7 +255,10 @@ export default function StockMutationsPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasNextPage && (
|
{hasNextPage && (
|
||||||
<div ref={sentinelRef} className="flex items-center justify-center py-4">
|
<div
|
||||||
|
ref={sentinelRef}
|
||||||
|
className="flex items-center justify-center py-4"
|
||||||
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
|
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||||
import { Button } from '@/components/ui/button';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -10,59 +10,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import {
|
import { formatNumber } from '@/lib/format';
|
||||||
Tooltip,
|
import { formatCurrency } from '@/lib/utils';
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { Product, ProductVariant } from '../columns';
|
|
||||||
import { stockMutations } from '@/routes/admin/master/products/variants';
|
import { stockMutations } from '@/routes/admin/master/products/variants';
|
||||||
|
import type { Product, ProductVariant } from '../columns';
|
||||||
import { TransferStockDialog } from './transfer-stock-dialog';
|
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({
|
export function VariantSubRow({
|
||||||
product,
|
product,
|
||||||
onEditVariant,
|
onEditVariant,
|
||||||
@ -119,8 +72,8 @@ export function VariantSubRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{variant.photo_urls?.length > 0 ? (
|
{variant.photo_urls?.length > 0 ? (
|
||||||
<VariantPhotoPreview
|
<ImagePreviewButton
|
||||||
urls={variant.photo_urls}
|
srcs={variant.photo_urls}
|
||||||
title={variant.name}
|
title={variant.name}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -161,86 +114,56 @@ export function VariantSubRow({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Transfer Stok',
|
||||||
<Button
|
icon: (
|
||||||
variant="ghost"
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
size="icon"
|
),
|
||||||
onClick={() =>
|
onClick: () =>
|
||||||
setTransferVariant({
|
setTransferVariant({
|
||||||
product,
|
product,
|
||||||
variant,
|
variant,
|
||||||
})
|
}),
|
||||||
}
|
},
|
||||||
>
|
{
|
||||||
<ArrowRightLeft className="h-4 w-4" />
|
label: 'Mutasi Stok',
|
||||||
</Button>
|
icon: (
|
||||||
</TooltipTrigger>
|
<ScrollText className="h-4 w-4" />
|
||||||
<TooltipContent side="top">
|
),
|
||||||
Transfer Stok
|
onClick: () => {
|
||||||
</TooltipContent>
|
window.location.href =
|
||||||
</Tooltip>
|
stockMutations.url({
|
||||||
<Tooltip>
|
product: product.id,
|
||||||
<TooltipTrigger asChild>
|
variant: variant.id,
|
||||||
<Button
|
});
|
||||||
variant="ghost"
|
},
|
||||||
size="icon"
|
},
|
||||||
onClick={() => {
|
{
|
||||||
window.location.href = stockMutations.url({
|
label: 'Edit',
|
||||||
product: product.id,
|
icon: (
|
||||||
variant: variant.id,
|
<Pencil className="h-4 w-4" />
|
||||||
});
|
),
|
||||||
}}
|
onClick: () =>
|
||||||
>
|
onEditVariant(
|
||||||
<ScrollText className="h-4 w-4" />
|
product,
|
||||||
</Button>
|
variant,
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">
|
},
|
||||||
Mutasi Stok
|
{
|
||||||
</TooltipContent>
|
label: 'Hapus',
|
||||||
</Tooltip>
|
icon: (
|
||||||
<Tooltip>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
<TooltipTrigger asChild>
|
),
|
||||||
<Button
|
onClick: () =>
|
||||||
variant="ghost"
|
onDeleteVariantClick(
|
||||||
size="icon"
|
product,
|
||||||
onClick={() =>
|
variant,
|
||||||
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>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
@ -9,25 +19,13 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
|
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
|
||||||
|
import { UNITS } from '@/lib/constants';
|
||||||
import { loadRawMaterialDraft } from '@/lib/raw-material-draft';
|
import { loadRawMaterialDraft } from '@/lib/raw-material-draft';
|
||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { index as rawMaterialIndex, store } from '@/routes/admin/master/raw-materials';
|
|
||||||
import { Form, Head, usePage } from '@inertiajs/react';
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
index as rawMaterialIndex,
|
||||||
Check,
|
store,
|
||||||
ClipboardPaste,
|
} from '@/routes/admin/master/raw-materials';
|
||||||
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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
type VariantState = {
|
type VariantState = {
|
||||||
variant: string;
|
variant: string;
|
||||||
@ -110,6 +108,7 @@ export default function RawMaterialCreate() {
|
|||||||
setVariants((prev) => {
|
setVariants((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
(updated[index] as Record<string, unknown>)[field] = value;
|
(updated[index] as Record<string, unknown>)[field] = value;
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -118,7 +117,9 @@ export default function RawMaterialCreate() {
|
|||||||
|
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(null);
|
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const confirmRemoveVariant = useCallback((index: number) => {
|
const confirmRemoveVariant = useCallback((index: number) => {
|
||||||
setDeleteVariantIndex(index);
|
setDeleteVariantIndex(index);
|
||||||
@ -131,6 +132,7 @@ export default function RawMaterialCreate() {
|
|||||||
navigator.clipboard.writeText(String(price));
|
navigator.clipboard.writeText(String(price));
|
||||||
setCopiedIndex(variantIndex);
|
setCopiedIndex(variantIndex);
|
||||||
setTimeout(() => setCopiedIndex(null), 1500);
|
setTimeout(() => setCopiedIndex(null), 1500);
|
||||||
|
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@ -139,10 +141,15 @@ export default function RawMaterialCreate() {
|
|||||||
navigator.clipboard.readText().then((text) => {
|
navigator.clipboard.readText().then((text) => {
|
||||||
try {
|
try {
|
||||||
const price = Number(text);
|
const price = Number(text);
|
||||||
|
|
||||||
if (!isNaN(price)) {
|
if (!isNaN(price)) {
|
||||||
setVariants((prev) => {
|
setVariants((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[variantIndex] = { ...updated[variantIndex], price };
|
updated[variantIndex] = {
|
||||||
|
...updated[variantIndex],
|
||||||
|
price,
|
||||||
|
};
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -155,6 +162,7 @@ export default function RawMaterialCreate() {
|
|||||||
const applyToAll = useCallback((variantIndex: number) => {
|
const applyToAll = useCallback((variantIndex: number) => {
|
||||||
setVariants((prev) => {
|
setVariants((prev) => {
|
||||||
const sourcePrice = prev[variantIndex].price;
|
const sourcePrice = prev[variantIndex].price;
|
||||||
|
|
||||||
return prev.map((v, i) =>
|
return prev.map((v, i) =>
|
||||||
i === variantIndex ? v : { ...v, price: sourcePrice },
|
i === variantIndex ? v : { ...v, price: sourcePrice },
|
||||||
);
|
);
|
||||||
@ -204,26 +212,35 @@ export default function RawMaterialCreate() {
|
|||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Informasi Bahan Baku</CardTitle>
|
<CardTitle>
|
||||||
|
Informasi Bahan Baku
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama Bahan Baku{' '}
|
Nama Bahan Baku{' '}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setName(e.target.value)
|
||||||
|
}
|
||||||
placeholder="Masukkan nama bahan baku"
|
placeholder="Masukkan nama bahan baku"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
<InputError message={errors.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Satuan <span className="text-destructive">*</span>
|
Satuan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
name="unit"
|
name="unit"
|
||||||
@ -232,9 +249,18 @@ export default function RawMaterialCreate() {
|
|||||||
className="flex flex-wrap gap-4"
|
className="flex flex-wrap gap-4"
|
||||||
>
|
>
|
||||||
{UNITS.map((u) => (
|
{UNITS.map((u) => (
|
||||||
<div key={u.value} className="flex items-center space-x-2">
|
<div
|
||||||
<RadioGroupItem value={u.value} id={`unit-${u.value}`} />
|
key={u.value}
|
||||||
<Label htmlFor={`unit-${u.value}`} className="font-normal">
|
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}
|
{u.label}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@ -242,7 +268,6 @@ export default function RawMaterialCreate() {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<InputError message={errors.unit} />
|
<InputError message={errors.unit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -251,136 +276,224 @@ export default function RawMaterialCreate() {
|
|||||||
<CardTitle>Varian Bahan Baku</CardTitle>
|
<CardTitle>Varian Bahan Baku</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{variants.map((variant, variantIndex) => (
|
{variants.map(
|
||||||
<div
|
(variant, variantIndex) => (
|
||||||
key={variantIndex}
|
<div
|
||||||
className="space-y-4 rounded-lg border p-4"
|
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">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
Varian {variantIndex + 1}
|
<h4 className="font-medium">
|
||||||
</h4>
|
Varian{' '}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
{variantIndex + 1}
|
||||||
<Button
|
</h4>
|
||||||
type="button"
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
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 && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={() => confirmRemoveVariant(variantIndex)}
|
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>
|
||||||
)}
|
<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>
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Nama Varian{' '}
|
Foto Varian{' '}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<FileUpload
|
||||||
value={variant.variant}
|
value={
|
||||||
onChange={(e) =>
|
variant.photo
|
||||||
updateVariant(variantIndex, 'variant', e.target.value)
|
|
||||||
}
|
}
|
||||||
placeholder="Contoh: Ukuran L, Warna Merah"
|
onChange={(key) => {
|
||||||
/>
|
|
||||||
<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(
|
updateVariant(
|
||||||
variantIndex,
|
variantIndex,
|
||||||
'stock',
|
'photo',
|
||||||
val,
|
key,
|
||||||
|
);
|
||||||
|
updateVariant(
|
||||||
|
variantIndex,
|
||||||
|
'photoUrl',
|
||||||
|
key
|
||||||
|
? getTemporaryUrl(
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
folder="raw-material-variant"
|
||||||
|
existingUrl={
|
||||||
|
variant.photoUrl
|
||||||
|
}
|
||||||
|
onUploadingChange={(
|
||||||
|
uploading,
|
||||||
|
) =>
|
||||||
|
updateVariant(
|
||||||
|
variantIndex,
|
||||||
|
'uploading',
|
||||||
|
uploading,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors[`variants.${variantIndex}.stock`]}
|
message={
|
||||||
|
errors[
|
||||||
|
`variants.${variantIndex}.photo_key`
|
||||||
|
]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -397,7 +510,10 @@ export default function RawMaterialCreate() {
|
|||||||
<div className="mt-6 flex items-center gap-4">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={processing || variants.some((v) => v.uploading)}
|
disabled={
|
||||||
|
processing ||
|
||||||
|
variants.some((v) => v.uploading)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -421,6 +537,7 @@ export default function RawMaterialCreate() {
|
|||||||
if (deleteVariantIndex !== null) {
|
if (deleteVariantIndex !== null) {
|
||||||
removeVariant(deleteVariantIndex);
|
removeVariant(deleteVariantIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeleteConfirmOpen(false);
|
setDeleteConfirmOpen(false);
|
||||||
setDeleteVariantIndex(null);
|
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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
@ -9,27 +19,15 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
|
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
|
||||||
|
import { UNITS } from '@/lib/constants';
|
||||||
import { clearRawMaterialDraft } from '@/lib/raw-material-draft';
|
import { clearRawMaterialDraft } from '@/lib/raw-material-draft';
|
||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { index as rawMaterialIndex, update } from '@/routes/admin/master/raw-materials';
|
|
||||||
import { Form, Head, usePage } from '@inertiajs/react';
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
index as rawMaterialIndex,
|
||||||
Check,
|
update,
|
||||||
ClipboardPaste,
|
} from '@/routes/admin/master/raw-materials';
|
||||||
Copy,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useCallback, useRef, useState } from 'react';
|
|
||||||
import type { RawMaterialForEdit, RawMaterialVariantForEdit } from './columns';
|
import type { RawMaterialForEdit, RawMaterialVariantForEdit } from './columns';
|
||||||
|
|
||||||
const UNITS = [
|
|
||||||
{ value: 'kg', label: 'Kilogram' },
|
|
||||||
{ value: 'meter', label: 'Meter' },
|
|
||||||
{ value: 'yard', label: 'Yard' },
|
|
||||||
];
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rawMaterial: RawMaterialForEdit;
|
rawMaterial: RawMaterialForEdit;
|
||||||
};
|
};
|
||||||
@ -124,6 +122,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
setVariants((prev) => {
|
setVariants((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
(updated[index] as Record<string, unknown>)[field] = value;
|
(updated[index] as Record<string, unknown>)[field] = value;
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -132,7 +131,9 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
|
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(null);
|
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const confirmRemoveVariant = useCallback((index: number) => {
|
const confirmRemoveVariant = useCallback((index: number) => {
|
||||||
setDeleteVariantIndex(index);
|
setDeleteVariantIndex(index);
|
||||||
@ -145,6 +146,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
navigator.clipboard.writeText(String(price));
|
navigator.clipboard.writeText(String(price));
|
||||||
setCopiedIndex(variantIndex);
|
setCopiedIndex(variantIndex);
|
||||||
setTimeout(() => setCopiedIndex(null), 1500);
|
setTimeout(() => setCopiedIndex(null), 1500);
|
||||||
|
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@ -153,10 +155,15 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
navigator.clipboard.readText().then((text) => {
|
navigator.clipboard.readText().then((text) => {
|
||||||
try {
|
try {
|
||||||
const price = Number(text);
|
const price = Number(text);
|
||||||
|
|
||||||
if (!isNaN(price)) {
|
if (!isNaN(price)) {
|
||||||
setVariants((prev) => {
|
setVariants((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[variantIndex] = { ...updated[variantIndex], price };
|
updated[variantIndex] = {
|
||||||
|
...updated[variantIndex],
|
||||||
|
price,
|
||||||
|
};
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -169,6 +176,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
const applyToAll = useCallback((variantIndex: number) => {
|
const applyToAll = useCallback((variantIndex: number) => {
|
||||||
setVariants((prev) => {
|
setVariants((prev) => {
|
||||||
const sourcePrice = prev[variantIndex].price;
|
const sourcePrice = prev[variantIndex].price;
|
||||||
|
|
||||||
return prev.map((v, i) =>
|
return prev.map((v, i) =>
|
||||||
i === variantIndex ? v : { ...v, price: sourcePrice },
|
i === variantIndex ? v : { ...v, price: sourcePrice },
|
||||||
);
|
);
|
||||||
@ -220,26 +228,35 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Informasi Bahan Baku</CardTitle>
|
<CardTitle>
|
||||||
|
Informasi Bahan Baku
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama Bahan Baku{' '}
|
Nama Bahan Baku{' '}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setName(e.target.value)
|
||||||
|
}
|
||||||
placeholder="Masukkan nama bahan baku"
|
placeholder="Masukkan nama bahan baku"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
<InputError message={errors.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Satuan <span className="text-destructive">*</span>
|
Satuan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
name="unit"
|
name="unit"
|
||||||
@ -248,9 +265,18 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
className="flex flex-wrap gap-4"
|
className="flex flex-wrap gap-4"
|
||||||
>
|
>
|
||||||
{UNITS.map((u) => (
|
{UNITS.map((u) => (
|
||||||
<div key={u.value} className="flex items-center space-x-2">
|
<div
|
||||||
<RadioGroupItem value={u.value} id={`unit-${u.value}`} />
|
key={u.value}
|
||||||
<Label htmlFor={`unit-${u.value}`} className="font-normal">
|
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}
|
{u.label}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@ -258,7 +284,6 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<InputError message={errors.unit} />
|
<InputError message={errors.unit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -267,141 +292,226 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
<CardTitle>Varian Bahan Baku</CardTitle>
|
<CardTitle>Varian Bahan Baku</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{variants.map((variant, variantIndex) => (
|
{variants.map(
|
||||||
<div
|
(variant, variantIndex) => (
|
||||||
key={variantIndex}
|
<div
|
||||||
className="space-y-4 rounded-lg border p-4"
|
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">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
Varian {variantIndex + 1}
|
<h4 className="font-medium">
|
||||||
</h4>
|
Varian{' '}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
{variantIndex + 1}
|
||||||
<Button
|
</h4>
|
||||||
type="button"
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
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 && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={() => confirmRemoveVariant(variantIndex)}
|
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>
|
||||||
)}
|
<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>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-2">
|
<Label>
|
||||||
<Label>
|
Nama Varian{' '}
|
||||||
Nama Varian{' '}
|
<span className="text-destructive">
|
||||||
<span className="text-destructive">*</span>
|
*
|
||||||
</Label>
|
</span>
|
||||||
<Input
|
</Label>
|
||||||
value={variant.variant}
|
<Input
|
||||||
onChange={(e) =>
|
value={
|
||||||
updateVariant(variantIndex, 'variant', e.target.value)
|
variant.variant
|
||||||
}
|
}
|
||||||
placeholder="Contoh: Ukuran L, Warna Merah"
|
onChange={(e) =>
|
||||||
/>
|
updateVariant(
|
||||||
<InputError
|
variantIndex,
|
||||||
message={errors[`variants.${variantIndex}.variant`]}
|
'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>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Harga <span className="text-destructive">*</span>
|
Foto Varian{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput
|
<FileUpload
|
||||||
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={
|
value={
|
||||||
Number(variant.stock) ||
|
variant.photo
|
||||||
0
|
|
||||||
}
|
}
|
||||||
onValueChange={(val) =>
|
onChange={(key) => {
|
||||||
updateVariant(
|
updateVariant(
|
||||||
variantIndex,
|
variantIndex,
|
||||||
'stock',
|
'photo',
|
||||||
val,
|
key,
|
||||||
|
);
|
||||||
|
updateVariant(
|
||||||
|
variantIndex,
|
||||||
|
'photoUrl',
|
||||||
|
key
|
||||||
|
? getTemporaryUrl(
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
folder="raw-material-variant"
|
||||||
|
existingUrl={
|
||||||
|
variant.photoUrl
|
||||||
|
}
|
||||||
|
onUploadingChange={(
|
||||||
|
uploading,
|
||||||
|
) =>
|
||||||
|
updateVariant(
|
||||||
|
variantIndex,
|
||||||
|
'uploading',
|
||||||
|
uploading,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors[`variants.${variantIndex}.stock`]}
|
message={
|
||||||
|
errors[
|
||||||
|
`variants.${variantIndex}.photo_key`
|
||||||
|
]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -418,7 +528,10 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
<div className="mt-6 flex items-center gap-4">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={processing || variants.some((v) => v.uploading)}
|
disabled={
|
||||||
|
processing ||
|
||||||
|
variants.some((v) => v.uploading)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -442,6 +555,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
|
|||||||
if (deleteVariantIndex !== null) {
|
if (deleteVariantIndex !== null) {
|
||||||
removeVariant(deleteVariantIndex);
|
removeVariant(deleteVariantIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeleteConfirmOpen(false);
|
setDeleteConfirmOpen(false);
|
||||||
setDeleteVariantIndex(null);
|
setDeleteVariantIndex(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Filter, Plus, X } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CardTable } from '@/components/card-table';
|
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 { 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 { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -17,6 +14,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
create as rawMaterialCreate,
|
create as rawMaterialCreate,
|
||||||
@ -52,10 +50,7 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
|||||||
rawMaterial: RawMaterial;
|
rawMaterial: RawMaterial;
|
||||||
variant: RawMaterialVariant;
|
variant: RawMaterialVariant;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const expand = useCardTableExpand(true);
|
const expand = useCardTableExpand(true);
|
||||||
const hasActiveFilters = filters.is_active || filters.stock;
|
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
current_page: rawMaterials.current_page,
|
current_page: rawMaterials.current_page,
|
||||||
@ -64,78 +59,26 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
|||||||
total: rawMaterials.total,
|
total: rawMaterials.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyFilter(key: string, value: string) {
|
const {
|
||||||
const newFilters = { ...filters };
|
search,
|
||||||
|
filterOpen,
|
||||||
if (value === '' || value === 'all') {
|
setFilterOpen,
|
||||||
delete newFilters[key as keyof typeof newFilters];
|
handlePageChange,
|
||||||
} else {
|
handlePerPageChange,
|
||||||
newFilters[key as keyof typeof newFilters] = value;
|
handleSearchChange,
|
||||||
}
|
applyFilter,
|
||||||
|
clearFilters,
|
||||||
router.get(rawMaterialIndex(), newFilters, {
|
} = useServerTable({
|
||||||
preserveState: true,
|
route: () => rawMaterialIndex.url(),
|
||||||
replace: true,
|
pagination,
|
||||||
});
|
filters,
|
||||||
}
|
filterWithParams: false,
|
||||||
|
});
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) return;
|
if (!deleting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.delete(destroy.url(deleting.id), {
|
router.delete(destroy.url(deleting.id), {
|
||||||
onSuccess: () => setDeleting(null),
|
onSuccess: () => setDeleting(null),
|
||||||
@ -143,7 +86,9 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteVariant() {
|
function handleDeleteVariant() {
|
||||||
if (!deletingVariant) return;
|
if (!deletingVariant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
variantDestroy.url({
|
variantDestroy.url({
|
||||||
@ -157,75 +102,49 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filterToolbar = (
|
const filterToolbar = (
|
||||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
<FilterPopover
|
||||||
<PopoverTrigger asChild>
|
open={filterOpen}
|
||||||
<Button variant="outline" size="sm">
|
onOpenChange={setFilterOpen}
|
||||||
<Filter className="h-4 w-4" />
|
filters={filters}
|
||||||
Filter
|
hasActiveFilters={Boolean(filters.is_active || filters.stock)}
|
||||||
{hasActiveFilters && (
|
onClear={clearFilters}
|
||||||
<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}
|
<div className="flex flex-col gap-2">
|
||||||
</span>
|
<label className="text-xs text-muted-foreground">Status</label>
|
||||||
)}
|
<Select
|
||||||
</Button>
|
value={filters.is_active ?? 'all'}
|
||||||
</PopoverTrigger>
|
onValueChange={(value) => applyFilter('is_active', value)}
|
||||||
<PopoverContent className="w-64" align="end">
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<SelectTrigger className="w-full">
|
||||||
<div className="flex items-center justify-between">
|
<SelectValue placeholder="Semua Status" />
|
||||||
<span className="text-sm font-medium">Filter</span>
|
</SelectTrigger>
|
||||||
{hasActiveFilters && (
|
<SelectContent>
|
||||||
<Button
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
variant="ghost"
|
<SelectItem value="true">Aktif</SelectItem>
|
||||||
size="sm"
|
<SelectItem value="false">Non Aktif</SelectItem>
|
||||||
className="h-6 px-2 text-xs"
|
</SelectContent>
|
||||||
onClick={clearFilters}
|
</Select>
|
||||||
>
|
</div>
|
||||||
<X className="mr-1 h-3 w-3" />
|
|
||||||
Hapus Semua
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">Stok</label>
|
||||||
Status
|
<Select
|
||||||
</label>
|
value={filters.stock ?? 'all'}
|
||||||
<Select
|
onValueChange={(value) => applyFilter('stock', value)}
|
||||||
value={filters.is_active ?? 'all'}
|
>
|
||||||
onValueChange={(value) => applyFilter('is_active', value)}
|
<SelectTrigger className="w-full">
|
||||||
>
|
<SelectValue placeholder="Semua Stok" />
|
||||||
<SelectTrigger className="w-full">
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="Semua Status" />
|
<SelectContent>
|
||||||
</SelectTrigger>
|
<SelectItem value="all">Semua Stok</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="empty">Habis</SelectItem>
|
||||||
<SelectItem value="all">Semua Status</SelectItem>
|
<SelectItem value="low">
|
||||||
<SelectItem value="true">Aktif</SelectItem>
|
Menipis (di bawah 10)
|
||||||
<SelectItem value="false">Non Aktif</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</FilterPopover>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -233,19 +152,17 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
|||||||
<Head title="Bahan Baku" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Bahan Baku"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Bahan Baku
|
<Button asChild>
|
||||||
</h2>
|
<a href={rawMaterialCreate.url()}>
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
<Button asChild>
|
Tambah
|
||||||
<a href={rawMaterialCreate.url()}>
|
</a>
|
||||||
<Plus className="h-4 w-4" />
|
</Button>
|
||||||
Tambah
|
}
|
||||||
</a>
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
data={rawMaterials.data}
|
data={rawMaterials.data}
|
||||||
@ -276,7 +193,9 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
|||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
onToggleExpand={onToggleExpand}
|
onToggleExpand={onToggleExpand}
|
||||||
onEdit={(rm) => {
|
onEdit={(rm) => {
|
||||||
window.location.href = rawMaterialEdit.url(rm.id);
|
window.location.href = rawMaterialEdit.url(
|
||||||
|
rm.id,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onDelete={(rm) => setDeleting(rm)}
|
onDelete={(rm) => setDeleting(rm)}
|
||||||
toggleStatusUrl={(id) => toggleStatus.url(id)}
|
toggleStatusUrl={(id) => toggleStatus.url(id)}
|
||||||
@ -287,29 +206,31 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Bahan Baku"
|
title="Hapus Bahan Baku"
|
||||||
description={`Apakah Anda yakin ingin menghapus bahan baku "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(rawMaterial) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus bahan baku "${rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deletingVariant !== null}
|
target={deletingVariant}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeletingVariant(null);
|
setDeletingVariant(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Varian"
|
title="Hapus Varian"
|
||||||
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.variant}" dari bahan baku "${deletingVariant?.rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(target) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus varian "${target.variant.variant}" dari bahan baku "${target.rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDeleteVariant}
|
onConfirm={handleDeleteVariant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { formatNumber } from '@/lib/format';
|
||||||
import {
|
import { formatCurrency } from '@/lib/utils';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { router } from '@inertiajs/react';
|
|
||||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
|
||||||
import type { RawMaterial } from './columns';
|
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 = {
|
export type RawMaterialCardRowParams = {
|
||||||
rawMaterial: RawMaterial;
|
rawMaterial: RawMaterial;
|
||||||
index: number;
|
index: number;
|
||||||
@ -43,12 +27,14 @@ export function RawMaterialCardRow({
|
|||||||
toggleStatusUrl,
|
toggleStatusUrl,
|
||||||
}: RawMaterialCardRowParams) {
|
}: RawMaterialCardRowParams) {
|
||||||
const variants = rawMaterial.raw_material_prices ?? [];
|
const variants = rawMaterial.raw_material_prices ?? [];
|
||||||
const totalStock = variants.reduce((sum, v) => sum + (Number(v.stock) || 0), 0);
|
const totalStock = variants.reduce(
|
||||||
const totalValue = variants.reduce((sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0), 0);
|
(sum, v) => sum + (Number(v.stock) || 0),
|
||||||
|
0,
|
||||||
function handleToggle() {
|
);
|
||||||
router.post(toggleStatusUrl(rawMaterial.id), {}, { preserveScroll: true });
|
const totalValue = variants.reduce(
|
||||||
}
|
(sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
@ -94,51 +80,35 @@ export function RawMaterialCardRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center gap-2">
|
<ToggleStatus
|
||||||
<Switch
|
url={toggleStatusUrl(rawMaterial.id)}
|
||||||
size="sm"
|
checked={rawMaterial.is_active}
|
||||||
checked={rawMaterial.is_active}
|
label={
|
||||||
onCheckedChange={handleToggle}
|
rawMaterial.is_active
|
||||||
/>
|
? 'Aktif'
|
||||||
<span
|
: 'Non Aktif'
|
||||||
className={`text-xs font-medium ${rawMaterial.is_active ? 'text-green-700' : 'text-red-700'}`}
|
}
|
||||||
>
|
/>
|
||||||
{rawMaterial.is_active ? 'Aktif' : 'Non Aktif'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => onEdit(rawMaterial),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => onEdit(rawMaterial)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => onDelete(rawMaterial),
|
||||||
</Tooltip>
|
},
|
||||||
<Tooltip>
|
]}
|
||||||
<TooltipTrigger asChild>
|
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import { router } from '@inertiajs/react';
|
|||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||||
import { Button } from '@/components/ui/button';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -12,52 +12,13 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { formatNumber } from '@/lib/format';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
edit as variantEdit,
|
||||||
TooltipContent,
|
destroy as variantDestroy,
|
||||||
TooltipProvider,
|
} from '@/routes/admin/master/raw-materials/variants';
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { RawMaterial, RawMaterialVariant } from '../columns';
|
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({
|
export function RawMaterialVariantSubRow({
|
||||||
rawMaterial,
|
rawMaterial,
|
||||||
@ -65,10 +26,13 @@ export function RawMaterialVariantSubRow({
|
|||||||
rawMaterial: RawMaterial;
|
rawMaterial: RawMaterial;
|
||||||
}) {
|
}) {
|
||||||
const variants = rawMaterial.raw_material_prices ?? [];
|
const variants = rawMaterial.raw_material_prices ?? [];
|
||||||
const [deletingVariant, setDeletingVariant] = useState<RawMaterialVariant | null>(null);
|
const [deletingVariant, setDeletingVariant] =
|
||||||
|
useState<RawMaterialVariant | null>(null);
|
||||||
|
|
||||||
function handleDeleteVariant() {
|
function handleDeleteVariant() {
|
||||||
if (!deletingVariant) return;
|
if (!deletingVariant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
variantDestroy.url({
|
variantDestroy.url({
|
||||||
@ -116,8 +80,8 @@ export function RawMaterialVariantSubRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{variant.photo_url ? (
|
{variant.photo_url ? (
|
||||||
<VariantPhotoPreview
|
<ImagePreviewButton
|
||||||
url={variant.photo_url}
|
srcs={[variant.photo_url]}
|
||||||
title={variant.variant}
|
title={variant.variant}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -136,45 +100,32 @@ export function RawMaterialVariantSubRow({
|
|||||||
{formatNumber(variant.stock)}
|
{formatNumber(variant.stock)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: (
|
||||||
variant="ghost"
|
<Pencil className="h-4 w-4" />
|
||||||
size="icon"
|
),
|
||||||
onClick={() => {
|
onClick: () => {
|
||||||
window.location.href = variantEdit.url({
|
window.location.href =
|
||||||
rawMaterial: rawMaterial.id,
|
variantEdit.url({
|
||||||
variant: variant.id,
|
rawMaterial:
|
||||||
});
|
rawMaterial.id,
|
||||||
}}
|
variant: variant.id,
|
||||||
>
|
});
|
||||||
<Pencil className="h-4 w-4" />
|
},
|
||||||
</Button>
|
},
|
||||||
</TooltipTrigger>
|
{
|
||||||
<TooltipContent side="top">
|
label: 'Hapus',
|
||||||
Edit
|
icon: (
|
||||||
</TooltipContent>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Tooltip>
|
),
|
||||||
<Tooltip>
|
onClick: () =>
|
||||||
<TooltipTrigger asChild>
|
setDeletingVariant(variant),
|
||||||
<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>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
export type Supplier = {
|
export type Supplier = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -62,39 +56,22 @@ export function createSupplierColumns(
|
|||||||
const supplier = row.original;
|
const supplier = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(supplier),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(supplier)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleDeleteClick(supplier),
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } 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 InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
destroy,
|
destroy,
|
||||||
store,
|
|
||||||
index as supplierIndex,
|
index as supplierIndex,
|
||||||
|
store,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/master/suppliers';
|
} 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 type { Supplier } from './columns';
|
||||||
import { createSupplierColumns } from './columns';
|
import { createSupplierColumns } from './columns';
|
||||||
|
|
||||||
@ -39,7 +35,7 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Supplier | null>(null);
|
const [editing, setEditing] = useState<Supplier | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Supplier | null>(null);
|
const [deleting, setDeleting] = useState<Supplier | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: suppliers.current_page,
|
current_page: suppliers.current_page,
|
||||||
last_page: suppliers.last_page,
|
last_page: suppliers.last_page,
|
||||||
@ -47,54 +43,15 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
total: suppliers.total,
|
total: suppliers.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
const {
|
||||||
router.get(
|
search,
|
||||||
supplierIndex.url(),
|
handlePageChange,
|
||||||
{
|
handlePerPageChange,
|
||||||
page,
|
handleSearchChange,
|
||||||
per_page: pagination.per_page,
|
} = useServerTable({
|
||||||
search,
|
route: () => supplierIndex.url(),
|
||||||
},
|
pagination,
|
||||||
{
|
});
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
@ -116,13 +73,9 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
<Head title="Supplier" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Supplier"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Supplier
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -132,88 +85,105 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
Tambah
|
Tambah
|
||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
}
|
||||||
<Form
|
/>
|
||||||
action={store()}
|
|
||||||
resetOnSuccess
|
<FormDialog
|
||||||
onSuccess={() => setCreateOpen(false)}
|
open={createOpen}
|
||||||
>
|
onOpenChange={setCreateOpen}
|
||||||
{({ errors, processing }) => {
|
title="Tambah Supplier"
|
||||||
return (
|
action={store()}
|
||||||
<>
|
resetOnSuccess
|
||||||
<DialogHeader>
|
onSuccess={() => setCreateOpen(false)}
|
||||||
<DialogTitle>
|
>
|
||||||
Tambah Supplier
|
{({ errors }) => (
|
||||||
</DialogTitle>
|
<div className="grid gap-4 py-4">
|
||||||
</DialogHeader>
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-4 py-4">
|
<Label htmlFor="name">
|
||||||
<div className="grid gap-2">
|
Nama{' '}
|
||||||
<Label htmlFor="name">
|
<span className="text-destructive">*</span>
|
||||||
Nama{' '}
|
</Label>
|
||||||
<span className="text-destructive">
|
<Input
|
||||||
*
|
id="name"
|
||||||
</span>
|
name="name"
|
||||||
</Label>
|
placeholder="Masukkan nama supplier"
|
||||||
<Input
|
/>
|
||||||
id="name"
|
<InputError message={errors.name} />
|
||||||
name="name"
|
</div>
|
||||||
placeholder="Masukkan nama supplier"
|
<div className="grid gap-2">
|
||||||
/>
|
<Label htmlFor="phone_number">
|
||||||
<InputError
|
No. Telepon
|
||||||
message={errors.name}
|
</Label>
|
||||||
/>
|
<PhoneNumberInput name="phone_number" />
|
||||||
</div>
|
<InputError message={errors.phone_number} />
|
||||||
<div className="grid gap-2">
|
</div>
|
||||||
<Label htmlFor="phone_number">
|
<div className="grid gap-2">
|
||||||
No. Telepon
|
<Label htmlFor="address">Alamat</Label>
|
||||||
</Label>
|
<Input
|
||||||
<PhoneNumberInput name="phone_number" />
|
id="address"
|
||||||
<InputError
|
name="address"
|
||||||
message={
|
placeholder="Masukkan alamat"
|
||||||
errors.phone_number
|
/>
|
||||||
}
|
<InputError message={errors.address} />
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
)}
|
||||||
<Label htmlFor="address">
|
</FormDialog>
|
||||||
Alamat
|
|
||||||
</Label>
|
<FormDialog
|
||||||
<Input
|
open={editing !== null}
|
||||||
id="address"
|
onOpenChange={(open) => {
|
||||||
name="address"
|
if (!open) {
|
||||||
placeholder="Masukkan alamat"
|
setEditing(null);
|
||||||
/>
|
}
|
||||||
<InputError
|
}}
|
||||||
message={errors.address}
|
title="Edit Supplier"
|
||||||
/>
|
action={editing ? update(editing.id) : ''}
|
||||||
</div>
|
resetOnSuccess
|
||||||
</div>
|
onSuccess={() => setEditing(null)}
|
||||||
<DialogFooter>
|
>
|
||||||
<Button
|
{({ errors }) =>
|
||||||
type="button"
|
editing && (
|
||||||
variant="outline"
|
<div className="grid gap-4 py-4">
|
||||||
onClick={() =>
|
<div className="grid gap-2">
|
||||||
setCreateOpen(false)
|
<Label htmlFor="edit-name">
|
||||||
}
|
Nama{' '}
|
||||||
>
|
<span className="text-destructive">
|
||||||
Batal
|
*
|
||||||
</Button>
|
</span>
|
||||||
<Button
|
</Label>
|
||||||
type="submit"
|
<Input
|
||||||
disabled={processing}
|
id="edit-name"
|
||||||
>
|
name="name"
|
||||||
{processing
|
placeholder="Masukkan nama supplier"
|
||||||
? 'Menyimpan...'
|
defaultValue={editing.name}
|
||||||
: 'Simpan'}
|
/>
|
||||||
</Button>
|
<InputError message={errors.name} />
|
||||||
</DialogFooter>
|
</div>
|
||||||
</>
|
<div className="grid gap-2">
|
||||||
);
|
<Label htmlFor="edit-phone_number">
|
||||||
}}
|
No. Telepon
|
||||||
</Form>
|
</Label>
|
||||||
</DialogContent>
|
<PhoneNumberInput
|
||||||
</Dialog>
|
name="phone_number"
|
||||||
</div>
|
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
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -228,115 +198,17 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
searchValue={search}
|
searchValue={search}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<DeleteConfirmDialog
|
||||||
open={editing !== null}
|
target={deleting}
|
||||||
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}
|
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Supplier"
|
title="Hapus Supplier"
|
||||||
description={`Apakah Anda yakin ingin menghapus supplier "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
description={(supplier) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus supplier "${supplier.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
export type Role = {
|
export type Role = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -60,37 +54,22 @@ export function createRoleColumns(
|
|||||||
const role = row.original;
|
const role = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RowActions
|
||||||
<div className="flex items-center justify-center gap-1">
|
actions={[
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
label: 'Edit',
|
||||||
<Button
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
variant="ghost"
|
onClick: () => handleEdit(role),
|
||||||
size="icon"
|
},
|
||||||
onClick={() => handleEdit(role)}
|
{
|
||||||
>
|
label: 'Hapus',
|
||||||
<Pencil className="h-4 w-4" />
|
icon: (
|
||||||
</Button>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</TooltipTrigger>
|
),
|
||||||
<TooltipContent side="top">Edit</TooltipContent>
|
onClick: () => handleDeleteClick(role),
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
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 { Button } from '@/components/ui/button';
|
||||||
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
index as rolesIndex,
|
index as rolesIndex,
|
||||||
create as roleCreate,
|
create as roleCreate,
|
||||||
@ -26,7 +28,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function RoleIndex({ roles }: Props) {
|
export default function RoleIndex({ roles }: Props) {
|
||||||
const [deleting, setDeleting] = useState<Role | null>(null);
|
const [deleting, setDeleting] = useState<Role | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const pagination: PaginationState = {
|
const pagination: PaginationState = {
|
||||||
current_page: roles.current_page,
|
current_page: roles.current_page,
|
||||||
last_page: roles.last_page,
|
last_page: roles.last_page,
|
||||||
@ -34,45 +36,15 @@ export default function RoleIndex({ roles }: Props) {
|
|||||||
total: roles.total,
|
total: roles.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
const {
|
||||||
router.get(
|
search,
|
||||||
rolesIndex.url(),
|
handlePageChange,
|
||||||
{
|
handlePerPageChange,
|
||||||
page,
|
handleSearchChange,
|
||||||
per_page: pagination.per_page,
|
} = useServerTable({
|
||||||
search,
|
route: () => rolesIndex.url(),
|
||||||
},
|
pagination,
|
||||||
{ 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
@ -96,19 +68,17 @@ export default function RoleIndex({ roles }: Props) {
|
|||||||
<Head title="Role & Permission" />
|
<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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Role & Permission"
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
actions={
|
||||||
Role & Permission
|
<Button asChild>
|
||||||
</h2>
|
<a href={roleCreate.url()}>
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
<Button asChild>
|
Tambah
|
||||||
<a href={roleCreate.url()}>
|
</a>
|
||||||
<Plus className="h-4 w-4" />
|
</Button>
|
||||||
Tambah
|
}
|
||||||
</a>
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -123,16 +93,17 @@ export default function RoleIndex({ roles }: Props) {
|
|||||||
searchValue={search}
|
searchValue={search}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleting !== null}
|
target={deleting}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Hapus Role"
|
title="Hapus Role"
|
||||||
description={`Apakah Anda yakin ingin menghapus role "${deleting?.name}"? Semua user dengan role ini akan kehilangan permission terkait.`}
|
description={(role) =>
|
||||||
confirmLabel="Hapus"
|
`Apakah Anda yakin ingin menghapus role "${role.name}"? Semua user dengan role ini akan kehilangan permission terkait.`
|
||||||
|
}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user