Each line of the adjustment form chose its category in a native select showing raw names. It now takes the compact CategoryCombobox of the split window: typing filters, hierarchical order, seed names translated. A new line still takes the first category of the list. D2: the hook reads the Transactions list, getAllCategories (active and open to input), instead of its own query on every active category. The list feeds two pickers only, the form and the split window of the Adjustments page; the detail view names a line's category from the entries' join, so a line saved on a category hidden from input keeps its name there and its value, its field empty until changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.9 KiB
TypeScript
189 lines
6.9 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import type { Category } from "../../shared/types";
|
|
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
|
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
|
|
|
interface Props {
|
|
initialData: AdjustmentFormData;
|
|
initialEntries: EntryFormData[];
|
|
categories: Category[];
|
|
isCreating: boolean;
|
|
isSaving: boolean;
|
|
onSave: (data: AdjustmentFormData, entries: EntryFormData[]) => void;
|
|
onCancel: () => void;
|
|
onDelete?: () => void;
|
|
}
|
|
|
|
export default function AdjustmentForm({
|
|
initialData,
|
|
initialEntries,
|
|
categories,
|
|
isCreating,
|
|
isSaving,
|
|
onSave,
|
|
onCancel,
|
|
onDelete,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const [form, setForm] = useState<AdjustmentFormData>(initialData);
|
|
const [entries, setEntries] = useState<EntryFormData[]>(initialEntries);
|
|
|
|
useEffect(() => {
|
|
setForm(initialData);
|
|
setEntries(initialEntries);
|
|
}, [initialData, initialEntries]);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!form.name.trim()) return;
|
|
onSave({ ...form, name: form.name.trim() }, entries);
|
|
};
|
|
|
|
const addEntry = () => {
|
|
const defaultCategoryId = categories.length > 0 ? categories[0].id : 0;
|
|
setEntries([...entries, { category_id: defaultCategoryId, amount: 0, description: "" }]);
|
|
};
|
|
|
|
const updateEntryField = (index: number, field: keyof EntryFormData, value: unknown) => {
|
|
setEntries(entries.map((e, i) => (i === index ? { ...e, [field]: value } : e)));
|
|
};
|
|
|
|
const removeEntry = (index: number) => {
|
|
setEntries(entries.filter((_, i) => i !== index));
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("adjustments.name")}</label>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("adjustments.date")}</label>
|
|
<input
|
|
type="date"
|
|
value={form.date}
|
|
onChange={(e) => {
|
|
setForm({ ...form, date: e.target.value });
|
|
// Close native date popup on WebKitGTK (#177)
|
|
e.currentTarget.blur();
|
|
}}
|
|
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("adjustments.description")}</label>
|
|
<textarea
|
|
value={form.description}
|
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
rows={2}
|
|
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.is_recurring}
|
|
onChange={(e) => setForm({ ...form, is_recurring: e.target.checked })}
|
|
className="rounded border-[var(--border)]"
|
|
/>
|
|
{t("adjustments.recurring")}
|
|
</label>
|
|
|
|
{/* Entries */}
|
|
<div className="border-t border-[var(--border)] pt-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold">{t("adjustments.entries")}</h3>
|
|
<button
|
|
type="button"
|
|
onClick={addEntry}
|
|
className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-[var(--primary)] hover:bg-[var(--primary)]/10"
|
|
>
|
|
<Plus size={14} />
|
|
{t("adjustments.addEntry")}
|
|
</button>
|
|
</div>
|
|
|
|
{entries.length === 0 ? (
|
|
<p className="text-xs text-[var(--muted-foreground)]">{t("adjustments.noEntries")}</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{entries.map((entry, index) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<CategoryCombobox
|
|
categories={categories}
|
|
value={entry.category_id}
|
|
onChange={(id) => updateEntryField(index, "category_id", id)}
|
|
compact
|
|
ariaLabel={t("adjustments.category")}
|
|
/>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={entry.amount}
|
|
onChange={(e) => updateEntryField(index, "amount", Number(e.target.value))}
|
|
className="w-28 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
|
placeholder={t("adjustments.amount")}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={entry.description}
|
|
onChange={(e) => updateEntryField(index, "description", e.target.value)}
|
|
className="w-36 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
|
placeholder={t("adjustments.description")}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeEntry(index)}
|
|
className="p-1.5 rounded-lg text-[var(--negative)] hover:bg-[var(--negative)]/10"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={isSaving || !form.name.trim()}
|
|
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{t("common.save")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
|
>
|
|
{t("common.cancel")}
|
|
</button>
|
|
{!isCreating && onDelete && (
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
className="ml-auto px-3 py-2 rounded-lg text-[var(--negative)] hover:bg-[var(--negative)]/10 text-sm flex items-center gap-1"
|
|
>
|
|
<Trash2 size={14} />
|
|
{t("common.delete")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|