388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import {
|
|
TextFieldRoot,
|
|
TextFieldInput,
|
|
DialogRoot,
|
|
DialogBackdrop,
|
|
DialogPositioner,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
Skeleton,
|
|
} from "@seed-design/react";
|
|
import { MercuryButton } from "@/ds/MercuryButton";
|
|
import { useCategories } from "@/api/hooks/reads";
|
|
import { useCategoryMutations } from "@/api/hooks/mutations";
|
|
import type { Category } from "@/api/schemas";
|
|
import { profileStrings } from "./strings";
|
|
|
|
// A small curated glyph set stands in for the iOS `SeedMulticolorIcon` catalog
|
|
// (not ported to web) — plain emoji stored verbatim in the `icon` string field.
|
|
const ICON_CHOICES = ["🍔", "🚗", "🏠", "💊", "🎮", "🛍️", "💡", "📚", "✈️", "🎁", "💰", "📱", "🐾", "⚽", "☕", "🧾"];
|
|
|
|
interface CategoryGroup {
|
|
name: string;
|
|
icon?: string | null;
|
|
children: Category[];
|
|
}
|
|
|
|
/** Mirrors `CategoriesModel.load()` in CategoriesView.swift: a depth-0 row
|
|
* starts a new group, every following depth>0 row until the next depth-0
|
|
* belongs to it. */
|
|
function groupCategories(categories: Category[]): CategoryGroup[] {
|
|
const groups: CategoryGroup[] = [];
|
|
let current: CategoryGroup | null = null;
|
|
for (const c of categories) {
|
|
if (c.depth === 0) {
|
|
current = { name: c.name, icon: c.icon, children: [] };
|
|
groups.push(current);
|
|
} else if (current) {
|
|
current.children.push(c);
|
|
}
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
type SheetState = { mode: "add" } | { mode: "edit"; category: Category } | null;
|
|
|
|
export interface CategoriesManagerProps {
|
|
onBack?: () => void;
|
|
}
|
|
|
|
export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
|
const { data: categories, isLoading } = useCategories();
|
|
const { add, update, delete: remove } = useCategoryMutations();
|
|
|
|
const [sheet, setSheet] = React.useState<SheetState>(null);
|
|
const [deleteTarget, setDeleteTarget] = React.useState<string | null>(null);
|
|
|
|
const groups = React.useMemo(() => groupCategories(categories ?? []), [categories]);
|
|
const parentNames = React.useMemo(() => groups.map((g) => g.name), [groups]);
|
|
|
|
async function handleConfirmDelete() {
|
|
if (!deleteTarget) return;
|
|
await remove.mutateAsync(deleteTarget);
|
|
setDeleteTarget(null);
|
|
}
|
|
|
|
if (sheet) {
|
|
return (
|
|
<CategoryEditor
|
|
parents={parentNames}
|
|
initial={sheet.mode === "edit" ? sheet.category : undefined}
|
|
onCancel={() => setSheet(null)}
|
|
onSave={async (values) => {
|
|
if (sheet.mode === "add") {
|
|
await add.mutateAsync({
|
|
name: values.name,
|
|
kind: "expense",
|
|
parent: values.parent || undefined,
|
|
icon: values.icon,
|
|
});
|
|
} else {
|
|
await update.mutateAsync({
|
|
oldName: sheet.category.name,
|
|
newName: values.name,
|
|
parent: values.parent ? values.parent : null,
|
|
icon: values.icon,
|
|
});
|
|
}
|
|
setSheet(null);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
{onBack && (
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
aria-label={profileStrings.categories.back}
|
|
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
|
>
|
|
←
|
|
</button>
|
|
)}
|
|
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.categories.title}</h1>
|
|
<MercuryButton variant="ghost" onClick={() => setSheet({ mode: "add" })}>
|
|
+ {profileStrings.categories.add}
|
|
</MercuryButton>
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
<Skeleton height="60px" />
|
|
<Skeleton height="60px" />
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && groups.length === 0 && (
|
|
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.empty}</p>
|
|
)}
|
|
|
|
{groups.map((group) => (
|
|
<div key={group.name} style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
<CategoryRow
|
|
icon={group.icon}
|
|
name={group.name}
|
|
heading
|
|
onEdit={() => setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })}
|
|
onDelete={() => setDeleteTarget(group.name)}
|
|
/>
|
|
{group.children.length > 0 && (
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, paddingLeft: 12 }}>
|
|
{group.children.map((child) => (
|
|
<CategoryChip
|
|
key={child.name}
|
|
category={child}
|
|
onEdit={() => setSheet({ mode: "edit", category: child })}
|
|
onDelete={() => setDeleteTarget(child.name)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<DialogRoot open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
|
<DialogBackdrop style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 40 }} />
|
|
<DialogPositioner
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 41,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: 16,
|
|
}}
|
|
>
|
|
<DialogContent
|
|
style={{
|
|
background: "var(--seed-color-bg-layer-floating)",
|
|
borderRadius: "var(--seed-radius-r3)",
|
|
padding: 20,
|
|
maxWidth: 360,
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
|
|
{profileStrings.categories.deleteTitle}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<DialogDescription style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
|
|
{deleteTarget ? profileStrings.categories.deleteMessage(deleteTarget) : ""}
|
|
</DialogDescription>
|
|
<DialogFooter style={{ display: "flex", gap: 12, marginTop: 20 }}>
|
|
<MercuryButton variant="secondary" onClick={() => setDeleteTarget(null)} style={{ flex: 1 }}>
|
|
{profileStrings.categories.cancel}
|
|
</MercuryButton>
|
|
<MercuryButton
|
|
variant="primary"
|
|
onClick={handleConfirmDelete}
|
|
loading={remove.isPending}
|
|
style={{ flex: 1, background: "#d92626", color: "#fff" }}
|
|
>
|
|
{profileStrings.categories.deleteAction}
|
|
</MercuryButton>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</DialogPositioner>
|
|
</DialogRoot>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CategoryRow({
|
|
icon,
|
|
name,
|
|
heading,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
icon?: string | null;
|
|
name: string;
|
|
heading?: boolean;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<span aria-hidden style={{ fontSize: heading ? 20 : 16 }}>{icon || "🏷️"}</span>
|
|
<span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 600 : 400, flex: 1 }}>{name}</span>
|
|
<button
|
|
type="button"
|
|
onClick={onEdit}
|
|
aria-label={`${profileStrings.categories.editAction}: ${name}`}
|
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}
|
|
>
|
|
{profileStrings.categories.editAction}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
aria-label={`${profileStrings.categories.deleteAction}: ${name}`}
|
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "#d92626" }}
|
|
>
|
|
{profileStrings.categories.deleteAction}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CategoryChip({
|
|
category,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
category: Category;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "6px 10px",
|
|
borderRadius: 999,
|
|
background: "var(--seed-color-bg-layer-default)",
|
|
}}
|
|
>
|
|
<span aria-hidden>{category.icon || "🏷️"}</span>
|
|
<span style={{ fontSize: 13 }}>{category.name}</span>
|
|
<button
|
|
type="button"
|
|
onClick={onEdit}
|
|
aria-label={`${profileStrings.categories.editAction}: ${category.name}`}
|
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}
|
|
>
|
|
✎
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
aria-label={`${profileStrings.categories.deleteAction}: ${category.name}`}
|
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "#d92626" }}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CategoryEditor({
|
|
parents,
|
|
initial,
|
|
onCancel,
|
|
onSave,
|
|
}: {
|
|
parents: string[];
|
|
initial?: Category;
|
|
onCancel: () => void;
|
|
onSave: (values: { name: string; parent: string; icon: string }) => Promise<void>;
|
|
}) {
|
|
const [name, setName] = React.useState(initial?.name ?? "");
|
|
const [parent, setParent] = React.useState("");
|
|
const [icon, setIcon] = React.useState(initial?.icon || ICON_CHOICES[0]);
|
|
const [saving, setSaving] = React.useState(false);
|
|
const editing = Boolean(initial);
|
|
const canSave = name.trim().length > 0 && !saving;
|
|
|
|
async function handleSave() {
|
|
if (!canSave) return;
|
|
setSaving(true);
|
|
try {
|
|
await onSave({ name: name.trim(), parent, icon });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
aria-label={profileStrings.categories.cancel}
|
|
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
|
>
|
|
←
|
|
</button>
|
|
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
|
|
{editing ? profileStrings.categories.edit : profileStrings.categories.add}
|
|
</h1>
|
|
</div>
|
|
|
|
<TextFieldRoot value={name} onValueChange={setName} name="categoryName">
|
|
<TextFieldInput
|
|
aria-label={profileStrings.categories.name}
|
|
placeholder={profileStrings.categories.namePlaceholder}
|
|
autoFocus
|
|
/>
|
|
</TextFieldRoot>
|
|
|
|
<div>
|
|
<p style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)", marginBottom: 8 }}>
|
|
{profileStrings.categories.icon}
|
|
</p>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: 8 }}>
|
|
{ICON_CHOICES.map((choice) => (
|
|
<button
|
|
key={choice}
|
|
type="button"
|
|
onClick={() => setIcon(choice)}
|
|
aria-pressed={icon === choice}
|
|
style={{
|
|
fontSize: 20,
|
|
padding: 8,
|
|
borderRadius: "var(--seed-radius-r3)",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
background: icon === choice ? "var(--seed-color-bg-layer-default)" : "transparent",
|
|
}}
|
|
>
|
|
{choice}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.parent}</span>
|
|
<select
|
|
value={parent}
|
|
onChange={(e) => setParent(e.target.value)}
|
|
style={{
|
|
padding: "10px 12px",
|
|
borderRadius: "var(--seed-radius-r3)",
|
|
border: "1px solid var(--seed-color-border-default, #e5e7eb)",
|
|
}}
|
|
>
|
|
<option value="">{profileStrings.categories.parentNone}</option>
|
|
{parents
|
|
.filter((p) => p !== initial?.name)
|
|
.map((p) => (
|
|
<option key={p} value={p}>
|
|
{p}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<MercuryButton variant="primary" onClick={handleSave} disabled={!canSave} loading={saving}>
|
|
{profileStrings.categories.save}
|
|
</MercuryButton>
|
|
</div>
|
|
);
|
|
}
|