TorbatYar/frontend/modules/beauty/features/owner/settings.tsx
Mortezakoohjani d579d0b142 feat(loyalty): add Loyalty Platform Frontend module
Add frontend/modules/loyalty with types, API client, design system, feature pages and thin App Router routes under app/loyalty/. BFF proxy at app/api/loyalty/. Include loyalty frontend docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:50:55 +03:30

254 lines
8.1 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Pencil } from "lucide-react";
import { beautyBusinessApi } from "@/modules/beauty/services/beauty-business-api";
import { useTenantId } from "@/hooks/useTenantId";
import { useBeautyPermissions } from "@/modules/beauty/hooks/useBeautyPermissions";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
FormField,
Select,
Card,
CardContent,
Badge,
TableToolbar,
Textarea,
} from "@/components/ds";
import { PermissionDeniedState } from "@/src/shared/ui";
import { useDebouncedValue } from "@/src/shared/hooks";
export default function BeautySettingsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const { can, canManage } = useBeautyPermissions();
const [open, setOpen] = useState(false);
const [editKey, setEditKey] = useState<string | null>(null);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const [form, setForm] = useState({
organization_id: "",
key: "",
valueJson: "{}",
description: "",
});
const orgsQ = useQuery({
queryKey: ["beauty", tenantId, "organizations"],
queryFn: () => beautyBusinessApi.organizations.list(tenantId!),
enabled: !!tenantId,
});
const settingsQ = useQuery({
queryKey: ["beauty", tenantId, "settings"],
queryFn: () => beautyBusinessApi.settings.list(tenantId!),
enabled: !!tenantId && can("beauty_business.settings.view"),
});
const permissionsQ = useQuery({
queryKey: ["beauty", tenantId, "permissions-catalog"],
queryFn: () => beautyBusinessApi.permissions.catalog(tenantId!),
enabled: !!tenantId,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ["beauty", tenantId, "settings"] });
const upsertM = useMutation({
mutationFn: () => {
let value: Record<string, unknown> | undefined;
try {
value = form.valueJson ? (JSON.parse(form.valueJson) as Record<string, unknown>) : undefined;
} catch {
throw new Error("JSON مقدار نامعتبر است");
}
return beautyBusinessApi.settings.upsert(tenantId!, {
organization_id: form.organization_id || undefined,
key: form.key,
value,
description: form.description || undefined,
});
},
onSuccess: async () => {
toast.success("تنظیم ذخیره شد");
setOpen(false);
setEditKey(null);
setForm({ organization_id: "", key: "", valueJson: "{}", description: "" });
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const manageAllowed = canManage("beauty_business.settings.view", "beauty_business.settings.manage");
if (!tenantId) return <LoadingState />;
if (!can("beauty_business.settings.view")) return <PermissionDeniedState />;
if (settingsQ.isLoading) return <LoadingState />;
if (settingsQ.error) {
return <ErrorState message={settingsQ.error.message} onRetry={() => settingsQ.refetch()} />;
}
const rows = (settingsQ.data ?? []).filter((s) => {
if (!debouncedSearch) return true;
const q = debouncedSearch.toLowerCase();
return s.key.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
});
const openEdit = (row: (typeof rows)[0]) => {
setEditKey(row.key);
setForm({
organization_id: row.organization_id ?? "",
key: row.key,
valueJson: row.value ? JSON.stringify(row.value, null, 2) : "{}",
description: row.description ?? "",
});
setOpen(true);
};
return (
<div className="space-y-4">
<PageHeader
title="تنظیمات"
description="کلید-مقدار تنظیمات سازمان و شعبه."
actions={
manageAllowed ? (
<Button
onClick={() => {
setEditKey(null);
setForm({ organization_id: "", key: "", valueJson: "{}", description: "" });
setOpen(true);
}}
>
<Plus className="h-4 w-4" />
تنظیم جدید
</Button>
) : null
}
/>
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="جستجو در کلیدها…"
/>
<DataTable
columns={[
{ key: "key", header: "کلید" },
{ key: "description", header: "توضیح" },
{
key: "value",
header: "مقدار",
render: (r) => (
<span className="text-xs text-[var(--muted)]">
{r.value ? JSON.stringify(r.value).slice(0, 80) : "—"}
</span>
),
},
{
key: "actions",
header: "عملیات",
render: (r) =>
manageAllowed ? (
<Button type="button" size="sm" variant="ghost" onClick={() => openEdit(r as never)}>
<Pencil className="h-4 w-4" />
ویرایش
</Button>
) : null,
},
]}
rows={rows as unknown as Record<string, unknown>[]}
empty={
<EmptyState
action={
manageAllowed ? (
<Button onClick={() => setOpen(true)}>افزودن تنظیم</Button>
) : undefined
}
/>
}
/>
{permissionsQ.data ? (
<Card>
<CardContent className="pt-5">
<h2 className="mb-2 font-semibold text-secondary">کاتالوگ مجوزها</h2>
<Badge tone="default">{permissionsQ.data.count} مجوز ثبتشده</Badge>
<p className="mt-2 text-xs text-[var(--muted)]">
پیشوند: {permissionsQ.data.prefix}
</p>
</CardContent>
</Card>
) : null}
<Dialog
open={open}
onClose={() => {
setOpen(false);
setEditKey(null);
}}
title={editKey ? "ویرایش تنظیم" : "تنظیم جدید"}
size="lg"
>
<div className="space-y-3">
<FormField label="سازمان (اختیاری)">
<Select
value={form.organization_id}
onChange={(e) => setForm((f) => ({ ...f, organization_id: e.target.value }))}
>
<option value=""></option>
{(orgsQ.data ?? []).map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</Select>
</FormField>
<FormField label="کلید">
<Input
value={form.key}
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
disabled={!!editKey}
required
/>
</FormField>
<FormField label="مقدار (JSON)">
<Textarea
value={form.valueJson}
onChange={(e) => setForm((f) => ({ ...f, valueJson: e.target.value }))}
rows={6}
className="font-mono text-xs"
/>
</FormField>
<FormField label="توضیح">
<Input
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</FormField>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button
type="button"
disabled={!form.key.trim() || upsertM.isPending}
onClick={() => upsertM.mutate()}
>
ذخیره
</Button>
</div>
</Dialog>
</div>
);
}