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