feat/build-branch #4
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { decodeJwt } from "jose";
|
import { decodeJwt } from "jose";
|
||||||
|
import { JWTPayload } from "@/app/utils/auth";
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@ -11,7 +10,7 @@ export async function POST(request: Request) {
|
|||||||
// Get the auth token from cookies first
|
// Get the auth token from cookies first
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = cookies();
|
const cookieStore = cookies();
|
||||||
const token = (await cookieStore).get(COOKIE_NAME)?.value;
|
const token = (await cookieStore).get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -23,15 +22,8 @@ export async function POST(request: Request) {
|
|||||||
// Check MustChangePassword flag from current token
|
// Check MustChangePassword flag from current token
|
||||||
let mustChangePassword = false;
|
let mustChangePassword = false;
|
||||||
try {
|
try {
|
||||||
const payload = decodeJwt(token);
|
const payload = decodeJwt<JWTPayload>(token);
|
||||||
const mustChangeClaim = payload.MustChangePassword;
|
mustChangePassword = payload.MustChangePassword;
|
||||||
if (typeof mustChangeClaim === "boolean") {
|
|
||||||
mustChangePassword = mustChangeClaim;
|
|
||||||
} else if (typeof mustChangeClaim === "string") {
|
|
||||||
mustChangePassword = mustChangeClaim.toLowerCase() === "true";
|
|
||||||
} else {
|
|
||||||
mustChangePassword = false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Failed to decode current JWT:", err);
|
console.error("❌ Failed to decode current JWT:", err);
|
||||||
}
|
}
|
||||||
@ -91,7 +83,7 @@ export async function POST(request: Request) {
|
|||||||
// Derive maxAge from JWT exp if available; fallback to 12h
|
// Derive maxAge from JWT exp if available; fallback to 12h
|
||||||
let maxAge = 60 * 60 * 12;
|
let maxAge = 60 * 60 * 12;
|
||||||
try {
|
try {
|
||||||
const payload = decodeJwt(newToken);
|
const payload = decodeJwt<JWTPayload>(newToken);
|
||||||
if (payload?.exp) {
|
if (payload?.exp) {
|
||||||
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
|
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
|
||||||
if (secondsLeft > 0) maxAge = secondsLeft;
|
if (secondsLeft > 0) maxAge = secondsLeft;
|
||||||
@ -99,7 +91,7 @@ export async function POST(request: Request) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
response.cookies.set({
|
response.cookies.set({
|
||||||
name: COOKIE_NAME,
|
name: AUTH_COOKIE_NAME,
|
||||||
value: newToken,
|
value: newToken,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { decodeJwt } from "jose";
|
import { decodeJwt } from "jose";
|
||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
import { JWTPayload } from "@/app/utils/auth";
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@ -16,8 +15,6 @@ export async function POST(request: Request) {
|
|||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[LOGIN] resp", resp);
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const errJson = await safeJson(resp);
|
const errJson = await safeJson(resp);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -41,7 +38,7 @@ export async function POST(request: Request) {
|
|||||||
let maxAge = 60 * 60 * 12; // fallback to 12h
|
let maxAge = 60 * 60 * 12; // fallback to 12h
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = decodeJwt(token);
|
const payload = decodeJwt<JWTPayload>(token);
|
||||||
|
|
||||||
// Extract exp if present
|
// Extract exp if present
|
||||||
if (payload?.exp) {
|
if (payload?.exp) {
|
||||||
@ -59,7 +56,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// Set the cookie
|
// Set the cookie
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(COOKIE_NAME, token, {
|
cookieStore.set(AUTH_COOKIE_NAME, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:3000";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
@ -23,6 +21,6 @@ export async function DELETE() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieStore.delete(COOKIE_NAME);
|
cookieStore.delete(AUTH_COOKIE_NAME);
|
||||||
return NextResponse.json({ success: true, message: "Logged out" });
|
return NextResponse.json({ success: true, message: "Logged out" });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
|
import { revalidateTag } from "next/cache";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUTH_COOKIE_NAME,
|
||||||
|
BE_BASE_URL,
|
||||||
|
USERS_CACHE_TAG,
|
||||||
|
} from "@/app/services/constants";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
// Interface matching the backend RegisterRequest and frontend IEditUserForm
|
// Interface matching the backend RegisterRequest and frontend IEditUserForm
|
||||||
interface RegisterRequest {
|
interface RegisterRequest {
|
||||||
creator: string;
|
creator: string;
|
||||||
@ -23,7 +27,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// Get the auth token from cookies
|
// Get the auth token from cookies
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -61,6 +65,8 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
|
revalidateTag(USERS_CACHE_TAG);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
@ -21,7 +19,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ valid: false }, { status: 401 });
|
return NextResponse.json({ valid: false }, { status: 401 });
|
||||||
|
|||||||
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,14 +1,38 @@
|
|||||||
|
import {
|
||||||
|
AUTH_COOKIE_NAME,
|
||||||
|
BE_BASE_URL,
|
||||||
|
REVALIDATE_SECONDS,
|
||||||
|
getAdminResourceCacheTag,
|
||||||
|
} from "@/app/services/constants";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { buildFilterParam } from "../utils";
|
import { buildFilterParam } from "../utils";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
const ALLOWED_RESOURCES = [
|
||||||
const COOKIE_NAME = "auth_token";
|
"groups",
|
||||||
|
"currencies",
|
||||||
|
"permissions",
|
||||||
|
"merchants",
|
||||||
|
"sessions",
|
||||||
|
"users",
|
||||||
|
];
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ resource: string }> }
|
||||||
|
) {
|
||||||
try {
|
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 { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -18,10 +42,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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();
|
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));
|
queryParams.set("page", String(pagination.page ?? 1));
|
||||||
|
|
||||||
if (sort?.field && sort?.order) {
|
if (sort?.field && sort?.order) {
|
||||||
@ -33,7 +57,7 @@ export async function POST(request: NextRequest) {
|
|||||||
queryParams.set("filter", filterParam);
|
queryParams.set("filter", filterParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendUrl = `${BE_BASE_URL}/api/v1/groups${
|
const backendUrl = `${BE_BASE_URL}/api/v1/${resource}${
|
||||||
queryParams.size ? `?${queryParams.toString()}` : ""
|
queryParams.size ? `?${queryParams.toString()}` : ""
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
@ -43,18 +67,21 @@ export async function POST(request: NextRequest) {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
next: {
|
||||||
|
revalidate: REVALIDATE_SECONDS,
|
||||||
|
tags: [getAdminResourceCacheTag(resource)],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response
|
const errorData = await response
|
||||||
.json()
|
.json()
|
||||||
.catch(() => ({ message: "Failed to fetch groups" }));
|
.catch(() => ({ message: `Failed to fetch ${resource}` }));
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
message: errorData?.message || "Failed to fetch groups",
|
message: errorData?.message || `Failed to fetch ${resource}`,
|
||||||
},
|
},
|
||||||
{ status: response.status }
|
{ status: response.status }
|
||||||
);
|
);
|
||||||
@ -63,7 +90,17 @@ export async function POST(request: NextRequest) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return NextResponse.json(data, { status: response.status });
|
return NextResponse.json(data, { status: response.status });
|
||||||
} catch (err: unknown) {
|
} 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";
|
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Internal server error", error: errorMessage },
|
{ message: "Internal server error", error: errorMessage },
|
||||||
@ -1,14 +1,12 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { buildFilterParam } from "../utils";
|
import { buildFilterParam } from "../utils";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -18,10 +16,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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();
|
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));
|
queryParams.set("page", String(pagination.page ?? 1));
|
||||||
|
|
||||||
if (sort?.field && sort?.order) {
|
if (sort?.field && sort?.order) {
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { buildFilterParam } from "../utils";
|
import { buildFilterParam } from "../utils";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -18,10 +16,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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();
|
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));
|
queryParams.set("page", String(pagination.page ?? 1));
|
||||||
|
|
||||||
if (sort?.field && sort?.order) {
|
if (sort?.field && sort?.order) {
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
// app/api/users/[id]/route.ts
|
// app/api/users/[id]/route.ts
|
||||||
import { NextResponse } from "next/server";
|
import { revalidateTag } from "next/cache";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
import {
|
||||||
const COOKIE_NAME = "auth_token";
|
AUTH_COOKIE_NAME,
|
||||||
|
BE_BASE_URL,
|
||||||
|
USERS_CACHE_TAG,
|
||||||
|
} from "@/app/services/constants";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
// Field mapping: snake_case input -> { snake_case for data, PascalCase for fields }
|
// Field mapping: snake_case input -> { snake_case for data, PascalCase for fields }
|
||||||
// Matches API metadata field_names.users mapping
|
// Matches API metadata field_names.users mapping
|
||||||
@ -75,7 +79,7 @@ export async function PUT(
|
|||||||
// Get the auth token from cookies
|
// Get the auth token from cookies
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -85,18 +89,27 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// According to swagger: /api/v1/users/{id}
|
// 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",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(transformedBody),
|
body: JSON.stringify(transformedBody),
|
||||||
});
|
};
|
||||||
|
console.log("request", { url: requestUrl, ...requestConfig });
|
||||||
|
|
||||||
|
const response = await fetch(requestUrl, requestConfig);
|
||||||
|
|
||||||
const data = await response.json();
|
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 });
|
return NextResponse.json(data, { status: response.status });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
console.error("Proxy PUT /api/v1/users/{id} error:", err);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err instanceof Error ? err.message : "Unknown error occurred";
|
err instanceof Error ? err.message : "Unknown error occurred";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -114,7 +127,7 @@ export async function DELETE(
|
|||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -139,6 +152,10 @@ export async function DELETE(
|
|||||||
data = { success: response.ok };
|
data = { success: response.ok };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
revalidateTag(USERS_CACHE_TAG);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(data ?? { success: response.ok }, {
|
return NextResponse.json(data ?? { success: response.ok }, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -29,6 +27,7 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
return NextResponse.json(data, { status: response.status });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("Proxy GET /api/v1/users/list error:", err);
|
console.error("Proxy GET /api/v1/users/list error:", err);
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const AUDITS_BASE_URL =
|
|
||||||
process.env.AUDITS_BASE_URL ||
|
|
||||||
process.env.BE_BASE_URL ||
|
|
||||||
"http://localhost:8583";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
const DEFAULT_LIMIT = "25";
|
const DEFAULT_LIMIT = "25";
|
||||||
const DEFAULT_PAGE = "1";
|
const DEFAULT_PAGE = "1";
|
||||||
|
|
||||||
@ -13,7 +8,7 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -38,7 +33,7 @@ export async function GET(request: NextRequest) {
|
|||||||
proxiedParams.set("page", DEFAULT_PAGE);
|
proxiedParams.set("page", DEFAULT_PAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendUrl = `${AUDITS_BASE_URL}/api/v1/audit${
|
const backendUrl = `${BE_BASE_URL}/api/v1/audit${
|
||||||
proxiedParams.size ? `?${proxiedParams.toString()}` : ""
|
proxiedParams.size ? `?${proxiedParams.toString()}` : ""
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
@ -48,7 +43,10 @@ export async function GET(request: NextRequest) {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
next: {
|
||||||
|
revalidate: 60,
|
||||||
|
tags: ["audits"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -65,11 +63,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("[AUDITS] data:", data);
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
return NextResponse.json(data, { status: response.status });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log("[AUDITS] error:", err);
|
|
||||||
|
|
||||||
console.error("Proxy GET /api/v1/audits error:", err);
|
console.error("Proxy GET /api/v1/audits error:", err);
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
42
app/api/dashboard/route.ts
Normal file
42
app/api/dashboard/route.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { fetchDashboardDataService } from "@/app/services/health";
|
||||||
|
import {
|
||||||
|
transformHealthDataToStats,
|
||||||
|
transformOverviewResponse,
|
||||||
|
} from "./utils/dashboard";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const dateStart = searchParams.get("dateStart") ?? undefined;
|
||||||
|
const dateEnd = searchParams.get("dateEnd") ?? undefined;
|
||||||
|
|
||||||
|
// Fetch all dashboard data (health, overview, and review transactions) concurrently
|
||||||
|
const dashboardData = await fetchDashboardDataService({
|
||||||
|
dateStart,
|
||||||
|
dateEnd,
|
||||||
|
});
|
||||||
|
const { healthData, overviewData, reviewTransactions } = dashboardData;
|
||||||
|
// Transform health data to stats format using shared util
|
||||||
|
const stats = transformHealthDataToStats(healthData);
|
||||||
|
const transformedOverviewData = transformOverviewResponse(overviewData);
|
||||||
|
// console.log("[TransformedOverviewData] - Route", transformedOverviewData);
|
||||||
|
const response = {
|
||||||
|
...healthData,
|
||||||
|
stats,
|
||||||
|
overviewData: {
|
||||||
|
...overviewData,
|
||||||
|
data: transformedOverviewData,
|
||||||
|
},
|
||||||
|
reviewTransactions: reviewTransactions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, { status: 200 });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: errorMessage },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
@ -11,7 +9,7 @@ export async function PUT(
|
|||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -32,7 +30,6 @@ export async function PUT(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data = await upstream.json();
|
const data = await upstream.json();
|
||||||
console.log("[DEBUG] [TRANSACTIONS] [PUT] Response data:", data);
|
|
||||||
return NextResponse.json(data, { status: upstream.status });
|
return NextResponse.json(data, { status: upstream.status });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
type FilterValue =
|
type FilterValue =
|
||||||
| string
|
| string
|
||||||
@ -13,7 +12,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -100,8 +98,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const queryString = queryParts.join("&");
|
const queryString = queryParts.join("&");
|
||||||
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
|
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
||||||
console.log("[DEBUG] [TRANSACTIONS] Backend URL:", backendUrl);
|
console.log("[Transactions] - backendUrl", backendUrl);
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
const response = await fetch(backendUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@ -125,7 +122,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("[DEBUG] [TRANSACTIONS] Response data:", data);
|
|
||||||
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
return NextResponse.json(data, { status: response.status });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@ -1,243 +0,0 @@
|
|||||||
import { GridColDef } from "@mui/x-data-grid";
|
|
||||||
|
|
||||||
export const withdrawalTransactionDummyData = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
userId: 17,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Error",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-17 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
manualCorrectionFlag: "-",
|
|
||||||
informationWhoApproved: "-",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
userId: 17,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Error",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-17 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
manualCorrectionFlag: "-",
|
|
||||||
informationWhoApproved: "-",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
userId: 17,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Completed",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-18 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
userId: 19,
|
|
||||||
transactionId: 1049136973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Completed",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-18 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
userId: 19,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Error",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-17 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
manualCorrectionFlag: "-",
|
|
||||||
informationWhoApproved: "-",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
userId: 27,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Error",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-17 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
manualCorrectionFlag: "-",
|
|
||||||
informationWhoApproved: "-",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
userId: 1,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Error",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-17 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
manualCorrectionFlag: "-",
|
|
||||||
informationWhoApproved: "-",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
userId: 172,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Card",
|
|
||||||
status: "Pending",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-12 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
userId: 174,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Inprogress",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-17 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
userId: 1,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Error",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-17 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
manualCorrectionFlag: "-",
|
|
||||||
informationWhoApproved: "-",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
userId: 1,
|
|
||||||
transactionId: 1049131973,
|
|
||||||
withdrawalMethod: "Bank Transfer",
|
|
||||||
status: "Error",
|
|
||||||
options: [
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Completed", label: "Completed" },
|
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
|
||||||
{ value: "Error", label: "Error" },
|
|
||||||
],
|
|
||||||
amount: 4000,
|
|
||||||
dateTime: "2025-06-17 10:10:30",
|
|
||||||
errorInfo: "-",
|
|
||||||
fraudScore: "frad score 1234",
|
|
||||||
manualCorrectionFlag: "-",
|
|
||||||
informationWhoApproved: "-",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const withdrawalTransactionsColumns: GridColDef[] = [
|
|
||||||
{ field: "userId", headerName: "User ID", width: 130 },
|
|
||||||
{ field: "transactionId", headerName: "Transaction ID", width: 130 },
|
|
||||||
{ field: "withdrawalMethod", headerName: "Withdrawal Method", width: 130 },
|
|
||||||
{ field: "status", headerName: "Status", width: 130 },
|
|
||||||
{ field: "actions", headerName: "Actions", width: 150 },
|
|
||||||
{ field: "amount", headerName: "Amount", width: 130 },
|
|
||||||
{ field: "dateTime", headerName: "Date / Time", width: 130 },
|
|
||||||
{ field: "errorInfo", headerName: "Error Info", width: 130 },
|
|
||||||
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
|
|
||||||
{
|
|
||||||
field: "manualCorrectionFlag",
|
|
||||||
headerName: "Manual Correction Flag",
|
|
||||||
width: 130,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "informationWhoApproved",
|
|
||||||
headerName: "Information who approved",
|
|
||||||
width: 130,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const withdrawalTransactionsSearchLabels = [
|
|
||||||
{
|
|
||||||
label: "Status",
|
|
||||||
field: "status",
|
|
||||||
type: "select",
|
|
||||||
options: ["Pending", "Inprogress", "Completed", "Failed"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Payment Method",
|
|
||||||
field: "depositMethod",
|
|
||||||
type: "select",
|
|
||||||
options: ["Card", "Bank Transfer"],
|
|
||||||
},
|
|
||||||
{ label: "Date / Time", field: "dateTime", type: "date" },
|
|
||||||
];
|
|
||||||
@ -1,67 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import {
|
|
||||||
withdrawalTransactionDummyData,
|
|
||||||
withdrawalTransactionsColumns,
|
|
||||||
withdrawalTransactionsSearchLabels,
|
|
||||||
} from "./mockData";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
/**
|
||||||
const { searchParams } = new URL(request.url);
|
* Placeholder Whitdrawal API route.
|
||||||
|
* Keeps the module valid while the real implementation
|
||||||
const userId = searchParams.get("userId");
|
* is being built, and makes the intent obvious to clients.
|
||||||
const status = searchParams.get("status");
|
*/
|
||||||
const withdrawalMethod = searchParams.get("withdrawalMethod");
|
export async function GET() {
|
||||||
|
|
||||||
const dateTimeStart = searchParams.get("dateTime_start");
|
|
||||||
const dateTimeEnd = searchParams.get("dateTime_end");
|
|
||||||
|
|
||||||
let filteredTransactions = [...withdrawalTransactionDummyData];
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
filteredTransactions = filteredTransactions.filter(
|
|
||||||
tx => tx.userId.toString() === userId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
filteredTransactions = filteredTransactions.filter(
|
|
||||||
tx => tx.status.toLowerCase() === status.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateTimeStart && dateTimeEnd) {
|
|
||||||
const start = new Date(dateTimeStart);
|
|
||||||
const end = new Date(dateTimeEnd);
|
|
||||||
|
|
||||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ message: "Settings endpoint not implemented" },
|
||||||
error: "Invalid date range",
|
{ status: 501 }
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredTransactions = filteredTransactions.filter(tx => {
|
|
||||||
const txDate = new Date(tx.dateTime);
|
|
||||||
|
|
||||||
if (isNaN(txDate.getTime())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return txDate >= start && txDate <= end;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withdrawalMethod) {
|
|
||||||
filteredTransactions = filteredTransactions.filter(
|
|
||||||
tx => tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
tableRows: filteredTransactions,
|
|
||||||
tableSearchLabels: withdrawalTransactionsSearchLabels,
|
|
||||||
tableColumns: withdrawalTransactionsColumns,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
|
import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
|
|
||||||
type FilterValue =
|
type FilterValue =
|
||||||
| string
|
| string
|
||||||
@ -13,7 +12,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
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);
|
||||||
|
};
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BE_BASE_URL } from "@/app/services/constants";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
// Proxy to backend metadata endpoint. Assumes BACKEND_BASE_URL is set.
|
// Proxy to backend metadata endpoint. Assumes BACKEND_BASE_URL is set.
|
||||||
@ -5,7 +6,6 @@ export async function GET() {
|
|||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get("auth_token")?.value;
|
const token = cookieStore.get("auth_token")?.value;
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@ -15,7 +15,6 @@ interface IPageLinksProps extends ISidebarLink {
|
|||||||
// PageLinks component
|
// PageLinks component
|
||||||
export default function PageLinks({ title, path, icon }: IPageLinksProps) {
|
export default function PageLinks({ title, path, icon }: IPageLinksProps) {
|
||||||
const Icon = resolveIcon(icon);
|
const Icon = resolveIcon(icon);
|
||||||
console.log("Icon", Icon);
|
|
||||||
return (
|
return (
|
||||||
<Link href={path} className={clsx("page-link", "page-link__container")}>
|
<Link href={path} className={clsx("page-link", "page-link__container")}>
|
||||||
{Icon && <Icon />}
|
{Icon && <Icon />}
|
||||||
|
|||||||
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 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 (
|
return (
|
||||||
<AdminResourceList
|
<AdminResourceList
|
||||||
title="Groups"
|
title="Groups"
|
||||||
@ -8,6 +23,9 @@ export default function GroupsPage() {
|
|||||||
responseCollectionKeys={["groups", "data", "items"]}
|
responseCollectionKeys={["groups", "data", "items"]}
|
||||||
primaryLabelKeys={["name", "groupName", "title"]}
|
primaryLabelKeys={["name", "groupName", "title"]}
|
||||||
chipKeys={["status", "role", "permissions"]}
|
chipKeys={["status", "role", "permissions"]}
|
||||||
|
addModalFields={GROUP_FIELDS as IAddModalField[]}
|
||||||
|
showEnabledToggle={true}
|
||||||
|
initialData={initialData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
55
app/dashboard/admin/matcher/MatcherBoard/DraggableItem.tsx
Normal file
55
app/dashboard/admin/matcher/MatcherBoard/DraggableItem.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Card, CardContent, Typography } from "@mui/material";
|
||||||
|
import { DraggableItemProps } from "../types";
|
||||||
|
|
||||||
|
export function DraggableItem({ item, isDragging }: DraggableItemProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
isDragging: isItemDragging,
|
||||||
|
} = useDraggable({
|
||||||
|
id: item.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
opacity: isItemDragging || isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="matcher-board__item"
|
||||||
|
sx={{
|
||||||
|
mb: 1,
|
||||||
|
cursor: "grab",
|
||||||
|
"&:active": {
|
||||||
|
cursor: "grabbing",
|
||||||
|
},
|
||||||
|
"&:hover": {
|
||||||
|
boxShadow: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 1.5, "&:last-child": { pb: 1.5 } }}>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{item.name}
|
||||||
|
</Typography>
|
||||||
|
{item.id && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
ID: {item.id}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
app/dashboard/admin/matcher/MatcherBoard/DroppableColumn.tsx
Normal file
80
app/dashboard/admin/matcher/MatcherBoard/DroppableColumn.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import { Box, Typography, Chip } from "@mui/material";
|
||||||
|
import { DroppableColumnProps } from "../types";
|
||||||
|
import { DraggableItem } from "./DraggableItem";
|
||||||
|
|
||||||
|
export function DroppableColumn({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
activeId,
|
||||||
|
}: DroppableColumnProps) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={setNodeRef}
|
||||||
|
className="matcher-board__column"
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 300,
|
||||||
|
maxWidth: 400,
|
||||||
|
bgcolor: isOver ? "action.hover" : "background.paper",
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 2,
|
||||||
|
border: "2px solid",
|
||||||
|
borderColor: isOver ? "primary.main" : "divider",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
className="matcher-board__column-content"
|
||||||
|
sx={{
|
||||||
|
minHeight: 400,
|
||||||
|
maxHeight: 600,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map(item => (
|
||||||
|
<DraggableItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isDragging={activeId === item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 200,
|
||||||
|
border: "2px dashed",
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Drop items here
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={`${items.length} items`}
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.scss
Normal file
36
app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.scss
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
.matcher-board {
|
||||||
|
&__column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__column-content {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.tsx
Normal file
125
app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { Box, Card, CardContent, Typography } from "@mui/material";
|
||||||
|
import { MatcherBoardProps } from "../types";
|
||||||
|
import { DroppableColumn } from "./DroppableColumn";
|
||||||
|
import { findActiveItem, isItemInSource, addItemIfNotPresent } from "../utils";
|
||||||
|
import "./MatcherBoard.scss";
|
||||||
|
|
||||||
|
export default function MatcherBoard({
|
||||||
|
sourceItems: initialSourceItems,
|
||||||
|
targetItems: initialTargetItems,
|
||||||
|
config,
|
||||||
|
onMatch,
|
||||||
|
}: MatcherBoardProps) {
|
||||||
|
const [sourceItems, setSourceItems] = useState(initialSourceItems);
|
||||||
|
const [targetItems, setTargetItems] = useState(initialTargetItems);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(String(event.active.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = String(active.id);
|
||||||
|
const overId = String(over.id);
|
||||||
|
|
||||||
|
const activeItem = findActiveItem(sourceItems, targetItems, activeId);
|
||||||
|
if (!activeItem) return;
|
||||||
|
|
||||||
|
const activeIsSource = isItemInSource(sourceItems, activeId);
|
||||||
|
const droppedOnSource = overId === "source";
|
||||||
|
const droppedOnTarget = overId === "target";
|
||||||
|
|
||||||
|
// If dragging from source to target column
|
||||||
|
if (activeIsSource && droppedOnTarget) {
|
||||||
|
setSourceItems(prev => prev.filter(item => item.id !== activeId));
|
||||||
|
setTargetItems(prev => addItemIfNotPresent(prev, activeItem));
|
||||||
|
|
||||||
|
if (onMatch) {
|
||||||
|
onMatch(activeId, "target");
|
||||||
|
}
|
||||||
|
} else if (!activeIsSource && droppedOnSource) {
|
||||||
|
setTargetItems(prev => prev.filter(item => item.id !== activeId));
|
||||||
|
setSourceItems(prev => addItemIfNotPresent(prev, activeItem));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeItem = activeId
|
||||||
|
? findActiveItem(sourceItems, targetItems, activeId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className="matcher-board"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 3,
|
||||||
|
p: 3,
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DroppableColumn
|
||||||
|
id="source"
|
||||||
|
title={config.sourceLabel}
|
||||||
|
items={sourceItems}
|
||||||
|
activeId={activeId}
|
||||||
|
/>
|
||||||
|
<DroppableColumn
|
||||||
|
id="target"
|
||||||
|
title={config.targetLabel}
|
||||||
|
items={targetItems}
|
||||||
|
activeId={activeId}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<DragOverlay>
|
||||||
|
{activeItem ? (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
width: 250,
|
||||||
|
boxShadow: 6,
|
||||||
|
transform: "rotate(5deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 1.5, "&:last-child": { pb: 1.5 } }}>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{activeItem.name}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
app/dashboard/admin/matcher/MatcherPageClient.tsx
Normal file
107
app/dashboard/admin/matcher/MatcherPageClient.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Alert,
|
||||||
|
} from "@mui/material";
|
||||||
|
import MatcherBoard from "./MatcherBoard/MatcherBoard";
|
||||||
|
import { MATCH_CONFIGS } from "./constants";
|
||||||
|
import { MatchableEntity, MatchConfig } from "./types";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
interface MatcherPageClientProps {
|
||||||
|
initialSourceItems: MatchableEntity[];
|
||||||
|
initialTargetItems: MatchableEntity[];
|
||||||
|
initialMatchType: string;
|
||||||
|
config: MatchConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MatcherPageClient({
|
||||||
|
initialSourceItems,
|
||||||
|
initialTargetItems,
|
||||||
|
initialMatchType,
|
||||||
|
config: initialConfig,
|
||||||
|
}: MatcherPageClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [matchType, setMatchType] = useState(initialMatchType);
|
||||||
|
|
||||||
|
const currentConfig = MATCH_CONFIGS[matchType] || initialConfig;
|
||||||
|
|
||||||
|
const handleMatchTypeChange = (newType: string) => {
|
||||||
|
setMatchType(newType);
|
||||||
|
// Update URL and reload page to fetch new data via SSR
|
||||||
|
router.push(`/dashboard/admin/matcher?type=${newType}`);
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMatch = useCallback(
|
||||||
|
async (sourceId: string, targetId: string) => {
|
||||||
|
try {
|
||||||
|
// TODO: Call API endpoint to save the match
|
||||||
|
// For now, just show a toast
|
||||||
|
toast.success(
|
||||||
|
`Matched ${currentConfig.sourceLabel} to ${currentConfig.targetLabel}`
|
||||||
|
);
|
||||||
|
console.log("Match:", {
|
||||||
|
sourceId,
|
||||||
|
targetId,
|
||||||
|
matchType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to save match");
|
||||||
|
console.error("Error saving match:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentConfig, matchType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, width: "100%" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 600 }}>
|
||||||
|
Entity Matcher
|
||||||
|
</Typography>
|
||||||
|
<FormControl sx={{ minWidth: 250 }}>
|
||||||
|
<InputLabel>Match Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={matchType}
|
||||||
|
label="Match Type"
|
||||||
|
onChange={e => handleMatchTypeChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(MATCH_CONFIGS).map(([key, config]) => (
|
||||||
|
<MenuItem key={key} value={key}>
|
||||||
|
{config.sourceLabel} → {config.targetLabel}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
Drag items from the left column to the right column to create matches.
|
||||||
|
You can also drag items back to remove matches.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<MatcherBoard
|
||||||
|
sourceItems={initialSourceItems}
|
||||||
|
targetItems={initialTargetItems}
|
||||||
|
config={currentConfig}
|
||||||
|
onMatch={handleMatch}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
app/dashboard/admin/matcher/constants.ts
Normal file
48
app/dashboard/admin/matcher/constants.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { MatchConfig } from "./types";
|
||||||
|
|
||||||
|
export const MATCH_CONFIGS: Record<string, MatchConfig> = {
|
||||||
|
"permissions-to-groups": {
|
||||||
|
type: "permissions-to-groups",
|
||||||
|
sourceLabel: "Permissions",
|
||||||
|
targetLabel: "Groups",
|
||||||
|
sourceEndpoint: "/api/dashboard/admin/permissions",
|
||||||
|
targetEndpoint: "/api/dashboard/admin/groups",
|
||||||
|
sourceCollectionKeys: ["permissions", "data", "items"],
|
||||||
|
targetCollectionKeys: ["groups", "data", "items"],
|
||||||
|
sourcePrimaryKey: "name",
|
||||||
|
targetPrimaryKey: "name",
|
||||||
|
},
|
||||||
|
"methods-to-psp": {
|
||||||
|
type: "methods-to-psp",
|
||||||
|
sourceLabel: "Methods",
|
||||||
|
targetLabel: "PSPs",
|
||||||
|
sourceEndpoint: "/api/dashboard/admin/methods",
|
||||||
|
targetEndpoint: "/api/dashboard/admin/psps",
|
||||||
|
sourceCollectionKeys: ["methods", "data", "items"],
|
||||||
|
targetCollectionKeys: ["psps", "data", "items"],
|
||||||
|
sourcePrimaryKey: "name",
|
||||||
|
targetPrimaryKey: "name",
|
||||||
|
},
|
||||||
|
"psp-to-merchant": {
|
||||||
|
type: "psp-to-merchant",
|
||||||
|
sourceLabel: "PSPs",
|
||||||
|
targetLabel: "Merchants",
|
||||||
|
sourceEndpoint: "/api/dashboard/admin/psps",
|
||||||
|
targetEndpoint: "/api/dashboard/admin/merchants",
|
||||||
|
sourceCollectionKeys: ["psps", "data", "items"],
|
||||||
|
targetCollectionKeys: ["merchants", "data", "items"],
|
||||||
|
sourcePrimaryKey: "name",
|
||||||
|
targetPrimaryKey: "name",
|
||||||
|
},
|
||||||
|
"groups-to-users": {
|
||||||
|
type: "groups-to-users",
|
||||||
|
sourceLabel: "Groups",
|
||||||
|
targetLabel: "Users",
|
||||||
|
sourceEndpoint: "/api/dashboard/admin/groups",
|
||||||
|
targetEndpoint: "/api/dashboard/admin/users",
|
||||||
|
sourceCollectionKeys: ["groups", "data", "items"],
|
||||||
|
targetCollectionKeys: ["users", "data", "items"],
|
||||||
|
sourcePrimaryKey: "name",
|
||||||
|
targetPrimaryKey: "email",
|
||||||
|
},
|
||||||
|
};
|
||||||
42
app/dashboard/admin/matcher/page.tsx
Normal file
42
app/dashboard/admin/matcher/page.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import MatcherPageClient from "./MatcherPageClient";
|
||||||
|
import { MATCH_CONFIGS } from "./constants";
|
||||||
|
import { getMatcherData } from "@/app/services/matcher";
|
||||||
|
import { getBaseUrl } from "@/app/services/constants";
|
||||||
|
|
||||||
|
export default async function MatcherPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const matchType =
|
||||||
|
(params.type as string) || Object.keys(MATCH_CONFIGS)[0] || "";
|
||||||
|
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
// Forward cookies for auth when calling the internal API route
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const cookieHeader = cookieStore
|
||||||
|
.getAll()
|
||||||
|
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
const { sourceItems, targetItems } = await getMatcherData(
|
||||||
|
matchType,
|
||||||
|
cookieHeader,
|
||||||
|
baseUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
const config =
|
||||||
|
MATCH_CONFIGS[matchType] || MATCH_CONFIGS[Object.keys(MATCH_CONFIGS)[0]];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MatcherPageClient
|
||||||
|
initialSourceItems={sourceItems}
|
||||||
|
initialTargetItems={targetItems}
|
||||||
|
initialMatchType={matchType}
|
||||||
|
config={config}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
app/dashboard/admin/matcher/types.ts
Normal file
58
app/dashboard/admin/matcher/types.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
type MatchType =
|
||||||
|
| "permissions-to-groups"
|
||||||
|
| "methods-to-psp"
|
||||||
|
| "psp-to-merchant"
|
||||||
|
| "groups-to-users";
|
||||||
|
|
||||||
|
interface MatchableEntity {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchColumn {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
items: MatchableEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchConfig {
|
||||||
|
type: MatchType;
|
||||||
|
sourceLabel: string;
|
||||||
|
targetLabel: string;
|
||||||
|
sourceEndpoint: string;
|
||||||
|
targetEndpoint: string;
|
||||||
|
sourceCollectionKeys?: string[];
|
||||||
|
targetCollectionKeys?: string[];
|
||||||
|
sourcePrimaryKey?: string;
|
||||||
|
targetPrimaryKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatcherBoardProps {
|
||||||
|
sourceItems: MatchableEntity[];
|
||||||
|
targetItems: MatchableEntity[];
|
||||||
|
config: MatchConfig;
|
||||||
|
onMatch?: (sourceId: string, targetId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggableItemProps {
|
||||||
|
item: MatchableEntity;
|
||||||
|
isDragging?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DroppableColumnProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
items: MatchableEntity[];
|
||||||
|
activeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MatchType,
|
||||||
|
MatchableEntity,
|
||||||
|
MatchColumn,
|
||||||
|
MatchConfig,
|
||||||
|
MatcherBoardProps,
|
||||||
|
DraggableItemProps,
|
||||||
|
DroppableColumnProps,
|
||||||
|
};
|
||||||
72
app/dashboard/admin/matcher/utils.ts
Normal file
72
app/dashboard/admin/matcher/utils.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { MatchableEntity } from "./types";
|
||||||
|
|
||||||
|
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
|
||||||
|
|
||||||
|
export function resolveCollection(
|
||||||
|
payload: unknown,
|
||||||
|
preferredKeys: string[] = []
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) {
|
||||||
|
const maybeCollection = obj?.[key];
|
||||||
|
if (Array.isArray(maybeCollection)) {
|
||||||
|
return maybeCollection as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEntity(
|
||||||
|
item: Record<string, unknown>,
|
||||||
|
primaryKey: string = "name",
|
||||||
|
fallbackId: number
|
||||||
|
): MatchableEntity {
|
||||||
|
const id = item.id ?? item._id ?? fallbackId;
|
||||||
|
const name =
|
||||||
|
(item[primaryKey] as string) ||
|
||||||
|
(item.name as string) ||
|
||||||
|
(item.title as string) ||
|
||||||
|
(item.email as string) ||
|
||||||
|
`Item ${id}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(id),
|
||||||
|
name: String(name),
|
||||||
|
...item,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findActiveItem(
|
||||||
|
sourceItems: MatchableEntity[],
|
||||||
|
targetItems: MatchableEntity[],
|
||||||
|
activeId: string
|
||||||
|
): MatchableEntity | undefined {
|
||||||
|
return [...sourceItems, ...targetItems].find(item => item.id === activeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isItemInSource(
|
||||||
|
sourceItems: MatchableEntity[],
|
||||||
|
activeId: string
|
||||||
|
): boolean {
|
||||||
|
return sourceItems.some(item => item.id === activeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addItemIfNotPresent(
|
||||||
|
items: MatchableEntity[],
|
||||||
|
item: MatchableEntity
|
||||||
|
): MatchableEntity[] {
|
||||||
|
if (items.some(existingItem => existingItem.id === item.id)) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return [...items, item];
|
||||||
|
}
|
||||||
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,10 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
export default function BackOfficeUsersPage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* This page will now be rendered on the client-side */}
|
|
||||||
hello
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,8 +1,21 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
|
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 (
|
return (
|
||||||
<AdminResourceList
|
<AdminResourceList
|
||||||
title="Permissions"
|
title="Permissions"
|
||||||
@ -10,6 +23,9 @@ export default function PermissionsPage() {
|
|||||||
responseCollectionKeys={["permissions", "data", "items"]}
|
responseCollectionKeys={["permissions", "data", "items"]}
|
||||||
primaryLabelKeys={["name", "permissionName", "title", "description"]}
|
primaryLabelKeys={["name", "permissionName", "title", "description"]}
|
||||||
chipKeys={["status", "scope", "category"]}
|
chipKeys={["status", "scope", "category"]}
|
||||||
|
addModalFields={PERMISSION_FIELDS as IAddModalField[]}
|
||||||
|
showEnabledToggle={true}
|
||||||
|
initialData={initialData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,59 +1,19 @@
|
|||||||
import Users from "@/app/features/Pages/Admin/Users/users";
|
import Users from "@/app/features/Pages/Admin/Users/users";
|
||||||
import { cookies } from "next/headers";
|
import { IUser } from "@/app/features/Pages/Admin/Users/interfaces";
|
||||||
|
import { fetchUsers } from "@/app/services/users";
|
||||||
|
|
||||||
export default async function BackOfficeUsersPage({
|
export default async function BackOfficeUsersPage() {
|
||||||
searchParams,
|
let users: IUser[] = [];
|
||||||
}: {
|
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
}) {
|
|
||||||
// Await searchParams and extract pagination
|
|
||||||
const params = await searchParams;
|
|
||||||
const limit = params.limit || "10";
|
|
||||||
const page = params.page || "1";
|
|
||||||
|
|
||||||
// Build absolute URL for server-side fetch
|
try {
|
||||||
// In server components, fetch requires absolute URLs
|
users = await fetchUsers();
|
||||||
const port = process.env.PORT || "3000";
|
} catch (error) {
|
||||||
const baseUrl =
|
console.error("Failed to fetch users:", error);
|
||||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
|
||||||
(process.env.VERCEL_URL
|
|
||||||
? `https://${process.env.VERCEL_URL}`
|
|
||||||
: `http://localhost:${port}`);
|
|
||||||
|
|
||||||
const url = new URL(`${baseUrl}/api/dashboard/admin/users`);
|
|
||||||
url.searchParams.set("limit", typeof limit === "string" ? limit : limit[0]);
|
|
||||||
url.searchParams.set("page", typeof page === "string" ? page : page[0]);
|
|
||||||
|
|
||||||
// Forward cookies for auth when calling the internal API route
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const cookieHeader = cookieStore
|
|
||||||
.getAll()
|
|
||||||
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
|
||||||
.join("; ");
|
|
||||||
|
|
||||||
const res = await fetch(url.toString(), {
|
|
||||||
headers: {
|
|
||||||
Cookie: cookieHeader,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error("Failed to fetch users:", res.status, res.statusText);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Users users={[]} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
// Handle different response structures: could be array directly, or wrapped in data/users property
|
|
||||||
const users = Array.isArray(data) ? data : data.users || data.data || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<Users users={users} />
|
<Users users={users} />
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
201
app/dashboard/audits/AuditTableClient.tsx
Normal file
201
app/dashboard/audits/AuditTableClient.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
GridColDef,
|
||||||
|
GridPaginationModel,
|
||||||
|
GridSortModel,
|
||||||
|
} from "@mui/x-data-grid";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { Box, debounce } from "@mui/material";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import { AuditRow } from "./auditTransforms";
|
||||||
|
import { PAGE_SIZE_OPTIONS } from "./auditConstants";
|
||||||
|
import {
|
||||||
|
buildSortParam,
|
||||||
|
deriveColumns,
|
||||||
|
parseSortModel,
|
||||||
|
toTitle,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
const FALLBACK_COLUMNS: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "placeholder",
|
||||||
|
headerName: "Audit Data",
|
||||||
|
flex: 1,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AuditTableClientProps {
|
||||||
|
rows: AuditRow[];
|
||||||
|
total: number;
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortParam?: string;
|
||||||
|
entityQuery?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditTableClient({
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
sortParam,
|
||||||
|
entityQuery = "",
|
||||||
|
}: AuditTableClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const searchParamsString = searchParams.toString();
|
||||||
|
const [isNavigating, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Derive values directly from props
|
||||||
|
const paginationModel = useMemo<GridPaginationModel>(
|
||||||
|
() => ({
|
||||||
|
page: pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[pageIndex, pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortModel = useMemo(() => parseSortModel(sortParam), [sortParam]);
|
||||||
|
const columns = useMemo(
|
||||||
|
() => (rows.length ? deriveColumns(rows) : FALLBACK_COLUMNS),
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only entitySearchInput needs state since it's a controlled input
|
||||||
|
const normalizedEntityQuery = entityQuery.trim();
|
||||||
|
const [entitySearchInput, setEntitySearchInput] = useState<string>(
|
||||||
|
normalizedEntityQuery
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync entity input when query prop changes (e.g., from URL navigation)
|
||||||
|
useEffect(() => {
|
||||||
|
setEntitySearchInput(normalizedEntityQuery);
|
||||||
|
}, [normalizedEntityQuery]);
|
||||||
|
|
||||||
|
const pageTitle = useMemo(
|
||||||
|
() =>
|
||||||
|
sortModel.length && sortModel[0].field
|
||||||
|
? `Audit Logs · sorted by ${toTitle(sortModel[0].field)}`
|
||||||
|
: "Audit Logs",
|
||||||
|
[sortModel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateWithParams = useCallback(
|
||||||
|
(updates: Record<string, string | null | undefined>) => {
|
||||||
|
const params = new URLSearchParams(searchParamsString);
|
||||||
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
if (!value) {
|
||||||
|
params.delete(key);
|
||||||
|
} else {
|
||||||
|
params.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
const queryString = params.toString();
|
||||||
|
router.push(queryString ? `${pathname}?${queryString}` : pathname);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[pathname, router, searchParamsString, startTransition]
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedEntityNavigate = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((value: string) => {
|
||||||
|
navigateWithParams({
|
||||||
|
page: "1",
|
||||||
|
entity: value.trim() ? value.trim() : null,
|
||||||
|
});
|
||||||
|
}, 500),
|
||||||
|
[navigateWithParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedEntityNavigate.clear();
|
||||||
|
};
|
||||||
|
}, [debouncedEntityNavigate]);
|
||||||
|
|
||||||
|
const handlePaginationChange = (model: GridPaginationModel) => {
|
||||||
|
navigateWithParams({
|
||||||
|
page: String(model.page + 1),
|
||||||
|
limit: String(model.pageSize),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortModelChange = (model: GridSortModel) => {
|
||||||
|
navigateWithParams({
|
||||||
|
page: "1",
|
||||||
|
sort: buildSortParam(model) ?? null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEntitySearchChange = (value: string) => {
|
||||||
|
setEntitySearchInput(value);
|
||||||
|
debouncedEntityNavigate(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = isNavigating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="audits-page">
|
||||||
|
<Box sx={{ display: "flex", gap: 2, mt: 5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Search by Entity"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
value={entitySearchInput}
|
||||||
|
onChange={e => handleEntitySearchChange(e.target.value)}
|
||||||
|
sx={{ width: 300, backgroundColor: "#f0f0f0" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<h1 className="page-title">{pageTitle}</h1>
|
||||||
|
<div className="table-container">
|
||||||
|
<div className="scroll-wrapper">
|
||||||
|
<div
|
||||||
|
className="table-inner"
|
||||||
|
style={{ minWidth: `${columns.length * 200}px` }}
|
||||||
|
>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns.length ? columns : FALLBACK_COLUMNS}
|
||||||
|
loading={loading}
|
||||||
|
paginationMode="server"
|
||||||
|
sortingMode="server"
|
||||||
|
paginationModel={paginationModel}
|
||||||
|
onPaginationModelChange={handlePaginationChange}
|
||||||
|
rowCount={total}
|
||||||
|
sortModel={sortModel}
|
||||||
|
onSortModelChange={handleSortModelChange}
|
||||||
|
pageSizeOptions={[...PAGE_SIZE_OPTIONS]}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
sx={{
|
||||||
|
border: 0,
|
||||||
|
minHeight: 500,
|
||||||
|
"& .MuiDataGrid-cell": {
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
app/dashboard/audits/auditConstants.ts
Normal file
2
app/dashboard/audits/auditConstants.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const ENTITY_PREFIX = "LIKE/";
|
||||||
|
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
137
app/dashboard/audits/auditTransforms.ts
Normal file
137
app/dashboard/audits/auditTransforms.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
export type AuditRow = Record<string, unknown> & { id: string | number };
|
||||||
|
|
||||||
|
export interface AuditApiResponse {
|
||||||
|
total?: number;
|
||||||
|
limit?: number;
|
||||||
|
page?: number;
|
||||||
|
data?: unknown;
|
||||||
|
items?: unknown[];
|
||||||
|
audits?: unknown[];
|
||||||
|
logs?: unknown[];
|
||||||
|
results?: unknown[];
|
||||||
|
records?: unknown[];
|
||||||
|
meta?: { total?: number };
|
||||||
|
pagination?: { total?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditQueryResult {
|
||||||
|
rows: AuditRow[];
|
||||||
|
total: number;
|
||||||
|
payload: AuditApiResponse;
|
||||||
|
pageIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PAGE_SIZE = 25;
|
||||||
|
const CANDIDATE_ARRAY_KEYS: (keyof AuditApiResponse)[] = [
|
||||||
|
"items",
|
||||||
|
"audits",
|
||||||
|
"logs",
|
||||||
|
"results",
|
||||||
|
"records",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const normalizeValue = (value: unknown): string | number => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? "true" : "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractArray = (payload: AuditApiResponse): unknown[] => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of CANDIDATE_ARRAY_KEYS) {
|
||||||
|
const candidate = payload[key];
|
||||||
|
if (Array.isArray(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRecord =
|
||||||
|
payload.data &&
|
||||||
|
typeof payload.data === "object" &&
|
||||||
|
!Array.isArray(payload.data)
|
||||||
|
? (payload.data as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (dataRecord) {
|
||||||
|
for (const key of CANDIDATE_ARRAY_KEYS) {
|
||||||
|
const candidate = dataRecord[key];
|
||||||
|
if (Array.isArray(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.data)) {
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveTotal = (
|
||||||
|
payload: AuditApiResponse,
|
||||||
|
fallback: number
|
||||||
|
): number => {
|
||||||
|
const fromPayload = payload.total;
|
||||||
|
const fromMeta = payload.meta?.total;
|
||||||
|
const fromPagination = payload.pagination?.total;
|
||||||
|
const fromData =
|
||||||
|
payload.data &&
|
||||||
|
typeof payload.data === "object" &&
|
||||||
|
!Array.isArray(payload.data)
|
||||||
|
? (payload.data as { total?: number }).total
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
(typeof fromPayload === "number" && fromPayload) ||
|
||||||
|
(typeof fromMeta === "number" && fromMeta) ||
|
||||||
|
(typeof fromPagination === "number" && fromPagination) ||
|
||||||
|
(typeof fromData === "number" && fromData) ||
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeRows = (
|
||||||
|
entries: unknown[],
|
||||||
|
pageIndex: number
|
||||||
|
): AuditRow[] =>
|
||||||
|
entries.map((entry, index) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
return {
|
||||||
|
id: `${pageIndex}-${index}`,
|
||||||
|
value: normalizeValue(entry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
Object.entries(record).forEach(([key, value]) => {
|
||||||
|
normalized[key] = normalizeValue(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const identifier =
|
||||||
|
record.id ??
|
||||||
|
record.audit_id ??
|
||||||
|
record.log_id ??
|
||||||
|
record._id ??
|
||||||
|
`${pageIndex}-${index}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: (identifier as string | number) ?? `${pageIndex}-${index}`,
|
||||||
|
...normalized,
|
||||||
|
};
|
||||||
|
});
|
||||||
7
app/dashboard/audits/loading.tsx
Normal file
7
app/dashboard/audits/loading.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function AuditLoading() {
|
||||||
|
return (
|
||||||
|
<div className="audits-page loading-state">
|
||||||
|
<p>Loading audit logs...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,332 +1,56 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
DataGrid,
|
|
||||||
GridColDef,
|
|
||||||
GridPaginationModel,
|
|
||||||
GridSortModel,
|
|
||||||
} from "@mui/x-data-grid";
|
|
||||||
import { getAudits } from "@/app/services/audits";
|
|
||||||
import "./page.scss";
|
import "./page.scss";
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import { Box, debounce } from "@mui/material";
|
|
||||||
|
|
||||||
type AuditRow = Record<string, unknown> & { id: string | number };
|
import AuditTableClient from "./AuditTableClient";
|
||||||
|
import { DEFAULT_PAGE_SIZE } from "./auditTransforms";
|
||||||
|
import { fetchAudits } from "@/app/services/audits";
|
||||||
|
import {
|
||||||
|
ENTITY_PREFIX,
|
||||||
|
PAGE_SIZE_OPTIONS,
|
||||||
|
} from "@/app/dashboard/audits/auditConstants";
|
||||||
|
|
||||||
interface AuditApiResponse {
|
type AuditPageProps = {
|
||||||
total?: number;
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||||
limit?: number;
|
};
|
||||||
page?: number;
|
|
||||||
data?: unknown;
|
|
||||||
items?: unknown[];
|
|
||||||
audits?: unknown[];
|
|
||||||
logs?: unknown[];
|
|
||||||
results?: unknown[];
|
|
||||||
records?: unknown[];
|
|
||||||
meta?: { total?: number };
|
|
||||||
pagination?: { total?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const toSingleValue = (value?: string | string[]): string | undefined => {
|
||||||
|
if (Array.isArray(value)) return value[0];
|
||||||
const FALLBACK_COLUMNS: GridColDef[] = [
|
|
||||||
{
|
|
||||||
field: "placeholder",
|
|
||||||
headerName: "Audit Data",
|
|
||||||
flex: 1,
|
|
||||||
sortable: false,
|
|
||||||
filterable: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CANDIDATE_ARRAY_KEYS: (keyof AuditApiResponse)[] = [
|
|
||||||
"items",
|
|
||||||
"audits",
|
|
||||||
"logs",
|
|
||||||
"results",
|
|
||||||
"records",
|
|
||||||
];
|
|
||||||
|
|
||||||
const normalizeValue = (value: unknown): string | number => {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
|
||||||
return value;
|
return value;
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "boolean") {
|
|
||||||
return value ? "true" : "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toTitle = (field: string) =>
|
const clampNumber = (value: number, min: number, max?: number) => {
|
||||||
field
|
if (Number.isNaN(value)) return min;
|
||||||
.replace(/_/g, " ")
|
if (value < min) return min;
|
||||||
.replace(/-/g, " ")
|
if (typeof max === "number" && value > max) return max;
|
||||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
return value;
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.replace(/^\w/g, char => char.toUpperCase());
|
|
||||||
|
|
||||||
const deriveColumns = (rows: AuditRow[]): GridColDef[] => {
|
|
||||||
if (!rows.length) return [];
|
|
||||||
|
|
||||||
return Object.keys(rows[0]).map(field => ({
|
|
||||||
field,
|
|
||||||
headerName: toTitle(field),
|
|
||||||
flex: field === "id" ? 0 : 1,
|
|
||||||
minWidth: field === "id" ? 140 : 200,
|
|
||||||
sortable: true,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractArray = (payload: AuditApiResponse): unknown[] => {
|
export default async function AuditPage({ searchParams }: AuditPageProps) {
|
||||||
if (Array.isArray(payload)) {
|
const params = searchParams ? await searchParams : {};
|
||||||
return payload;
|
const pageParam = toSingleValue(params?.page);
|
||||||
}
|
const limitParam = toSingleValue(params?.limit);
|
||||||
|
const sortParam = toSingleValue(params?.sort) || undefined;
|
||||||
|
const entityQuery = toSingleValue(params?.entity)?.trim() || "";
|
||||||
|
|
||||||
for (const key of CANDIDATE_ARRAY_KEYS) {
|
const page = clampNumber(parseInt(pageParam || "1", 10), 1);
|
||||||
const candidate = payload[key];
|
const parsedLimit = parseInt(limitParam || String(DEFAULT_PAGE_SIZE), 10);
|
||||||
if (Array.isArray(candidate)) {
|
const limit =
|
||||||
return candidate;
|
PAGE_SIZE_OPTIONS.find(size => size === parsedLimit) ?? DEFAULT_PAGE_SIZE;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataRecord =
|
const data = await fetchAudits({
|
||||||
payload.data &&
|
limit,
|
||||||
typeof payload.data === "object" &&
|
page,
|
||||||
!Array.isArray(payload.data)
|
|
||||||
? (payload.data as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (dataRecord) {
|
|
||||||
for (const key of CANDIDATE_ARRAY_KEYS) {
|
|
||||||
const candidate = dataRecord[key];
|
|
||||||
if (Array.isArray(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(payload.data)) {
|
|
||||||
return payload.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveTotal = (payload: AuditApiResponse, fallback: number): number => {
|
|
||||||
const fromPayload = payload.total;
|
|
||||||
const fromMeta = payload.meta?.total;
|
|
||||||
const fromPagination = payload.pagination?.total;
|
|
||||||
const fromData =
|
|
||||||
payload.data &&
|
|
||||||
typeof payload.data === "object" &&
|
|
||||||
!Array.isArray(payload.data)
|
|
||||||
? (payload.data as { total?: number }).total
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
(typeof fromPayload === "number" && fromPayload) ||
|
|
||||||
(typeof fromMeta === "number" && fromMeta) ||
|
|
||||||
(typeof fromPagination === "number" && fromPagination) ||
|
|
||||||
(typeof fromData === "number" && fromData) ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeRows = (entries: unknown[], page: number): AuditRow[] =>
|
|
||||||
entries.map((entry, index) => {
|
|
||||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
||||||
return {
|
|
||||||
id: `${page}-${index}`,
|
|
||||||
value: normalizeValue(entry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = entry as Record<string, unknown>;
|
|
||||||
|
|
||||||
const normalized: Record<string, unknown> = {};
|
|
||||||
Object.entries(record).forEach(([key, value]) => {
|
|
||||||
normalized[key] = normalizeValue(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const identifier =
|
|
||||||
record.id ??
|
|
||||||
record.audit_id ??
|
|
||||||
record.log_id ??
|
|
||||||
record._id ??
|
|
||||||
`${page}-${index}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: (identifier as string | number) ?? `${page}-${index}`,
|
|
||||||
...normalized,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function AuditPage() {
|
|
||||||
const [rows, setRows] = useState<AuditRow[]>([]);
|
|
||||||
const [columns, setColumns] = useState<GridColDef[]>([]);
|
|
||||||
const [rowCount, setRowCount] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
|
|
||||||
page: 0,
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
});
|
|
||||||
const [sortModel, setSortModel] = useState<GridSortModel>([]);
|
|
||||||
const [entitySearch, setEntitySearch] = useState<string>("");
|
|
||||||
const [entitySearchInput, setEntitySearchInput] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const fetchAudits = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const sortParam =
|
|
||||||
sortModel.length && sortModel[0].field && sortModel[0].sort
|
|
||||||
? `${sortModel[0].field}:${sortModel[0].sort}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const entityParam = entitySearch.trim()
|
|
||||||
? `LIKE/${entitySearch.trim()}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = (await getAudits({
|
|
||||||
limit: paginationModel.pageSize,
|
|
||||||
page: paginationModel.page + 1,
|
|
||||||
sort: sortParam,
|
sort: sortParam,
|
||||||
entity: entityParam,
|
entity: entityQuery ? `${ENTITY_PREFIX}${entityQuery}` : undefined,
|
||||||
signal: controller.signal,
|
});
|
||||||
})) as AuditApiResponse;
|
|
||||||
|
|
||||||
const auditEntries = extractArray(payload);
|
|
||||||
const normalized = normalizeRows(auditEntries, paginationModel.page);
|
|
||||||
|
|
||||||
setColumns(prev =>
|
|
||||||
normalized.length
|
|
||||||
? deriveColumns(normalized)
|
|
||||||
: prev.length
|
|
||||||
? prev
|
|
||||||
: FALLBACK_COLUMNS
|
|
||||||
);
|
|
||||||
|
|
||||||
setRows(normalized);
|
|
||||||
setRowCount(resolveTotal(payload, normalized.length));
|
|
||||||
} catch (err) {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : "Failed to load audits";
|
|
||||||
setError(message);
|
|
||||||
setRows([]);
|
|
||||||
setColumns(prev => (prev.length ? prev : FALLBACK_COLUMNS));
|
|
||||||
} finally {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAudits();
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [paginationModel, sortModel, entitySearch]);
|
|
||||||
|
|
||||||
const handlePaginationChange = (model: GridPaginationModel) => {
|
|
||||||
setPaginationModel(model);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortModelChange = (model: GridSortModel) => {
|
|
||||||
setSortModel(model);
|
|
||||||
setPaginationModel(prev => ({ ...prev, page: 0 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const debouncedSetEntitySearch = useMemo(
|
|
||||||
() =>
|
|
||||||
debounce((value: string) => {
|
|
||||||
setEntitySearch(value);
|
|
||||||
setPaginationModel(prev => ({ ...prev, page: 0 }));
|
|
||||||
}, 500),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
debouncedSetEntitySearch.clear();
|
|
||||||
};
|
|
||||||
}, [debouncedSetEntitySearch]);
|
|
||||||
|
|
||||||
const handleEntitySearchChange = (value: string) => {
|
|
||||||
setEntitySearchInput(value);
|
|
||||||
debouncedSetEntitySearch(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pageTitle = useMemo(
|
|
||||||
() =>
|
|
||||||
sortModel.length && sortModel[0].field
|
|
||||||
? `Audit Logs · sorted by ${toTitle(sortModel[0].field)}`
|
|
||||||
: "Audit Logs",
|
|
||||||
[sortModel]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="audits-page">
|
<AuditTableClient
|
||||||
<Box sx={{ display: "flex", gap: 2, mt: 5 }}>
|
rows={data.rows}
|
||||||
<TextField
|
total={data.total ?? data.rows.length}
|
||||||
label="Search by Entity"
|
pageIndex={data.pageIndex}
|
||||||
variant="outlined"
|
pageSize={limit}
|
||||||
size="small"
|
sortParam={sortParam}
|
||||||
value={entitySearchInput}
|
entityQuery={entityQuery}
|
||||||
onChange={e => handleEntitySearchChange(e.target.value)}
|
|
||||||
sx={{ width: 300, backgroundColor: "#f0f0f0" }}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
<h1 className="page-title">{pageTitle}</h1>
|
|
||||||
{error && (
|
|
||||||
<div className="error-alert" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="table-container">
|
|
||||||
<div className="scroll-wrapper">
|
|
||||||
<div
|
|
||||||
className="table-inner"
|
|
||||||
style={{ minWidth: `${columns.length * 200}px` }}
|
|
||||||
>
|
|
||||||
<DataGrid
|
|
||||||
rows={rows}
|
|
||||||
columns={columns.length ? columns : FALLBACK_COLUMNS}
|
|
||||||
loading={loading}
|
|
||||||
paginationMode="server"
|
|
||||||
sortingMode="server"
|
|
||||||
paginationModel={paginationModel}
|
|
||||||
onPaginationModelChange={handlePaginationChange}
|
|
||||||
rowCount={rowCount}
|
|
||||||
sortModel={sortModel}
|
|
||||||
onSortModelChange={handleSortModelChange}
|
|
||||||
pageSizeOptions={[10, 25, 50, 100]}
|
|
||||||
disableRowSelectionOnClick
|
|
||||||
sx={{
|
|
||||||
border: 0,
|
|
||||||
minHeight: 500,
|
|
||||||
"& .MuiDataGrid-cell": {
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
app/dashboard/audits/utils.ts
Normal file
38
app/dashboard/audits/utils.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { GridColDef, GridSortModel } from "@mui/x-data-grid";
|
||||||
|
import { AuditRow } from "./auditTransforms";
|
||||||
|
|
||||||
|
export const buildSortParam = (sortModel: GridSortModel) =>
|
||||||
|
sortModel.length && sortModel[0].field && sortModel[0].sort
|
||||||
|
? `${sortModel[0].field}:${sortModel[0].sort}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export const parseSortModel = (sortParam?: string): GridSortModel => {
|
||||||
|
if (!sortParam) return [];
|
||||||
|
const [field, direction] = sortParam.split(":");
|
||||||
|
if (!field || (direction !== "asc" && direction !== "desc")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ field, sort: direction }];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toTitle = (field: string) =>
|
||||||
|
field
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/-/g, " ")
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/^\w/g, char => char.toUpperCase());
|
||||||
|
|
||||||
|
export const deriveColumns = (rows: AuditRow[]): GridColDef[] => {
|
||||||
|
if (!rows.length) return [];
|
||||||
|
|
||||||
|
return Object.keys(rows[0]).map(field => ({
|
||||||
|
field,
|
||||||
|
headerName: toTitle(field),
|
||||||
|
flex: field === "id" ? 0 : 1,
|
||||||
|
minWidth: field === "id" ? 140 : 200,
|
||||||
|
sortable: true,
|
||||||
|
}));
|
||||||
|
};
|
||||||
@ -1,9 +1,51 @@
|
|||||||
"use client";
|
import { transformOverviewResponse } from "../api/dashboard/utils/dashboard";
|
||||||
|
|
||||||
import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage";
|
import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage";
|
||||||
|
import { fetchDashboardDataService } from "../services/health";
|
||||||
|
import { ITransactionsOverviewData } from "../services/types";
|
||||||
|
import { getDefaultDateRange } from "../utils/formatDate";
|
||||||
|
|
||||||
const DashboardPage = () => {
|
export default async function DashboardPage() {
|
||||||
return <DashboardHomePage />;
|
// Fetch all dashboard data (health, overview, and review transactions) concurrently with default 24h range
|
||||||
};
|
const defaultDates = getDefaultDateRange();
|
||||||
|
let initialHealthData = null;
|
||||||
|
let initialStats = null;
|
||||||
|
let initialOverviewData = null;
|
||||||
|
let initialReviewTransactions = null;
|
||||||
|
|
||||||
export default DashboardPage;
|
try {
|
||||||
|
const dashboardData = await fetchDashboardDataService({
|
||||||
|
dateStart: defaultDates.dateStart,
|
||||||
|
dateEnd: defaultDates.dateEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { healthData, overviewData, reviewTransactions } = dashboardData;
|
||||||
|
|
||||||
|
initialHealthData = healthData;
|
||||||
|
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
|
||||||
|
const error = _error as Error;
|
||||||
|
console.error("Failed to fetch dashboard data:", error.cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardHomePage
|
||||||
|
initialHealthData={initialHealthData}
|
||||||
|
initialStats={initialStats}
|
||||||
|
initialDateRange={[
|
||||||
|
{
|
||||||
|
startDate: new Date(defaultDates.dateStart),
|
||||||
|
endDate: new Date(defaultDates.dateEnd),
|
||||||
|
key: "selection",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
initialOverviewData={initialOverviewData}
|
||||||
|
initialReviewTransactions={initialReviewTransactions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,19 +1,5 @@
|
|||||||
import SettingsPageClient from "@/app/features/Pages/Settings/SettingsPageClient";
|
import SettingsPageClient from "@/app/features/Pages/Settings/SettingsPageClient";
|
||||||
|
|
||||||
// We can enable this if we want to fetch the user from the database by the id but it's not necessary since we already have the user in the redux store
|
|
||||||
// async function getUser() {
|
|
||||||
// const token = (await cookies()).get("auth_token")?.value;
|
|
||||||
// const payload = token ? await validateToken(token) : null;
|
|
||||||
// const userId = payload?.id; // requires JWT to include id
|
|
||||||
// if (!userId) throw new Error("No user id in token");
|
|
||||||
// const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/dashboard/admin/users/${userId}`, {
|
|
||||||
// headers: { Authorization: `Bearer ${token}` },
|
|
||||||
// cache: "no-store",
|
|
||||||
// });
|
|
||||||
// if (!res.ok) throw new Error("Failed to fetch user");
|
|
||||||
// return res.json();
|
|
||||||
// }
|
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
// const user = await getUser();
|
// const user = await getUser();
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import DataTable from "@/app/features/DataTable/DataTable";
|
import DataTable from "@/app/features/DataTable/DataTable";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { AppDispatch } from "@/app/redux/store";
|
import { AppDispatch } from "@/app/redux/store";
|
||||||
@ -12,7 +13,6 @@ import {
|
|||||||
setStatus,
|
setStatus,
|
||||||
setError as setAdvancedSearchError,
|
setError as setAdvancedSearchError,
|
||||||
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { TransactionRow, BackendTransaction } from "../interface";
|
import { TransactionRow, BackendTransaction } from "../interface";
|
||||||
|
|
||||||
export default function DepositTransactionPage() {
|
export default function DepositTransactionPage() {
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import Spinner from "@/app/components/Spinner/Spinner";
|
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 { DataRowBase } from "@/app/features/DataTable/types";
|
||||||
import {
|
import {
|
||||||
setError as setAdvancedSearchError,
|
|
||||||
setStatus,
|
|
||||||
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
|
||||||
import {
|
|
||||||
selectError,
|
|
||||||
selectFilters,
|
selectFilters,
|
||||||
selectPagination,
|
selectPagination,
|
||||||
selectSort,
|
selectSort,
|
||||||
selectStatus,
|
|
||||||
} from "@/app/redux/advanedSearch/selectors";
|
} from "@/app/redux/advanedSearch/selectors";
|
||||||
import { AppDispatch } from "@/app/redux/store";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Divider,
|
IconButton,
|
||||||
List,
|
Switch,
|
||||||
ListItem,
|
TextField,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} 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 { 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 =
|
type FilterValue =
|
||||||
| string
|
| string
|
||||||
@ -43,6 +54,10 @@ interface AdminResourceListProps {
|
|||||||
chipKeys?: string[];
|
chipKeys?: string[];
|
||||||
excludeKeys?: string[];
|
excludeKeys?: string[];
|
||||||
filterOverrides?: Record<string, FilterValue>;
|
filterOverrides?: Record<string, FilterValue>;
|
||||||
|
addModalFields?: IAddModalField[];
|
||||||
|
showEnabledToggle?: boolean;
|
||||||
|
idField?: string;
|
||||||
|
initialData?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
|
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
|
||||||
@ -57,13 +72,14 @@ const ensureRowId = (
|
|||||||
return row as ResourceRow;
|
return row as ResourceRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const identifier = currentId;
|
||||||
const numericId = Number(currentId);
|
const numericId = Number(currentId);
|
||||||
|
|
||||||
if (!Number.isNaN(numericId) && numericId !== 0) {
|
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 = (
|
const resolveCollection = (
|
||||||
@ -91,18 +107,50 @@ const AdminResourceList = ({
|
|||||||
primaryLabelKeys,
|
primaryLabelKeys,
|
||||||
chipKeys = [],
|
chipKeys = [],
|
||||||
excludeKeys = [],
|
excludeKeys = [],
|
||||||
|
addModalFields,
|
||||||
|
showEnabledToggle = false,
|
||||||
|
idField,
|
||||||
|
initialData,
|
||||||
}: AdminResourceListProps) => {
|
}: AdminResourceListProps) => {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
// Keep Redux for shared state (filters, pagination, sort)
|
||||||
const filters = useSelector(selectFilters);
|
const filters = useSelector(selectFilters);
|
||||||
const pagination = useSelector(selectPagination);
|
const pagination = useSelector(selectPagination);
|
||||||
const sort = useSelector(selectSort);
|
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 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 excludedKeys = useMemo(() => {
|
||||||
const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]);
|
const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]);
|
||||||
excludeKeys.forEach(key => baseExcluded.add(key));
|
excludeKeys.forEach(key => baseExcluded.add(key));
|
||||||
@ -134,10 +182,48 @@ const AdminResourceList = ({
|
|||||||
[responseCollectionKeys]
|
[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(() => {
|
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 () => {
|
const fetchResources = async () => {
|
||||||
dispatch(setStatus("loading"));
|
setLocalStatus("loading");
|
||||||
dispatch(setAdvancedSearchError(null));
|
setLocalError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
@ -151,10 +237,9 @@ const AdminResourceList = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
dispatch(
|
setLocalError(`Failed to fetch ${normalizedTitle}`);
|
||||||
setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`)
|
|
||||||
);
|
|
||||||
setRows([]);
|
setRows([]);
|
||||||
|
setLocalStatus("failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,98 +249,334 @@ const AdminResourceList = ({
|
|||||||
resolvedCollectionKeys
|
resolvedCollectionKeys
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextRows = collection.map((item, index) =>
|
const nextRows = collection.map((item, index) => {
|
||||||
ensureRowId(item, index + 1)
|
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);
|
setRows(nextRows);
|
||||||
dispatch(setStatus("succeeded"));
|
setLocalStatus("succeeded");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch(
|
setLocalError(error instanceof Error ? error.message : "Unknown error");
|
||||||
setAdvancedSearchError(
|
|
||||||
error instanceof Error ? error.message : "Unknown error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setRows([]);
|
setRows([]);
|
||||||
|
setLocalStatus("failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchResources();
|
fetchResources();
|
||||||
}, [
|
}, [
|
||||||
dispatch,
|
|
||||||
endpoint,
|
endpoint,
|
||||||
filters,
|
filters,
|
||||||
pagination,
|
pagination,
|
||||||
sort,
|
sort,
|
||||||
resolvedCollectionKeys,
|
resolvedCollectionKeys,
|
||||||
normalizedTitle,
|
normalizedTitle,
|
||||||
|
refreshCounter,
|
||||||
|
idField,
|
||||||
|
initialData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleAddClick = () => {
|
||||||
|
if (!addModalFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddModalOpen(true);
|
||||||
|
setAddError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Box sx={{ p: 3, width: "100%", maxWidth: 900 }}>
|
<div className="admin-resource-list">
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
<div className="admin-resource-list__header">
|
||||||
|
<Typography variant="h5" className="admin-resource-list__title">
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{status === "loading" && (
|
{addModalFields && (
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mb: 2 }}>
|
<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" />
|
<Spinner size="small" color="#000" />
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
{`Loading ${normalizedTitle}...`}
|
{`Loading ${normalizedTitle}...`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "failed" && (
|
{localStatus === "failed" && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" className="admin-resource-list__error">
|
||||||
{errorMessage || `Failed to load ${normalizedTitle}`}
|
{localError || `Failed to load ${normalizedTitle}`}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!rows.length && status === "succeeded" && (
|
{!rows.length && localStatus === "succeeded" && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" className="admin-resource-list__empty">
|
||||||
{`No ${normalizedTitle} found.`}
|
{`No ${normalizedTitle} found.`}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<List
|
<div className="admin-resource-list__list">
|
||||||
sx={{ bgcolor: "background.paper", borderRadius: 2, boxShadow: 1 }}
|
|
||||||
>
|
|
||||||
{rows.map(row => {
|
{rows.map(row => {
|
||||||
const chips = getMetaChips(row);
|
const chips = getMetaChips(row);
|
||||||
const secondary = getSecondaryDetails(row);
|
const secondary = getSecondaryDetails(row);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={row.id}>
|
<div key={row.id} className="admin-resource-list__row">
|
||||||
<ListItem alignItems="flex-start">
|
<div className="admin-resource-list__row-content">
|
||||||
<Box sx={{ width: "100%" }}>
|
<div className="admin-resource-list__row-container">
|
||||||
<Box
|
<div className="admin-resource-list__row-header">
|
||||||
sx={{
|
{editingRowId === row.id ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
flexDirection: "column",
|
||||||
flexWrap: "wrap",
|
gap: "8px",
|
||||||
gap: 1,
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle1" fontWeight={600}>
|
<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)}
|
{getPrimaryLabel(row)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
className="admin-resource-list__row-id"
|
||||||
|
>
|
||||||
ID: {row.id}
|
ID: {row.id}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{chips.length > 0 && (
|
{chips.length > 0 && editingRowId !== row.id && (
|
||||||
<Box
|
<div className="admin-resource-list__row-chips">
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 1,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
my: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{chips.map(chip => (
|
{chips.map(chip => (
|
||||||
<Chip
|
<Chip
|
||||||
key={`${row.id}-${chip.key}`}
|
key={`${row.id}-${chip.key}`}
|
||||||
@ -265,25 +586,133 @@ const AdminResourceList = ({
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{secondary.length > 0 && (
|
{secondary.length > 0 && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
className="admin-resource-list__row-secondary"
|
||||||
|
>
|
||||||
{secondary
|
{secondary
|
||||||
.map(([key, value]) => `${key}: ${String(value)}`)
|
.map(([key, value]) => `${key}: ${String(value)}`)
|
||||||
.join(" • ")}
|
.join(" • ")}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</div>
|
||||||
</ListItem>
|
|
||||||
<Divider component="li" />
|
{(addModalFields || showEnabledToggle) && (
|
||||||
</Box>
|
<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();
|
||||||
|
}
|
||||||
@ -45,7 +45,6 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
const [operators, setOperators] = useState<Record<string, string>>({});
|
const [operators, setOperators] = useState<Record<string, string>>({});
|
||||||
const conditionOperators = useSelector(selectConditionOperators);
|
const conditionOperators = useSelector(selectConditionOperators);
|
||||||
|
|
||||||
console.log("[conditionOperators]", conditionOperators);
|
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
|
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
|
|||||||
@ -2,15 +2,25 @@
|
|||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||||
import "./PieCharts.scss";
|
import "./PieCharts.scss";
|
||||||
const data = [
|
|
||||||
|
interface IPieChartData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPieChartsProps {
|
||||||
|
data?: IPieChartData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#4caf50", "#ff9800", "#f44336", "#9e9e9e"];
|
||||||
|
|
||||||
|
const defaultData: IPieChartData[] = [
|
||||||
{ name: "Group A", value: 100 },
|
{ name: "Group A", value: 100 },
|
||||||
{ name: "Group B", value: 200 },
|
{ name: "Group B", value: 200 },
|
||||||
{ name: "Group C", value: 400 },
|
{ name: "Group C", value: 400 },
|
||||||
{ name: "Group D", value: 300 },
|
{ name: "Group D", value: 300 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLORS = ["#4caf50", "#ff9800", "#f44336", "#9e9e9e"];
|
|
||||||
|
|
||||||
const RADIAN = Math.PI / 180;
|
const RADIAN = Math.PI / 180;
|
||||||
const renderCustomizedLabel = ({
|
const renderCustomizedLabel = ({
|
||||||
cx,
|
cx,
|
||||||
@ -37,7 +47,7 @@ const renderCustomizedLabel = ({
|
|||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export const PieCharts = () => {
|
export default function PieCharts({ data = defaultData }: IPieChartsProps) {
|
||||||
return (
|
return (
|
||||||
<Box className="pie-charts">
|
<Box className="pie-charts">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@ -63,4 +73,4 @@ export const PieCharts = () => {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
@ -33,14 +33,10 @@ const DataTable = <TRow extends DataRowBase>({
|
|||||||
}: DataTableProps<TRow>) => {
|
}: DataTableProps<TRow>) => {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const [showExtraColumns, setShowExtraColumns] = useState(false);
|
const [showExtraColumns, setShowExtraColumns] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [statusDialogData, setStatusDialogData] = useState<{
|
||||||
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
rowId: number;
|
||||||
const [pendingStatus, setPendingStatus] = useState<string>("");
|
newStatus: string;
|
||||||
const [reason, setReason] = useState<string>("");
|
} | null>(null);
|
||||||
const [statusUpdateError, setStatusUpdateError] = useState<string | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
|
||||||
|
|
||||||
const status = useSelector(selectStatus);
|
const status = useSelector(selectStatus);
|
||||||
const errorMessage = useSelector(selectError);
|
const errorMessage = useSelector(selectError);
|
||||||
@ -49,7 +45,6 @@ const DataTable = <TRow extends DataRowBase>({
|
|||||||
|
|
||||||
const handlePaginationModelChange = useCallback(
|
const handlePaginationModelChange = useCallback(
|
||||||
(model: GridPaginationModel) => {
|
(model: GridPaginationModel) => {
|
||||||
console.log("model", model);
|
|
||||||
const nextPage = model.page + 1;
|
const nextPage = model.page + 1;
|
||||||
const nextLimit = model.pageSize;
|
const nextLimit = model.pageSize;
|
||||||
|
|
||||||
@ -61,57 +56,12 @@ const DataTable = <TRow extends DataRowBase>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleStatusChange = useCallback((rowId: number, newStatus: string) => {
|
const handleStatusChange = useCallback((rowId: number, newStatus: string) => {
|
||||||
setSelectedRowId(rowId);
|
setStatusDialogData({ rowId, newStatus });
|
||||||
setPendingStatus(newStatus);
|
|
||||||
setModalOpen(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStatusSave = async () => {
|
const handleDialogClose = useCallback(() => {
|
||||||
if (!selectedRowId || !pendingStatus) return;
|
setStatusDialogData(null);
|
||||||
setStatusUpdateError(null);
|
}, []);
|
||||||
setIsUpdatingStatus(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
data: {
|
|
||||||
status: pendingStatus,
|
|
||||||
notes: reason.trim(),
|
|
||||||
},
|
|
||||||
fields: ["Status", "Notes"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/dashboard/transactions/${selectedRowId}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
result?.message || result?.error || "Failed to update transaction"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalOpen(false);
|
|
||||||
setReason("");
|
|
||||||
setPendingStatus("");
|
|
||||||
setStatusUpdateError(null);
|
|
||||||
setSelectedRowId(null);
|
|
||||||
} catch (err) {
|
|
||||||
setStatusUpdateError(
|
|
||||||
err instanceof Error ? err.message : "Failed to update transaction"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsUpdatingStatus(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectEnhancedColumns = useMemo(makeSelectEnhancedColumns, []);
|
const selectEnhancedColumns = useMemo(makeSelectEnhancedColumns, []);
|
||||||
|
|
||||||
@ -170,20 +120,10 @@ const DataTable = <TRow extends DataRowBase>({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<StatusChangeDialog
|
<StatusChangeDialog
|
||||||
open={modalOpen}
|
open={Boolean(statusDialogData)}
|
||||||
newStatus={pendingStatus}
|
transactionId={statusDialogData?.rowId}
|
||||||
reason={reason}
|
newStatus={statusDialogData?.newStatus ?? ""}
|
||||||
setReason={setReason}
|
onClose={handleDialogClose}
|
||||||
handleClose={() => {
|
|
||||||
setModalOpen(false);
|
|
||||||
setReason("");
|
|
||||||
setPendingStatus("");
|
|
||||||
setStatusUpdateError(null);
|
|
||||||
setSelectedRowId(null);
|
|
||||||
}}
|
|
||||||
handleSave={handleStatusSave}
|
|
||||||
isSubmitting={isUpdatingStatus}
|
|
||||||
errorMessage={statusUpdateError}
|
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -8,39 +8,90 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
interface StatusChangeDialogProps {
|
interface StatusChangeDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
transactionId?: number | null;
|
||||||
newStatus: string;
|
newStatus: string;
|
||||||
reason: string;
|
onClose: () => void;
|
||||||
setReason: React.Dispatch<React.SetStateAction<string>>;
|
onStatusUpdated?: () => void;
|
||||||
handleClose: () => void;
|
|
||||||
handleSave: () => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusChangeDialog = ({
|
const StatusChangeDialog = ({
|
||||||
open,
|
open,
|
||||||
|
transactionId,
|
||||||
newStatus,
|
newStatus,
|
||||||
reason,
|
onClose,
|
||||||
setReason,
|
onStatusUpdated,
|
||||||
handleClose,
|
|
||||||
handleSave,
|
|
||||||
isSubmitting = false,
|
|
||||||
errorMessage,
|
|
||||||
}: StatusChangeDialogProps) => {
|
}: StatusChangeDialogProps) => {
|
||||||
const [isValid, setIsValid] = useState(false);
|
const [reason, setReason] = useState("");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setReason("");
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isValid = useMemo(() => {
|
||||||
const noSpaces = reason.replace(/\s/g, "");
|
const noSpaces = reason.replace(/\s/g, "");
|
||||||
const length = noSpaces.length;
|
const length = noSpaces.length;
|
||||||
setIsValid(length >= 12 && length <= 400);
|
return length >= 12 && length <= 400;
|
||||||
}, [reason]);
|
}, [reason]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!transactionId || !newStatus || !isValid) return;
|
||||||
|
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
data: {
|
||||||
|
status: newStatus,
|
||||||
|
notes: reason.trim(),
|
||||||
|
},
|
||||||
|
fields: ["Status", "Notes"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/dashboard/transactions/${transactionId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
result?.message || result?.error || "Failed to update transaction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setReason("");
|
||||||
|
setErrorMessage(null);
|
||||||
|
onStatusUpdated?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMessage(
|
||||||
|
err instanceof Error ? err.message : "Failed to update transaction"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>Change Status</DialogTitle>
|
<DialogTitle>Change Status</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
You want to change the status to <b>{newStatus}</b>. Please provide a
|
You want to change the status to <b>{newStatus}</b>. Please provide a
|
||||||
@ -63,13 +114,13 @@ const StatusChangeDialog = ({
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleClose} disabled={isSubmitting}>
|
<Button onClick={onClose} disabled={isSubmitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!isValid || isSubmitting}
|
disabled={!isValid || isSubmitting || !transactionId}
|
||||||
startIcon={isSubmitting ? <CircularProgress size={18} /> : undefined}
|
startIcon={isSubmitting ? <CircularProgress size={18} /> : undefined}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
|
|||||||
@ -8,8 +8,13 @@ import "react-date-range/dist/theme/default.css";
|
|||||||
|
|
||||||
import "./DateRangePicker.scss";
|
import "./DateRangePicker.scss";
|
||||||
|
|
||||||
export const DateRangePicker = () => {
|
interface IDateRangePickerProps {
|
||||||
const [range, setRange] = useState<Range[]>([
|
value?: Range[];
|
||||||
|
onChange?: (ranges: Range[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateRangePicker = ({ value, onChange }: IDateRangePickerProps) => {
|
||||||
|
const [internalRange, setInternalRange] = useState<Range[]>([
|
||||||
{
|
{
|
||||||
startDate: new Date(),
|
startDate: new Date(),
|
||||||
endDate: new Date(),
|
endDate: new Date(),
|
||||||
@ -19,9 +24,16 @@ export const DateRangePicker = () => {
|
|||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const range = value ?? internalRange;
|
||||||
|
|
||||||
const handleSelect: DateRangeProps["onChange"] = ranges => {
|
const handleSelect: DateRangeProps["onChange"] = ranges => {
|
||||||
if (ranges.selection) {
|
if (ranges.selection) {
|
||||||
setRange([ranges.selection]);
|
const newRange = [ranges.selection];
|
||||||
|
if (onChange) {
|
||||||
|
onChange(newRange);
|
||||||
|
} else {
|
||||||
|
setInternalRange(newRange);
|
||||||
|
}
|
||||||
if (ranges.selection.endDate !== ranges.selection.startDate) {
|
if (ranges.selection.endDate !== ranges.selection.startDate) {
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,109 @@
|
|||||||
import { Box, Card, CardContent, Typography, IconButton } from "@mui/material";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
} from "@mui/material";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||||
|
import { Range } from "react-date-range";
|
||||||
|
|
||||||
import { DateRangePicker } from "../DateRangePicker/DateRangePicker";
|
import { DateRangePicker } from "../DateRangePicker/DateRangePicker";
|
||||||
import { StatItem } from "./components/StatItem";
|
import { StatItem } from "./components/StatItem";
|
||||||
|
import { DEFAULT_DATE_RANGE } from "./constants";
|
||||||
|
import { type IHealthData } from "@/app/services/types";
|
||||||
|
import { useDebouncedDateRange } from "@/app/hooks/useDebouncedDateRange";
|
||||||
|
import { dashboardService } from "@/app/services/dashboardService";
|
||||||
|
import { normalizeDateRangeForAPI } from "@/app/utils/formatDate";
|
||||||
|
|
||||||
import "./GeneralHealthCard.scss";
|
import "./GeneralHealthCard.scss";
|
||||||
|
|
||||||
const stats = [
|
interface IGeneralHealthCardProps {
|
||||||
{ label: "TOTAL", value: 5, change: "-84.85%" },
|
initialHealthData?: IHealthData | null;
|
||||||
{ label: "SUCCESSFUL", value: 10, change: "100%" },
|
initialStats?: Array<{
|
||||||
{ label: "ACCEPTANCE RATE", value: "0%", change: "-100%" },
|
label: string;
|
||||||
{ label: "AMOUNT", value: "€0.00", change: "-100%" },
|
value: string | number;
|
||||||
{ label: "ATV", value: "€0.00", change: "-100%" },
|
change: string;
|
||||||
|
}> | null;
|
||||||
|
initialDateRange?: Range[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
export const GeneralHealthCard = ({
|
||||||
|
initialHealthData = null,
|
||||||
|
initialStats = null,
|
||||||
|
initialDateRange,
|
||||||
|
}: IGeneralHealthCardProps) => {
|
||||||
|
const [healthData, setHealthData] = useState<IHealthData | null>(
|
||||||
|
initialHealthData
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch health data for a given range
|
||||||
|
*/
|
||||||
|
const fetchHealthData = async (range: Range[]) => {
|
||||||
|
if (!range || !range[0]) return;
|
||||||
|
|
||||||
|
const startDate = range[0]?.startDate;
|
||||||
|
const endDate = range[0]?.endDate;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Normalize dates to ensure full day coverage
|
||||||
|
const { dateStart, dateEnd } = normalizeDateRangeForAPI(
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// This will update the service and notify all subscribers
|
||||||
|
const { healthData } = await dashboardService.fetchDashboardData({
|
||||||
|
dateStart,
|
||||||
|
dateEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
setHealthData(healthData);
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch health data";
|
||||||
|
setError(message);
|
||||||
|
setHealthData(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { dateRange, handleDateRangeChange } = useDebouncedDateRange({
|
||||||
|
initialDateRange: initialDateRange ?? DEFAULT_DATE_RANGE,
|
||||||
|
debounceMs: DEBOUNCE_MS,
|
||||||
|
onDateRangeChange: fetchHealthData,
|
||||||
|
skipInitialFetch: !!initialHealthData,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve stats source
|
||||||
|
*/
|
||||||
|
const stats = healthData?.stats ??
|
||||||
|
initialStats ?? [
|
||||||
|
{ 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%" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GeneralHealthCard = () => {
|
|
||||||
return (
|
return (
|
||||||
<Card className="general-health-card">
|
<Card className="general-health-card">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -22,21 +111,47 @@ export const GeneralHealthCard = () => {
|
|||||||
<Typography variant="h5" fontWeight="bold">
|
<Typography variant="h5" fontWeight="bold">
|
||||||
General Health
|
General Health
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box className="general-health-card__right-side">
|
<Box className="general-health-card__right-side">
|
||||||
<CalendarTodayIcon fontSize="small" />
|
<CalendarTodayIcon fontSize="small" />
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<DateRangePicker />
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={handleDateRangeChange}
|
||||||
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton size="small">
|
<IconButton size="small">
|
||||||
<MoreVertIcon fontSize="small" />
|
<MoreVertIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
!error && (
|
||||||
<Box className="general-health-card__stat-items">
|
<Box className="general-health-card__stat-items">
|
||||||
{stats.map((item, i) => (
|
{stats.map((item, i) => (
|
||||||
<StatItem key={item.label + i} {...item} />
|
<StatItem key={`${item.label}-${i}`} {...item} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
9
app/features/GeneralHealthCard/constants.ts
Normal file
9
app/features/GeneralHealthCard/constants.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Range } from "react-date-range";
|
||||||
|
|
||||||
|
export const DEFAULT_DATE_RANGE = [
|
||||||
|
{
|
||||||
|
startDate: new Date(),
|
||||||
|
endDate: new Date(),
|
||||||
|
key: "selection",
|
||||||
|
},
|
||||||
|
] as Range[];
|
||||||
0
app/features/GeneralHealthCard/interfaces.ts
Normal file
0
app/features/GeneralHealthCard/interfaces.ts
Normal file
15
app/features/GeneralHealthCard/utils.ts
Normal file
15
app/features/GeneralHealthCard/utils.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)}%`;
|
||||||
|
};
|
||||||
@ -17,8 +17,6 @@ const Users: React.FC<UsersProps> = ({ users }) => {
|
|||||||
const [showAddUser, setShowAddUser] = useState(false);
|
const [showAddUser, setShowAddUser] = useState(false);
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
|
||||||
console.log("[Users] - users", users);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<UserTopBar
|
<UserTopBar
|
||||||
|
|||||||
@ -336,11 +336,10 @@ export function ApproveTable<T extends { id: string | number }>({
|
|||||||
|
|
||||||
<StatusChangeDialog
|
<StatusChangeDialog
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
|
transactionId={selected[0] as number}
|
||||||
newStatus={action}
|
newStatus={action}
|
||||||
reason={reason}
|
onClose={() => setModalOpen(false)}
|
||||||
setReason={setReason}
|
onStatusUpdated={handleStatusSave}
|
||||||
handleClose={() => setModalOpen(false)}
|
|
||||||
handleSave={handleStatusSave}
|
|
||||||
/>
|
/>
|
||||||
<HistoryModal
|
<HistoryModal
|
||||||
open={historyModal}
|
open={historyModal}
|
||||||
|
|||||||
@ -1,26 +1,69 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Range } from "react-date-range";
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { GeneralHealthCard } from "../../GeneralHealthCard/GeneralHealthCard";
|
import { GeneralHealthCard } from "../../GeneralHealthCard/GeneralHealthCard";
|
||||||
import { TransactionsWaitingApproval } from "../../TransactionsWaitingApproval/TransactionsWaitingApproval";
|
import { TransactionsWaitingApproval } from "../../TransactionsWaitingApproval/TransactionsWaitingApproval";
|
||||||
import { FetchReport } from "../../FetchReports/FetchReports";
|
import { TransactionsOverview } from "../../TransactionsOverview/TransactionsOverview";
|
||||||
import { Documentation } from "../../Documentation/Documentation";
|
import {
|
||||||
import { AccountIQ } from "../../AccountIQ/AccountIQ";
|
type ITransactionsOverviewData,
|
||||||
import { WhatsNew } from "../../WhatsNew/WhatsNew";
|
type IReviewTransactionsData,
|
||||||
import { TransactionsOverView } from "../../TransactionsOverView/TransactionsOverview";
|
} from "@/app/services/types";
|
||||||
|
import { dashboardService } from "@/app/services/dashboardService";
|
||||||
|
|
||||||
|
interface IDashboardHomePageProps {
|
||||||
|
initialHealthData?: {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
total?: number;
|
||||||
|
successful?: number;
|
||||||
|
acceptance_rate?: number;
|
||||||
|
amount?: number;
|
||||||
|
atv?: number;
|
||||||
|
} | null;
|
||||||
|
initialStats?: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
change: string;
|
||||||
|
}> | null;
|
||||||
|
initialDateRange?: Range[];
|
||||||
|
initialOverviewData?: ITransactionsOverviewData | null;
|
||||||
|
initialReviewTransactions?: IReviewTransactionsData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardHomePage = ({
|
||||||
|
initialHealthData,
|
||||||
|
initialStats,
|
||||||
|
initialDateRange,
|
||||||
|
initialOverviewData,
|
||||||
|
initialReviewTransactions,
|
||||||
|
}: IDashboardHomePageProps) => {
|
||||||
|
// Initialize service with server data if available
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialHealthData && initialOverviewData && initialReviewTransactions) {
|
||||||
|
dashboardService.updateDashboardData({
|
||||||
|
healthData: initialHealthData,
|
||||||
|
overviewData: initialOverviewData,
|
||||||
|
reviewTransactions: initialReviewTransactions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialHealthData, initialOverviewData, initialReviewTransactions]);
|
||||||
|
|
||||||
export const DashboardHomePage = () => {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Conditional rendering of the Generic Modal, passing LoginModal as children */}
|
{/* Conditional rendering of the Generic Modal, passing LoginModal as children */}
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ p: 2 }}>
|
||||||
<GeneralHealthCard />
|
<GeneralHealthCard
|
||||||
|
initialHealthData={initialHealthData}
|
||||||
|
initialStats={initialStats}
|
||||||
|
initialDateRange={initialDateRange}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<TransactionsOverView />
|
<TransactionsOverview initialOverviewData={initialOverviewData} />
|
||||||
<FetchReport />
|
{/* <FetchReport /> */}
|
||||||
<TransactionsWaitingApproval />
|
<TransactionsWaitingApproval
|
||||||
<Documentation />
|
initialReviewTransactions={initialReviewTransactions}
|
||||||
<AccountIQ />
|
/>
|
||||||
<WhatsNew />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,26 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Box, Button, IconButton, Paper, Typography } from "@mui/material";
|
import { Box, Button, IconButton, Paper, Typography } from "@mui/material";
|
||||||
import { PieCharts } from "../PieCharts/PieCharts";
|
|
||||||
|
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
|
import { type ITransactionsOverviewData } from "@/app/services/types";
|
||||||
|
import { dashboardService } from "@/app/services/dashboardService";
|
||||||
import { TransactionsOverViewTable } from "./components/TransactionsOverViewTable";
|
import { TransactionsOverViewTable } from "./components/TransactionsOverViewTable";
|
||||||
|
import PieCharts from "../Charts/PieCharts";
|
||||||
|
import "./TransactionsOverview.scss";
|
||||||
|
|
||||||
import "./TransactionsOverView.scss";
|
interface ITransactionsOverviewProps {
|
||||||
|
initialOverviewData?: ITransactionsOverviewData | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const TransactionsOverView = () => {
|
export const TransactionsOverview = ({
|
||||||
|
initialOverviewData = null,
|
||||||
|
}: ITransactionsOverviewProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [overviewData, setOverviewData] =
|
||||||
|
useState<ITransactionsOverviewData | null>(
|
||||||
|
initialOverviewData ||
|
||||||
|
dashboardService.getCurrentDashboardData()?.overviewData ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to dashboard data changes
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = dashboardService
|
||||||
|
.getDashboardData$()
|
||||||
|
.subscribe(data => {
|
||||||
|
// console.log("[data]", data);
|
||||||
|
if (data?.overviewData) {
|
||||||
|
console.log("[OverViewData] - Subscribe", data);
|
||||||
|
setOverviewData(data.overviewData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup subscription on unmount
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
console.log("[OverviewData] - Component", overviewData);
|
||||||
|
/**
|
||||||
|
* Use transformed data from API (already includes percentages and colors)
|
||||||
|
*/
|
||||||
|
const enrichedData = overviewData?.data || [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform overview data for PieCharts (expects { name, value }[])
|
||||||
|
*/
|
||||||
|
const pieChartData = enrichedData.map(item => ({
|
||||||
|
name: item.state,
|
||||||
|
value: item.count,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="transaction-overview" elevation={3}>
|
<Paper className="transaction-overview" elevation={3}>
|
||||||
{/* Title and All Transactions Button */}
|
|
||||||
<Box className="transaction-overview__header">
|
<Box className="transaction-overview__header">
|
||||||
<Typography variant="h5" fontWeight="bold">
|
<Typography variant="h5" fontWeight="bold">
|
||||||
Transactions Overview (Last 24h)
|
Transactions Overview
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box>
|
<Box>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => router.push("dashboard/transactions")}
|
onClick={() => router.push("dashboard/transactions/all")}
|
||||||
>
|
>
|
||||||
All Transactions
|
All Transactions
|
||||||
</Button>
|
</Button>
|
||||||
@ -30,11 +78,12 @@ export const TransactionsOverView = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Chart and Table */}
|
{overviewData && (
|
||||||
<Box className="transaction-overview__chart-table">
|
<Box className="transaction-overview__chart-table">
|
||||||
<PieCharts />
|
<PieCharts data={pieChartData} />
|
||||||
<TransactionsOverViewTable />
|
<TransactionsOverViewTable data={overviewData?.data || []} />
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,14 +12,29 @@ import {
|
|||||||
|
|
||||||
import "./TransactionsOverViewTable.scss";
|
import "./TransactionsOverViewTable.scss";
|
||||||
|
|
||||||
const data1 = [
|
interface ITableData {
|
||||||
|
state: string;
|
||||||
|
count: number;
|
||||||
|
percentage: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ITransactionsOverViewTableProps {
|
||||||
|
data?: ITableData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultData: ITableData[] = [
|
||||||
{ state: "Success", count: 120, percentage: "60%", color: "green" },
|
{ state: "Success", count: 120, percentage: "60%", color: "green" },
|
||||||
{ state: "Pending", count: 50, percentage: "25%", color: "orange" },
|
{ state: "Pending", count: 50, percentage: "25%", color: "orange" },
|
||||||
{ state: "Failed", count: 20, percentage: "10%", color: "red" },
|
{ state: "Failed", count: 20, percentage: "10%", color: "red" },
|
||||||
{ state: "Other", count: 10, percentage: "5%", color: "gray" },
|
{ state: "Other", count: 10, percentage: "5%", color: "gray" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TransactionsOverViewTable = () => {
|
export const TransactionsOverViewTable = ({
|
||||||
|
data = defaultData,
|
||||||
|
}: ITransactionsOverViewTableProps) => {
|
||||||
|
console.log("data", data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer className="transactions-overview-table" component={Paper}>
|
<TableContainer className="transactions-overview-table" component={Paper}>
|
||||||
<Table>
|
<Table>
|
||||||
@ -32,8 +47,8 @@ export const TransactionsOverViewTable = () => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data1.map((row, i) => (
|
{data.map((row, i) => (
|
||||||
<TableRow key={row.state + i}>
|
<TableRow key={`${row.state}-${i}`}>
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Box className="transactions-overview-table__state-wrapper">
|
<Box className="transactions-overview-table__state-wrapper">
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
71
app/features/TransactionsOverview/utils.ts
Normal file
71
app/features/TransactionsOverview/utils.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Map transaction state to color
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
*/
|
||||||
|
export 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 if missing
|
||||||
|
*/
|
||||||
|
export 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),
|
||||||
|
}));
|
||||||
|
};
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@ -13,110 +16,80 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
import CancelIcon from "@mui/icons-material/Cancel";
|
import CancelIcon from "@mui/icons-material/Cancel";
|
||||||
|
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
|
|
||||||
import "./TransactionsWaitingApproval.scss";
|
import { dashboardService } from "@/app/services/dashboardService";
|
||||||
const transactions = [
|
import { formatToDateTimeString } from "@/app/utils/formatDate";
|
||||||
{
|
|
||||||
id: "1049078821",
|
import "./TransactionsWaitingApproval.scss";
|
||||||
user: "17",
|
import {
|
||||||
created: "2025-06-17 16:45",
|
type IReviewTransactionsData,
|
||||||
type: "BestPayWithdrawal",
|
type IReviewTransaction,
|
||||||
amount: "-787.49 TRY",
|
} from "@/app/services/types";
|
||||||
psp: "BestPay",
|
import StatusChangeDialog from "../DataTable/StatusChangeDialog";
|
||||||
},
|
|
||||||
{
|
interface ITransactionsWaitingApprovalProps {
|
||||||
id: "1049078822",
|
initialReviewTransactions?: IReviewTransactionsData | null;
|
||||||
user: "17",
|
}
|
||||||
created: "2025-06-17 16:45",
|
|
||||||
type: "BestPayWithdrawal",
|
export const TransactionsWaitingApproval = ({
|
||||||
amount: "-787.49 TRY",
|
initialReviewTransactions = null,
|
||||||
psp: "BestPay",
|
}: ITransactionsWaitingApprovalProps) => {
|
||||||
},
|
const [reviewTransactions, setReviewTransactions] =
|
||||||
{
|
useState<IReviewTransactionsData | null>(
|
||||||
id: "1049078823",
|
initialReviewTransactions ||
|
||||||
user: "17",
|
dashboardService.getCurrentDashboardData()?.reviewTransactions ||
|
||||||
created: "2025-06-17 16:45",
|
null
|
||||||
type: "BestPayWithdrawal",
|
);
|
||||||
amount: "-787.49 TRY",
|
const [statusDialogData, setStatusDialogData] = useState<{
|
||||||
psp: "BestPay",
|
rowId: number;
|
||||||
},
|
newStatus: string;
|
||||||
{
|
} | null>(null);
|
||||||
id: "1049078824",
|
|
||||||
user: "17",
|
/**
|
||||||
created: "2025-06-17 16:45",
|
* Subscribe to dashboard data changes
|
||||||
type: "BestPayWithdrawal",
|
*/
|
||||||
amount: "-787.49 TRY",
|
useEffect(() => {
|
||||||
psp: "BestPay",
|
const subscription = dashboardService
|
||||||
},
|
.getDashboardData$()
|
||||||
{
|
.subscribe(data => {
|
||||||
id: "1049078821",
|
if (data?.reviewTransactions) {
|
||||||
user: "17",
|
setReviewTransactions(data.reviewTransactions);
|
||||||
created: "2025-06-17 16:45",
|
}
|
||||||
type: "BestPayWithdrawal",
|
});
|
||||||
amount: "-787.49 TRY",
|
|
||||||
psp: "BestPay",
|
// Cleanup subscription on unmount
|
||||||
},
|
return () => subscription.unsubscribe();
|
||||||
{
|
}, []);
|
||||||
id: "1049078822",
|
|
||||||
user: "17",
|
/**
|
||||||
created: "2025-06-17 16:45",
|
* Format transaction for display
|
||||||
type: "BestPayWithdrawal",
|
*/
|
||||||
amount: "-787.49 TRY",
|
const formatTransaction = (tx: IReviewTransaction) => {
|
||||||
psp: "BestPay",
|
const createdDate = tx.created || tx.modified || "";
|
||||||
},
|
const formattedDate = createdDate
|
||||||
{
|
? formatToDateTimeString(createdDate)
|
||||||
id: "1049078823",
|
: "";
|
||||||
user: "17",
|
|
||||||
created: "2025-06-17 16:45",
|
return {
|
||||||
type: "BestPayWithdrawal",
|
id: tx.id ?? tx.external_id ?? "",
|
||||||
amount: "-787.49 TRY",
|
user: tx.customer || "",
|
||||||
psp: "BestPay",
|
created: formattedDate,
|
||||||
},
|
type: tx.type || "",
|
||||||
{
|
amount: tx.amount
|
||||||
id: "1049078824",
|
? `${tx.amount < 0 ? "-" : ""}${Math.abs(tx.amount).toFixed(2)} ${tx.currency || ""}`
|
||||||
user: "17",
|
: "",
|
||||||
created: "2025-06-17 16:45",
|
psp: tx.psp_id || "",
|
||||||
type: "BestPayWithdrawal",
|
};
|
||||||
amount: "-787.49 TRY",
|
};
|
||||||
psp: "BestPay",
|
|
||||||
},
|
const transactions = reviewTransactions?.transactions || [];
|
||||||
{
|
const displayTransactions = transactions.map(formatTransaction);
|
||||||
id: "1049078821",
|
|
||||||
user: "17",
|
const handleStatusChange = (rowId: number, newStatus: string) => {
|
||||||
created: "2025-06-17 16:45",
|
setStatusDialogData({ rowId, newStatus });
|
||||||
type: "BestPayWithdrawal",
|
};
|
||||||
amount: "-787.49 TRY",
|
|
||||||
psp: "BestPay",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "1049078822",
|
|
||||||
user: "17",
|
|
||||||
created: "2025-06-17 16:45",
|
|
||||||
type: "BestPayWithdrawal",
|
|
||||||
amount: "-787.49 TRY",
|
|
||||||
psp: "BestPay",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "1049078823",
|
|
||||||
user: "17",
|
|
||||||
created: "2025-06-17 16:45",
|
|
||||||
type: "BestPayWithdrawal",
|
|
||||||
amount: "-787.49 TRY",
|
|
||||||
psp: "BestPay",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "1049078824",
|
|
||||||
user: "17",
|
|
||||||
created: "2025-06-17 16:45",
|
|
||||||
type: "BestPayWithdrawal",
|
|
||||||
amount: "-787.49 TRY",
|
|
||||||
psp: "BestPay",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TransactionsWaitingApproval = () => {
|
|
||||||
return (
|
return (
|
||||||
<Paper elevation={3} className="transactions-waiting-approval">
|
<Paper elevation={3} className="transactions-waiting-approval">
|
||||||
<Box sx={{ p: 3 }}>
|
<Box sx={{ p: 3 }}>
|
||||||
@ -128,14 +101,15 @@ export const TransactionsWaitingApproval = () => {
|
|||||||
<Button variant="outlined">All Pending Withdrawals</Button>
|
<Button variant="outlined">All Pending Withdrawals</Button>
|
||||||
<IconButton size="small">
|
<IconButton size="small">
|
||||||
<MoreVertIcon fontSize="small" />
|
<MoreVertIcon fontSize="small" />
|
||||||
</IconButton>{" "}
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{reviewTransactions && (
|
||||||
<TableContainer
|
<TableContainer
|
||||||
component={Paper}
|
component={Paper}
|
||||||
sx={{
|
sx={{
|
||||||
maxHeight: 400, // Set desired height
|
maxHeight: 400,
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -166,8 +140,9 @@ export const TransactionsWaitingApproval = () => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{transactions.map((tx, i) => (
|
{displayTransactions.length > 0 ? (
|
||||||
<TableRow key={tx.id + i}>
|
displayTransactions.map((tx, i) => (
|
||||||
|
<TableRow key={`${tx.id}-${i}`}>
|
||||||
<TableCell>{tx.id}</TableCell>
|
<TableCell>{tx.id}</TableCell>
|
||||||
<TableCell>{tx.user}</TableCell>
|
<TableCell>{tx.user}</TableCell>
|
||||||
<TableCell>{tx.created}</TableCell>
|
<TableCell>{tx.created}</TableCell>
|
||||||
@ -175,19 +150,41 @@ export const TransactionsWaitingApproval = () => {
|
|||||||
<TableCell>{tx.amount}</TableCell>
|
<TableCell>{tx.amount}</TableCell>
|
||||||
<TableCell>{tx.psp}</TableCell>
|
<TableCell>{tx.psp}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<IconButton color="success">
|
<IconButton
|
||||||
|
color="success"
|
||||||
|
onClick={() => handleStatusChange(tx.id, "approved")}
|
||||||
|
>
|
||||||
<CheckCircleIcon />
|
<CheckCircleIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton color="error">
|
<IconButton
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleStatusChange(tx.id, "declined")}
|
||||||
|
>
|
||||||
<CancelIcon />
|
<CancelIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No transactions waiting for approval
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<StatusChangeDialog
|
||||||
|
open={Boolean(statusDialogData)}
|
||||||
|
transactionId={statusDialogData?.rowId ?? undefined}
|
||||||
|
newStatus={statusDialogData?.newStatus ?? ""}
|
||||||
|
onClose={() => setStatusDialogData(null)}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import Modal from "@/app/components/Modal/Modal";
|
import { useRouter } from "next/navigation";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { AppDispatch } from "@/app/redux/store";
|
import { AppDispatch } from "@/app/redux/store";
|
||||||
import { deleteUser, clearError } from "@/app/redux/user/userSlice";
|
import { deleteUser, clearError } from "@/app/redux/user/userSlice";
|
||||||
import { IUser } from "../../Pages/Admin/Users/interfaces";
|
import { IUser } from "../../Pages/Admin/Users/interfaces";
|
||||||
|
import Modal from "@/app/components/Modal/Modal";
|
||||||
import Spinner from "@/app/components/Spinner/Spinner";
|
import Spinner from "@/app/components/Spinner/Spinner";
|
||||||
import { RootState } from "@/app/redux/store";
|
import { RootState } from "@/app/redux/store";
|
||||||
|
import { selectUser } from "@/app/redux/auth/selectors";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import "./DeleteUser.scss";
|
import "./DeleteUser.scss";
|
||||||
|
|
||||||
interface DeleteUserProps {
|
interface DeleteUserProps {
|
||||||
@ -23,8 +23,10 @@ const DeleteUser: React.FC<DeleteUserProps> = ({ open, onClose, user }) => {
|
|||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { status, error } = useSelector((state: RootState) => state.user);
|
const { status, error } = useSelector((state: RootState) => state.user);
|
||||||
|
const currentUser = useSelector(selectUser);
|
||||||
|
|
||||||
const loading = status === "loading";
|
const loading = status === "loading";
|
||||||
|
const isSelfDeletion = currentUser?.id === user?.id;
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
@ -32,6 +34,11 @@ const DeleteUser: React.FC<DeleteUserProps> = ({ open, onClose, user }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSelfDeletion) {
|
||||||
|
toast.error("You cannot delete your own account");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resultAction = await dispatch(deleteUser(user.id));
|
const resultAction = await dispatch(deleteUser(user.id));
|
||||||
|
|
||||||
@ -60,10 +67,6 @@ const DeleteUser: React.FC<DeleteUserProps> = ({ open, onClose, user }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={handleClose} title="Delete User">
|
<Modal open={open} onClose={handleClose} title="Delete User">
|
||||||
<div className="delete-user__content">
|
<div className="delete-user__content">
|
||||||
@ -74,6 +77,7 @@ const DeleteUser: React.FC<DeleteUserProps> = ({ open, onClose, user }) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
<div className="delete-user__user-info">
|
<div className="delete-user__user-info">
|
||||||
<div className="delete-user__row">
|
<div className="delete-user__row">
|
||||||
<label className="delete-user__label">Username</label>
|
<label className="delete-user__label">Username</label>
|
||||||
@ -90,6 +94,7 @@ const DeleteUser: React.FC<DeleteUserProps> = ({ open, onClose, user }) => {
|
|||||||
<div className="delete-user__value">{user.email}</div>
|
<div className="delete-user__value">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="delete-user__error">
|
<div className="delete-user__error">
|
||||||
@ -109,7 +114,7 @@ const DeleteUser: React.FC<DeleteUserProps> = ({ open, onClose, user }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={loading}
|
disabled={loading || isSelfDeletion}
|
||||||
className="delete-user__button delete-user__button--delete"
|
className="delete-user__button delete-user__button--delete"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@ -89,8 +89,17 @@ const EditUser = ({ user }: { user: IUser }) => {
|
|||||||
// Check if this was a toggle-only update
|
// Check if this was a toggle-only update
|
||||||
const isToggle = !e || ("enabled" in e && Object.keys(e).length === 1);
|
const isToggle = !e || ("enabled" in e && Object.keys(e).length === 1);
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
let updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// If toggle, send entire user object with updated enabled field (excluding id)
|
||||||
|
if (isToggle) {
|
||||||
|
// 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
|
// Compare form fields vs original user object
|
||||||
Object.entries(form).forEach(([key, value]) => {
|
Object.entries(form).forEach(([key, value]) => {
|
||||||
const originalValue = (user as unknown as Record<string, unknown>)[key];
|
const originalValue = (user as unknown as Record<string, unknown>)[key];
|
||||||
@ -102,10 +111,6 @@ const EditUser = ({ user }: { user: IUser }) => {
|
|||||||
updates[key] = value;
|
updates[key] = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle enabled toggle separately
|
|
||||||
if (isToggle) {
|
|
||||||
updates.enabled = !(enabled ?? true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing changed — no need to call API
|
// Nothing changed — no need to call API
|
||||||
@ -114,7 +119,6 @@ const EditUser = ({ user }: { user: IUser }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[handleUpdate] - updates", updates);
|
|
||||||
try {
|
try {
|
||||||
const resultAction = await dispatch(
|
const resultAction = await dispatch(
|
||||||
updateUserDetails({ id: user.id, updates })
|
updateUserDetails({ id: user.id, updates })
|
||||||
|
|||||||
@ -41,6 +41,8 @@ export default function UserRoleCard({ user }: Props) {
|
|||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const { username, first_name, last_name, email, groups } = user;
|
const { username, first_name, last_name, email, groups } = user;
|
||||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||||
|
|
||||||
|
console.log("[UserRoleCard] - user", user);
|
||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
setIsEditing(!isEditing);
|
setIsEditing(!isEditing);
|
||||||
};
|
};
|
||||||
@ -137,7 +139,7 @@ export default function UserRoleCard({ user }: Props) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} mt={1} flexWrap="wrap">
|
<Stack direction="row" spacing={1} mt={1} flexWrap="wrap">
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
{groups.map(role => (
|
{groups?.map(role => (
|
||||||
<Chip key={role} label={role} size="small" />
|
<Chip key={role} label={role} size="small" />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
@ -150,7 +152,7 @@ export default function UserRoleCard({ user }: Props) {
|
|||||||
>
|
>
|
||||||
{isEditing && <EditUser user={user} />}
|
{isEditing && <EditUser user={user} />}
|
||||||
</div>
|
</div>
|
||||||
{openDeleteUser && (
|
{openDeleteUser && user && (
|
||||||
<DeleteUser
|
<DeleteUser
|
||||||
user={user}
|
user={user}
|
||||||
open={openDeleteUser}
|
open={openDeleteUser}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
.header {
|
.header {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
.header__toolbar {
|
.header__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
65
app/hooks/useDebouncedDateRange.ts
Normal file
65
app/hooks/useDebouncedDateRange.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { Range } from "react-date-range";
|
||||||
|
|
||||||
|
interface IUseDebouncedDateRangeOptions {
|
||||||
|
initialDateRange?: Range[];
|
||||||
|
debounceMs?: number;
|
||||||
|
onDateRangeChange?: (range: Range[]) => void | Promise<void>;
|
||||||
|
skipInitialFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDebouncedDateRange = ({
|
||||||
|
initialDateRange,
|
||||||
|
debounceMs = 1000,
|
||||||
|
onDateRangeChange,
|
||||||
|
skipInitialFetch = false,
|
||||||
|
}: IUseDebouncedDateRangeOptions = {}) => {
|
||||||
|
const [dateRange, setDateRange] = useState<Range[]>(initialDateRange ?? []);
|
||||||
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const isFirstMount = useRef(true);
|
||||||
|
|
||||||
|
const handleDateRangeChange = useCallback(
|
||||||
|
(newRange: Range[]) => {
|
||||||
|
// Update state immediately for UI responsiveness
|
||||||
|
setDateRange(newRange);
|
||||||
|
|
||||||
|
// Clear any existing debounce timeout
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip fetch on first mount if requested
|
||||||
|
if (isFirstMount.current) {
|
||||||
|
isFirstMount.current = false;
|
||||||
|
if (skipInitialFetch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRange = newRange[0];
|
||||||
|
if (!currentRange?.startDate || !currentRange?.endDate) return;
|
||||||
|
|
||||||
|
// Debounce the callback
|
||||||
|
debounceTimeoutRef.current = setTimeout(() => {
|
||||||
|
onDateRangeChange?.(newRange);
|
||||||
|
}, debounceMs);
|
||||||
|
},
|
||||||
|
[onDateRangeChange, debounceMs, skipInitialFetch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateRange,
|
||||||
|
handleDateRangeChange,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -8,7 +8,7 @@ import "../styles/globals.scss";
|
|||||||
import Modals from "./modals";
|
import Modals from "./modals";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Your App",
|
title: "Cashier BO",
|
||||||
description: "Generated by Next.js",
|
description: "Generated by Next.js",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,7 @@ const initialState: AdvancedSearchState = {
|
|||||||
filters: {},
|
filters: {},
|
||||||
pagination: {
|
pagination: {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10,
|
limit: 100,
|
||||||
},
|
},
|
||||||
status: "idle",
|
status: "idle",
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export const selectPaginationModel = createSelector(
|
|||||||
[selectPagination],
|
[selectPagination],
|
||||||
pagination => ({
|
pagination => ({
|
||||||
page: Math.max(0, (pagination.page ?? 1) - 1),
|
page: Math.max(0, (pagination.page ?? 1) - 1),
|
||||||
pageSize: pagination.limit ?? 10,
|
pageSize: pagination.limit ?? 100,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -230,7 +230,6 @@ export const addUser = createAsyncThunk<
|
|||||||
try {
|
try {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const currentUserId = state.auth.user?.id;
|
const currentUserId = state.auth.user?.id;
|
||||||
console.log("[DEBUG] [ADD-USER] [currentUserId]: ", currentUserId);
|
|
||||||
const res = await fetch("/api/auth/register", {
|
const res = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@ -239,8 +238,6 @@ export const addUser = createAsyncThunk<
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
console.log("[DEBUG] [ADD-USER] [data]: ", data);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return rejectWithValue(data.message || "Failed to create user");
|
return rejectWithValue(data.message || "Failed to create user");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,8 +37,6 @@ export const addUser = createAsyncThunk<
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
console.log("[DEBUG] [ADD-USER] [data]: ", data);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return rejectWithValue(data.message || "Failed to create user");
|
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();
|
||||||
|
}
|
||||||
@ -1,44 +1,83 @@
|
|||||||
interface GetAuditsParams {
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuditApiResponse,
|
||||||
|
AuditQueryResult,
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
extractArray,
|
||||||
|
normalizeRows,
|
||||||
|
resolveTotal,
|
||||||
|
} from "@/app/dashboard/audits/auditTransforms";
|
||||||
|
import {
|
||||||
|
AUDIT_CACHE_TAG,
|
||||||
|
AUTH_COOKIE_NAME,
|
||||||
|
BE_BASE_URL,
|
||||||
|
REVALIDATE_SECONDS,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
interface FetchAuditsParams {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
filter?: string;
|
filter?: string;
|
||||||
entity?: string;
|
entity?: string;
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAudits({
|
export async function fetchAudits({
|
||||||
limit,
|
limit = DEFAULT_PAGE_SIZE,
|
||||||
page,
|
page = 1,
|
||||||
sort,
|
sort,
|
||||||
filter,
|
filter,
|
||||||
entity,
|
entity,
|
||||||
signal,
|
}: FetchAuditsParams = {}): Promise<AuditQueryResult> {
|
||||||
}: GetAuditsParams = {}) {
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Missing auth token");
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (limit) params.set("limit", String(limit));
|
params.set("limit", String(limit));
|
||||||
if (page) params.set("page", String(page));
|
params.set("page", String(page));
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
if (filter) params.set("filter", filter);
|
if (filter) params.set("filter", filter);
|
||||||
if (entity) params.set("Entity", entity);
|
if (entity) params.set("Entity", entity);
|
||||||
|
|
||||||
const queryString = params.toString();
|
const backendUrl = `${BE_BASE_URL}/api/v1/audit${
|
||||||
const response = await fetch(
|
params.size ? `?${params.toString()}` : ""
|
||||||
`/api/dashboard/audits${queryString ? `?${queryString}` : ""}`,
|
}`;
|
||||||
{
|
|
||||||
|
const response = await fetch(backendUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
cache: "no-store",
|
headers: {
|
||||||
signal,
|
"Content-Type": "application/json",
|
||||||
}
|
Authorization: `Bearer ${token}`,
|
||||||
);
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: REVALIDATE_SECONDS,
|
||||||
|
tags: [AUDIT_CACHE_TAG],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response
|
const errorData = await response
|
||||||
.json()
|
.json()
|
||||||
.catch(() => ({ message: "Unknown error" }));
|
.catch(() => ({ message: "Failed to fetch audits" }));
|
||||||
throw new Error(errorData.message || "Failed to fetch audits");
|
throw new Error(errorData?.message || "Failed to fetch audits");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
const payload = (await response.json()) as AuditApiResponse;
|
||||||
|
const pageIndex = page - 1;
|
||||||
|
const auditEntries = extractArray(payload);
|
||||||
|
const rows = normalizeRows(auditEntries, pageIndex);
|
||||||
|
const total = resolveTotal(payload, rows.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
payload,
|
||||||
|
pageIndex,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/services/constants.ts
Normal file
22
app/services/constants.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export const AUDIT_CACHE_TAG = "audits";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export function getBaseUrl(): string {
|
||||||
|
const port = process.env.PORT || "3000";
|
||||||
|
return (
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
(process.env.VERCEL_URL
|
||||||
|
? `https://${process.env.VERCEL_URL}`
|
||||||
|
: `http://localhost:${port}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
42
app/services/dashboardService.ts
Normal file
42
app/services/dashboardService.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
import { IDashboardData } from "./types";
|
||||||
|
import { getDashboardData } from "./transactions";
|
||||||
|
|
||||||
|
class DashboardService {
|
||||||
|
private dashboardData$ = new BehaviorSubject<IDashboardData | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get observable for dashboard data
|
||||||
|
*/
|
||||||
|
getDashboardData$(): Observable<IDashboardData | null> {
|
||||||
|
return this.dashboardData$.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current dashboard data
|
||||||
|
*/
|
||||||
|
getCurrentDashboardData(): IDashboardData | null {
|
||||||
|
return this.dashboardData$.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update dashboard data (called when fetching new data)
|
||||||
|
*/
|
||||||
|
updateDashboardData(data: IDashboardData): void {
|
||||||
|
this.dashboardData$.next(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and update dashboard data
|
||||||
|
*/
|
||||||
|
async fetchDashboardData(params: {
|
||||||
|
dateStart?: string;
|
||||||
|
dateEnd?: string;
|
||||||
|
}): Promise<IDashboardData> {
|
||||||
|
const data = await getDashboardData(params);
|
||||||
|
this.updateDashboardData(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardService = new DashboardService();
|
||||||
133
app/services/health.ts
Normal file
133
app/services/health.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"use server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUTH_COOKIE_NAME,
|
||||||
|
BE_BASE_URL,
|
||||||
|
REVALIDATE_SECONDS,
|
||||||
|
HEALTH_CACHE_TAG,
|
||||||
|
} from "./constants";
|
||||||
|
import {
|
||||||
|
type IDashboardData,
|
||||||
|
type IFetchHealthDataParams,
|
||||||
|
type IHealthData,
|
||||||
|
type IReviewTransactionsData,
|
||||||
|
type ITransactionsOverviewData,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch both health and overview data concurrently
|
||||||
|
* This is optimized for initial page load
|
||||||
|
* Always includes overview data with the provided date range
|
||||||
|
*/
|
||||||
|
export async function fetchDashboardDataService({
|
||||||
|
dateStart,
|
||||||
|
dateEnd,
|
||||||
|
}: IFetchHealthDataParams): Promise<IDashboardData> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Missing auth token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParts: string[] = [];
|
||||||
|
|
||||||
|
// Add date filter if provided
|
||||||
|
if (dateStart && dateEnd) {
|
||||||
|
queryParts.push(
|
||||||
|
`Modified=BETWEEN/${encodeURIComponent(dateStart)}/${encodeURIComponent(dateEnd)}`
|
||||||
|
);
|
||||||
|
} else if (dateStart) {
|
||||||
|
queryParts.push(`Modified=>/${encodeURIComponent(dateStart)}`);
|
||||||
|
} else if (dateEnd) {
|
||||||
|
queryParts.push(`Modified=</${encodeURIComponent(dateEnd)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = queryParts.join("&");
|
||||||
|
|
||||||
|
const fetchConfig = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: REVALIDATE_SECONDS,
|
||||||
|
tags: [HEALTH_CACHE_TAG],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch all three endpoints concurrently
|
||||||
|
const [healthResponse, overviewResponse, reviewResponse] = await Promise.all([
|
||||||
|
fetch(
|
||||||
|
`${BE_BASE_URL}/api/v1/transactions/health${
|
||||||
|
queryString ? `?${queryString}` : ""
|
||||||
|
}`,
|
||||||
|
fetchConfig
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${BE_BASE_URL}/api/v1/transactions/overview${
|
||||||
|
queryString ? `?${queryString}` : ""
|
||||||
|
}`,
|
||||||
|
fetchConfig
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${BE_BASE_URL}/api/v1/transactions?limit=1000&page=1${
|
||||||
|
queryString ? `&${queryString}` : ""
|
||||||
|
}&Status==/review`,
|
||||||
|
fetchConfig
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle health data response
|
||||||
|
if (!healthResponse.ok) {
|
||||||
|
const errorData = await healthResponse
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ message: "Failed to fetch health data" }));
|
||||||
|
throw new Error(errorData?.message || "Failed to fetch health data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthData = (await healthResponse.json()) as IHealthData;
|
||||||
|
|
||||||
|
// Handle overview data response
|
||||||
|
let overviewData: ITransactionsOverviewData = {
|
||||||
|
success: false,
|
||||||
|
successful_count: 0,
|
||||||
|
waiting_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
cancelled_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!overviewResponse.ok) {
|
||||||
|
// Don't fail the whole request if overview fails, just log it
|
||||||
|
console.error("Failed to fetch transactions overview");
|
||||||
|
} else {
|
||||||
|
overviewData = (await overviewResponse.json()) as ITransactionsOverviewData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle review transactions response
|
||||||
|
let reviewTransactions: IReviewTransactionsData = {
|
||||||
|
success: false,
|
||||||
|
transactions: [],
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!reviewResponse.ok) {
|
||||||
|
// Don't fail the whole request if review transactions fail, just log it
|
||||||
|
console.error("Failed to fetch review transactions");
|
||||||
|
} else {
|
||||||
|
const reviewData = (await reviewResponse.json()) as IReviewTransactionsData;
|
||||||
|
reviewTransactions = {
|
||||||
|
success: reviewData.success ?? true,
|
||||||
|
transactions: reviewData.transactions || [],
|
||||||
|
total: reviewData.total || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthData,
|
||||||
|
overviewData,
|
||||||
|
reviewTransactions,
|
||||||
|
};
|
||||||
|
}
|
||||||
83
app/services/matcher.ts
Normal file
83
app/services/matcher.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { MATCH_CONFIGS } from "../dashboard/admin/matcher/constants";
|
||||||
|
import { MatchableEntity, MatchConfig } from "../dashboard/admin/matcher/types";
|
||||||
|
import {
|
||||||
|
normalizeEntity,
|
||||||
|
resolveCollection,
|
||||||
|
} from "../dashboard/admin/matcher/utils";
|
||||||
|
|
||||||
|
export async function getMatcherData(
|
||||||
|
matchType: string,
|
||||||
|
cookieHeader: string,
|
||||||
|
baseUrl: string
|
||||||
|
): Promise<{
|
||||||
|
sourceItems: MatchableEntity[];
|
||||||
|
targetItems: MatchableEntity[];
|
||||||
|
}> {
|
||||||
|
const config = MATCH_CONFIGS[matchType];
|
||||||
|
if (!config) {
|
||||||
|
return { sourceItems: [], targetItems: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [sourceItems, targetItems] = await Promise.all([
|
||||||
|
fetchEntities(config.sourceEndpoint, config, cookieHeader, baseUrl, true),
|
||||||
|
fetchEntities(
|
||||||
|
config.targetEndpoint,
|
||||||
|
config,
|
||||||
|
cookieHeader,
|
||||||
|
baseUrl,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { sourceItems, targetItems };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching matcher data:", error);
|
||||||
|
return { sourceItems: [], targetItems: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntities(
|
||||||
|
endpoint: string,
|
||||||
|
config: MatchConfig,
|
||||||
|
cookieHeader: string,
|
||||||
|
baseUrl: string,
|
||||||
|
isSource: boolean
|
||||||
|
): Promise<MatchableEntity[]> {
|
||||||
|
const url = new URL(`${baseUrl}${endpoint}`);
|
||||||
|
|
||||||
|
// For now, fetch all items (no pagination limit)
|
||||||
|
// In production, you might want to add pagination
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Cookie: cookieHeader,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
filters: {},
|
||||||
|
pagination: { page: 1, limit: 1000 },
|
||||||
|
sort: {},
|
||||||
|
}),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to fetch from ${endpoint}:`, response.status);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("[fetchEntities] data:", data, url.toString());
|
||||||
|
const collectionKeys = isSource
|
||||||
|
? config.sourceCollectionKeys
|
||||||
|
: config.targetCollectionKeys;
|
||||||
|
const primaryKey = isSource
|
||||||
|
? config.sourcePrimaryKey
|
||||||
|
: config.targetPrimaryKey;
|
||||||
|
|
||||||
|
const collection = resolveCollection(data, collectionKeys);
|
||||||
|
return collection.map((item, index) =>
|
||||||
|
normalizeEntity(item, primaryKey || "name", index + 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
import { getBaseUrl } from "./constants";
|
||||||
|
import { IFetchHealthDataParams, IHealthData, IDashboardData } from "./types";
|
||||||
|
|
||||||
export async function getTransactions({
|
export async function getTransactions({
|
||||||
transactionType,
|
transactionType,
|
||||||
query,
|
query,
|
||||||
@ -6,7 +9,7 @@ export async function getTransactions({
|
|||||||
query: string;
|
query: string;
|
||||||
}) {
|
}) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`,
|
`${getBaseUrl()}/api/dashboard/transactions/${transactionType}?${query}`,
|
||||||
{
|
{
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
}
|
}
|
||||||
@ -22,3 +25,52 @@ export async function getTransactions({
|
|||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side function to fetch dashboard data (health + overview) via the /api/dashboard proxy
|
||||||
|
* This function calls a single endpoint that returns both health and overview data
|
||||||
|
*/
|
||||||
|
export async function getDashboardData({
|
||||||
|
dateStart,
|
||||||
|
dateEnd,
|
||||||
|
}: IFetchHealthDataParams = {}): Promise<IDashboardData> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (dateStart) params.set("dateStart", dateStart);
|
||||||
|
if (dateEnd) params.set("dateEnd", dateEnd);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const res = await fetch(
|
||||||
|
`${getBaseUrl()}/api/dashboard${queryString ? `?${queryString}` : ""}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ message: "Unknown error" }));
|
||||||
|
throw new Error(errorData.message || `HTTP error! status: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Extract overviewData and reviewTransactions from the response
|
||||||
|
const { overviewData, reviewTransactions, ...healthDataWithStats } = data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthData: healthDataWithStats as IHealthData,
|
||||||
|
overviewData: overviewData || {
|
||||||
|
success: false,
|
||||||
|
successful_count: 0,
|
||||||
|
waiting_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
cancelled_count: 0,
|
||||||
|
},
|
||||||
|
reviewTransactions: reviewTransactions || {
|
||||||
|
success: false,
|
||||||
|
transactions: [],
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
67
app/services/types.ts
Normal file
67
app/services/types.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
export interface IHealthData {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
total?: number;
|
||||||
|
successful?: number;
|
||||||
|
acceptance_rate?: number;
|
||||||
|
amount?: number;
|
||||||
|
atv?: number;
|
||||||
|
stats?: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
change: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFetchHealthDataParams {
|
||||||
|
dateStart?: string;
|
||||||
|
dateEnd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITransactionsOverviewData {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
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;
|
||||||
|
percentage: string;
|
||||||
|
color?: string;
|
||||||
|
}>;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDashboardData {
|
||||||
|
healthData: IHealthData;
|
||||||
|
overviewData: ITransactionsOverviewData;
|
||||||
|
reviewTransactions: IReviewTransactionsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReviewTransaction {
|
||||||
|
id: number;
|
||||||
|
customer?: string;
|
||||||
|
external_id?: string;
|
||||||
|
type?: string;
|
||||||
|
currency?: string;
|
||||||
|
amount?: number;
|
||||||
|
status?: string;
|
||||||
|
created?: string;
|
||||||
|
modified?: string;
|
||||||
|
merchant_id?: string;
|
||||||
|
psp_id?: string;
|
||||||
|
method_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReviewTransactionsData {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
transactions?: IReviewTransaction[];
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
61
app/services/users.ts
Normal file
61
app/services/users.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { IUser } from "@/app/features/Pages/Admin/Users/interfaces";
|
||||||
|
import {
|
||||||
|
AUTH_COOKIE_NAME,
|
||||||
|
BE_BASE_URL,
|
||||||
|
REVALIDATE_SECONDS,
|
||||||
|
USERS_CACHE_TAG,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
interface FetchUsersParams {
|
||||||
|
limit?: number;
|
||||||
|
page?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers({
|
||||||
|
limit = DEFAULT_PAGE_SIZE,
|
||||||
|
page = 1,
|
||||||
|
}: FetchUsersParams = {}): Promise<IUser[]> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Missing auth token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", String(limit));
|
||||||
|
params.set("page", String(page));
|
||||||
|
|
||||||
|
const backendUrl = `${BE_BASE_URL}/api/v1/users${
|
||||||
|
params.size ? `?${params.toString()}` : ""
|
||||||
|
}`;
|
||||||
|
console.log("[Users] - backendUrl", backendUrl);
|
||||||
|
const response = await fetch(backendUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: REVALIDATE_SECONDS,
|
||||||
|
tags: [USERS_CACHE_TAG],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ message: "Failed to fetch users" }));
|
||||||
|
throw new Error(errorData?.message || "Failed to fetch users");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const users = Array.isArray(data) ? data : data.users || data.data || [];
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ export interface JWTPayload {
|
|||||||
role: string;
|
role: string;
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
|
MustChangePassword: boolean;
|
||||||
|
Groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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)}%`;
|
||||||
|
};
|
||||||
@ -11,3 +11,39 @@ export const formatToDateTimeString = (dateString: string): string => {
|
|||||||
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDefaultDateRange = () => {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setHours(startDate.getHours() - 24);
|
||||||
|
return {
|
||||||
|
dateStart: startDate.toISOString(),
|
||||||
|
dateEnd: endDate.toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize date range for API calls
|
||||||
|
* - Start date is set to beginning of day (00:00:00.000)
|
||||||
|
* - End date is set to end of day (23:59:59.999)
|
||||||
|
* This ensures same-day selections include the entire day
|
||||||
|
*/
|
||||||
|
export const normalizeDateRangeForAPI = (
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): { dateStart: string; dateEnd: string } => {
|
||||||
|
// Clone dates to avoid mutating originals
|
||||||
|
const normalizedStart = new Date(startDate);
|
||||||
|
const normalizedEnd = new Date(endDate);
|
||||||
|
|
||||||
|
// Set start date to beginning of day
|
||||||
|
normalizedStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Set end date to end of day
|
||||||
|
normalizedEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateStart: normalizedStart.toISOString(),
|
||||||
|
dateEnd: normalizedEnd.toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import AccountBalanceIcon from "@mui/icons-material/AccountBalance";
|
|||||||
import PaymentIcon from "@mui/icons-material/Payment";
|
import PaymentIcon from "@mui/icons-material/Payment";
|
||||||
import CurrencyExchangeIcon from "@mui/icons-material/CurrencyExchange";
|
import CurrencyExchangeIcon from "@mui/icons-material/CurrencyExchange";
|
||||||
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
|
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
|
||||||
|
import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
|
||||||
|
|
||||||
const IconMap = {
|
const IconMap = {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
@ -53,6 +54,7 @@ const IconMap = {
|
|||||||
PaymentIcon,
|
PaymentIcon,
|
||||||
CurrencyExchangeIcon,
|
CurrencyExchangeIcon,
|
||||||
ViewSidebarIcon,
|
ViewSidebarIcon,
|
||||||
|
CompareArrowsIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default IconMap;
|
export default IconMap;
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
|
import { AUTH_COOKIE_NAME } from "@/app/services/constants";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { jwtVerify } from "jose";
|
import { jwtVerify } from "jose";
|
||||||
|
|
||||||
const COOKIE_NAME = "auth_token";
|
|
||||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||||
|
|
||||||
// Define route-to-role mappings
|
// Define route-to-role mappings
|
||||||
@ -57,7 +56,6 @@ async function validateToken(token: string) {
|
|||||||
algorithms: ["HS256"],
|
algorithms: ["HS256"],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[middleware] payload", payload);
|
|
||||||
return payload as {
|
return payload as {
|
||||||
exp?: number;
|
exp?: number;
|
||||||
MustChangePassword?: boolean;
|
MustChangePassword?: boolean;
|
||||||
@ -71,7 +69,7 @@ async function validateToken(token: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const token = request.cookies.get(COOKIE_NAME)?.value;
|
const token = request.cookies.get(AUTH_COOKIE_NAME)?.value;
|
||||||
const loginUrl = new URL("/login", request.url);
|
const loginUrl = new URL("/login", request.url);
|
||||||
const currentPath = request.nextUrl.pathname;
|
const currentPath = request.nextUrl.pathname;
|
||||||
|
|
||||||
@ -87,7 +85,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
const res = NextResponse.redirect(loginUrl);
|
const res = NextResponse.redirect(loginUrl);
|
||||||
res.cookies.delete(COOKIE_NAME);
|
res.cookies.delete(AUTH_COOKIE_NAME);
|
||||||
loginUrl.searchParams.set("reason", "invalid-token");
|
loginUrl.searchParams.set("reason", "invalid-token");
|
||||||
loginUrl.searchParams.set("redirect", currentPath);
|
loginUrl.searchParams.set("redirect", currentPath);
|
||||||
return res;
|
return res;
|
||||||
@ -96,7 +94,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
// 3️⃣ Expiry check
|
// 3️⃣ Expiry check
|
||||||
if (isExpired(payload.exp)) {
|
if (isExpired(payload.exp)) {
|
||||||
const res = NextResponse.redirect(loginUrl);
|
const res = NextResponse.redirect(loginUrl);
|
||||||
res.cookies.delete(COOKIE_NAME);
|
res.cookies.delete(AUTH_COOKIE_NAME);
|
||||||
loginUrl.searchParams.set("reason", "expired-token");
|
loginUrl.searchParams.set("reason", "expired-token");
|
||||||
loginUrl.searchParams.set("redirect", currentPath);
|
loginUrl.searchParams.set("redirect", currentPath);
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
153
package-lock.json
generated
153
package-lock.json
generated
@ -24,9 +24,13 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-date-range": "^2.0.1",
|
"react-date-range": "^2.0.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
"redux-observable": "^3.0.0-rc.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
|
"swr": "^2.3.6",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -37,9 +41,13 @@
|
|||||||
"@types/react-date-range": "^1.4.10",
|
"@types/react-date-range": "^1.4.10",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-redux": "^7.1.34",
|
"@types/react-redux": "^7.1.34",
|
||||||
|
"@types/redux-persist": "^4.3.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.3",
|
"eslint-config-next": "15.3.3",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"msw": "^2.10.2",
|
"msw": "^2.10.2",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -392,6 +400,13 @@
|
|||||||
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@epic-web/invariant": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||||
@ -2361,6 +2376,17 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/redux-persist": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/redux-persist/-/redux-persist-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-YkMnMUk+4//wPtiSTMfsxST/F9Gh9sPWX0LVxHuOidGjojHtMdpep2cYvQgfiDMnj34orXyZI+QJCQMZDlafKA==",
|
||||||
|
"deprecated": "This is a stub types definition for redux-persist (https://github.com/rt2zz/redux-persist). redux-persist provides its own type definitions, so you don't need @types/redux-persist installed!",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redux-persist": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/statuses": {
|
"node_modules/@types/statuses": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
|
||||||
@ -3621,6 +3647,24 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@epic-web/invariant": "^1.0.0",
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "dist/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -3906,6 +3950,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||||
@ -4941,6 +4994,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||||
|
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@ -5080,6 +5142,22 @@
|
|||||||
"react-is": "^16.7.0"
|
"react-is": "^16.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/husky": {
|
||||||
|
"version": "9.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
|
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"husky": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/typicode"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -6355,6 +6433,22 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@ -6454,6 +6548,23 @@
|
|||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@ -6580,6 +6691,26 @@
|
|||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/redux-observable": {
|
||||||
|
"version": "3.0.0-rc.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-observable/-/redux-observable-3.0.0-rc.2.tgz",
|
||||||
|
"integrity": "sha512-gG/pWIKgSrcTyyavm2so5tc7tuyCQ47p3VdCAG6wt+CV0WGhDr50cMQHLcYKxFZSGgTm19a8ZmyfJGndmGDpYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": ">=5 <6",
|
||||||
|
"rxjs": ">=7 <8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux-persist": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": ">4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
@ -6730,6 +6861,15 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-array-concat": {
|
"node_modules/safe-array-concat": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||||
@ -7344,6 +7484,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
|
||||||
|
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
|||||||
@ -19,6 +19,9 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mui/icons-material": "^7.1.1",
|
"@mui/icons-material": "^7.1.1",
|
||||||
@ -41,6 +44,7 @@
|
|||||||
"redux-observable": "^3.0.0-rc.2",
|
"redux-observable": "^3.0.0-rc.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
|
"swr": "^2.3.6",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user