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";
|
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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
commercialGet,
|
commercialGet,
|
||||||
commercialPatch,
|
commercialPatch,
|
||||||
commercialPost,
|
commercialPost,
|
||||||
commercialPut,
|
|
||||||
extractItems,
|
extractItems,
|
||||||
failResult,
|
failResult,
|
||||||
listResult,
|
listResult,
|
||||||
@ -14,12 +13,54 @@ 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,
|
||||||
|
tenantId?: string | null
|
||||||
): Promise<AdapterResult<RegistryRecord[]>> {
|
): Promise<AdapterResult<RegistryRecord[]>> {
|
||||||
const res = await commercialGet<unknown>(resource.listPath);
|
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) {
|
if (!res.ok) {
|
||||||
return failResult(res.availability, res.message, []);
|
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);
|
const items = extractItems<RegistryRecord>(res.data, resource.envelopeKeys);
|
||||||
return listResult(items, `admin.${resource.key}`, "موردی در رجیستری نیست");
|
return listResult(items, `admin.${resource.key}`, "موردی در رجیستری نیست");
|
||||||
}
|
}
|
||||||
@ -41,9 +82,7 @@ export async function updateAdminRegistryItem(
|
|||||||
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(
|
||||||
|
|||||||
@ -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" });
|
||||||
|
|||||||
@ -48,6 +48,8 @@ export interface CommercialAdminResourceDef {
|
|||||||
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"
|
||||||
@ -343,6 +345,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
nameField: "tenant_id",
|
nameField: "tenant_id",
|
||||||
envelopeKeys: ["items", "subscriptions"],
|
envelopeKeys: ["items", "subscriptions"],
|
||||||
group: "tenant",
|
group: "tenant",
|
||||||
|
requiresTenant: true,
|
||||||
|
readOnly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "licenses",
|
key: "licenses",
|
||||||
@ -354,6 +358,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "licenses"],
|
envelopeKeys: ["items", "licenses"],
|
||||||
group: "tenant",
|
group: "tenant",
|
||||||
|
requiresTenant: true,
|
||||||
|
readOnly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "entitlements",
|
key: "entitlements",
|
||||||
@ -365,6 +371,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
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",
|
||||||
@ -376,6 +384,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
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",
|
||||||
@ -387,6 +397,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "invoices"],
|
envelopeKeys: ["items", "invoices"],
|
||||||
group: "tenant",
|
group: "tenant",
|
||||||
|
requiresTenant: true,
|
||||||
|
readOnly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "transactions",
|
key: "transactions",
|
||||||
@ -398,6 +410,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "transactions"],
|
envelopeKeys: ["items", "transactions"],
|
||||||
group: "tenant",
|
group: "tenant",
|
||||||
|
requiresTenant: true,
|
||||||
|
readOnly: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user