whisper-money/resources/js/components/automation-rules/automation-rules-dialog.tsx

429 lines
16 KiB
TypeScript

import { usePage } from '@inertiajs/react';
import {
Cell,
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import * as Icons from 'lucide-react';
import { MoreHorizontal } from 'lucide-react';
import { useMemo, useState } from 'react';
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog';
import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog';
import { LabelBadges } from '@/components/shared/label-combobox';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type Label } from '@/types/label';
interface AutomationRulesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function AutomationRuleActions({
rule,
categories,
labels,
}: {
rule: AutomationRule;
categories: Category[];
labels: Label[];
}) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label="Actions"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setEditOpen(true)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteAutomationRuleDialog
rule={rule}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</>
);
}
function AutomationRuleRow({
row,
categories,
labels,
}: {
row: Row<AutomationRule>;
categories: Category[];
labels: Label[];
}) {
const rule = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
return (
<>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
<TableRow
data-state={
(row.getIsSelected() || contextMenuOpen) &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell: Cell<AutomationRule, unknown>) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel>Actions</ContextMenuLabel>
<ContextMenuItem onClick={() => setEditOpen(true)}>
Edit
</ContextMenuItem>
<ContextMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteAutomationRuleDialog
rule={rule}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</>
);
}
export function AutomationRulesDialog({
open,
onOpenChange,
}: AutomationRulesDialogProps) {
const { isKeySet } = useEncryptionKey();
const { automationRules: rawRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
// Get categories and labels from globally shared Inertia data
const categories = usePage().props.categories as Category[];
const labels = usePage().props.labels as Label[];
const rules = useMemo(
() =>
rawRules.map((rule) => ({
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
})),
[rawRules],
);
const [sorting, setSorting] = useState<SortingState>([
{
id: 'priority',
desc: false,
},
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const columns: ColumnDef<AutomationRule>[] = [
{
accessorKey: 'priority',
header: 'Priority',
cell: ({ row }) => {
return (
<div className="font-medium">
{row.getValue('priority')}
</div>
);
},
},
{
accessorKey: 'title',
header: 'Title',
cell: ({ row }) => {
return (
<div className="font-medium">{row.getValue('title')}</div>
);
},
},
{
id: 'actions_display',
header: 'Actions',
cell: ({ row }) => {
const rule = row.original;
const actions = getRuleActions(rule);
if (actions.type === 'multiple') {
return (
<div className="flex flex-wrap items-center gap-2">
{actions.category && (
<Badge
className={`${getCategoryColorClasses(actions.category.color).bg} ${getCategoryColorClasses(actions.category.color).text} flex items-center gap-2`}
>
{(() => {
const IconComponent = Icons[
actions.category
.icon as keyof typeof Icons
] as Icons.LucideIcon;
return IconComponent ? (
<IconComponent className="h-3 w-3 opacity-80" />
) : null;
})()}
{actions.category.name}
</Badge>
)}
{actions.hasLabels && actions.labels && (
<LabelBadges labels={actions.labels} max={2} />
)}
{actions.hasNote && (
<span className="text-sm text-muted-foreground">
+ note
</span>
)}
</div>
);
}
if (actions.type === 'category' && actions.category) {
const colorClasses = getCategoryColorClasses(
actions.category.color,
);
const IconComponent = Icons[
actions.category.icon as keyof typeof Icons
] as Icons.LucideIcon;
return (
<Badge
className={`${colorClasses.bg} ${colorClasses.text} flex items-center gap-2`}
>
<IconComponent className="h-3 w-3 opacity-80" />
{actions.category.name}
</Badge>
);
}
if (actions.type === 'labels' && actions.labels) {
return <LabelBadges labels={actions.labels} max={3} />;
}
return (
<span className="text-sm text-muted-foreground">
Add note
</span>
);
},
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => (
<AutomationRuleActions
rule={row.original}
categories={categories}
labels={labels}
/>
),
},
];
const table = useReactTable({
data: rules,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnFilters,
columnVisibility,
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Automation Rules</DialogTitle>
<DialogDescription>
Manage your transaction automation rules. Rules will be
applied to categorize transactions automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Input
placeholder="Filter rules...."
value={
(table
.getColumn('title')
?.getFilterValue() as string) ?? ''
}
onChange={(event) =>
table
.getColumn('title')
?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<CreateAutomationRuleDialog
categories={categories}
labels={labels}
disabled={!isKeySet}
/>
</div>
<div className="max-h-[75vh] overflow-y-auto rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<AutomationRuleRow
key={row.id}
row={row}
categories={categories}
labels={labels}
/>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No automation rules found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end">
<div className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} rule(s)
total.
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}