fix(commercial): stabilize admin updates and API access
This commit is contained in:
parent
bf2913cdd5
commit
d77aabdb8c
65
frontend/app/api/core/[...path]/route.ts
Normal file
65
frontend/app/api/core/[...path]/route.ts
Normal 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);
|
||||
}
|
||||
@ -5,8 +5,9 @@
|
||||
|
||||
import { getValidAccessToken } from "./auth";
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
|
||||
const SERVER_API_BASE =
|
||||
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 {
|
||||
constructor(
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
|
||||
import { getValidAccessToken } from "@/lib/auth";
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
"http://localhost:8000";
|
||||
const SERVER_API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
"http://localhost:8000";
|
||||
const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
|
||||
@ -2,9 +2,8 @@ import type { AdapterResult } from "../types";
|
||||
import {
|
||||
commercialDelete,
|
||||
commercialGet,
|
||||
commercialPatch,
|
||||
commercialPost,
|
||||
commercialPut,
|
||||
commercialPatch,
|
||||
commercialPost,
|
||||
extractItems,
|
||||
failResult,
|
||||
listResult,
|
||||
@ -13,14 +12,56 @@ import type { CommercialAdminResourceDef } from "../admin/resources";
|
||||
|
||||
export type RegistryRecord = Record<string, unknown>;
|
||||
|
||||
export async function listAdminRegistry(
|
||||
resource: CommercialAdminResourceDef
|
||||
): Promise<AdapterResult<RegistryRecord[]>> {
|
||||
const res = await commercialGet<unknown>(resource.listPath);
|
||||
if (!res.ok) {
|
||||
return failResult(res.availability, res.message, []);
|
||||
}
|
||||
const items = extractItems<RegistryRecord>(res.data, resource.envelopeKeys);
|
||||
export async function listAdminRegistry(
|
||||
resource: CommercialAdminResourceDef,
|
||||
tenantId?: string | null
|
||||
): Promise<AdapterResult<RegistryRecord[]>> {
|
||||
if (resource.requiresTenant && !tenantId) {
|
||||
return {
|
||||
availability: "empty",
|
||||
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}`, "موردی در رجیستری نیست");
|
||||
}
|
||||
|
||||
@ -38,12 +79,10 @@ export async function updateAdminRegistryItem(
|
||||
code: string,
|
||||
body: RegistryRecord
|
||||
): Promise<{ ok: true; data: RegistryRecord } | { ok: false; message: string }> {
|
||||
const path = `${resource.listPath}/${encodeURIComponent(code)}`;
|
||||
const patch = await commercialPatch<RegistryRecord>(path, body);
|
||||
if (patch.ok) return { ok: true, data: patch.data };
|
||||
const put = await commercialPut<RegistryRecord>(path, body);
|
||||
if (put.ok) return { ok: true, data: put.data };
|
||||
return { ok: false, message: patch.message || put.message };
|
||||
const path = `${resource.listPath}/${encodeURIComponent(code)}`;
|
||||
const patch = await commercialPatch<RegistryRecord>(path, body);
|
||||
if (patch.ok) return { ok: true, data: patch.data };
|
||||
return { ok: false, message: patch.message };
|
||||
}
|
||||
|
||||
export async function deleteAdminRegistryItem(
|
||||
|
||||
@ -7,10 +7,11 @@
|
||||
import { getValidAccessToken } from "@/lib/auth";
|
||||
import type { AdapterResult, RegistryAvailability } from "../types";
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
"http://localhost:8000";
|
||||
const SERVER_API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
"http://localhost:8000";
|
||||
const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core";
|
||||
|
||||
async function authHeaders(): Promise<Headers> {
|
||||
const headers = new Headers({ "Content-Type": "application/json" });
|
||||
|
||||
@ -46,8 +46,10 @@ export interface CommercialAdminResourceDef {
|
||||
/** Item code/id field for display + PATCH/DELETE */
|
||||
codeField: string;
|
||||
nameField: string;
|
||||
/** Envelope keys for list extraction */
|
||||
envelopeKeys: string[];
|
||||
/** Envelope keys for list extraction */
|
||||
envelopeKeys: string[];
|
||||
requiresTenant?: boolean;
|
||||
readOnly?: boolean;
|
||||
group:
|
||||
| "catalog"
|
||||
| "commerce"
|
||||
@ -333,8 +335,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
||||
envelopeKeys: ["items", "support_levels"],
|
||||
group: "ops",
|
||||
},
|
||||
{
|
||||
key: "subscriptions",
|
||||
{
|
||||
key: "subscriptions",
|
||||
label: "اشتراکها",
|
||||
description: "Tenant subscriptions",
|
||||
listPath: "/api/v1/commercial/subscriptions",
|
||||
@ -342,10 +344,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
||||
codeField: "id",
|
||||
nameField: "tenant_id",
|
||||
envelopeKeys: ["items", "subscriptions"],
|
||||
group: "tenant",
|
||||
group: "tenant",
|
||||
requiresTenant: true,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
key: "licenses",
|
||||
{
|
||||
key: "licenses",
|
||||
label: "لایسنسها",
|
||||
description: "Licenses",
|
||||
listPath: "/api/v1/commercial/licenses",
|
||||
@ -353,10 +357,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
||||
codeField: "license_code",
|
||||
nameField: "display_name",
|
||||
envelopeKeys: ["items", "licenses"],
|
||||
group: "tenant",
|
||||
group: "tenant",
|
||||
requiresTenant: true,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
key: "entitlements",
|
||||
{
|
||||
key: "entitlements",
|
||||
label: "سطوح دسترسی",
|
||||
description: "Entitlement projections",
|
||||
listPath: "/api/v1/commercial/entitlements",
|
||||
@ -364,10 +370,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
||||
codeField: "entitlement_code",
|
||||
nameField: "display_name",
|
||||
envelopeKeys: ["items", "entitlements"],
|
||||
group: "tenant",
|
||||
group: "tenant",
|
||||
requiresTenant: true,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
key: "activation-logs",
|
||||
{
|
||||
key: "activation-logs",
|
||||
label: "لاگ فعالسازی",
|
||||
description: "Activation pipelines",
|
||||
listPath: "/api/v1/commercial/activation-logs",
|
||||
@ -375,10 +383,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
||||
codeField: "correlation_id",
|
||||
nameField: "tenant_id",
|
||||
envelopeKeys: ["items", "pipelines", "activation_logs"],
|
||||
group: "tenant",
|
||||
group: "tenant",
|
||||
requiresTenant: true,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
key: "invoices",
|
||||
{
|
||||
key: "invoices",
|
||||
label: "فاکتورها",
|
||||
description: "Commercial invoices (published read models)",
|
||||
listPath: "/api/v1/commercial/invoices",
|
||||
@ -386,10 +396,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
||||
codeField: "invoice_code",
|
||||
nameField: "display_name",
|
||||
envelopeKeys: ["items", "invoices"],
|
||||
group: "tenant",
|
||||
group: "tenant",
|
||||
requiresTenant: true,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
key: "transactions",
|
||||
{
|
||||
key: "transactions",
|
||||
label: "تراکنشها",
|
||||
description: "Payment history read models (no Payment edits)",
|
||||
listPath: "/api/v1/commercial/transactions",
|
||||
@ -397,7 +409,9 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
||||
codeField: "transaction_code",
|
||||
nameField: "display_name",
|
||||
envelopeKeys: ["items", "transactions"],
|
||||
group: "tenant",
|
||||
group: "tenant",
|
||||
requiresTenant: true,
|
||||
readOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -26,9 +26,12 @@ export function BundleCard({
|
||||
const prices = (bundle.pricing_refs || [])
|
||||
.map((ref) => pricingItems.find((item) => item.pricing_item_code === ref))
|
||||
.filter((item): item is CommercialPricingItem => Boolean(item));
|
||||
const productNames = (bundle.product_codes || []).map(
|
||||
(code) => products.find((product) => product.product_code === code)?.display_name || humanizeCommercialCode(code)
|
||||
);
|
||||
const presentedProducts = (bundle.product_codes || []).map((code, index) => ({
|
||||
key: `${code}-${index}`,
|
||||
name:
|
||||
products.find((product) => product.product_code === code)?.display_name ||
|
||||
humanizeCommercialCode(code),
|
||||
}));
|
||||
const badge = bundle.recommendation_badge
|
||||
? enumLabel(bundle.recommendation_badge)
|
||||
: recommendationScore != null
|
||||
@ -70,13 +73,16 @@ export function BundleCard({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{productNames.length ? (
|
||||
{presentedProducts.length ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold text-gray-500">محصولات این بسته</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{productNames.map((name) => (
|
||||
<span key={name} className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800">
|
||||
{name}
|
||||
{presentedProducts.map((product) => (
|
||||
<span
|
||||
key={product.key}
|
||||
className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800"
|
||||
>
|
||||
{product.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AdminPageHeader, Banner, Button, EmptyState, Modal } from "@/components/admin/controls";
|
||||
import { useMe } from "@/hooks/useMe";
|
||||
import type { CommercialAdminResourceDef } from "../admin/resources";
|
||||
import {
|
||||
createAdminRegistryItem,
|
||||
@ -41,6 +42,11 @@ function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef):
|
||||
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 {
|
||||
const seed: RegistryRecord = {
|
||||
[resource.codeField]: "",
|
||||
@ -195,6 +201,7 @@ function CommercialRecordForm({
|
||||
}
|
||||
|
||||
export function AdminRegistryManager({ resource }: { resource: CommercialAdminResourceDef }) {
|
||||
const { me } = useMe();
|
||||
const [result, setResult] = useState<AdapterResult<RegistryRecord[]> | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
@ -212,13 +219,13 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const response = await listAdminRegistry(resource);
|
||||
const response = await listAdminRegistry(resource, me?.current_tenant_id);
|
||||
setResult(response);
|
||||
if (["unavailable", "permission_denied", "error"].includes(response.availability)) {
|
||||
setError(friendlyMessage(response.message, "بارگذاری اطلاعات انجام نشد. لطفاً دوباره تلاش کنید."));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [resource]);
|
||||
}, [me?.current_tenant_id, resource]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
@ -267,10 +274,35 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
||||
const submitEdit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!editItem) return;
|
||||
const code = recordCode(editItem, resource);
|
||||
if (!code) return setError("شناسه این مورد پیدا نشد. صفحه را تازهسازی کنید.");
|
||||
const targetId = recordTargetId(editItem, resource);
|
||||
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);
|
||||
const response = await updateAdminRegistryItem(resource, code, formBody);
|
||||
const response = await updateAdminRegistryItem(resource, targetId, changedFields);
|
||||
if (!response.ok) setError(friendlyMessage(response.message, "ذخیره تغییرات انجام نشد. دوباره تلاش کنید."));
|
||||
else {
|
||||
setEditItem(null);
|
||||
@ -284,9 +316,9 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
||||
if (!targets.length || !window.confirm(`${targets.length.toLocaleString("fa-IR")} مورد حذف شود؟`)) return;
|
||||
setBusy(true);
|
||||
for (const item of targets) {
|
||||
const code = recordCode(item, resource);
|
||||
if (code) {
|
||||
const response = await deleteAdminRegistryItem(resource, code);
|
||||
const targetId = recordTargetId(item, resource);
|
||||
if (targetId) {
|
||||
const response = await deleteAdminRegistryItem(resource, targetId);
|
||||
if (!response.ok) {
|
||||
setError(friendlyMessage(response.message, "حذف بعضی موارد انجام نشد."));
|
||||
break;
|
||||
@ -303,7 +335,7 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
||||
<AdminPageHeader
|
||||
title={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}
|
||||
{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>
|
||||
</select>
|
||||
<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>
|
||||
|
||||
<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 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"><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>
|
||||
);
|
||||
})}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user