fix(commercial): stabilize admin updates and API access

This commit is contained in:
Mortezakoohjani 2026-07-29 20:50:50 +03:30
parent bf2913cdd5
commit d77aabdb8c
8 changed files with 230 additions and 65 deletions

View File

@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
const UPSTREAM =
process.env.CORE_SERVICE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.NEXT_PUBLIC_BACKEND_URL ||
"http://core-service:8000";
async function proxy(request: NextRequest, pathSegments: string[]) {
const path = pathSegments.join("/");
const url = new URL(request.url);
const target = `${UPSTREAM.replace(/\/$/, "")}/${path}${url.search}`;
const headers = new Headers();
for (const name of ["authorization", "content-type", "x-tenant-id", "accept", "idempotency-key"]) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
const body =
request.method === "GET" || request.method === "HEAD"
? undefined
: await request.arrayBuffer();
try {
const response = await fetch(target, {
method: request.method,
headers,
body,
cache: "no-store",
});
const responseHeaders = new Headers();
const contentType = response.headers.get("content-type");
if (contentType) responseHeaders.set("content-type", contentType);
return new NextResponse(await response.arrayBuffer(), {
status: response.status,
headers: responseHeaders,
});
} catch {
return NextResponse.json(
{
error: {
code: "core_proxy_unavailable",
message: "ارتباط با سرویس اصلی برقرار نشد. لطفاً دوباره تلاش کنید.",
},
},
{ status: 502 }
);
}
}
type Context = { params: Promise<{ path: string[] }> };
export async function GET(request: NextRequest, context: Context) {
return proxy(request, (await context.params).path);
}
export async function POST(request: NextRequest, context: Context) {
return proxy(request, (await context.params).path);
}
export async function PATCH(request: NextRequest, context: Context) {
return proxy(request, (await context.params).path);
}
export async function PUT(request: NextRequest, context: Context) {
return proxy(request, (await context.params).path);
}
export async function DELETE(request: NextRequest, context: Context) {
return proxy(request, (await context.params).path);
}

View File

@ -5,8 +5,9 @@
import { getValidAccessToken } from "./auth"; import { getValidAccessToken } from "./auth";
const API_BASE = const SERVER_API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"; process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core";
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(

View File

@ -5,10 +5,11 @@
import { getValidAccessToken } from "@/lib/auth"; import { getValidAccessToken } from "@/lib/auth";
const API_BASE = const SERVER_API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.NEXT_PUBLIC_BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_URL ||
"http://localhost:8000"; "http://localhost:8000";
const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core";
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(

View File

@ -2,9 +2,8 @@ import type { AdapterResult } from "../types";
import { import {
commercialDelete, commercialDelete,
commercialGet, commercialGet,
commercialPatch, commercialPatch,
commercialPost, commercialPost,
commercialPut,
extractItems, extractItems,
failResult, failResult,
listResult, listResult,
@ -13,14 +12,56 @@ import type { CommercialAdminResourceDef } from "../admin/resources";
export type RegistryRecord = Record<string, unknown>; export type RegistryRecord = Record<string, unknown>;
export async function listAdminRegistry( export async function listAdminRegistry(
resource: CommercialAdminResourceDef resource: CommercialAdminResourceDef,
): Promise<AdapterResult<RegistryRecord[]>> { tenantId?: string | null
const res = await commercialGet<unknown>(resource.listPath); ): Promise<AdapterResult<RegistryRecord[]>> {
if (!res.ok) { if (resource.requiresTenant && !tenantId) {
return failResult(res.availability, res.message, []); return {
} availability: "empty",
const items = extractItems<RegistryRecord>(res.data, resource.envelopeKeys); data: [],
source: "admin.tenant-selection",
message: "برای مشاهده این بخش، ابتدا یک فضای کاری انتخاب کنید.",
};
}
if (resource.key === "entitlements") {
return {
availability: "empty",
data: [],
source: "admin.entitlements",
message: "سطوح دسترسی در صفحه اشتراک و محصولات هر فضای کاری نمایش داده می‌شود.",
};
}
const query = tenantId ? `tenant_id=${encodeURIComponent(tenantId)}` : "";
const path =
resource.key === "subscriptions"
? `/api/v1/commercial/dashboard?${query}`
: resource.key === "activation-logs"
? `/api/v1/commercial/activation?${query}`
: `${resource.listPath}${query ? `?${query}` : ""}`;
const res = await commercialGet<unknown>(path);
if (!res.ok) {
return failResult(res.availability, res.message, []);
}
if (resource.key === "subscriptions") {
const subscription = (res.data as { subscription?: RegistryRecord | null }).subscription;
return listResult(
subscription ? [subscription] : [],
"admin.subscriptions",
"اشتراکی ثبت نشده است."
);
}
if (resource.key === "activation-logs") {
const pipeline = (res.data as { pipeline?: RegistryRecord | null }).pipeline;
const item = pipeline || (res.data as RegistryRecord);
return listResult(
item && Object.keys(item).length ? [item] : [],
"admin.activation",
"فعال‌سازی ثبت نشده است."
);
}
const items = extractItems<RegistryRecord>(res.data, resource.envelopeKeys);
return listResult(items, `admin.${resource.key}`, "موردی در رجیستری نیست"); return listResult(items, `admin.${resource.key}`, "موردی در رجیستری نیست");
} }
@ -38,12 +79,10 @@ export async function updateAdminRegistryItem(
code: string, code: string,
body: RegistryRecord body: RegistryRecord
): Promise<{ ok: true; data: RegistryRecord } | { ok: false; message: string }> { ): Promise<{ ok: true; data: RegistryRecord } | { ok: false; message: string }> {
const path = `${resource.listPath}/${encodeURIComponent(code)}`; const path = `${resource.listPath}/${encodeURIComponent(code)}`;
const patch = await commercialPatch<RegistryRecord>(path, body); const patch = await commercialPatch<RegistryRecord>(path, body);
if (patch.ok) return { ok: true, data: patch.data }; if (patch.ok) return { ok: true, data: patch.data };
const put = await commercialPut<RegistryRecord>(path, body); return { ok: false, message: patch.message };
if (put.ok) return { ok: true, data: put.data };
return { ok: false, message: patch.message || put.message };
} }
export async function deleteAdminRegistryItem( export async function deleteAdminRegistryItem(

View File

@ -7,10 +7,11 @@
import { getValidAccessToken } from "@/lib/auth"; import { getValidAccessToken } from "@/lib/auth";
import type { AdapterResult, RegistryAvailability } from "../types"; import type { AdapterResult, RegistryAvailability } from "../types";
const API_BASE = const SERVER_API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.NEXT_PUBLIC_BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_URL ||
"http://localhost:8000"; "http://localhost:8000";
const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core";
async function authHeaders(): Promise<Headers> { async function authHeaders(): Promise<Headers> {
const headers = new Headers({ "Content-Type": "application/json" }); const headers = new Headers({ "Content-Type": "application/json" });

View File

@ -46,8 +46,10 @@ export interface CommercialAdminResourceDef {
/** Item code/id field for display + PATCH/DELETE */ /** Item code/id field for display + PATCH/DELETE */
codeField: string; codeField: string;
nameField: string; nameField: string;
/** Envelope keys for list extraction */ /** Envelope keys for list extraction */
envelopeKeys: string[]; envelopeKeys: string[];
requiresTenant?: boolean;
readOnly?: boolean;
group: group:
| "catalog" | "catalog"
| "commerce" | "commerce"
@ -333,8 +335,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
envelopeKeys: ["items", "support_levels"], envelopeKeys: ["items", "support_levels"],
group: "ops", group: "ops",
}, },
{ {
key: "subscriptions", key: "subscriptions",
label: "اشتراک‌ها", label: "اشتراک‌ها",
description: "Tenant subscriptions", description: "Tenant subscriptions",
listPath: "/api/v1/commercial/subscriptions", listPath: "/api/v1/commercial/subscriptions",
@ -342,10 +344,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
codeField: "id", codeField: "id",
nameField: "tenant_id", nameField: "tenant_id",
envelopeKeys: ["items", "subscriptions"], envelopeKeys: ["items", "subscriptions"],
group: "tenant", group: "tenant",
requiresTenant: true,
readOnly: true,
}, },
{ {
key: "licenses", key: "licenses",
label: "لایسنس‌ها", label: "لایسنس‌ها",
description: "Licenses", description: "Licenses",
listPath: "/api/v1/commercial/licenses", listPath: "/api/v1/commercial/licenses",
@ -353,10 +357,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
codeField: "license_code", codeField: "license_code",
nameField: "display_name", nameField: "display_name",
envelopeKeys: ["items", "licenses"], envelopeKeys: ["items", "licenses"],
group: "tenant", group: "tenant",
requiresTenant: true,
readOnly: true,
}, },
{ {
key: "entitlements", key: "entitlements",
label: "سطوح دسترسی", label: "سطوح دسترسی",
description: "Entitlement projections", description: "Entitlement projections",
listPath: "/api/v1/commercial/entitlements", listPath: "/api/v1/commercial/entitlements",
@ -364,10 +370,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
codeField: "entitlement_code", codeField: "entitlement_code",
nameField: "display_name", nameField: "display_name",
envelopeKeys: ["items", "entitlements"], envelopeKeys: ["items", "entitlements"],
group: "tenant", group: "tenant",
requiresTenant: true,
readOnly: true,
}, },
{ {
key: "activation-logs", key: "activation-logs",
label: "لاگ فعال‌سازی", label: "لاگ فعال‌سازی",
description: "Activation pipelines", description: "Activation pipelines",
listPath: "/api/v1/commercial/activation-logs", listPath: "/api/v1/commercial/activation-logs",
@ -375,10 +383,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
codeField: "correlation_id", codeField: "correlation_id",
nameField: "tenant_id", nameField: "tenant_id",
envelopeKeys: ["items", "pipelines", "activation_logs"], envelopeKeys: ["items", "pipelines", "activation_logs"],
group: "tenant", group: "tenant",
requiresTenant: true,
readOnly: true,
}, },
{ {
key: "invoices", key: "invoices",
label: "فاکتورها", label: "فاکتورها",
description: "Commercial invoices (published read models)", description: "Commercial invoices (published read models)",
listPath: "/api/v1/commercial/invoices", listPath: "/api/v1/commercial/invoices",
@ -386,10 +396,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
codeField: "invoice_code", codeField: "invoice_code",
nameField: "display_name", nameField: "display_name",
envelopeKeys: ["items", "invoices"], envelopeKeys: ["items", "invoices"],
group: "tenant", group: "tenant",
requiresTenant: true,
readOnly: true,
}, },
{ {
key: "transactions", key: "transactions",
label: "تراکنش‌ها", label: "تراکنش‌ها",
description: "Payment history read models (no Payment edits)", description: "Payment history read models (no Payment edits)",
listPath: "/api/v1/commercial/transactions", listPath: "/api/v1/commercial/transactions",
@ -397,7 +409,9 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
codeField: "transaction_code", codeField: "transaction_code",
nameField: "display_name", nameField: "display_name",
envelopeKeys: ["items", "transactions"], envelopeKeys: ["items", "transactions"],
group: "tenant", group: "tenant",
requiresTenant: true,
readOnly: true,
}, },
]; ];

View File

@ -26,9 +26,12 @@ export function BundleCard({
const prices = (bundle.pricing_refs || []) const prices = (bundle.pricing_refs || [])
.map((ref) => pricingItems.find((item) => item.pricing_item_code === ref)) .map((ref) => pricingItems.find((item) => item.pricing_item_code === ref))
.filter((item): item is CommercialPricingItem => Boolean(item)); .filter((item): item is CommercialPricingItem => Boolean(item));
const productNames = (bundle.product_codes || []).map( const presentedProducts = (bundle.product_codes || []).map((code, index) => ({
(code) => products.find((product) => product.product_code === code)?.display_name || humanizeCommercialCode(code) key: `${code}-${index}`,
); name:
products.find((product) => product.product_code === code)?.display_name ||
humanizeCommercialCode(code),
}));
const badge = bundle.recommendation_badge const badge = bundle.recommendation_badge
? enumLabel(bundle.recommendation_badge) ? enumLabel(bundle.recommendation_badge)
: recommendationScore != null : recommendationScore != null
@ -70,13 +73,16 @@ export function BundleCard({
) : null} ) : null}
</div> </div>
</div> </div>
{productNames.length ? ( {presentedProducts.length ? (
<div className="mt-4"> <div className="mt-4">
<p className="text-xs font-semibold text-gray-500">محصولات این بسته</p> <p className="text-xs font-semibold text-gray-500">محصولات این بسته</p>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{productNames.map((name) => ( {presentedProducts.map((product) => (
<span key={name} className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800"> <span
{name} key={product.key}
className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800"
>
{product.name}
</span> </span>
))} ))}
</div> </div>

View File

@ -3,6 +3,7 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { AdminPageHeader, Banner, Button, EmptyState, Modal } from "@/components/admin/controls"; import { AdminPageHeader, Banner, Button, EmptyState, Modal } from "@/components/admin/controls";
import { useMe } from "@/hooks/useMe";
import type { CommercialAdminResourceDef } from "../admin/resources"; import type { CommercialAdminResourceDef } from "../admin/resources";
import { import {
createAdminRegistryItem, createAdminRegistryItem,
@ -41,6 +42,11 @@ function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef):
return code == null ? "" : String(code); return code == null ? "" : String(code);
} }
function recordTargetId(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
const target = item.registry_object_id ?? item.id ?? item[resource.codeField];
return target == null ? "" : String(target);
}
function defaultRecord(resource: CommercialAdminResourceDef): RegistryRecord { function defaultRecord(resource: CommercialAdminResourceDef): RegistryRecord {
const seed: RegistryRecord = { const seed: RegistryRecord = {
[resource.codeField]: "", [resource.codeField]: "",
@ -195,6 +201,7 @@ function CommercialRecordForm({
} }
export function AdminRegistryManager({ resource }: { resource: CommercialAdminResourceDef }) { export function AdminRegistryManager({ resource }: { resource: CommercialAdminResourceDef }) {
const { me } = useMe();
const [result, setResult] = useState<AdapterResult<RegistryRecord[]> | null>(null); const [result, setResult] = useState<AdapterResult<RegistryRecord[]> | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
@ -212,13 +219,13 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
const response = await listAdminRegistry(resource); const response = await listAdminRegistry(resource, me?.current_tenant_id);
setResult(response); setResult(response);
if (["unavailable", "permission_denied", "error"].includes(response.availability)) { if (["unavailable", "permission_denied", "error"].includes(response.availability)) {
setError(friendlyMessage(response.message, "بارگذاری اطلاعات انجام نشد. لطفاً دوباره تلاش کنید.")); setError(friendlyMessage(response.message, "بارگذاری اطلاعات انجام نشد. لطفاً دوباره تلاش کنید."));
} }
setLoading(false); setLoading(false);
}, [resource]); }, [me?.current_tenant_id, resource]);
useEffect(() => { void load(); }, [load]); useEffect(() => { void load(); }, [load]);
@ -267,10 +274,35 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
const submitEdit = async (event: FormEvent) => { const submitEdit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!editItem) return; if (!editItem) return;
const code = recordCode(editItem, resource); const targetId = recordTargetId(editItem, resource);
if (!code) return setError("شناسه این مورد پیدا نشد. صفحه را تازه‌سازی کنید."); if (!targetId) return setError("شناسه این مورد پیدا نشد. صفحه را تازه‌سازی کنید.");
const changedFields = Object.fromEntries(
Object.entries(formBody).filter(
([key, value]) =>
![
"registry_object_id",
"id",
"kind",
"slug",
"stable_code",
"created_at",
"updated_at",
"created_by",
"updated_by",
"published_at",
"archived_at",
"is_deleted",
"clone_of_id",
].includes(key) && !Object.is(value, editItem[key])
)
);
if (!Object.keys(changedFields).length) {
setEditItem(null);
setMsg("تغییری برای ذخیره وجود نداشت.");
return;
}
setBusy(true); setBusy(true);
const response = await updateAdminRegistryItem(resource, code, formBody); const response = await updateAdminRegistryItem(resource, targetId, changedFields);
if (!response.ok) setError(friendlyMessage(response.message, "ذخیره تغییرات انجام نشد. دوباره تلاش کنید.")); if (!response.ok) setError(friendlyMessage(response.message, "ذخیره تغییرات انجام نشد. دوباره تلاش کنید."));
else { else {
setEditItem(null); setEditItem(null);
@ -284,9 +316,9 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
if (!targets.length || !window.confirm(`${targets.length.toLocaleString("fa-IR")} مورد حذف شود؟`)) return; if (!targets.length || !window.confirm(`${targets.length.toLocaleString("fa-IR")} مورد حذف شود؟`)) return;
setBusy(true); setBusy(true);
for (const item of targets) { for (const item of targets) {
const code = recordCode(item, resource); const targetId = recordTargetId(item, resource);
if (code) { if (targetId) {
const response = await deleteAdminRegistryItem(resource, code); const response = await deleteAdminRegistryItem(resource, targetId);
if (!response.ok) { if (!response.ok) {
setError(friendlyMessage(response.message, "حذف بعضی موارد انجام نشد.")); setError(friendlyMessage(response.message, "حذف بعضی موارد انجام نشد."));
break; break;
@ -303,7 +335,7 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
<AdminPageHeader <AdminPageHeader
title={resource.label} title={resource.label}
subtitle={`جست‌وجو، ویرایش، انتشار و مدیریت ${resource.label}`} subtitle={`جست‌وجو، ویرایش، انتشار و مدیریت ${resource.label}`}
action={<div className="flex flex-wrap gap-2"><Link href="/admin/commercial"><Button variant="outline">بازگشت</Button></Link><Button onClick={openCreate}>افزودن مورد جدید</Button></div>} action={<div className="flex flex-wrap gap-2"><Link href="/admin/commercial"><Button variant="outline">بازگشت</Button></Link>{!resource.readOnly ? <Button onClick={openCreate}>افزودن مورد جدید</Button> : null}</div>}
/> />
{error ? <Banner variant="error">{error}</Banner> : null} {error ? <Banner variant="error">{error}</Banner> : null}
{msg ? <Banner variant="success">{msg}</Banner> : null} {msg ? <Banner variant="success">{msg}</Banner> : null}
@ -318,7 +350,7 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
<option value="all">همه وضعیتها</option><option value="active">فعال</option><option value="published">منتشرشده</option><option value="draft">پیشنویس</option><option value="inactive">غیرفعال</option> <option value="all">همه وضعیتها</option><option value="active">فعال</option><option value="published">منتشرشده</option><option value="draft">پیشنویس</option><option value="inactive">غیرفعال</option>
</select> </select>
<Button variant="outline" size="sm" onClick={() => setSortAsc((value) => !value)}>مرتبسازی {sortAsc ? "الف تا ی" : "ی تا الف"}</Button> <Button variant="outline" size="sm" onClick={() => setSortAsc((value) => !value)}>مرتبسازی {sortAsc ? "الف تا ی" : "ی تا الف"}</Button>
{selected.size ? <Button variant="outline" size="sm" disabled={busy} onClick={() => void removeItems(items.filter((item) => selected.has(recordCode(item, resource))))}>حذف انتخابشدهها</Button> : null} {selected.size && !resource.readOnly ? <Button variant="outline" size="sm" disabled={busy} onClick={() => void removeItems(items.filter((item) => selected.has(recordCode(item, resource))))}>حذف انتخابشدهها</Button> : null}
</div> </div>
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm"> <section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
@ -339,7 +371,13 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
<td className="px-4 py-3"><input type="checkbox" checked={selected.has(code)} aria-label={`انتخاب ${recordLabel(item, resource)}`} onChange={() => setSelected((current) => { const next = new Set(current); next.has(code) ? next.delete(code) : next.add(code); return next; })} /></td> <td className="px-4 py-3"><input type="checkbox" checked={selected.has(code)} aria-label={`انتخاب ${recordLabel(item, resource)}`} onChange={() => setSelected((current) => { const next = new Set(current); next.has(code) ? next.delete(code) : next.add(code); return next; })} /></td>
<td className="px-4 py-3 font-medium text-secondary">{recordLabel(item, resource)}</td> <td className="px-4 py-3 font-medium text-secondary">{recordLabel(item, resource)}</td>
<td className="px-4 py-3"><span className={`rounded-full px-2.5 py-1 text-xs ${status === "active" || status === "published" ? "bg-emerald-50 text-emerald-700" : status === "draft" ? "bg-amber-50 text-amber-700" : "bg-gray-100 text-gray-600"}`}>{enumLabel(status)}</span></td> <td className="px-4 py-3"><span className={`rounded-full px-2.5 py-1 text-xs ${status === "active" || status === "published" ? "bg-emerald-50 text-emerald-700" : status === "draft" ? "bg-amber-50 text-amber-700" : "bg-gray-100 text-gray-600"}`}>{enumLabel(status)}</span></td>
<td className="px-4 py-3"><div className="flex gap-2"><Button variant="outline" size="sm" onClick={() => openEdit(item)}><span aria-hidden="true"></span> ویرایش</Button><Button variant="outline" size="sm" disabled={busy} onClick={() => void removeItems([item])}><span aria-hidden="true"></span> حذف</Button></div></td> <td className="px-4 py-3">
{resource.readOnly ? (
<span className="text-xs text-gray-500">نمایش فقطخواندنی</span>
) : (
<div className="flex gap-2"><Button variant="outline" size="sm" onClick={() => openEdit(item)}><span aria-hidden="true"></span> ویرایش</Button><Button variant="outline" size="sm" disabled={busy} onClick={() => void removeItems([item])}><span aria-hidden="true"></span> حذف</Button></div>
)}
</td>
</tr> </tr>
); );
})} })}