Added more to entity modification
This commit is contained in:
parent
3c8b99776d
commit
fabb507851
143
app/api/dashboard/admin/[resource]/[id]/route.ts
Normal file
143
app/api/dashboard/admin/[resource]/[id]/route.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
BE_BASE_URL,
|
||||
getAdminResourceCacheTag,
|
||||
} from "@/app/services/constants";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
const ALLOWED_RESOURCES = [
|
||||
"groups",
|
||||
"currencies",
|
||||
"permissions",
|
||||
"merchants",
|
||||
"sessions",
|
||||
"users",
|
||||
];
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ resource: string; id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { resource, id } = await context.params;
|
||||
|
||||
if (!ALLOWED_RESOURCES.includes(resource)) {
|
||||
return NextResponse.json(
|
||||
{ message: `Resource '${resource}' is not allowed` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ message: "Missing Authorization header" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${BE_BASE_URL}/api/v1/${resource}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Revalidate the cache for this resource after successful update
|
||||
if (response.ok) {
|
||||
revalidateTag(getAdminResourceCacheTag(resource));
|
||||
}
|
||||
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (err: unknown) {
|
||||
let resourceName = "resource";
|
||||
try {
|
||||
const { resource } = await context.params;
|
||||
resourceName = resource;
|
||||
} catch {
|
||||
// If we can't get resource, use default
|
||||
}
|
||||
console.error(`Proxy PUT /api/v1/${resourceName}/{id} error:`, err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error", error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ resource: string; id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { resource, id } = await context.params;
|
||||
|
||||
if (!ALLOWED_RESOURCES.includes(resource)) {
|
||||
return NextResponse.json(
|
||||
{ message: `Resource '${resource}' is not allowed` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ message: "Missing Authorization header" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${BE_BASE_URL}/api/v1/${resource}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = { success: response.ok };
|
||||
}
|
||||
|
||||
// Revalidate the cache for this resource after successful deletion
|
||||
if (response.ok) {
|
||||
revalidateTag(getAdminResourceCacheTag(resource));
|
||||
}
|
||||
|
||||
return NextResponse.json(data ?? { success: response.ok }, {
|
||||
status: response.status,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
let resourceName = "resource";
|
||||
try {
|
||||
const { resource } = await context.params;
|
||||
resourceName = resource;
|
||||
} catch {
|
||||
// If we can't get resource, use default
|
||||
}
|
||||
console.error(`Proxy DELETE /api/v1/${resourceName}/{id} error:`, err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error", error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
79
app/api/dashboard/admin/[resource]/create/route.ts
Normal file
79
app/api/dashboard/admin/[resource]/create/route.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
BE_BASE_URL,
|
||||
getAdminResourceCacheTag,
|
||||
} from "@/app/services/constants";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
const ALLOWED_RESOURCES = [
|
||||
"groups",
|
||||
"currencies",
|
||||
"permissions",
|
||||
"merchants",
|
||||
"sessions",
|
||||
"users",
|
||||
];
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ resource: string }> }
|
||||
) {
|
||||
try {
|
||||
const { resource } = await context.params;
|
||||
|
||||
if (!ALLOWED_RESOURCES.includes(resource)) {
|
||||
return NextResponse.json(
|
||||
{ message: `Resource '${resource}' is not allowed` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ message: "Missing Authorization header" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${BE_BASE_URL}/api/v1/${resource}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Revalidate the cache for this resource after successful creation
|
||||
if (response.ok) {
|
||||
revalidateTag(getAdminResourceCacheTag(resource));
|
||||
}
|
||||
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (err: unknown) {
|
||||
let resourceName = "resource";
|
||||
try {
|
||||
const { resource } = await context.params;
|
||||
resourceName = resource;
|
||||
} catch {
|
||||
// If we can't get resource, use default
|
||||
}
|
||||
console.error(`Proxy POST /api/v1/${resourceName} error:`, err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error", error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,35 @@
|
||||
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
BE_BASE_URL,
|
||||
REVALIDATE_SECONDS,
|
||||
getAdminResourceCacheTag,
|
||||
} from "@/app/services/constants";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildFilterParam } from "../utils";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ALLOWED_RESOURCES = [
|
||||
"groups",
|
||||
"currencies",
|
||||
"permissions",
|
||||
"merchants",
|
||||
"sessions",
|
||||
"users",
|
||||
];
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ resource: string }> }
|
||||
) {
|
||||
try {
|
||||
const { resource } = await context.params;
|
||||
|
||||
if (!ALLOWED_RESOURCES.includes(resource)) {
|
||||
return NextResponse.json(
|
||||
{ message: `Resource '${resource}' is not allowed` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
@ -16,10 +42,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
||||
const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("limit", String(pagination.limit ?? 10));
|
||||
queryParams.set("limit", String(pagination.limit ?? 100));
|
||||
queryParams.set("page", String(pagination.page ?? 1));
|
||||
|
||||
if (sort?.field && sort?.order) {
|
||||
@ -31,7 +57,7 @@ export async function POST(request: NextRequest) {
|
||||
queryParams.set("filter", filterParam);
|
||||
}
|
||||
|
||||
const backendUrl = `${BE_BASE_URL}/api/v1/groups${
|
||||
const backendUrl = `${BE_BASE_URL}/api/v1/${resource}${
|
||||
queryParams.size ? `?${queryParams.toString()}` : ""
|
||||
}`;
|
||||
|
||||
@ -41,18 +67,21 @@ export async function POST(request: NextRequest) {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
next: {
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: [getAdminResourceCacheTag(resource)],
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to fetch groups" }));
|
||||
.catch(() => ({ message: `Failed to fetch ${resource}` }));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: errorData?.message || "Failed to fetch groups",
|
||||
message: errorData?.message || `Failed to fetch ${resource}`,
|
||||
},
|
||||
{ status: response.status }
|
||||
);
|
||||
@ -61,7 +90,17 @@ export async function POST(request: NextRequest) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (err: unknown) {
|
||||
console.error("Proxy POST /api/dashboard/admin/groups error:", err);
|
||||
let resourceName = "resource";
|
||||
try {
|
||||
const { resource } = await context.params;
|
||||
resourceName = resource;
|
||||
} catch {
|
||||
// If we can't get resource, use default
|
||||
}
|
||||
console.error(
|
||||
`Proxy POST /api/dashboard/admin/${resourceName} error:`,
|
||||
err
|
||||
);
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error", error: errorMessage },
|
||||
@ -16,10 +16,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
||||
const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("limit", String(pagination.limit ?? 10));
|
||||
queryParams.set("limit", String(pagination.limit ?? 100));
|
||||
queryParams.set("page", String(pagination.page ?? 1));
|
||||
|
||||
if (sort?.field && sort?.order) {
|
||||
|
||||
@ -16,10 +16,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
||||
const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("limit", String(pagination.limit ?? 10));
|
||||
queryParams.set("limit", String(pagination.limit ?? 100));
|
||||
queryParams.set("page", String(pagination.page ?? 1));
|
||||
|
||||
if (sort?.field && sort?.order) {
|
||||
|
||||
@ -89,21 +89,27 @@ export async function PUT(
|
||||
}
|
||||
|
||||
// According to swagger: /api/v1/users/{id}
|
||||
const response = await fetch(`${BE_BASE_URL}/api/v1/users/${id}`, {
|
||||
const requestUrl = `${BE_BASE_URL}/api/v1/users/${id}`;
|
||||
const requestConfig = {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(transformedBody),
|
||||
});
|
||||
};
|
||||
console.log("request", { url: requestUrl, ...requestConfig });
|
||||
|
||||
const response = await fetch(requestUrl, requestConfig);
|
||||
|
||||
const data = await response.json();
|
||||
console.log("response", { data, status: response.status });
|
||||
if (response.ok) {
|
||||
revalidateTag(USERS_CACHE_TAG);
|
||||
}
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (err: unknown) {
|
||||
console.error("Proxy PUT /api/v1/users/{id} error:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
return NextResponse.json(
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { fetchDashboardDataService } from "@/app/services/health";
|
||||
import { transformHealthDataToStats } from "@/app/features/GeneralHealthCard/utils";
|
||||
import {
|
||||
transformHealthDataToStats,
|
||||
transformOverviewResponse,
|
||||
} from "./utils/dashboard";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@ -13,15 +16,19 @@ export async function GET(request: NextRequest) {
|
||||
dateStart,
|
||||
dateEnd,
|
||||
});
|
||||
|
||||
const { healthData, overviewData, reviewTransactions } = dashboardData;
|
||||
// Transform health data to stats format using shared util
|
||||
const stats = transformHealthDataToStats(dashboardData.healthData);
|
||||
|
||||
const stats = transformHealthDataToStats(healthData);
|
||||
const transformedOverviewData = transformOverviewResponse(overviewData);
|
||||
// console.log("[TransformedOverviewData] - Route", transformedOverviewData);
|
||||
const response = {
|
||||
...dashboardData.healthData,
|
||||
...healthData,
|
||||
stats,
|
||||
overviewData: dashboardData.overviewData,
|
||||
reviewTransactions: dashboardData.reviewTransactions,
|
||||
overviewData: {
|
||||
...overviewData,
|
||||
data: transformedOverviewData,
|
||||
},
|
||||
reviewTransactions: reviewTransactions,
|
||||
};
|
||||
|
||||
return NextResponse.json(response, { status: 200 });
|
||||
|
||||
172
app/api/dashboard/utils/dashboard.ts
Normal file
172
app/api/dashboard/utils/dashboard.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import "server-only";
|
||||
|
||||
import { formatCurrency, formatPercentage } from "@/app/utils/formatCurrency";
|
||||
|
||||
interface IHealthData {
|
||||
total?: number;
|
||||
successful?: number;
|
||||
acceptance_rate?: number;
|
||||
amount?: number;
|
||||
atv?: number;
|
||||
}
|
||||
|
||||
interface IStatItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
change: string;
|
||||
}
|
||||
|
||||
export const transformHealthDataToStats = (
|
||||
healthData: IHealthData | null
|
||||
): IStatItem[] => {
|
||||
if (!healthData) {
|
||||
return [
|
||||
{ label: "TOTAL", value: 0, change: "0%" },
|
||||
{ label: "SUCCESSFUL", value: 0, change: "0%" },
|
||||
{ label: "ACCEPTANCE RATE", value: "0%", change: "0%" },
|
||||
{ label: "AMOUNT", value: "€0.00", change: "0%" },
|
||||
{ label: "ATV", value: "€0.00", change: "0%" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: "TOTAL",
|
||||
value: healthData.total ?? 0,
|
||||
change: "0%",
|
||||
},
|
||||
{
|
||||
label: "SUCCESSFUL",
|
||||
value: healthData.successful ?? 0,
|
||||
change: "0%",
|
||||
},
|
||||
{
|
||||
label: "ACCEPTANCE RATE",
|
||||
value: formatPercentage(healthData.acceptance_rate),
|
||||
change: "0%",
|
||||
},
|
||||
{
|
||||
label: "AMOUNT",
|
||||
value: formatCurrency(healthData.amount),
|
||||
change: "0%",
|
||||
},
|
||||
{
|
||||
label: "ATV",
|
||||
value: formatCurrency(healthData.atv),
|
||||
change: "0%",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Map transaction state to color
|
||||
*/
|
||||
const getStateColor = (state: string): string => {
|
||||
const normalizedState = state.toLowerCase();
|
||||
|
||||
switch (normalizedState) {
|
||||
case "success":
|
||||
case "completed":
|
||||
case "successful":
|
||||
return "#4caf50"; // green
|
||||
case "pending":
|
||||
case "waiting":
|
||||
return "#ff9800"; // orange
|
||||
case "failed":
|
||||
case "error":
|
||||
return "#f44336"; // red
|
||||
case "cancelled":
|
||||
case "canceled":
|
||||
return "#9e9e9e"; // gray
|
||||
default:
|
||||
return "#9e9e9e"; // gray
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate percentage for each state
|
||||
*/
|
||||
const calculatePercentages = (
|
||||
items: Array<{ state: string; count: number }>
|
||||
): Array<{
|
||||
state: string;
|
||||
count: number;
|
||||
percentage: string;
|
||||
}> => {
|
||||
const total = items.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
if (total === 0) {
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
percentage: "0%",
|
||||
}));
|
||||
}
|
||||
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
percentage: `${Math.round((item.count / total) * 100)}%`,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform API overview data to include colors
|
||||
*/
|
||||
const enrichOverviewData = (
|
||||
data: Array<{
|
||||
state: string;
|
||||
count: number;
|
||||
percentage: string;
|
||||
color?: string;
|
||||
}>
|
||||
): Array<{
|
||||
state: string;
|
||||
count: number;
|
||||
percentage: string;
|
||||
color: string;
|
||||
}> => {
|
||||
return data.map(item => ({
|
||||
...item,
|
||||
color: item.color || getStateColor(item.state),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform flat API overview response to enriched array format with percentages and colors
|
||||
*/
|
||||
export const transformOverviewResponse = (
|
||||
data:
|
||||
| {
|
||||
cancelled_count?: number;
|
||||
failed_count?: number;
|
||||
successful_count?: number;
|
||||
waiting_count?: number;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): Array<{
|
||||
state: string;
|
||||
count: number;
|
||||
percentage: string;
|
||||
color: string;
|
||||
}> => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const states = [
|
||||
{ key: "successful_count", label: "Successful" },
|
||||
{ key: "waiting_count", label: "Waiting" },
|
||||
{ key: "failed_count", label: "Failed" },
|
||||
{ key: "cancelled_count", label: "Cancelled" },
|
||||
];
|
||||
|
||||
const transformed = states
|
||||
.map(({ key, label }) => ({
|
||||
state: label,
|
||||
count: data[key as keyof typeof data] || 0,
|
||||
}))
|
||||
.filter(item => item.count > 0); // Only include states with counts > 0
|
||||
|
||||
const withPercentages = calculatePercentages(transformed);
|
||||
return enrichOverviewData(withPercentages);
|
||||
};
|
||||
148
app/components/AddModal/AddModal.tsx
Normal file
148
app/components/AddModal/AddModal.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Typography, Button, Alert, TextField } from "@mui/material";
|
||||
import Modal from "@/app/components/Modal/Modal";
|
||||
import Spinner from "@/app/components/Spinner/Spinner";
|
||||
import { IAddModalProps } from "./types";
|
||||
|
||||
const AddModal: React.FC<IAddModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
resourceType = "item",
|
||||
fields,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const initialData: Record<string, unknown> = {};
|
||||
fields.forEach(field => {
|
||||
initialData[field.name] = field.defaultValue ?? "";
|
||||
});
|
||||
setFormData(initialData);
|
||||
setValidationErrors({});
|
||||
}
|
||||
}, [open, fields]);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
if (validationErrors[name]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
fields.forEach(field => {
|
||||
const value = formData[field.name];
|
||||
const isEmpty =
|
||||
value === undefined || value === null || String(value).trim() === "";
|
||||
|
||||
if (field.required && isEmpty) {
|
||||
errors[field.name] = `${field.label} is required`;
|
||||
}
|
||||
|
||||
if (field.type === "email" && value && !isEmpty) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(String(value))) {
|
||||
errors[field.name] = "Please enter a valid email address";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.resolve(onConfirm(formData));
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading) {
|
||||
setFormData({});
|
||||
setValidationErrors({});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const title = `Add ${resourceType.charAt(0).toUpperCase() + resourceType.slice(1)}`;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title={title}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack spacing={3}>
|
||||
{fields.map(field => (
|
||||
<TextField
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
type={field.type === "number" ? "number" : field.type || "text"}
|
||||
value={formData[field.name] ?? ""}
|
||||
onChange={handleChange}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder}
|
||||
error={!!validationErrors[field.name]}
|
||||
helperText={validationErrors[field.name]}
|
||||
disabled={isLoading}
|
||||
multiline={field.multiline || field.type === "textarea"}
|
||||
rows={field.rows || (field.multiline ? 4 : undefined)}
|
||||
fullWidth
|
||||
/>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
disabled={isLoading}
|
||||
startIcon={
|
||||
isLoading ? <Spinner size="small" color="#fff" /> : null
|
||||
}
|
||||
>
|
||||
{isLoading ? "Adding..." : `Add ${resourceType}`}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddModal;
|
||||
22
app/components/AddModal/types.ts
Normal file
22
app/components/AddModal/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export type TAddModalFieldType = "text" | "email" | "number" | "textarea";
|
||||
|
||||
export interface IAddModalField {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: TAddModalFieldType;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export interface IAddModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (data: Record<string, unknown>) => Promise<void> | void;
|
||||
resourceType?: string;
|
||||
fields: IAddModalField[];
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
65
app/components/DeleteModal/DeleteModal.tsx
Normal file
65
app/components/DeleteModal/DeleteModal.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Stack, Typography, Button, Alert } from "@mui/material";
|
||||
import Modal from "@/app/components/Modal/Modal";
|
||||
import Spinner from "@/app/components/Spinner/Spinner";
|
||||
import { IDeleteModalProps } from "./types";
|
||||
|
||||
const DeleteModal: React.FC<IDeleteModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
resource,
|
||||
resourceType = "item",
|
||||
isLoading = false,
|
||||
error = null,
|
||||
}) => {
|
||||
const handleConfirm = async () => {
|
||||
if (!resource?.id) return;
|
||||
await Promise.resolve(onConfirm(resource.id));
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const resourceLabel = resource?.label || `this ${resourceType}`;
|
||||
const title = `Delete ${resourceType.charAt(0).toUpperCase() + resourceType.slice(1)}`;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title={title}>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="body1">
|
||||
Are you sure you want to delete <strong>{resourceLabel}</strong>? This
|
||||
action cannot be undone.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button variant="outlined" onClick={handleClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading || !resource?.id}
|
||||
startIcon={isLoading ? <Spinner size="small" color="#fff" /> : null}
|
||||
>
|
||||
{isLoading ? "Deleting..." : `Delete ${resourceType}`}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteModal;
|
||||
14
app/components/DeleteModal/types.ts
Normal file
14
app/components/DeleteModal/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export type TDeleteModalResource = {
|
||||
id: number | string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export interface IDeleteModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (id: number | string) => Promise<void> | void;
|
||||
resource: TDeleteModalResource | null;
|
||||
resourceType?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
53
app/dashboard/admin/constants.ts
Normal file
53
app/dashboard/admin/constants.ts
Normal file
@ -0,0 +1,53 @@
|
||||
export const MERCHANT_FIELDS = [
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "Enter merchant name",
|
||||
},
|
||||
];
|
||||
|
||||
export const CURRENCY_FIELDS = [
|
||||
{
|
||||
name: "code",
|
||||
label: "Code",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "Enter currency code (e.g. USD)",
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "Enter currency name",
|
||||
},
|
||||
{
|
||||
name: "rate",
|
||||
label: "Rate",
|
||||
type: "number",
|
||||
required: false,
|
||||
placeholder: "Enter exchange rate",
|
||||
},
|
||||
];
|
||||
|
||||
export const GROUP_FIELDS = [
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "Enter group name",
|
||||
},
|
||||
];
|
||||
|
||||
export const PERMISSION_FIELDS = [
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "Enter permission name",
|
||||
},
|
||||
];
|
||||
32
app/dashboard/admin/currencies/page.tsx
Normal file
32
app/dashboard/admin/currencies/page.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
|
||||
import { IAddModalField } from "@/app/components/AddModal/types";
|
||||
import { CURRENCY_FIELDS } from "../constants";
|
||||
import { fetchAdminResource } from "@/app/services/adminResources";
|
||||
|
||||
export default async function CurrenciesPage() {
|
||||
let initialData = null;
|
||||
|
||||
try {
|
||||
initialData = await fetchAdminResource({
|
||||
resource: "currencies",
|
||||
pagination: { page: 1, limit: 100 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch currencies server-side:", error);
|
||||
// Continue without initial data - component will fetch client-side
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminResourceList
|
||||
title="Currencies"
|
||||
endpoint="/api/dashboard/admin/currencies"
|
||||
responseCollectionKeys={["currencies", "data", "items"]}
|
||||
primaryLabelKeys={["name", "code", "title"]}
|
||||
chipKeys={["rate", "enabled", "status"]}
|
||||
addModalFields={CURRENCY_FIELDS as IAddModalField[]}
|
||||
showEnabledToggle={true}
|
||||
idField="code"
|
||||
initialData={initialData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,21 @@
|
||||
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
|
||||
import { IAddModalField } from "@/app/components/AddModal/types";
|
||||
import { GROUP_FIELDS } from "../constants";
|
||||
import { fetchAdminResource } from "@/app/services/adminResources";
|
||||
|
||||
export default async function GroupsPage() {
|
||||
let initialData = null;
|
||||
|
||||
try {
|
||||
initialData = await fetchAdminResource({
|
||||
resource: "groups",
|
||||
pagination: { page: 1, limit: 100 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch groups:", error);
|
||||
// Continue without initial data - component will fetch client-side
|
||||
}
|
||||
|
||||
export default function GroupsPage() {
|
||||
return (
|
||||
<AdminResourceList
|
||||
title="Groups"
|
||||
@ -8,6 +23,9 @@ export default function GroupsPage() {
|
||||
responseCollectionKeys={["groups", "data", "items"]}
|
||||
primaryLabelKeys={["name", "groupName", "title"]}
|
||||
chipKeys={["status", "role", "permissions"]}
|
||||
addModalFields={GROUP_FIELDS as IAddModalField[]}
|
||||
showEnabledToggle={true}
|
||||
initialData={initialData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
17
app/dashboard/admin/merchants/page.tsx
Normal file
17
app/dashboard/admin/merchants/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
|
||||
import { MERCHANT_FIELDS } from "../constants";
|
||||
import { IAddModalField } from "@/app/components/AddModal/types";
|
||||
|
||||
export default function GroupsPage() {
|
||||
return (
|
||||
<AdminResourceList
|
||||
title="Merchants"
|
||||
endpoint="/api/dashboard/admin/merchants"
|
||||
responseCollectionKeys={["merchants", "data", "items"]}
|
||||
primaryLabelKeys={["name", "merchantName", "title"]}
|
||||
chipKeys={["status", "role", "permissions"]}
|
||||
addModalFields={MERCHANT_FIELDS as IAddModalField[]}
|
||||
showEnabledToggle={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
|
||||
import { IAddModalField } from "@/app/components/AddModal/types";
|
||||
import { PERMISSION_FIELDS } from "../constants";
|
||||
import { fetchAdminResource } from "@/app/services/adminResources";
|
||||
|
||||
export default async function PermissionsPage() {
|
||||
let initialData = null;
|
||||
|
||||
try {
|
||||
initialData = await fetchAdminResource({
|
||||
resource: "permissions",
|
||||
pagination: { page: 1, limit: 100 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch permissions server-side:", error);
|
||||
// Continue without initial data - component will fetch client-side
|
||||
}
|
||||
|
||||
export default function PermissionsPage() {
|
||||
return (
|
||||
<AdminResourceList
|
||||
title="Permissions"
|
||||
@ -10,6 +23,9 @@ export default function PermissionsPage() {
|
||||
responseCollectionKeys={["permissions", "data", "items"]}
|
||||
primaryLabelKeys={["name", "permissionName", "title", "description"]}
|
||||
chipKeys={["status", "scope", "category"]}
|
||||
addModalFields={PERMISSION_FIELDS as IAddModalField[]}
|
||||
showEnabledToggle={true}
|
||||
initialData={initialData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { transformOverviewResponse } from "../api/dashboard/utils/dashboard";
|
||||
import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage";
|
||||
import { transformHealthDataToStats } from "../features/GeneralHealthCard/utils";
|
||||
import { fetchDashboardDataService } from "../services/health";
|
||||
import { ITransactionsOverviewData } from "../services/types";
|
||||
import { getDefaultDateRange } from "../utils/formatDate";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
@ -18,9 +19,13 @@ export default async function DashboardPage() {
|
||||
});
|
||||
|
||||
const { healthData, overviewData, reviewTransactions } = dashboardData;
|
||||
|
||||
initialHealthData = healthData;
|
||||
initialStats = healthData.stats ?? transformHealthDataToStats(healthData);
|
||||
initialOverviewData = overviewData;
|
||||
initialStats = healthData.stats ?? null;
|
||||
initialOverviewData = {
|
||||
data: transformOverviewResponse(overviewData),
|
||||
...overviewData,
|
||||
} as ITransactionsOverviewData;
|
||||
initialReviewTransactions = reviewTransactions;
|
||||
} catch (_error: unknown) {
|
||||
// If fetch fails, component will handle it client-side
|
||||
|
||||
109
app/features/AdminList/AdminResourceList.scss
Normal file
109
app/features/AdminList/AdminResourceList.scss
Normal file
@ -0,0 +1,109 @@
|
||||
.admin-resource-list {
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
&__list {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&__row {
|
||||
&-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-id {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
height: 1px;
|
||||
background-color: rgba(0, 0, 0, 0.12);
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
&__secondary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__enabled-label {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__edit-field {
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
@ -1,32 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Spinner from "@/app/components/Spinner/Spinner";
|
||||
import DeleteModal from "@/app/components/DeleteModal/DeleteModal";
|
||||
import { TDeleteModalResource } from "@/app/components/DeleteModal/types";
|
||||
import AddModal from "@/app/components/AddModal/AddModal";
|
||||
import { IAddModalField } from "@/app/components/AddModal/types";
|
||||
import { DataRowBase } from "@/app/features/DataTable/types";
|
||||
import {
|
||||
setError as setAdvancedSearchError,
|
||||
setStatus,
|
||||
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
||||
import {
|
||||
selectError,
|
||||
selectFilters,
|
||||
selectPagination,
|
||||
selectSort,
|
||||
selectStatus,
|
||||
} from "@/app/redux/advanedSearch/selectors";
|
||||
import { AppDispatch } from "@/app/redux/store";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
IconButton,
|
||||
Switch,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
type ResourceRow = DataRowBase & Record<string, unknown>;
|
||||
import {
|
||||
createResourceApi,
|
||||
deleteResourceApi,
|
||||
updateResourceApi,
|
||||
} from "./AdminResourceList.utils";
|
||||
import "./AdminResourceList.scss";
|
||||
|
||||
type ResourceRow = DataRowBase & {
|
||||
identifier?: string | number;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
type FilterValue =
|
||||
| string
|
||||
@ -43,6 +54,10 @@ interface AdminResourceListProps {
|
||||
chipKeys?: string[];
|
||||
excludeKeys?: string[];
|
||||
filterOverrides?: Record<string, FilterValue>;
|
||||
addModalFields?: IAddModalField[];
|
||||
showEnabledToggle?: boolean;
|
||||
idField?: string;
|
||||
initialData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
|
||||
@ -57,13 +72,14 @@ const ensureRowId = (
|
||||
return row as ResourceRow;
|
||||
}
|
||||
|
||||
const identifier = currentId;
|
||||
const numericId = Number(currentId);
|
||||
|
||||
if (!Number.isNaN(numericId) && numericId !== 0) {
|
||||
return { ...row, id: numericId } as ResourceRow;
|
||||
return { ...row, id: numericId, identifier } as ResourceRow;
|
||||
}
|
||||
|
||||
return { ...row, id: fallbackId } as ResourceRow;
|
||||
return { ...row, id: fallbackId, identifier } as ResourceRow;
|
||||
};
|
||||
|
||||
const resolveCollection = (
|
||||
@ -91,18 +107,50 @@ const AdminResourceList = ({
|
||||
primaryLabelKeys,
|
||||
chipKeys = [],
|
||||
excludeKeys = [],
|
||||
addModalFields,
|
||||
showEnabledToggle = false,
|
||||
idField,
|
||||
initialData,
|
||||
}: AdminResourceListProps) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
// Keep Redux for shared state (filters, pagination, sort)
|
||||
const filters = useSelector(selectFilters);
|
||||
const pagination = useSelector(selectPagination);
|
||||
const sort = useSelector(selectSort);
|
||||
const status = useSelector(selectStatus);
|
||||
const errorMessage = useSelector(selectError);
|
||||
|
||||
const [rows, setRows] = useState<ResourceRow[]>([]);
|
||||
// Use local state for component-specific status/error
|
||||
const [localStatus, setLocalStatus] = useState<
|
||||
"idle" | "loading" | "succeeded" | "failed"
|
||||
>(initialData ? "succeeded" : "idle");
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const [refreshCounter, setRefreshCounter] = useState(0);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [resourceToDelete, setResourceToDelete] =
|
||||
useState<TDeleteModalResource | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [editingRowId, setEditingRowId] = useState<number | string | null>(
|
||||
null
|
||||
);
|
||||
const [editingName, setEditingName] = useState<string>("");
|
||||
const [editingRate, setEditingRate] = useState<string>("");
|
||||
const [isSavingName, setIsSavingName] = useState(false);
|
||||
const [updatingEnabled, setUpdatingEnabled] = useState<Set<number | string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
const normalizedTitle = title.toLowerCase();
|
||||
|
||||
const getRowIdentifier = (row: ResourceRow): number | string => {
|
||||
if (idField && row[idField] !== undefined) {
|
||||
return row[idField] as number | string;
|
||||
}
|
||||
return (row.identifier as string | number | undefined) ?? row.id;
|
||||
};
|
||||
|
||||
const excludedKeys = useMemo(() => {
|
||||
const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]);
|
||||
excludeKeys.forEach(key => baseExcluded.add(key));
|
||||
@ -134,10 +182,48 @@ const AdminResourceList = ({
|
||||
[responseCollectionKeys]
|
||||
);
|
||||
|
||||
// Initialize rows synchronously from initialData if available
|
||||
const getInitialRows = (): ResourceRow[] => {
|
||||
if (initialData) {
|
||||
const collection = resolveCollection(initialData, resolvedCollectionKeys);
|
||||
return collection.map((item, index) => {
|
||||
const row = ensureRowId(item, index + 1);
|
||||
// If idField is specified, use that field as identifier
|
||||
if (idField && row[idField] !== undefined) {
|
||||
return { ...row, identifier: row[idField] } as ResourceRow;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const [rows, setRows] = useState<ResourceRow[]>(() => getInitialRows());
|
||||
|
||||
useEffect(() => {
|
||||
// Skip initial fetch if we have server-rendered data and no filters/pagination/sort changes
|
||||
// Always fetch when filters/pagination/sort change or after mutations (refreshCounter > 0)
|
||||
const hasFilters = Object.keys(filters).length > 0;
|
||||
const hasSort = sort?.field && sort?.order;
|
||||
|
||||
// Only skip if we have initialData AND it's the first render (refreshCounter === 0) AND no filters/sort
|
||||
// Also check if pagination is at default values (page 1, limit 100)
|
||||
const isDefaultPagination =
|
||||
pagination.page === 1 && pagination.limit === 100;
|
||||
const shouldSkipInitialFetch =
|
||||
initialData &&
|
||||
refreshCounter === 0 &&
|
||||
!hasFilters &&
|
||||
!hasSort &&
|
||||
isDefaultPagination;
|
||||
|
||||
if (shouldSkipInitialFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchResources = async () => {
|
||||
dispatch(setStatus("loading"));
|
||||
dispatch(setAdvancedSearchError(null));
|
||||
setLocalStatus("loading");
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
@ -151,10 +237,9 @@ const AdminResourceList = ({
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
dispatch(
|
||||
setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`)
|
||||
);
|
||||
setLocalError(`Failed to fetch ${normalizedTitle}`);
|
||||
setRows([]);
|
||||
setLocalStatus("failed");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -164,98 +249,334 @@ const AdminResourceList = ({
|
||||
resolvedCollectionKeys
|
||||
);
|
||||
|
||||
const nextRows = collection.map((item, index) =>
|
||||
ensureRowId(item, index + 1)
|
||||
);
|
||||
const nextRows = collection.map((item, index) => {
|
||||
const row = ensureRowId(item, index + 1);
|
||||
// If idField is specified, use that field as identifier
|
||||
if (idField && row[idField] !== undefined) {
|
||||
return { ...row, identifier: row[idField] } as ResourceRow;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
setRows(nextRows);
|
||||
dispatch(setStatus("succeeded"));
|
||||
setLocalStatus("succeeded");
|
||||
} catch (error) {
|
||||
dispatch(
|
||||
setAdvancedSearchError(
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
)
|
||||
);
|
||||
setLocalError(error instanceof Error ? error.message : "Unknown error");
|
||||
setRows([]);
|
||||
setLocalStatus("failed");
|
||||
}
|
||||
};
|
||||
|
||||
fetchResources();
|
||||
}, [
|
||||
dispatch,
|
||||
endpoint,
|
||||
filters,
|
||||
pagination,
|
||||
sort,
|
||||
resolvedCollectionKeys,
|
||||
normalizedTitle,
|
||||
refreshCounter,
|
||||
idField,
|
||||
initialData,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, width: "100%", maxWidth: 900 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
const handleAddClick = () => {
|
||||
if (!addModalFields) {
|
||||
return;
|
||||
}
|
||||
setAddModalOpen(true);
|
||||
setAddError(null);
|
||||
};
|
||||
|
||||
{status === "loading" && (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mb: 2 }}>
|
||||
const handleAddConfirm = async (data: Record<string, unknown>) => {
|
||||
if (!addModalFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
setAddError(null);
|
||||
|
||||
try {
|
||||
await createResourceApi(endpoint, data, normalizedTitle);
|
||||
setAddModalOpen(false);
|
||||
setRefreshCounter(current => current + 1);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to create ${normalizedTitle}`;
|
||||
setAddError(errorMessage);
|
||||
setLocalError(errorMessage);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddModalClose = () => {
|
||||
if (!isAdding) {
|
||||
setAddModalOpen(false);
|
||||
setAddError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (row: ResourceRow) => {
|
||||
if (!addModalFields || !row.id) {
|
||||
return;
|
||||
}
|
||||
const currentName = getPrimaryLabel(row);
|
||||
const currentRate = row.rate !== undefined ? String(row.rate) : "";
|
||||
setEditingRowId(row.id);
|
||||
setEditingName(currentName);
|
||||
setEditingRate(currentRate);
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingRowId(null);
|
||||
setEditingName("");
|
||||
setEditingRate("");
|
||||
};
|
||||
|
||||
const handleEditSave = async (row: ResourceRow) => {
|
||||
if (!addModalFields || !editingName.trim()) {
|
||||
handleEditCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const identifier = getRowIdentifier(row);
|
||||
|
||||
setIsSavingName(true);
|
||||
|
||||
try {
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
name: editingName.trim(),
|
||||
};
|
||||
|
||||
// Include rate if it exists and has been modified
|
||||
if (row.rate !== undefined && editingRate !== "") {
|
||||
const rateValue = parseFloat(editingRate);
|
||||
if (!Number.isNaN(rateValue)) {
|
||||
updatePayload.rate = rateValue;
|
||||
}
|
||||
}
|
||||
|
||||
await updateResourceApi(
|
||||
endpoint,
|
||||
identifier,
|
||||
updatePayload,
|
||||
normalizedTitle
|
||||
);
|
||||
setEditingRowId(null);
|
||||
setEditingName("");
|
||||
setEditingRate("");
|
||||
setRefreshCounter(current => current + 1);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to update ${normalizedTitle}`;
|
||||
setLocalError(errorMessage);
|
||||
} finally {
|
||||
setIsSavingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent, row: ResourceRow) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleEditSave(row);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleEditCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (row: ResourceRow) => {
|
||||
if (!addModalFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
const identifier = getRowIdentifier(row);
|
||||
if (!identifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceToDelete({
|
||||
id: identifier,
|
||||
label: getPrimaryLabel(row),
|
||||
});
|
||||
setDeleteModalOpen(true);
|
||||
setDeleteError(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async (id: number | string) => {
|
||||
if (!addModalFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
|
||||
try {
|
||||
await deleteResourceApi(endpoint, id, normalizedTitle);
|
||||
setDeleteModalOpen(false);
|
||||
setResourceToDelete(null);
|
||||
setRefreshCounter(current => current + 1);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to delete ${normalizedTitle}`;
|
||||
setDeleteError(errorMessage);
|
||||
setLocalError(errorMessage);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModalClose = () => {
|
||||
if (!isDeleting) {
|
||||
setDeleteModalOpen(false);
|
||||
setResourceToDelete(null);
|
||||
setDeleteError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnabledToggle = async (row: ResourceRow, newEnabled: boolean) => {
|
||||
if (!showEnabledToggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const identifier = getRowIdentifier(row);
|
||||
|
||||
setUpdatingEnabled(prev => new Set(prev).add(identifier));
|
||||
|
||||
try {
|
||||
await updateResourceApi(
|
||||
endpoint,
|
||||
identifier,
|
||||
{ enabled: newEnabled },
|
||||
normalizedTitle
|
||||
);
|
||||
setRefreshCounter(current => current + 1);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to update ${normalizedTitle}`;
|
||||
setLocalError(errorMessage);
|
||||
} finally {
|
||||
setUpdatingEnabled(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(identifier);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-resource-list">
|
||||
<div className="admin-resource-list__header">
|
||||
<Typography variant="h5" className="admin-resource-list__title">
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{addModalFields && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleAddClick}
|
||||
className="admin-resource-list__button--add"
|
||||
>
|
||||
Add {title}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{localStatus === "loading" && (
|
||||
<div className="admin-resource-list__loading">
|
||||
<Spinner size="small" color="#000" />
|
||||
<Typography variant="body2">
|
||||
{`Loading ${normalizedTitle}...`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "failed" && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{errorMessage || `Failed to load ${normalizedTitle}`}
|
||||
{localStatus === "failed" && (
|
||||
<Alert severity="error" className="admin-resource-list__error">
|
||||
{localError || `Failed to load ${normalizedTitle}`}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!rows.length && status === "succeeded" && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{!rows.length && localStatus === "succeeded" && (
|
||||
<Typography variant="body2" className="admin-resource-list__empty">
|
||||
{`No ${normalizedTitle} found.`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<List
|
||||
sx={{ bgcolor: "background.paper", borderRadius: 2, boxShadow: 1 }}
|
||||
>
|
||||
<div className="admin-resource-list__list">
|
||||
{rows.map(row => {
|
||||
const chips = getMetaChips(row);
|
||||
const secondary = getSecondaryDetails(row);
|
||||
|
||||
return (
|
||||
<Box key={row.id}>
|
||||
<ListItem alignItems="flex-start">
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
{getPrimaryLabel(row)}
|
||||
</Typography>
|
||||
<div key={row.id} className="admin-resource-list__row">
|
||||
<div className="admin-resource-list__row-content">
|
||||
<div className="admin-resource-list__row-container">
|
||||
<div className="admin-resource-list__row-header">
|
||||
{editingRowId === row.id ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
value={editingName}
|
||||
onChange={e => setEditingName(e.target.value)}
|
||||
onKeyDown={e => handleEditKeyDown(e, row)}
|
||||
autoFocus
|
||||
disabled={isSavingName}
|
||||
size="small"
|
||||
className="admin-resource-list__edit-field"
|
||||
variant="standard"
|
||||
label="Name"
|
||||
/>
|
||||
{row.rate !== undefined && (
|
||||
<TextField
|
||||
value={editingRate}
|
||||
onChange={e => setEditingRate(e.target.value)}
|
||||
onKeyDown={e => handleEditKeyDown(e, row)}
|
||||
disabled={isSavingName}
|
||||
size="small"
|
||||
className="admin-resource-list__edit-field"
|
||||
variant="standard"
|
||||
label="Rate"
|
||||
type="number"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
className="admin-resource-list__row-title"
|
||||
>
|
||||
{getPrimaryLabel(row)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="admin-resource-list__row-id"
|
||||
>
|
||||
ID: {row.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{chips.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
my: 1,
|
||||
}}
|
||||
>
|
||||
{chips.length > 0 && editingRowId !== row.id && (
|
||||
<div className="admin-resource-list__row-chips">
|
||||
{chips.map(chip => (
|
||||
<Chip
|
||||
key={`${row.id}-${chip.key}`}
|
||||
@ -265,25 +586,133 @@ const AdminResourceList = ({
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secondary.length > 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="admin-resource-list__row-secondary"
|
||||
>
|
||||
{secondary
|
||||
.map(([key, value]) => `${key}: ${String(value)}`)
|
||||
.join(" • ")}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ListItem>
|
||||
<Divider component="li" />
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{(addModalFields || showEnabledToggle) && (
|
||||
<div className="admin-resource-list__secondary-actions">
|
||||
{showEnabledToggle && (
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="admin-resource-list__enabled-label"
|
||||
>
|
||||
Enabled
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={Boolean(row.enabled)}
|
||||
onChange={e =>
|
||||
handleEnabledToggle(row, e.target.checked)
|
||||
}
|
||||
disabled={updatingEnabled.has(
|
||||
(row.identifier as string | number | undefined) ??
|
||||
row.id
|
||||
)}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{addModalFields && (
|
||||
<>
|
||||
{editingRowId === row.id ? (
|
||||
<>
|
||||
<Tooltip title="Save">
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label={`save ${normalizedTitle} name`}
|
||||
onClick={() => handleEditSave(row)}
|
||||
disabled={isSavingName}
|
||||
size="small"
|
||||
color="primary"
|
||||
>
|
||||
{isSavingName ? (
|
||||
<Spinner size="small" color="#1976d2" />
|
||||
) : (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Cancel">
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="cancel edit"
|
||||
onClick={handleEditCancel}
|
||||
disabled={isSavingName}
|
||||
size="small"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label={`edit ${normalizedTitle}`}
|
||||
onClick={() => handleEditClick(row)}
|
||||
size="small"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label={`delete ${normalizedTitle}`}
|
||||
onClick={() => handleDeleteClick(row)}
|
||||
size="small"
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-resource-list__divider" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{addModalFields && (
|
||||
<>
|
||||
<AddModal
|
||||
open={addModalOpen}
|
||||
onClose={handleAddModalClose}
|
||||
onConfirm={handleAddConfirm}
|
||||
resourceType={normalizedTitle}
|
||||
fields={addModalFields}
|
||||
isLoading={isAdding}
|
||||
error={addError}
|
||||
/>
|
||||
<DeleteModal
|
||||
open={deleteModalOpen}
|
||||
onClose={handleDeleteModalClose}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
resource={resourceToDelete}
|
||||
resourceType={normalizedTitle}
|
||||
isLoading={isDeleting}
|
||||
error={deleteError}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
146
app/features/AdminList/AdminResourceList.utils.ts
Normal file
146
app/features/AdminList/AdminResourceList.utils.ts
Normal file
@ -0,0 +1,146 @@
|
||||
export type TCreateResourcePayload = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Transforms create payload to convert string numbers to actual numbers
|
||||
* for fields that should be numeric (e.g., rate, limit, etc.)
|
||||
*/
|
||||
function transformCreatePayload(
|
||||
payload: TCreateResourcePayload
|
||||
): TCreateResourcePayload {
|
||||
const transformed: TCreateResourcePayload = { ...payload };
|
||||
|
||||
for (const [key, value] of Object.entries(transformed)) {
|
||||
// Convert string numbers to actual numbers for common numeric fields
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const numericValue = Number(value);
|
||||
// Only convert if it's a valid number and the key suggests it should be numeric
|
||||
if (
|
||||
!Number.isNaN(numericValue) &&
|
||||
(key.toLowerCase().includes("rate") ||
|
||||
key.toLowerCase().includes("price") ||
|
||||
key.toLowerCase().includes("amount") ||
|
||||
key.toLowerCase().includes("limit") ||
|
||||
key.toLowerCase().includes("count"))
|
||||
) {
|
||||
transformed[key] = numericValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
export async function createResourceApi(
|
||||
endpointBase: string,
|
||||
payload: TCreateResourcePayload,
|
||||
resourceName: string
|
||||
) {
|
||||
// Transform the payload to convert string numbers to actual numbers
|
||||
const transformedPayload = transformCreatePayload(payload);
|
||||
|
||||
const response = await fetch(`${endpointBase}/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(transformedPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ message: `Failed to create ${resourceName}` }));
|
||||
throw new Error(errorData?.message || `Failed to create ${resourceName}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteResourceApi(
|
||||
endpointBase: string,
|
||||
id: number | string,
|
||||
resourceName: string
|
||||
) {
|
||||
const response = await fetch(`${endpointBase}/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ message: `Failed to delete ${resourceName}` }));
|
||||
throw new Error(errorData?.message || `Failed to delete ${resourceName}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export type TUpdateResourcePayload = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Converts a key to PascalCase (e.g., "enabled" -> "Enabled", "first_name" -> "FirstName")
|
||||
*/
|
||||
function toPascalCase(key: string): string {
|
||||
return key
|
||||
.split("_")
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms frontend data to backend format
|
||||
* - data object uses lowercase keys (matching API response)
|
||||
* - fields array uses PascalCase (required by backend)
|
||||
*/
|
||||
function transformResourceUpdateData(updates: Record<string, unknown>): {
|
||||
data: Record<string, unknown>;
|
||||
fields: string[];
|
||||
} {
|
||||
const data: Record<string, unknown> = {};
|
||||
const fields: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
// Skip undefined/null values
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the key as-is for data (matching API response casing)
|
||||
data[key] = value;
|
||||
// Convert to PascalCase for fields array (required by backend)
|
||||
fields.push(toPascalCase(key));
|
||||
}
|
||||
|
||||
return { data, fields };
|
||||
}
|
||||
|
||||
export async function updateResourceApi(
|
||||
endpointBase: string,
|
||||
id: number | string,
|
||||
payload: TUpdateResourcePayload,
|
||||
resourceName: string
|
||||
) {
|
||||
// Transform the payload to match backend format
|
||||
const transformedPayload = transformResourceUpdateData(payload);
|
||||
|
||||
const response = await fetch(`${endpointBase}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(transformedPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ message: `Failed to update ${resourceName}` }));
|
||||
throw new Error(errorData?.message || `Failed to update ${resourceName}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@ -13,59 +13,3 @@ export const formatPercentage = (
|
||||
if (isNaN(numValue)) return "0%";
|
||||
return `${numValue.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
interface IHealthData {
|
||||
total?: number;
|
||||
successful?: number;
|
||||
acceptance_rate?: number;
|
||||
amount?: number;
|
||||
atv?: number;
|
||||
}
|
||||
|
||||
interface IStatItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
change: string;
|
||||
}
|
||||
|
||||
export const transformHealthDataToStats = (
|
||||
healthData: IHealthData | null
|
||||
): IStatItem[] => {
|
||||
if (!healthData) {
|
||||
return [
|
||||
{ label: "TOTAL", value: 0, change: "0%" },
|
||||
{ label: "SUCCESSFUL", value: 0, change: "0%" },
|
||||
{ label: "ACCEPTANCE RATE", value: "0%", change: "0%" },
|
||||
{ label: "AMOUNT", value: "€0.00", change: "0%" },
|
||||
{ label: "ATV", value: "€0.00", change: "0%" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: "TOTAL",
|
||||
value: healthData.total ?? 0,
|
||||
change: "0%",
|
||||
},
|
||||
{
|
||||
label: "SUCCESSFUL",
|
||||
value: healthData.successful ?? 0,
|
||||
change: "0%",
|
||||
},
|
||||
{
|
||||
label: "ACCEPTANCE RATE",
|
||||
value: formatPercentage(healthData.acceptance_rate),
|
||||
change: "0%",
|
||||
},
|
||||
{
|
||||
label: "AMOUNT",
|
||||
value: formatCurrency(healthData.amount),
|
||||
change: "0%",
|
||||
},
|
||||
{
|
||||
label: "ATV",
|
||||
value: formatCurrency(healthData.atv),
|
||||
change: "0%",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -17,8 +17,6 @@ const Users: React.FC<UsersProps> = ({ users }) => {
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
// console.log("[Users] - users", users);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserTopBar
|
||||
|
||||
@ -7,14 +7,9 @@ import { Box, Button, IconButton, Paper, Typography } from "@mui/material";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import { type ITransactionsOverviewData } from "@/app/services/types";
|
||||
import { dashboardService } from "@/app/services/dashboardService";
|
||||
import {
|
||||
transformOverviewResponse,
|
||||
calculatePercentages,
|
||||
enrichOverviewData,
|
||||
} from "../TransactionsOverview/utils";
|
||||
import { TransactionsOverViewTable } from "./components/TransactionsOverViewTable";
|
||||
import PieCharts from "../Charts/PieCharts";
|
||||
import "./TransactionsOverView.scss";
|
||||
import "./TransactionsOverview.scss";
|
||||
|
||||
interface ITransactionsOverviewProps {
|
||||
initialOverviewData?: ITransactionsOverviewData | null;
|
||||
@ -38,7 +33,9 @@ export const TransactionsOverview = ({
|
||||
const subscription = dashboardService
|
||||
.getDashboardData$()
|
||||
.subscribe(data => {
|
||||
// console.log("[data]", data);
|
||||
if (data?.overviewData) {
|
||||
console.log("[OverViewData] - Subscribe", data);
|
||||
setOverviewData(data.overviewData);
|
||||
}
|
||||
});
|
||||
@ -47,25 +44,11 @@ export const TransactionsOverview = ({
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
console.log("[OverviewData] - Component", overviewData);
|
||||
/**
|
||||
* Transform overview data from flat object to array format
|
||||
* Use transformed data from API (already includes percentages and colors)
|
||||
*/
|
||||
const transformedData = overviewData
|
||||
? transformOverviewResponse({
|
||||
cancelled: overviewData.cancelled,
|
||||
failed: overviewData.failed,
|
||||
successful: overviewData.successful,
|
||||
waiting: overviewData.waiting,
|
||||
})
|
||||
: [];
|
||||
|
||||
/**
|
||||
* Calculate percentages and enrich with colors
|
||||
*/
|
||||
const enrichedData =
|
||||
transformedData.length > 0
|
||||
? enrichOverviewData(calculatePercentages(transformedData))
|
||||
: [];
|
||||
const enrichedData = overviewData?.data || [];
|
||||
|
||||
/**
|
||||
* Transform overview data for PieCharts (expects { name, value }[])
|
||||
@ -75,11 +58,6 @@ export const TransactionsOverview = ({
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Transform overview data for table (expects { state, count, percentage, color }[])
|
||||
*/
|
||||
const tableData = enrichedData;
|
||||
|
||||
return (
|
||||
<Paper className="transaction-overview" elevation={3}>
|
||||
<Box className="transaction-overview__header">
|
||||
@ -103,7 +81,7 @@ export const TransactionsOverview = ({
|
||||
{overviewData && (
|
||||
<Box className="transaction-overview__chart-table">
|
||||
<PieCharts data={pieChartData} />
|
||||
<TransactionsOverViewTable data={tableData} />
|
||||
<TransactionsOverViewTable data={overviewData?.data || []} />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
@ -33,6 +33,8 @@ const defaultData: ITableData[] = [
|
||||
export const TransactionsOverViewTable = ({
|
||||
data = defaultData,
|
||||
}: ITransactionsOverViewTableProps) => {
|
||||
console.log("data", data);
|
||||
|
||||
return (
|
||||
<TableContainer className="transactions-overview-table" component={Paper}>
|
||||
<Table>
|
||||
|
||||
@ -23,33 +23,6 @@ export const getStateColor = (state: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform flat API overview response to array format
|
||||
*/
|
||||
export const transformOverviewResponse = (data: {
|
||||
cancelled?: number;
|
||||
failed?: number;
|
||||
successful?: number;
|
||||
waiting?: number;
|
||||
}): Array<{
|
||||
state: string;
|
||||
count: number;
|
||||
}> => {
|
||||
const states = [
|
||||
{ key: "successful", label: "Successful" },
|
||||
{ key: "waiting", label: "Waiting" },
|
||||
{ key: "failed", label: "Failed" },
|
||||
{ key: "cancelled", label: "Cancelled" },
|
||||
];
|
||||
|
||||
return states
|
||||
.map(({ key, label }) => ({
|
||||
state: label,
|
||||
count: data[key as keyof typeof data] || 0,
|
||||
}))
|
||||
.filter(item => item.count > 0); // Only include states with counts > 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate percentage for each state
|
||||
*/
|
||||
|
||||
@ -89,23 +89,28 @@ const EditUser = ({ user }: { user: IUser }) => {
|
||||
// Check if this was a toggle-only update
|
||||
const isToggle = !e || ("enabled" in e && Object.keys(e).length === 1);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
let updates: Record<string, unknown> = {};
|
||||
|
||||
// Compare form fields vs original user object
|
||||
Object.entries(form).forEach(([key, value]) => {
|
||||
const originalValue = (user as unknown as Record<string, unknown>)[key];
|
||||
// Only include changed values and skip empty ones
|
||||
if (
|
||||
value !== "" &&
|
||||
JSON.stringify(value) !== JSON.stringify(originalValue)
|
||||
) {
|
||||
updates[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle enabled toggle separately
|
||||
// If toggle, send entire user object with updated enabled field (excluding id)
|
||||
if (isToggle) {
|
||||
updates.enabled = !(enabled ?? true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id, ...rest } = user;
|
||||
updates = {
|
||||
...rest,
|
||||
enabled: !(enabled ?? true),
|
||||
};
|
||||
} else {
|
||||
// Compare form fields vs original user object
|
||||
Object.entries(form).forEach(([key, value]) => {
|
||||
const originalValue = (user as unknown as Record<string, unknown>)[key];
|
||||
// Only include changed values and skip empty ones
|
||||
if (
|
||||
value !== "" &&
|
||||
JSON.stringify(value) !== JSON.stringify(originalValue)
|
||||
) {
|
||||
updates[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Nothing changed — no need to call API
|
||||
@ -114,7 +119,6 @@ const EditUser = ({ user }: { user: IUser }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[handleUpdate] - updates", updates);
|
||||
try {
|
||||
const resultAction = await dispatch(
|
||||
updateUserDetails({ id: user.id, updates })
|
||||
|
||||
@ -8,7 +8,7 @@ import "../styles/globals.scss";
|
||||
import Modals from "./modals";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your App",
|
||||
title: "Cashier BO",
|
||||
description: "Generated by Next.js",
|
||||
};
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ const initialState: AdvancedSearchState = {
|
||||
filters: {},
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
limit: 100,
|
||||
},
|
||||
status: "idle",
|
||||
error: null,
|
||||
|
||||
@ -16,7 +16,7 @@ export const selectPaginationModel = createSelector(
|
||||
[selectPagination],
|
||||
pagination => ({
|
||||
page: Math.max(0, (pagination.page ?? 1) - 1),
|
||||
pageSize: pagination.limit ?? 10,
|
||||
pageSize: pagination.limit ?? 100,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@ -37,8 +37,6 @@ export const addUser = createAsyncThunk<
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
console.log("[DEBUG] [ADD-USER] [data]: ", data);
|
||||
|
||||
if (!res.ok) {
|
||||
return rejectWithValue(data.message || "Failed to create user");
|
||||
}
|
||||
|
||||
71
app/services/adminResources.ts
Normal file
71
app/services/adminResources.ts
Normal file
@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
BE_BASE_URL,
|
||||
REVALIDATE_SECONDS,
|
||||
getAdminResourceCacheTag,
|
||||
} from "./constants";
|
||||
import { buildFilterParam } from "@/app/api/dashboard/admin/utils";
|
||||
|
||||
export interface IFetchAdminResourceParams {
|
||||
resource: string;
|
||||
filters?: Record<string, unknown>;
|
||||
pagination?: { page: number; limit: number };
|
||||
sort?: { field: string; order: "asc" | "desc" };
|
||||
}
|
||||
|
||||
export async function fetchAdminResource({
|
||||
resource,
|
||||
filters = {},
|
||||
pagination = { page: 1, limit: 100 },
|
||||
sort,
|
||||
}: IFetchAdminResourceParams) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
throw new Error("Missing auth token");
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("limit", String(pagination.limit ?? 100));
|
||||
queryParams.set("page", String(pagination.page ?? 1));
|
||||
|
||||
if (sort?.field && sort?.order) {
|
||||
queryParams.set("sort", `${sort.field}:${sort.order}`);
|
||||
}
|
||||
|
||||
const filterParam = buildFilterParam(
|
||||
filters as Record<string, string | { operator?: string; value: string }>
|
||||
);
|
||||
if (filterParam) {
|
||||
queryParams.set("filter", filterParam);
|
||||
}
|
||||
|
||||
const backendUrl = `${BE_BASE_URL}/api/v1/${resource}${
|
||||
queryParams.size ? `?${queryParams.toString()}` : ""
|
||||
}`;
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
next: {
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: [getAdminResourceCacheTag(resource)],
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ message: `Failed to fetch ${resource}` }));
|
||||
throw new Error(errorData?.message || `Failed to fetch ${resource}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@ -3,6 +3,11 @@ export const USERS_CACHE_TAG = "users";
|
||||
export const HEALTH_CACHE_TAG = "health";
|
||||
export const REVALIDATE_SECONDS = 100;
|
||||
|
||||
// Admin resource cache tags
|
||||
export const ADMIN_RESOURCE_CACHE_TAG_PREFIX = "admin-resource";
|
||||
export const getAdminResourceCacheTag = (resource: string) =>
|
||||
`${ADMIN_RESOURCE_CACHE_TAG_PREFIX}-${resource}`;
|
||||
|
||||
export const BE_BASE_URL = process.env.BE_BASE_URL || "";
|
||||
export const AUTH_COOKIE_NAME = "auth_token";
|
||||
|
||||
|
||||
@ -93,10 +93,10 @@ export async function fetchDashboardDataService({
|
||||
// Handle overview data response
|
||||
let overviewData: ITransactionsOverviewData = {
|
||||
success: false,
|
||||
cancelled: 0,
|
||||
failed: 0,
|
||||
successful: 0,
|
||||
waiting: 0,
|
||||
successful_count: 0,
|
||||
waiting_count: 0,
|
||||
failed_count: 0,
|
||||
cancelled_count: 0,
|
||||
};
|
||||
|
||||
if (!overviewResponse.ok) {
|
||||
|
||||
@ -62,10 +62,10 @@ export async function getDashboardData({
|
||||
healthData: healthDataWithStats as IHealthData,
|
||||
overviewData: overviewData || {
|
||||
success: false,
|
||||
cancelled: 0,
|
||||
failed: 0,
|
||||
successful: 0,
|
||||
waiting: 0,
|
||||
successful_count: 0,
|
||||
waiting_count: 0,
|
||||
failed_count: 0,
|
||||
cancelled_count: 0,
|
||||
},
|
||||
reviewTransactions: reviewTransactions || {
|
||||
success: false,
|
||||
|
||||
@ -21,10 +21,14 @@ export interface IFetchHealthDataParams {
|
||||
export interface ITransactionsOverviewData {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
cancelled?: number;
|
||||
failed?: number;
|
||||
successful?: number;
|
||||
waiting?: number;
|
||||
successful_count?: number;
|
||||
waiting_count?: number;
|
||||
failed_count?: number;
|
||||
cancelled_count?: number;
|
||||
successful_ratio?: number;
|
||||
waiting_ratio?: number;
|
||||
failed_ratio?: number;
|
||||
cancelled_ratio?: number;
|
||||
data?: Array<{
|
||||
state: string;
|
||||
count: number;
|
||||
|
||||
15
app/utils/formatCurrency.ts
Normal file
15
app/utils/formatCurrency.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const formatCurrency = (value: number | string | undefined): string => {
|
||||
if (value === undefined || value === null) return "€0.00";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(numValue)) return "€0.00";
|
||||
return `€${numValue.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const formatPercentage = (
|
||||
value: number | string | undefined
|
||||
): string => {
|
||||
if (value === undefined || value === null) return "0%";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(numValue)) return "0%";
|
||||
return `${numValue.toFixed(2)}%`;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user