Compare commits
No commits in common. "main" and "transactionTables" have entirely different histories.
main
...
transactio
@ -24,14 +24,7 @@ export async function POST(request: Request) {
|
|||||||
let mustChangePassword = false;
|
let mustChangePassword = false;
|
||||||
try {
|
try {
|
||||||
const payload = decodeJwt(token);
|
const payload = decodeJwt(token);
|
||||||
const mustChangeClaim = payload.MustChangePassword;
|
mustChangePassword = payload.MustChangePassword || false;
|
||||||
if (typeof mustChangeClaim === "boolean") {
|
|
||||||
mustChangePassword = mustChangeClaim;
|
|
||||||
} else if (typeof mustChangeClaim === "string") {
|
|
||||||
mustChangePassword = mustChangeClaim.toLowerCase() === "true";
|
|
||||||
} else {
|
|
||||||
mustChangePassword = false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Failed to decode current JWT:", err);
|
console.error("❌ Failed to decode current JWT:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,6 @@ export async function POST(request: Request) {
|
|||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[LOGIN] resp", resp);
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const errJson = await safeJson(resp);
|
const errJson = await safeJson(resp);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
import { NextResponse } from "next/server";
|
||||||
// @ts-ignore
|
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
||||||
const COOKIE_NAME = "auth_token";
|
const COOKIE_NAME = "auth_token";
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: Request,
|
||||||
context: { params: Promise<{ id: string }> }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
const { id } = params;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -41,14 +39,15 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let data;
|
// Attempt to parse JSON; fall back to status-only response
|
||||||
|
let data: unknown = null;
|
||||||
try {
|
try {
|
||||||
data = await resp.json();
|
data = await resp.json();
|
||||||
} catch {
|
} catch {
|
||||||
data = { success: resp.ok };
|
data = { success: resp.ok };
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(data, {
|
return NextResponse.json(data ?? { success: resp.ok }, {
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing Authorization header" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.set("limit", String(pagination.limit ?? 10));
|
|
||||||
queryParams.set("page", String(pagination.page ?? 1));
|
|
||||||
|
|
||||||
if (sort?.field && sort?.order) {
|
|
||||||
queryParams.set("sort", `${sort.field}:${sort.order}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterParam = buildFilterParam(filters);
|
|
||||||
if (filterParam) {
|
|
||||||
queryParams.set("filter", filterParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendUrl = `${BE_BASE_URL}/api/v1/groups${
|
|
||||||
queryParams.size ? `?${queryParams.toString()}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "Failed to fetch groups" }));
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: errorData?.message || "Failed to fetch groups",
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Internal server error", error: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing Authorization header" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.set("limit", String(pagination.limit ?? 10));
|
|
||||||
queryParams.set("page", String(pagination.page ?? 1));
|
|
||||||
|
|
||||||
if (sort?.field && sort?.order) {
|
|
||||||
queryParams.set("sort", `${sort.field}:${sort.order}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterParam = buildFilterParam(filters);
|
|
||||||
if (filterParam) {
|
|
||||||
queryParams.set("filter", filterParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendUrl = `${BE_BASE_URL}/api/v1/permissions${
|
|
||||||
queryParams.size ? `?${queryParams.toString()}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "Failed to fetch permissions" }));
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: errorData?.message || "Failed to fetch permissions",
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("Proxy POST /api/dashboard/admin/permissions error:", err);
|
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Internal server error", error: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing Authorization header" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.set("limit", String(pagination.limit ?? 10));
|
|
||||||
queryParams.set("page", String(pagination.page ?? 1));
|
|
||||||
|
|
||||||
if (sort?.field && sort?.order) {
|
|
||||||
queryParams.set("sort", `${sort.field}:${sort.order}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterParam = buildFilterParam(filters);
|
|
||||||
if (filterParam) {
|
|
||||||
queryParams.set("filter", filterParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendUrl = `${BE_BASE_URL}/api/v1/sessions${
|
|
||||||
queryParams.size ? `?${queryParams.toString()}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "Failed to fetch sessions" }));
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: errorData?.message || "Failed to fetch sessions",
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("Proxy POST /api/dashboard/admin/sessions error:", err);
|
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Internal server error", error: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -63,10 +63,10 @@ function transformUserUpdateData(updates: Record<string, unknown>): {
|
|||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: Request,
|
request: Request,
|
||||||
context: { params: Promise<{ id: string }> }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Transform the request body to match backend format
|
// Transform the request body to match backend format
|
||||||
@ -108,10 +108,10 @@ export async function PUT(
|
|||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
context: { params: Promise<{ id: string }> }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
const { id } = await params;
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
export type FilterValue =
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
operator?: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildFilterParam = (filters: Record<string, FilterValue>) => {
|
|
||||||
const filterExpressions: string[] = [];
|
|
||||||
|
|
||||||
for (const [key, filterValue] of Object.entries(filters)) {
|
|
||||||
if (!filterValue) continue;
|
|
||||||
|
|
||||||
let operator = "==";
|
|
||||||
let value: string;
|
|
||||||
|
|
||||||
if (typeof filterValue === "string") {
|
|
||||||
value = filterValue;
|
|
||||||
} else {
|
|
||||||
operator = filterValue.operator || "==";
|
|
||||||
value = filterValue.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) continue;
|
|
||||||
|
|
||||||
const encodedValue = encodeURIComponent(value);
|
|
||||||
const needsEqualsPrefix = /^[A-Za-z]/.test(operator);
|
|
||||||
const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator;
|
|
||||||
|
|
||||||
filterExpressions.push(`${key}${operatorSegment}/${encodedValue}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filterExpressions.length > 0 ? filterExpressions.join(",") : undefined;
|
|
||||||
};
|
|
||||||
88
app/api/dashboard/audits/mockData.ts
Normal file
88
app/api/dashboard/audits/mockData.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { GridColDef } from "@mui/x-data-grid";
|
||||||
|
|
||||||
|
export const AuditColumns: GridColDef[] = [
|
||||||
|
{ field: "actionType", headerName: "Action Type", width: 130 },
|
||||||
|
{
|
||||||
|
field: "timeStampOfTheAction",
|
||||||
|
headerName: "Timestamp of the action",
|
||||||
|
width: 130,
|
||||||
|
},
|
||||||
|
{ field: "adminUsername", headerName: "Admin username", width: 130 },
|
||||||
|
{ field: "adminId", headerName: "Admin ID", width: 130 },
|
||||||
|
{ field: "affectedUserId", headerName: "Affected user ID", width: 130 },
|
||||||
|
{ field: "adminIPAddress", headerName: "Admin IP address", width: 130 },
|
||||||
|
{ field: "reasonNote", headerName: "Reason/Note", width: 130 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AuditData = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
actionType: "Create",
|
||||||
|
timeStampOfTheAction: "2023-03-01T12:00:00",
|
||||||
|
adminUsername: "admin1",
|
||||||
|
adminId: "12345",
|
||||||
|
affectedUserId: "67890",
|
||||||
|
adminIPAddress: "192.168.1.1",
|
||||||
|
reasonNote: "New user created",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
actionType: "Update",
|
||||||
|
timeStampOfTheAction: "2023-03-02T12:00:00",
|
||||||
|
adminUsername: "admin2",
|
||||||
|
adminId: "54321",
|
||||||
|
affectedUserId: "09876",
|
||||||
|
adminIPAddress: "192.168.2.2",
|
||||||
|
reasonNote: "User details updated",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
actionType: "Delete",
|
||||||
|
timeStampOfTheAction: "2023-03-03T12:00:00",
|
||||||
|
adminUsername: "admin3",
|
||||||
|
adminId: "98765",
|
||||||
|
affectedUserId: "45678",
|
||||||
|
adminIPAddress: "192.168.3.3",
|
||||||
|
reasonNote: "User deleted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
actionType: "Create",
|
||||||
|
timeStampOfTheAction: "2023-03-04T12:00:00",
|
||||||
|
adminUsername: "admin4",
|
||||||
|
adminId: "98765",
|
||||||
|
affectedUserId: "45678",
|
||||||
|
adminIPAddress: "192.168.3.3",
|
||||||
|
reasonNote: "New user created",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
actionType: "Update",
|
||||||
|
timeStampOfTheAction: "2023-03-05T12:00:00",
|
||||||
|
adminUsername: "admin2",
|
||||||
|
adminId: "98765",
|
||||||
|
affectedUserId: "45678",
|
||||||
|
adminIPAddress: "192.168.3.3",
|
||||||
|
reasonNote: "User details updated",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AuditSearchLabels = [
|
||||||
|
{ label: "Action Type", field: "actionType", type: "text" },
|
||||||
|
{ label: "Date / Time", field: "dateTime", type: "date" },
|
||||||
|
{
|
||||||
|
label: "affectedUserId",
|
||||||
|
field: "Affected user ID",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Admin ID",
|
||||||
|
field: "adminId",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Admin username",
|
||||||
|
field: "adminUsername",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -1,80 +1,69 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { AuditColumns, AuditData, AuditSearchLabels } from "./mockData";
|
||||||
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";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
const { searchParams } = new URL(request.url);
|
||||||
const { cookies } = await import("next/headers");
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
|
||||||
|
|
||||||
if (!token) {
|
const actionType = searchParams.get("actionType");
|
||||||
return NextResponse.json(
|
const affectedUserId = searchParams.get("affectedUserId");
|
||||||
{ message: "Missing Authorization header" },
|
const adminId = searchParams.get("adminId");
|
||||||
{ status: 401 }
|
const adminUsername = searchParams.get("adminUsername");
|
||||||
|
|
||||||
|
const dateTimeStart = searchParams.get("dateTime_start");
|
||||||
|
const dateTimeEnd = searchParams.get("dateTime_end");
|
||||||
|
|
||||||
|
let filteredRows = [...AuditData];
|
||||||
|
|
||||||
|
if (actionType) {
|
||||||
|
filteredRows = filteredRows.filter(
|
||||||
|
tx => tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
if (affectedUserId) {
|
||||||
const proxiedParams = new URLSearchParams();
|
filteredRows = filteredRows.filter(
|
||||||
|
tx => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase()
|
||||||
// Forward provided params
|
);
|
||||||
searchParams.forEach((value, key) => {
|
|
||||||
if (value == null || value === "") return;
|
|
||||||
proxiedParams.append(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!proxiedParams.has("limit")) {
|
|
||||||
proxiedParams.set("limit", DEFAULT_LIMIT);
|
|
||||||
}
|
|
||||||
if (!proxiedParams.has("page")) {
|
|
||||||
proxiedParams.set("page", DEFAULT_PAGE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendUrl = `${AUDITS_BASE_URL}/api/v1/audit${
|
if (adminId) {
|
||||||
proxiedParams.size ? `?${proxiedParams.toString()}` : ""
|
filteredRows = filteredRows.filter(tx => tx.adminId === adminId);
|
||||||
}`;
|
}
|
||||||
|
if (adminUsername) {
|
||||||
|
filteredRows = filteredRows.filter(
|
||||||
|
tx => tx.adminUsername === adminUsername
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
if (dateTimeStart && dateTimeEnd) {
|
||||||
method: "GET",
|
const start = new Date(dateTimeStart);
|
||||||
headers: {
|
const end = new Date(dateTimeEnd);
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// Validate the date range to ensure it’s correct
|
||||||
const errorData = await response
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "Failed to fetch audits" }));
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
error: "Invalid date range",
|
||||||
message: errorData?.message || "Failed to fetch audits",
|
|
||||||
},
|
},
|
||||||
{ status: response.status }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
filteredRows = filteredRows.filter(tx => {
|
||||||
console.log("[AUDITS] data:", data);
|
const txDate = new Date(tx.timeStampOfTheAction);
|
||||||
return NextResponse.json(data, { status: response.status });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.log("[AUDITS] error:", err);
|
|
||||||
|
|
||||||
console.error("Proxy GET /api/v1/audits error:", err);
|
// Validate if the timestamp is a valid date
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
if (isNaN(txDate.getTime())) {
|
||||||
return NextResponse.json(
|
return false; // Skip invalid dates
|
||||||
{ message: "Internal server error", error: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return txDate >= start && txDate <= end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tableRows: filteredRows,
|
||||||
|
tableColumns: AuditColumns,
|
||||||
|
tableSearchLabels: AuditSearchLabels,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
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 }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const { cookies } = await import("next/headers");
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing Authorization header" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await request.json();
|
|
||||||
|
|
||||||
const upstream = await fetch(`${BE_BASE_URL}/api/v1/transactions/${id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
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";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Internal server error", error: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
264
app/api/dashboard/transactions/deposits/mockData.ts
Normal file
264
app/api/dashboard/transactions/deposits/mockData.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import { GridColDef } from "@mui/x-data-grid";
|
||||||
|
|
||||||
|
export const depositTransactionDummyData = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: 17,
|
||||||
|
merchandId: 100987998,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Card",
|
||||||
|
status: "Completed",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-18 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
userId: 17,
|
||||||
|
merchandId: 100987998,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Card",
|
||||||
|
status: "Completed",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-18 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
userId: 17,
|
||||||
|
merchandId: 100987997,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Card",
|
||||||
|
status: "Completed",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-18 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
userId: 19,
|
||||||
|
merchandId: 100987997,
|
||||||
|
transactionId: 1049136973,
|
||||||
|
depositMethod: "Card",
|
||||||
|
status: "Completed",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-18 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
userId: 19,
|
||||||
|
merchandId: 100987998,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Card",
|
||||||
|
status: "Completed",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-18 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
userId: 27,
|
||||||
|
merchandId: 100987997,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Card",
|
||||||
|
status: "Pending",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-18 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 175,
|
||||||
|
merchandId: 100987938,
|
||||||
|
transactionId: 1049136973,
|
||||||
|
depositMethod: "Card",
|
||||||
|
status: "Pending",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-18 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
userId: 172,
|
||||||
|
merchandId: 100987938,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Card",
|
||||||
|
status: "Pending",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-12 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
userId: 174,
|
||||||
|
merchandId: 100987938,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Bank Transfer",
|
||||||
|
status: "Inprogress",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-17 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
userId: 7,
|
||||||
|
merchandId: 100987998,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Bank Transfer",
|
||||||
|
status: "Inprogress",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-17 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
userId: 1,
|
||||||
|
merchandId: 100987998,
|
||||||
|
transactionId: 1049131973,
|
||||||
|
depositMethod: "Bank Transfer",
|
||||||
|
status: "Error",
|
||||||
|
options: [
|
||||||
|
{ value: "Pending", label: "Pending" },
|
||||||
|
{ value: "Completed", label: "Completed" },
|
||||||
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
|
{ value: "Error", label: "Error" },
|
||||||
|
],
|
||||||
|
amount: 4000,
|
||||||
|
currency: "EUR",
|
||||||
|
dateTime: "2025-06-17 10:10:30",
|
||||||
|
errorInfo: "-",
|
||||||
|
fraudScore: "frad score 1234",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const depositTransactionsColumns: GridColDef[] = [
|
||||||
|
{ field: "userId", headerName: "User ID", width: 130 },
|
||||||
|
{ field: "merchandId", headerName: "Merchant ID", width: 130 },
|
||||||
|
{ field: "transactionId", headerName: "Transaction ID", width: 130 },
|
||||||
|
{ field: "depositMethod", headerName: "Deposit Method", width: 130 },
|
||||||
|
{ field: "status", headerName: "Status", width: 130 },
|
||||||
|
{ field: "actions", headerName: "Actions", width: 150 },
|
||||||
|
{ field: "amount", headerName: "Amount", width: 130 },
|
||||||
|
{ field: "currency", headerName: "Currency", width: 130 },
|
||||||
|
{ field: "dateTime", headerName: "Date / Time", width: 130 },
|
||||||
|
{ field: "errorInfo", headerName: "Error Info", width: 130 },
|
||||||
|
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const depositTransactionsExtraColumns: GridColDef[] = [
|
||||||
|
{ field: "currency", headerName: "Currency", width: 130 },
|
||||||
|
{ field: "errorInfo", headerName: "Error Info", width: 130 },
|
||||||
|
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// export const extraColumns = ["currency", "errorInfo", "fraudScore"]
|
||||||
|
|
||||||
|
export const depositTransactionsSearchLabels = [
|
||||||
|
{ label: "User", field: "userId", type: "text" },
|
||||||
|
{ label: "Transaction ID", field: "transactionId", type: "text" },
|
||||||
|
{
|
||||||
|
label: "Transaction Reference ID",
|
||||||
|
field: "transactionReferenceId",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Currency",
|
||||||
|
field: "currency",
|
||||||
|
type: "select",
|
||||||
|
options: ["USD", "EUR", "GBP"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Status",
|
||||||
|
field: "status",
|
||||||
|
type: "select",
|
||||||
|
options: ["Pending", "Inprogress", "Completed", "Failed"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Payment Method",
|
||||||
|
field: "depositMethod",
|
||||||
|
type: "select",
|
||||||
|
options: ["Card", "Bank Transfer"],
|
||||||
|
},
|
||||||
|
{ label: "Date / Time", field: "dateTime", type: "date" },
|
||||||
|
];
|
||||||
@ -1,108 +1,82 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
import {
|
||||||
const COOKIE_NAME = "auth_token";
|
depositTransactionDummyData,
|
||||||
|
depositTransactionsColumns,
|
||||||
|
depositTransactionsSearchLabels,
|
||||||
|
// extraColumns
|
||||||
|
} from "./mockData";
|
||||||
|
// import { formatToDateTimeString } from "@/app/utils/formatDate";
|
||||||
|
|
||||||
type FilterValue =
|
export async function GET(request: NextRequest) {
|
||||||
| string
|
const { searchParams } = new URL(request.url);
|
||||||
| {
|
|
||||||
operator?: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
const status = searchParams.get("status");
|
||||||
try {
|
const userId = searchParams.get("userId");
|
||||||
const { cookies } = await import("next/headers");
|
const depositMethod = searchParams.get("depositMethod");
|
||||||
const cookieStore = await cookies();
|
const merchandId = searchParams.get("merchandId");
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
const transactionId = searchParams.get("transactionId");
|
||||||
|
// const dateTime = searchParams.get("dateTime");
|
||||||
|
|
||||||
if (!token) {
|
const dateTimeStart = searchParams.get("dateTime_start");
|
||||||
return NextResponse.json(
|
const dateTimeEnd = searchParams.get("dateTime_end");
|
||||||
{ message: "Missing Authorization header" },
|
|
||||||
{ status: 401 }
|
let filteredTransactions = [...depositTransactionDummyData];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
filteredTransactions = filteredTransactions.filter(
|
||||||
|
tx => tx.userId.toString() === userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
if (status) {
|
||||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
filteredTransactions = filteredTransactions.filter(
|
||||||
|
tx => tx.status.toLowerCase() === status.toLowerCase()
|
||||||
// Force deposits filter while allowing other filters to stack
|
);
|
||||||
const mergedFilters: Record<string, FilterValue> = {
|
|
||||||
...filters,
|
|
||||||
Type: {
|
|
||||||
operator: "==",
|
|
||||||
value: "deposit",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryParts: string[] = [];
|
|
||||||
queryParts.push(`limit=${pagination.limit}`);
|
|
||||||
queryParts.push(`page=${pagination.page}`);
|
|
||||||
|
|
||||||
if (sort) {
|
|
||||||
queryParts.push(`sort=${sort.field}:${sort.order}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, filterValue] of Object.entries(mergedFilters)) {
|
if (depositMethod) {
|
||||||
if (!filterValue) continue;
|
filteredTransactions = filteredTransactions.filter(
|
||||||
|
tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
|
||||||
let operator: string;
|
);
|
||||||
let value: string;
|
}
|
||||||
|
if (merchandId) {
|
||||||
if (typeof filterValue === "string") {
|
filteredTransactions = filteredTransactions.filter(
|
||||||
operator = "==";
|
tx => tx.merchandId.toString() === merchandId
|
||||||
value = filterValue;
|
);
|
||||||
} else {
|
}
|
||||||
operator = filterValue.operator || "==";
|
if (transactionId) {
|
||||||
value = filterValue.value;
|
filteredTransactions = filteredTransactions.filter(
|
||||||
|
tx => tx.transactionId.toString() === transactionId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!value) continue;
|
if (dateTimeStart && dateTimeEnd) {
|
||||||
|
const start = new Date(dateTimeStart);
|
||||||
|
const end = new Date(dateTimeEnd);
|
||||||
|
|
||||||
const encodedValue = encodeURIComponent(value);
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
const needsEqualsPrefix = /^[A-Za-z]/.test(operator);
|
|
||||||
const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator;
|
|
||||||
|
|
||||||
queryParts.push(`${key}${operatorSegment}/${encodedValue}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = queryParts.join("&");
|
|
||||||
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${
|
|
||||||
queryString ? `?${queryString}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "Failed to fetch deposits" }));
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
error: "Invalid date range",
|
||||||
message: errorData?.message || "Failed to fetch deposits",
|
|
||||||
},
|
},
|
||||||
{ status: response.status }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
filteredTransactions = filteredTransactions.filter(tx => {
|
||||||
return NextResponse.json(data, { status: response.status });
|
const txDate = new Date(tx.dateTime);
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error(
|
if (isNaN(txDate.getTime())) {
|
||||||
"Proxy POST /api/dashboard/transactions/deposits error:",
|
return false;
|
||||||
err
|
|
||||||
);
|
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Internal server error", error: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return txDate >= start && txDate <= end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tableRows: filteredTransactions,
|
||||||
|
tableSearchLabels: depositTransactionsSearchLabels,
|
||||||
|
tableColumns: depositTransactionsColumns,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,32 +32,10 @@ export async function POST(request: NextRequest) {
|
|||||||
queryParts.push(`sort=${sort.field}:${sort.order}`);
|
queryParts.push(`sort=${sort.field}:${sort.order}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track date ranges separately so we can emit BETWEEN/>/< syntax
|
|
||||||
const dateRanges: Record<string, { start?: string; end?: string }> = {};
|
|
||||||
|
|
||||||
// Process filters - convert FilterValue objects to operator/value format
|
// Process filters - convert FilterValue objects to operator/value format
|
||||||
for (const [key, filterValue] of Object.entries(filters)) {
|
for (const [key, filterValue] of Object.entries(filters)) {
|
||||||
if (!filterValue) continue;
|
if (!filterValue) continue;
|
||||||
|
|
||||||
// Handle date range helpers (e.g. Created_start / Created_end)
|
|
||||||
if (/_start$|_end$/.test(key)) {
|
|
||||||
const baseField = key.replace(/_(start|end)$/, "");
|
|
||||||
if (!dateRanges[baseField]) {
|
|
||||||
dateRanges[baseField] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetKey = key.endsWith("_start") ? "start" : "end";
|
|
||||||
const stringValue =
|
|
||||||
typeof filterValue === "string"
|
|
||||||
? filterValue
|
|
||||||
: (filterValue as { value?: string }).value;
|
|
||||||
|
|
||||||
if (stringValue) {
|
|
||||||
dateRanges[baseField][targetKey] = stringValue;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let op: string;
|
let op: string;
|
||||||
let value: string;
|
let value: string;
|
||||||
|
|
||||||
@ -79,24 +57,6 @@ export async function POST(request: NextRequest) {
|
|||||||
queryParts.push(`${key}=${op}/${encodedValue}`);
|
queryParts.push(`${key}=${op}/${encodedValue}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit date range filters using backend format
|
|
||||||
for (const [field, { start, end }] of Object.entries(dateRanges)) {
|
|
||||||
if (start && end) {
|
|
||||||
queryParts.push(
|
|
||||||
`${field}=BETWEEN/${encodeURIComponent(start)}/${encodeURIComponent(
|
|
||||||
end
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start) {
|
|
||||||
queryParts.push(`${field}=>/${encodeURIComponent(start)}`);
|
|
||||||
} else if (end) {
|
|
||||||
queryParts.push(`${field}=</${encodeURIComponent(end)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = queryParts.join("&");
|
const queryString = queryParts.join("&");
|
||||||
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
|
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
||||||
|
|||||||
@ -1,108 +0,0 @@
|
|||||||
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
|
|
||||||
| {
|
|
||||||
operator?: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { cookies } = await import("next/headers");
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing Authorization header" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
|
||||||
|
|
||||||
// Force withdrawals filter while allowing other filters to stack
|
|
||||||
const mergedFilters: Record<string, FilterValue> = {
|
|
||||||
...filters,
|
|
||||||
Type: {
|
|
||||||
operator: "==",
|
|
||||||
value: "withdrawal",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryParts: string[] = [];
|
|
||||||
queryParts.push(`limit=${pagination.limit}`);
|
|
||||||
queryParts.push(`page=${pagination.page}`);
|
|
||||||
|
|
||||||
if (sort) {
|
|
||||||
queryParts.push(`sort=${sort.field}:${sort.order}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, filterValue] of Object.entries(mergedFilters)) {
|
|
||||||
if (!filterValue) continue;
|
|
||||||
|
|
||||||
let operator: string;
|
|
||||||
let value: string;
|
|
||||||
|
|
||||||
if (typeof filterValue === "string") {
|
|
||||||
operator = "==";
|
|
||||||
value = filterValue;
|
|
||||||
} else {
|
|
||||||
operator = filterValue.operator || "==";
|
|
||||||
value = filterValue.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) continue;
|
|
||||||
|
|
||||||
const encodedValue = encodeURIComponent(value);
|
|
||||||
const needsEqualsPrefix = /^[A-Za-z]/.test(operator);
|
|
||||||
const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator;
|
|
||||||
|
|
||||||
queryParts.push(`${key}${operatorSegment}/${encodedValue}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = queryParts.join("&");
|
|
||||||
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${
|
|
||||||
queryString ? `?${queryString}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "Failed to fetch withdrawals" }));
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: errorData?.message || "Failed to fetch withdrawals",
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error(
|
|
||||||
"Proxy POST /api/dashboard/transactions/withdrawals error:",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Internal server error", error: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder Settings 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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,7 +4,7 @@ import React from "react";
|
|||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { Button, Box, Typography, Stack } from "@mui/material";
|
import { Button, Box, Typography, Stack } from "@mui/material";
|
||||||
import { AppDispatch } from "@/app/redux/types";
|
import { AppDispatch } from "@/app/redux/types";
|
||||||
import { autoLogout, validateAuth } from "@/app/redux/auth/authSlice";
|
import { autoLogout, refreshAuthStatus } from "@/app/redux/auth/authSlice";
|
||||||
|
|
||||||
export default function TestTokenExpiration() {
|
export default function TestTokenExpiration() {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
@ -14,7 +14,7 @@ export default function TestTokenExpiration() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshAuth = () => {
|
const handleRefreshAuth = () => {
|
||||||
dispatch(validateAuth());
|
dispatch(refreshAuthStatus());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
|
|
||||||
|
|
||||||
export default function GroupsPage() {
|
|
||||||
return (
|
|
||||||
<AdminResourceList
|
|
||||||
title="Groups"
|
|
||||||
endpoint="/api/dashboard/admin/groups"
|
|
||||||
responseCollectionKeys={["groups", "data", "items"]}
|
|
||||||
primaryLabelKeys={["name", "groupName", "title"]}
|
|
||||||
chipKeys={["status", "role", "permissions"]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
|
|
||||||
|
|
||||||
export default function PermissionsPage() {
|
|
||||||
return (
|
|
||||||
<AdminResourceList
|
|
||||||
title="Permissions"
|
|
||||||
endpoint="/api/dashboard/admin/permissions"
|
|
||||||
responseCollectionKeys={["permissions", "data", "items"]}
|
|
||||||
primaryLabelKeys={["name", "permissionName", "title", "description"]}
|
|
||||||
chipKeys={["status", "scope", "category"]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
|
|
||||||
|
|
||||||
export default function SessionsPage() {
|
|
||||||
return (
|
|
||||||
<AdminResourceList
|
|
||||||
title="Sessions"
|
|
||||||
endpoint="/api/dashboard/admin/sessions"
|
|
||||||
responseCollectionKeys={["sessions", "data", "items"]}
|
|
||||||
primaryLabelKeys={["sessionId", "id", "userId", "name", "title"]}
|
|
||||||
chipKeys={["status", "channel", "platform"]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
.audits-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-alert {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background-color: #fee;
|
|
||||||
color: #c62828;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #ffcdd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
width: 100%;
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow:
|
|
||||||
0px 2px 1px -1px rgba(0, 0, 0, 0.2),
|
|
||||||
0px 1px 1px 0px rgba(0, 0, 0, 0.14),
|
|
||||||
0px 1px 3px 0px rgba(0, 0, 0, 0.12);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.scroll-wrapper {
|
|
||||||
width: 85dvw;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
|
|
||||||
.table-inner {
|
|
||||||
min-width: 1200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,332 +1,22 @@
|
|||||||
"use client";
|
import DataTable from "@/app/features/DataTable/DataTable";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
DataGrid,
|
|
||||||
GridColDef,
|
|
||||||
GridPaginationModel,
|
|
||||||
GridSortModel,
|
|
||||||
} from "@mui/x-data-grid";
|
|
||||||
import { getAudits } from "@/app/services/audits";
|
import { getAudits } from "@/app/services/audits";
|
||||||
import "./page.scss";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import { Box, debounce } from "@mui/material";
|
|
||||||
|
|
||||||
type AuditRow = Record<string, unknown> & { id: string | number };
|
export default async function AuditPage({
|
||||||
|
searchParams,
|
||||||
interface AuditApiResponse {
|
}: {
|
||||||
total?: number;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
limit?: number;
|
}) {
|
||||||
page?: number;
|
// Await searchParams before processing
|
||||||
data?: unknown;
|
const params = await searchParams;
|
||||||
items?: unknown[];
|
// Create a safe query string by filtering only string values
|
||||||
audits?: unknown[];
|
const safeParams: Record<string, string> = {};
|
||||||
logs?: unknown[];
|
for (const [key, value] of Object.entries(params)) {
|
||||||
results?: unknown[];
|
if (typeof value === "string") {
|
||||||
records?: unknown[];
|
safeParams[key] = value;
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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 query = new URLSearchParams(safeParams).toString();
|
||||||
|
const data = await getAudits({ query });
|
||||||
|
|
||||||
const dataRecord =
|
return <DataTable data={data} />;
|
||||||
payload.data &&
|
|
||||||
typeof payload.data === "object" &&
|
|
||||||
!Array.isArray(payload.data)
|
|
||||||
? (payload.data as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (dataRecord) {
|
|
||||||
for (const key of CANDIDATE_ARRAY_KEYS) {
|
|
||||||
const candidate = dataRecord[key];
|
|
||||||
if (Array.isArray(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(payload.data)) {
|
|
||||||
return payload.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveTotal = (payload: AuditApiResponse, fallback: number): number => {
|
|
||||||
const fromPayload = payload.total;
|
|
||||||
const fromMeta = payload.meta?.total;
|
|
||||||
const fromPagination = payload.pagination?.total;
|
|
||||||
const fromData =
|
|
||||||
payload.data &&
|
|
||||||
typeof payload.data === "object" &&
|
|
||||||
!Array.isArray(payload.data)
|
|
||||||
? (payload.data as { total?: number }).total
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
(typeof fromPayload === "number" && fromPayload) ||
|
|
||||||
(typeof fromMeta === "number" && fromMeta) ||
|
|
||||||
(typeof fromPagination === "number" && fromPagination) ||
|
|
||||||
(typeof fromData === "number" && fromData) ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeRows = (entries: unknown[], page: number): AuditRow[] =>
|
|
||||||
entries.map((entry, index) => {
|
|
||||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
||||||
return {
|
|
||||||
id: `${page}-${index}`,
|
|
||||||
value: normalizeValue(entry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = entry as Record<string, unknown>;
|
|
||||||
|
|
||||||
const normalized: Record<string, unknown> = {};
|
|
||||||
Object.entries(record).forEach(([key, value]) => {
|
|
||||||
normalized[key] = normalizeValue(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const identifier =
|
|
||||||
record.id ??
|
|
||||||
record.audit_id ??
|
|
||||||
record.log_id ??
|
|
||||||
record._id ??
|
|
||||||
`${page}-${index}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: (identifier as string | number) ?? `${page}-${index}`,
|
|
||||||
...normalized,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function AuditPage() {
|
|
||||||
const [rows, setRows] = useState<AuditRow[]>([]);
|
|
||||||
const [columns, setColumns] = useState<GridColDef[]>([]);
|
|
||||||
const [rowCount, setRowCount] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
|
|
||||||
page: 0,
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
});
|
|
||||||
const [sortModel, setSortModel] = useState<GridSortModel>([]);
|
|
||||||
const [entitySearch, setEntitySearch] = useState<string>("");
|
|
||||||
const [entitySearchInput, setEntitySearchInput] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const fetchAudits = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const sortParam =
|
|
||||||
sortModel.length && sortModel[0].field && sortModel[0].sort
|
|
||||||
? `${sortModel[0].field}:${sortModel[0].sort}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const entityParam = entitySearch.trim()
|
|
||||||
? `LIKE/${entitySearch.trim()}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = (await getAudits({
|
|
||||||
limit: paginationModel.pageSize,
|
|
||||||
page: paginationModel.page + 1,
|
|
||||||
sort: sortParam,
|
|
||||||
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 (
|
|
||||||
<div className="audits-page">
|
|
||||||
<Box sx={{ display: "flex", gap: 2, mt: 5 }}>
|
|
||||||
<TextField
|
|
||||||
label="Search by Entity"
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
value={entitySearchInput}
|
|
||||||
onChange={e => handleEntitySearchChange(e.target.value)}
|
|
||||||
sx={{ width: 300, backgroundColor: "#f0f0f0" }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<h1 className="page-title">{pageTitle}</h1>
|
|
||||||
{error && (
|
|
||||||
<div className="error-alert" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="table-container">
|
|
||||||
<div className="scroll-wrapper">
|
|
||||||
<div
|
|
||||||
className="table-inner"
|
|
||||||
style={{ minWidth: `${columns.length * 200}px` }}
|
|
||||||
>
|
|
||||||
<DataGrid
|
|
||||||
rows={rows}
|
|
||||||
columns={columns.length ? columns : FALLBACK_COLUMNS}
|
|
||||||
loading={loading}
|
|
||||||
paginationMode="server"
|
|
||||||
sortingMode="server"
|
|
||||||
paginationModel={paginationModel}
|
|
||||||
onPaginationModelChange={handlePaginationChange}
|
|
||||||
rowCount={rowCount}
|
|
||||||
sortModel={sortModel}
|
|
||||||
onSortModelChange={handleSortModelChange}
|
|
||||||
pageSizeOptions={[10, 25, 50, 100]}
|
|
||||||
disableRowSelectionOnClick
|
|
||||||
sx={{
|
|
||||||
border: 0,
|
|
||||||
minHeight: 500,
|
|
||||||
"& .MuiDataGrid-cell": {
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,36 @@ import {
|
|||||||
selectFilters,
|
selectFilters,
|
||||||
selectPagination,
|
selectPagination,
|
||||||
selectSort,
|
selectSort,
|
||||||
|
selectStatus,
|
||||||
|
selectError,
|
||||||
} from "@/app/redux/advanedSearch/selectors";
|
} from "@/app/redux/advanedSearch/selectors";
|
||||||
|
import { GridColDef } from "@mui/x-data-grid";
|
||||||
|
import Spinner from "@/app/components/Spinner/Spinner";
|
||||||
import { AppDispatch } from "@/app/redux/store";
|
import { AppDispatch } from "@/app/redux/store";
|
||||||
import { setError as setAdvancedSearchError } from "@/app/redux/advanedSearch/advancedSearchSlice";
|
import {
|
||||||
import { TransactionRow, BackendTransaction } from "../interface";
|
setStatus,
|
||||||
|
setError as setAdvancedSearchError,
|
||||||
|
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TABLE_COLUMNS,
|
||||||
|
TABLE_SEARCH_LABELS,
|
||||||
|
} from "@/app/features/DataTable/constants";
|
||||||
|
|
||||||
|
interface TransactionRow {
|
||||||
|
id: number;
|
||||||
|
userId?: string;
|
||||||
|
transactionId: string;
|
||||||
|
type?: string;
|
||||||
|
currency?: string;
|
||||||
|
amount?: number;
|
||||||
|
status?: string;
|
||||||
|
dateTime?: string;
|
||||||
|
merchantId?: string;
|
||||||
|
pspId?: string;
|
||||||
|
methodId?: string;
|
||||||
|
modified?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AllTransactionPage() {
|
export default function AllTransactionPage() {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
@ -18,21 +44,16 @@ export default function AllTransactionPage() {
|
|||||||
const pagination = useSelector(selectPagination);
|
const pagination = useSelector(selectPagination);
|
||||||
const sort = useSelector(selectSort);
|
const sort = useSelector(selectSort);
|
||||||
|
|
||||||
const [tableData, setTableData] = useState<{
|
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
|
||||||
transactions: TransactionRow[];
|
|
||||||
total: number;
|
|
||||||
}>({ transactions: [], total: 0 });
|
|
||||||
const extraColumns: string[] = []; // static for now
|
const extraColumns: string[] = []; // static for now
|
||||||
|
|
||||||
// Memoize rows to avoid new reference each render
|
// Memoize rows to avoid new reference each render
|
||||||
const memoizedRows = useMemo(
|
const memoizedRows = useMemo(() => tableRows, [tableRows]);
|
||||||
() => tableData.transactions,
|
|
||||||
[tableData.transactions]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch data when filters, pagination, or sort changes
|
// Fetch data when filters, pagination, or sort changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
dispatch(setStatus("loading"));
|
||||||
dispatch(setAdvancedSearchError(null));
|
dispatch(setAdvancedSearchError(null));
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/dashboard/transactions", {
|
const response = await fetch("/api/dashboard/transactions", {
|
||||||
@ -43,15 +64,15 @@ export default function AllTransactionPage() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
dispatch(setAdvancedSearchError("Failed to fetch transactions"));
|
dispatch(setAdvancedSearchError("Failed to fetch transactions"));
|
||||||
setTableData({ transactions: [], total: 0 });
|
setTableRows([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const backendData = await response.json();
|
||||||
const transactions = data.transactions || [];
|
const transactions = backendData.transactions || [];
|
||||||
|
|
||||||
const rows = transactions.map((tx: BackendTransaction) => ({
|
const rows = transactions.map((tx: any) => ({
|
||||||
id: tx.id || 0,
|
id: tx.id,
|
||||||
userId: tx.customer,
|
userId: tx.customer,
|
||||||
transactionId: tx.external_id || tx.id,
|
transactionId: tx.external_id || tx.id,
|
||||||
type: tx.type,
|
type: tx.type,
|
||||||
@ -65,25 +86,20 @@ export default function AllTransactionPage() {
|
|||||||
modified: tx.modified,
|
modified: tx.modified,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setTableData({ transactions: rows, total: data?.total });
|
setTableRows(rows);
|
||||||
|
dispatch(setStatus("succeeded"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch(
|
dispatch(
|
||||||
setAdvancedSearchError(
|
setAdvancedSearchError(
|
||||||
error instanceof Error ? error.message : "Unknown error"
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setTableData({ transactions: [], total: 0 });
|
setTableRows([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [dispatch, filters, pagination, sort]);
|
}, [dispatch, filters, pagination, sort]);
|
||||||
|
|
||||||
return (
|
return <DataTable rows={memoizedRows} extraColumns={extraColumns} />;
|
||||||
<DataTable
|
|
||||||
rows={memoizedRows}
|
|
||||||
extraColumns={extraColumns}
|
|
||||||
totalRows={tableData.total}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,97 +1,23 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import DataTable from "@/app/features/DataTable/DataTable";
|
import DataTable from "@/app/features/DataTable/DataTable";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { getTransactions } from "@/app/services/transactions";
|
||||||
import { AppDispatch } from "@/app/redux/store";
|
|
||||||
import {
|
|
||||||
selectFilters,
|
|
||||||
selectPagination,
|
|
||||||
selectSort,
|
|
||||||
} from "@/app/redux/advanedSearch/selectors";
|
|
||||||
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() {
|
export default async function DepositTransactionPage({
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
searchParams,
|
||||||
const filters = useSelector(selectFilters);
|
}: {
|
||||||
const pagination = useSelector(selectPagination);
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
const sort = useSelector(selectSort);
|
}) {
|
||||||
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
|
// Await searchParams before processing
|
||||||
const [rowCount, setRowCount] = useState(0);
|
const params = await searchParams;
|
||||||
|
// Create a safe query string by filtering only string values
|
||||||
const memoizedRows = useMemo(() => tableRows, [tableRows]);
|
const safeParams: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
const depositFilters = useMemo(() => {
|
if (typeof value === "string") {
|
||||||
return {
|
safeParams[key] = value;
|
||||||
...filters,
|
|
||||||
Type: {
|
|
||||||
operator: "==",
|
|
||||||
value: "deposit",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchDeposits = async () => {
|
|
||||||
dispatch(setStatus("loading"));
|
|
||||||
dispatch(setAdvancedSearchError(null));
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/dashboard/transactions/deposits", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
filters: depositFilters,
|
|
||||||
pagination,
|
|
||||||
sort,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
dispatch(setAdvancedSearchError("Failed to fetch deposits"));
|
|
||||||
setTableRows([]);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendData = await response.json();
|
|
||||||
const transactions: BackendTransaction[] =
|
|
||||||
backendData.transactions || [];
|
|
||||||
|
|
||||||
const rows: TransactionRow[] = transactions.map(tx => ({
|
|
||||||
id: tx.id,
|
|
||||||
userId: tx.customer,
|
|
||||||
transactionId: String(tx.external_id ?? tx.id),
|
|
||||||
type: tx.type,
|
|
||||||
currency: tx.currency,
|
|
||||||
amount: tx.amount,
|
|
||||||
status: tx.status,
|
|
||||||
dateTime: tx.created || tx.modified,
|
|
||||||
merchantId: tx.merchant_id,
|
|
||||||
pspId: tx.psp_id,
|
|
||||||
methodId: tx.method_id,
|
|
||||||
modified: tx.modified,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTableRows(rows);
|
|
||||||
setRowCount(100);
|
|
||||||
dispatch(setStatus("succeeded"));
|
|
||||||
} catch (error) {
|
|
||||||
dispatch(
|
|
||||||
setAdvancedSearchError(
|
|
||||||
error instanceof Error ? error.message : "Unknown error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setTableRows([]);
|
|
||||||
}
|
}
|
||||||
};
|
const query = new URLSearchParams(safeParams).toString();
|
||||||
|
const transactionType = "deposits";
|
||||||
|
const data = await getTransactions({ transactionType, query });
|
||||||
|
|
||||||
fetchDeposits();
|
return <DataTable data={data} />;
|
||||||
}, [dispatch, depositFilters, pagination, sort]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable rows={memoizedRows} enableStatusActions totalRows={rowCount} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
export interface TransactionRow {
|
|
||||||
id: number;
|
|
||||||
userId?: string;
|
|
||||||
transactionId: string;
|
|
||||||
type?: string;
|
|
||||||
currency?: string;
|
|
||||||
amount?: number;
|
|
||||||
status?: string;
|
|
||||||
dateTime?: string;
|
|
||||||
merchantId?: string;
|
|
||||||
pspId?: string;
|
|
||||||
methodId?: string;
|
|
||||||
modified?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackendTransaction {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,100 +1,23 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import DataTable from "@/app/features/DataTable/DataTable";
|
import DataTable from "@/app/features/DataTable/DataTable";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { getTransactions } from "@/app/services/transactions";
|
||||||
import { AppDispatch } from "@/app/redux/store";
|
|
||||||
import {
|
|
||||||
selectFilters,
|
|
||||||
selectPagination,
|
|
||||||
selectSort,
|
|
||||||
} from "@/app/redux/advanedSearch/selectors";
|
|
||||||
import {
|
|
||||||
setStatus,
|
|
||||||
setError as setAdvancedSearchError,
|
|
||||||
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { TransactionRow, BackendTransaction } from "../interface";
|
|
||||||
|
|
||||||
export default function WithdrawalTransactionPage() {
|
export default async function WithdrawalTransactionPage({
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
searchParams,
|
||||||
const filters = useSelector(selectFilters);
|
}: {
|
||||||
const pagination = useSelector(selectPagination);
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
const sort = useSelector(selectSort);
|
}) {
|
||||||
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
|
// Await searchParams before processing
|
||||||
const [rowCount, setRowCount] = useState(0);
|
const params = await searchParams;
|
||||||
|
// Create a safe query string by filtering only string values
|
||||||
const memoizedRows = useMemo(() => tableRows, [tableRows]);
|
const safeParams: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
const withdrawalFilters = useMemo(() => {
|
if (typeof value === "string") {
|
||||||
return {
|
safeParams[key] = value;
|
||||||
...filters,
|
|
||||||
Type: {
|
|
||||||
operator: "==",
|
|
||||||
value: "withdrawal",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchWithdrawals = async () => {
|
|
||||||
dispatch(setStatus("loading"));
|
|
||||||
dispatch(setAdvancedSearchError(null));
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/dashboard/transactions/withdrawals",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
filters: withdrawalFilters,
|
|
||||||
pagination,
|
|
||||||
sort,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
dispatch(setAdvancedSearchError("Failed to fetch withdrawals"));
|
|
||||||
setTableRows([]);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
const query = new URLSearchParams(safeParams).toString();
|
||||||
|
const transactionType = "withdrawal";
|
||||||
|
const data = await getTransactions({ transactionType, query });
|
||||||
|
|
||||||
const backendData = await response.json();
|
return <DataTable data={data} />;
|
||||||
const transactions: BackendTransaction[] =
|
|
||||||
backendData.transactions || [];
|
|
||||||
|
|
||||||
const rows: TransactionRow[] = transactions.map(tx => ({
|
|
||||||
id: tx.id,
|
|
||||||
userId: tx.customer,
|
|
||||||
transactionId: String(tx.external_id ?? tx.id),
|
|
||||||
type: tx.type,
|
|
||||||
currency: tx.currency,
|
|
||||||
amount: tx.amount,
|
|
||||||
status: tx.status,
|
|
||||||
dateTime: tx.created || tx.modified,
|
|
||||||
merchantId: tx.merchant_id,
|
|
||||||
pspId: tx.psp_id,
|
|
||||||
methodId: tx.method_id,
|
|
||||||
modified: tx.modified,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTableRows(rows);
|
|
||||||
setRowCount(100);
|
|
||||||
dispatch(setStatus("succeeded"));
|
|
||||||
} catch (error) {
|
|
||||||
dispatch(
|
|
||||||
setAdvancedSearchError(
|
|
||||||
error instanceof Error ? error.message : "Unknown error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setTableRows([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchWithdrawals();
|
|
||||||
}, [dispatch, withdrawalFilters, pagination, sort]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable rows={memoizedRows} enableStatusActions totalRows={rowCount} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,290 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Spinner from "@/app/components/Spinner/Spinner";
|
|
||||||
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,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
|
|
||||||
type ResourceRow = DataRowBase & Record<string, unknown>;
|
|
||||||
|
|
||||||
type FilterValue =
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
operator?: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AdminResourceListProps {
|
|
||||||
title: string;
|
|
||||||
endpoint: string;
|
|
||||||
responseCollectionKeys?: string[];
|
|
||||||
primaryLabelKeys: string[];
|
|
||||||
chipKeys?: string[];
|
|
||||||
excludeKeys?: string[];
|
|
||||||
filterOverrides?: Record<string, FilterValue>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
|
|
||||||
|
|
||||||
const ensureRowId = (
|
|
||||||
row: Record<string, unknown>,
|
|
||||||
fallbackId: number
|
|
||||||
): ResourceRow => {
|
|
||||||
const currentId = row.id;
|
|
||||||
|
|
||||||
if (typeof currentId === "number") {
|
|
||||||
return row as ResourceRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numericId = Number(currentId);
|
|
||||||
|
|
||||||
if (!Number.isNaN(numericId) && numericId !== 0) {
|
|
||||||
return { ...row, id: numericId } as ResourceRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...row, id: fallbackId } as ResourceRow;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveCollection = (
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
preferredKeys: string[] = []
|
|
||||||
) => {
|
|
||||||
for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) {
|
|
||||||
const maybeCollection = payload?.[key];
|
|
||||||
if (Array.isArray(maybeCollection)) {
|
|
||||||
return maybeCollection as Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
return payload as Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminResourceList = ({
|
|
||||||
title,
|
|
||||||
endpoint,
|
|
||||||
responseCollectionKeys = [],
|
|
||||||
primaryLabelKeys,
|
|
||||||
chipKeys = [],
|
|
||||||
excludeKeys = [],
|
|
||||||
}: AdminResourceListProps) => {
|
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
|
||||||
const filters = useSelector(selectFilters);
|
|
||||||
const pagination = useSelector(selectPagination);
|
|
||||||
const sort = useSelector(selectSort);
|
|
||||||
const status = useSelector(selectStatus);
|
|
||||||
const errorMessage = useSelector(selectError);
|
|
||||||
|
|
||||||
const [rows, setRows] = useState<ResourceRow[]>([]);
|
|
||||||
|
|
||||||
const normalizedTitle = title.toLowerCase();
|
|
||||||
|
|
||||||
const excludedKeys = useMemo(() => {
|
|
||||||
const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]);
|
|
||||||
excludeKeys.forEach(key => baseExcluded.add(key));
|
|
||||||
return Array.from(baseExcluded);
|
|
||||||
}, [primaryLabelKeys, chipKeys, excludeKeys]);
|
|
||||||
|
|
||||||
const getPrimaryLabel = (row: ResourceRow) => {
|
|
||||||
for (const key of primaryLabelKeys) {
|
|
||||||
if (row[key]) {
|
|
||||||
return String(row[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `${title} #${row.id}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMetaChips = (row: ResourceRow) =>
|
|
||||||
chipKeys
|
|
||||||
.filter(key => row[key])
|
|
||||||
.map(key => ({
|
|
||||||
key,
|
|
||||||
value: String(row[key]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getSecondaryDetails = (row: ResourceRow) =>
|
|
||||||
Object.entries(row).filter(([key]) => !excludedKeys.includes(key));
|
|
||||||
|
|
||||||
const resolvedCollectionKeys = useMemo(
|
|
||||||
() => [...responseCollectionKeys],
|
|
||||||
[responseCollectionKeys]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchResources = async () => {
|
|
||||||
dispatch(setStatus("loading"));
|
|
||||||
dispatch(setAdvancedSearchError(null));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
filters,
|
|
||||||
pagination,
|
|
||||||
sort,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
dispatch(
|
|
||||||
setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`)
|
|
||||||
);
|
|
||||||
setRows([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendData = await response.json();
|
|
||||||
const collection = resolveCollection(
|
|
||||||
backendData,
|
|
||||||
resolvedCollectionKeys
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextRows = collection.map((item, index) =>
|
|
||||||
ensureRowId(item, index + 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
setRows(nextRows);
|
|
||||||
dispatch(setStatus("succeeded"));
|
|
||||||
} catch (error) {
|
|
||||||
dispatch(
|
|
||||||
setAdvancedSearchError(
|
|
||||||
error instanceof Error ? error.message : "Unknown error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setRows([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchResources();
|
|
||||||
}, [
|
|
||||||
dispatch,
|
|
||||||
endpoint,
|
|
||||||
filters,
|
|
||||||
pagination,
|
|
||||||
sort,
|
|
||||||
resolvedCollectionKeys,
|
|
||||||
normalizedTitle,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 3, width: "100%", maxWidth: 900 }}>
|
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{status === "loading" && (
|
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mb: 2 }}>
|
|
||||||
<Spinner size="small" color="#000" />
|
|
||||||
<Typography variant="body2">
|
|
||||||
{`Loading ${normalizedTitle}...`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "failed" && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{errorMessage || `Failed to load ${normalizedTitle}`}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!rows.length && status === "succeeded" && (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{`No ${normalizedTitle} found.`}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{rows.length > 0 && (
|
|
||||||
<List
|
|
||||||
sx={{ bgcolor: "background.paper", borderRadius: 2, boxShadow: 1 }}
|
|
||||||
>
|
|
||||||
{rows.map(row => {
|
|
||||||
const chips = getMetaChips(row);
|
|
||||||
const secondary = getSecondaryDetails(row);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={row.id}>
|
|
||||||
<ListItem alignItems="flex-start">
|
|
||||||
<Box sx={{ width: "100%" }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" fontWeight={600}>
|
|
||||||
{getPrimaryLabel(row)}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
ID: {row.id}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{chips.length > 0 && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 1,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
my: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{chips.map(chip => (
|
|
||||||
<Chip
|
|
||||||
key={`${row.id}-${chip.key}`}
|
|
||||||
label={`${chip.key}: ${chip.value}`}
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{secondary.length > 0 && (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{secondary
|
|
||||||
.map(([key, value]) => `${key}: ${String(value)}`)
|
|
||||||
.join(" • ")}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</ListItem>
|
|
||||||
<Divider component="li" />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminResourceList;
|
|
||||||
@ -28,7 +28,6 @@ import {
|
|||||||
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
||||||
import { selectFilters } from "@/app/redux/advanedSearch/selectors";
|
import { selectFilters } from "@/app/redux/advanedSearch/selectors";
|
||||||
import { normalizeValue, defaultOperatorForField } from "./utils/utils";
|
import { normalizeValue, defaultOperatorForField } from "./utils/utils";
|
||||||
import { selectConditionOperators } from "@/app/redux/metadata/selectors";
|
|
||||||
|
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
// COMPONENT
|
// COMPONENT
|
||||||
@ -43,9 +42,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
// Local form state for UI (synced with Redux)
|
// Local form state for UI (synced with Redux)
|
||||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||||
const [operators, setOperators] = useState<Record<string, string>>({});
|
const [operators, setOperators] = useState<Record<string, string>>({});
|
||||||
const conditionOperators = useSelector(selectConditionOperators);
|
|
||||||
|
|
||||||
console.log("[conditionOperators]", conditionOperators);
|
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
|
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
@ -258,14 +255,12 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
updateOperator(field, e.target.value)
|
updateOperator(field, e.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{Object.entries(conditionOperators ?? {}).map(
|
<MenuItem value=">=">Greater or equal</MenuItem>
|
||||||
([key, value]) => (
|
<MenuItem value="<=">Less or equal</MenuItem>
|
||||||
<MenuItem key={key} value={value}>
|
<MenuItem value="=">Equal</MenuItem>
|
||||||
{key.replace(/_/g, " ")}{" "}
|
<MenuItem value="!=">Not equal</MenuItem>
|
||||||
{/* Optional: make it readable */}
|
<MenuItem value=">">Greater</MenuItem>
|
||||||
</MenuItem>
|
<MenuItem value="<">Less</MenuItem>
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,17 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// -----------------------------------------------------
|
||||||
|
// UTILITIES
|
||||||
|
// -----------------------------------------------------
|
||||||
|
|
||||||
|
export const extractOperator = (val?: string | null): string | null => {
|
||||||
|
if (!val) return null;
|
||||||
|
|
||||||
|
const match = val.match(/^(==|!=|>=|<=|LIKE|>|<)/);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatWithOperator = (operator: string, value: string) =>
|
||||||
|
`${operator}/${value}`;
|
||||||
|
|
||||||
export const normalizeValue = (input: any): string => {
|
export const normalizeValue = (input: any): string => {
|
||||||
if (input == null) return "";
|
if (input == null) return "";
|
||||||
if (typeof input === "string" || typeof input === "number")
|
if (typeof input === "string" || typeof input === "number")
|
||||||
@ -14,6 +27,22 @@ export const normalizeValue = (input: any): string => {
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const encodeFilter = (fullValue: string): string => {
|
||||||
|
// Split ONLY on the first slash
|
||||||
|
const index = fullValue.indexOf("/");
|
||||||
|
if (index === -1) return fullValue;
|
||||||
|
|
||||||
|
const operator = fullValue.slice(0, index);
|
||||||
|
const rawValue = fullValue.slice(index + 1);
|
||||||
|
|
||||||
|
return `${operator}/${encodeURIComponent(rawValue)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeFilter = (encoded: string): string => {
|
||||||
|
const [operator, encodedValue] = encoded.split("/");
|
||||||
|
return `${operator}/${decodeURIComponent(encodedValue)}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Default operator based on field and type
|
// Default operator based on field and type
|
||||||
export const defaultOperatorForField = (
|
export const defaultOperatorForField = (
|
||||||
field: string,
|
field: string,
|
||||||
|
|||||||
@ -1,129 +1,149 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useMemo } from "react";
|
import React, { useState, useMemo } from "react";
|
||||||
import { DataGrid, GridPaginationModel } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { Box, Paper, Alert } from "@mui/material";
|
import { Box, Paper, IconButton, Alert } from "@mui/material";
|
||||||
import DataTableHeader from "./DataTableHeader";
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||||
import StatusChangeDialog from "./StatusChangeDialog";
|
import StatusChangeDialog from "./StatusChangeDialog";
|
||||||
|
import DataTableHeader from "./DataTableHeader";
|
||||||
|
import { TABLE_COLUMNS } from "./constants";
|
||||||
import Spinner from "@/app/components/Spinner/Spinner";
|
import Spinner from "@/app/components/Spinner/Spinner";
|
||||||
import {
|
import { useSelector } from "react-redux";
|
||||||
selectStatus,
|
import { selectStatus, selectError } from "@/app/redux/auth/selectors";
|
||||||
selectError,
|
|
||||||
selectPagination,
|
|
||||||
selectPaginationModel,
|
|
||||||
} from "@/app/redux/advanedSearch/selectors";
|
|
||||||
import { makeSelectEnhancedColumns } from "./re-selectors";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { DataRowBase } from "./types";
|
|
||||||
import { setPagination } from "@/app/redux/advanedSearch/advancedSearchSlice";
|
|
||||||
import { AppDispatch } from "@/app/redux/store";
|
|
||||||
|
|
||||||
interface DataTableProps<TRow extends DataRowBase> {
|
interface IDataTableProps<TRow extends { id: number }> {
|
||||||
rows: TRow[];
|
rows: TRow[];
|
||||||
extraColumns?: string[];
|
extraColumns?: string[];
|
||||||
enableStatusActions?: boolean;
|
|
||||||
totalRows?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataTable = <TRow extends DataRowBase>({
|
const DataTable = <TRow extends { id: number }>({
|
||||||
rows: localRows,
|
rows,
|
||||||
extraColumns,
|
extraColumns,
|
||||||
enableStatusActions = false,
|
}: IDataTableProps<TRow>) => {
|
||||||
totalRows: totalRows,
|
|
||||||
}: DataTableProps<TRow>) => {
|
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
|
||||||
const [showExtraColumns, setShowExtraColumns] = useState(false);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
||||||
const [pendingStatus, setPendingStatus] = useState<string>("");
|
const [newStatus, setNewStatus] = useState<string>("");
|
||||||
const [reason, setReason] = useState<string>("");
|
const [reason, setReason] = useState<string>("");
|
||||||
const [statusUpdateError, setStatusUpdateError] = useState<string | null>(
|
const [showExtraColumns, setShowExtraColumns] = useState(false);
|
||||||
null
|
|
||||||
);
|
|
||||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
|
||||||
|
|
||||||
const status = useSelector(selectStatus);
|
const status = useSelector(selectStatus);
|
||||||
const errorMessage = useSelector(selectError);
|
const errorMessage = useSelector(selectError);
|
||||||
const pagination = useSelector(selectPagination);
|
|
||||||
const paginationModel = useSelector(selectPaginationModel);
|
|
||||||
|
|
||||||
const handlePaginationModelChange = useCallback(
|
// Open status modal
|
||||||
(model: GridPaginationModel) => {
|
const handleStatusChange = (id: number, status: string) => {
|
||||||
console.log("model", model);
|
setSelectedRowId(id);
|
||||||
const nextPage = model.page + 1;
|
setNewStatus(status);
|
||||||
const nextLimit = model.pageSize;
|
|
||||||
|
|
||||||
if (nextPage !== pagination.page || nextLimit !== pagination.limit) {
|
|
||||||
dispatch(setPagination({ page: nextPage, limit: nextLimit }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dispatch, pagination.page, pagination.limit]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStatusChange = useCallback((rowId: number, newStatus: string) => {
|
|
||||||
setSelectedRowId(rowId);
|
|
||||||
setPendingStatus(newStatus);
|
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}, []);
|
|
||||||
|
|
||||||
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(
|
const handleStatusSave = () => {
|
||||||
`/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);
|
setModalOpen(false);
|
||||||
setReason("");
|
setReason("");
|
||||||
setPendingStatus("");
|
// rows update should happen in parent component
|
||||||
setStatusUpdateError(null);
|
|
||||||
setSelectedRowId(null);
|
|
||||||
} catch (err) {
|
|
||||||
setStatusUpdateError(
|
|
||||||
err instanceof Error ? err.message : "Failed to update transaction"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsUpdatingStatus(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectEnhancedColumns = useMemo(makeSelectEnhancedColumns, []);
|
// Columns filtered by extraColumns toggle
|
||||||
|
const visibleColumns = useMemo(() => {
|
||||||
|
if (!extraColumns || extraColumns.length === 0) return TABLE_COLUMNS;
|
||||||
|
return showExtraColumns
|
||||||
|
? TABLE_COLUMNS
|
||||||
|
: TABLE_COLUMNS.filter(col => !extraColumns.includes(col.field));
|
||||||
|
}, [extraColumns, showExtraColumns]);
|
||||||
|
|
||||||
const enhancedColumns = useSelector(state =>
|
// Columns with custom renderers
|
||||||
selectEnhancedColumns(state, {
|
const enhancedColumns = useMemo<GridColDef[]>(() => {
|
||||||
enableStatusActions,
|
return visibleColumns.map(col => {
|
||||||
extraColumns,
|
if (col.field === "status") {
|
||||||
showExtraColumns,
|
return {
|
||||||
localRows,
|
...col,
|
||||||
handleStatusChange,
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
})
|
const value = params.value?.toLowerCase();
|
||||||
|
let bgColor = "#e0e0e0";
|
||||||
|
let textColor = "#000";
|
||||||
|
switch (value) {
|
||||||
|
case "completed":
|
||||||
|
bgColor = "#d0f0c0";
|
||||||
|
textColor = "#1b5e20";
|
||||||
|
break;
|
||||||
|
case "pending":
|
||||||
|
bgColor = "#fff4cc";
|
||||||
|
textColor = "#9e7700";
|
||||||
|
break;
|
||||||
|
case "inprogress":
|
||||||
|
bgColor = "#cce5ff";
|
||||||
|
textColor = "#004085";
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
bgColor = "#ffcdd2";
|
||||||
|
textColor = "#c62828";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
color: textColor,
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
display: "inline-block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{params.value}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.field === "userId") {
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
headerAlign: "center",
|
||||||
|
align: "center",
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{params.value}
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
href={`/users/${params.value}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0.5, ml: 1 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<OpenInNewIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
}, [visibleColumns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{status === "loading" && <Spinner size="small" color="#fff" />}
|
{status === "loading" && <Spinner size="small" color="#fff" />}
|
||||||
@ -140,15 +160,11 @@ const DataTable = <TRow extends DataRowBase>({
|
|||||||
onOpenExport={() => {}}
|
onOpenExport={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ width: "85vw" }}>
|
<Box sx={{ width: "100%", overflowX: "auto" }}>
|
||||||
<Box sx={{ minWidth: 1200 }}>
|
<Box sx={{ minWidth: 1200 }}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={localRows}
|
rows={rows}
|
||||||
columns={enhancedColumns}
|
columns={enhancedColumns}
|
||||||
paginationModel={paginationModel}
|
|
||||||
onPaginationModelChange={handlePaginationModelChange}
|
|
||||||
paginationMode={totalRows ? "server" : "client"}
|
|
||||||
rowCount={totalRows}
|
|
||||||
pageSizeOptions={[10, 25, 50, 100]}
|
pageSizeOptions={[10, 25, 50, 100]}
|
||||||
sx={{
|
sx={{
|
||||||
border: 0,
|
border: 0,
|
||||||
@ -171,19 +187,11 @@ const DataTable = <TRow extends DataRowBase>({
|
|||||||
|
|
||||||
<StatusChangeDialog
|
<StatusChangeDialog
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
newStatus={pendingStatus}
|
newStatus={newStatus}
|
||||||
reason={reason}
|
reason={reason}
|
||||||
setReason={setReason}
|
setReason={setReason}
|
||||||
handleClose={() => {
|
handleClose={() => setModalOpen(false)}
|
||||||
setModalOpen(false);
|
|
||||||
setReason("");
|
|
||||||
setPendingStatus("");
|
|
||||||
setStatusUpdateError(null);
|
|
||||||
setSelectedRowId(null);
|
|
||||||
}}
|
|
||||||
handleSave={handleStatusSave}
|
handleSave={handleStatusSave}
|
||||||
isSubmitting={isUpdatingStatus}
|
|
||||||
errorMessage={statusUpdateError}
|
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
Button,
|
Button,
|
||||||
TextField,
|
TextField,
|
||||||
Alert,
|
|
||||||
CircularProgress,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
@ -17,8 +15,6 @@ interface StatusChangeDialogProps {
|
|||||||
setReason: React.Dispatch<React.SetStateAction<string>>;
|
setReason: React.Dispatch<React.SetStateAction<string>>;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
handleSave: () => void;
|
handleSave: () => void;
|
||||||
isSubmitting?: boolean;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusChangeDialog = ({
|
const StatusChangeDialog = ({
|
||||||
@ -28,19 +24,17 @@ const StatusChangeDialog = ({
|
|||||||
setReason,
|
setReason,
|
||||||
handleClose,
|
handleClose,
|
||||||
handleSave,
|
handleSave,
|
||||||
isSubmitting = false,
|
|
||||||
errorMessage,
|
|
||||||
}: StatusChangeDialogProps) => {
|
}: StatusChangeDialogProps) => {
|
||||||
const [isValid, setIsValid] = useState(false);
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const noSpaces = reason.replace(/\s/g, "");
|
const noSpaces = reason.replace(/\s/g, ""); // remove all spaces
|
||||||
const length = noSpaces.length;
|
const length = noSpaces.length;
|
||||||
setIsValid(length >= 12 && length <= 400);
|
setIsValid(length >= 12 && length <= 400);
|
||||||
}, [reason]);
|
}, [reason]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
<Dialog open={open} onClose={handleClose}>
|
||||||
<DialogTitle>Change Status</DialogTitle>
|
<DialogTitle>Change Status</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
You want to change the status to <b>{newStatus}</b>. Please provide a
|
You want to change the status to <b>{newStatus}</b>. Please provide a
|
||||||
@ -56,22 +50,10 @@ const StatusChangeDialog = ({
|
|||||||
helperText="Reason must be between 12 and 400 characters"
|
helperText="Reason must be between 12 and 400 characters"
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2 }}
|
||||||
/>
|
/>
|
||||||
{errorMessage && (
|
|
||||||
<Alert severity="error" sx={{ mt: 2 }}>
|
|
||||||
{errorMessage}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleClose} disabled={isSubmitting}>
|
<Button onClick={handleClose}>Cancel</Button>
|
||||||
Cancel
|
<Button variant="contained" onClick={handleSave} disabled={!isValid}>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!isValid || isSubmitting}
|
|
||||||
startIcon={isSubmitting ? <CircularProgress size={18} /> : undefined}
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
@ -33,5 +33,5 @@ export const TABLE_SEARCH_LABELS: ISearchLabel[] = [
|
|||||||
options: ["pending", "completed", "failed"],
|
options: ["pending", "completed", "failed"],
|
||||||
},
|
},
|
||||||
{ label: "Amount", field: "Amount", type: "text" },
|
{ label: "Amount", field: "Amount", type: "text" },
|
||||||
{ label: "Date / Time", field: "Created", type: "date" },
|
{ label: "Date / Time", field: "created", type: "date" },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,333 +0,0 @@
|
|||||||
import { createSelector } from "@reduxjs/toolkit";
|
|
||||||
import { Box, IconButton, MenuItem, Select } from "@mui/material";
|
|
||||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
|
||||||
import { GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
|
||||||
import { RootState } from "@/app/redux/store";
|
|
||||||
import { TABLE_COLUMNS } from "../constants";
|
|
||||||
import { selectTransactionStatuses } from "@/app/redux/metadata/selectors";
|
|
||||||
import { DataRowBase } from "../types";
|
|
||||||
|
|
||||||
const TRANSACTION_STATUS_FALLBACK: string[] = [
|
|
||||||
"pending",
|
|
||||||
"completed",
|
|
||||||
"failed",
|
|
||||||
"inprogress",
|
|
||||||
"error",
|
|
||||||
];
|
|
||||||
|
|
||||||
type SelectorProps = {
|
|
||||||
enableStatusActions: boolean;
|
|
||||||
extraColumns?: string[] | null;
|
|
||||||
showExtraColumns?: boolean;
|
|
||||||
localRows: DataRowBase[];
|
|
||||||
handleStatusChange: (rowId: number, newStatus: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------
|
|
||||||
// Basic Selectors (props-driven)
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
const propsEnableStatusActions = (_: RootState, props: SelectorProps) =>
|
|
||||||
props.enableStatusActions;
|
|
||||||
|
|
||||||
const propsExtraColumns = (_: RootState, props: SelectorProps) =>
|
|
||||||
props.extraColumns ?? null;
|
|
||||||
|
|
||||||
const propsShowExtraColumns = (_: RootState, props: SelectorProps) =>
|
|
||||||
props.showExtraColumns ?? false;
|
|
||||||
|
|
||||||
const propsLocalRows = (_: RootState, props: SelectorProps) =>
|
|
||||||
props.localRows ?? [];
|
|
||||||
|
|
||||||
const propsStatusChangeHandler = (_: RootState, props: SelectorProps) =>
|
|
||||||
props.handleStatusChange;
|
|
||||||
|
|
||||||
// -------------------------
|
|
||||||
// Helper: Format field name to header name
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a field name to a readable header name
|
|
||||||
* e.g., "userId" -> "User ID", "transactionId" -> "Transaction ID"
|
|
||||||
*/
|
|
||||||
const formatFieldNameToHeader = (fieldName: string): string => {
|
|
||||||
// Handle camelCase: insert space before capital letters and capitalize first letter
|
|
||||||
return fieldName
|
|
||||||
.replace(/([A-Z])/g, " $1") // Add space before capital letters
|
|
||||||
.replace(/^./, str => str.toUpperCase()) // Capitalize first letter
|
|
||||||
.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------
|
|
||||||
// Dynamic Columns from Row Data
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
const makeSelectDynamicColumns = () =>
|
|
||||||
createSelector([propsLocalRows], (localRows): GridColDef[] => {
|
|
||||||
// If no rows, fall back to static columns
|
|
||||||
if (!localRows || localRows.length === 0) {
|
|
||||||
return TABLE_COLUMNS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all unique field names from the row data
|
|
||||||
const fieldSet = new Set<string>();
|
|
||||||
localRows.forEach(row => {
|
|
||||||
Object.keys(row).forEach(key => {
|
|
||||||
if (key !== "options") {
|
|
||||||
// Exclude internal fields
|
|
||||||
fieldSet.add(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build columns from actual row data fields
|
|
||||||
const dynamicColumns: GridColDef[] = Array.from(fieldSet).map(field => {
|
|
||||||
// Format field name to readable header
|
|
||||||
const headerName = formatFieldNameToHeader(field);
|
|
||||||
|
|
||||||
// Set default widths based on field type
|
|
||||||
let width = 150;
|
|
||||||
if (field.includes("id") || field.includes("Id")) {
|
|
||||||
width = 180;
|
|
||||||
} else if (field === "amount" || field === "currency") {
|
|
||||||
width = 120;
|
|
||||||
} else if (field === "status") {
|
|
||||||
width = 120;
|
|
||||||
} else if (
|
|
||||||
field.includes("date") ||
|
|
||||||
field.includes("Date") ||
|
|
||||||
field === "dateTime" ||
|
|
||||||
field === "created" ||
|
|
||||||
field === "modified"
|
|
||||||
) {
|
|
||||||
width = 180;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
field,
|
|
||||||
headerName,
|
|
||||||
width,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
} as GridColDef;
|
|
||||||
});
|
|
||||||
|
|
||||||
return dynamicColumns;
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------
|
|
||||||
// Base Columns
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
const makeSelectBaseColumns = () =>
|
|
||||||
createSelector(
|
|
||||||
[makeSelectDynamicColumns(), propsEnableStatusActions],
|
|
||||||
(dynamicColumns, enableStatusActions) => {
|
|
||||||
const baseColumns = dynamicColumns;
|
|
||||||
|
|
||||||
if (!enableStatusActions) return baseColumns;
|
|
||||||
|
|
||||||
return [
|
|
||||||
...baseColumns,
|
|
||||||
{
|
|
||||||
field: "actions",
|
|
||||||
headerName: "Actions",
|
|
||||||
width: 160,
|
|
||||||
sortable: false,
|
|
||||||
filterable: false,
|
|
||||||
} as GridColDef,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// -------------------------
|
|
||||||
// Visible Columns
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
const makeSelectVisibleColumns = () =>
|
|
||||||
createSelector(
|
|
||||||
[makeSelectBaseColumns(), propsExtraColumns, propsShowExtraColumns],
|
|
||||||
(baseColumns, extraColumns, showExtraColumns) => {
|
|
||||||
// Columns are already built from row data, so they're all valid
|
|
||||||
if (!extraColumns || extraColumns.length === 0) return baseColumns;
|
|
||||||
|
|
||||||
const visibleColumns = showExtraColumns
|
|
||||||
? baseColumns
|
|
||||||
: baseColumns.filter(col => !extraColumns.includes(col.field));
|
|
||||||
|
|
||||||
console.log("visibleColumns", visibleColumns);
|
|
||||||
return visibleColumns;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// -------------------------
|
|
||||||
// Resolved Statuses (STATE-based)
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
const makeSelectResolvedStatuses = () =>
|
|
||||||
createSelector([selectTransactionStatuses], statuses =>
|
|
||||||
statuses.length > 0 ? statuses : TRANSACTION_STATUS_FALLBACK
|
|
||||||
);
|
|
||||||
|
|
||||||
// -------------------------
|
|
||||||
// Enhanced Columns
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
export const makeSelectEnhancedColumns = () =>
|
|
||||||
createSelector(
|
|
||||||
[
|
|
||||||
makeSelectVisibleColumns(),
|
|
||||||
propsLocalRows,
|
|
||||||
propsStatusChangeHandler,
|
|
||||||
makeSelectResolvedStatuses(),
|
|
||||||
],
|
|
||||||
(
|
|
||||||
visibleColumns,
|
|
||||||
localRows,
|
|
||||||
handleStatusChange,
|
|
||||||
resolvedStatusOptions
|
|
||||||
): GridColDef[] => {
|
|
||||||
console.log("visibleColumns", visibleColumns);
|
|
||||||
return visibleColumns.map(col => {
|
|
||||||
// --------------------------------
|
|
||||||
// 1. STATUS COLUMN RENDERER
|
|
||||||
// --------------------------------
|
|
||||||
if (col.field === "status") {
|
|
||||||
return {
|
|
||||||
...col,
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
|
||||||
const value = params.value?.toLowerCase();
|
|
||||||
let bgColor = "#e0e0e0";
|
|
||||||
let textColor = "#000";
|
|
||||||
|
|
||||||
switch (value) {
|
|
||||||
case "completed":
|
|
||||||
bgColor = "#d0f0c0";
|
|
||||||
textColor = "#1b5e20";
|
|
||||||
break;
|
|
||||||
case "pending":
|
|
||||||
bgColor = "#fff4cc";
|
|
||||||
textColor = "#9e7700";
|
|
||||||
break;
|
|
||||||
case "inprogress":
|
|
||||||
bgColor = "#cce5ff";
|
|
||||||
textColor = "#004085";
|
|
||||||
break;
|
|
||||||
case "error":
|
|
||||||
bgColor = "#ffcdd2";
|
|
||||||
textColor = "#c62828";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
backgroundColor: bgColor,
|
|
||||||
color: textColor,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.5,
|
|
||||||
borderRadius: 1,
|
|
||||||
fontWeight: 500,
|
|
||||||
textTransform: "capitalize",
|
|
||||||
display: "inline-block",
|
|
||||||
width: "100%",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{params.value}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------
|
|
||||||
// 2. USER ID COLUMN
|
|
||||||
// --------------------------------
|
|
||||||
if (col.field === "userId") {
|
|
||||||
return {
|
|
||||||
...col,
|
|
||||||
headerAlign: "center",
|
|
||||||
align: "center",
|
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr auto",
|
|
||||||
alignItems: "center",
|
|
||||||
width: "100%",
|
|
||||||
px: 1,
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
color: "text.primary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{params.value}
|
|
||||||
</Box>
|
|
||||||
<IconButton
|
|
||||||
href={`/users/${params.value}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
size="small"
|
|
||||||
sx={{ p: 0.5, ml: 1 }}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<OpenInNewIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------
|
|
||||||
// 3. ACTIONS COLUMN
|
|
||||||
// --------------------------------
|
|
||||||
if (col.field === "actions") {
|
|
||||||
return {
|
|
||||||
...col,
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
|
||||||
const currentRow = localRows.find(row => row.id === params.id);
|
|
||||||
|
|
||||||
const options =
|
|
||||||
currentRow?.options?.map(option => option.value) ??
|
|
||||||
resolvedStatusOptions;
|
|
||||||
|
|
||||||
const uniqueOptions = Array.from(new Set(options));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select<string>
|
|
||||||
value={currentRow?.status ?? ""}
|
|
||||||
onChange={e =>
|
|
||||||
handleStatusChange(
|
|
||||||
params.id as number,
|
|
||||||
e.target.value as string
|
|
||||||
)
|
|
||||||
}
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
displayEmpty
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
|
|
||||||
"& .MuiSelect-select": { py: 0.5 },
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{uniqueOptions.map(option => (
|
|
||||||
<MenuItem key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return col;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@ -11,27 +11,3 @@ export interface IDataTable<TRow, TColumn> {
|
|||||||
tableSearchLabels: ISearchLabel[];
|
tableSearchLabels: ISearchLabel[];
|
||||||
extraColumns: string[];
|
extraColumns: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataRowBase {
|
|
||||||
id: number;
|
|
||||||
status?: string;
|
|
||||||
options?: { value: string; label: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ITransactions {
|
|
||||||
id: string;
|
|
||||||
psp_id: string;
|
|
||||||
method_id: string;
|
|
||||||
merchant_id: string;
|
|
||||||
external_id?: string; // optional: may not always be present
|
|
||||||
customer?: string; // keep as string unless you provide structure
|
|
||||||
type?: string;
|
|
||||||
currency?: string;
|
|
||||||
amount: number | string; // sometimes APIs return strings for money
|
|
||||||
status?: string;
|
|
||||||
notes?: string;
|
|
||||||
creator?: string;
|
|
||||||
created: string; // ISO datetime string from API
|
|
||||||
modifier?: string;
|
|
||||||
modified?: string; // ISO datetime string or undefined
|
|
||||||
}
|
|
||||||
|
|||||||
@ -31,8 +31,6 @@ const SettingsAccountSecurity: React.FC = () => {
|
|||||||
currentPassword: passwordData.currentPassword,
|
currentPassword: passwordData.currentPassword,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Error handling is now done by the epic
|
// Error handling is now done by the epic
|
||||||
console.error("Password change error:", err);
|
console.error("Password change error:", err);
|
||||||
|
|||||||
@ -20,9 +20,11 @@ const SettingsPageClient: React.FC = () => {
|
|||||||
<Box sx={{ display: "flex", gap: 3 }}>
|
<Box sx={{ display: "flex", gap: 3 }}>
|
||||||
<SettingsSidebar
|
<SettingsSidebar
|
||||||
active={activeSection}
|
active={activeSection}
|
||||||
onChange={(section: "personal" | "account") =>
|
onChange={(
|
||||||
setActiveSection(section)
|
section:
|
||||||
}
|
| string
|
||||||
|
| ((prevState: "personal" | "account") => "personal" | "account")
|
||||||
|
) => setActiveSection(section)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { updateUserDetails } from "@/app/redux/user/userSlice";
|
|||||||
|
|
||||||
const SettingsPersonalInfo: React.FC = () => {
|
const SettingsPersonalInfo: React.FC = () => {
|
||||||
const user = useSelector((state: RootState) => state.auth.user);
|
const user = useSelector((state: RootState) => state.auth.user);
|
||||||
console.log("[SettingsPersonalInfo] user", user);
|
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@ -29,8 +28,8 @@ const SettingsPersonalInfo: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setFormData({
|
setFormData({
|
||||||
first_name: user.first_name ?? "",
|
first_name: user.firstName ?? "",
|
||||||
last_name: user.last_name ?? "",
|
last_name: user.lastName ?? "",
|
||||||
username: user.username ?? "",
|
username: user.username ?? "",
|
||||||
email: user.email ?? "",
|
email: user.email ?? "",
|
||||||
});
|
});
|
||||||
@ -67,20 +66,17 @@ const SettingsPersonalInfo: React.FC = () => {
|
|||||||
<TextField
|
<TextField
|
||||||
label="First Name"
|
label="First Name"
|
||||||
value={formData.first_name}
|
value={formData.first_name}
|
||||||
disabled={true}
|
|
||||||
onChange={handleChange("first_name")}
|
onChange={handleChange("first_name")}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Last Name"
|
label="Last Name"
|
||||||
value={formData.last_name}
|
value={formData.last_name}
|
||||||
disabled={true}
|
|
||||||
onChange={handleChange("last_name")}
|
onChange={handleChange("last_name")}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Username"
|
label="Username"
|
||||||
disabled={true}
|
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={handleChange("username")}
|
onChange={handleChange("username")}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@ -1,20 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { RootState } from "@/app/redux/store";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { AppDispatch } from "@/app/redux/store";
|
import { AppDispatch } from "@/app/redux/store";
|
||||||
|
import "./AddUser.scss";
|
||||||
import { addUser } from "@/app/redux/auth/authSlice";
|
import { addUser } from "@/app/redux/auth/authSlice";
|
||||||
import { IEditUserForm } from "../User.interfaces";
|
import { IEditUserForm } from "../User.interfaces";
|
||||||
|
import { COUNTRY_CODES } from "../constants";
|
||||||
import { formatPhoneDisplay, validatePhone } from "../utils";
|
import { formatPhoneDisplay, validatePhone } from "../utils";
|
||||||
import Spinner from "../../../components/Spinner/Spinner";
|
import Spinner from "../../../components/Spinner/Spinner";
|
||||||
|
import { RootState } from "@/app/redux/store";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import Modal from "@/app/components/Modal/Modal";
|
import Modal from "@/app/components/Modal/Modal";
|
||||||
import {
|
import { selectAppMetadata } from "@/app/redux/metadata/selectors";
|
||||||
selectAppMetadata,
|
|
||||||
selectPhoneNumberCountries,
|
|
||||||
} from "@/app/redux/metadata/selectors";
|
|
||||||
import "./AddUser.scss";
|
|
||||||
|
|
||||||
interface AddUserProps {
|
interface AddUserProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -44,8 +42,6 @@ const AddUser: React.FC<AddUserProps> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
const loading = status === "loading";
|
const loading = status === "loading";
|
||||||
|
|
||||||
const COUNTRY_CODES = useSelector(selectPhoneNumberCountries);
|
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
) => {
|
) => {
|
||||||
@ -119,6 +115,7 @@ const AddUser: React.FC<AddUserProps> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
if (result && resultAction.payload.success) {
|
if (result && resultAction.payload.success) {
|
||||||
toast.success(resultAction.payload.message);
|
toast.success(resultAction.payload.message);
|
||||||
|
// router.refresh();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -258,12 +255,9 @@ const AddUser: React.FC<AddUserProps> = ({ open, onClose }) => {
|
|||||||
onChange={handleCountryCodeChange}
|
onChange={handleCountryCodeChange}
|
||||||
className="country-code-select"
|
className="country-code-select"
|
||||||
>
|
>
|
||||||
{COUNTRY_CODES.map((country, i) => (
|
{COUNTRY_CODES.map(country => (
|
||||||
<option
|
<option key={country.code} value={country.code}>
|
||||||
key={`${country.code}-${country.name} ${i}`}
|
{country.flag} {country.code} {country.country}
|
||||||
value={country.code}
|
|
||||||
>
|
|
||||||
{country.flag} {country.code} {country.name}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -47,7 +47,6 @@ const DeleteUser: React.FC<DeleteUserProps> = ({ open, onClose, user }) => {
|
|||||||
(resultAction.payload as string) || "Failed to delete user"
|
(resultAction.payload as string) || "Failed to delete user"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || "An unexpected error occurred");
|
toast.error(error.message || "An unexpected error occurred");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import AccountMenu from "./accountMenu/AccountMenu";
|
|||||||
import "./Header.scss";
|
import "./Header.scss";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
|
const handleChange = () => {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar
|
<AppBar
|
||||||
className="header"
|
className="header"
|
||||||
@ -15,7 +17,7 @@ const Header = () => {
|
|||||||
>
|
>
|
||||||
<Toolbar className="header__toolbar">
|
<Toolbar className="header__toolbar">
|
||||||
<div className="header__left-group">
|
<div className="header__left-group">
|
||||||
<Dropdown />
|
<Dropdown onChange={handleChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header__right-group">
|
<div className="header__right-group">
|
||||||
|
|||||||
@ -1,137 +1,58 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
FormControl,
|
||||||
Menu,
|
InputLabel,
|
||||||
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
ListItemText,
|
SelectChangeEvent,
|
||||||
ListItemIcon,
|
|
||||||
Divider,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import PageLinks from "../../../../components/PageLinks/PageLinks";
|
||||||
|
import { SidebarItem } from "@/app/redux/metadata/metadataSlice";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
|
||||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
|
||||||
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
|
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
|
||||||
import { SidebarLink } from "@/app/redux/metadata/metadataSlice";
|
|
||||||
import { resolveIcon } from "@/app/utils/iconMap";
|
|
||||||
import "./DropDown.scss";
|
import "./DropDown.scss";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onChange?: (path: string) => void;
|
onChange?: (event: SelectChangeEvent<string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SidebarDropdown({ onChange }: Props) {
|
export default function SidebarDropdown({ onChange }: Props) {
|
||||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
const [value, setValue] = React.useState("");
|
||||||
const [openMenus, setOpenMenus] = React.useState<Record<string, boolean>>({});
|
const sidebar = useSelector(selectNavigationSidebar)?.links;
|
||||||
const sidebar = useSelector(selectNavigationSidebar);
|
const handleChange = (event: SelectChangeEvent<string>) => {
|
||||||
const router = useRouter();
|
setValue(event.target.value);
|
||||||
const open = Boolean(anchorEl);
|
onChange?.(event);
|
||||||
|
|
||||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
setAnchorEl(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setAnchorEl(null);
|
|
||||||
setOpenMenus({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMenu = (title: string) => {
|
|
||||||
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNavigation = (path: string) => {
|
|
||||||
router.push(path);
|
|
||||||
onChange?.(path);
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMenuItem = (
|
|
||||||
link: SidebarLink,
|
|
||||||
level: number = 0
|
|
||||||
): React.ReactNode => {
|
|
||||||
const Icon = link.icon ? resolveIcon(link.icon as string) : undefined;
|
|
||||||
const hasChildren = link.children && link.children.length > 0;
|
|
||||||
const isOpen = openMenus[link.title];
|
|
||||||
const indent = level * 24;
|
|
||||||
|
|
||||||
if (hasChildren) {
|
|
||||||
return (
|
|
||||||
<React.Fragment key={link.title || link.path}>
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => toggleMenu(link.title)}
|
|
||||||
sx={{
|
|
||||||
pl: `${8 + indent}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Icon && (
|
|
||||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
|
||||||
<Icon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
)}
|
|
||||||
<ListItemText primary={link.title} />
|
|
||||||
{isOpen ? (
|
|
||||||
<KeyboardArrowDownIcon fontSize="small" />
|
|
||||||
) : (
|
|
||||||
<KeyboardArrowRightIcon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
{isOpen &&
|
|
||||||
link.children?.map(child => renderMenuItem(child, level + 1))}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
key={link.path}
|
|
||||||
onClick={() => handleNavigation(link.path)}
|
|
||||||
sx={{
|
|
||||||
pl: `${8 + indent}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Icon && (
|
|
||||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
|
||||||
<Icon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
)}
|
|
||||||
<ListItemText primary={link.title} />
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<FormControl fullWidth variant="outlined" sx={{ minWidth: 200 }}>
|
||||||
<Button
|
<InputLabel id="sidebar-dropdown-label">Navigate To</InputLabel>
|
||||||
variant="outlined"
|
<Select
|
||||||
onClick={handleClick}
|
labelId="sidebar-dropdown-label"
|
||||||
sx={{ minWidth: 200, justifyContent: "space-between" }}
|
value={value}
|
||||||
>
|
onChange={handleChange}
|
||||||
Navigate To
|
label="Navigate To"
|
||||||
{open ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
|
MenuProps={{
|
||||||
</Button>
|
PaperProps: {
|
||||||
<Menu
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
open={open}
|
|
||||||
onClose={handleClose}
|
|
||||||
MenuListProps={{
|
|
||||||
"aria-labelledby": "sidebar-dropdown-button",
|
|
||||||
}}
|
|
||||||
PaperProps={{
|
|
||||||
style: {
|
style: {
|
||||||
maxHeight: 400,
|
maxHeight: 200,
|
||||||
width: "250px",
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem disabled>
|
<em className="em">Select a page</em>
|
||||||
<ListItemText primary="Select a page" />
|
<MenuItem value="" disabled></MenuItem>
|
||||||
</MenuItem>
|
|
||||||
<Divider />
|
|
||||||
<div className="sidebar-dropdown__container">
|
<div className="sidebar-dropdown__container">
|
||||||
{sidebar?.map(link => renderMenuItem(link))}
|
{sidebar?.map((link: SidebarItem) => (
|
||||||
</div>
|
<PageLinks
|
||||||
</Menu>
|
key={link.path}
|
||||||
|
title={link.title}
|
||||||
|
path={link.path}
|
||||||
|
icon={link.icon as string}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export const MainContent = ({ children }: MainContentProps) => {
|
|||||||
const isSidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen);
|
const isSidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen);
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
marginLeft: isSidebarOpen ? "230px" : "30px",
|
marginLeft: isSidebarOpen ? "240px" : "30px",
|
||||||
padding: "24px",
|
padding: "24px",
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
width: isSidebarOpen ? "calc(100% - 240px)" : "calc(100% - 30px)",
|
width: isSidebarOpen ? "calc(100% - 240px)" : "calc(100% - 30px)",
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
|||||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||||
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
|
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
|
||||||
import { SidebarLink } from "@/app/redux/metadata/metadataSlice";
|
import { SidebarItem } from "@/app/redux/metadata/metadataSlice";
|
||||||
import { resolveIcon } from "@/app/utils/iconMap";
|
import { resolveIcon } from "@/app/utils/iconMap";
|
||||||
import "./sideBar.scss";
|
import "./sideBar.scss";
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
|
const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
|
||||||
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
||||||
const sidebar = useSelector(selectNavigationSidebar);
|
const sidebar = useSelector(selectNavigationSidebar)?.links;
|
||||||
const toggleMenu = (title: string) => {
|
const toggleMenu = (title: string) => {
|
||||||
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
|
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
|
||||||
};
|
};
|
||||||
@ -32,12 +32,12 @@ const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
|
|||||||
</button>
|
</button>
|
||||||
<div className="sidebar__header">
|
<div className="sidebar__header">
|
||||||
<span>
|
<span>
|
||||||
Cashier
|
Betrise cashier
|
||||||
<DashboardIcon fontSize="small" className="sidebar__icon-spacing" />
|
<DashboardIcon fontSize="small" className="sidebar__icon-spacing" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sidebar?.map((link: SidebarLink) => {
|
{sidebar?.map((link: SidebarItem) => {
|
||||||
if (link.children) {
|
if (link.children) {
|
||||||
const Icon = resolveIcon(link.icon as string);
|
const Icon = resolveIcon(link.icon as string);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -10,22 +10,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
border-right: 1px solid #835454;
|
border-right: 1px solid #333;
|
||||||
transition: transform 0.3s ease-in-out;
|
transition: transform 0.3s ease-in-out;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.sidebar__submenu {
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: 200px;
|
|
||||||
transition: max-height 0.3s ease-in-out;
|
|
||||||
overflow-y: auto;
|
|
||||||
&:hover {
|
|
||||||
max-height: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--collapsed {
|
&--collapsed {
|
||||||
transform: translateX(-210px); // Hide 90% (210px out of 240px)
|
transform: translateX(-210px); // Hide 90% (210px out of 240px)
|
||||||
|
|
||||||
.sidebar__header,
|
.sidebar__header,
|
||||||
.sidebar__dropdown-button,
|
.sidebar__dropdown-button,
|
||||||
.sidebar__submenu,
|
.sidebar__submenu,
|
||||||
@ -162,9 +153,3 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Track (background behind the thumb) */
|
|
||||||
.scrollable-div::-webkit-scrollbar-track {
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { validateAuth } from "./auth/authSlice";
|
import { initializeAuth } from "./auth/authSlice";
|
||||||
import { AppDispatch } from "./types";
|
import { AppDispatch } from "./types";
|
||||||
|
|
||||||
export function InitializeAuth() {
|
export function InitializeAuth() {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(validateAuth());
|
dispatch(initializeAuth());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import { store } from "./store";
|
import { store } from "./store";
|
||||||
import { validateAuth } from "./auth/authSlice";
|
import { checkAuthStatus } from "./auth/authSlice";
|
||||||
|
|
||||||
export default function ReduxProvider({
|
export default function ReduxProvider({
|
||||||
children,
|
children,
|
||||||
@ -16,17 +16,17 @@ export default function ReduxProvider({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check authentication status when the ReduxProvider component mounts on the client.
|
// Check authentication status when the ReduxProvider component mounts on the client.
|
||||||
// This ensures your Redux auth state is synced with the server-side token.
|
// This ensures your Redux auth state is synced with the server-side token.
|
||||||
store.dispatch(validateAuth());
|
store.dispatch(checkAuthStatus());
|
||||||
|
|
||||||
// Do an additional check after 2 seconds to ensure we have the latest token info
|
// Do an additional check after 2 seconds to ensure we have the latest token info
|
||||||
initialCheckRef.current = setTimeout(() => {
|
initialCheckRef.current = setTimeout(() => {
|
||||||
store.dispatch(validateAuth());
|
store.dispatch(checkAuthStatus());
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
// Set up periodic token validation every 5 minutes
|
// Set up periodic token validation every 5 minutes
|
||||||
intervalRef.current = setInterval(
|
intervalRef.current = setInterval(
|
||||||
() => {
|
() => {
|
||||||
store.dispatch(validateAuth());
|
store.dispatch(checkAuthStatus());
|
||||||
},
|
},
|
||||||
5 * 60 * 1000
|
5 * 60 * 1000
|
||||||
); // 5 minutes
|
); // 5 minutes
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { createSelector } from "@reduxjs/toolkit";
|
|
||||||
import { RootState } from "../store";
|
import { RootState } from "../store";
|
||||||
import {
|
import {
|
||||||
AdvancedSearchFilters,
|
AdvancedSearchFilters,
|
||||||
@ -12,14 +11,6 @@ export const selectFilters = (state: RootState): AdvancedSearchFilters =>
|
|||||||
export const selectPagination = (state: RootState) =>
|
export const selectPagination = (state: RootState) =>
|
||||||
state.advancedSearch.pagination;
|
state.advancedSearch.pagination;
|
||||||
|
|
||||||
export const selectPaginationModel = createSelector(
|
|
||||||
[selectPagination],
|
|
||||||
pagination => ({
|
|
||||||
page: Math.max(0, (pagination.page ?? 1) - 1),
|
|
||||||
pageSize: pagination.limit ?? 10,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export const selectSort = (state: RootState) => state.advancedSearch.sort;
|
export const selectSort = (state: RootState) => state.advancedSearch.sort;
|
||||||
|
|
||||||
export const selectFilterValue = (
|
export const selectFilterValue = (
|
||||||
|
|||||||
@ -1,240 +0,0 @@
|
|||||||
// Country codes for phone prefixes
|
|
||||||
export const COUNTRY_CODES = [
|
|
||||||
{ code: "+233", flag: "🇬🇭", country: "Ghana" },
|
|
||||||
{ code: "+672", flag: "🇳🇫", country: "Norfolk Island" },
|
|
||||||
{ code: "+226", flag: "🇧🇫", country: "Burkina Faso" },
|
|
||||||
{ code: "+591", flag: "🇧🇴", country: "Bolivia" },
|
|
||||||
{ code: "+33", flag: "🇫🇷", country: "France" },
|
|
||||||
{ code: "+1-876", flag: "🇯🇲", country: "Jamaica" },
|
|
||||||
{ code: "+249", flag: "🇸🇩", country: "Sudan" },
|
|
||||||
{
|
|
||||||
code: "+243",
|
|
||||||
flag: "🇨🇩",
|
|
||||||
country: "Congo, Democratic Republic Of (Was Zaire)",
|
|
||||||
},
|
|
||||||
{ code: "+679", flag: "🇫🇯", country: "Fiji" },
|
|
||||||
{ code: "+44", flag: "🇮🇲", country: "Isle Of Man" },
|
|
||||||
{ code: "+382", flag: "🇲🇪", country: "Montenegro" },
|
|
||||||
{ code: "+353", flag: "🇮🇪", country: "Ireland" },
|
|
||||||
{ code: "+237", flag: "🇨🇲", country: "Cameroon" },
|
|
||||||
{ code: "+592", flag: "🇬🇾", country: "Guyana" },
|
|
||||||
{ code: "+234", flag: "🇳🇬", country: "Nigeria" },
|
|
||||||
{ code: "+1-868", flag: "🇹🇹", country: "Trinidad And Tobago" },
|
|
||||||
{ code: "+45", flag: "🇩🇰", country: "Denmark" },
|
|
||||||
{ code: "+852", flag: "🇭🇰", country: "Hong Kong" },
|
|
||||||
{ code: "+223", flag: "🇲🇱", country: "Mali" },
|
|
||||||
{ code: "+239", flag: "🇸🇹", country: "Sao Tome And Principe" },
|
|
||||||
{ code: "+690", flag: "🇹🇰", country: "Tokelau" },
|
|
||||||
{ code: "+590", flag: "🇬🇵", country: "Guadeloupe" },
|
|
||||||
{ code: "+66", flag: "🇹🇭", country: "Thailand" },
|
|
||||||
{ code: "+504", flag: "🇭🇳", country: "Honduras" },
|
|
||||||
{ code: "+27", flag: "🇿🇦", country: "South Africa" },
|
|
||||||
{ code: "+358", flag: "🇫🇮", country: "Finland" },
|
|
||||||
{ code: "+1-264", flag: "🇦🇮", country: "Anguilla" },
|
|
||||||
{ code: "+262", flag: "🇷🇪", country: "Reunion" },
|
|
||||||
{ code: "+992", flag: "🇹🇯", country: "Tajikistan" },
|
|
||||||
{ code: "+971", flag: "🇦🇪", country: "United Arab Emirates" },
|
|
||||||
{ code: "+212", flag: "🇪🇭", country: "Western Sahara" },
|
|
||||||
{ code: "+692", flag: "🇲🇭", country: "Marshall Islands" },
|
|
||||||
{ code: "+674", flag: "🇳🇷", country: "Nauru" },
|
|
||||||
{ code: "+229", flag: "🇧🇯", country: "Benin" },
|
|
||||||
{ code: "+55", flag: "🇧🇷", country: "Brazil" },
|
|
||||||
{ code: "+299", flag: "🇬🇱", country: "Greenland" },
|
|
||||||
{ code: "+61", flag: "🇭🇲", country: "Heard and Mc Donald Islands" },
|
|
||||||
{ code: "+98", flag: "🇮🇷", country: "Iran (Islamic Republic Of)" },
|
|
||||||
{ code: "+231", flag: "🇱🇷", country: "Liberia" },
|
|
||||||
{ code: "+370", flag: "🇱🇹", country: "Lithuania" },
|
|
||||||
{ code: "+377", flag: "🇲🇨", country: "Monaco" },
|
|
||||||
{ code: "+222", flag: "🇲🇷", country: "Mauritania" },
|
|
||||||
{ code: "+57", flag: "🇨🇴", country: "Colombia" },
|
|
||||||
{ code: "+216", flag: "🇹🇳", country: "Tunisia" },
|
|
||||||
{ code: "+1-345", flag: "🇰🇾", country: "Cayman Islands" },
|
|
||||||
{ code: "+62", flag: "🇮🇩", country: "Indonesia" },
|
|
||||||
{ code: "+378", flag: "🇸🇲", country: "San Marino" },
|
|
||||||
{ code: "+1", flag: "🇺🇸", country: "United States" },
|
|
||||||
{ code: "+383", flag: "🇽🇰", country: "Kosovo" },
|
|
||||||
{ code: "+376", flag: "🇦🇩", country: "Andorra" },
|
|
||||||
{ code: "+1-246", flag: "🇧🇧", country: "Barbados" },
|
|
||||||
{ code: "+963", flag: "🇸🇾", country: "Syrian Arab Republic" },
|
|
||||||
{ code: "+359", flag: "🇧🇬", country: "Bulgaria" },
|
|
||||||
{ code: "+213", flag: "🇩🇿", country: "Algeria" },
|
|
||||||
{ code: "+593", flag: "🇪🇨", country: "Ecuador" },
|
|
||||||
{ code: "+240", flag: "🇬🇶", country: "Equatorial Guinea" },
|
|
||||||
{ code: "+44", flag: "🇯🇪", country: "Jersey" },
|
|
||||||
{ code: "+254", flag: "🇰🇪", country: "Kenya" },
|
|
||||||
{ code: "+64", flag: "🇳🇿", country: "New Zealand" },
|
|
||||||
{ code: "+250", flag: "🇷🇼", country: "Rwanda" },
|
|
||||||
{ code: "+291", flag: "🇪🇷", country: "Eritrea" },
|
|
||||||
{ code: "+47", flag: "🇳🇴", country: "Norway" },
|
|
||||||
{ code: "+51", flag: "🇵🇪", country: "Peru" },
|
|
||||||
{ code: "+290", flag: "🇸🇭", country: "Saint Helena" },
|
|
||||||
{ code: "+508", flag: "🇵🇲", country: "Saint Pierre And Miquelon" },
|
|
||||||
{ code: "+260", flag: "🇿🇲", country: "Zambia" },
|
|
||||||
{ code: "+354", flag: "🇮🇸", country: "Iceland" },
|
|
||||||
{ code: "+39", flag: "🇮🇹", country: "Italy" },
|
|
||||||
{ code: "+977", flag: "🇳🇵", country: "Nepal" },
|
|
||||||
{ code: "+386", flag: "🇸🇮", country: "Slovenia" },
|
|
||||||
{ code: "+218", flag: "🇱🇾", country: "Libyan Arab Jamahiriya" },
|
|
||||||
{ code: "+505", flag: "🇳🇮", country: "Nicaragua" },
|
|
||||||
{ code: "+248", flag: "🇸🇨", country: "Seychelles" },
|
|
||||||
{ code: "+594", flag: "🇬🇫", country: "French Guiana" },
|
|
||||||
{ code: "+972", flag: "🇮🇱", country: "Israel" },
|
|
||||||
{ code: "+1-670", flag: "🇲🇵", country: "Northern Mariana Islands" },
|
|
||||||
{ code: "+1-64", flag: "🇵🇳", country: "Pitcairn" },
|
|
||||||
{ code: "+351", flag: "🇵🇹", country: "Portugal" },
|
|
||||||
{ code: "+503", flag: "🇸🇻", country: "El Salvador" },
|
|
||||||
{ code: "+44", flag: "🇬🇧", country: "United Kingdom" },
|
|
||||||
{ code: "+689", flag: "🇵🇫", country: "French Polynesia" },
|
|
||||||
{ code: "+1-721", flag: "🇸🇽", country: "Sint Maarten" },
|
|
||||||
{ code: "+380", flag: "🇺🇦", country: "Ukraine" },
|
|
||||||
{ code: "+599", flag: "🇧🇶", country: "Bonaire, Saint Eustatius and Saba" },
|
|
||||||
{ code: "+500", flag: "🇫🇰", country: "Falkland Islands (Malvinas)" },
|
|
||||||
{ code: "+995", flag: "🇬🇪", country: "Georgia" },
|
|
||||||
{ code: "+1-671", flag: "🇬🇺", country: "Guam" },
|
|
||||||
{ code: "+82", flag: "🇰🇷", country: "Korea, Republic Of" },
|
|
||||||
{ code: "+507", flag: "🇵🇦", country: "Panama" },
|
|
||||||
{ code: "+1", flag: "🇺🇸", country: "United States Minor Outlying Islands" },
|
|
||||||
{ code: "+964", flag: "🇮🇶", country: "Iraq" },
|
|
||||||
{ code: "+965", flag: "🇰🇼", country: "Kuwait" },
|
|
||||||
{ code: "+39", flag: "🇻🇦", country: "Vatican City State (Holy See)" },
|
|
||||||
{ code: "+385", flag: "🇭🇷", country: "Croatia (Local Name: Hrvatska)" },
|
|
||||||
{ code: "+92", flag: "🇵🇰", country: "Pakistan" },
|
|
||||||
{ code: "+967", flag: "🇾🇪", country: "Yemen" },
|
|
||||||
{ code: "+267", flag: "🇧🇼", country: "Botswana" },
|
|
||||||
{ code: "+970", flag: "🇵🇸", country: "Palestinian Territory, Occupied" },
|
|
||||||
{ code: "+90", flag: "🇹🇷", country: "Turkey" },
|
|
||||||
{ code: "+1-473", flag: "🇬🇩", country: "Grenada" },
|
|
||||||
{ code: "+356", flag: "🇲🇹", country: "Malta" },
|
|
||||||
{
|
|
||||||
code: "+995",
|
|
||||||
flag: "🇬🇪",
|
|
||||||
country: "South Georgia And The South Sandwich Islands",
|
|
||||||
},
|
|
||||||
{ code: "+236", flag: "🇨🇫", country: "Central African Republic" },
|
|
||||||
{ code: "+371", flag: "🇱🇻", country: "Latvia" },
|
|
||||||
{
|
|
||||||
code: "+850",
|
|
||||||
flag: "🇰🇵",
|
|
||||||
country: "Korea, Democratic People's Republic Of",
|
|
||||||
},
|
|
||||||
{ code: "+1-649", flag: "🇹🇨", country: "Turks And Caicos Islands" },
|
|
||||||
{ code: "+599", flag: "🇨🇼", country: "Curacao" },
|
|
||||||
{ code: "+245", flag: "🇬🇼", country: "Guinea-Bissau" },
|
|
||||||
{ code: "+94", flag: "🇱🇰", country: "Sri Lanka" },
|
|
||||||
{ code: "+596", flag: "🇲🇶", country: "Martinique" },
|
|
||||||
{ code: "+262", flag: "🇾🇹", country: "Mayotte" },
|
|
||||||
{ code: "+688", flag: "🇹🇻", country: "Tuvalu" },
|
|
||||||
{ code: "+49", flag: "🇩🇪", country: "Germany" },
|
|
||||||
{ code: "+65", flag: "🇸🇬", country: "Singapore" },
|
|
||||||
{ code: "+381", flag: "🇷🇸", country: "Serbia" },
|
|
||||||
{ code: "+975", flag: "🇧🇹", country: "Bhutan" },
|
|
||||||
{ code: "+266", flag: "🇱🇸", country: "Lesotho" },
|
|
||||||
{ code: "+421", flag: "🇸🇰", country: "Slovakia" },
|
|
||||||
{ code: "+1-784", flag: "🇻🇨", country: "Saint Vincent And The Grenadines" },
|
|
||||||
{ code: "+673", flag: "🇧🇳", country: "Brunei Darussalam" },
|
|
||||||
{ code: "+509", flag: "🇭🇹", country: "Haiti" },
|
|
||||||
{
|
|
||||||
code: "+389",
|
|
||||||
flag: "🇲🇰",
|
|
||||||
country: "Macedonia, The Former Yugoslav Republic Of",
|
|
||||||
},
|
|
||||||
{ code: "+886", flag: "🇹🇼", country: "Taiwan" },
|
|
||||||
{ code: "+670", flag: "🇹🇱", country: "Cocos (Keeling) Islands" },
|
|
||||||
{ code: "+352", flag: "🇱🇺", country: "Luxembourg" },
|
|
||||||
{ code: "+880", flag: "🇧🇩", country: "Bangladesh" },
|
|
||||||
{ code: "+676", flag: "🇹🇴", country: "Tonga" },
|
|
||||||
{ code: "+681", flag: "🇼🇫", country: "Wallis And Futuna Islands" },
|
|
||||||
{ code: "+257", flag: "🇧🇮", country: "Burundi" },
|
|
||||||
{ code: "+502", flag: "🇬🇹", country: "Guatemala" },
|
|
||||||
{ code: "+855", flag: "🇰🇭", country: "Cambodia" },
|
|
||||||
{ code: "+235", flag: "🇹🇩", country: "Chad" },
|
|
||||||
{ code: "+216", flag: "🇹🇳", country: "Tunisia" },
|
|
||||||
{ code: "+1-242", flag: "🇧🇸", country: "Bahamas" },
|
|
||||||
{ code: "+350", flag: "🇬🇮", country: "Gibraltar" },
|
|
||||||
{ code: "+52", flag: "🇲🇽", country: "Mexico" },
|
|
||||||
{ code: "+856", flag: "🇱🇦", country: "Lao People's Democratic Republic" },
|
|
||||||
{ code: "+680", flag: "🇵🇼", country: "Palau" },
|
|
||||||
{ code: "+249", flag: "🇸🇩", country: "South Sudan" },
|
|
||||||
{ code: "+1-340", flag: "🇻🇮", country: "Virgin Islands (U.S.)" },
|
|
||||||
{ code: "+355", flag: "🇦🇱", country: "Albania" },
|
|
||||||
{ code: "+246", flag: "🇮🇴", country: "British Indian Ocean Territory" },
|
|
||||||
{ code: "+235", flag: "🇹🇩", country: "Chad" },
|
|
||||||
{ code: "+263", flag: "🇿🇼", country: "Zimbabwe" },
|
|
||||||
{ code: "+357", flag: "🇨🇾", country: "Cyprus" },
|
|
||||||
{ code: "+350", flag: "🇬🇮", country: "Gibraltar" },
|
|
||||||
{ code: "+256", flag: "🇺🇬", country: "Uganda" },
|
|
||||||
{ code: "+685", flag: "🇼🇸", country: "Samoa" },
|
|
||||||
{ code: "+1", flag: "🇺🇸", country: "Canada" },
|
|
||||||
{ code: "+506", flag: "🇨🇷", country: "Costa Rica" },
|
|
||||||
{ code: "+34", flag: "🇪🇸", country: "Spain" },
|
|
||||||
{ code: "+684", flag: "🇦🇸", country: "American Samoa" },
|
|
||||||
{ code: "+1-268", flag: "🇦🇬", country: "Antigua and Barbuda" },
|
|
||||||
{ code: "+86", flag: "🇨🇳", country: "China" },
|
|
||||||
{ code: "+48", flag: "🇵🇱", country: "Poland" },
|
|
||||||
{ code: "+974", flag: "🇶🇦", country: "Qatar" },
|
|
||||||
{ code: "+36", flag: "🇭🇺", country: "Hungary" },
|
|
||||||
{ code: "+996", flag: "🇰🇬", country: "Kyrgyzstan" },
|
|
||||||
{ code: "+258", flag: "🇲🇿", country: "Mozambique" },
|
|
||||||
{ code: "+675", flag: "🇵🇬", country: "Papua New Guinea" },
|
|
||||||
{ code: "+41", flag: "🇨🇭", country: "Switzerland" },
|
|
||||||
{ code: "+269", flag: "🇰🇲", country: "Comoros" },
|
|
||||||
{ code: "+230", flag: "🇲🇺", country: "Mauritius" },
|
|
||||||
{ code: "+60", flag: "🇲🇾", country: "Malaysia" },
|
|
||||||
{ code: "+228", flag: "🇹🇬", country: "Togo" },
|
|
||||||
{ code: "+994", flag: "🇦🇿", country: "Azerbaijan" },
|
|
||||||
{ code: "+501", flag: "🇧🇿", country: "Belize" },
|
|
||||||
{ code: "+682", flag: "🇨🇰", country: "Cook Islands" },
|
|
||||||
{ code: "+1-767", flag: "🇩🇲", country: "Dominica" },
|
|
||||||
{ code: "+372", flag: "🇪🇪", country: "Estonia" },
|
|
||||||
{ code: "+220", flag: "🇬🇲", country: "Gambia" },
|
|
||||||
{ code: "+423", flag: "🇱🇮", country: "Liechtenstein" },
|
|
||||||
{ code: "+683", flag: "🇳🇺", country: "Niue" },
|
|
||||||
{ code: "+244", flag: "🇦🇴", country: "Angola" },
|
|
||||||
{ code: "+241", flag: "🇬🇦", country: "Gabon" },
|
|
||||||
{ code: "+40", flag: "🇷🇴", country: "Romania" },
|
|
||||||
{ code: "+966", flag: "🇸🇦", country: "Saudi Arabia" },
|
|
||||||
{ code: "+221", flag: "🇸🇳", country: "Senegal" },
|
|
||||||
{ code: "+232", flag: "🇸🇱", country: "Sierra Leone" },
|
|
||||||
{ code: "+262", flag: "🇹🇫", country: "French Southern Territories" },
|
|
||||||
{ code: "+670", flag: "🇹🇱", country: "Timor-Leste" },
|
|
||||||
{ code: "+1-284", flag: "🇻🇬", country: "Virgin Islands (British)" },
|
|
||||||
{ code: "+297", flag: "🇦🇼", country: "Aruba" },
|
|
||||||
{ code: "+56", flag: "🇨🇱", country: "Chile" },
|
|
||||||
{ code: "+53", flag: "🇨🇺", country: "Cuba" },
|
|
||||||
{ code: "+595", flag: "🇵🇾", country: "Paraguay" },
|
|
||||||
{ code: "+43", flag: "🇦🇹", country: "Austria" },
|
|
||||||
{ code: "+590", flag: "🇧🇱", country: "Saint Barthélemy" },
|
|
||||||
{ code: "+238", flag: "🇨🇻", country: "Cape Verde" },
|
|
||||||
{ code: "+853", flag: "🇲🇴", country: "Macau" },
|
|
||||||
{ code: "+1-664", flag: "🇲🇸", country: "Montserrat" },
|
|
||||||
{ code: "+265", flag: "🇲🇼", country: "Malawi" },
|
|
||||||
{ code: "+678", flag: "🇻🇺", country: "Vanuatu" },
|
|
||||||
{ code: "+251", flag: "🇪🇹", country: "Ethiopia" },
|
|
||||||
{ code: "+298", flag: "🇫🇴", country: "Faroe Islands" },
|
|
||||||
{ code: "+224", flag: "🇬🇳", country: "Guinea" },
|
|
||||||
{ code: "+30", flag: "🇬🇷", country: "Greece" },
|
|
||||||
{ code: "+370", flag: "🇱🇹", country: "Aaland Islands" },
|
|
||||||
{ code: "+84", flag: "🇻🇳", country: "Viet Nam" },
|
|
||||||
{ code: "+960", flag: "🇲🇻", country: "Maldives" },
|
|
||||||
{ code: "+264", flag: "🇳🇦", country: "Namibia" },
|
|
||||||
{ code: "+31", flag: "🇳🇱", country: "Netherlands" },
|
|
||||||
{ code: "+1-340", flag: "🇻🇮", country: "Virgin Islands (U.S.)" },
|
|
||||||
{ code: "+374", flag: "🇦🇲", country: "Armenia" },
|
|
||||||
{ code: "+255", flag: "🇹🇿", country: "Tanzania, United Republic Of" },
|
|
||||||
{ code: "+373", flag: "🇲🇩", country: "Moldova, Republic Of" },
|
|
||||||
{ code: "+681", flag: "🇼🇫", country: "Wallis And Futuna Islands" },
|
|
||||||
{ code: "+46", flag: "🇸🇪", country: "Sweden" },
|
|
||||||
{ code: "+973", flag: "🇧🇭", country: "Bahrain" },
|
|
||||||
{ code: "+32", flag: "🇧🇪", country: "Belgium" },
|
|
||||||
{ code: "+61", flag: "🇦🇶", country: "Christmas Island" },
|
|
||||||
{ code: "+20", flag: "🇪🇬", country: "Egypt" },
|
|
||||||
{ code: "+420", flag: "🇨🇿", country: "Czech Republic" },
|
|
||||||
{ code: "+61", flag: "🇦🇺", country: "Australia" },
|
|
||||||
{ code: "+1-441", flag: "🇧🇸", country: "Bermuda" },
|
|
||||||
{ code: "+228", flag: "🇬🇲", country: "Guernsey" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export type CountryCodeEntry = (typeof COUNTRY_CODES)[number];
|
|
||||||
|
|
||||||
// Phone number validation regex
|
|
||||||
export const PHONE_REGEX = /^[\+]?[1-9][\d]{0,15}$/;
|
|
||||||
@ -1,30 +1,21 @@
|
|||||||
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
|
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
export interface SidebarLink {
|
export interface SidebarItem {
|
||||||
id?: string;
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
icon?: string;
|
icon?: string; // icon name from backend; map client-side if needed
|
||||||
groups?: string[];
|
permissions?: string[]; // required permissions for visibility
|
||||||
children?: SidebarLink[];
|
children?: SidebarItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarPayload {
|
|
||||||
links: SidebarLink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FieldGroupMap = Record<string, Record<string, string>>;
|
|
||||||
|
|
||||||
export interface AppMetadata {
|
export interface AppMetadata {
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
field_names?: FieldGroupMap;
|
|
||||||
job_titles?: string[];
|
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
|
job_titles?: string[];
|
||||||
merchants?: string[];
|
merchants?: string[];
|
||||||
countries?: string[];
|
message?: string;
|
||||||
sidebar?: SidebarPayload;
|
sidebar?: SidebarItem[];
|
||||||
transaction_status?: string[];
|
success: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetadataState {
|
interface MetadataState {
|
||||||
|
|||||||
@ -1,98 +1,7 @@
|
|||||||
import { createSelector } from "@reduxjs/toolkit";
|
|
||||||
import { RootState } from "../store";
|
import { RootState } from "../store";
|
||||||
import { FieldGroupMap, SidebarLink } from "./metadataSlice";
|
|
||||||
import { COUNTRY_CODES, CountryCodeEntry } from "./constants";
|
|
||||||
|
|
||||||
|
// Selectors
|
||||||
export const selectMetadataState = (state: RootState) => state.metadata;
|
export const selectMetadataState = (state: RootState) => state.metadata;
|
||||||
|
export const selectAppMetadata = (state: RootState) => state.metadata.data;
|
||||||
export const selectMetadataStatus = (state: RootState) =>
|
export const selectNavigationSidebar = (state: RootState) =>
|
||||||
state.metadata?.status;
|
state.metadata.data?.sidebar || [];
|
||||||
|
|
||||||
export const selectMetadataError = (state: RootState) => state.metadata?.error;
|
|
||||||
|
|
||||||
export const selectAppMetadata = (state: RootState) => state.metadata?.data;
|
|
||||||
|
|
||||||
export const selectFieldNames = (state: RootState): FieldGroupMap | undefined =>
|
|
||||||
state.metadata.data?.field_names;
|
|
||||||
|
|
||||||
export const selectSidebarLinks = (state: RootState): SidebarLink[] =>
|
|
||||||
state.metadata.data?.sidebar?.links ?? [];
|
|
||||||
|
|
||||||
export const selectJobTitles = (state: RootState): string[] =>
|
|
||||||
state.metadata.data?.job_titles ?? [];
|
|
||||||
|
|
||||||
export const selectGroups = (state: RootState): string[] =>
|
|
||||||
state.metadata.data?.groups ?? [];
|
|
||||||
|
|
||||||
export const selectMerchants = (state: RootState): string[] =>
|
|
||||||
state.metadata.data?.merchants ?? [];
|
|
||||||
|
|
||||||
export const selectCountries = (state: RootState): string[] =>
|
|
||||||
state.metadata.data?.countries ?? [];
|
|
||||||
|
|
||||||
export const selectTransactionStatuses = (state: RootState): string[] =>
|
|
||||||
state.metadata.data?.transaction_status ?? [];
|
|
||||||
|
|
||||||
export const selectNavigationSidebar = (state: RootState): SidebarLink[] =>
|
|
||||||
state.metadata.data?.sidebar?.links ?? [];
|
|
||||||
|
|
||||||
export const selectConditionOperators = (
|
|
||||||
state: RootState
|
|
||||||
): Record<string, string> | undefined =>
|
|
||||||
state.metadata.data?.field_names?.conditions;
|
|
||||||
|
|
||||||
export const selectTransactionFieldNames = (
|
|
||||||
state: RootState
|
|
||||||
): Record<string, string> | undefined =>
|
|
||||||
state.metadata.data?.field_names?.transactions;
|
|
||||||
|
|
||||||
// Re-Selectcrors
|
|
||||||
const normalizeCountryName = (value: string): string =>
|
|
||||||
value
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.replace(/[^a-z0-9]/gi, "")
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
const COUNTRY_CODE_LOOKUP = COUNTRY_CODES.reduce<
|
|
||||||
Record<string, CountryCodeEntry>
|
|
||||||
>((acc, entry) => {
|
|
||||||
acc[normalizeCountryName(entry.country)] = entry;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const findCountryMetadata = (
|
|
||||||
countryName: string
|
|
||||||
): CountryCodeEntry | undefined => {
|
|
||||||
const normalized = normalizeCountryName(countryName);
|
|
||||||
if (!normalized) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (COUNTRY_CODE_LOOKUP[normalized]) {
|
|
||||||
return COUNTRY_CODE_LOOKUP[normalized];
|
|
||||||
}
|
|
||||||
|
|
||||||
return COUNTRY_CODES.find(entry => {
|
|
||||||
const normalizedCountry = normalizeCountryName(entry.country);
|
|
||||||
return (
|
|
||||||
normalizedCountry &&
|
|
||||||
(normalizedCountry.includes(normalized) ||
|
|
||||||
normalized.includes(normalizedCountry))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const selectPhoneNumberCountries = createSelector(
|
|
||||||
[selectCountries],
|
|
||||||
countries =>
|
|
||||||
countries.map(country => {
|
|
||||||
const metadata = findCountryMetadata(country);
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: metadata?.code ?? "",
|
|
||||||
flag: metadata?.flag ?? "",
|
|
||||||
name: country,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import toast from "react-hot-toast";
|
|||||||
import { filter, switchMap } from "rxjs/operators";
|
import { filter, switchMap } from "rxjs/operators";
|
||||||
import { of } from "rxjs";
|
import { of } from "rxjs";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const changePasswordEpic: Epic<any, any, RootState> = action$ =>
|
export const changePasswordEpic: Epic<any, any, RootState> = action$ =>
|
||||||
action$.pipe(
|
action$.pipe(
|
||||||
// Listen for any action related to changePassword (pending, fulfilled, rejected)
|
// Listen for any action related to changePassword (pending, fulfilled, rejected)
|
||||||
|
|||||||
@ -1,44 +1,18 @@
|
|||||||
interface GetAuditsParams {
|
export async function getAudits({ query }: { query: string }) {
|
||||||
limit?: number;
|
const res = await fetch(
|
||||||
page?: number;
|
`http://localhost:4000/api/dashboard/audits?${query}`,
|
||||||
sort?: string;
|
|
||||||
filter?: string;
|
|
||||||
entity?: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAudits({
|
|
||||||
limit,
|
|
||||||
page,
|
|
||||||
sort,
|
|
||||||
filter,
|
|
||||||
entity,
|
|
||||||
signal,
|
|
||||||
}: GetAuditsParams = {}) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
if (limit) params.set("limit", String(limit));
|
|
||||||
if (page) 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",
|
cache: "no-store",
|
||||||
signal,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await response
|
// Handle error from the API
|
||||||
|
const errorData = await res
|
||||||
.json()
|
.json()
|
||||||
.catch(() => ({ message: "Unknown error" }));
|
.catch(() => ({ message: "Unknown error" }));
|
||||||
throw new Error(errorData.message || "Failed to fetch audits");
|
throw new Error(errorData.message || `HTTP error! status: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,16 +18,9 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
|||||||
import HistoryIcon from "@mui/icons-material/History";
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import FactCheckIcon from "@mui/icons-material/FactCheck";
|
import FactCheckIcon from "@mui/icons-material/FactCheck";
|
||||||
import WidgetsIcon from "@mui/icons-material/Widgets"; // fallback
|
import WidgetsIcon from "@mui/icons-material/Widgets"; // fallback
|
||||||
import GroupIcon from "@mui/icons-material/Group";
|
|
||||||
import SecurityIcon from "@mui/icons-material/Security";
|
|
||||||
import TimerIcon from "@mui/icons-material/Timer";
|
|
||||||
import StoreIcon from "@mui/icons-material/Store";
|
|
||||||
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";
|
|
||||||
|
|
||||||
const IconMap = {
|
// Map string keys from backend to actual Icon components
|
||||||
|
const iconRegistry: Record<string, ElementType> = {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
AccountBalanceWalletIcon,
|
AccountBalanceWalletIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -44,21 +37,6 @@ const IconMap = {
|
|||||||
ArrowUpwardIcon,
|
ArrowUpwardIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
FactCheckIcon,
|
FactCheckIcon,
|
||||||
WidgetsIcon,
|
|
||||||
GroupIcon,
|
|
||||||
SecurityIcon,
|
|
||||||
TimerIcon,
|
|
||||||
StoreIcon,
|
|
||||||
AccountBalanceIcon,
|
|
||||||
PaymentIcon,
|
|
||||||
CurrencyExchangeIcon,
|
|
||||||
ViewSidebarIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IconMap;
|
|
||||||
// Map string keys from backend to actual Icon components
|
|
||||||
const iconRegistry: Record<string, ElementType> = {
|
|
||||||
...IconMap,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveIcon(
|
export function resolveIcon(
|
||||||
|
|||||||
@ -5,46 +5,6 @@ import { jwtVerify } from "jose";
|
|||||||
const COOKIE_NAME = "auth_token";
|
const COOKIE_NAME = "auth_token";
|
||||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||||
|
|
||||||
// Define route-to-role mappings
|
|
||||||
// Routes can be protected by specific roles/groups or left open to all authenticated users
|
|
||||||
// Users can have multiple groups, and access is granted if ANY of their groups match
|
|
||||||
const ROUTE_ROLES: Record<string, string[]> = {
|
|
||||||
// Admin routes - only accessible by Super Admin or admin groups
|
|
||||||
"/dashboard/admin": ["Super Admin", "Admin"],
|
|
||||||
"/admin": ["Super Admin", "Admin"],
|
|
||||||
|
|
||||||
// Add more route guards here as needed
|
|
||||||
// Example: "/dashboard/settings": ["Super Admin", "admin", "manager"],
|
|
||||||
// Example: "/dashboard/transactions": ["Super Admin", "admin", "operator", "viewer"],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user's groups have access to a specific route
|
|
||||||
* Returns true if ANY of the user's groups match ANY of the required roles
|
|
||||||
*/
|
|
||||||
function hasRouteAccess(
|
|
||||||
userGroups: string[] | undefined,
|
|
||||||
pathname: string
|
|
||||||
): boolean {
|
|
||||||
// If no role is required for this route, allow access
|
|
||||||
const requiredRoles = Object.entries(ROUTE_ROLES).find(([route]) =>
|
|
||||||
pathname.startsWith(route)
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
// If no role requirement found, allow access (route is open to all authenticated users)
|
|
||||||
if (!requiredRoles) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user has no groups, deny access
|
|
||||||
if (!userGroups || userGroups.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ANY of the user's groups match ANY of the required roles
|
|
||||||
return userGroups.some(group => requiredRoles.includes(group));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExpired(exp?: number) {
|
function isExpired(exp?: number) {
|
||||||
return exp ? exp * 1000 <= Date.now() : false;
|
return exp ? exp * 1000 <= Date.now() : false;
|
||||||
}
|
}
|
||||||
@ -57,11 +17,9 @@ async function validateToken(token: string) {
|
|||||||
algorithms: ["HS256"],
|
algorithms: ["HS256"],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[middleware] payload", payload);
|
|
||||||
return payload as {
|
return payload as {
|
||||||
exp?: number;
|
exp?: number;
|
||||||
MustChangePassword?: boolean;
|
MustChangePassword?: boolean;
|
||||||
Groups?: string[];
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -109,19 +67,6 @@ export async function middleware(request: NextRequest) {
|
|||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5️⃣ Role-based route guard (checking Groups array)
|
|
||||||
const userGroups = (payload.Groups as string[] | undefined) || [];
|
|
||||||
if (!hasRouteAccess(userGroups, currentPath)) {
|
|
||||||
// Redirect to dashboard home or unauthorized page
|
|
||||||
const unauthorizedUrl = new URL("/dashboard", request.url);
|
|
||||||
unauthorizedUrl.searchParams.set("reason", "unauthorized");
|
|
||||||
unauthorizedUrl.searchParams.set(
|
|
||||||
"message",
|
|
||||||
"You don't have permission to access this page"
|
|
||||||
);
|
|
||||||
return NextResponse.redirect(unauthorizedUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ All good
|
// ✅ All good
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user