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:
Yoga Pangestu 2026-08-02 23:40:12 +07:00
parent cf0e772159
commit d91ec8869e
64 changed files with 3649 additions and 5490 deletions

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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]);
}

View File

@ -1,10 +1,6 @@
import {
clearProductDraft,
saveProductDraft,
type ProductDraftData,
} from '@/lib/product-draft';
import { router } from '@inertiajs/react';
import { useEffect, useRef } from 'react';
import { useDraftSave } from '@/hooks/use-draft-save';
import { clearProductDraft, saveProductDraft } from '@/lib/product-draft';
import type { ProductDraftData } from '@/lib/product-draft';
type DraftType = 'create' | 'edit';
@ -15,55 +11,12 @@ export function useProductDraftSave(
productId?: number,
delay = 500,
) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dataRef = useRef(data);
dataRef.current = data;
const submittedRef = useRef(false);
useEffect(() => {
const offBefore = router.on('before', (event) => {
if (event.detail.visit.method !== 'get') {
submittedRef.current = true;
}
});
const offError = router.on('error', () => {
submittedRef.current = false;
});
return () => {
offBefore();
offError();
};
}, []);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
if (!submittedRef.current) {
saveProductDraft(type, dataRef.current, userId, productId);
}
timeoutRef.current = null;
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [data, type, userId, productId, delay]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (submittedRef.current) {
clearProductDraft(type, userId, productId);
} else {
saveProductDraft(type, dataRef.current, userId, productId);
}
};
}, [type, userId, productId]);
return useDraftSave({
type,
data,
userId,
extraId: productId,
delay,
store: { save: saveProductDraft, clear: clearProductDraft },
});
}

View File

@ -1,10 +1,6 @@
import {
clearPurchaseDraft,
savePurchaseDraft,
type PurchaseDraftData,
} from '@/lib/purchase-draft';
import { router } from '@inertiajs/react';
import { useEffect, useRef } from 'react';
import { useDraftSave } from '@/hooks/use-draft-save';
import { clearPurchaseDraft, savePurchaseDraft } from '@/lib/purchase-draft';
import type { PurchaseDraftData } from '@/lib/purchase-draft';
type DraftType = 'create' | 'edit';
@ -14,55 +10,11 @@ export function usePurchaseDraftSave(
userId?: number,
delay = 500,
) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dataRef = useRef(data);
dataRef.current = data;
const submittedRef = useRef(false);
useEffect(() => {
const offBefore = router.on('before', (event) => {
if (event.detail.visit.method !== 'get') {
submittedRef.current = true;
}
});
const offError = router.on('error', () => {
submittedRef.current = false;
});
return () => {
offBefore();
offError();
};
}, []);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
if (!submittedRef.current) {
savePurchaseDraft(type, dataRef.current, userId);
}
timeoutRef.current = null;
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [data, type, userId, delay]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (submittedRef.current) {
clearPurchaseDraft(type, userId);
} else {
savePurchaseDraft(type, dataRef.current, userId);
}
};
}, [type, userId]);
return useDraftSave({
type,
data,
userId,
delay,
store: { save: savePurchaseDraft, clear: clearPurchaseDraft },
});
}

View File

@ -1,10 +1,9 @@
import { useDraftSave } from '@/hooks/use-draft-save';
import {
clearRawMaterialDraft,
saveRawMaterialDraft,
type RawMaterialDraftData,
} from '@/lib/raw-material-draft';
import { router } from '@inertiajs/react';
import { useEffect, useRef } from 'react';
import type { RawMaterialDraftData } from '@/lib/raw-material-draft';
type DraftType = 'create' | 'edit';
@ -15,54 +14,12 @@ export function useRawMaterialDraftSave(
rawMaterialId?: number,
delay = 500,
) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dataRef = useRef(data);
dataRef.current = data;
const submittedRef = useRef(false);
useEffect(() => {
const offBefore = router.on('before', (event) => {
if (event.detail.visit.method !== 'get') {
submittedRef.current = true;
}
});
const offError = router.on('error', () => {
submittedRef.current = false;
});
return () => {
offBefore();
offError();
};
}, []);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
if (!submittedRef.current) {
saveRawMaterialDraft(type, dataRef.current, userId, rawMaterialId);
}
timeoutRef.current = null;
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [data, type, userId, rawMaterialId, delay]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (submittedRef.current) {
clearRawMaterialDraft(type, userId, rawMaterialId);
} else {
saveRawMaterialDraft(type, dataRef.current, userId, rawMaterialId);
}
};
}, [type, userId, rawMaterialId]);
return useDraftSave({
type,
data,
userId,
extraId: rawMaterialId,
delay,
store: { save: saveRawMaterialDraft, clear: clearRawMaterialDraft },
});
}

View File

@ -1,11 +1,6 @@
import { router } from '@inertiajs/react';
import { useEffect, useRef } from 'react';
import {
clearRestockDraft,
saveRestockDraft
} from '@/lib/restock-draft';
import type {RestockDraftData} from '@/lib/restock-draft';
import { useDraftSave } from '@/hooks/use-draft-save';
import { clearRestockDraft, saveRestockDraft } from '@/lib/restock-draft';
import type { RestockDraftData } from '@/lib/restock-draft';
type DraftType = 'create' | 'edit';
@ -15,61 +10,11 @@ export function useRestockDraftSave(
userId?: number,
delay = 500,
) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dataRef = useRef(data);
const submittedRef = useRef(false);
useEffect(() => {
dataRef.current = data;
}, [data]);
useEffect(() => {
const offBefore = router.on('before', (event) => {
if (event.detail.visit.method !== 'get') {
submittedRef.current = true;
}
});
const offError = router.on('error', () => {
submittedRef.current = false;
});
return () => {
offBefore();
offError();
};
}, []);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
if (!submittedRef.current) {
saveRestockDraft(type, dataRef.current, userId);
}
timeoutRef.current = null;
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [data, type, userId, delay]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (submittedRef.current) {
clearRestockDraft(type, userId);
} else {
saveRestockDraft(type, dataRef.current, userId);
}
};
}, [type, userId]);
return useDraftSave({
type,
data,
userId,
delay,
store: { save: saveRestockDraft, clear: clearRestockDraft },
});
}

View 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,
};
}

View 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' },
];

View 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
}
},
};
}

View 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',
});
}

View File

@ -1,4 +1,4 @@
const DRAFT_PREFIX = 'product-draft';
import { createDraftStore } from '@/lib/draft-store';
export type ProductDraftData = {
productName: string;
@ -19,17 +19,8 @@ export type ProductDraftData = {
}>;
};
function getKey(
type: 'create' | 'edit',
userId?: number,
productId?: number,
): string {
if (type === 'edit' && productId) {
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${productId}`;
}
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
}
export const productDraftStore =
createDraftStore<ProductDraftData>('product-draft');
export function saveProductDraft(
type: 'create' | 'edit',
@ -37,18 +28,7 @@ export function saveProductDraft(
userId?: number,
productId?: number,
): boolean {
if (type !== 'create') {
return false;
}
try {
const key = getKey(type, userId, productId);
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch {
return false;
}
return productDraftStore.save(type, data, userId, productId);
}
export function loadProductDraft(
@ -56,18 +36,7 @@ export function loadProductDraft(
userId?: number,
productId?: number,
): ProductDraftData | null {
try {
const key = getKey(type, userId, productId);
const raw = localStorage.getItem(key);
if (!raw) {
return null;
}
return JSON.parse(raw) as ProductDraftData;
} catch {
return null;
}
return productDraftStore.load(type, userId, productId);
}
export function clearProductDraft(
@ -75,10 +44,5 @@ export function clearProductDraft(
userId?: number,
productId?: number,
): void {
try {
const key = getKey(type, userId, productId);
localStorage.removeItem(key);
} catch {
// ignore
}
productDraftStore.clear(type, userId, productId);
}

View File

@ -1,4 +1,4 @@
const DRAFT_PREFIX = 'purchase-draft';
import { createDraftStore } from '@/lib/draft-store';
export type PurchaseDraftData = {
name: string;
@ -19,60 +19,27 @@ export type PurchaseDraftData = {
photo?: string;
};
function getKey(type: 'create' | 'edit', userId?: number): string {
if (type === 'edit') {
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}`;
}
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
}
export const purchaseDraftStore =
createDraftStore<PurchaseDraftData>('purchase-draft');
export function savePurchaseDraft(
type: 'create' | 'edit',
data: PurchaseDraftData,
userId?: number,
): boolean {
if (type !== 'create') {
return false;
}
try {
const key = getKey(type, userId);
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch {
return false;
}
return purchaseDraftStore.save(type, data, userId);
}
export function loadPurchaseDraft(
type: 'create' | 'edit',
userId?: number,
): PurchaseDraftData | null {
try {
const key = getKey(type, userId);
const raw = localStorage.getItem(key);
if (!raw) {
return null;
}
return JSON.parse(raw) as PurchaseDraftData;
} catch {
return null;
}
return purchaseDraftStore.load(type, userId);
}
export function clearPurchaseDraft(
type: 'create' | 'edit',
userId?: number,
): void {
try {
const key = getKey(type, userId);
localStorage.removeItem(key);
} catch {
// ignore
}
purchaseDraftStore.clear(type, userId);
}

View File

@ -1,4 +1,4 @@
const DRAFT_PREFIX = 'raw-material-draft';
import { createDraftStore } from '@/lib/draft-store';
export type RawMaterialDraftData = {
name: string;
@ -13,17 +13,8 @@ export type RawMaterialDraftData = {
}>;
};
function getKey(
type: 'create' | 'edit',
userId?: number,
rawMaterialId?: number,
): string {
if (type === 'edit' && rawMaterialId) {
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${rawMaterialId}`;
}
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
}
export const rawMaterialDraftStore =
createDraftStore<RawMaterialDraftData>('raw-material-draft');
export function saveRawMaterialDraft(
type: 'create' | 'edit',
@ -31,18 +22,7 @@ export function saveRawMaterialDraft(
userId?: number,
rawMaterialId?: number,
): boolean {
if (type !== 'create') {
return false;
}
try {
const key = getKey(type, userId, rawMaterialId);
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch {
return false;
}
return rawMaterialDraftStore.save(type, data, userId, rawMaterialId);
}
export function loadRawMaterialDraft(
@ -50,18 +30,7 @@ export function loadRawMaterialDraft(
userId?: number,
rawMaterialId?: number,
): RawMaterialDraftData | null {
try {
const key = getKey(type, userId, rawMaterialId);
const raw = localStorage.getItem(key);
if (!raw) {
return null;
}
return JSON.parse(raw) as RawMaterialDraftData;
} catch {
return null;
}
return rawMaterialDraftStore.load(type, userId, rawMaterialId);
}
export function clearRawMaterialDraft(
@ -69,10 +38,5 @@ export function clearRawMaterialDraft(
userId?: number,
rawMaterialId?: number,
): void {
try {
const key = getKey(type, userId, rawMaterialId);
localStorage.removeItem(key);
} catch {
// ignore
}
rawMaterialDraftStore.clear(type, userId, rawMaterialId);
}

View File

@ -1,4 +1,4 @@
const DRAFT_PREFIX = 'restock-draft';
import { createDraftStore } from '@/lib/draft-store';
export type RestockDraftData = {
stockType: 'good' | 'reject';
@ -8,60 +8,27 @@ export type RestockDraftData = {
photo?: string;
};
function getKey(type: 'create' | 'edit', userId?: number): string {
if (type === 'edit') {
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}`;
}
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
}
export const restockDraftStore =
createDraftStore<RestockDraftData>('restock-draft');
export function saveRestockDraft(
type: 'create' | 'edit',
data: RestockDraftData,
userId?: number,
): boolean {
if (type !== 'create') {
return false;
}
try {
const key = getKey(type, userId);
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch {
return false;
}
return restockDraftStore.save(type, data, userId);
}
export function loadRestockDraft(
type: 'create' | 'edit',
userId?: number,
): RestockDraftData | null {
try {
const key = getKey(type, userId);
const raw = localStorage.getItem(key);
if (!raw) {
return null;
}
return JSON.parse(raw) as RestockDraftData;
} catch {
return null;
}
return restockDraftStore.load(type, userId);
}
export function clearRestockDraft(
type: 'create' | 'edit',
userId?: number,
): void {
try {
const key = getKey(type, userId);
localStorage.removeItem(key);
} catch {
// ignore
}
restockDraftStore.clear(type, userId);
}

View File

@ -1,12 +1,6 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { RowActions } from '@/components/row-actions';
import { formatCurrency } from '@/lib/utils';
export type CashAccount = {
@ -55,39 +49,22 @@ export function createCashAccountColumns(
const cashAccount = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(cashAccount)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(cashAccount)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(cashAccount),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(cashAccount),
},
]}
/>
);
},
},

View File

@ -1,34 +1,19 @@
import { Form, Head, router } from '@inertiajs/react';
import {
ArrowDownToLine,
ArrowUpFromLine,
Filter,
Wallet,
X,
} from 'lucide-react';
import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { Head, router } from '@inertiajs/react';
import { ArrowDownToLine, ArrowUpFromLine, Wallet } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import { FilterPopover } from '@/components/filter-popover';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
@ -36,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import { formatCurrency } from '@/lib/utils';
import {
index as cashAccountIndex,
@ -78,7 +64,6 @@ export default function CashAccountIndex({
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [editing, setEditing] = useState<CashTransaction | null>(null);
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(
null,
@ -105,7 +90,6 @@ export default function CashAccountIndex({
type: string;
} | null>(null);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: transactions.current_page,
last_page: transactions.last_page,
@ -113,41 +97,20 @@ export default function CashAccountIndex({
total: transactions.total,
};
const hasActiveFilters = filters.type;
function applyFilter(key: string, value: string) {
const newFilters = { ...filters };
if (value === '' || value === 'all') {
delete newFilters[key as keyof typeof newFilters];
} else {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(
cashAccountIndex(),
{
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function clearFilters() {
router.get(
cashAccountIndex(),
{
page: 1,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false);
}
const {
search,
filterOpen,
setFilterOpen,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({
route: () => cashAccountIndex.url(),
pagination,
filters,
});
function handleDelete() {
if (!deleting) {
@ -159,49 +122,6 @@ export default function CashAccountIndex({
});
}
function handlePageChange(page: number) {
router.get(
cashAccountIndex(),
{
...filters,
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
cashAccountIndex(),
{
...filters,
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
cashAccountIndex(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, filters],
);
const columns = createTransactionColumns({
handleEdit: (transaction) => {
setEditing(transaction);
@ -211,66 +131,34 @@ export default function CashAccountIndex({
});
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Tipe Sumber
</label>
<Select
value={filters.type ?? 'all'}
onValueChange={(value) =>
applyFilter('type', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Tipe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Tipe</SelectItem>
<SelectItem value="deposit">Deposit</SelectItem>
<SelectItem value="withdrawal">
Withdrawal
</SelectItem>
<SelectItem value="expense">
Pengeluaran
</SelectItem>
<SelectItem value="transfer">
Transfer
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={Boolean(filters.type)}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Tipe Sumber
</label>
<Select
value={filters.type ?? 'all'}
onValueChange={(value) => applyFilter('type', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Tipe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Tipe</SelectItem>
<SelectItem value="deposit">Deposit</SelectItem>
<SelectItem value="withdrawal">Withdrawal</SelectItem>
<SelectItem value="expense">Pengeluaran</SelectItem>
<SelectItem value="transfer">Transfer</SelectItem>
</SelectContent>
</Select>
</div>
</FilterPopover>
);
return (
@ -278,29 +166,27 @@ export default function CashAccountIndex({
<Head title="Kas Toko" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Kas Toko
</h2>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setDepositOpen(true)}
>
<ArrowDownToLine className="h-4 w-4" />
Deposit
</Button>
<Button
variant="outline"
onClick={() => setWithdrawalOpen(true)}
>
<ArrowUpFromLine className="h-4 w-4" />
Withdrawal
</Button>
</div>
</div>
<PageHeader
title="Kas Toko"
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setDepositOpen(true)}
>
<ArrowDownToLine className="h-4 w-4" />
Deposit
</Button>
<Button
variant="outline"
onClick={() => setWithdrawalOpen(true)}
>
<ArrowUpFromLine className="h-4 w-4" />
Withdrawal
</Button>
</div>
}
/>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@ -330,7 +216,7 @@ export default function CashAccountIndex({
toolbar={filterToolbar}
/>
<Dialog
<FormDialog
open={depositOpen}
onOpenChange={(open) => {
setDepositOpen(open);
@ -340,123 +226,72 @@ export default function CashAccountIndex({
setDepositFileMeta(null);
}
}}
title="Deposit"
action={deposit()}
resetOnSuccess
onSuccess={() => {
setDepositOpen(false);
setDepositReceiptKey(null);
setDepositFileMeta(null);
}}
submitDisabled={depositUploading}
submitLabel={depositUploading ? 'Mengunggah...' : 'Simpan'}
>
<DialogContent>
<Form
action={deposit()}
resetOnSuccess
onSuccess={() => {
setDepositOpen(false);
setDepositReceiptKey(null);
setDepositFileMeta(null);
}}
>
{({ errors, processing }) => (
<>
<DialogHeader>
<DialogTitle>Deposit</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
/>
<InputError
message={errors.description}
/>
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={depositReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={
depositFileMeta?.size ?? ''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
depositFileMeta?.type ?? ''
}
/>
<FileUpload
value={depositReceiptKey}
onChange={setDepositReceiptKey}
folder="cash-transaction"
onUploadingChange={
setDepositUploading
}
onFileMeta={setDepositFileMeta}
/>
<InputError
message={errors.receipt_key}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() =>
setDepositOpen(false)
}
>
Batal
</Button>
<Button
type="submit"
disabled={
processing || depositUploading
}
>
{processing
? 'Menyimpan...'
: depositUploading
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
)}
</Form>
</DialogContent>
</Dialog>
{({ errors }) => (
<>
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">*</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label>
Keterangan{' '}
<span className="text-destructive">*</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={depositReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={depositFileMeta?.size ?? ''}
/>
<input
type="hidden"
name="file_mime_type"
value={depositFileMeta?.type ?? ''}
/>
<FileUpload
value={depositReceiptKey}
onChange={setDepositReceiptKey}
folder="cash-transaction"
onUploadingChange={setDepositUploading}
onFileMeta={setDepositFileMeta}
/>
<InputError message={errors.receipt_key} />
</div>
</>
)}
</FormDialog>
<Dialog
<FormDialog
open={withdrawalOpen}
onOpenChange={(open) => {
setWithdrawalOpen(open);
@ -466,132 +301,74 @@ export default function CashAccountIndex({
setWithdrawalFileMeta(null);
}
}}
title="Withdrawal"
action={withdrawal()}
resetOnSuccess
onSuccess={() => {
setWithdrawalOpen(false);
setWithdrawalReceiptKey(null);
setWithdrawalFileMeta(null);
}}
submitDisabled={withdrawalUploading}
submitLabel={
withdrawalUploading ? 'Mengunggah...' : 'Simpan'
}
>
<DialogContent>
<Form
action={withdrawal()}
resetOnSuccess
onSuccess={() => {
setWithdrawalOpen(false);
setWithdrawalReceiptKey(null);
setWithdrawalFileMeta(null);
}}
>
{({ errors, processing }) => (
<>
<DialogHeader>
<DialogTitle>Withdrawal</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
/>
<InputError
message={errors.description}
/>
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
withdrawalReceiptKey ?? ''
}
/>
<input
type="hidden"
name="file_size"
value={
withdrawalFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
withdrawalFileMeta?.type ??
''
}
/>
<FileUpload
value={withdrawalReceiptKey}
onChange={
setWithdrawalReceiptKey
}
folder="cash-transaction"
onUploadingChange={
setWithdrawalUploading
}
onFileMeta={
setWithdrawalFileMeta
}
/>
<InputError
message={errors.receipt_key}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() =>
setWithdrawalOpen(false)
}
>
Batal
</Button>
<Button
type="submit"
disabled={
processing ||
withdrawalUploading
}
>
{processing
? 'Menyimpan...'
: withdrawalUploading
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
)}
</Form>
</DialogContent>
</Dialog>
{({ errors }) => (
<>
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">*</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label>
Keterangan{' '}
<span className="text-destructive">*</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={withdrawalReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={withdrawalFileMeta?.size ?? ''}
/>
<input
type="hidden"
name="file_mime_type"
value={withdrawalFileMeta?.type ?? ''}
/>
<FileUpload
value={withdrawalReceiptKey}
onChange={setWithdrawalReceiptKey}
folder="cash-transaction"
onUploadingChange={setWithdrawalUploading}
onFileMeta={setWithdrawalFileMeta}
/>
<InputError message={errors.receipt_key} />
</div>
</>
)}
</FormDialog>
<Dialog
<FormDialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
@ -600,143 +377,96 @@ export default function CashAccountIndex({
setEditFileMeta(null);
}
}}
title="Edit Transaksi"
action={editing ? updateTransaction(editing.id) : ''}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}
submitDisabled={editUploading}
submitLabel={editUploading ? 'Mengunggah...' : 'Simpan'}
>
<DialogContent>
{editing && (
<Form
action={updateTransaction(editing.id)}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}
>
{({ errors, processing }) => (
<>
<DialogHeader>
<DialogTitle>
Edit Transaksi
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
defaultValue={
editing.description
}
/>
<InputError
message={errors.description}
/>
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={editReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={
editFileMeta?.size ?? ''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
editFileMeta?.type ?? ''
}
/>
<FileUpload
value={editReceiptKey}
onChange={setEditReceiptKey}
folder="cash-transaction"
onUploadingChange={
setEditUploading
}
existingUrl={
editing.receipt_url
}
onFileMeta={setEditFileMeta}
/>
<InputError
message={errors.receipt_key}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setEditing(null)}
>
Batal
</Button>
<Button
type="submit"
disabled={
processing || editUploading
}
>
{processing
? 'Menyimpan...'
: editUploading
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
)}
</Form>
)}
</DialogContent>
</Dialog>
{({ errors }) =>
editing && (
<>
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={editing.amount}
min={1}
/>
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
defaultValue={editing.description}
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={editReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={editFileMeta?.size ?? ''}
/>
<input
type="hidden"
name="file_mime_type"
value={editFileMeta?.type ?? ''}
/>
<FileUpload
value={editReceiptKey}
onChange={setEditReceiptKey}
folder="cash-transaction"
onUploadingChange={setEditUploading}
existingUrl={editing.receipt_url}
onFileMeta={setEditFileMeta}
/>
<InputError message={errors.receipt_key} />
</div>
</>
)
}
</FormDialog>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Transaksi"
description={`Apakah Anda yakin ingin menghapus transaksi "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(transaction) =>
`Apakah Anda yakin ingin menghapus transaksi "${transaction.description}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>

View File

@ -1,15 +1,9 @@
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatCurrency } from '@/lib/utils';
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ImagePreviewButton } from '@/components/image-preview-button';
import { RowActions } from '@/components/row-actions';
import { formatDate } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
export type CashTransaction = {
id: number;
@ -30,23 +24,6 @@ export type CashTransaction = {
} | null;
};
function formatDate(dateString: string): string {
const date = new Date(dateString);
return (
date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) +
' ' +
date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
}
function getTypeLabel(type: string): string {
const labels: Record<string, string> = {
deposit: 'Deposit',
@ -69,31 +46,6 @@ function getReferenceLabel(type: string): string {
return labels[type] ?? '-';
}
function ReceiptPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
>
<img
src={url}
alt={title}
className="h-full w-full object-cover"
/>
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={url}
title={title}
/>
</>
);
}
type CreateColumnsParams = {
handleEdit: (transaction: CashTransaction) => void;
handleDeleteClick: (transaction: CashTransaction) => void;
@ -182,8 +134,8 @@ export function createTransactionColumns(
}
return (
<ReceiptPreview
url={receiptUrl}
<ImagePreviewButton
srcs={[receiptUrl]}
title={row.original.description}
/>
);
@ -216,39 +168,22 @@ export function createTransactionColumns(
}
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(transaction)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(transaction)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(transaction),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(transaction),
},
]}
/>
);
},
},

View File

@ -1,13 +1,8 @@
import type { ColumnDef } from '@tanstack/react-table';
import { CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatDate, formatShortDate } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
export type EmployeeAdvance = {
@ -27,33 +22,6 @@ export type EmployeeAdvance = {
};
};
function formatDate(dateString: string): string {
const date = new Date(dateString);
return (
date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) +
' ' +
date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
}
function formatShortDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function getStatusBadge(status: string) {
const statusConfig: Record<string, { label: string; className: string }> = {
pending: {
@ -165,79 +133,39 @@ export function createEmployeeAdvanceColumns(
const employeeAdvance = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
{employeeAdvance.status === 'pending' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleApprove(employeeAdvance)
}
>
<CheckCircle className="h-4 w-4 text-green-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Setujui
</TooltipContent>
</Tooltip>
)}
{employeeAdvance.status === 'approved' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handlePay(employeeAdvance)
}
>
<CircleDollarSign className="h-4 w-4 text-blue-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Bayar
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(employeeAdvance)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(employeeAdvance)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Setujui',
icon: (
<CheckCircle className="h-4 w-4 text-green-600" />
),
show: employeeAdvance.status === 'pending',
onClick: () => handleApprove(employeeAdvance),
},
{
label: 'Bayar',
icon: (
<CircleDollarSign className="h-4 w-4 text-blue-600" />
),
show: employeeAdvance.status === 'approved',
onClick: () => handlePay(employeeAdvance),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(employeeAdvance),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () =>
handleDeleteClick(employeeAdvance),
},
]}
/>
);
},
},

View File

@ -1,22 +1,18 @@
import { Form, Head, router } from '@inertiajs/react';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { useEffect, useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
index as employeeAdvanceIndex,
@ -48,7 +44,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
undefined,
);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: employeeAdvances.current_page,
last_page: employeeAdvances.last_page,
@ -56,6 +52,16 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
total: employeeAdvances.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => employeeAdvanceIndex.url(),
pagination,
});
useEffect(() => {
if (editing) {
setEditingDueDate(new Date(editing.due_date));
@ -102,46 +108,6 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
);
}
function handlePageChange(page: number) {
router.get(
employeeAdvanceIndex.url(),
{
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
employeeAdvanceIndex.url(),
{
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
employeeAdvanceIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page],
);
const columns = createEmployeeAdvanceColumns({
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
@ -154,22 +120,9 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
<Head title="Kasbon" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Kasbon
</h2>
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setDueDate(undefined);
}
}}
>
<PageHeader
title="Kasbon"
actions={
<Button asChild>
<button
type="button"
@ -179,113 +132,72 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
Tambah
</button>
</Button>
<DialogContent>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Tambah Kasbon
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="description"
name="description"
placeholder="Masukkan keterangan"
/>
<InputError
message={
errors.description
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="due_date">
Jatuh Tempo{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="due_date"
value={
dueDate
? dueDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Pilih jatuh tempo"
min={new Date()}
/>
<InputError
message={
errors.due_date
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
}
/>
<FormDialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setDueDate(undefined);
}
}}
title="Tambah Kasbon"
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors }) => (
<>
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">*</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="description"
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label htmlFor="due_date">
Jatuh Tempo{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="due_date"
value={
dueDate
? dueDate
.toISOString()
.split('T')[0]
: ''
}
/>
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Pilih jatuh tempo"
min={new Date()}
/>
<InputError message={errors.due_date} />
</div>
</>
)}
</FormDialog>
<DataTable
columns={columns}
@ -300,7 +212,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
searchValue={search}
/>
<Dialog
<FormDialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
@ -308,162 +220,117 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
setEditingDueDate(undefined);
}
}}
title="Edit Kasbon"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditingDueDate(undefined);
}}
>
<DialogContent>
{editing && (
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditingDueDate(undefined);
}}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Edit Kasbon
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-description"
name="description"
placeholder="Masukkan keterangan"
defaultValue={
editing.description
}
/>
<InputError
message={
errors.description
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-due_date">
Jatuh Tempo{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="due_date"
value={
editingDueDate
? editingDueDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={editingDueDate}
onChange={
setEditingDueDate
}
placeholder="Pilih jatuh tempo"
min={new Date()}
/>
<InputError
message={
errors.due_date
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
{({ errors }) =>
editing && (
<>
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={editing.amount}
min={1}
/>
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-description"
name="description"
placeholder="Masukkan keterangan"
defaultValue={editing.description}
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-due_date">
Jatuh Tempo{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="due_date"
value={
editingDueDate
? editingDueDate
.toISOString()
.split('T')[0]
: ''
}
/>
<DatePicker
value={editingDueDate}
onChange={setEditingDueDate}
placeholder="Pilih jatuh tempo"
min={new Date()}
/>
<InputError message={errors.due_date} />
</div>
</>
)
}
</FormDialog>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Kasbon"
description={`Apakah Anda yakin ingin menghapus kasbon "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(advance) =>
`Apakah Anda yakin ingin menghapus kasbon "${advance.description}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
<ConfirmDialog
open={approving !== null}
<DeleteConfirmDialog
target={approving}
onOpenChange={(open) => {
if (!open) {
setApproving(null);
}
}}
title="Setujui Kasbon"
description={`Apakah Anda yakin ingin menyetujui kasbon "${approving?.description}"?`}
description={(advance) =>
`Apakah Anda yakin ingin menyetujui kasbon "${advance.description}"?`
}
confirmLabel="Setujui"
onConfirm={handleApprove}
/>
<ConfirmDialog
open={paying !== null}
<DeleteConfirmDialog
target={paying}
onOpenChange={(open) => {
if (!open) {
setPaying(null);
}
}}
title="Bayar Kasbon"
description={`Apakah Anda yakin ingin membayar kasbon "${paying?.description}" sebesar ${paying?.amount}? Saldo kas akan dikembalikan.`}
description={(advance) =>
`Apakah Anda yakin ingin membayar kasbon "${advance.description}" sebesar ${advance.amount}? Saldo kas akan dikembalikan.`
}
confirmLabel="Bayar"
onConfirm={handlePay}
/>

View File

@ -1,15 +1,9 @@
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatCurrency } from '@/lib/utils';
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ImagePreviewButton } from '@/components/image-preview-button';
import { RowActions } from '@/components/row-actions';
import { formatDate } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
export type Expense = {
id: number;
@ -25,53 +19,11 @@ export type Expense = {
};
};
function formatDate(dateString: string): string {
const date = new Date(dateString);
return (
date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) +
' ' +
date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
}
type CreateColumnsParams = {
handleEdit: (expense: Expense) => void;
handleDeleteClick: (expense: Expense) => void;
};
function ReceiptPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
>
<img
src={url}
alt={title}
className="h-full w-full object-cover"
/>
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={url}
title={title}
/>
</>
);
}
export function createExpenseColumns(
params: CreateColumnsParams,
): ColumnDef<Expense>[] {
@ -105,8 +57,8 @@ export function createExpenseColumns(
}
return (
<ReceiptPreview
url={receiptUrl}
<ImagePreviewButton
srcs={[receiptUrl]}
title={row.original.description}
/>
);
@ -141,39 +93,22 @@ export function createExpenseColumns(
const expense = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(expense)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(expense)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(expense),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(expense),
},
]}
/>
);
},
},

View File

@ -1,22 +1,18 @@
import { Form, Head, router } from '@inertiajs/react';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
index as expenseIndex,
@ -54,7 +50,7 @@ export default function ExpenseIndex({ expenses }: Props) {
size: number;
type: string;
} | null>(null);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: expenses.current_page,
last_page: expenses.last_page,
@ -62,45 +58,15 @@ export default function ExpenseIndex({ expenses }: Props) {
total: expenses.total,
};
function handlePageChange(page: number) {
router.get(
expenseIndex.url(),
{
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
expenseIndex.url(),
{
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
expenseIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page],
);
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => expenseIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
@ -125,23 +91,9 @@ export default function ExpenseIndex({ expenses }: Props) {
<Head title="Pengeluaran" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Pengeluaran
</h2>
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setCreateReceiptKey(null);
setCreateFileMeta(null);
}
}}
>
<PageHeader
title="Pengeluaran"
actions={
<Button asChild>
<button
type="button"
@ -151,140 +103,173 @@ export default function ExpenseIndex({ expenses }: Props) {
Tambah
</button>
</Button>
<DialogContent>
<Form
action={store()}
resetOnSuccess
onSuccess={() => {
setCreateOpen(false);
setCreateReceiptKey(null);
setCreateFileMeta(null);
}}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Tambah Pengeluaran
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="description"
name="description"
placeholder="Masukkan keterangan"
/>
<InputError
message={
errors.description
}
/>
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
createReceiptKey ??
''
}
/>
<input
type="hidden"
name="file_size"
value={
createFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
createFileMeta?.type ??
''
}
/>
<FileUpload
value={createReceiptKey}
onChange={
setCreateReceiptKey
}
folder="expense"
onUploadingChange={
setCreateUploading
}
onFileMeta={
setCreateFileMeta
}
/>
<InputError
message={
errors.receipt_key
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type="submit"
disabled={
processing ||
createUploading
}
>
{processing
? 'Menyimpan...'
: createUploading
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
}
/>
<FormDialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setCreateReceiptKey(null);
setCreateFileMeta(null);
}
}}
title="Tambah Pengeluaran"
action={store()}
resetOnSuccess
onSuccess={() => {
setCreateOpen(false);
setCreateReceiptKey(null);
setCreateFileMeta(null);
}}
submitDisabled={createUploading}
submitLabel={createUploading ? 'Mengunggah...' : 'Simpan'}
>
{({ errors }) => (
<>
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">*</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="description"
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={createReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={createFileMeta?.size ?? ''}
/>
<input
type="hidden"
name="file_mime_type"
value={createFileMeta?.type ?? ''}
/>
<FileUpload
value={createReceiptKey}
onChange={setCreateReceiptKey}
folder="expense"
onUploadingChange={setCreateUploading}
onFileMeta={setCreateFileMeta}
/>
<InputError message={errors.receipt_key} />
</div>
</>
)}
</FormDialog>
<FormDialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}
}}
title="Edit Pengeluaran"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}
submitDisabled={editUploading}
submitLabel={editUploading ? 'Mengunggah...' : 'Simpan'}
>
{({ errors }) =>
editing && (
<>
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={editing.amount}
min={1}
/>
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-description"
name="description"
placeholder="Masukkan keterangan"
defaultValue={editing.description}
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={editReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={editFileMeta?.size ?? ''}
/>
<input
type="hidden"
name="file_mime_type"
value={editFileMeta?.type ?? ''}
/>
<FileUpload
value={editReceiptKey}
onChange={setEditReceiptKey}
folder="expense"
onUploadingChange={setEditUploading}
existingUrl={editing.receipt_url}
onFileMeta={setEditFileMeta}
/>
<InputError message={errors.receipt_key} />
</div>
</>
)
}
</FormDialog>
<DataTable
columns={columns}
@ -299,170 +284,17 @@ export default function ExpenseIndex({ expenses }: Props) {
searchValue={search}
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}
}}
>
<DialogContent>
{editing && (
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Edit Pengeluaran
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-description"
name="description"
placeholder="Masukkan keterangan"
defaultValue={
editing.description
}
/>
<InputError
message={
errors.description
}
/>
</div>
<div className="grid gap-2">
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
editReceiptKey ?? ''
}
/>
<input
type="hidden"
name="file_size"
value={
editFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
editFileMeta?.type ??
''
}
/>
<FileUpload
value={editReceiptKey}
onChange={
setEditReceiptKey
}
folder="expense"
onUploadingChange={
setEditUploading
}
existingUrl={
editing.receipt_url
}
onFileMeta={
setEditFileMeta
}
/>
<InputError
message={
errors.receipt_key
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={
processing ||
editUploading
}
>
{processing
? 'Menyimpan...'
: editUploading
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Pengeluaran"
description={`Apakah Anda yakin ingin menghapus pengeluaran "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(expense) =>
`Apakah Anda yakin ingin menghapus pengeluaran "${expense.description}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>

View File

@ -1,15 +1,9 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Eye, Lock, Unlock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { MONTH_NAMES } from '@/lib/constants';
import { formatCurrency } from '@/lib/utils';
import { Link } from '@inertiajs/react';
export type PayrollPeriod = {
id: number;
@ -26,22 +20,6 @@ export type PayrollPeriod = {
payrolls_sum_deduction_amount: number | null;
};
const MONTH_NAMES = [
'',
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
function formatPeriod(period: PayrollPeriod): string {
return `${MONTH_NAMES[period.month]} ${period.year}`;
}
@ -201,56 +179,31 @@ export function createPayrollPeriodColumns(
const period = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href={showUrl(period.id)}>
<Eye className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Lihat Detail
</TooltipContent>
</Tooltip>
{period.status === 'open' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleClose(period)}
>
<Lock className="h-4 w-4 text-orange-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Tutup Periode
</TooltipContent>
</Tooltip>
)}
{period.status === 'closed' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleReopen(period)}
>
<Unlock className="h-4 w-4 text-blue-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Buka Periode
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: showUrl(period.id),
},
{
label: 'Tutup Periode',
icon: (
<Lock className="h-4 w-4 text-orange-600" />
),
show: period.status === 'open',
onClick: () => handleClose(period),
},
{
label: 'Buka Periode',
icon: (
<Unlock className="h-4 w-4 text-blue-600" />
),
show: period.status === 'closed',
onClick: () => handleReopen(period),
},
]}
/>
);
},
},

View File

@ -1,19 +1,17 @@
import { Head, router } from '@inertiajs/react';
import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { PageHeader } from '@/components/page-header';
import { useServerTable } from '@/hooks/use-server-table';
import { MONTH_NAMES } from '@/lib/constants';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
import { show as payrollPeriodShow } from '@/routes/admin/finance/payroll-periods';
import { close, reopen } from '@/routes/admin/finance/payroll-periods';
index as payrollPeriodsIndex,
show as payrollPeriodShow,
close,
reopen,
} from '@/routes/admin/finance/payroll-periods';
import { createPayrollPeriodColumns } from './columns';
import type { PayrollPeriod } from './columns';
@ -27,26 +25,10 @@ type Props = {
};
};
const MONTH_NAMES = [
'',
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: payrollPeriods.current_page,
last_page: payrollPeriods.last_page,
@ -54,6 +36,16 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
total: payrollPeriods.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => payrollPeriodsIndex.url(),
pagination,
});
function handleClose() {
if (!closing) {
return;
@ -82,46 +74,6 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
);
}
function handlePageChange(page: number) {
router.get(
payrollPeriodsIndex(),
{
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
payrollPeriodsIndex(),
{
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
payrollPeriodsIndex(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page],
);
const columns = createPayrollPeriodColumns({
showUrl: (id) => payrollPeriodShow(id).url,
handleClose: (period) => setClosing(period),
@ -133,13 +85,7 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
<Head title="Gaji" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Gaji
</h2>
</div>
</div>
<PageHeader title="Gaji" />
<DataTable
columns={columns}
@ -154,28 +100,32 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
searchValue={search}
/>
<ConfirmDialog
open={closing !== null}
<DeleteConfirmDialog
target={closing}
onOpenChange={(open) => {
if (!open) {
setClosing(null);
}
}}
title="Tutup Periode Gaji"
description={`Apakah Anda yakin ingin menutup periode gaji ${closing ? `${MONTH_NAMES[closing.month]} ${closing.year}` : ''}? Semua gaji harus sudah dibayar sebelum periode ditutup.`}
description={(period) =>
`Apakah Anda yakin ingin menutup periode gaji ${MONTH_NAMES[period.month]} ${period.year}? Semua gaji harus sudah dibayar sebelum periode ditutup.`
}
confirmLabel="Tutup"
onConfirm={handleClose}
/>
<ConfirmDialog
open={reopening !== null}
<DeleteConfirmDialog
target={reopening}
onOpenChange={(open) => {
if (!open) {
setReopening(null);
}
}}
title="Buka Periode Gaji"
description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`}
description={(period) =>
`Apakah Anda yakin ingin membuka kembali periode gaji ${MONTH_NAMES[period.month]} ${period.year}?`
}
confirmLabel="Buka"
onConfirm={handleReopen}
/>

View File

@ -1,13 +1,7 @@
import type { ColumnDef } from '@tanstack/react-table';
import { CircleDollarSign, Pencil, Trash2, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatCurrency } from '@/lib/utils';
export type Payroll = {
@ -215,64 +209,32 @@ export function createPayrollColumns(
const payroll = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
{payroll.status === 'unpaid' && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleAddAdjustment(payroll)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Tambah Adjustment
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handlePay(payroll)
}
>
<CircleDollarSign className="h-4 w-4 text-green-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Tandai Dibayar
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleCancel(payroll)
}
>
<XCircle className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Batalkan
</TooltipContent>
</Tooltip>
</>
)}
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Tambah Adjustment',
icon: <Pencil className="h-4 w-4" />,
show: payroll.status === 'unpaid',
onClick: () => handleAddAdjustment(payroll),
},
{
label: 'Tandai Dibayar',
icon: (
<CircleDollarSign className="h-4 w-4 text-green-600" />
),
show: payroll.status === 'unpaid',
onClick: () => handlePay(payroll),
},
{
label: 'Batalkan',
icon: (
<XCircle className="h-4 w-4 text-destructive" />
),
show: payroll.status === 'unpaid',
onClick: () => handleCancel(payroll),
},
]}
/>
);
},
},

View File

@ -1,3 +1,6 @@
import { Form, Head, router } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import InputError from '@/components/input-error';
@ -13,6 +16,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { MONTH_NAMES } from '@/lib/constants';
import { formatCurrency } from '@/lib/utils';
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
@ -21,9 +25,6 @@ import {
pay as payrollPay,
} from '@/routes/admin/finance/payrolls';
import { store as adjustmentStore } from '@/routes/admin/finance/payrolls/adjustments';
import { Form, Head, router } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
import type { Payroll, PayrollAdjustment } from './show-columns';
import { createPayrollColumns } from './show-columns';
@ -37,22 +38,6 @@ type Props = {
};
};
const MONTH_NAMES = [
'',
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
export default function PayrollPeriodShow({ payrollPeriod }: Props) {
const [paying, setPaying] = useState<Payroll | null>(null);
const [cancelling, setCancelling] = useState<Payroll | null>(null);
@ -66,7 +51,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
function handlePay() {
if (!paying) return;
if (!paying) {
return;
}
router.post(
payrollPay(paying.id),
@ -78,7 +65,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
}
function handleCancel() {
if (!cancelling) return;
if (!cancelling) {
return;
}
router.post(
payrollCancel(cancelling.id),
@ -90,7 +79,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
}
function handleDeleteAdjustment() {
if (!deletingAdjustment) return;
if (!deletingAdjustment) {
return;
}
router.delete(adjustmentDestroy(deletingAdjustment.adjustment.id), {
onSuccess: () => setDeletingAdjustment(null),
@ -336,7 +327,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
<ConfirmDialog
open={paying !== null}
onOpenChange={(open) => {
if (!open) setPaying(null);
if (!open) {
setPaying(null);
}
}}
title="Tandai Dibayar"
description={`Apakah Anda yakin ingin menandai gaji "${paying?.employee?.user?.user_profile?.full_name}" sebesar ${formatCurrency(paying?.total_amount ?? 0)} sebagai sudah dibayar? Penyesuaian tidak dapat ditambahkan setelah dibayar.`}
@ -348,7 +341,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
<ConfirmDialog
open={cancelling !== null}
onOpenChange={(open) => {
if (!open) setCancelling(null);
if (!open) {
setCancelling(null);
}
}}
title="Batalkan Gaji"
description={`Apakah Anda yakin ingin membatalkan gaji "${cancelling?.employee?.user?.user_profile?.full_name}"?`}
@ -360,7 +355,9 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
<ConfirmDialog
open={deletingAdjustment !== null}
onOpenChange={(open) => {
if (!open) setDeletingAdjustment(null);
if (!open) {
setDeletingAdjustment(null);
}
}}
title="Hapus Penyesuaian"
description={`Apakah Anda yakin ingin menghapus penyesuaian "${deletingAdjustment?.adjustment.description}" sebesar ${formatCurrency(deletingAdjustment?.adjustment.amount ?? 0)}?`}

View File

@ -1,14 +1,8 @@
import type { ColumnDef } from '@tanstack/react-table';
import { router } from '@inertiajs/react';
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { RowActions } from '@/components/row-actions';
import { ToggleStatus } from '@/components/toggle-status';
import { formatCurrency } from '@/lib/utils';
export type Employee = {
id: number;
@ -38,14 +32,6 @@ function getEmploymentStatusLabel(status: string): string {
return labels[status] ?? status;
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
type CreateColumnsParams = {
handleEdit: (employee: Employee) => void;
handleDeleteClick: (employee: Employee) => void;
@ -132,18 +118,10 @@ export function createEmployeeColumns(
return (
<div className="flex justify-center">
<Switch
size="sm"
<ToggleStatus
url={toggleActiveUrl(employee.id)}
checked={employee.is_active}
onCheckedChange={() => {
router.post(
toggleActiveUrl(employee.id),
{},
{
preserveScroll: true,
},
);
}}
wrapperClassName="flex items-center justify-center gap-1"
/>
</div>
);
@ -160,56 +138,29 @@ export function createEmployeeColumns(
const employee = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(employee)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleResetPassword(employee)
}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset Kata Sandi
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(employee)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(employee),
},
{
label: 'Reset Kata Sandi',
icon: (
<KeyRound className="h-4 w-4 text-muted-foreground" />
),
onClick: () => handleResetPassword(employee),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(employee),
},
]}
/>
);
},
},

View File

@ -1,15 +1,12 @@
import { Head, router } from '@inertiajs/react';
import { Filter, Plus, X } from 'lucide-react';
import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
@ -17,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
create as employeeCreate,
@ -47,8 +45,7 @@ export default function EmployeeIndex({ employees, filters }: Props) {
const [deleting, setDeleting] = useState<Employee | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] =
useState<Employee | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: employees.current_page,
last_page: employees.last_page,
@ -56,84 +53,20 @@ export default function EmployeeIndex({ employees, filters }: Props) {
total: employees.total,
};
const hasActiveFilters = filters.employment_status || filters.is_active;
function applyFilter(key: string, value: string) {
const newFilters = { ...filters };
if (value === '' || value === 'all') {
delete newFilters[key as keyof typeof newFilters];
} else {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(
employeeIndex.url(),
{
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function clearFilters() {
router.get(
employeeIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false);
}
function handlePageChange(page: number) {
router.get(
employeeIndex.url(),
{
...filters,
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
employeeIndex.url(),
{
...filters,
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
employeeIndex.url(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, filters],
);
const {
search,
filterOpen,
setFilterOpen,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({
route: () => employeeIndex.url(),
pagination,
filters,
});
function handleDelete() {
if (!deleting) {
@ -169,115 +102,77 @@ export default function EmployeeIndex({ employees, filters }: Props) {
});
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={Boolean(
filters.employment_status || filters.is_active,
)}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status Karyawan
</label>
<Select
value={filters.employment_status ?? 'all'}
onValueChange={(value) =>
applyFilter('employment_status', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="full_time">Full Time</SelectItem>
<SelectItem value="part_time">Part Time</SelectItem>
<SelectItem value="contract">Kontrak</SelectItem>
<SelectItem value="internship">Magang</SelectItem>
<SelectItem value="resigned">Keluar</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status Karyawan
</label>
<Select
value={filters.employment_status ?? 'all'}
onValueChange={(value) =>
applyFilter('employment_status', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Semua Status
</SelectItem>
<SelectItem value="full_time">
Full Time
</SelectItem>
<SelectItem value="part_time">
Part Time
</SelectItem>
<SelectItem value="contract">
Kontrak
</SelectItem>
<SelectItem value="internship">
Magang
</SelectItem>
<SelectItem value="resigned">Keluar</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status Aktif
</label>
<Select
value={filters.is_active ?? 'all'}
onValueChange={(value) => applyFilter('is_active', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="1">Aktif</SelectItem>
<SelectItem value="0">Tidak Aktif</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status Aktif
</label>
<Select
value={filters.is_active ?? 'all'}
onValueChange={(value) =>
applyFilter('is_active', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="1">Aktif</SelectItem>
<SelectItem value="0">Tidak Aktif</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Jenis Kelamin
</label>
<Select
value={filters.gender ?? 'all'}
onValueChange={(value) =>
applyFilter('gender', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="male">Laki-laki</SelectItem>
<SelectItem value="female">
Perempuan
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Jenis Kelamin
</label>
<Select
value={filters.gender ?? 'all'}
onValueChange={(value) => applyFilter('gender', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="male">Laki-laki</SelectItem>
<SelectItem value="female">Perempuan</SelectItem>
</SelectContent>
</Select>
</div>
</FilterPopover>
);
return (
@ -285,19 +180,17 @@ export default function EmployeeIndex({ employees, filters }: Props) {
<Head title="Pegawai" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Pegawai
</h2>
</div>
<Button asChild>
<a href={employeeCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<PageHeader
title="Pegawai"
actions={
<Button asChild>
<a href={employeeCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<DataTable
columns={columns}
@ -313,28 +206,31 @@ export default function EmployeeIndex({ employees, filters }: Props) {
toolbar={filterToolbar}
/>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Pegawai"
description={`Apakah Anda yakin ingin menghapus pegawai "${deleting?.user_profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(employee) =>
`Apakah Anda yakin ingin menghapus pegawai "${employee.user_profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
<ConfirmDialog
open={resetPasswordTarget !== null}
<DeleteConfirmDialog
target={resetPasswordTarget}
onOpenChange={(open) => {
if (!open) {
setResetPasswordTarget(null);
}
}}
title="Reset Kata Sandi"
description={`Apakah Anda yakin ingin mereset kata sandi pegawai "${resetPasswordTarget?.user_profile?.full_name}" ke kata sandi default?`}
description={(employee) =>
`Apakah Anda yakin ingin mereset kata sandi pegawai "${employee.user_profile?.full_name}" ke kata sandi default?`
}
confirmLabel="Reset"
variant="default"
onConfirm={handleResetPassword}

View File

@ -1,13 +1,8 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { ColumnDef } from '@tanstack/react-table';
import { CheckCircle, Pencil, Trash2, XCircle } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { formatShortDate } from '@/lib/format';
export type LeaveRequest = {
id: number;
@ -25,16 +20,6 @@ export type LeaveRequest = {
};
};
function formatShortDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function getStatusBadge(status: string) {
const statusConfig: Record<string, { label: string; className: string }> = {
pending: {
@ -136,77 +121,38 @@ export function createLeaveRequestColumns(
const leaveRequest = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
{leaveRequest.status === 'pending' && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleApprove(leaveRequest)
}
>
<CheckCircle className="h-4 w-4 text-green-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Setujui
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleReject(leaveRequest)
}
>
<XCircle className="h-4 w-4 text-red-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Tolak
</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(leaveRequest)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(leaveRequest)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Setujui',
icon: (
<CheckCircle className="h-4 w-4 text-green-600" />
),
show: leaveRequest.status === 'pending',
onClick: () => handleApprove(leaveRequest),
},
{
label: 'Tolak',
icon: (
<XCircle className="h-4 w-4 text-red-600" />
),
show: leaveRequest.status === 'pending',
onClick: () => handleReject(leaveRequest),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(leaveRequest),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(leaveRequest),
},
]}
/>
);
},
},

View File

@ -1,25 +1,16 @@
import { Form, Head, router } from '@inertiajs/react';
import { Filter, Plus, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
@ -27,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import {
approve,
destroy,
@ -65,8 +57,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(
undefined,
);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: leaveRequests.current_page,
last_page: leaveRequests.last_page,
@ -74,7 +65,20 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
total: leaveRequests.total,
};
const hasActiveFilters = filters.status;
const {
search,
filterOpen,
setFilterOpen,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({
route: () => leaveRequestIndex.url(),
pagination,
filters,
});
useEffect(() => {
if (editing) {
@ -86,40 +90,6 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
}
}, [editing]);
function applyFilter(key: string, value: string) {
const newFilters = { ...filters };
if (value === '' || value === 'all') {
delete newFilters[key as keyof typeof newFilters];
} else {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(
leaveRequestIndex(),
{
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function clearFilters() {
router.get(
leaveRequestIndex(),
{
page: 1,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false);
}
function handleDelete() {
if (!deleting) {
return;
@ -158,49 +128,6 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
);
}
function handlePageChange(page: number) {
router.get(
leaveRequestIndex(),
{
...filters,
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
leaveRequestIndex(),
{
...filters,
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
leaveRequestIndex(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, filters],
);
const columns = createLeaveRequestColumns({
handleEdit: (leaveRequest) => setEditing(leaveRequest),
handleDeleteClick: (leaveRequest) => setDeleting(leaveRequest),
@ -209,70 +136,32 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
});
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status
</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) =>
applyFilter('status', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Semua Status
</SelectItem>
<SelectItem value="pending">
Menunggu
</SelectItem>
<SelectItem value="approved">
Disetujui
</SelectItem>
<SelectItem value="rejected">
Ditolak
</SelectItem>
<SelectItem value="cancelled">
Dibatalkan
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={Boolean(filters.status)}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Status</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="pending">Menunggu</SelectItem>
<SelectItem value="approved">Disetujui</SelectItem>
<SelectItem value="rejected">Ditolak</SelectItem>
<SelectItem value="cancelled">Dibatalkan</SelectItem>
</SelectContent>
</Select>
</div>
</FilterPopover>
);
return (
@ -280,23 +169,9 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
<Head title="Cuti" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Cuti
</h2>
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setStartDate(undefined);
setEndDate(undefined);
}
}}
>
<PageHeader
title="Cuti"
actions={
<Button asChild>
<button
type="button"
@ -306,112 +181,77 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
Tambah
</button>
</Button>
<DialogContent>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Tambah Permohonan Cuti
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Tanggal Mulai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="start_date"
value={
startDate
? startDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={startDate}
onChange={setStartDate}
placeholder="Pilih tanggal mulai"
min={new Date()}
/>
<InputError
message={
errors.start_date
}
/>
</div>
<div className="grid gap-2">
<Label>
Tanggal Selesai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="end_date"
value={
endDate
? endDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={endDate}
onChange={setEndDate}
placeholder="Pilih tanggal selesai"
min={startDate}
/>
<InputError
message={
errors.end_date
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
}
/>
<FormDialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setStartDate(undefined);
setEndDate(undefined);
}
}}
title="Tambah Permohonan Cuti"
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors }) => (
<>
<div className="grid gap-2">
<Label>
Tanggal Mulai{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="start_date"
value={
startDate
? startDate
.toISOString()
.split('T')[0]
: ''
}
/>
<DatePicker
value={startDate}
onChange={setStartDate}
placeholder="Pilih tanggal mulai"
min={new Date()}
/>
<InputError message={errors.start_date} />
</div>
<div className="grid gap-2">
<Label>
Tanggal Selesai{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="end_date"
value={
endDate
? endDate
.toISOString()
.split('T')[0]
: ''
}
/>
<DatePicker
value={endDate}
onChange={setEndDate}
placeholder="Pilih tanggal selesai"
min={startDate}
/>
<InputError message={errors.end_date} />
</div>
</>
)}
</FormDialog>
<DataTable
columns={columns}
@ -427,7 +267,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
toolbar={filterToolbar}
/>
<Dialog
<FormDialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
@ -436,125 +276,77 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
setEditingEndDate(undefined);
}
}}
title="Edit Permohonan Cuti"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditingStartDate(undefined);
setEditingEndDate(undefined);
}}
>
<DialogContent>
{editing && (
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditingStartDate(undefined);
setEditingEndDate(undefined);
}}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Edit Permohonan Cuti
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Tanggal Mulai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="start_date"
value={
editingStartDate
? editingStartDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={editingStartDate}
onChange={
setEditingStartDate
}
placeholder="Pilih tanggal mulai"
min={new Date()}
/>
<InputError
message={
errors.start_date
}
/>
</div>
<div className="grid gap-2">
<Label>
Tanggal Selesai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="end_date"
value={
editingEndDate
? editingEndDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={editingEndDate}
onChange={
setEditingEndDate
}
placeholder="Pilih tanggal selesai"
min={editingStartDate}
/>
<InputError
message={
errors.end_date
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
{({ errors }) =>
editing && (
<>
<div className="grid gap-2">
<Label>
Tanggal Mulai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="start_date"
value={
editingStartDate
? editingStartDate
.toISOString()
.split('T')[0]
: ''
}
/>
<DatePicker
value={editingStartDate}
onChange={setEditingStartDate}
placeholder="Pilih tanggal mulai"
min={new Date()}
/>
<InputError message={errors.start_date} />
</div>
<div className="grid gap-2">
<Label>
Tanggal Selesai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="end_date"
value={
editingEndDate
? editingEndDate
.toISOString()
.split('T')[0]
: ''
}
/>
<DatePicker
value={editingEndDate}
onChange={setEditingEndDate}
placeholder="Pilih tanggal selesai"
min={editingStartDate}
/>
<InputError message={errors.end_date} />
</div>
</>
)
}
</FormDialog>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
@ -562,12 +354,11 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
}}
title="Hapus Permohonan Cuti"
description={`Apakah Anda yakin ingin menghapus permohonan cuti ini? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
<ConfirmDialog
open={approving !== null}
<DeleteConfirmDialog
target={approving}
onOpenChange={(open) => {
if (!open) {
setApproving(null);
@ -579,8 +370,8 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
onConfirm={handleApprove}
/>
<ConfirmDialog
open={rejecting !== null}
<DeleteConfirmDialog
target={rejecting}
onOpenChange={(open) => {
if (!open) {
setRejecting(null);

View File

@ -41,18 +41,14 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { usePurchaseDraftSave } from '@/hooks/use-purchase-draft';
import { UNITS } from '@/lib/constants';
import { formatNumber } from '@/lib/format';
import { loadPurchaseDraft } from '@/lib/purchase-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases';
import type { PurchaseCreateData } from './columns';
const UNITS = [
{ value: 'kg', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'yard', label: 'Yard' },
];
type VariantState = {
variant: string;
price: number;
@ -383,9 +379,7 @@ export default function PurchaseCreate({ data }: Props) {
})();
function formatQuantity(value: number): string {
return new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 4,
}).format(value);
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
@ -806,9 +800,9 @@ export default function PurchaseCreate({ data }: Props) {
</Label>
<Combobox
items={rawMaterials}
itemToStringLabel={(m) =>
m.name
}
itemToStringLabel={(
m,
) => m.name}
value={selectedMaterial}
onValueChange={(
value,
@ -834,10 +828,14 @@ export default function PurchaseCreate({ data }: Props) {
key={
m.id
}
value={m}
value={
m
}
>
{m.name}{' '}
({m.unit})
(
{m.unit}
)
</ComboboxItem>
)}
</ComboboxList>
@ -932,7 +930,9 @@ export default function PurchaseCreate({ data }: Props) {
<Minus className="h-4 w-4" />
</Button>
<NumberInput
min={0}
min={
0
}
className="w-24 text-center"
value={
quantities[
@ -997,9 +997,7 @@ export default function PurchaseCreate({ data }: Props) {
onValueChange={(value) =>
setSupplierId(
value
? String(
value.id,
)
? String(value.id)
: '',
)
}

View File

@ -39,6 +39,7 @@ import {
} from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
import {
@ -100,9 +101,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url);
const [uploading, setUploading] = useState(false);
const [mode, setMode] = useState<'new' | 'existing'>(
purchase.default_mode,
);
const [mode, setMode] = useState<'new' | 'existing'>(purchase.default_mode);
const [selectedMaterialName, setSelectedMaterialName] = useState(
purchase.existing_material_name ?? '',
);
@ -321,9 +320,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
})();
function formatQuantity(value: number): string {
return new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 4,
}).format(value);
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
@ -704,9 +701,9 @@ export default function PurchaseEdit({ purchase, data }: Props) {
</Label>
<Combobox
items={rawMaterials}
itemToStringLabel={(m) =>
m.name
}
itemToStringLabel={(
m,
) => m.name}
value={selectedMaterial}
onValueChange={(
value,
@ -732,10 +729,14 @@ export default function PurchaseEdit({ purchase, data }: Props) {
key={
m.id
}
value={m}
value={
m
}
>
{m.name}{' '}
({m.unit})
(
{m.unit}
)
</ComboboxItem>
)}
</ComboboxList>
@ -830,7 +831,9 @@ export default function PurchaseEdit({ purchase, data }: Props) {
<Minus className="h-4 w-4" />
</Button>
<NumberInput
min={0}
min={
0
}
className="w-24 text-center"
value={
quantities[
@ -895,9 +898,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
onValueChange={(value) =>
setSupplierId(
value
? String(
value.id,
)
? String(value.id)
: '',
)
}

View File

@ -1,10 +1,12 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useState } from 'react';
import { CardTable } from '@/components/card-table';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
create as purchaseCreate,
@ -27,7 +29,6 @@ type Props = {
export default function PurchaseIndex({ purchases }: Props) {
const [deleting, setDeleting] = useState<Purchase | null>(null);
const [search, setSearch] = useState('');
const expand = useCardTableExpand(true);
const pagination = {
@ -37,45 +38,15 @@ export default function PurchaseIndex({ purchases }: Props) {
total: purchases.total,
};
function handlePageChange(page: number) {
router.get(
purchaseIndex.url(),
{
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
purchaseIndex.url(),
{
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
purchaseIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page],
);
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => purchaseIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
@ -92,19 +63,17 @@ export default function PurchaseIndex({ purchases }: Props) {
<Head title="Belanja" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Belanja
</h2>
</div>
<Button asChild>
<a href={purchaseCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<PageHeader
title="Belanja"
actions={
<Button asChild>
<a href={purchaseCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<CardTable
data={purchases.data}
@ -127,7 +96,7 @@ export default function PurchaseIndex({ purchases }: Props) {
purchase={item}
index={
(pagination.current_page - 1) *
pagination.per_page +
pagination.per_page +
index +
1
}
@ -144,16 +113,17 @@ export default function PurchaseIndex({ purchases }: Props) {
)}
/>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Belanja"
description={`Apakah Anda yakin ingin menghapus belanja dari "${deleting?.supplier?.name}"? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(purchase) =>
`Apakah Anda yakin ingin menghapus belanja dari "${purchase.supplier?.name}"? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>

View File

@ -1,41 +1,12 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ImagePreviewButton } from '@/components/image-preview-button';
import { RowActions } from '@/components/row-actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatDateTime, formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { Purchase } from './columns';
function formatDateTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 4,
}).format(num);
}
export type PurchaseCardRowParams = {
purchase: Purchase;
index: number;
@ -57,10 +28,11 @@ export function PurchaseCardRow({
const variantCount = items.length;
const rawMaterialName =
items[0]?.raw_material_price?.raw_material?.name ?? '-';
const unit =
items[0]?.raw_material_price?.raw_material?.unit ?? '';
const totalQty = items.reduce((sum, item) => sum + Number(item.quantity), 0);
const [photoPreviewOpen, setPhotoPreviewOpen] = useState(false);
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
const totalQty = items.reduce(
(sum, item) => sum + Number(item.quantity),
0,
);
return (
<>
@ -110,86 +82,68 @@ export function PurchaseCardRow({
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>
<span className="text-muted-foreground">Qty: </span>
<span className="text-muted-foreground">
Qty:{' '}
</span>
{formatNumber(totalQty)} {unit}
</span>
<span>
<span className="text-muted-foreground">Sub: </span>
<span className="text-muted-foreground">
Sub:{' '}
</span>
{formatCurrency(purchase.subtotal)}
</span>
<span>
<span className="text-muted-foreground">Disc: </span>
<span className="text-muted-foreground">
Disc:{' '}
</span>
{formatCurrency(purchase.discount)}
</span>
<span>
<span className="text-muted-foreground">Ongkir: </span>
<span className="text-muted-foreground">
Ongkir:{' '}
</span>
{formatCurrency(purchase.shipping_cost)}
</span>
<span className="font-semibold">
<span className="text-muted-foreground font-normal">Total: </span>
<span className="font-normal text-muted-foreground">
Total:{' '}
</span>
{formatCurrency(purchase.total)}
</span>
</div>
{purchase.photo_url && (
<div className="mt-2">
<button
type="button"
onClick={() => setPhotoPreviewOpen(true)}
className="block h-16 w-16 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
>
<img
src={purchase.photo_url}
alt="Foto belanja"
className="h-full w-full object-cover"
/>
</button>
<ImagePreviewButton
srcs={[purchase.photo_url]}
title="Foto Belanja"
className="h-16 w-16"
/>
</div>
)}
</div>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(purchase)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(purchase)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => onEdit(purchase),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => onDelete(purchase),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
</div>
</CardContent>
</Card>
{purchase.photo_url && (
<ImagePreviewModal
open={photoPreviewOpen}
onOpenChange={setPhotoPreviewOpen}
src={purchase.photo_url}
title="Foto Belanja"
/>
)}
</>
);
}

View File

@ -1,5 +1,4 @@
import { useState } from 'react';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { ImagePreviewButton } from '@/components/image-preview-button';
import {
Table,
TableBody,
@ -8,67 +7,13 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { Purchase } from './columns';
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 4,
}).format(num);
}
function formatDateTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="block h-10 w-10 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
>
<img
src={url}
alt={title}
className="h-full w-full object-cover"
/>
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={url}
title={title}
/>
</>
);
}
export function PurchaseItemSubRow({
purchase,
}: {
purchase: Purchase;
}) {
export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
const items = purchase.purchase_items ?? [];
const unit =
items[0]?.raw_material_price?.raw_material?.unit ?? '';
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
return (
<div className="space-y-4 overflow-x-auto">
@ -103,9 +48,14 @@ export function PurchaseItemSubRow({
</TableCell>
<TableCell>
{item.raw_material_price?.photo_url ? (
<VariantPhotoPreview
url={item.raw_material_price.photo_url}
title={item.raw_material_price.variant}
<ImagePreviewButton
srcs={[
item.raw_material_price
.photo_url,
]}
title={
item.raw_material_price.variant
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">

View File

@ -29,6 +29,7 @@ import {
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { useRestockDraftSave } from '@/hooks/use-restock-draft';
import { formatNumber } from '@/lib/format';
import { loadRestockDraft } from '@/lib/restock-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
@ -107,8 +108,7 @@ export default function RestockCreate({ data }: Props) {
}, [quantities]);
const selectedProduct = useMemo(
() =>
products.find((p) => String(p.id) === selectedProductId) ?? null,
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
[products, selectedProductId],
);
@ -178,9 +178,7 @@ export default function RestockCreate({ data }: Props) {
})();
function formatQuantity(value: number): string {
return new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 4,
}).format(value);
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
@ -292,8 +290,7 @@ export default function RestockCreate({ data }: Props) {
(quantities[
variant
.id
] ??
0) > 0
] ?? 0) > 0
? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex items-center justify-between gap-3 rounded-lg border p-3'
}
@ -329,11 +326,9 @@ export default function RestockCreate({ data }: Props) {
)}{' '}
pcs
·{' '}
{
formatCurrency(
variant.capital_price,
)
}
{formatCurrency(
variant.capital_price,
)}
</p>
</div>
</div>
@ -416,8 +411,7 @@ export default function RestockCreate({ data }: Props) {
onValueChange={(value) =>
setStockType(
value as
| 'good'
| 'reject',
'good' | 'reject',
)
}
className="flex flex-wrap gap-4"
@ -507,9 +501,7 @@ export default function RestockCreate({ data }: Props) {
}}
folder="restock"
existingUrl={photoUrl}
onUploadingChange={
setUploading
}
onUploadingChange={setUploading}
/>
<InputError
message={errors.photo_key}
@ -523,9 +515,9 @@ export default function RestockCreate({ data }: Props) {
processing ||
uploading ||
!selectedProductId ||
Object.values(
quantities,
).every((q) => q <= 0)
Object.values(quantities).every(
(q) => q <= 0,
)
}
>
{processing
@ -648,8 +640,7 @@ export default function RestockCreate({ data }: Props) {
</div>
<span className="font-medium">
{formatCurrency(
item.price *
item.quantity,
item.price * item.quantity,
)}
</span>
</div>

View File

@ -20,12 +20,10 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
import {
index as restockIndex,
update,
} from '@/routes/admin/manage/restocks';
import { index as restockIndex, update } from '@/routes/admin/manage/restocks';
import type { RestockCreateData, RestockForEdit } from './columns';
type CartLine = {
@ -71,9 +69,7 @@ export default function RestockEdit({ restock, data }: Props) {
);
const [notes, setNotes] = useState(restock.notes ?? '');
const [photo, setPhoto] = useState<string | null>(restock.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(
restock.photo_url,
);
const [photoUrl, setPhotoUrl] = useState<string | null>(restock.photo_url);
const [uploading, setUploading] = useState(false);
const [cartOpen, setCartOpen] = useState(false);
const [previewKey, setPreviewKey] = useState<string | null>(null);
@ -86,8 +82,7 @@ export default function RestockEdit({ restock, data }: Props) {
}, [quantities]);
const selectedProduct = useMemo(
() =>
products.find((p) => String(p.id) === selectedProductId) ?? null,
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
[products, selectedProductId],
);
@ -157,9 +152,7 @@ export default function RestockEdit({ restock, data }: Props) {
})();
function formatQuantity(value: number): string {
return new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 4,
}).format(value);
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
@ -225,8 +218,7 @@ export default function RestockEdit({ restock, data }: Props) {
(quantities[
variant
.id
] ??
0) > 0
] ?? 0) > 0
? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex items-center justify-between gap-3 rounded-lg border p-3'
}
@ -262,11 +254,9 @@ export default function RestockEdit({ restock, data }: Props) {
)}{' '}
pcs
·{' '}
{
formatCurrency(
variant.capital_price,
)
}
{formatCurrency(
variant.capital_price,
)}
</p>
</div>
</div>
@ -354,8 +344,7 @@ export default function RestockEdit({ restock, data }: Props) {
onValueChange={(value) =>
setStockType(
value as
| 'good'
| 'reject',
'good' | 'reject',
)
}
className="flex flex-wrap gap-4"
@ -445,9 +434,7 @@ export default function RestockEdit({ restock, data }: Props) {
}}
folder="restock"
existingUrl={photoUrl}
onUploadingChange={
setUploading
}
onUploadingChange={setUploading}
/>
<InputError
message={errors.photo_key}
@ -460,9 +447,9 @@ export default function RestockEdit({ restock, data }: Props) {
disabled={
processing ||
uploading ||
Object.values(
quantities,
).every((q) => q <= 0)
Object.values(quantities).every(
(q) => q <= 0,
)
}
>
{processing
@ -585,8 +572,7 @@ export default function RestockEdit({ restock, data }: Props) {
</div>
<span className="font-medium">
{formatCurrency(
item.price *
item.quantity,
item.price * item.quantity,
)}
</span>
</div>

View File

@ -1,10 +1,12 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useState } from 'react';
import { CardTable } from '@/components/card-table';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
create as restockCreate,
@ -27,7 +29,6 @@ type Props = {
export default function RestockIndex({ restocks }: Props) {
const [deleting, setDeleting] = useState<Restock | null>(null);
const [search, setSearch] = useState('');
const expand = useCardTableExpand(true);
const pagination = {
@ -37,45 +38,15 @@ export default function RestockIndex({ restocks }: Props) {
total: restocks.total,
};
function handlePageChange(page: number) {
router.get(
restockIndex.url(),
{
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
restockIndex.url(),
{
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
restockIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page],
);
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => restockIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
@ -92,19 +63,17 @@ export default function RestockIndex({ restocks }: Props) {
<Head title="Restock" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Restock
</h2>
</div>
<Button asChild>
<a href={restockCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<PageHeader
title="Restock"
actions={
<Button asChild>
<a href={restockCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<CardTable
data={restocks.data}
@ -127,7 +96,7 @@ export default function RestockIndex({ restocks }: Props) {
restock={item}
index={
(pagination.current_page - 1) *
pagination.per_page +
pagination.per_page +
index +
1
}
@ -144,8 +113,8 @@ export default function RestockIndex({ restocks }: Props) {
)}
/>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
@ -153,7 +122,6 @@ export default function RestockIndex({ restocks }: Props) {
}}
title="Hapus Restock"
description="Apakah Anda yakin ingin menghapus restock ini? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan."
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
</div>

View File

@ -1,41 +1,12 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatDateTime, formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { Restock, RestockStockType } from './columns';
function formatDateTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 4,
}).format(num);
}
const STOCK_TYPE_CONFIG: Record<
RestockStockType,
{ label: string; className: string }
@ -71,7 +42,9 @@ export function RestockCardRow({
const variantCount = items.length;
const productNames = [
...new Set(
items.map((item) => item.product_variant?.product?.name).filter(Boolean),
items
.map((item) => item.product_variant?.product?.name)
.filter(Boolean),
),
];
const totalQty = items.reduce(
@ -152,7 +125,7 @@ export function RestockCardRow({
{formatCurrency(restock.subtotal)}
</span>
<span className="font-semibold">
<span className="text-muted-foreground font-normal">
<span className="font-normal text-muted-foreground">
Total:{' '}
</span>
{formatCurrency(restock.total)}
@ -160,36 +133,23 @@ export function RestockCardRow({
</div>
</div>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(restock)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(restock)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => onEdit(restock),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => onDelete(restock),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
</div>
</CardContent>
</Card>

View File

@ -1,5 +1,4 @@
import { useState } from 'react';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { ImagePreviewButton } from '@/components/image-preview-button';
import {
Table,
TableBody,
@ -8,48 +7,10 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { Restock } from './columns';
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 4,
}).format(num);
}
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="block h-10 w-10 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
>
<img
src={url}
alt={title}
className="h-full w-full object-cover"
/>
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={url}
title={title}
/>
</>
);
}
export function RestockItemSubRow({ restock }: { restock: Restock }) {
const items = restock.restock_items ?? [];
@ -89,8 +50,10 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
</TableCell>
<TableCell>
{item.product_variant?.photo_url ? (
<VariantPhotoPreview
url={item.product_variant.photo_url}
<ImagePreviewButton
srcs={[
item.product_variant.photo_url,
]}
title={item.product_variant.name}
/>
) : (

View File

@ -1,12 +1,6 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { RowActions } from '@/components/row-actions';
export type Category = {
id: number;
@ -44,39 +38,22 @@ export function createCategoryColumns(
const category = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(category)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(category)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(category),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(category),
},
]}
/>
);
},
},

View File

@ -1,20 +1,16 @@
import { Form, Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
index as categoryIndex,
@ -39,7 +35,6 @@ export default function CategoryIndex({ categories, highlight }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Category | null>(null);
const [deleting, setDeleting] = useState<Category | null>(null);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: categories.current_page,
@ -48,54 +43,15 @@ export default function CategoryIndex({ categories, highlight }: Props) {
total: categories.total,
};
function handlePageChange(page: number) {
router.get(
categoryIndex.url(),
{
page,
per_page: pagination.per_page,
search,
},
{
preserveState: true,
replace: true,
},
);
}
function handlePerPageChange(perPage: number) {
router.get(
categoryIndex.url(),
{
page: 1,
per_page: perPage,
search,
},
{
preserveState: true,
replace: true,
},
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
categoryIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{
preserveState: true,
replace: true,
},
);
},
[pagination.per_page],
);
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => categoryIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
@ -117,12 +73,10 @@ export default function CategoryIndex({ categories, highlight }: Props) {
<Head title="Kategori" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Kategori
</h2>
{highlight && (
<PageHeader
title="Kategori"
description={
highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan kategori dari notifikasi.
<button
@ -141,9 +95,9 @@ export default function CategoryIndex({ categories, highlight }: Props) {
Tampilkan semua
</button>
</p>
)}
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
)
}
actions={
<Button asChild>
<button
type="button"
@ -153,64 +107,62 @@ export default function CategoryIndex({ categories, highlight }: Props) {
Tambah
</button>
</Button>
<DialogContent>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Tambah Kategori
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama kategori"
/>
<InputError
message={errors.name}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
}
/>
<FormDialog
open={createOpen}
onOpenChange={setCreateOpen}
title="Tambah Kategori"
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors }) => (
<div className="grid gap-2">
<Label htmlFor="name">
Nama <span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama kategori"
/>
<InputError message={errors.name} />
</div>
)}
</FormDialog>
<FormDialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
title="Edit Kategori"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors }) =>
editing && (
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama kategori"
defaultValue={editing.name}
/>
<InputError message={errors.name} />
</div>
)
}
</FormDialog>
<DataTable
columns={columns}
@ -225,87 +177,17 @@ export default function CategoryIndex({ categories, highlight }: Props) {
searchValue={search}
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
>
<DialogContent>
{editing && (
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Edit Kategori
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama kategori"
defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Kategori"
description={`Apakah Anda yakin ingin menghapus kategori "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(category) =>
`Apakah Anda yakin ingin menghapus kategori "${category.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>

View File

@ -1,12 +1,6 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { RowActions } from '@/components/row-actions';
export type Customer = {
id: number;
@ -62,39 +56,22 @@ export function createCustomerColumns(
const customer = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(customer)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(customer)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(customer),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(customer),
},
]}
/>
);
},
},

View File

@ -1,27 +1,23 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { PhoneNumberInput } from '@/components/phone-number-input';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as customerIndex,
destroy,
index as customerIndex,
store,
update,
} from '@/routes/admin/master/customers';
import { Form, Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import type { Customer } from './columns';
import { createCustomerColumns } from './columns';
@ -39,7 +35,7 @@ export default function CustomerIndex({ customers }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Customer | null>(null);
const [deleting, setDeleting] = useState<Customer | null>(null);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: customers.current_page,
last_page: customers.last_page,
@ -47,54 +43,15 @@ export default function CustomerIndex({ customers }: Props) {
total: customers.total,
};
function handlePageChange(page: number) {
router.get(
customerIndex.url(),
{
page,
per_page: pagination.per_page,
search,
},
{
preserveState: true,
replace: true,
},
);
}
function handlePerPageChange(perPage: number) {
router.get(
customerIndex.url(),
{
page: 1,
per_page: perPage,
search,
},
{
preserveState: true,
replace: true,
},
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
customerIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{
preserveState: true,
replace: true,
},
);
},
[pagination.per_page],
);
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => customerIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
@ -116,13 +73,9 @@ export default function CustomerIndex({ customers }: Props) {
<Head title="Customer" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Customer
</h2>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<PageHeader
title="Customer"
actions={
<Button asChild>
<button
type="button"
@ -132,88 +85,105 @@ export default function CustomerIndex({ customers }: Props) {
Tambah
</button>
</Button>
<DialogContent>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Tambah Customer
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama customer"
/>
<InputError
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number" />
<InputError
message={
errors.phone_number
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="address">
Alamat
</Label>
<Input
id="address"
name="address"
placeholder="Masukkan alamat"
/>
<InputError
message={errors.address}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
}
/>
<FormDialog
open={createOpen}
onOpenChange={setCreateOpen}
title="Tambah Customer"
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors }) => (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama customer"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number" />
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label htmlFor="address">Alamat</Label>
<Input
id="address"
name="address"
placeholder="Masukkan alamat"
/>
<InputError message={errors.address} />
</div>
</div>
)}
</FormDialog>
<FormDialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
title="Edit Customer"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama customer"
defaultValue={editing.name}
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-phone_number">
No. Telepon
</Label>
<PhoneNumberInput
name="phone_number"
defaultValue={editing.phone_number}
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-address">Alamat</Label>
<Input
id="edit-address"
name="address"
placeholder="Masukkan alamat"
defaultValue={editing.address ?? ''}
/>
<InputError message={errors.address} />
</div>
</div>
)
}
</FormDialog>
<DataTable
columns={columns}
@ -228,115 +198,17 @@ export default function CustomerIndex({ customers }: Props) {
searchValue={search}
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
>
<DialogContent>
{editing && (
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Edit Customer
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama customer"
defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number" defaultValue={editing.phone_number} />
<InputError
message={
errors.phone_number
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-address">
Alamat
</Label>
<Input
id="edit-address"
name="address"
placeholder="Masukkan alamat"
defaultValue={
editing.address ??
''
}
/>
<InputError
message={errors.address}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Customer"
description={`Apakah Anda yakin ingin menghapus customer "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(customer) =>
`Apakah Anda yakin ingin menghapus customer "${customer.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>

View File

@ -1,14 +1,11 @@
import type { ColumnDef } from '@tanstack/react-table';
import { router } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { ChevronRight, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
export type ProductVariant = {
id: number;
@ -58,23 +55,12 @@ function getStatusVariant(status: string): string {
return variants[status] ?? 'bg-gray-100 text-gray-800';
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num);
}
function getFilteredVariants(
allVariants: ProductVariant[],
searchValue: string,
): ProductVariant[] {
const query = searchValue.toLowerCase().trim();
return query
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
: allVariants;
@ -361,39 +347,22 @@ export function createProductColumns(
const product = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(product)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(product)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(product),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(product),
},
]}
/>
);
},
},

View File

@ -1,9 +1,11 @@
import { Head, router } from '@inertiajs/react';
import { Filter, Plus, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { CardTable } from '@/components/card-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Combobox,
@ -13,11 +15,6 @@ import {
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
@ -25,6 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
create as productCreate,
@ -66,11 +64,7 @@ export default function ProductIndex({ products, categories, filters }: Props) {
product: Product;
variant: ProductVariant;
} | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const expand = useCardTableExpand(true);
const hasActiveFilters =
filters.status || filters.name || filters.stock || filters.category;
const pagination = {
current_page: products.current_page,
@ -79,87 +73,33 @@ export default function ProductIndex({ products, categories, filters }: Props) {
total: products.total,
};
const {
search,
filterOpen,
setFilterOpen,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({
route: () => productIndex.url(),
pagination,
filters,
filterWithParams: false,
});
const productNames = useMemo(() => {
const names = products.data.map((p) => p.name);
return [...new Set(names)].sort();
}, [products.data]);
const selectedCategory = useMemo(
() =>
categories.find((c) => String(c.id) === filters.category) ?? null,
() => categories.find((c) => String(c.id) === filters.category) ?? null,
[categories, filters.category],
);
function applyFilter(key: string, value: string) {
const newFilters = { ...filters };
if (value === '' || value === 'all') {
delete newFilters[key as keyof typeof newFilters];
} else {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(productIndex(), newFilters, {
preserveState: true,
replace: true,
});
}
function clearFilters() {
router.get(
productIndex(),
{},
{
preserveState: true,
replace: true,
},
);
setFilterOpen(false);
}
function handlePageChange(page: number) {
router.get(
productIndex.url(),
{
page,
per_page: pagination.per_page,
search,
...filters,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
productIndex.url(),
{
page: 1,
per_page: perPage,
search,
...filters,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
productIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
...filters,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, filters],
);
function handleDelete() {
if (!deleting) {
return;
@ -187,153 +127,117 @@ export default function ProductIndex({ products, categories, filters }: Props) {
}
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={Boolean(
filters.status ||
filters.name ||
filters.stock ||
filters.category,
)}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Nama Produk
</label>
<Combobox
items={productNames}
value={filters.name ?? ''}
onValueChange={(value) =>
applyFilter('name', (value as string) ?? '')
}
>
<ComboboxInput
placeholder="Pilih produk..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada produk ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(name) => (
<ComboboxItem value={name}>{name}</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Nama Produk
</label>
<Combobox
items={productNames}
value={filters.name ?? ''}
onValueChange={(value) =>
applyFilter('name', (value as string) ?? '')
}
>
<ComboboxInput
placeholder="Pilih produk..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada produk ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(name) => (
<ComboboxItem value={name}>
{name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Status</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="inactive">Non Aktif</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status
</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) =>
applyFilter('status', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Semua Status
</SelectItem>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="inactive">
Non Aktif
</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Stok</label>
<span className="-mt-1 text-[0.65rem] text-muted-foreground/70">
Berdasarkan stok bagus
</span>
<Select
value={filters.stock ?? 'all'}
onValueChange={(value) => applyFilter('stock', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Stok" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Stok</SelectItem>
<SelectItem value="empty">Habis</SelectItem>
<SelectItem value="low">
Menipis (di bawah 10)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Stok
</label>
<span className="-mt-1 text-[0.65rem] text-muted-foreground/70">
Berdasarkan stok bagus
</span>
<Select
value={filters.stock ?? 'all'}
onValueChange={(value) =>
applyFilter('stock', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Stok" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Stok</SelectItem>
<SelectItem value="empty">Habis</SelectItem>
<SelectItem value="low">
Menipis (di bawah 10)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Kategori
</label>
<Combobox
items={categories}
itemToStringLabel={(cat) => cat.name}
value={selectedCategory}
onValueChange={(value) =>
applyFilter(
'category',
value ? String(value.id) : '',
)
}
>
<ComboboxInput
placeholder="Pilih kategori..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada kategori ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(cat) => (
<ComboboxItem value={cat}>
{cat.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</div>
</PopoverContent>
</Popover>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Kategori
</label>
<Combobox
items={categories}
itemToStringLabel={(cat) => cat.name}
value={selectedCategory}
onValueChange={(value) =>
applyFilter('category', value ? String(value.id) : '')
}
>
<ComboboxInput
placeholder="Pilih kategori..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada kategori ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(cat) => (
<ComboboxItem value={cat}>
{cat.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FilterPopover>
);
return (
@ -341,19 +245,17 @@ export default function ProductIndex({ products, categories, filters }: Props) {
<Head title="Produk" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Produk
</h2>
</div>
<Button asChild>
<a href={productCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<PageHeader
title="Produk"
actions={
<Button asChild>
<a href={productCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<CardTable
data={products.data}
@ -406,29 +308,31 @@ export default function ProductIndex({ products, categories, filters }: Props) {
)}
/>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Produk"
description={`Apakah Anda yakin ingin menghapus produk "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(product) =>
`Apakah Anda yakin ingin menghapus produk "${product.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
<ConfirmDialog
open={deletingVariant !== null}
<DeleteConfirmDialog
target={deletingVariant}
onOpenChange={(open) => {
if (!open) {
setDeletingVariant(null);
}
}}
title="Hapus Varian"
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.name}" dari produk "${deletingVariant?.product.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(target) =>
`Apakah Anda yakin ingin menghapus varian "${target.variant.name}" dari produk "${target.product.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDeleteVariant}
/>
</div>

View File

@ -1,26 +1,18 @@
import { router } from '@inertiajs/react';
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { ToggleStatus } from '@/components/toggle-status';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatNumber } from '@/lib/format';
import type { Product } from './columns';
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num);
}
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
active: 'Aktif',
inactive: 'Non Aktif',
draft: 'Draft',
};
return labels[status] ?? status;
}
@ -30,6 +22,7 @@ function getStatusVariant(status: string): string {
inactive: 'bg-red-100 text-red-800',
draft: 'bg-yellow-100 text-yellow-800',
};
return variants[status] ?? 'bg-gray-100 text-gray-800';
}
@ -68,10 +61,6 @@ export function ProductCardRow({
product.status === 'active' || product.status === 'inactive';
const isChecked = product.status === 'active';
function handleToggle() {
router.post(toggleStatusUrl(product.id), {}, { preserveScroll: true });
}
return (
<Card className="overflow-hidden">
<CardContent className="p-0">
@ -138,18 +127,11 @@ export function ProductCardRow({
<div className="mt-2">
{isToggleable ? (
<div className="flex items-center gap-2">
<Switch
size="sm"
checked={isChecked}
onCheckedChange={handleToggle}
/>
<span
className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}
>
{getStatusLabel(product.status)}
</span>
</div>
<ToggleStatus
url={toggleStatusUrl(product.id)}
checked={isChecked}
label={getStatusLabel(product.status)}
/>
) : (
<span
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}
@ -160,36 +142,23 @@ export function ProductCardRow({
</div>
</div>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(product)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(product)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => onEdit(product),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => onDelete(product),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
</div>
</CardContent>
</Card>

View File

@ -1,7 +1,3 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
import { stockMutations } from '@/routes/admin/master/products/variants';
import { Head } from '@inertiajs/react';
import {
ArrowDown,
@ -13,8 +9,12 @@ import {
Pencil,
ScrollText,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
import { formatNumber } from '@/lib/format';
import { index as productIndex } from '@/routes/admin/master/products';
import { stockMutations } from '@/routes/admin/master/products/variants';
type Mutation = {
id: number;
@ -47,10 +47,6 @@ type Props = {
mutations: PaginatedMutations;
};
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num);
}
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat('id-ID', {
day: 'numeric',
@ -67,6 +63,7 @@ function getQualityLabel(quality: string): string {
reject: 'Reject',
retail: 'Ecer',
};
return labels[quality] ?? quality;
}
@ -76,6 +73,7 @@ function getQualityColor(quality: string): string {
reject: 'bg-red-100 text-red-800',
retail: 'bg-blue-100 text-blue-800',
};
return colors[quality] ?? 'bg-gray-100 text-gray-800';
}
@ -83,14 +81,27 @@ function getTypeLabel(type: string, quantity: number): string {
if (type === 'in') {
return quantity >= 0 ? 'Penambahan' : 'Pengurangan';
}
return quantity < 0 ? 'Pengurangan' : 'Penambahan';
}
function getMutationTitle(description: string | null): string {
if (!description) return 'Perubahan Stok';
if (description.includes('Transfer stok')) return 'Transfer Stok';
if (description.includes('Stok awal')) return 'Stok Awal';
if (description.includes('Penyesuaian stok')) return 'Edit Varian';
if (!description) {
return 'Perubahan Stok';
}
if (description.includes('Transfer stok')) {
return 'Transfer Stok';
}
if (description.includes('Stok awal')) {
return 'Stok Awal';
}
if (description.includes('Penyesuaian stok')) {
return 'Edit Varian';
}
return 'Perubahan Stok';
}
@ -98,12 +109,15 @@ function getMutationIcon(description: string | null): React.ReactNode {
if (description?.includes('Transfer stok')) {
return <ArrowRightLeft className="h-4 w-4" />;
}
if (description?.includes('Stok awal')) {
return <Package className="h-4 w-4" />;
}
if (description?.includes('Penyesuaian stok')) {
return <Pencil className="h-4 w-4" />;
}
return <ScrollText className="h-4 w-4" />;
}
@ -114,7 +128,10 @@ export default function StockMutationsPage({
}: Props) {
const { items, loading, hasNextPage, sentinelRef } = useInfiniteScroll({
initialData: mutations,
fetchUrl: stockMutations.url({ product: product.id, variant: variant.id }),
fetchUrl: stockMutations.url({
product: product.id,
variant: variant.id,
}),
});
return (
@ -152,13 +169,17 @@ export default function StockMutationsPage({
) : (
items.map((mutation) => {
const isPositive = mutation.quantity > 0;
const qualityColor = getQualityColor(mutation.stock_quality);
const qualityColor = getQualityColor(
mutation.stock_quality,
);
return (
<Card key={mutation.id}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${isPositive ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${isPositive ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}
>
{isPositive ? (
<ArrowUp className="h-5 w-5" />
) : (
@ -169,28 +190,54 @@ export default function StockMutationsPage({
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{getMutationTitle(mutation.description)}
{getMutationTitle(
mutation.description,
)}
</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${qualityColor}`}>
{getQualityLabel(mutation.stock_quality)}
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${qualityColor}`}
>
{getQualityLabel(
mutation.stock_quality,
)}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>
{formatDate(mutation.created_at)}
{formatDate(
mutation.created_at,
)}
</span>
<span>
{mutation.user?.full_name ?? mutation.user?.username}
{mutation.user
?.full_name ??
mutation.user
?.username}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span className={isPositive ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
{isPositive ? '+' : ''}{formatNumber(mutation.quantity)}
<span
className={
isPositive
? 'font-medium text-green-600'
: 'font-medium text-red-600'
}
>
{isPositive ? '+' : ''}
{formatNumber(
mutation.quantity,
)}
</span>
<span className="text-muted-foreground">
{formatNumber(mutation.stock_before)} {formatNumber(mutation.stock_after)}
{formatNumber(
mutation.stock_before,
)}{' '}
{' '}
{formatNumber(
mutation.stock_after,
)}
</span>
</div>
@ -208,7 +255,10 @@ export default function StockMutationsPage({
)}
{hasNextPage && (
<div ref={sentinelRef} className="flex items-center justify-center py-4">
<div
ref={sentinelRef}
className="flex items-center justify-center py-4"
>
{loading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />

View File

@ -1,7 +1,7 @@
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import { ImagePreviewButton } from '@/components/image-preview-button';
import { RowActions } from '@/components/row-actions';
import {
Table,
TableBody,
@ -10,59 +10,12 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { Product, ProductVariant } from '../columns';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import { stockMutations } from '@/routes/admin/master/products/variants';
import type { Product, ProductVariant } from '../columns';
import { TransferStockDialog } from './transfer-stock-dialog';
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num);
}
function VariantPhotoPreview({ urls, title }: { urls: string[]; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="relative block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
>
<img
src={urls[0]}
alt={title}
className="h-full w-full object-cover"
/>
{urls.length > 1 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{urls.length}
</span>
)}
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={urls[0]}
sources={urls}
title={title}
/>
</>
);
}
export function VariantSubRow({
product,
onEditVariant,
@ -119,8 +72,8 @@ export function VariantSubRow({
</TableCell>
<TableCell>
{variant.photo_urls?.length > 0 ? (
<VariantPhotoPreview
urls={variant.photo_urls}
<ImagePreviewButton
srcs={variant.photo_urls}
title={variant.name}
/>
) : (
@ -161,86 +114,56 @@ export function VariantSubRow({
)}
</TableCell>
<TableCell>
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
setTransferVariant({
product,
variant,
})
}
>
<ArrowRightLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Transfer Stok
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
window.location.href = stockMutations.url({
product: product.id,
variant: variant.id,
});
}}
>
<ScrollText className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Mutasi Stok
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
onEditVariant(
product,
variant,
)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
onDeleteVariantClick(
product,
variant,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Transfer Stok',
icon: (
<ArrowRightLeft className="h-4 w-4" />
),
onClick: () =>
setTransferVariant({
product,
variant,
}),
},
{
label: 'Mutasi Stok',
icon: (
<ScrollText className="h-4 w-4" />
),
onClick: () => {
window.location.href =
stockMutations.url({
product: product.id,
variant: variant.id,
});
},
},
{
label: 'Edit',
icon: (
<Pencil className="h-4 w-4" />
),
onClick: () =>
onEditVariant(
product,
variant,
),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () =>
onDeleteVariantClick(
product,
variant,
),
},
]}
/>
</TableCell>
</TableRow>
))

View File

@ -1,3 +1,13 @@
import { Form, Head, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Check,
ClipboardPaste,
Copy,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
@ -9,25 +19,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
import { UNITS } from '@/lib/constants';
import { loadRawMaterialDraft } from '@/lib/raw-material-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { index as rawMaterialIndex, store } from '@/routes/admin/master/raw-materials';
import { Form, Head, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Check,
ClipboardPaste,
Copy,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
const UNITS = [
{ value: 'kg', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'yard', label: 'Yard' },
];
index as rawMaterialIndex,
store,
} from '@/routes/admin/master/raw-materials';
type VariantState = {
variant: string;
@ -110,6 +108,7 @@ export default function RawMaterialCreate() {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
@ -118,7 +117,9 @@ export default function RawMaterialCreate() {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(null);
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
null,
);
const confirmRemoveVariant = useCallback((index: number) => {
setDeleteVariantIndex(index);
@ -131,6 +132,7 @@ export default function RawMaterialCreate() {
navigator.clipboard.writeText(String(price));
setCopiedIndex(variantIndex);
setTimeout(() => setCopiedIndex(null), 1500);
return prev;
});
}, []);
@ -139,10 +141,15 @@ export default function RawMaterialCreate() {
navigator.clipboard.readText().then((text) => {
try {
const price = Number(text);
if (!isNaN(price)) {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = { ...updated[variantIndex], price };
updated[variantIndex] = {
...updated[variantIndex],
price,
};
return updated;
});
}
@ -155,6 +162,7 @@ export default function RawMaterialCreate() {
const applyToAll = useCallback((variantIndex: number) => {
setVariants((prev) => {
const sourcePrice = prev[variantIndex].price;
return prev.map((v, i) =>
i === variantIndex ? v : { ...v, price: sourcePrice },
);
@ -204,26 +212,35 @@ export default function RawMaterialCreate() {
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Bahan Baku</CardTitle>
<CardTitle>
Informasi Bahan Baku
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">
Nama Bahan Baku{' '}
<span className="text-destructive">*</span>
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) =>
setName(e.target.value)
}
placeholder="Masukkan nama bahan baku"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>
Satuan <span className="text-destructive">*</span>
Satuan{' '}
<span className="text-destructive">
*
</span>
</Label>
<RadioGroup
name="unit"
@ -232,9 +249,18 @@ export default function RawMaterialCreate() {
className="flex flex-wrap gap-4"
>
{UNITS.map((u) => (
<div key={u.value} className="flex items-center space-x-2">
<RadioGroupItem value={u.value} id={`unit-${u.value}`} />
<Label htmlFor={`unit-${u.value}`} className="font-normal">
<div
key={u.value}
className="flex items-center space-x-2"
>
<RadioGroupItem
value={u.value}
id={`unit-${u.value}`}
/>
<Label
htmlFor={`unit-${u.value}`}
className="font-normal"
>
{u.label}
</Label>
</div>
@ -242,7 +268,6 @@ export default function RawMaterialCreate() {
</RadioGroup>
<InputError message={errors.unit} />
</div>
</CardContent>
</Card>
@ -251,136 +276,224 @@ export default function RawMaterialCreate() {
<CardTitle>Varian Bahan Baku</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map((variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="font-medium">
Varian {variantIndex + 1}
</h4>
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => copyPrice(variantIndex)}
>
{copiedIndex === variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => pastePrice(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
{variantIndex > 0 && (
{variants.map(
(variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="font-medium">
Varian{' '}
{variantIndex + 1}
</h4>
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => confirmRemoveVariant(variantIndex)}
size="sm"
className="whitespace-nowrap"
onClick={() =>
copyPrice(
variantIndex,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
{copiedIndex ===
variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin Harga
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() =>
pastePrice(
variantIndex,
)
}
>
<ClipboardPaste className="h-4 w-4" />
Tempel Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() =>
applyToAll(
variantIndex,
)
}
>
Terapkan ke
Semua
</Button>
{variantIndex >
0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
confirmRemoveVariant(
variantIndex,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={
variant.variant
}
onChange={(e) =>
updateVariant(
variantIndex,
'variant',
e.target
.value,
)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={
errors[
`variants.${variantIndex}.variant`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Harga{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
variant.price
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'price',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.price`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok{' '}
<span className="text-destructive">
*
</span>
</Label>
<NumberInput
min={0}
value={
variant.stock
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'stock',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.stock`
]
}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">*</span>
Foto Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={variant.variant}
onChange={(e) =>
updateVariant(variantIndex, 'variant', e.target.value)
<FileUpload
value={
variant.photo
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={errors[`variants.${variantIndex}.variant`]}
/>
</div>
<div className="grid gap-2">
<Label>
Harga <span className="text-destructive">*</span>
</Label>
<RupiahInput
value={variant.price}
onValueChange={(val) =>
updateVariant(variantIndex, 'price', val)
}
/>
<InputError
message={errors[`variants.${variantIndex}.price`]}
/>
</div>
<div className="grid gap-2">
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<NumberInput
min={0}
value={variant.stock}
onValueChange={(val) =>
onChange={(key) => {
updateVariant(
variantIndex,
'stock',
val,
'photo',
key,
);
updateVariant(
variantIndex,
'photoUrl',
key
? getTemporaryUrl(
key,
)
: null,
);
}}
folder="raw-material-variant"
existingUrl={
variant.photoUrl
}
onUploadingChange={(
uploading,
) =>
updateVariant(
variantIndex,
'uploading',
uploading,
)
}
/>
<InputError
message={errors[`variants.${variantIndex}.stock`]}
message={
errors[
`variants.${variantIndex}.photo_key`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>Foto Varian <span className="text-destructive">*</span></Label>
<FileUpload
value={variant.photo}
onChange={(key) => {
updateVariant(variantIndex, 'photo', key);
updateVariant(
variantIndex,
'photoUrl',
key ? getTemporaryUrl(key) : null,
);
}}
folder="raw-material-variant"
existingUrl={variant.photoUrl}
onUploadingChange={(uploading) =>
updateVariant(variantIndex, 'uploading', uploading)
}
/>
<InputError
message={errors[`variants.${variantIndex}.photo_key`]}
/>
</div>
</div>
))}
),
)}
<Button
type="button"
@ -397,7 +510,10 @@ export default function RawMaterialCreate() {
<div className="mt-6 flex items-center gap-4">
<Button
type="submit"
disabled={processing || variants.some((v) => v.uploading)}
disabled={
processing ||
variants.some((v) => v.uploading)
}
>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
@ -421,6 +537,7 @@ export default function RawMaterialCreate() {
if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex);
}
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}}

View File

@ -1,3 +1,13 @@
import { Form, Head, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Check,
ClipboardPaste,
Copy,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
@ -9,27 +19,15 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
import { UNITS } from '@/lib/constants';
import { clearRawMaterialDraft } from '@/lib/raw-material-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { index as rawMaterialIndex, update } from '@/routes/admin/master/raw-materials';
import { Form, Head, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Check,
ClipboardPaste,
Copy,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
index as rawMaterialIndex,
update,
} from '@/routes/admin/master/raw-materials';
import type { RawMaterialForEdit, RawMaterialVariantForEdit } from './columns';
const UNITS = [
{ value: 'kg', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'yard', label: 'Yard' },
];
type Props = {
rawMaterial: RawMaterialForEdit;
};
@ -124,6 +122,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
@ -132,7 +131,9 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(null);
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
null,
);
const confirmRemoveVariant = useCallback((index: number) => {
setDeleteVariantIndex(index);
@ -145,6 +146,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
navigator.clipboard.writeText(String(price));
setCopiedIndex(variantIndex);
setTimeout(() => setCopiedIndex(null), 1500);
return prev;
});
}, []);
@ -153,10 +155,15 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
navigator.clipboard.readText().then((text) => {
try {
const price = Number(text);
if (!isNaN(price)) {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = { ...updated[variantIndex], price };
updated[variantIndex] = {
...updated[variantIndex],
price,
};
return updated;
});
}
@ -169,6 +176,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
const applyToAll = useCallback((variantIndex: number) => {
setVariants((prev) => {
const sourcePrice = prev[variantIndex].price;
return prev.map((v, i) =>
i === variantIndex ? v : { ...v, price: sourcePrice },
);
@ -220,26 +228,35 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Bahan Baku</CardTitle>
<CardTitle>
Informasi Bahan Baku
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">
Nama Bahan Baku{' '}
<span className="text-destructive">*</span>
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) =>
setName(e.target.value)
}
placeholder="Masukkan nama bahan baku"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>
Satuan <span className="text-destructive">*</span>
Satuan{' '}
<span className="text-destructive">
*
</span>
</Label>
<RadioGroup
name="unit"
@ -248,9 +265,18 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
className="flex flex-wrap gap-4"
>
{UNITS.map((u) => (
<div key={u.value} className="flex items-center space-x-2">
<RadioGroupItem value={u.value} id={`unit-${u.value}`} />
<Label htmlFor={`unit-${u.value}`} className="font-normal">
<div
key={u.value}
className="flex items-center space-x-2"
>
<RadioGroupItem
value={u.value}
id={`unit-${u.value}`}
/>
<Label
htmlFor={`unit-${u.value}`}
className="font-normal"
>
{u.label}
</Label>
</div>
@ -258,7 +284,6 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
</RadioGroup>
<InputError message={errors.unit} />
</div>
</CardContent>
</Card>
@ -267,141 +292,226 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
<CardTitle>Varian Bahan Baku</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map((variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="font-medium">
Varian {variantIndex + 1}
</h4>
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => copyPrice(variantIndex)}
>
{copiedIndex === variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => pastePrice(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
{variantIndex > 0 && (
{variants.map(
(variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="font-medium">
Varian{' '}
{variantIndex + 1}
</h4>
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => confirmRemoveVariant(variantIndex)}
size="sm"
className="whitespace-nowrap"
onClick={() =>
copyPrice(
variantIndex,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
{copiedIndex ===
variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin Harga
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() =>
pastePrice(
variantIndex,
)
}
>
<ClipboardPaste className="h-4 w-4" />
Tempel Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() =>
applyToAll(
variantIndex,
)
}
>
Terapkan ke
Semua
</Button>
{variantIndex >
0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
confirmRemoveVariant(
variantIndex,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">*</span>
</Label>
<Input
value={variant.variant}
onChange={(e) =>
updateVariant(variantIndex, 'variant', e.target.value)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={errors[`variants.${variantIndex}.variant`]}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={
variant.variant
}
onChange={(e) =>
updateVariant(
variantIndex,
'variant',
e.target
.value,
)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={
errors[
`variants.${variantIndex}.variant`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Harga{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
variant.price
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'price',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.price`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok{' '}
<span className="text-destructive">
*
</span>
</Label>
<NumberInput
min={0}
value={
Number(
variant.stock,
) || 0
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'stock',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.stock`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Harga <span className="text-destructive">*</span>
Foto Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={variant.price}
onValueChange={(val) =>
updateVariant(variantIndex, 'price', val)
}
/>
<InputError
message={errors[`variants.${variantIndex}.price`]}
/>
</div>
<div className="grid gap-2">
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<NumberInput
min={0}
<FileUpload
value={
Number(variant.stock) ||
0
variant.photo
}
onValueChange={(val) =>
onChange={(key) => {
updateVariant(
variantIndex,
'stock',
val,
'photo',
key,
);
updateVariant(
variantIndex,
'photoUrl',
key
? getTemporaryUrl(
key,
)
: null,
);
}}
folder="raw-material-variant"
existingUrl={
variant.photoUrl
}
onUploadingChange={(
uploading,
) =>
updateVariant(
variantIndex,
'uploading',
uploading,
)
}
/>
<InputError
message={errors[`variants.${variantIndex}.stock`]}
message={
errors[
`variants.${variantIndex}.photo_key`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Foto Varian <span className="text-destructive">*</span>
</Label>
<FileUpload
value={variant.photo}
onChange={(key) => {
updateVariant(variantIndex, 'photo', key);
updateVariant(
variantIndex,
'photoUrl',
key ? getTemporaryUrl(key) : null,
);
}}
folder="raw-material-variant"
existingUrl={variant.photoUrl}
onUploadingChange={(uploading) =>
updateVariant(variantIndex, 'uploading', uploading)
}
/>
<InputError
message={errors[`variants.${variantIndex}.photo_key`]}
/>
</div>
</div>
))}
),
)}
<Button
type="button"
@ -418,7 +528,10 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
<div className="mt-6 flex items-center gap-4">
<Button
type="submit"
disabled={processing || variants.some((v) => v.uploading)}
disabled={
processing ||
variants.some((v) => v.uploading)
}
>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
@ -442,6 +555,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex);
}
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}}

View File

@ -1,15 +1,12 @@
import { Head, router } from '@inertiajs/react';
import { Filter, Plus, X } from 'lucide-react';
import { useCallback, useState } from 'react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { CardTable } from '@/components/card-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
@ -17,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
create as rawMaterialCreate,
@ -52,10 +50,7 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
rawMaterial: RawMaterial;
variant: RawMaterialVariant;
} | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const expand = useCardTableExpand(true);
const hasActiveFilters = filters.is_active || filters.stock;
const pagination = {
current_page: rawMaterials.current_page,
@ -64,78 +59,26 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
total: rawMaterials.total,
};
function applyFilter(key: string, value: string) {
const newFilters = { ...filters };
if (value === '' || value === 'all') {
delete newFilters[key as keyof typeof newFilters];
} else {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(rawMaterialIndex(), newFilters, {
preserveState: true,
replace: true,
});
}
function clearFilters() {
router.get(
rawMaterialIndex(),
{},
{
preserveState: true,
replace: true,
},
);
setFilterOpen(false);
}
function handlePageChange(page: number) {
router.get(
rawMaterialIndex.url(),
{
page,
per_page: pagination.per_page,
search,
...filters,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
rawMaterialIndex.url(),
{
page: 1,
per_page: perPage,
search,
...filters,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
rawMaterialIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
...filters,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, filters],
);
const {
search,
filterOpen,
setFilterOpen,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({
route: () => rawMaterialIndex.url(),
pagination,
filters,
filterWithParams: false,
});
function handleDelete() {
if (!deleting) return;
if (!deleting) {
return;
}
router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null),
@ -143,7 +86,9 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
}
function handleDeleteVariant() {
if (!deletingVariant) return;
if (!deletingVariant) {
return;
}
router.delete(
variantDestroy.url({
@ -157,75 +102,49 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
}
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={Boolean(filters.is_active || filters.stock)}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Status</label>
<Select
value={filters.is_active ?? 'all'}
onValueChange={(value) => applyFilter('is_active', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="true">Aktif</SelectItem>
<SelectItem value="false">Non Aktif</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status
</label>
<Select
value={filters.is_active ?? 'all'}
onValueChange={(value) => applyFilter('is_active', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="true">Aktif</SelectItem>
<SelectItem value="false">Non Aktif</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Stok
</label>
<Select
value={filters.stock ?? 'all'}
onValueChange={(value) => applyFilter('stock', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Stok" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Stok</SelectItem>
<SelectItem value="empty">Habis</SelectItem>
<SelectItem value="low">Menipis (di bawah 10)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Stok</label>
<Select
value={filters.stock ?? 'all'}
onValueChange={(value) => applyFilter('stock', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Stok" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Stok</SelectItem>
<SelectItem value="empty">Habis</SelectItem>
<SelectItem value="low">
Menipis (di bawah 10)
</SelectItem>
</SelectContent>
</Select>
</div>
</FilterPopover>
);
return (
@ -233,19 +152,17 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
<Head title="Bahan Baku" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Bahan Baku
</h2>
</div>
<Button asChild>
<a href={rawMaterialCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<PageHeader
title="Bahan Baku"
actions={
<Button asChild>
<a href={rawMaterialCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<CardTable
data={rawMaterials.data}
@ -276,7 +193,9 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
onEdit={(rm) => {
window.location.href = rawMaterialEdit.url(rm.id);
window.location.href = rawMaterialEdit.url(
rm.id,
);
}}
onDelete={(rm) => setDeleting(rm)}
toggleStatusUrl={(id) => toggleStatus.url(id)}
@ -287,29 +206,31 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
)}
/>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Bahan Baku"
description={`Apakah Anda yakin ingin menghapus bahan baku "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(rawMaterial) =>
`Apakah Anda yakin ingin menghapus bahan baku "${rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
<ConfirmDialog
open={deletingVariant !== null}
<DeleteConfirmDialog
target={deletingVariant}
onOpenChange={(open) => {
if (!open) {
setDeletingVariant(null);
}
}}
title="Hapus Varian"
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.variant}" dari bahan baku "${deletingVariant?.rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(target) =>
`Apakah Anda yakin ingin menghapus varian "${target.variant.variant}" dari bahan baku "${target.rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDeleteVariant}
/>
</div>

View File

@ -1,28 +1,12 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { ToggleStatus } from '@/components/toggle-status';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { router } from '@inertiajs/react';
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { RawMaterial } from './columns';
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID', { maximumFractionDigits: 4 }).format(num);
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
export type RawMaterialCardRowParams = {
rawMaterial: RawMaterial;
index: number;
@ -43,12 +27,14 @@ export function RawMaterialCardRow({
toggleStatusUrl,
}: RawMaterialCardRowParams) {
const variants = rawMaterial.raw_material_prices ?? [];
const totalStock = variants.reduce((sum, v) => sum + (Number(v.stock) || 0), 0);
const totalValue = variants.reduce((sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0), 0);
function handleToggle() {
router.post(toggleStatusUrl(rawMaterial.id), {}, { preserveScroll: true });
}
const totalStock = variants.reduce(
(sum, v) => sum + (Number(v.stock) || 0),
0,
);
const totalValue = variants.reduce(
(sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0),
0,
);
return (
<Card className="overflow-hidden">
@ -94,51 +80,35 @@ export function RawMaterialCardRow({
</div>
<div className="mt-2">
<div className="flex items-center gap-2">
<Switch
size="sm"
checked={rawMaterial.is_active}
onCheckedChange={handleToggle}
/>
<span
className={`text-xs font-medium ${rawMaterial.is_active ? 'text-green-700' : 'text-red-700'}`}
>
{rawMaterial.is_active ? 'Aktif' : 'Non Aktif'}
</span>
</div>
<ToggleStatus
url={toggleStatusUrl(rawMaterial.id)}
checked={rawMaterial.is_active}
label={
rawMaterial.is_active
? 'Aktif'
: 'Non Aktif'
}
/>
</div>
</div>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(rawMaterial)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(rawMaterial)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => onEdit(rawMaterial),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => onDelete(rawMaterial),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
</div>
</CardContent>
</Card>

View File

@ -2,8 +2,8 @@ import { router } from '@inertiajs/react';
import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import { ImagePreviewButton } from '@/components/image-preview-button';
import { RowActions } from '@/components/row-actions';
import {
Table,
TableBody,
@ -12,52 +12,13 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
edit as variantEdit,
destroy as variantDestroy,
} from '@/routes/admin/master/raw-materials/variants';
import type { RawMaterial, RawMaterialVariant } from '../columns';
import { edit as variantEdit, destroy as variantDestroy } from '@/routes/admin/master/raw-materials/variants';
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID', { maximumFractionDigits: 4 }).format(num);
}
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="relative block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
>
<img
src={url}
alt={title}
className="h-full w-full object-cover"
/>
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={url}
sources={[url]}
title={title}
/>
</>
);
}
export function RawMaterialVariantSubRow({
rawMaterial,
@ -65,10 +26,13 @@ export function RawMaterialVariantSubRow({
rawMaterial: RawMaterial;
}) {
const variants = rawMaterial.raw_material_prices ?? [];
const [deletingVariant, setDeletingVariant] = useState<RawMaterialVariant | null>(null);
const [deletingVariant, setDeletingVariant] =
useState<RawMaterialVariant | null>(null);
function handleDeleteVariant() {
if (!deletingVariant) return;
if (!deletingVariant) {
return;
}
router.delete(
variantDestroy.url({
@ -116,8 +80,8 @@ export function RawMaterialVariantSubRow({
</TableCell>
<TableCell>
{variant.photo_url ? (
<VariantPhotoPreview
url={variant.photo_url}
<ImagePreviewButton
srcs={[variant.photo_url]}
title={variant.variant}
/>
) : (
@ -136,45 +100,32 @@ export function RawMaterialVariantSubRow({
{formatNumber(variant.stock)}
</TableCell>
<TableCell>
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
window.location.href = variantEdit.url({
rawMaterial: rawMaterial.id,
variant: variant.id,
});
}}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
setDeletingVariant(variant)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: (
<Pencil className="h-4 w-4" />
),
onClick: () => {
window.location.href =
variantEdit.url({
rawMaterial:
rawMaterial.id,
variant: variant.id,
});
},
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () =>
setDeletingVariant(variant),
},
]}
/>
</TableCell>
</TableRow>
))

View File

@ -1,12 +1,6 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { RowActions } from '@/components/row-actions';
export type Supplier = {
id: number;
@ -62,39 +56,22 @@ export function createSupplierColumns(
const supplier = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(supplier)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(supplier)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(supplier),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(supplier),
},
]}
/>
);
},
},

View File

@ -1,27 +1,23 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { PhoneNumberInput } from '@/components/phone-number-input';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
store,
index as supplierIndex,
store,
update,
} from '@/routes/admin/master/suppliers';
import { Form, Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import type { Supplier } from './columns';
import { createSupplierColumns } from './columns';
@ -39,7 +35,7 @@ export default function SupplierIndex({ suppliers }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Supplier | null>(null);
const [deleting, setDeleting] = useState<Supplier | null>(null);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: suppliers.current_page,
last_page: suppliers.last_page,
@ -47,54 +43,15 @@ export default function SupplierIndex({ suppliers }: Props) {
total: suppliers.total,
};
function handlePageChange(page: number) {
router.get(
supplierIndex.url(),
{
page,
per_page: pagination.per_page,
search,
},
{
preserveState: true,
replace: true,
},
);
}
function handlePerPageChange(perPage: number) {
router.get(
supplierIndex.url(),
{
page: 1,
per_page: perPage,
search,
},
{
preserveState: true,
replace: true,
},
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
supplierIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{
preserveState: true,
replace: true,
},
);
},
[pagination.per_page],
);
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => supplierIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
@ -116,13 +73,9 @@ export default function SupplierIndex({ suppliers }: Props) {
<Head title="Supplier" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Supplier
</h2>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<PageHeader
title="Supplier"
actions={
<Button asChild>
<button
type="button"
@ -132,88 +85,105 @@ export default function SupplierIndex({ suppliers }: Props) {
Tambah
</button>
</Button>
<DialogContent>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Tambah Supplier
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama supplier"
/>
<InputError
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number" />
<InputError
message={
errors.phone_number
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="address">
Alamat
</Label>
<Input
id="address"
name="address"
placeholder="Masukkan alamat"
/>
<InputError
message={errors.address}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
}
/>
<FormDialog
open={createOpen}
onOpenChange={setCreateOpen}
title="Tambah Supplier"
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors }) => (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama supplier"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number" />
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label htmlFor="address">Alamat</Label>
<Input
id="address"
name="address"
placeholder="Masukkan alamat"
/>
<InputError message={errors.address} />
</div>
</div>
)}
</FormDialog>
<FormDialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
title="Edit Supplier"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama supplier"
defaultValue={editing.name}
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-phone_number">
No. Telepon
</Label>
<PhoneNumberInput
name="phone_number"
defaultValue={editing.phone_number}
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-address">Alamat</Label>
<Input
id="edit-address"
name="address"
placeholder="Masukkan alamat"
defaultValue={editing.address ?? ''}
/>
<InputError message={errors.address} />
</div>
</div>
)
}
</FormDialog>
<DataTable
columns={columns}
@ -228,115 +198,17 @@ export default function SupplierIndex({ suppliers }: Props) {
searchValue={search}
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
>
<DialogContent>
{editing && (
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>
Edit Supplier
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama supplier"
defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number" defaultValue={editing.phone_number} />
<InputError
message={
errors.phone_number
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-address">
Alamat
</Label>
<Input
id="edit-address"
name="address"
placeholder="Masukkan alamat"
defaultValue={
editing.address ??
''
}
/>
<InputError
message={errors.address}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Supplier"
description={`Apakah Anda yakin ingin menghapus supplier "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
description={(supplier) =>
`Apakah Anda yakin ingin menghapus supplier "${supplier.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>

View File

@ -1,12 +1,6 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { RowActions } from '@/components/row-actions';
export type Role = {
id: number;
@ -60,37 +54,22 @@ export function createRoleColumns(
const role = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(role)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(role)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(role),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(role),
},
]}
/>
);
},
},

View File

@ -1,10 +1,12 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as rolesIndex,
create as roleCreate,
@ -26,7 +28,7 @@ type Props = {
export default function RoleIndex({ roles }: Props) {
const [deleting, setDeleting] = useState<Role | null>(null);
const [search, setSearch] = useState('');
const pagination: PaginationState = {
current_page: roles.current_page,
last_page: roles.last_page,
@ -34,45 +36,15 @@ export default function RoleIndex({ roles }: Props) {
total: roles.total,
};
function handlePageChange(page: number) {
router.get(
rolesIndex.url(),
{
page,
per_page: pagination.per_page,
search,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
rolesIndex.url(),
{
page: 1,
per_page: perPage,
search,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
rolesIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page],
);
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => rolesIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
@ -96,19 +68,17 @@ export default function RoleIndex({ roles }: Props) {
<Head title="Role & Permission" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Role & Permission
</h2>
</div>
<Button asChild>
<a href={roleCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<PageHeader
title="Role & Permission"
actions={
<Button asChild>
<a href={roleCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<DataTable
columns={columns}
@ -123,16 +93,17 @@ export default function RoleIndex({ roles }: Props) {
searchValue={search}
/>
<ConfirmDialog
open={deleting !== null}
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Role"
description={`Apakah Anda yakin ingin menghapus role "${deleting?.name}"? Semua user dengan role ini akan kehilangan permission terkait.`}
confirmLabel="Hapus"
description={(role) =>
`Apakah Anda yakin ingin menghapus role "${role.name}"? Semua user dengan role ini akan kehilangan permission terkait.`
}
onConfirm={handleDelete}
/>
</div>