183 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|