diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/app/api/auth/change-password/route.ts b/app/api/auth/change-password/route.ts index 399aaa4..e01c3e5 100644 --- a/app/api/auth/change-password/route.ts +++ b/app/api/auth/change-password/route.ts @@ -1,8 +1,7 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; import { NextResponse } from "next/server"; import { decodeJwt } from "jose"; - -const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583"; -const COOKIE_NAME = "auth_token"; +import { JWTPayload } from "@/app/utils/auth"; export async function POST(request: Request) { try { @@ -11,7 +10,7 @@ export async function POST(request: Request) { // Get the auth token from cookies first const { cookies } = await import("next/headers"); const cookieStore = cookies(); - const token = (await cookieStore).get(COOKIE_NAME)?.value; + const token = (await cookieStore).get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -23,15 +22,8 @@ export async function POST(request: Request) { // Check MustChangePassword flag from current token let mustChangePassword = false; try { - const payload = decodeJwt(token); - const mustChangeClaim = payload.MustChangePassword; - if (typeof mustChangeClaim === "boolean") { - mustChangePassword = mustChangeClaim; - } else if (typeof mustChangeClaim === "string") { - mustChangePassword = mustChangeClaim.toLowerCase() === "true"; - } else { - mustChangePassword = false; - } + const payload = decodeJwt(token); + mustChangePassword = payload.MustChangePassword; } catch (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 let maxAge = 60 * 60 * 12; try { - const payload = decodeJwt(newToken); + const payload = decodeJwt(newToken); if (payload?.exp) { const secondsLeft = payload.exp - Math.floor(Date.now() / 1000); if (secondsLeft > 0) maxAge = secondsLeft; @@ -99,7 +91,7 @@ export async function POST(request: Request) { } catch {} response.cookies.set({ - name: COOKIE_NAME, + name: AUTH_COOKIE_NAME, value: newToken, httpOnly: true, secure: process.env.NODE_ENV === "production", diff --git a/app/api/auth/login/route.tsx b/app/api/auth/login/route.tsx index de83096..2e9f623 100644 --- a/app/api/auth/login/route.tsx +++ b/app/api/auth/login/route.tsx @@ -1,9 +1,8 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { decodeJwt } from "jose"; - -const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583"; -const COOKIE_NAME = "auth_token"; +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; +import { JWTPayload } from "@/app/utils/auth"; export async function POST(request: Request) { try { @@ -16,8 +15,6 @@ export async function POST(request: Request) { body: JSON.stringify({ email, password }), }); - console.log("[LOGIN] resp", resp); - if (!resp.ok) { const errJson = await safeJson(resp); return NextResponse.json( @@ -41,7 +38,7 @@ export async function POST(request: Request) { let maxAge = 60 * 60 * 12; // fallback to 12h try { - const payload = decodeJwt(token); + const payload = decodeJwt(token); // Extract exp if present if (payload?.exp) { @@ -59,7 +56,7 @@ export async function POST(request: Request) { // Set the cookie const cookieStore = await cookies(); - cookieStore.set(COOKIE_NAME, token, { + cookieStore.set(AUTH_COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", diff --git a/app/api/auth/logout/route.tsx b/app/api/auth/logout/route.tsx index 0ba3c09..002d1e0 100644 --- a/app/api/auth/logout/route.tsx +++ b/app/api/auth/logout/route.tsx @@ -1,12 +1,10 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; import { NextResponse } from "next/server"; 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() { const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (token) { 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" }); } diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 31982d3..349e96f 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -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 { 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 RegisterRequest { creator: string; @@ -23,7 +27,7 @@ export async function POST(request: Request) { // Get the auth token from cookies const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -61,6 +65,8 @@ export async function POST(request: Request) { const data = await resp.json(); + revalidateTag(USERS_CACHE_TAG); + return NextResponse.json( { success: true, diff --git a/app/api/auth/reset-password/[id]/route.ts b/app/api/auth/reset-password/[id]/route.ts index 75c63bd..3aecaa4 100644 --- a/app/api/auth/reset-password/[id]/route.ts +++ b/app/api/auth/reset-password/[id]/route.ts @@ -1,11 +1,9 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; import { NextResponse, type NextRequest } from "next/server"; 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( request: NextRequest, context: { params: Promise<{ id: string }> } @@ -21,7 +19,7 @@ export async function PUT( } const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/auth/validate/route.ts b/app/api/auth/validate/route.ts index ea6b10b..bf002c1 100644 --- a/app/api/auth/validate/route.ts +++ b/app/api/auth/validate/route.ts @@ -1,12 +1,10 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; import { NextResponse } from "next/server"; 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() { const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json({ valid: false }, { status: 401 }); diff --git a/app/api/dashboard/admin/[resource]/[id]/route.ts b/app/api/dashboard/admin/[resource]/[id]/route.ts new file mode 100644 index 0000000..dad5d49 --- /dev/null +++ b/app/api/dashboard/admin/[resource]/[id]/route.ts @@ -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 } + ); + } +} diff --git a/app/api/dashboard/admin/[resource]/create/route.ts b/app/api/dashboard/admin/[resource]/create/route.ts new file mode 100644 index 0000000..4286bde --- /dev/null +++ b/app/api/dashboard/admin/[resource]/create/route.ts @@ -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 } + ); + } +} diff --git a/app/api/dashboard/admin/groups/route.ts b/app/api/dashboard/admin/[resource]/route.ts similarity index 52% rename from app/api/dashboard/admin/groups/route.ts rename to app/api/dashboard/admin/[resource]/route.ts index a7c6daa..4db9d26 100644 --- a/app/api/dashboard/admin/groups/route.ts +++ b/app/api/dashboard/admin/[resource]/route.ts @@ -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 { buildFilterParam } from "../utils"; -const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; -const COOKIE_NAME = "auth_token"; +const ALLOWED_RESOURCES = [ + "groups", + "currencies", + "permissions", + "merchants", + "sessions", + "users", +]; -export async function POST(request: NextRequest) { +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(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -18,10 +42,10 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body; const queryParams = new URLSearchParams(); - queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("limit", String(pagination.limit ?? 100)); queryParams.set("page", String(pagination.page ?? 1)); if (sort?.field && sort?.order) { @@ -33,7 +57,7 @@ export async function POST(request: NextRequest) { queryParams.set("filter", filterParam); } - const backendUrl = `${BE_BASE_URL}/api/v1/groups${ + const backendUrl = `${BE_BASE_URL}/api/v1/${resource}${ queryParams.size ? `?${queryParams.toString()}` : "" }`; @@ -43,18 +67,21 @@ export async function POST(request: NextRequest) { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - cache: "no-store", + next: { + revalidate: REVALIDATE_SECONDS, + tags: [getAdminResourceCacheTag(resource)], + }, }); if (!response.ok) { const errorData = await response .json() - .catch(() => ({ message: "Failed to fetch groups" })); + .catch(() => ({ message: `Failed to fetch ${resource}` })); return NextResponse.json( { success: false, - message: errorData?.message || "Failed to fetch groups", + message: errorData?.message || `Failed to fetch ${resource}`, }, { status: response.status } ); @@ -63,7 +90,17 @@ export async function POST(request: NextRequest) { const data = await response.json(); return NextResponse.json(data, { status: response.status }); } catch (err: unknown) { - console.error("Proxy POST /api/dashboard/admin/groups error:", err); + let resourceName = "resource"; + try { + const { resource } = await context.params; + resourceName = resource; + } catch { + // If we can't get resource, use default + } + console.error( + `Proxy POST /api/dashboard/admin/${resourceName} error:`, + err + ); const errorMessage = err instanceof Error ? err.message : "Unknown error"; return NextResponse.json( { message: "Internal server error", error: errorMessage }, diff --git a/app/api/dashboard/admin/permissions/route.ts b/app/api/dashboard/admin/permissions/route.ts index 8fb217d..ae1114a 100644 --- a/app/api/dashboard/admin/permissions/route.ts +++ b/app/api/dashboard/admin/permissions/route.ts @@ -1,14 +1,12 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; import { NextRequest, NextResponse } from "next/server"; 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) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -18,10 +16,10 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body; const queryParams = new URLSearchParams(); - queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("limit", String(pagination.limit ?? 100)); queryParams.set("page", String(pagination.page ?? 1)); if (sort?.field && sort?.order) { diff --git a/app/api/dashboard/admin/sessions/route.ts b/app/api/dashboard/admin/sessions/route.ts index 17049c6..bb6bfad 100644 --- a/app/api/dashboard/admin/sessions/route.ts +++ b/app/api/dashboard/admin/sessions/route.ts @@ -1,14 +1,12 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; import { NextRequest, NextResponse } from "next/server"; 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) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -18,10 +16,10 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body; const queryParams = new URLSearchParams(); - queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("limit", String(pagination.limit ?? 100)); queryParams.set("page", String(pagination.page ?? 1)); if (sort?.field && sort?.order) { diff --git a/app/api/dashboard/admin/users/[id]/route.ts b/app/api/dashboard/admin/users/[id]/route.ts index 9a9c5df..0f44858 100644 --- a/app/api/dashboard/admin/users/[id]/route.ts +++ b/app/api/dashboard/admin/users/[id]/route.ts @@ -1,8 +1,12 @@ // 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"; -const COOKIE_NAME = "auth_token"; +import { + 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 } // Matches API metadata field_names.users mapping @@ -75,7 +79,7 @@ export async function PUT( // Get the auth token from cookies const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -85,18 +89,27 @@ export async function PUT( } // According to swagger: /api/v1/users/{id} - const response = await fetch(`${BE_BASE_URL}/api/v1/users/${id}`, { + const requestUrl = `${BE_BASE_URL}/api/v1/users/${id}`; + const requestConfig = { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(transformedBody), - }); + }; + console.log("request", { url: requestUrl, ...requestConfig }); + + const response = await fetch(requestUrl, requestConfig); const data = await response.json(); + console.log("response", { data, status: response.status }); + if (response.ok) { + revalidateTag(USERS_CACHE_TAG); + } return NextResponse.json(data, { status: response.status }); } catch (err: unknown) { + console.error("Proxy PUT /api/v1/users/{id} error:", err); const errorMessage = err instanceof Error ? err.message : "Unknown error occurred"; return NextResponse.json( @@ -114,7 +127,7 @@ export async function DELETE( const { id } = await context.params; const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -139,6 +152,10 @@ export async function DELETE( data = { success: response.ok }; } + if (response.ok) { + revalidateTag(USERS_CACHE_TAG); + } + return NextResponse.json(data ?? { success: response.ok }, { status: response.status, }); diff --git a/app/api/dashboard/admin/users/route.ts b/app/api/dashboard/admin/users/route.ts index 759225a..ee5764a 100644 --- a/app/api/dashboard/admin/users/route.ts +++ b/app/api/dashboard/admin/users/route.ts @@ -1,13 +1,11 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -29,6 +27,7 @@ export async function GET(request: Request) { }); const data = await response.json(); + return NextResponse.json(data, { status: response.status }); } catch (err: unknown) { console.error("Proxy GET /api/v1/users/list error:", err); diff --git a/app/api/dashboard/audits/route.ts b/app/api/dashboard/audits/route.ts index dfe76b8..93f0336 100644 --- a/app/api/dashboard/audits/route.ts +++ b/app/api/dashboard/audits/route.ts @@ -1,11 +1,6 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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_PAGE = "1"; @@ -13,7 +8,7 @@ export async function GET(request: NextRequest) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -38,7 +33,7 @@ export async function GET(request: NextRequest) { 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()}` : "" }`; @@ -48,7 +43,10 @@ export async function GET(request: NextRequest) { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - cache: "no-store", + next: { + revalidate: 60, + tags: ["audits"], + }, }); if (!response.ok) { @@ -65,11 +63,8 @@ export async function GET(request: NextRequest) { } const data = await response.json(); - console.log("[AUDITS] data:", data); return NextResponse.json(data, { status: response.status }); } catch (err: unknown) { - console.log("[AUDITS] error:", err); - console.error("Proxy GET /api/v1/audits error:", err); const errorMessage = err instanceof Error ? err.message : "Unknown error"; return NextResponse.json( diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..6e84482 --- /dev/null +++ b/app/api/dashboard/route.ts @@ -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 } + ); + } +} diff --git a/app/api/dashboard/transactions/[id]/route.ts b/app/api/dashboard/transactions/[id]/route.ts index 2702f90..adfd133 100644 --- a/app/api/dashboard/transactions/[id]/route.ts +++ b/app/api/dashboard/transactions/[id]/route.ts @@ -1,8 +1,6 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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( request: NextRequest, context: { params: Promise<{ id: string }> } @@ -11,7 +9,7 @@ export async function PUT( const { id } = await context.params; const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -32,7 +30,6 @@ export async function PUT( }); const data = await upstream.json(); - console.log("[DEBUG] [TRANSACTIONS] [PUT] Response data:", data); return NextResponse.json(data, { status: upstream.status }); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; diff --git a/app/api/dashboard/transactions/deposits/route.ts b/app/api/dashboard/transactions/deposits/route.ts index d294c7c..110fb44 100644 --- a/app/api/dashboard/transactions/deposits/route.ts +++ b/app/api/dashboard/transactions/deposits/route.ts @@ -1,6 +1,5 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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 = | string @@ -13,7 +12,7 @@ export async function POST(request: NextRequest) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/dashboard/transactions/route.ts b/app/api/dashboard/transactions/route.ts index c297e66..5a74ffb 100644 --- a/app/api/dashboard/transactions/route.ts +++ b/app/api/dashboard/transactions/route.ts @@ -1,13 +1,11 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -100,8 +98,7 @@ export async function POST(request: NextRequest) { const queryString = queryParts.join("&"); 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, { method: "GET", headers: { @@ -125,7 +122,6 @@ export async function POST(request: NextRequest) { } const data = await response.json(); - console.log("[DEBUG] [TRANSACTIONS] Response data:", data); return NextResponse.json(data, { status: response.status }); } catch (err: unknown) { diff --git a/app/api/dashboard/transactions/withdrawal/mockData.ts b/app/api/dashboard/transactions/withdrawal/mockData.ts deleted file mode 100644 index 48c8fc8..0000000 --- a/app/api/dashboard/transactions/withdrawal/mockData.ts +++ /dev/null @@ -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" }, -]; diff --git a/app/api/dashboard/transactions/withdrawal/route.ts b/app/api/dashboard/transactions/withdrawal/route.ts index 52c8d3c..fa61a38 100644 --- a/app/api/dashboard/transactions/withdrawal/route.ts +++ b/app/api/dashboard/transactions/withdrawal/route.ts @@ -1,67 +1,13 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - withdrawalTransactionDummyData, - withdrawalTransactionsColumns, - withdrawalTransactionsSearchLabels, -} from "./mockData"; +import { NextResponse } from "next/server"; -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - - const userId = searchParams.get("userId"); - const status = searchParams.get("status"); - const withdrawalMethod = searchParams.get("withdrawalMethod"); - - 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( - { - error: "Invalid date range", - }, - { 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, - }); +/** + * Placeholder Whitdrawal API route. + * Keeps the module valid while the real implementation + * is being built, and makes the intent obvious to clients. + */ +export async function GET() { + return NextResponse.json( + { message: "Settings endpoint not implemented" }, + { status: 501 } + ); } diff --git a/app/api/dashboard/transactions/withdrawals/route.ts b/app/api/dashboard/transactions/withdrawals/route.ts index eafbad8..19bc5ed 100644 --- a/app/api/dashboard/transactions/withdrawals/route.ts +++ b/app/api/dashboard/transactions/withdrawals/route.ts @@ -1,6 +1,5 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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 = | string @@ -13,7 +12,7 @@ export async function POST(request: NextRequest) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/dashboard/utils/dashboard.ts b/app/api/dashboard/utils/dashboard.ts new file mode 100644 index 0000000..a36aefe --- /dev/null +++ b/app/api/dashboard/utils/dashboard.ts @@ -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); +}; diff --git a/app/api/metadata/route.ts b/app/api/metadata/route.ts index 113b8f4..911581f 100644 --- a/app/api/metadata/route.ts +++ b/app/api/metadata/route.ts @@ -1,3 +1,4 @@ +import { BE_BASE_URL } from "@/app/services/constants"; import { NextResponse } from "next/server"; // 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 cookieStore = await cookies(); const token = cookieStore.get("auth_token")?.value; - const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; if (!token) { return NextResponse.json( diff --git a/app/components/AddModal/AddModal.tsx b/app/components/AddModal/AddModal.tsx new file mode 100644 index 0000000..917b829 --- /dev/null +++ b/app/components/AddModal/AddModal.tsx @@ -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 = ({ + open, + onClose, + onConfirm, + resourceType = "item", + fields, + isLoading = false, + error = null, +}) => { + const [formData, setFormData] = useState>({}); + const [validationErrors, setValidationErrors] = useState< + Record + >({}); + + useEffect(() => { + if (open) { + const initialData: Record = {}; + fields.forEach(field => { + initialData[field.name] = field.defaultValue ?? ""; + }); + setFormData(initialData); + setValidationErrors({}); + } + }, [open, fields]); + + const handleChange = ( + e: React.ChangeEvent + ) => { + 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 = {}; + + 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 ( + +
+ + {fields.map(field => ( + + ))} + + {error && ( + + {error} + + )} + + + + + + +
+
+ ); +}; + +export default AddModal; diff --git a/app/components/AddModal/types.ts b/app/components/AddModal/types.ts new file mode 100644 index 0000000..feb3753 --- /dev/null +++ b/app/components/AddModal/types.ts @@ -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) => Promise | void; + resourceType?: string; + fields: IAddModalField[]; + isLoading?: boolean; + error?: string | null; +} diff --git a/app/components/DeleteModal/DeleteModal.tsx b/app/components/DeleteModal/DeleteModal.tsx new file mode 100644 index 0000000..2ae1c72 --- /dev/null +++ b/app/components/DeleteModal/DeleteModal.tsx @@ -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 = ({ + 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 ( + + + + Are you sure you want to delete {resourceLabel}? This + action cannot be undone. + + + {error && ( + + {error} + + )} + + + + + + + + ); +}; + +export default DeleteModal; diff --git a/app/components/DeleteModal/types.ts b/app/components/DeleteModal/types.ts new file mode 100644 index 0000000..233c3b4 --- /dev/null +++ b/app/components/DeleteModal/types.ts @@ -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; + resource: TDeleteModalResource | null; + resourceType?: string; + isLoading?: boolean; + error?: string | null; +} diff --git a/app/components/PageLinks/PageLinks.tsx b/app/components/PageLinks/PageLinks.tsx index 34f319f..62af6b4 100644 --- a/app/components/PageLinks/PageLinks.tsx +++ b/app/components/PageLinks/PageLinks.tsx @@ -15,7 +15,6 @@ interface IPageLinksProps extends ISidebarLink { // PageLinks component export default function PageLinks({ title, path, icon }: IPageLinksProps) { const Icon = resolveIcon(icon); - console.log("Icon", Icon); return ( {Icon && } diff --git a/app/dashboard/admin/constants.ts b/app/dashboard/admin/constants.ts new file mode 100644 index 0000000..d41badc --- /dev/null +++ b/app/dashboard/admin/constants.ts @@ -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", + }, +]; diff --git a/app/dashboard/admin/currencies/page.tsx b/app/dashboard/admin/currencies/page.tsx new file mode 100644 index 0000000..7cf927b --- /dev/null +++ b/app/dashboard/admin/currencies/page.tsx @@ -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 ( + + ); +} diff --git a/app/dashboard/admin/groups/page.tsx b/app/dashboard/admin/groups/page.tsx index f2e668d..dfbbfb7 100644 --- a/app/dashboard/admin/groups/page.tsx +++ b/app/dashboard/admin/groups/page.tsx @@ -1,6 +1,21 @@ import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; +import { IAddModalField } from "@/app/components/AddModal/types"; +import { GROUP_FIELDS } from "../constants"; +import { fetchAdminResource } from "@/app/services/adminResources"; + +export default async function GroupsPage() { + let initialData = null; + + try { + initialData = await fetchAdminResource({ + resource: "groups", + pagination: { page: 1, limit: 100 }, + }); + } catch (error) { + console.error("Failed to fetch groups:", error); + // Continue without initial data - component will fetch client-side + } -export default function GroupsPage() { return ( ); } diff --git a/app/dashboard/admin/matcher/MatcherBoard/DraggableItem.tsx b/app/dashboard/admin/matcher/MatcherBoard/DraggableItem.tsx new file mode 100644 index 0000000..2b7fcac --- /dev/null +++ b/app/dashboard/admin/matcher/MatcherBoard/DraggableItem.tsx @@ -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 ( + + + + {item.name} + + {item.id && ( + + ID: {item.id} + + )} + + + ); +} diff --git a/app/dashboard/admin/matcher/MatcherBoard/DroppableColumn.tsx b/app/dashboard/admin/matcher/MatcherBoard/DroppableColumn.tsx new file mode 100644 index 0000000..fd1b3e6 --- /dev/null +++ b/app/dashboard/admin/matcher/MatcherBoard/DroppableColumn.tsx @@ -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 ( + + + {title} + + + {items.map(item => ( + + ))} + {items.length === 0 && ( + + + Drop items here + + + )} + + + + ); +} diff --git a/app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.scss b/app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.scss new file mode 100644 index 0000000..435295f --- /dev/null +++ b/app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.scss @@ -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); + } + } +} diff --git a/app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.tsx b/app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.tsx new file mode 100644 index 0000000..301d9e9 --- /dev/null +++ b/app/dashboard/admin/matcher/MatcherBoard/MatcherBoard.tsx @@ -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(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 ( + + + + + + + {activeItem ? ( + + + + {activeItem.name} + + + + ) : null} + + + ); +} diff --git a/app/dashboard/admin/matcher/MatcherPageClient.tsx b/app/dashboard/admin/matcher/MatcherPageClient.tsx new file mode 100644 index 0000000..35ef9cb --- /dev/null +++ b/app/dashboard/admin/matcher/MatcherPageClient.tsx @@ -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 ( + + + + Entity Matcher + + + Match Type + + + + + + Drag items from the left column to the right column to create matches. + You can also drag items back to remove matches. + + + + + ); +} diff --git a/app/dashboard/admin/matcher/constants.ts b/app/dashboard/admin/matcher/constants.ts new file mode 100644 index 0000000..0b37cf6 --- /dev/null +++ b/app/dashboard/admin/matcher/constants.ts @@ -0,0 +1,48 @@ +import { MatchConfig } from "./types"; + +export const MATCH_CONFIGS: Record = { + "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", + }, +}; diff --git a/app/dashboard/admin/matcher/page.tsx b/app/dashboard/admin/matcher/page.tsx new file mode 100644 index 0000000..e97e375 --- /dev/null +++ b/app/dashboard/admin/matcher/page.tsx @@ -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>; +}) { + 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 ( + + ); +} diff --git a/app/dashboard/admin/matcher/types.ts b/app/dashboard/admin/matcher/types.ts new file mode 100644 index 0000000..5087723 --- /dev/null +++ b/app/dashboard/admin/matcher/types.ts @@ -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, +}; diff --git a/app/dashboard/admin/matcher/utils.ts b/app/dashboard/admin/matcher/utils.ts new file mode 100644 index 0000000..9b38e43 --- /dev/null +++ b/app/dashboard/admin/matcher/utils.ts @@ -0,0 +1,72 @@ +import { MatchableEntity } from "./types"; + +const DEFAULT_COLLECTION_KEYS = ["data", "items"]; + +export function resolveCollection( + payload: unknown, + preferredKeys: string[] = [] +): Record[] { + if (typeof payload !== "object" || payload === null) { + return []; + } + + const obj = payload as Record; + + for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) { + const maybeCollection = obj?.[key]; + if (Array.isArray(maybeCollection)) { + return maybeCollection as Record[]; + } + } + + if (Array.isArray(payload)) { + return payload as Record[]; + } + + return []; +} + +export function normalizeEntity( + item: Record, + 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]; +} diff --git a/app/dashboard/admin/merchants/page.tsx b/app/dashboard/admin/merchants/page.tsx new file mode 100644 index 0000000..18954b5 --- /dev/null +++ b/app/dashboard/admin/merchants/page.tsx @@ -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 ( + + ); +} diff --git a/app/dashboard/admin/page.tsx b/app/dashboard/admin/page.tsx deleted file mode 100644 index 4e7a55d..0000000 --- a/app/dashboard/admin/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; - -export default function BackOfficeUsersPage() { - return ( -
- {/* This page will now be rendered on the client-side */} - hello -
- ); -} diff --git a/app/dashboard/admin/permissions/page.tsx b/app/dashboard/admin/permissions/page.tsx index cde9367..13a6818 100644 --- a/app/dashboard/admin/permissions/page.tsx +++ b/app/dashboard/admin/permissions/page.tsx @@ -1,8 +1,21 @@ -"use client"; - import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; +import { IAddModalField } from "@/app/components/AddModal/types"; +import { PERMISSION_FIELDS } from "../constants"; +import { fetchAdminResource } from "@/app/services/adminResources"; + +export default async function PermissionsPage() { + let initialData = null; + + try { + initialData = await fetchAdminResource({ + resource: "permissions", + pagination: { page: 1, limit: 100 }, + }); + } catch (error) { + console.error("Failed to fetch permissions server-side:", error); + // Continue without initial data - component will fetch client-side + } -export default function PermissionsPage() { return ( ); } diff --git a/app/dashboard/admin/users/page.tsx b/app/dashboard/admin/users/page.tsx index ed1e353..65d7eb3 100644 --- a/app/dashboard/admin/users/page.tsx +++ b/app/dashboard/admin/users/page.tsx @@ -1,59 +1,19 @@ 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({ - searchParams, -}: { - searchParams: Promise>; -}) { - // Await searchParams and extract pagination - const params = await searchParams; - const limit = params.limit || "10"; - const page = params.page || "1"; +export default async function BackOfficeUsersPage() { + let users: IUser[] = []; - // Build absolute URL for server-side fetch - // In server components, fetch requires absolute URLs - const port = process.env.PORT || "3000"; - const baseUrl = - 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 ( -
- -
- ); + try { + users = await fetchUsers(); + } catch (error) { + console.error("Failed to fetch users:", error); } - 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 ( -
+ <> -
+ ); } diff --git a/app/dashboard/audits/AuditTableClient.tsx b/app/dashboard/audits/AuditTableClient.tsx new file mode 100644 index 0000000..98abfd5 --- /dev/null +++ b/app/dashboard/audits/AuditTableClient.tsx @@ -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( + () => ({ + 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( + 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) => { + 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 ( +
+ + handleEntitySearchChange(e.target.value)} + sx={{ width: 300, backgroundColor: "#f0f0f0" }} + /> + +

{pageTitle}

+
+
+
+ +
+
+
+
+ ); +} diff --git a/app/dashboard/audits/auditConstants.ts b/app/dashboard/audits/auditConstants.ts new file mode 100644 index 0000000..8895bb6 --- /dev/null +++ b/app/dashboard/audits/auditConstants.ts @@ -0,0 +1,2 @@ +export const ENTITY_PREFIX = "LIKE/"; +export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; diff --git a/app/dashboard/audits/auditTransforms.ts b/app/dashboard/audits/auditTransforms.ts new file mode 100644 index 0000000..77cbf9f --- /dev/null +++ b/app/dashboard/audits/auditTransforms.ts @@ -0,0 +1,137 @@ +export type AuditRow = Record & { 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) + : 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; + + const normalized: Record = {}; + 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, + }; + }); diff --git a/app/dashboard/audits/loading.tsx b/app/dashboard/audits/loading.tsx new file mode 100644 index 0000000..964a770 --- /dev/null +++ b/app/dashboard/audits/loading.tsx @@ -0,0 +1,7 @@ +export default function AuditLoading() { + return ( +
+

Loading audit logs...

+
+ ); +} diff --git a/app/dashboard/audits/page.tsx b/app/dashboard/audits/page.tsx index 5217065..c4a3309 100644 --- a/app/dashboard/audits/page.tsx +++ b/app/dashboard/audits/page.tsx @@ -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 TextField from "@mui/material/TextField"; -import { Box, debounce } from "@mui/material"; -type AuditRow = Record & { 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 { - total?: number; - 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 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; - } - - if (typeof value === "boolean") { - return value ? "true" : "false"; - } - - return JSON.stringify(value); +type AuditPageProps = { + searchParams?: Promise>; }; -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()); - -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 toSingleValue = (value?: string | string[]): string | undefined => { + if (Array.isArray(value)) return value[0]; + return value; }; -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) - : 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 clampNumber = (value: number, min: number, max?: number) => { + if (Number.isNaN(value)) return min; + if (value < min) return min; + if (typeof max === "number" && value > max) return max; + return value; }; -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; +export default async function AuditPage({ searchParams }: AuditPageProps) { + const params = searchParams ? await searchParams : {}; + const pageParam = toSingleValue(params?.page); + const limitParam = toSingleValue(params?.limit); + const sortParam = toSingleValue(params?.sort) || undefined; + const entityQuery = toSingleValue(params?.entity)?.trim() || ""; - return ( - (typeof fromPayload === "number" && fromPayload) || - (typeof fromMeta === "number" && fromMeta) || - (typeof fromPagination === "number" && fromPagination) || - (typeof fromData === "number" && fromData) || - fallback - ); -}; + const page = clampNumber(parseInt(pageParam || "1", 10), 1); + const parsedLimit = parseInt(limitParam || String(DEFAULT_PAGE_SIZE), 10); + const limit = + PAGE_SIZE_OPTIONS.find(size => size === parsedLimit) ?? DEFAULT_PAGE_SIZE; -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; - - const normalized: Record = {}; - 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, - }; + const data = await fetchAudits({ + limit, + page, + sort: sortParam, + entity: entityQuery ? `${ENTITY_PREFIX}${entityQuery}` : undefined, }); -export default function AuditPage() { - const [rows, setRows] = useState([]); - const [columns, setColumns] = useState([]); - const [rowCount, setRowCount] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [paginationModel, setPaginationModel] = useState({ - page: 0, - pageSize: DEFAULT_PAGE_SIZE, - }); - const [sortModel, setSortModel] = useState([]); - const [entitySearch, setEntitySearch] = useState(""); - const [entitySearchInput, setEntitySearchInput] = useState(""); - - 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, - entity: entityParam, - 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 ( -
- - handleEntitySearchChange(e.target.value)} - sx={{ width: 300, backgroundColor: "#f0f0f0" }} - /> - -

{pageTitle}

- {error && ( -
- {error} -
- )} -
-
-
- -
-
-
-
+ ); } diff --git a/app/dashboard/audits/utils.ts b/app/dashboard/audits/utils.ts new file mode 100644 index 0000000..6fe4f1f --- /dev/null +++ b/app/dashboard/audits/utils.ts @@ -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, + })); +}; diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 707967c..ad53ec8 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,9 +1,51 @@ -"use client"; - +import { transformOverviewResponse } from "../api/dashboard/utils/dashboard"; import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage"; +import { fetchDashboardDataService } from "../services/health"; +import { ITransactionsOverviewData } from "../services/types"; +import { getDefaultDateRange } from "../utils/formatDate"; -const DashboardPage = () => { - return ; -}; +export default async function DashboardPage() { + // 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 ( + + ); +} diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx index 9ccc2ab..767ad41 100644 --- a/app/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -1,19 +1,5 @@ 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() { // const user = await getUser(); diff --git a/app/dashboard/transactions/deposits/page.tsx b/app/dashboard/transactions/deposits/page.tsx index 97d269f..8c660e5 100644 --- a/app/dashboard/transactions/deposits/page.tsx +++ b/app/dashboard/transactions/deposits/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import DataTable from "@/app/features/DataTable/DataTable"; import { useDispatch, useSelector } from "react-redux"; import { AppDispatch } from "@/app/redux/store"; @@ -12,7 +13,6 @@ import { setStatus, setError as setAdvancedSearchError, } from "@/app/redux/advanedSearch/advancedSearchSlice"; -import { useEffect, useMemo, useState } from "react"; import { TransactionRow, BackendTransaction } from "../interface"; export default function DepositTransactionPage() { diff --git a/app/features/AdminList/AdminResourceList.scss b/app/features/AdminList/AdminResourceList.scss new file mode 100644 index 0000000..055d11b --- /dev/null +++ b/app/features/AdminList/AdminResourceList.scss @@ -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; + } +} diff --git a/app/features/AdminList/AdminResourceList.tsx b/app/features/AdminList/AdminResourceList.tsx index 4695d26..beddd07 100644 --- a/app/features/AdminList/AdminResourceList.tsx +++ b/app/features/AdminList/AdminResourceList.tsx @@ -1,32 +1,43 @@ "use client"; import Spinner from "@/app/components/Spinner/Spinner"; +import DeleteModal from "@/app/components/DeleteModal/DeleteModal"; +import { TDeleteModalResource } from "@/app/components/DeleteModal/types"; +import AddModal from "@/app/components/AddModal/AddModal"; +import { IAddModalField } from "@/app/components/AddModal/types"; import { DataRowBase } from "@/app/features/DataTable/types"; import { - setError as setAdvancedSearchError, - setStatus, -} from "@/app/redux/advanedSearch/advancedSearchSlice"; -import { - selectError, selectFilters, selectPagination, selectSort, - selectStatus, } from "@/app/redux/advanedSearch/selectors"; -import { AppDispatch } from "@/app/redux/store"; import { Alert, - Box, + Button, Chip, - Divider, - List, - ListItem, + IconButton, + Switch, + TextField, + Tooltip, Typography, } from "@mui/material"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import EditIcon from "@mui/icons-material/Edit"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; import { useEffect, useMemo, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; -type ResourceRow = DataRowBase & Record; +import { + createResourceApi, + deleteResourceApi, + updateResourceApi, +} from "./AdminResourceList.utils"; +import "./AdminResourceList.scss"; + +type ResourceRow = DataRowBase & { + identifier?: string | number; +} & Record; type FilterValue = | string @@ -43,6 +54,10 @@ interface AdminResourceListProps { chipKeys?: string[]; excludeKeys?: string[]; filterOverrides?: Record; + addModalFields?: IAddModalField[]; + showEnabledToggle?: boolean; + idField?: string; + initialData?: Record; } const DEFAULT_COLLECTION_KEYS = ["data", "items"]; @@ -57,13 +72,14 @@ const ensureRowId = ( return row as ResourceRow; } + const identifier = currentId; const numericId = Number(currentId); if (!Number.isNaN(numericId) && numericId !== 0) { - return { ...row, id: numericId } as ResourceRow; + return { ...row, id: numericId, identifier } as ResourceRow; } - return { ...row, id: fallbackId } as ResourceRow; + return { ...row, id: fallbackId, identifier } as ResourceRow; }; const resolveCollection = ( @@ -91,18 +107,50 @@ const AdminResourceList = ({ primaryLabelKeys, chipKeys = [], excludeKeys = [], + addModalFields, + showEnabledToggle = false, + idField, + initialData, }: AdminResourceListProps) => { - const dispatch = useDispatch(); + // Keep Redux for shared state (filters, pagination, sort) const filters = useSelector(selectFilters); const pagination = useSelector(selectPagination); const sort = useSelector(selectSort); - const status = useSelector(selectStatus); - const errorMessage = useSelector(selectError); - const [rows, setRows] = useState([]); + // Use local state for component-specific status/error + const [localStatus, setLocalStatus] = useState< + "idle" | "loading" | "succeeded" | "failed" + >(initialData ? "succeeded" : "idle"); + const [localError, setLocalError] = useState(null); + + const [refreshCounter, setRefreshCounter] = useState(0); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [resourceToDelete, setResourceToDelete] = + useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [addModalOpen, setAddModalOpen] = useState(false); + const [isAdding, setIsAdding] = useState(false); + const [addError, setAddError] = useState(null); + const [editingRowId, setEditingRowId] = useState( + null + ); + const [editingName, setEditingName] = useState(""); + const [editingRate, setEditingRate] = useState(""); + const [isSavingName, setIsSavingName] = useState(false); + const [updatingEnabled, setUpdatingEnabled] = useState>( + new Set() + ); const normalizedTitle = title.toLowerCase(); + const getRowIdentifier = (row: ResourceRow): number | string => { + if (idField && row[idField] !== undefined) { + return row[idField] as number | string; + } + return (row.identifier as string | number | undefined) ?? row.id; + }; + const excludedKeys = useMemo(() => { const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]); excludeKeys.forEach(key => baseExcluded.add(key)); @@ -134,10 +182,48 @@ const AdminResourceList = ({ [responseCollectionKeys] ); + // Initialize rows synchronously from initialData if available + const getInitialRows = (): ResourceRow[] => { + if (initialData) { + const collection = resolveCollection(initialData, resolvedCollectionKeys); + return collection.map((item, index) => { + const row = ensureRowId(item, index + 1); + // If idField is specified, use that field as identifier + if (idField && row[idField] !== undefined) { + return { ...row, identifier: row[idField] } as ResourceRow; + } + return row; + }); + } + return []; + }; + + const [rows, setRows] = useState(() => getInitialRows()); + useEffect(() => { + // Skip initial fetch if we have server-rendered data and no filters/pagination/sort changes + // Always fetch when filters/pagination/sort change or after mutations (refreshCounter > 0) + const hasFilters = Object.keys(filters).length > 0; + const hasSort = sort?.field && sort?.order; + + // Only skip if we have initialData AND it's the first render (refreshCounter === 0) AND no filters/sort + // Also check if pagination is at default values (page 1, limit 100) + const isDefaultPagination = + pagination.page === 1 && pagination.limit === 100; + const shouldSkipInitialFetch = + initialData && + refreshCounter === 0 && + !hasFilters && + !hasSort && + isDefaultPagination; + + if (shouldSkipInitialFetch) { + return; + } + const fetchResources = async () => { - dispatch(setStatus("loading")); - dispatch(setAdvancedSearchError(null)); + setLocalStatus("loading"); + setLocalError(null); try { const response = await fetch(endpoint, { @@ -151,10 +237,9 @@ const AdminResourceList = ({ }); if (!response.ok) { - dispatch( - setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`) - ); + setLocalError(`Failed to fetch ${normalizedTitle}`); setRows([]); + setLocalStatus("failed"); return; } @@ -164,98 +249,334 @@ const AdminResourceList = ({ resolvedCollectionKeys ); - const nextRows = collection.map((item, index) => - ensureRowId(item, index + 1) - ); + const nextRows = collection.map((item, index) => { + const row = ensureRowId(item, index + 1); + // If idField is specified, use that field as identifier + if (idField && row[idField] !== undefined) { + return { ...row, identifier: row[idField] } as ResourceRow; + } + return row; + }); setRows(nextRows); - dispatch(setStatus("succeeded")); + setLocalStatus("succeeded"); } catch (error) { - dispatch( - setAdvancedSearchError( - error instanceof Error ? error.message : "Unknown error" - ) - ); + setLocalError(error instanceof Error ? error.message : "Unknown error"); setRows([]); + setLocalStatus("failed"); } }; fetchResources(); }, [ - dispatch, endpoint, filters, pagination, sort, resolvedCollectionKeys, normalizedTitle, + refreshCounter, + idField, + initialData, ]); - return ( - - - {title} - + const handleAddClick = () => { + if (!addModalFields) { + return; + } + setAddModalOpen(true); + setAddError(null); + }; - {status === "loading" && ( - + const handleAddConfirm = async (data: Record) => { + 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 = { + 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 ( +
+
+ + {title} + + + {addModalFields && ( + + )} +
+ + {localStatus === "loading" && ( +
{`Loading ${normalizedTitle}...`} - +
)} - {status === "failed" && ( - - {errorMessage || `Failed to load ${normalizedTitle}`} + {localStatus === "failed" && ( + + {localError || `Failed to load ${normalizedTitle}`} )} - {!rows.length && status === "succeeded" && ( - + {!rows.length && localStatus === "succeeded" && ( + {`No ${normalizedTitle} found.`} )} {rows.length > 0 && ( - +
{rows.map(row => { const chips = getMetaChips(row); const secondary = getSecondaryDetails(row); return ( - - - - - - {getPrimaryLabel(row)} - +
+
+
+
+ {editingRowId === row.id ? ( +
+ 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 && ( + 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" + /> + )} +
+ ) : ( + + {getPrimaryLabel(row)} + + )} - + ID: {row.id} - +
- {chips.length > 0 && ( - + {chips.length > 0 && editingRowId !== row.id && ( +
{chips.map(chip => ( ))} - +
)} {secondary.length > 0 && ( - + {secondary .map(([key, value]) => `${key}: ${String(value)}`) .join(" • ")} )} -
- - - +
+ + {(addModalFields || showEnabledToggle) && ( +
+ {showEnabledToggle && ( + <> + + Enabled + + + handleEnabledToggle(row, e.target.checked) + } + disabled={updatingEnabled.has( + (row.identifier as string | number | undefined) ?? + row.id + )} + size="small" + /> + + )} + {addModalFields && ( + <> + {editingRowId === row.id ? ( + <> + + handleEditSave(row)} + disabled={isSavingName} + size="small" + color="primary" + > + {isSavingName ? ( + + ) : ( + + )} + + + + + + + + + ) : ( + + handleEditClick(row)} + size="small" + > + + + + )} + + handleDeleteClick(row)} + size="small" + > + + + + + )} +
+ )} +
+
+
); })} - +
)} -
+ + {addModalFields && ( + <> + + + + )} +
); }; diff --git a/app/features/AdminList/AdminResourceList.utils.ts b/app/features/AdminList/AdminResourceList.utils.ts new file mode 100644 index 0000000..576cdfc --- /dev/null +++ b/app/features/AdminList/AdminResourceList.utils.ts @@ -0,0 +1,146 @@ +export type TCreateResourcePayload = Record; + +/** + * 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; + +/** + * 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): { + data: Record; + fields: string[]; +} { + const data: Record = {}; + 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(); +} diff --git a/app/features/AdvancedSearch/AdvancedSearch.tsx b/app/features/AdvancedSearch/AdvancedSearch.tsx index 97088f1..16b2453 100644 --- a/app/features/AdvancedSearch/AdvancedSearch.tsx +++ b/app/features/AdvancedSearch/AdvancedSearch.tsx @@ -45,7 +45,6 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) { const [operators, setOperators] = useState>({}); const conditionOperators = useSelector(selectConditionOperators); - console.log("[conditionOperators]", conditionOperators); // ----------------------------------------------------- // SYNC REDUX FILTERS TO LOCAL STATE ON LOAD // ----------------------------------------------------- diff --git a/app/features/PieCharts/PieCharts.scss b/app/features/Charts/PieCharts.scss similarity index 100% rename from app/features/PieCharts/PieCharts.scss rename to app/features/Charts/PieCharts.scss diff --git a/app/features/PieCharts/PieCharts.tsx b/app/features/Charts/PieCharts.tsx similarity index 87% rename from app/features/PieCharts/PieCharts.tsx rename to app/features/Charts/PieCharts.tsx index 77a3ac8..4abe27b 100644 --- a/app/features/PieCharts/PieCharts.tsx +++ b/app/features/Charts/PieCharts.tsx @@ -2,15 +2,25 @@ import { Box } from "@mui/material"; import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; 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 B", value: 200 }, { name: "Group C", value: 400 }, { name: "Group D", value: 300 }, ]; -const COLORS = ["#4caf50", "#ff9800", "#f44336", "#9e9e9e"]; - const RADIAN = Math.PI / 180; const renderCustomizedLabel = ({ cx, @@ -37,7 +47,7 @@ const renderCustomizedLabel = ({ ); }; -export const PieCharts = () => { +export default function PieCharts({ data = defaultData }: IPieChartsProps) { return ( @@ -63,4 +73,4 @@ export const PieCharts = () => { ); -}; +} diff --git a/app/features/DataTable/DataTable.tsx b/app/features/DataTable/DataTable.tsx index 1816a8b..9a22f26 100644 --- a/app/features/DataTable/DataTable.tsx +++ b/app/features/DataTable/DataTable.tsx @@ -33,14 +33,10 @@ const DataTable = ({ }: DataTableProps) => { const dispatch = useDispatch(); const [showExtraColumns, setShowExtraColumns] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [selectedRowId, setSelectedRowId] = useState(null); - const [pendingStatus, setPendingStatus] = useState(""); - const [reason, setReason] = useState(""); - const [statusUpdateError, setStatusUpdateError] = useState( - null - ); - const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const [statusDialogData, setStatusDialogData] = useState<{ + rowId: number; + newStatus: string; + } | null>(null); const status = useSelector(selectStatus); const errorMessage = useSelector(selectError); @@ -49,7 +45,6 @@ const DataTable = ({ const handlePaginationModelChange = useCallback( (model: GridPaginationModel) => { - console.log("model", model); const nextPage = model.page + 1; const nextLimit = model.pageSize; @@ -61,57 +56,12 @@ const DataTable = ({ ); const handleStatusChange = useCallback((rowId: number, newStatus: string) => { - setSelectedRowId(rowId); - setPendingStatus(newStatus); - setModalOpen(true); + setStatusDialogData({ rowId, newStatus }); }, []); - const handleStatusSave = async () => { - if (!selectedRowId || !pendingStatus) return; - 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 handleDialogClose = useCallback(() => { + setStatusDialogData(null); + }, []); const selectEnhancedColumns = useMemo(makeSelectEnhancedColumns, []); @@ -170,20 +120,10 @@ const DataTable = ({ { - setModalOpen(false); - setReason(""); - setPendingStatus(""); - setStatusUpdateError(null); - setSelectedRowId(null); - }} - handleSave={handleStatusSave} - isSubmitting={isUpdatingStatus} - errorMessage={statusUpdateError} + open={Boolean(statusDialogData)} + transactionId={statusDialogData?.rowId} + newStatus={statusDialogData?.newStatus ?? ""} + onClose={handleDialogClose} /> diff --git a/app/features/DataTable/StatusChangeDialog.tsx b/app/features/DataTable/StatusChangeDialog.tsx index aa66c8e..c75d4bb 100644 --- a/app/features/DataTable/StatusChangeDialog.tsx +++ b/app/features/DataTable/StatusChangeDialog.tsx @@ -8,39 +8,90 @@ import { Alert, CircularProgress, } from "@mui/material"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; interface StatusChangeDialogProps { open: boolean; + transactionId?: number | null; newStatus: string; - reason: string; - setReason: React.Dispatch>; - handleClose: () => void; - handleSave: () => void; - isSubmitting?: boolean; - errorMessage?: string | null; + onClose: () => void; + onStatusUpdated?: () => void; } const StatusChangeDialog = ({ open, + transactionId, newStatus, - reason, - setReason, - handleClose, - handleSave, - isSubmitting = false, - errorMessage, + onClose, + onStatusUpdated, }: StatusChangeDialogProps) => { - const [isValid, setIsValid] = useState(false); + const [reason, setReason] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { + if (!open) { + setReason(""); + setErrorMessage(null); + setIsSubmitting(false); + } + }, [open]); + + const isValid = useMemo(() => { const noSpaces = reason.replace(/\s/g, ""); const length = noSpaces.length; - setIsValid(length >= 12 && length <= 400); + return length >= 12 && length <= 400; }, [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 ( - + Change Status You want to change the status to {newStatus}. Please provide a @@ -63,13 +114,13 @@ const StatusChangeDialog = ({ )} - @@ -30,11 +78,12 @@ export const TransactionsOverView = () => { - {/* Chart and Table */} - - - - + {overviewData && ( + + + + + )} ); }; diff --git a/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx b/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx index dd16547..a552a15 100644 --- a/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx +++ b/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx @@ -12,14 +12,29 @@ import { 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: "Pending", count: 50, percentage: "25%", color: "orange" }, { state: "Failed", count: 20, percentage: "10%", color: "red" }, { state: "Other", count: 10, percentage: "5%", color: "gray" }, ]; -export const TransactionsOverViewTable = () => { +export const TransactionsOverViewTable = ({ + data = defaultData, +}: ITransactionsOverViewTableProps) => { + console.log("data", data); + return ( @@ -32,8 +47,8 @@ export const TransactionsOverViewTable = () => { - {data1.map((row, i) => ( - + {data.map((row, i) => ( + { + 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), + })); +}; diff --git a/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx b/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx index 34ddc01..a7dc637 100644 --- a/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx +++ b/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useEffect, useState } from "react"; import { Box, Button, @@ -13,110 +16,80 @@ import { } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CancelIcon from "@mui/icons-material/Cancel"; - import MoreVertIcon from "@mui/icons-material/MoreVert"; -import "./TransactionsWaitingApproval.scss"; -const transactions = [ - { - id: "1049078821", - user: "17", - created: "2025-06-17 16:45", - 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", - }, - { - id: "1049078821", - user: "17", - created: "2025-06-17 16:45", - 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", - }, - { - id: "1049078821", - user: "17", - created: "2025-06-17 16:45", - 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", - }, -]; +import { dashboardService } from "@/app/services/dashboardService"; +import { formatToDateTimeString } from "@/app/utils/formatDate"; + +import "./TransactionsWaitingApproval.scss"; +import { + type IReviewTransactionsData, + type IReviewTransaction, +} from "@/app/services/types"; +import StatusChangeDialog from "../DataTable/StatusChangeDialog"; + +interface ITransactionsWaitingApprovalProps { + initialReviewTransactions?: IReviewTransactionsData | null; +} + +export const TransactionsWaitingApproval = ({ + initialReviewTransactions = null, +}: ITransactionsWaitingApprovalProps) => { + const [reviewTransactions, setReviewTransactions] = + useState( + initialReviewTransactions || + dashboardService.getCurrentDashboardData()?.reviewTransactions || + null + ); + const [statusDialogData, setStatusDialogData] = useState<{ + rowId: number; + newStatus: string; + } | null>(null); + + /** + * Subscribe to dashboard data changes + */ + useEffect(() => { + const subscription = dashboardService + .getDashboardData$() + .subscribe(data => { + if (data?.reviewTransactions) { + setReviewTransactions(data.reviewTransactions); + } + }); + + // Cleanup subscription on unmount + return () => subscription.unsubscribe(); + }, []); + + /** + * Format transaction for display + */ + const formatTransaction = (tx: IReviewTransaction) => { + const createdDate = tx.created || tx.modified || ""; + const formattedDate = createdDate + ? formatToDateTimeString(createdDate) + : ""; + + return { + id: tx.id ?? tx.external_id ?? "", + user: tx.customer || "", + created: formattedDate, + type: tx.type || "", + amount: tx.amount + ? `${tx.amount < 0 ? "-" : ""}${Math.abs(tx.amount).toFixed(2)} ${tx.currency || ""}` + : "", + psp: tx.psp_id || "", + }; + }; + + const transactions = reviewTransactions?.transactions || []; + const displayTransactions = transactions.map(formatTransaction); + + const handleStatusChange = (rowId: number, newStatus: string) => { + setStatusDialogData({ rowId, newStatus }); + }; -export const TransactionsWaitingApproval = () => { return ( @@ -128,66 +101,90 @@ export const TransactionsWaitingApproval = () => { - {" "} + - -
- - - - ID - - - User - - - Created - - - Type - - - Amount - - - PSP - - - Action - - - - - {transactions.map((tx, i) => ( - - {tx.id} - {tx.user} - {tx.created} - {tx.type} - {tx.amount} - {tx.psp} + {reviewTransactions && ( + +
+ + - - - - - - + ID + + + User + + + Created + + + Type + + + Amount + + + PSP + + + Action - ))} - -
-
+ + + {displayTransactions.length > 0 ? ( + displayTransactions.map((tx, i) => ( + + {tx.id} + {tx.user} + {tx.created} + {tx.type} + {tx.amount} + {tx.psp} + + handleStatusChange(tx.id, "approved")} + > + + + handleStatusChange(tx.id, "declined")} + > + + + + + )) + ) : ( + + + + No transactions waiting for approval + + + + )} + + + + )} + setStatusDialogData(null)} + /> ); }; diff --git a/app/features/UserRoles/DeleteUser/DeleteUser.tsx b/app/features/UserRoles/DeleteUser/DeleteUser.tsx index 3fa929f..3111d8f 100644 --- a/app/features/UserRoles/DeleteUser/DeleteUser.tsx +++ b/app/features/UserRoles/DeleteUser/DeleteUser.tsx @@ -2,15 +2,15 @@ import React from "react"; 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 { AppDispatch } from "@/app/redux/store"; import { deleteUser, clearError } from "@/app/redux/user/userSlice"; import { IUser } from "../../Pages/Admin/Users/interfaces"; +import Modal from "@/app/components/Modal/Modal"; import Spinner from "@/app/components/Spinner/Spinner"; import { RootState } from "@/app/redux/store"; - -import { useRouter } from "next/navigation"; +import { selectUser } from "@/app/redux/auth/selectors"; import "./DeleteUser.scss"; interface DeleteUserProps { @@ -23,8 +23,10 @@ const DeleteUser: React.FC = ({ open, onClose, user }) => { const dispatch = useDispatch(); const router = useRouter(); const { status, error } = useSelector((state: RootState) => state.user); + const currentUser = useSelector(selectUser); const loading = status === "loading"; + const isSelfDeletion = currentUser?.id === user?.id; const handleDelete = async () => { if (!user?.id) { @@ -32,6 +34,11 @@ const DeleteUser: React.FC = ({ open, onClose, user }) => { return; } + if (isSelfDeletion) { + toast.error("You cannot delete your own account"); + return; + } + try { const resultAction = await dispatch(deleteUser(user.id)); @@ -60,10 +67,6 @@ const DeleteUser: React.FC = ({ open, onClose, user }) => { } }; - if (!user) { - return null; - } - return (
@@ -74,22 +77,24 @@ const DeleteUser: React.FC = ({ open, onClose, user }) => {

-
-
- -
{user.username}
-
-
- -
- {user.first_name} {user.last_name} + {user && ( +
+
+ +
{user.username}
+
+
+ +
+ {user.first_name} {user.last_name} +
+
+
+ +
{user.email}
-
- -
{user.email}
-
-
+ )} {error && (
@@ -109,7 +114,7 @@ const DeleteUser: React.FC = ({ open, onClose, user }) => {
- {openDeleteUser && ( + {openDeleteUser && user && ( void | Promise; + skipInitialFetch?: boolean; +} + +export const useDebouncedDateRange = ({ + initialDateRange, + debounceMs = 1000, + onDateRangeChange, + skipInitialFetch = false, +}: IUseDebouncedDateRangeOptions = {}) => { + const [dateRange, setDateRange] = useState(initialDateRange ?? []); + const debounceTimeoutRef = useRef(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, + }; +}; diff --git a/app/layout.tsx b/app/layout.tsx index cfa4e7b..52a7859 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,7 +8,7 @@ import "../styles/globals.scss"; import Modals from "./modals"; export const metadata: Metadata = { - title: "Your App", + title: "Cashier BO", description: "Generated by Next.js", }; diff --git a/app/redux/advanedSearch/advancedSearchSlice.ts b/app/redux/advanedSearch/advancedSearchSlice.ts index 46f04ce..2b22dbb 100644 --- a/app/redux/advanedSearch/advancedSearchSlice.ts +++ b/app/redux/advanedSearch/advancedSearchSlice.ts @@ -32,7 +32,7 @@ const initialState: AdvancedSearchState = { filters: {}, pagination: { page: 1, - limit: 10, + limit: 100, }, status: "idle", error: null, diff --git a/app/redux/advanedSearch/selectors.ts b/app/redux/advanedSearch/selectors.ts index eecb2e6..7879a76 100644 --- a/app/redux/advanedSearch/selectors.ts +++ b/app/redux/advanedSearch/selectors.ts @@ -16,7 +16,7 @@ export const selectPaginationModel = createSelector( [selectPagination], pagination => ({ page: Math.max(0, (pagination.page ?? 1) - 1), - pageSize: pagination.limit ?? 10, + pageSize: pagination.limit ?? 100, }) ); diff --git a/app/redux/auth/authSlice.tsx b/app/redux/auth/authSlice.tsx index d9bb870..9a649ab 100644 --- a/app/redux/auth/authSlice.tsx +++ b/app/redux/auth/authSlice.tsx @@ -230,7 +230,6 @@ export const addUser = createAsyncThunk< try { const state = getState(); const currentUserId = state.auth.user?.id; - console.log("[DEBUG] [ADD-USER] [currentUserId]: ", currentUserId); const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -239,8 +238,6 @@ export const addUser = createAsyncThunk< const data = await res.json(); - console.log("[DEBUG] [ADD-USER] [data]: ", data); - if (!res.ok) { return rejectWithValue(data.message || "Failed to create user"); } diff --git a/app/redux/user/userSlice.ts b/app/redux/user/userSlice.ts index 70e05e5..f50fa8b 100644 --- a/app/redux/user/userSlice.ts +++ b/app/redux/user/userSlice.ts @@ -37,8 +37,6 @@ export const addUser = createAsyncThunk< const data = await res.json(); - console.log("[DEBUG] [ADD-USER] [data]: ", data); - if (!res.ok) { return rejectWithValue(data.message || "Failed to create user"); } diff --git a/app/services/adminResources.ts b/app/services/adminResources.ts new file mode 100644 index 0000000..acae750 --- /dev/null +++ b/app/services/adminResources.ts @@ -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; + 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 + ); + 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(); +} diff --git a/app/services/audits.ts b/app/services/audits.ts index 9dbb954..eb170b0 100644 --- a/app/services/audits.ts +++ b/app/services/audits.ts @@ -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; page?: number; sort?: string; filter?: string; entity?: string; - signal?: AbortSignal; } -export async function getAudits({ - limit, - page, +export async function fetchAudits({ + limit = DEFAULT_PAGE_SIZE, + page = 1, sort, filter, entity, - signal, -}: GetAuditsParams = {}) { +}: FetchAuditsParams = {}): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + + if (!token) { + throw new Error("Missing auth token"); + } + const params = new URLSearchParams(); - if (limit) params.set("limit", String(limit)); - if (page) params.set("page", String(page)); + params.set("limit", String(limit)); + params.set("page", String(page)); if (sort) params.set("sort", sort); if (filter) params.set("filter", filter); if (entity) params.set("Entity", entity); - const queryString = params.toString(); - const response = await fetch( - `/api/dashboard/audits${queryString ? `?${queryString}` : ""}`, - { - method: "GET", - cache: "no-store", - signal, - } - ); + const backendUrl = `${BE_BASE_URL}/api/v1/audit${ + params.size ? `?${params.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + next: { + revalidate: REVALIDATE_SECONDS, + tags: [AUDIT_CACHE_TAG], + }, + }); if (!response.ok) { const errorData = await response .json() - .catch(() => ({ message: "Unknown error" })); - throw new Error(errorData.message || "Failed to fetch audits"); + .catch(() => ({ 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, + }; } diff --git a/app/services/constants.ts b/app/services/constants.ts new file mode 100644 index 0000000..969c350 --- /dev/null +++ b/app/services/constants.ts @@ -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}`) + ); +} diff --git a/app/services/dashboardService.ts b/app/services/dashboardService.ts new file mode 100644 index 0000000..84906c2 --- /dev/null +++ b/app/services/dashboardService.ts @@ -0,0 +1,42 @@ +import { BehaviorSubject, Observable } from "rxjs"; +import { IDashboardData } from "./types"; +import { getDashboardData } from "./transactions"; + +class DashboardService { + private dashboardData$ = new BehaviorSubject(null); + + /** + * Get observable for dashboard data + */ + getDashboardData$(): Observable { + 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 { + const data = await getDashboardData(params); + this.updateDashboardData(data); + return data; + } +} + +export const dashboardService = new DashboardService(); diff --git a/app/services/health.ts b/app/services/health.ts new file mode 100644 index 0000000..826bee7 --- /dev/null +++ b/app/services/health.ts @@ -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 { + 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= ({ 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, + }; +} diff --git a/app/services/matcher.ts b/app/services/matcher.ts new file mode 100644 index 0000000..b2b9063 --- /dev/null +++ b/app/services/matcher.ts @@ -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 { + 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) + ); +} diff --git a/app/services/transactions.ts b/app/services/transactions.ts index 9f91d1b..6124e27 100644 --- a/app/services/transactions.ts +++ b/app/services/transactions.ts @@ -1,3 +1,6 @@ +import { getBaseUrl } from "./constants"; +import { IFetchHealthDataParams, IHealthData, IDashboardData } from "./types"; + export async function getTransactions({ transactionType, query, @@ -6,7 +9,7 @@ export async function getTransactions({ query: string; }) { const res = await fetch( - `http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`, + `${getBaseUrl()}/api/dashboard/transactions/${transactionType}?${query}`, { cache: "no-store", } @@ -22,3 +25,52 @@ export async function getTransactions({ 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 { + 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, + }, + }; +} diff --git a/app/services/types.ts b/app/services/types.ts new file mode 100644 index 0000000..ed0f1f1 --- /dev/null +++ b/app/services/types.ts @@ -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; +} diff --git a/app/services/users.ts b/app/services/users.ts new file mode 100644 index 0000000..7a51eea --- /dev/null +++ b/app/services/users.ts @@ -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 { + 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; +} diff --git a/app/utils/auth.ts b/app/utils/auth.ts index 452582a..1456bfb 100644 --- a/app/utils/auth.ts +++ b/app/utils/auth.ts @@ -9,6 +9,8 @@ export interface JWTPayload { role: string; iat: number; exp: number; + MustChangePassword: boolean; + Groups: string[]; } /** diff --git a/app/utils/formatCurrency.ts b/app/utils/formatCurrency.ts new file mode 100644 index 0000000..7f6478c --- /dev/null +++ b/app/utils/formatCurrency.ts @@ -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)}%`; +}; diff --git a/app/utils/formatDate.ts b/app/utils/formatDate.ts index 6fb9ff9..a399f4d 100644 --- a/app/utils/formatDate.ts +++ b/app/utils/formatDate.ts @@ -11,3 +11,39 @@ export const formatToDateTimeString = (dateString: string): string => { 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(), + }; +}; diff --git a/app/utils/iconMap.ts b/app/utils/iconMap.ts index 014b96c..ed4fbe8 100644 --- a/app/utils/iconMap.ts +++ b/app/utils/iconMap.ts @@ -26,6 +26,7 @@ import AccountBalanceIcon from "@mui/icons-material/AccountBalance"; import PaymentIcon from "@mui/icons-material/Payment"; import CurrencyExchangeIcon from "@mui/icons-material/CurrencyExchange"; import ViewSidebarIcon from "@mui/icons-material/ViewSidebar"; +import CompareArrowsIcon from "@mui/icons-material/CompareArrows"; const IconMap = { HomeIcon, @@ -53,6 +54,7 @@ const IconMap = { PaymentIcon, CurrencyExchangeIcon, ViewSidebarIcon, + CompareArrowsIcon, }; export default IconMap; diff --git a/middleware.ts b/middleware.ts index 545cd9e..4947eb7 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,8 +1,7 @@ +import { AUTH_COOKIE_NAME } from "@/app/services/constants"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { jwtVerify } from "jose"; - -const COOKIE_NAME = "auth_token"; const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!); // Define route-to-role mappings @@ -57,7 +56,6 @@ async function validateToken(token: string) { algorithms: ["HS256"], }); - console.log("[middleware] payload", payload); return payload as { exp?: number; MustChangePassword?: boolean; @@ -71,7 +69,7 @@ async function validateToken(token: string) { } 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 currentPath = request.nextUrl.pathname; @@ -87,7 +85,7 @@ export async function middleware(request: NextRequest) { if (!payload) { 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("redirect", currentPath); return res; @@ -96,7 +94,7 @@ export async function middleware(request: NextRequest) { // 3️⃣ Expiry check if (isExpired(payload.exp)) { 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("redirect", currentPath); return res; diff --git a/package-lock.json b/package-lock.json index 6d3c28f..df83f91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,9 +24,13 @@ "react": "^19.0.0", "react-date-range": "^2.0.1", "react-dom": "^19.0.0", + "react-hot-toast": "^2.6.0", "react-redux": "^9.2.0", "recharts": "^2.15.3", + "redux-observable": "^3.0.0-rc.2", + "rxjs": "^7.8.2", "sass": "^1.89.2", + "swr": "^2.3.6", "xlsx": "^0.18.5" }, "devDependencies": { @@ -37,9 +41,13 @@ "@types/react-date-range": "^1.4.10", "@types/react-dom": "^19", "@types/react-redux": "^7.1.34", + "@types/redux-persist": "^4.3.1", + "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "15.3.3", + "husky": "^9.1.7", "msw": "^2.10.2", + "prettier": "^3.6.2", "typescript": "^5" } }, @@ -392,6 +400,13 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "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": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -2361,6 +2376,17 @@ "@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": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", @@ -3621,6 +3647,24 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3906,6 +3950,15 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -4941,6 +4994,15 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5080,6 +5142,22 @@ "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6355,6 +6433,22 @@ "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": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6454,6 +6548,23 @@ "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": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6580,6 +6691,26 @@ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", @@ -6730,6 +6861,15 @@ "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": { "version": "1.1.3", "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" } }, + "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": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/package.json b/package.json index a44481f..aebfab8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "prepare": "husky" }, "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/styled": "^11.14.0", "@mui/icons-material": "^7.1.1", @@ -41,6 +44,7 @@ "redux-observable": "^3.0.0-rc.2", "rxjs": "^7.8.2", "sass": "^1.89.2", + "swr": "^2.3.6", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/services/roles.services.ts b/services/roles.services.ts index 718180a..20b8d3d 100644 --- a/services/roles.services.ts +++ b/services/roles.services.ts @@ -8,7 +8,6 @@ export async function editUser(id: string, data: IEditUserForm) { }); if (!res.ok) { - console.log("[editUser] - FAILING", id, data); throw new Error("Failed to update user"); } diff --git a/yarn.lock b/yarn.lock index f238341..a6bb054 100644 --- a/yarn.lock +++ b/yarn.lock @@ -104,25 +104,56 @@ "@types/tough-cookie" "^4.0.5" tough-cookie "^4.1.4" -"@emnapi/core@^1.4.3": - version "1.4.5" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.5.tgz#bfbb0cbbbb9f96ec4e2c4fd917b7bbe5495ceccb" - integrity sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q== +"@dnd-kit/accessibility@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af" + integrity sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw== dependencies: - "@emnapi/wasi-threads" "1.0.4" + tslib "^2.0.0" + +"@dnd-kit/core@^6.3.1": + version "6.3.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.3.1.tgz#4c36406a62c7baac499726f899935f93f0e6d003" + integrity sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ== + dependencies: + "@dnd-kit/accessibility" "^3.1.1" + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + +"@dnd-kit/sortable@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz#1f9382b90d835cd5c65d92824fa9dafb78c4c3e8" + integrity sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg== + dependencies: + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz#5a32b6af356dc5f74d61b37d6f7129a4040ced7b" + integrity sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg== + dependencies: + tslib "^2.0.0" + +"@emnapi/core@^1.4.3": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.7.1.tgz#3a79a02dbc84f45884a1806ebb98e5746bdfaac4" + integrity sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" tslib "^2.4.0" "@emnapi/runtime@^1.4.3": - version "1.4.5" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.5.tgz#c67710d0661070f38418b6474584f159de38aba9" - integrity sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg== + version "1.7.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.7.1.tgz#a73784e23f5d57287369c808197288b52276b791" + integrity sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA== dependencies: tslib "^2.4.0" -"@emnapi/wasi-threads@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz#703fc094d969e273b1b71c292523b2f792862bf4" - integrity sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g== +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== dependencies: tslib "^2.4.0" @@ -235,7 +266,7 @@ "@epic-web/invariant@^1.0.0": version "1.0.0" - resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813" + resolved "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz" integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0": @@ -900,9 +931,9 @@ tslib "^2.8.0" "@tybys/wasm-util@^0.10.0": - version "0.10.0" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.0.tgz#2fd3cd754b94b378734ce17058d0507c45c88369" - integrity sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ== + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== dependencies: tslib "^2.4.0" @@ -1043,7 +1074,7 @@ "@types/redux-persist@^4.3.1": version "4.3.1" - resolved "https://registry.yarnpkg.com/@types/redux-persist/-/redux-persist-4.3.1.tgz#aa4c876859e0bea5155e5f7980e5b8c4699dc2e6" + resolved "https://registry.npmjs.org/@types/redux-persist/-/redux-persist-4.3.1.tgz" integrity sha512-YkMnMUk+4//wPtiSTMfsxST/F9Gh9sPWX0LVxHuOidGjojHtMdpep2cYvQgfiDMnj34orXyZI+QJCQMZDlafKA== dependencies: redux-persist "*" @@ -1626,7 +1657,7 @@ crc-32@~1.2.0, crc-32@~1.2.1: cross-env@^10.1.0: version "10.1.0" - resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783" + resolved "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz" integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== dependencies: "@epic-web/invariant" "^1.0.0" @@ -1808,6 +1839,11 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz" @@ -2423,7 +2459,7 @@ globalthis@^1.0.4: goober@^2.1.16: version "2.1.18" - resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.18.tgz#b72d669bd24d552d441638eee26dfd5716ea6442" + resolved "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz" integrity sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw== gopd@^1.0.1, gopd@^1.2.0: @@ -2498,7 +2534,7 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1: husky@^9.1.7: version "9.1.7" - resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d" + resolved "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz" integrity sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== ignore@^5.2.0: @@ -3192,7 +3228,7 @@ prelude-ls@^1.2.1: prettier@^3.6.2: version "3.6.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" + resolved "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz" integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: @@ -3245,7 +3281,7 @@ react-dom@^19.0.0: react-hot-toast@^2.6.0: version "2.6.0" - resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.6.0.tgz#4ada6ed3c75c5e42a90d562f55665ff37ee1442b" + resolved "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz" integrity sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg== dependencies: csstype "^3.1.3" @@ -3331,12 +3367,12 @@ recharts@^2.15.3: redux-observable@^3.0.0-rc.2: version "3.0.0-rc.2" - resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-3.0.0-rc.2.tgz#baef603781c5dabd9ddd70526357076cd5c128a2" + resolved "https://registry.npmjs.org/redux-observable/-/redux-observable-3.0.0-rc.2.tgz" integrity sha512-gG/pWIKgSrcTyyavm2so5tc7tuyCQ47p3VdCAG6wt+CV0WGhDr50cMQHLcYKxFZSGgTm19a8ZmyfJGndmGDpYg== redux-persist@*: version "6.0.0" - resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" + resolved "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz" integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== redux-thunk@^3.1.0: @@ -3439,7 +3475,7 @@ run-parallel@^1.1.9: rxjs@^7.8.2: version "7.8.2" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz" integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== dependencies: tslib "^2.1.0" @@ -3792,6 +3828,14 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swr@^2.3.6: + version "2.3.6" + resolved "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz" + integrity sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw== + dependencies: + dequal "^2.0.3" + use-sync-external-store "^1.4.0" + tiny-invariant@^1.3.1: version "1.3.3" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz" @@ -3837,7 +3881,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==