From 455971431ffa0092a7240ba0126fd40854a0299a Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Wed, 9 Sep 2026 01:00:28 -0400 Subject: [PATCH] feat: add admin dashboard summary Add seller-scoped dashboard aggregation and API, private financial summary DTOs, responsive admin metrics, and authenticated E2E coverage. --- apps/api/src/app/build-app.js | 5 +++ apps/api/src/app/server.js | 8 ++++ .../routes/admin-dashboard-routes.js | 20 ++++++++++ .../dashboard/services/dashboard-service.js | 38 +++++++++++++++++++ .../application/dashboard-service.test.js | 32 ++++++++++++++++ apps/api/tests/http/dashboard-routes.test.js | 16 ++++++++ apps/web/src/pages/AdminHomePage.vue | 27 ++++++++++++- .../src/services/admin-dashboard-service.js | 8 ++++ .../tests/e2e/admin-catalog-workflow.spec.js | 2 + packages/contracts/dashboard.js | 21 ++++++++++ packages/contracts/package.json | 1 + 11 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/dashboard/routes/admin-dashboard-routes.js create mode 100644 apps/api/src/modules/dashboard/services/dashboard-service.js create mode 100644 apps/api/tests/application/dashboard-service.test.js create mode 100644 apps/api/tests/http/dashboard-routes.test.js create mode 100644 apps/web/src/services/admin-dashboard-service.js create mode 100644 packages/contracts/dashboard.js diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index 9a98740..4b582f1 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -16,6 +16,7 @@ import { registerPublicSearchRoutes } from '../modules/search/routes/public-sear import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js'; import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js'; import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js'; +import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js'; export function buildApp(dependencies) { const app = Fastify(); @@ -71,6 +72,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1/admin', }); + app.register(registerAdminDashboardRoutes, { + ...dependencies, + prefix: '/api/v1/admin', + }); app.register(registerPublicSearchRoutes, { ...dependencies, prefix: '/api/v1', diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index b5a6202..ddcc067 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -41,6 +41,7 @@ import { createSaleRepository } from '../modules/sales/repositories/sale-reposit import { createSaleService } from '../modules/sales/services/sale-service.js'; import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js'; import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js'; +import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js'; function parsePort(value) { const port = Number.parseInt(value ?? '3000', 10); @@ -123,6 +124,13 @@ async function startServer() { listingService: createListingService({ listingRepository, inventoryImageRepository }), saleService: createSaleService({ saleRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }), + dashboardService: createDashboardService({ + productService: createProductService({ productRepository }), + inventoryItemService: createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository }), + listingService: createListingService({ listingRepository, inventoryImageRepository }), + saleService: createSaleService({ saleRepository }), + acquisitionService: createAcquisitionService({ acquisitionRepository }), + }), publicListingService: createPublicListingService({ listingRepository, publicImageService }), productService: createProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }), diff --git a/apps/api/src/modules/dashboard/routes/admin-dashboard-routes.js b/apps/api/src/modules/dashboard/routes/admin-dashboard-routes.js new file mode 100644 index 0000000..7d2fdfd --- /dev/null +++ b/apps/api/src/modules/dashboard/routes/admin-dashboard-routes.js @@ -0,0 +1,20 @@ +import { toAdminDashboardDto } from '@pokemon-inventory/contracts/dashboard'; + +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +export async function registerAdminDashboardRoutes(app, options) { + const { + authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, + dashboardService, + } = options; + + app.addHook('preHandler', async (request) => { + const admin = await authenticateAdmin(request); + if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); + request.admin = admin; + }); + + app.get('/dashboard', async (request) => ({ + data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })), + })); +} \ No newline at end of file diff --git a/apps/api/src/modules/dashboard/services/dashboard-service.js b/apps/api/src/modules/dashboard/services/dashboard-service.js new file mode 100644 index 0000000..c31ff3a --- /dev/null +++ b/apps/api/src/modules/dashboard/services/dashboard-service.js @@ -0,0 +1,38 @@ +export function createDashboardService({ productService, inventoryItemService, listingService, saleService, acquisitionService }) { + return { + async get({ sellerId }) { + const [products, inventory, listings, sales, acquisitions] = await Promise.all([ + productService.listBySellerId(sellerId), + inventoryItemService.listBySellerId(sellerId), + listingService.list({ sellerId }), + saleService.list({ sellerId }), + acquisitionService.list({ sellerId }), + ]); + + const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status)); + const completedSales = sales.filter(({ status }) => status !== 'VOIDED'); + const total = (values, field) => values.reduce((sum, value) => sum + (value[field] ?? 0), 0); + + return { + counts: { + products: products.length, + inventory: inventory.length, + activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length, + soldInventory: inventory.filter(({ status }) => status === 'SOLD').length, + availableListings: activeListings.length, + sales: completedSales.length, + acquisitions: acquisitions.length, + missingImages: listings.filter(({ imageCount }) => imageCount < 1).length, + }, + financials: { + totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'), + totalAskingValueMinor: total(activeListings, 'askingPriceMinor'), + totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'), + totalProfitMinor: total(completedSales, 'profitMinor'), + }, + recentSales: completedSales.slice(0, 5), + recentAcquisitions: acquisitions.slice(0, 5), + }; + }, + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/dashboard-service.test.js b/apps/api/tests/application/dashboard-service.test.js new file mode 100644 index 0000000..c83e391 --- /dev/null +++ b/apps/api/tests/application/dashboard-service.test.js @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createDashboardService } from '../../src/modules/dashboard/services/dashboard-service.js'; + +test('aggregates seller dashboard counts and financial totals', async () => { + const service = createDashboardService({ + productService: { async listBySellerId() { return [{ id: 'product-1' }]; } }, + inventoryItemService: { async listBySellerId() { return [{ status: 'ACTIVE' }, { status: 'SOLD' }]; } }, + listingService: { async list() { return [{ status: 'AVAILABLE', askingPriceMinor: 2500, imageCount: 1 }]; } }, + saleService: { async list() { return [{ status: 'COMPLETED', netProceedsMinor: 2200, profitMinor: 700 }]; } }, + acquisitionService: { async list() { return [{ costBasisMinor: 1500 }]; } }, + }); + + const dashboard = await service.get({ sellerId: 'seller-1' }); + assert.deepEqual(dashboard.counts, { + products: 1, + inventory: 2, + activeInventory: 1, + soldInventory: 1, + availableListings: 1, + sales: 1, + acquisitions: 1, + missingImages: 0, + }); + assert.deepEqual(dashboard.financials, { + totalAcquisitionCostMinor: 1500, + totalAskingValueMinor: 2500, + totalNetSaleProceedsMinor: 2200, + totalProfitMinor: 700, + }); +}); \ No newline at end of file diff --git a/apps/api/tests/http/dashboard-routes.test.js b/apps/api/tests/http/dashboard-routes.test.js new file mode 100644 index 0000000..82853b4 --- /dev/null +++ b/apps/api/tests/http/dashboard-routes.test.js @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; + +test('returns a seller-scoped dashboard', async (testContext) => { + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + dashboardService: { async get({ sellerId }) { assert.equal(sellerId, 'seller-1'); return { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }; } }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ method: 'GET', url: '/api/v1/admin/dashboard' }); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().data, { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }); +}); \ No newline at end of file diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index 9a2b862..ffbc4a3 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -8,6 +8,7 @@ import { createAdminInventory, createAdminProduct, listAdminCategories, listAdmi import { createAdminListing, listAdminListings, markListingPending, publishAdminListing } from '@/services/admin-listing-service.js'; import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js'; import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js'; +import { getAdminDashboard } from '@/services/admin-dashboard-service.js'; const { principal } = useAuth(); const products = ref([]); @@ -15,6 +16,7 @@ const inventory = ref([]); const listings = ref([]); const sales = ref([]); const acquisitions = ref([]); +const dashboard = ref(null); const categories = ref([]); const conditions = ref([]); const isLoading = ref(true); @@ -45,7 +47,7 @@ function csrfToken() { async function load() { isLoading.value = true; try { - [products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value, acquisitions.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales(), listAdminAcquisitions()]); + [products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value, acquisitions.value, dashboard.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales(), listAdminAcquisitions(), getAdminDashboard()]); } catch (requestError) { error.value = requestError; } finally { isLoading.value = false; } } @@ -154,6 +156,20 @@ onMounted(load);

Loading inventory...

+
+

At a glance

+
+
Active inventory
{{ dashboard.counts.activeInventory }}
+
Available listings
{{ dashboard.counts.availableListings }}
+
Sold inventory
{{ dashboard.counts.soldInventory }}
+
Missing images
{{ dashboard.counts.missingImages }}
+
Acquisition cost
${{ (dashboard.financials.totalAcquisitionCostMinor / 100).toFixed(2) }}
+
Asking value
${{ (dashboard.financials.totalAskingValueMinor / 100).toFixed(2) }}
+
Net proceeds
${{ (dashboard.financials.totalNetSaleProceedsMinor / 100).toFixed(2) }}
+
Profit
${{ (dashboard.financials.totalProfitMinor / 100).toFixed(2) }}
+
+
+

Add product

@@ -275,3 +291,12 @@ onMounted(load); + + diff --git a/apps/web/src/services/admin-dashboard-service.js b/apps/web/src/services/admin-dashboard-service.js new file mode 100644 index 0000000..032b610 --- /dev/null +++ b/apps/web/src/services/admin-dashboard-service.js @@ -0,0 +1,8 @@ +const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; + +export async function getAdminDashboard() { + const response = await fetch(`${apiBaseUrl}/admin/dashboard`, { credentials: 'include' }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error?.message ?? 'Dashboard request failed.'); + return payload?.data; +} diff --git a/apps/web/tests/e2e/admin-catalog-workflow.spec.js b/apps/web/tests/e2e/admin-catalog-workflow.spec.js index 90193a1..7f99e3c 100644 --- a/apps/web/tests/e2e/admin-catalog-workflow.spec.js +++ b/apps/web/tests/e2e/admin-catalog-workflow.spec.js @@ -15,6 +15,8 @@ test('signs in and publishes a listing after adding an inventory image', async ( await expect(page).toHaveURL(/\/admin$/); await expect(page.getByRole('heading', { name: 'Collector workspace' })).toBeVisible(); await expect(page.getByRole('status', { name: 'Loading inventory...' })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible(); + await expect(page.getByText('Active inventory')).toBeVisible(); const category = page.locator('#product-category option').nth(1); await expect(category).toBeAttached(); diff --git a/packages/contracts/dashboard.js b/packages/contracts/dashboard.js new file mode 100644 index 0000000..3d990ae --- /dev/null +++ b/packages/contracts/dashboard.js @@ -0,0 +1,21 @@ +export function toAdminDashboardDto(dashboard) { + return { + counts: dashboard.counts, + financials: dashboard.financials, + recentSales: dashboard.recentSales.map(({ sale, saleItem, inventoryItem, productName, netProceedsMinor, profitMinor }) => ({ + id: sale.id, + productName: productName ?? inventoryItem.id, + saleDate: sale.saleDate, + soldPriceMinor: sale.soldPriceMinor, + netProceedsMinor, + profitMinor, + inventoryItemId: saleItem.inventoryItemId, + })), + recentAcquisitions: dashboard.recentAcquisitions.map((acquisition) => ({ + id: acquisition.id, + inventoryItemId: acquisition.inventoryItemId, + date: acquisition.date ?? null, + costBasisMinor: acquisition.costBasisMinor ?? null, + })), + }; +} \ No newline at end of file diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9f7696d..2a19f82 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -5,6 +5,7 @@ "exports": { "./auth": "./auth.js", "./acquisition": "./acquisition.js", + "./dashboard": "./dashboard.js", "./inventory": "./inventory.js", "./image": "./image.js", "./listing": "./listing.js",