Skip to content

feat: Admin Escrow Analytics Dashboard#21

Open
Michaelkingsdev wants to merge 1 commit intoTrustless-Work:mainfrom
Michaelkingsdev:admin-analytics
Open

feat: Admin Escrow Analytics Dashboard#21
Michaelkingsdev wants to merge 1 commit intoTrustless-Work:mainfrom
Michaelkingsdev:admin-analytics

Conversation

@Michaelkingsdev
Copy link

@Michaelkingsdev Michaelkingsdev commented Feb 28, 2026

Closes #14

Admin Escrow Analytics Dashboard

This PR adds a new Admin Escrow Analytics Dashboard.
It helps admins see escrow activity, total volume, and growth over time.

The goal is to keep the system scalable, simple, and easy to maintain.


What Was Done

Better Data Loading

Inside useAdminEscrowAnalytics, I used a two-step data loading process to avoid Firestore index limits:

  1. First, fetch only the contract IDs.
  2. Then, fetch full contract details in batches.

This:

  • Avoids complex Firestore indexes
  • Reduces unnecessary data loading
  • Works better as data grows

Data Processing

Created aggregation.ts to turn raw data into useful metrics like:

  • Total volume (by currency)
  • Escrow type breakdown
  • Monthly totals
  • Month-over-Month (MoM) growth

The file only handles calculations. It is separate from the UI and easy to test.


State & Caching

  • Used @tanstack/react-query to cache data and avoid repeated requests.
  • Saved engagementId in localStorage so it stays after page refresh.

Visuals

  • Restored the requested 4-metric grid layout.
  • Used recharts for charts.
  • Applied clean, high-contrast styling (White / Primary theme).

Note

Since no route was provided in the issue, I created a temporary route at /admin.

This is just for review and can be changed or removed based on feedback.

Screenshot

image image

Summary by CodeRabbit

Release Notes

  • New Features
    • Added admin portal with search functionality to access engagement analytics
    • Introduced comprehensive analytics dashboard displaying key performance indicators (total escrows, amounts, balances)
    • Added data visualizations including charts for escrow breakdown by type, creation dates, and month-over-month growth metrics
    • Included refresh capability for real-time data updates

@coderabbitai
Copy link

coderabbitai bot commented Feb 28, 2026

📝 Walkthrough

Walkthrough

This PR introduces a complete Admin Analytics Dashboard system consisting of an admin page entry point, dashboard components with charting utilities, and specialized escrow analytics hooks with aggregation logic for computing metrics like total escrows, type distribution, and month-over-month growth.

Changes

Cohort / File(s) Summary
Admin Portal Entry
src/app/admin/page.tsx
New client-side admin page with search form to load engagements, persisting selection to localStorage and rendering the analytics dashboard with the selected engagementId.
Dashboard Components & Utilities
src/components/tw-blocks/dashboard/dashboard-01/Dashboard.tsx, src/components/tw-blocks/dashboard/dashboard-01/chart.tsx, src/components/tw-blocks/dashboard/dashboard-01/useDashboard.ts
Core dashboard component with KPI cards and multiple charts (bar, donut, area), chart wrapper utilities (ChartContainer, ChartTooltip, ChartTooltipContent), and a hook for deriving wallet-scoped metrics from escrow data.
Escrow Admin Analytics
src/components/tw-blocks/escrows/admin-analytics/AdminEscrowAnalyticsDashboard.tsx, src/components/tw-blocks/escrows/admin-analytics/useAdminEscrowAnalytics.ts, src/components/tw-blocks/escrows/admin-analytics/aggregation.ts
Engagement-scoped analytics dashboard component, hook for fetching and aggregating admin escrow data with react-query, and utility functions for grouping by type/date and computing month-over-month growth.
Module Exports
src/components/tw-blocks/escrows/admin-analytics/index.ts
Barrel export file consolidating admin analytics modules for simplified imports.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant AdminPage as Admin Page
    participant Hook as useAdminEscrowAnalytics
    participant Indexer as Escrow Indexer
    participant Agg as Aggregation Utils
    participant Dashboard as Analytics Dashboard
    participant Charts as Chart Components

    User->>AdminPage: Enter engagementId & submit
    AdminPage->>Hook: Call hook with engagementId
    Hook->>Indexer: Fetch escrows by signer for engagement
    Indexer-->>Hook: Return escrow list
    Hook->>Indexer: Fetch detailed escrows by contract IDs
    Indexer-->>Hook: Return detailed escrow data
    Hook->>Agg: groupEscrowsByType(escrows)
    Agg-->>Hook: Return type distribution
    Hook->>Agg: groupEscrowsByDate(escrows)
    Agg-->>Hook: Return date aggregation
    Hook->>Agg: calculateMoMGrowth(escrows)
    Agg-->>Hook: Return monthly growth %
    Hook-->>AdminPage: Return aggregated analytics
    AdminPage->>Dashboard: Render with analytics data
    Dashboard->>Charts: Render charts with data
    Charts-->>Dashboard: Display visualizations
    Dashboard-->>User: Present dashboard UI
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related issues

  • frontend: dashboard for admin #14: frontend: dashboard for admin — This PR directly implements the requested Admin Analytics Dashboard feature, including engagement-scoped escrow analytics with all required charts (by type, by date, bar chart, MoM growth), proper data aggregation, and integration with the indexer hooks as specified.

Poem

🐰 Hop, hop, hooray! Analytics now bloom,
Charts and escrows fill the admin room!
With dashboards dancing and growth measured bright,
Month-over-month trends shimmer in sight! ✨📊

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Admin Escrow Analytics Dashboard' is concise, specific, and accurately describes the primary change in this pull request.
Linked Issues check ✅ Passed The PR fully implements issue #14's requirements: accepts engagementId prop, uses indexer hook for data, computes all required metrics (Total Escrows, Escrows by Type, by Date, MoM Growth), handles loading/empty/error states, and follows repository patterns.
Out of Scope Changes check ✅ Passed All changes are scoped to the admin dashboard feature. No out-of-scope modifications to backend, smart contracts, indexer logic, or unrelated components are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (9)
src/components/tw-blocks/dashboard/dashboard-01/Dashboard.tsx (2)

76-85: Consider using domain-specific naming instead of template placeholders.

The donut data transformation uses browser and visitors as property names, which appear to be artifacts from a template. For clarity and consistency with other analytics components, consider using domain-specific names like type and count.

♻️ Suggested naming improvement
  const donutData = React.useMemo(
    () =>
      typeSlices.map((s) => ({
-       browser: s.type === "single" ? "single" : "multi",
-       visitors: s.value,
+       type: s.type,
+       count: s.value,
        fill:
          s.type === "single" ? "var(--color-single)" : "var(--color-multi)",
      })),
    [typeSlices]
  );

Then update the PieChart to use dataKey="count" and nameKey="type".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/tw-blocks/dashboard/dashboard-01/Dashboard.tsx` around lines
76 - 85, The donutData mapping uses generic template names ("browser" and
"visitors") instead of domain-specific names; change the object keys produced by
donutData (computed from typeSlices) to use type and count (e.g., { type: ...,
count: ..., fill: ... }) and then update the PieChart usage to reference
dataKey="count" and nameKey="type" so the chart and data shape are consistent.

117-118: Currency is hardcoded to "USDC".

Unlike AdminEscrowAnalyticsDashboard which extracts the currency from the escrow data (data.raw[0]?.trustline?.symbol || "USDC"), this dashboard hardcodes "USDC". If multi-currency support is expected, consider deriving the currency from the data.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/tw-blocks/dashboard/dashboard-01/Dashboard.tsx` around lines
117 - 118, The JSX currently calls formatCurrency(totalAmount, "USDC") and
hardcodes the currency; change it to derive the currency from the component's
data (same approach as AdminEscrowAnalyticsDashboard) by passing something like
data.raw?.[0]?.trustline?.symbol || "USDC" to formatCurrency instead of the
literal "USDC". Update the expression inside the div (which uses isLoading,
formatCurrency, and totalAmount) to use the derivedCurrency variable or inline
expression so multi-currency values render correctly with a fallback to "USDC".
src/components/tw-blocks/escrows/admin-analytics/useAdminEscrowAnalytics.ts (1)

36-37: Return type includes null but the function never returns it.

The queryFn return type is Promise<AdminAnalyticsData | null>, but the function either returns an AdminAnalyticsData object or throws an error—it never returns null. Consider simplifying the return type to Promise<AdminAnalyticsData>.

🔧 Suggested type fix
-        queryFn: async (): Promise<AdminAnalyticsData | null> => {
+        queryFn: async (): Promise<AdminAnalyticsData> => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/tw-blocks/escrows/admin-analytics/useAdminEscrowAnalytics.ts`
around lines 36 - 37, The queryFn is typed as Promise<AdminAnalyticsData | null>
but it always either throws or returns AdminAnalyticsData; update the function's
return type to Promise<AdminAnalyticsData> and adjust any surrounding generics
(e.g., the useQuery call) to use AdminAnalyticsData (remove the nullable union)
so types correctly reflect that null is never returned; check the queryFn
declaration and the useQuery<T> generic where AdminAnalyticsData | null is used
and replace with AdminAnalyticsData.
src/components/tw-blocks/escrows/admin-analytics/AdminEscrowAnalyticsDashboard.tsx (1)

228-233: Pie chart color assignment by array index may cause inconsistent coloring.

The Cell colors are assigned based on array index (index === 0 ? "white" : "hsl(var(--primary))"), but the order of data.byType entries depends on the order types are encountered in the reduce operation (in groupEscrowsByType). If only multi-release escrows exist, they would get the "white" color intended for single-release.

Consider assigning colors based on the type value instead:

🎨 Suggested fix for consistent coloring
                                {data.byType.map((_: unknown, index: number) => (
+                               {data.byType.map((entry: { type: string }, index: number) => (
                                    <Cell
                                        key={`cell-${index}`}
-                                       fill={index === 0 ? "white" : "hsl(var(--primary))"}
+                                       fill={entry.type === "Single Release" ? "white" : "hsl(var(--primary))"}
                                    />
                                ))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/tw-blocks/escrows/admin-analytics/AdminEscrowAnalyticsDashboard.tsx`
around lines 228 - 233, The pie slice coloring relies on array index in the map
(data.byType and the rendered Cell) which can miscolor types when grouping order
changes; update the mapping to determine color from the escrow type value
instead of index: inside the map over data.byType use the group's type property
(from the output of groupEscrowsByType) to pick the correct color (e.g., map
"singleRelease" -> "white", "multiRelease" -> "hsl(var(--primary))") so
rendering of Cell uses that computed color, ensuring colors remain consistent
regardless of array order.
src/components/tw-blocks/dashboard/dashboard-01/useDashboard.ts (1)

61-105: Consider reusing aggregation utilities from the admin-analytics module.

This hook duplicates aggregation logic that exists in src/components/tw-blocks/escrows/admin-analytics/aggregation.ts (e.g., calculateTotalAmount, calculateTotalBalance, groupEscrowsByDate). While the implementations differ slightly, consolidating could reduce maintenance burden.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/tw-blocks/dashboard/dashboard-01/useDashboard.ts` around lines
61 - 105, Replace the inline aggregation code in useDashboard (the computations
for totalEscrows, totalAmount, totalBalance, amountsByDate and createdByDate)
with calls to the shared functions from
src/components/tw-blocks/escrows/admin-analytics/aggregation.ts (for example
calculateTotalAmount, calculateTotalBalance and groupEscrowsByDate); import
those utilities at the top of the file, use calculateTotalAmount(data) and
calculateTotalBalance(data) for totalAmount/totalBalance, derive totalEscrows
from data.length (or a provided utility if present), and use
groupEscrowsByDate(data) (or its variants) to produce the amountsByDate and
createdByDate shapes, leaving typeSlices as-is or moving its logic into a shared
utility if available.
src/components/tw-blocks/escrows/admin-analytics/aggregation.ts (3)

40-54: Assumes createdAt._seconds always exists.

If an escrow has a malformed or missing createdAt field, accessing _seconds will return undefined, causing new Date(undefined * 1000) to produce an Invalid Date. Consider adding defensive handling.

🛡️ Defensive handling for malformed dates
export const groupEscrowsByDate = (escrows: Escrow[]): ChartDataPoint[] => {
    const groups = escrows.reduce((acc, escrow) => {
+       if (!escrow.createdAt?._seconds) return acc;
        const date = new Date(escrow.createdAt._seconds * 1000);
+       if (isNaN(date.getTime())) return acc;
        const dateKey = format(date, "yyyy-MM-dd");
        acc[dateKey] = (acc[dateKey] || 0) + 1;
        return acc;
    }, {} as Record<string, number>);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/tw-blocks/escrows/admin-analytics/aggregation.ts` around lines
40 - 54, The reducer in groupEscrowsByDate assumes escrow.createdAt._seconds
exists; add defensive validation to handle missing/malformed createdAt by
checking escrow.createdAt and escrow.createdAt._seconds (or other timestamp
forms) before building the Date, and either skip the escrow or use a safe
fallback (e.g., treat as current time or a sentinel) when the timestamp is
absent; ensure you only call format(date, ...) for a valid Date and update the
groups accumulation (acc / dateKey) accordingly so invalid dates don't produce
"Invalid Date" keys.

31-34: Type mapping treats "unknown" types as "Multi Release".

The current mapping only explicitly handles "single-release" - all other types (including "unknown" or any unexpected values) are labeled as "Multi Release". This could be misleading in analytics if escrows have unexpected type values.

🔧 Suggested fix for explicit type handling
    return Object.entries(types).map(([type, count]) => ({
-       type: type === "single-release" ? "Single Release" : "Multi Release",
+       type: type === "single-release" 
+           ? "Single Release" 
+           : type === "multi-release" 
+               ? "Multi Release" 
+               : "Unknown",
        count,
    }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/tw-blocks/escrows/admin-analytics/aggregation.ts` around lines
31 - 34, The current mapping in aggregation.ts that transforms
Object.entries(types).map(([type, count]) => ({ type: type === "single-release"
? "Single Release" : "Multi Release", count })) incorrectly treats any
non-"single-release" value (including "unknown") as "Multi Release"; change the
mapping to explicitly handle "single-release" and "multi-release" and map any
other/unknown values to a neutral label like "Unknown" (or include the raw type)
so analytics don't misclassify unexpected types—update the map callback
accordingly to check for "single-release", "multi-release", then default to
"Unknown".

74-78: Month iteration mutates Date object in place.

The while loop mutates current via setMonth(), then immediately wraps it in new Date() and startOfMonth(). While this works, the mutation pattern is error-prone and could cause subtle bugs if the logic changes.

♻️ Cleaner iteration using date-fns addMonths
+import { startOfMonth, format, addMonths } from "date-fns";

    while (current <= end) {
        const monthKey = format(current, "yyyy-MM");
        months[monthKey] = 0;
-       current = startOfMonth(new Date(current.setMonth(current.getMonth() + 1)));
+       current = addMonths(current, 1);
    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/tw-blocks/escrows/admin-analytics/aggregation.ts` around lines
74 - 78, The loop mutates the Date object via current.setMonth(...); change it
to use date-fns addMonths to avoid in-place mutation: in the while loop that
builds months (variables current, end, months, format, startOfMonth), set
current = startOfMonth(addMonths(current, 1)) instead of new
Date(current.setMonth(...)); import addMonths from date-fns and ensure current
is treated immutably so each iteration advances by addMonths(current, 1).
src/components/tw-blocks/dashboard/dashboard-01/chart.tsx (1)

19-23: Type assertion to any for CSS custom properties.

The (style as any)[varName] assertion is needed because TypeScript's CSSProperties doesn't include CSS custom properties by default. This is a known limitation. An alternative is to use a typed index signature.

🔧 Optional: Type-safe CSS custom properties
export function ChartContainer({
  config,
  className,
  children,
}: ChartContainerProps) {
- const style: React.CSSProperties = {};
+ const style: React.CSSProperties & Record<`--color-${string}`, string> = {};
  for (const [key, value] of Object.entries(config)) {
    const varName = `--color-${key}` as const;
-   if (value.color) (style as any)[varName] = value.color;
+   if (value.color) style[varName] = value.color;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/tw-blocks/dashboard/dashboard-01/chart.tsx` around lines 19 -
23, The code uses an unsafe (style as any)[varName] cast to assign CSS custom
properties; instead declare style with a typed index signature so TypeScript
knows custom properties are allowed (e.g. change the declaration of style to
React.CSSProperties & Record<string, string> or a template-keyed type for
`--color-*`) and then assign (style[varName] = value.color) without using any;
apply this to the existing symbols `style`, `varName`, and the loop over
`config`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/components/tw-blocks/dashboard/dashboard-01/chart.tsx`:
- Around line 19-23: The code uses an unsafe (style as any)[varName] cast to
assign CSS custom properties; instead declare style with a typed index signature
so TypeScript knows custom properties are allowed (e.g. change the declaration
of style to React.CSSProperties & Record<string, string> or a template-keyed
type for `--color-*`) and then assign (style[varName] = value.color) without
using any; apply this to the existing symbols `style`, `varName`, and the loop
over `config`.

In `@src/components/tw-blocks/dashboard/dashboard-01/Dashboard.tsx`:
- Around line 76-85: The donutData mapping uses generic template names
("browser" and "visitors") instead of domain-specific names; change the object
keys produced by donutData (computed from typeSlices) to use type and count
(e.g., { type: ..., count: ..., fill: ... }) and then update the PieChart usage
to reference dataKey="count" and nameKey="type" so the chart and data shape are
consistent.
- Around line 117-118: The JSX currently calls formatCurrency(totalAmount,
"USDC") and hardcodes the currency; change it to derive the currency from the
component's data (same approach as AdminEscrowAnalyticsDashboard) by passing
something like data.raw?.[0]?.trustline?.symbol || "USDC" to formatCurrency
instead of the literal "USDC". Update the expression inside the div (which uses
isLoading, formatCurrency, and totalAmount) to use the derivedCurrency variable
or inline expression so multi-currency values render correctly with a fallback
to "USDC".

In `@src/components/tw-blocks/dashboard/dashboard-01/useDashboard.ts`:
- Around line 61-105: Replace the inline aggregation code in useDashboard (the
computations for totalEscrows, totalAmount, totalBalance, amountsByDate and
createdByDate) with calls to the shared functions from
src/components/tw-blocks/escrows/admin-analytics/aggregation.ts (for example
calculateTotalAmount, calculateTotalBalance and groupEscrowsByDate); import
those utilities at the top of the file, use calculateTotalAmount(data) and
calculateTotalBalance(data) for totalAmount/totalBalance, derive totalEscrows
from data.length (or a provided utility if present), and use
groupEscrowsByDate(data) (or its variants) to produce the amountsByDate and
createdByDate shapes, leaving typeSlices as-is or moving its logic into a shared
utility if available.

In
`@src/components/tw-blocks/escrows/admin-analytics/AdminEscrowAnalyticsDashboard.tsx`:
- Around line 228-233: The pie slice coloring relies on array index in the map
(data.byType and the rendered Cell) which can miscolor types when grouping order
changes; update the mapping to determine color from the escrow type value
instead of index: inside the map over data.byType use the group's type property
(from the output of groupEscrowsByType) to pick the correct color (e.g., map
"singleRelease" -> "white", "multiRelease" -> "hsl(var(--primary))") so
rendering of Cell uses that computed color, ensuring colors remain consistent
regardless of array order.

In `@src/components/tw-blocks/escrows/admin-analytics/aggregation.ts`:
- Around line 40-54: The reducer in groupEscrowsByDate assumes
escrow.createdAt._seconds exists; add defensive validation to handle
missing/malformed createdAt by checking escrow.createdAt and
escrow.createdAt._seconds (or other timestamp forms) before building the Date,
and either skip the escrow or use a safe fallback (e.g., treat as current time
or a sentinel) when the timestamp is absent; ensure you only call format(date,
...) for a valid Date and update the groups accumulation (acc / dateKey)
accordingly so invalid dates don't produce "Invalid Date" keys.
- Around line 31-34: The current mapping in aggregation.ts that transforms
Object.entries(types).map(([type, count]) => ({ type: type === "single-release"
? "Single Release" : "Multi Release", count })) incorrectly treats any
non-"single-release" value (including "unknown") as "Multi Release"; change the
mapping to explicitly handle "single-release" and "multi-release" and map any
other/unknown values to a neutral label like "Unknown" (or include the raw type)
so analytics don't misclassify unexpected types—update the map callback
accordingly to check for "single-release", "multi-release", then default to
"Unknown".
- Around line 74-78: The loop mutates the Date object via current.setMonth(...);
change it to use date-fns addMonths to avoid in-place mutation: in the while
loop that builds months (variables current, end, months, format, startOfMonth),
set current = startOfMonth(addMonths(current, 1)) instead of new
Date(current.setMonth(...)); import addMonths from date-fns and ensure current
is treated immutably so each iteration advances by addMonths(current, 1).

In `@src/components/tw-blocks/escrows/admin-analytics/useAdminEscrowAnalytics.ts`:
- Around line 36-37: The queryFn is typed as Promise<AdminAnalyticsData | null>
but it always either throws or returns AdminAnalyticsData; update the function's
return type to Promise<AdminAnalyticsData> and adjust any surrounding generics
(e.g., the useQuery call) to use AdminAnalyticsData (remove the nullable union)
so types correctly reflect that null is never returned; check the queryFn
declaration and the useQuery<T> generic where AdminAnalyticsData | null is used
and replace with AdminAnalyticsData.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7f5ba56 and feb3cef.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • src/app/admin/page.tsx
  • src/components/tw-blocks/dashboard/dashboard-01/Dashboard.tsx
  • src/components/tw-blocks/dashboard/dashboard-01/chart.tsx
  • src/components/tw-blocks/dashboard/dashboard-01/useDashboard.ts
  • src/components/tw-blocks/escrows/admin-analytics/AdminEscrowAnalyticsDashboard.tsx
  • src/components/tw-blocks/escrows/admin-analytics/aggregation.ts
  • src/components/tw-blocks/escrows/admin-analytics/index.ts
  • src/components/tw-blocks/escrows/admin-analytics/useAdminEscrowAnalytics.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

frontend: dashboard for admin

1 participant