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...
{{ error.message }}
+