feat/build-branch #4

Open
Mitchell wants to merge 12 commits from feat/build-branch into main
102 changed files with 4464 additions and 1302 deletions

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

View File

@ -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",

View File

@ -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",

View File

@ -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" });
} }

View File

@ -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,

View File

@ -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(

View File

@ -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 });

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -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 },

View File

@ -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) {

View File

@ -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) {

View File

@ -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,
}); });

View File

@ -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);

View File

@ -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(

View 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 }
);
}
}

View File

@ -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";

View File

@ -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(

View File

@ -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) {

View File

@ -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" },
];

View File

@ -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,
});
} }

View File

@ -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(

View 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);
};

View File

@ -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(

View 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;

View 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;
}

View 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;

View 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;
}

View File

@ -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 />}

View 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",
},
];

View 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}
/>
);
}

View File

@ -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}
/> />
); );
} }

View 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>
);
}

View 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>
);
}

View 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);
}
}
}

View 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>
);
}

View 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>
);
}

View 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",
},
};

View 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}
/>
);
}

View 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,
};

View 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];
}

View 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}
/>
);
}

View File

@ -1,10 +0,0 @@
"use client";
export default function BackOfficeUsersPage() {
return (
<div>
{/* This page will now be rendered on the client-side */}
hello
</div>
);
}

View File

@ -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}
/> />
); );
} }

View File

@ -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> </>
); );
} }

View 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>
);
}

View File

@ -0,0 +1,2 @@
export const ENTITY_PREFIX = "LIKE/";
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];

View 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,
};
});

View File

@ -0,0 +1,7 @@
export default function AuditLoading() {
return (
<div className="audits-page loading-state">
<p>Loading audit logs...</p>
</div>
);
}

View File

@ -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>
); );
} }

View 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,
}));
};

View File

@ -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}
/>
);
}

View File

@ -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();

View File

@ -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() {

View 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;
}
}

View File

@ -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>
); );
}; };

View 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();
}

View File

@ -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
// ----------------------------------------------------- // -----------------------------------------------------

View File

@ -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>
); );
}; }

View File

@ -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>
</> </>

View File

@ -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

View File

@ -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);
} }

View File

@ -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>
); );

View 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[];

View 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)}%`;
};

View File

@ -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

View File

@ -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}

View File

@ -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 />
</> </>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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

View 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),
}));
};

View File

@ -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>
); );
}; };

View File

@ -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 ? (

View File

@ -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 })

View File

@ -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}

View File

@ -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;

View 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,
};
};

View File

@ -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",
}; };

View File

@ -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,

View File

@ -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,
}) })
); );

View File

@ -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");
} }

View File

@ -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");
} }

View 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();
}

View File

@ -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
View 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}`)
);
}

View 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
View 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
View 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)
);
}

View File

@ -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
View 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
View 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;
}

View File

@ -9,6 +9,8 @@ export interface JWTPayload {
role: string; role: string;
iat: number; iat: number;
exp: number; exp: number;
MustChangePassword: boolean;
Groups: string[];
} }
/** /**

View 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)}%`;
};

View File

@ -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(),
};
};

View File

@ -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;

View File

@ -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
View File

@ -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",

View File

@ -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