217 lines
6.3 KiB
TypeScript
217 lines
6.3 KiB
TypeScript
import { Form } from '@inertiajs/react';
|
|
import type { ComponentProps, ReactNode } from 'react';
|
|
import { useState } from 'react';
|
|
import InputError from '@/components/input-error';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import { cn } from '@/lib/utils';
|
|
import type { RouteFormDefinition } from '@/wayfinder';
|
|
|
|
const controlClasses =
|
|
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs outline-none focus-visible:ring-[3px] disabled:opacity-50 md:text-sm';
|
|
|
|
export function PageHeader({
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
action?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div className="flex flex-col gap-1">
|
|
<h1 className="font-display text-xl font-bold tracking-tight">
|
|
{title}
|
|
</h1>
|
|
{description && (
|
|
<p className="text-muted-foreground text-sm">
|
|
{description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{action}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FormField({
|
|
id,
|
|
label,
|
|
error,
|
|
className,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
error?: string;
|
|
className?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={cn('grid gap-2', className)}>
|
|
<Label htmlFor={id}>{label}</Label>
|
|
{children}
|
|
<InputError message={error} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function NativeSelect({
|
|
options,
|
|
className,
|
|
...props
|
|
}: ComponentProps<'select'> & {
|
|
options: ReadonlyArray<{ value: string; label: string }>;
|
|
}) {
|
|
return (
|
|
<select
|
|
className={cn(controlClasses, 'h-9 cursor-pointer py-1', className)}
|
|
{...props}
|
|
>
|
|
{options.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
export function Textarea({ className, ...props }: ComponentProps<'textarea'>) {
|
|
return (
|
|
<textarea
|
|
rows={4}
|
|
className={cn(controlClasses, className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type Errors = Partial<Record<string, string>>;
|
|
|
|
/**
|
|
* A dialog holding a create/edit form; it closes itself once the server
|
|
* accepts the submission.
|
|
*/
|
|
export function FormDialog({
|
|
trigger,
|
|
title,
|
|
description,
|
|
form,
|
|
submitLabel = 'Simpan',
|
|
className,
|
|
children,
|
|
}: {
|
|
trigger: ReactNode;
|
|
title: string;
|
|
description?: string;
|
|
form: RouteFormDefinition<'get' | 'post'>;
|
|
submitLabel?: string;
|
|
className?: string;
|
|
children: (errors: Errors) => ReactNode;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
|
<DialogContent
|
|
className={cn(
|
|
'max-h-[90vh] overflow-y-auto sm:max-w-lg',
|
|
className,
|
|
)}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
{description && (
|
|
<DialogDescription>{description}</DialogDescription>
|
|
)}
|
|
</DialogHeader>
|
|
<Form
|
|
{...form}
|
|
options={{ preserveScroll: true }}
|
|
onSuccess={() => setOpen(false)}
|
|
className="flex flex-col gap-4"
|
|
>
|
|
{({ processing, errors }) => (
|
|
<>
|
|
{children(errors)}
|
|
<DialogFooter className="gap-2">
|
|
<DialogClose asChild>
|
|
<Button type="button" variant="secondary">
|
|
Batal
|
|
</Button>
|
|
</DialogClose>
|
|
<Button type="submit" disabled={processing}>
|
|
{submitLabel}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/** Asks for confirmation before sending a delete request. */
|
|
export function ConfirmDelete({
|
|
trigger,
|
|
title,
|
|
description,
|
|
form,
|
|
}: {
|
|
trigger: ReactNode;
|
|
title: string;
|
|
description: string;
|
|
form: RouteFormDefinition<'get' | 'post'>;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</DialogHeader>
|
|
<Form
|
|
{...form}
|
|
options={{ preserveScroll: true }}
|
|
onSuccess={() => setOpen(false)}
|
|
>
|
|
{({ processing }) => (
|
|
<DialogFooter className="gap-2">
|
|
<DialogClose asChild>
|
|
<Button type="button" variant="secondary">
|
|
Batal
|
|
</Button>
|
|
</DialogClose>
|
|
<Button
|
|
type="submit"
|
|
variant="destructive"
|
|
disabled={processing}
|
|
>
|
|
Hapus
|
|
</Button>
|
|
</DialogFooter>
|
|
)}
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|