mercury-web/src/features/profile/CategoriesManager.tsx

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>
);
}