Implemented new API functions to fetch and update agent node profiles, enhancing the management capabilities for agent data. This addition improves the overall functionality of the admin agents console, allowing for better user interaction with agent profiles. Updated related types for improved type safety and clarity in the codebase.
413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { Pencil, Trash2 } from "lucide-react";
|
|
import { useCallback, useState } from "react";
|
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
deleteAdminCurrency,
|
|
getAdminCurrencies,
|
|
postAdminCurrency,
|
|
putAdminCurrency,
|
|
} from "@/api/admin-currencies";
|
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import type { AdminCurrencyRow } from "@/types/api/admin-currency";
|
|
|
|
type CurrencyFormState = {
|
|
code: string;
|
|
name: string;
|
|
decimal_places: string;
|
|
is_enabled: boolean;
|
|
is_bettable: boolean;
|
|
};
|
|
|
|
const EMPTY_FORM: CurrencyFormState = {
|
|
code: "",
|
|
name: "",
|
|
decimal_places: "2",
|
|
is_enabled: true,
|
|
is_bettable: false,
|
|
};
|
|
|
|
function toFormState(row: AdminCurrencyRow): CurrencyFormState {
|
|
return {
|
|
code: row.code,
|
|
name: row.name,
|
|
decimal_places: String(row.decimal_places),
|
|
is_enabled: row.is_enabled,
|
|
is_bettable: row.is_enabled && row.is_bettable,
|
|
};
|
|
}
|
|
|
|
export function CurrencySettingsPanel() {
|
|
const { t } = useTranslation(["config", "adminUsers"]);
|
|
const tRef = useTranslationRef(["config", "adminUsers"]);
|
|
const exportLabels = useExportLabels("currencies");
|
|
const profile = useAdminProfile();
|
|
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
|
|
const [items, setItems] = useState<AdminCurrencyRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [mode, setMode] = useState<"create" | "edit">("create");
|
|
const [editingCode, setEditingCode] = useState<string | null>(null);
|
|
const [form, setForm] = useState<CurrencyFormState>(EMPTY_FORM);
|
|
const [deleteTarget, setDeleteTarget] = useState<AdminCurrencyRow | null>(null);
|
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!canManage) {
|
|
setItems([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const data = await getAdminCurrencies();
|
|
setItems(data.items);
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof LotteryApiBizError
|
|
? error.message
|
|
: tRef.current("currencies.loadFailed", { ns: "config" }),
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canManage]);
|
|
|
|
useAsyncEffect(() => {
|
|
void load();
|
|
}, [canManage]);
|
|
|
|
function openCreate(): void {
|
|
setMode("create");
|
|
setEditingCode(null);
|
|
setForm(EMPTY_FORM);
|
|
setDialogOpen(true);
|
|
}
|
|
|
|
function openEdit(row: AdminCurrencyRow): void {
|
|
setMode("edit");
|
|
setEditingCode(row.code);
|
|
setForm(toFormState(row));
|
|
setDialogOpen(true);
|
|
}
|
|
|
|
function updateForm<K extends keyof CurrencyFormState>(key: K, value: CurrencyFormState[K]): void {
|
|
setForm((prev) => {
|
|
const next = { ...prev, [key]: value };
|
|
if (key === "is_enabled" && value === false) {
|
|
next.is_bettable = false;
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function handleSubmit(): Promise<void> {
|
|
const payload = {
|
|
name: form.name.trim(),
|
|
decimal_places: Number.parseInt(form.decimal_places || "0", 10),
|
|
is_enabled: form.is_enabled,
|
|
is_bettable: form.is_enabled && form.is_bettable,
|
|
};
|
|
|
|
if (mode === "create") {
|
|
if (form.code.trim() === "" || payload.name === "") {
|
|
toast.error(t("currencies.form.required", { ns: "config" }));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!Number.isFinite(payload.decimal_places) || payload.decimal_places < 0) {
|
|
toast.error(t("currencies.form.decimalInvalid", { ns: "config" }));
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
if (mode === "create") {
|
|
await postAdminCurrency({
|
|
code: form.code.trim().toUpperCase(),
|
|
...payload,
|
|
});
|
|
toast.success(t("currencies.createSuccess", { ns: "config" }));
|
|
} else if (editingCode !== null) {
|
|
await putAdminCurrency(editingCode, payload);
|
|
toast.success(t("currencies.updateSuccess", { ns: "config" }));
|
|
}
|
|
|
|
setDialogOpen(false);
|
|
await load();
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof LotteryApiBizError
|
|
? error.message
|
|
: t(mode === "create" ? "currencies.createFailed" : "currencies.updateFailed", { ns: "config" }),
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete(): Promise<void> {
|
|
if (deleteTarget === null) {
|
|
return;
|
|
}
|
|
|
|
setDeleteBusy(true);
|
|
try {
|
|
await deleteAdminCurrency(deleteTarget.code);
|
|
toast.success(t("currencies.deleteSuccess", { ns: "config", code: deleteTarget.code }));
|
|
setDeleteTarget(null);
|
|
await load();
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof LotteryApiBizError
|
|
? error.message
|
|
: t("currencies.deleteFailed", { ns: "config" }),
|
|
);
|
|
} finally {
|
|
setDeleteBusy(false);
|
|
}
|
|
}
|
|
|
|
if (!canManage) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<AdminPageCard
|
|
title={t("currencies.title", { ns: "config" })}
|
|
description={t("currencies.description", { ns: "config" })}
|
|
actions={
|
|
<>
|
|
<AdminTableExportButton
|
|
tableId="admin-currencies-table"
|
|
filename={exportLabels.filename}
|
|
sheetName={exportLabels.sheetName}
|
|
/>
|
|
<Button onClick={openCreate}>{t("currencies.actions.create", { ns: "config" })}</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="admin-table-shell">
|
|
<Table id="admin-currencies-table">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="whitespace-nowrap">{t("currencies.table.code", { ns: "config" })}</TableHead>
|
|
<TableHead>{t("currencies.table.name", { ns: "config" })}</TableHead>
|
|
<TableHead className="whitespace-nowrap">{t("currencies.table.decimals", { ns: "config" })}</TableHead>
|
|
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
|
|
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
|
|
<TableHead className="sticky right-0 z-20 bg-muted w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("currencies.table.actions", { ns: "config" })}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
|
{t("currencies.loading", { ns: "config" })}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : items.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
|
{t("currencies.empty", { ns: "config" })}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
items.map((row) => (
|
|
<TableRow key={row.code}>
|
|
<TableCell className="font-mono">{row.code}</TableCell>
|
|
<TableCell>{row.name}</TableCell>
|
|
<TableCell>{row.decimal_places}</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
|
|
{row.is_enabled
|
|
? t("system.states.enabled", { ns: "config" })
|
|
: t("system.states.disabled", { ns: "config" })}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge
|
|
status={row.is_enabled && row.is_bettable ? "enabled" : "disabled"}
|
|
>
|
|
{row.is_enabled && row.is_bettable
|
|
? t("system.states.enabled", { ns: "config" })
|
|
: t("system.states.disabled", { ns: "config" })}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
|
<AdminRowActionsMenu
|
|
actions={[
|
|
{
|
|
key: "edit",
|
|
label: t("currencies.actions.edit", { ns: "config" }),
|
|
icon: Pencil,
|
|
onClick: () => openEdit(row),
|
|
},
|
|
{
|
|
key: "delete",
|
|
label: t("currencies.actions.delete", { ns: "config" }),
|
|
icon: Trash2,
|
|
destructive: true,
|
|
onClick: () => setDeleteTarget(row),
|
|
},
|
|
]}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</AdminPageCard>
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent showCloseButton className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t(mode === "create" ? "currencies.dialog.createTitle" : "currencies.dialog.editTitle", {
|
|
ns: "config",
|
|
})}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("currencies.dialog.description", { ns: "config" })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="currency-code">{t("currencies.form.code", { ns: "config" })}</Label>
|
|
<Input
|
|
id="currency-code"
|
|
value={form.code}
|
|
placeholder={t("currencies.form.codePlaceholder", { ns: "config" })}
|
|
onChange={(e) => updateForm("code", e.target.value.toUpperCase())}
|
|
disabled={saving || mode === "edit"}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="currency-name">{t("currencies.form.name", { ns: "config" })}</Label>
|
|
<Input
|
|
id="currency-name"
|
|
value={form.name}
|
|
placeholder={t("currencies.form.namePlaceholder", { ns: "config" })}
|
|
onChange={(e) => updateForm("name", e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="currency-decimals">{t("currencies.form.decimals", { ns: "config" })}</Label>
|
|
<Input
|
|
id="currency-decimals"
|
|
type="number"
|
|
min="0"
|
|
max="12"
|
|
step="1"
|
|
value={form.decimal_places}
|
|
placeholder={t("currencies.form.decimalsPlaceholder", { ns: "config" })}
|
|
onChange={(e) => updateForm("decimal_places", e.target.value)}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">{t("currencies.form.enabled", { ns: "config" })}</p>
|
|
<p className="text-xs text-muted-foreground">{t("currencies.form.enabledHint", { ns: "config" })}</p>
|
|
</div>
|
|
<Switch
|
|
checked={form.is_enabled}
|
|
onCheckedChange={(checked) => updateForm("is_enabled", checked)}
|
|
disabled={saving}
|
|
aria-label={t("currencies.form.enabled", { ns: "config" })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">{t("currencies.form.bettable", { ns: "config" })}</p>
|
|
<p className="text-xs text-muted-foreground">{t("currencies.form.bettableHint", { ns: "config" })}</p>
|
|
</div>
|
|
<Switch
|
|
checked={form.is_enabled && form.is_bettable}
|
|
onCheckedChange={(checked) => updateForm("is_bettable", checked)}
|
|
disabled={saving || !form.is_enabled}
|
|
aria-label={t("currencies.form.bettable", { ns: "config" })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
|
{t("actions.cancel", { ns: "adminUsers" })}
|
|
</Button>
|
|
<Button onClick={() => void handleSubmit()} disabled={saving}>
|
|
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent showCloseButton className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("currencies.deleteDialog.title", { ns: "config" })}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("currencies.deleteDialog.description", {
|
|
ns: "config",
|
|
code: deleteTarget?.code ?? "",
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleteBusy}>
|
|
{t("actions.cancel", { ns: "adminUsers" })}
|
|
</Button>
|
|
<Button variant="destructive" onClick={() => void confirmDelete()} disabled={deleteBusy}>
|
|
{deleteBusy ? t("deleting", { ns: "adminUsers" }) : t("currencies.actions.delete", { ns: "config" })}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|