whisper-money/resources/js/components/transactions/categorizer-command.tsx

183 lines
6.9 KiB
TypeScript

import { CategoryIcon } from '@/components/shared/category-combobox';
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Kbd } from '@/components/ui/kbd';
import { type AnimationState } from '@/hooks/use-categorize-transactions';
import {
buildCategoryTree,
flattenCategoryTree,
getCategoryPath,
} from '@/lib/category-tree';
import { cn } from '@/lib/utils';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { __ } from '@/utils/i18n';
import { ArrowDown, ArrowUp } from 'lucide-react';
import { type RefObject, useMemo } from 'react';
interface CategorizerCommandProps {
sortedCategories: Category[];
animationState: AnimationState;
currentTransaction: DecryptedTransaction | undefined;
searchValue: string;
onSearchChange: (value: string) => void;
onCategorySelect: (category: Category) => void;
commandInputRef: RefObject<HTMLInputElement | null>;
disabled?: boolean;
}
export function CategorizerCommand({
sortedCategories,
animationState,
currentTransaction,
searchValue,
onSearchChange,
onCategorySelect,
commandInputRef,
disabled = false,
}: CategorizerCommandProps) {
const treeCategories = useMemo(
() => flattenCategoryTree(buildCategoryTree(sortedCategories)),
[sortedCategories],
);
const query = searchValue.trim().toLowerCase();
// Tree-aware search: keep each match together with its ancestors so a
// matching child still shows its parent chain for context.
const visibleCategories = useMemo(() => {
if (!query) {
return treeCategories;
}
const parentOf = new Map(
sortedCategories.map((c) => [c.id, c.parent_id]),
);
const include = new Set<string>();
for (const category of sortedCategories) {
if (!category.name.toLowerCase().includes(query)) {
continue;
}
let id: string | null | undefined = category.id;
let guard = 0;
while (id != null && guard++ < 10) {
include.add(id);
id = parentOf.get(id);
}
}
return treeCategories.filter((node) => include.has(node.id));
}, [treeCategories, sortedCategories, query]);
if (animationState === 'success' || !currentTransaction) {
return null;
}
return (
<div
className={cn(
'flex flex-col gap-4 px-6 pt-2 transition-all duration-300',
animationState === 'exiting' && 'translate-y-[-10px] opacity-0',
animationState === 'entering' &&
'animate-categorizer-command-enter',
animationState === 'idle' && 'translate-y-0 opacity-100',
disabled && 'pointer-events-none opacity-40',
)}
>
<div className="flex flex-col gap-1">
<h3 className="font-medium">{__('Assign a new category')}</h3>
<p className="flex items-center gap-1 text-sm text-muted-foreground">
{__('Search, move')}{' '}
<Kbd>
<ArrowUp className="size-3" />
</Kbd>
<Kbd>
<ArrowDown className="size-3" />
</Kbd>
{__(', and press')}
<Kbd></Kbd>
</p>
</div>
<Command
className="rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
shouldFilter={false}
>
<CommandInput
ref={commandInputRef}
placeholder={__('Search categories...')}
value={searchValue}
onValueChange={onSearchChange}
disabled={animationState !== 'idle' || disabled}
/>
<CommandList className="max-h-64">
{visibleCategories.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
{__('No categories found.')}
</div>
)}
<CommandGroup>
{visibleCategories.map((category) => {
const colorClasses = getCategoryColorClasses(
category.color,
);
return (
<CommandItem
key={category.id}
value={getCategoryPath(
category.id,
sortedCategories,
)}
onSelect={() => onCategorySelect(category)}
disabled={
animationState !== 'idle' || disabled
}
className="group cursor-pointer gap-3 p-2"
style={{
paddingLeft: `${0.5 + category.depth * 1.25}rem`,
}}
>
<div
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full transition-transform duration-200 group-data-[selected=true]:scale-110',
colorClasses.bg,
)}
>
<CategoryIcon category={category} />
</div>
<span className="flex-1 truncate font-medium">
{category.name}
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
<style>{`
@keyframes categorizer-command-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-categorizer-command-enter {
animation: categorizer-command-enter 0.3s ease-out 0.1s forwards;
opacity: 0;
}
`}</style>
</div>
);
}