From 4e12b03e4b605a61c23cd4ed31f5a7b51dae213b Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 8 Oct 2026 16:28:56 +0530 Subject: [PATCH 1/4] Add analytics functionality for stores --- .../Features/Store/IStoreRepository.cs | 1 + .../Features/Store/StoreController.cs | 7 ++ Distribution/Features/Store/StoreDTOs.cs | 37 ++++++++ .../Features/Store/StoreRepository.cs | 8 ++ Distribution/Features/Store/StoreService.cs | 88 +++++++++++++++++++ 5 files changed, 141 insertions(+) diff --git a/Distribution/Features/Store/IStoreRepository.cs b/Distribution/Features/Store/IStoreRepository.cs index 296ce50..ef6678c 100644 --- a/Distribution/Features/Store/IStoreRepository.cs +++ b/Distribution/Features/Store/IStoreRepository.cs @@ -13,4 +13,5 @@ public interface IStoreRepository void Update(Models.Store entity); void Remove(Models.Store entity); Task> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive); + Task> GetInvoicesForAnalyticsAsync(Guid storeId); } diff --git a/Distribution/Features/Store/StoreController.cs b/Distribution/Features/Store/StoreController.cs index 11235e2..0a8e100 100644 --- a/Distribution/Features/Store/StoreController.cs +++ b/Distribution/Features/Store/StoreController.cs @@ -92,6 +92,13 @@ public class StoreController(StoreService service) : ControllerBase return result is null ? NotFound() : Ok(result); } + [HttpGet("{id:guid}/analytics")] + public async Task GetAnalytics(Guid id, [FromQuery] int months = 12, [FromQuery] int overdueDays = 30) + { + var result = await service.GetAnalyticsAsync(id, months, overdueDays); + return result is null ? NotFound() : Ok(result); + } + [HttpPatch("{id:guid}/status")] public async Task PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct) { diff --git a/Distribution/Features/Store/StoreDTOs.cs b/Distribution/Features/Store/StoreDTOs.cs index a9f0ffc..7f66542 100644 --- a/Distribution/Features/Store/StoreDTOs.cs +++ b/Distribution/Features/Store/StoreDTOs.cs @@ -59,3 +59,40 @@ public record SchemeEligibilityResponse(bool Eligible, string? Reason); public record PatchStoreStatusRequest(bool IsActive); public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate); + +public record StoreAnalyticsSummary( + decimal TotalSales, + decimal TotalCollected, + decimal Outstanding, + decimal OverdueAmount, + int OverdueInvoiceCount, + int InvoiceCount, + decimal AverageOrderValue, + DateTime? LastSaleDate, + decimal CreditLimit, + decimal CreditUtilizationPercent); + +public record StoreMonthlySales(string Month, decimal Invoiced, decimal Collected, decimal Outstanding); + +public record StoreTopProduct(Guid ItemId, string Name, int Quantity, decimal Revenue, DateTime LastSoldAt); + +public record StoreAgeingBucket(string Label, decimal Amount, int InvoiceCount); + +public record StoreAnalyticsInvoice( + Guid InvoiceId, + DateTime InvoiceDate, + DateTime DueDate, + decimal TotalAmount, + decimal PaidAmount, + decimal Balance, + string PaymentStatus, + int DaysOverdue); + +public record StoreAnalyticsResponse( + Guid StoreId, + int OverdueDays, + StoreAnalyticsSummary Summary, + List MonthlySales, + List TopProducts, + List Ageing, + List Invoices); diff --git a/Distribution/Features/Store/StoreRepository.cs b/Distribution/Features/Store/StoreRepository.cs index a9fcff6..f2d96ea 100644 --- a/Distribution/Features/Store/StoreRepository.cs +++ b/Distribution/Features/Store/StoreRepository.cs @@ -51,4 +51,12 @@ public class StoreRepository(AppDBContext context) : IStoreRepository await context.ShopPOs .Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive) .ToListAsync(); + + public async Task> GetInvoicesForAnalyticsAsync(Guid storeId) => + await context.Invoices + .AsNoTracking() + .Include(i => i.Payments) + .Include(i => i.ShopPO).ThenInclude(p => p.Items).ThenInclude(x => x.Item) + .Where(i => i.IsActive && i.Status != InvoiceStatus.Draft && i.ShopPO.FK_StoreId == storeId) + .ToListAsync(); } diff --git a/Distribution/Features/Store/StoreService.cs b/Distribution/Features/Store/StoreService.cs index 9d41b36..0e82bb0 100644 --- a/Distribution/Features/Store/StoreService.cs +++ b/Distribution/Features/Store/StoreService.cs @@ -253,4 +253,92 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe orders.Sum(o => o.TotalAmount), orders.Count == 0 ? null : orders.Max(o => o.CreatedAt)); } + + public async Task GetAnalyticsAsync(Guid id, int months, int overdueDays) + { + var store = await repo.GetByIdAsync(id); + if (store is null) return null; + if (!await HasStoreScopeAsync(id)) return null; + + months = Math.Clamp(months, 1, 36); + overdueDays = Math.Clamp(overdueDays, 0, 365); + var now = DateTime.Now; + var from = new DateTime(now.Year, now.Month, 1).AddMonths(-(months - 1)); + + var invoices = (await repo.GetInvoicesForAnalyticsAsync(id)) + .Select(i => + { + var date = i.FinalizedAt ?? i.CreatedAt; + var paid = i.Payments.Where(p => p.IsActive).Sum(p => p.Amount); + var balance = Math.Max(0, i.TotalAmount - paid); + var due = date.AddDays(overdueDays); + var daysOverdue = balance > 0 && now > due ? (int)(now - due).TotalDays : 0; + return (Invoice: i, Date: date, Due: due, Paid: paid, Balance: balance, DaysOverdue: daysOverdue); + }) + .ToList(); + + var inRange = invoices.Where(x => x.Date >= from).ToList(); + var totalSales = inRange.Sum(x => x.Invoice.TotalAmount); + var overdue = invoices.Where(x => x.DaysOverdue > 0).ToList(); + + var summary = new StoreAnalyticsSummary( + totalSales, + inRange.Sum(x => x.Paid), + invoices.Sum(x => x.Balance), + overdue.Sum(x => x.Balance), + overdue.Count, + inRange.Count, + inRange.Count == 0 ? 0 : Math.Round(totalSales / inRange.Count, 2), + invoices.Count == 0 ? null : invoices.Max(x => x.Date), + store.CreditLimit, + store.CreditLimit > 0 ? Math.Round(store.OutstandingBalance / store.CreditLimit * 100, 1) : 0); + + var monthly = Enumerable.Range(0, months) + .Select(n => from.AddMonths(n)) + .Select(m => new StoreMonthlySales( + m.ToString("yyyy-MM"), + inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Invoice.TotalAmount), + inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Paid), + invoices + .Where(x => x.Date < m.AddMonths(1)) + .Sum(x => Math.Max(0, x.Invoice.TotalAmount - x.Invoice.Payments.Where(p => p.IsActive && p.CollectedAt < m.AddMonths(1)).Sum(p => p.Amount))))) + .ToList(); + + var topProducts = inRange + .SelectMany(x => x.Invoice.ShopPO.Items.Select(it => (it, x.Date))) + .GroupBy(t => t.it.FK_ItemId) + .Select(g => new StoreTopProduct( + g.Key, + g.First().it.Item?.Name ?? "Unknown", + g.Sum(t => t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity), + g.Sum(t => (t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity) * t.it.UnitPrice), + g.Max(t => t.Date))) + .OrderByDescending(p => p.Revenue) + .Take(10) + .ToList(); + + var unpaid = invoices.Where(x => x.Balance > 0).ToList(); + StoreAgeingBucket Bucket(string label, Func match) + { + var items = unpaid.Where(x => match(x.DaysOverdue)).ToList(); + return new StoreAgeingBucket(label, items.Sum(x => x.Balance), items.Count); + } + var ageing = new List + { + Bucket("Current", d => d <= 0), + Bucket("1-30", d => d is >= 1 and <= 30), + Bucket("31-60", d => d is >= 31 and <= 60), + Bucket("61-90", d => d is >= 61 and <= 90), + Bucket("90+", d => d > 90) + }; + + var list = invoices + .OrderByDescending(x => x.Date) + .Select(x => new StoreAnalyticsInvoice( + x.Invoice.Id, x.Date, x.Due, x.Invoice.TotalAmount, x.Paid, x.Balance, + x.Invoice.PaymentStatus.ToString(), x.DaysOverdue)) + .ToList(); + + return new StoreAnalyticsResponse(id, overdueDays, summary, monthly, topProducts, ageing, list); + } } -- 2.52.0 From e5f6aa15b97425007384e61300c775c84f0bfa55 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 8 Oct 2026 16:32:23 +0530 Subject: [PATCH 2/4] Implement Store analytics and api integrations --- .../app/(dashboard)/stores/[id]/page.tsx | 96 +---- .../stores/_components/store-analytics.tsx | 403 ++++++++++++++++++ distribution_fe/components/ui/chart.tsx | 372 ++++++++++++++++ distribution_fe/components/ui/progress.tsx | 82 ++++ distribution_fe/components/ui/tabs.tsx | 81 ++++ distribution_fe/lib/api/stores.ts | 3 + distribution_fe/package-lock.json | 372 ++++++++++++++++ distribution_fe/package.json | 1 + distribution_fe/types/api.ts | 37 ++ 9 files changed, 1367 insertions(+), 80 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/stores/_components/store-analytics.tsx create mode 100644 distribution_fe/components/ui/chart.tsx create mode 100644 distribution_fe/components/ui/progress.tsx create mode 100644 distribution_fe/components/ui/tabs.tsx diff --git a/distribution_fe/app/(dashboard)/stores/[id]/page.tsx b/distribution_fe/app/(dashboard)/stores/[id]/page.tsx index c9be7e2..270c466 100644 --- a/distribution_fe/app/(dashboard)/stores/[id]/page.tsx +++ b/distribution_fe/app/(dashboard)/stores/[id]/page.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' -import dynamic from 'next/dynamic' import { HugeiconsIcon } from '@hugeicons/react' import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons' import { Button } from '@/components/ui/button' @@ -11,36 +10,21 @@ import { toast } from '@/components/ui/toast' import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index' import type { StoreResponse, RouteResponse } from '@/types/api' -const LocationPicker = dynamic( - () => import('../_components/location-picker').then((m) => m.LocationPicker), - { ssr: false, loading: () =>
} -) +import { StoreAnalytics } from '../_components/store-analytics' const fmtDate = (v: string) => new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) -const fmtMoney = (v: number) => - `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}` function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
+
{label} {children}
) } -function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) { - return ( -
-

{label}

-

{value}

- {sub &&

{sub}

} -
- ) -} - export default function StoreViewPage() { const { id } = useParams<{ id: string }>() const router = useRouter() @@ -66,7 +50,6 @@ export default function StoreViewPage() { const routeName = store ? routesList.find((r) => r.id === store.routeId)?.routeName : undefined - const availableCredit = store ? store.creditLimit - store.outstandingBalance : 0 return (
@@ -103,73 +86,26 @@ export default function StoreViewPage() {
- {/* Body skeleton */} - {loading && ( -
-
- {Array.from({ length: 7 }).map((_, i) => ( -
- ))} -
-
-
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
-
-
-
- )} + {loading &&
} {/* Body */} {!loading && store && ( -
- - {/* Left — store details */} -
-
- {store.ownerName ?? "—"} - {store.contactNumber ?? "—"} - {store.storeAddress} - - {routeName ?? {store.routeId}} - - {store.segment && ( - {store.segment} - )} - {fmtMoney(store.creditLimit)} - {fmtDate(store.createdAt)} -
-
- - {/* Right — credit stats + map */} -
-
- - - -
- - +
+
+ {store.ownerName ?? '—'} + {store.contactNumber ?? '—'} + + {routeName ?? {store.routeId}} + + {store.storeAddress} + {store.segment ?? '—'} + {fmtDate(store.createdAt)}
)} + + {/* Shop analytics */} + {!loading && store && }
) } diff --git a/distribution_fe/app/(dashboard)/stores/_components/store-analytics.tsx b/distribution_fe/app/(dashboard)/stores/_components/store-analytics.tsx new file mode 100644 index 0000000..41b5fb0 --- /dev/null +++ b/distribution_fe/app/(dashboard)/stores/_components/store-analytics.tsx @@ -0,0 +1,403 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Label, + Line, + LineChart, + PolarAngleAxis, + Pie, + PieChart, + RadialBar, + RadialBarChart, + XAxis, + YAxis, +} from 'recharts' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart' +import { toast } from '@/components/ui/toast' +import { stores as storesApi, getApiError } from '@/lib/api/index' +import type { StoreAnalyticsResponse } from '@/types/api' + +const fmtMoney = (v: number) => + `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + +const fmtCompact = (v: number) => + v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 }) + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const fmtMonth = (v: string) => + new Date(`${v}-01`).toLocaleDateString('en-GB', { month: 'short', year: '2-digit' }) + +const daysAgo = (v: string) => { + const d = Math.floor((Date.now() - new Date(v).getTime()) / 86_400_000) + return d <= 0 ? 'Today' : d === 1 ? '1 day ago' : `${d} days ago` +} + +const salesConfig = { + invoiced: { label: 'Invoiced', color: 'var(--chart-2)' }, + collected: { label: 'Collected', color: 'var(--chart-4)' }, +} satisfies ChartConfig + +const trendConfig = { + invoiced: { label: "Invoiced", color: "var(--chart-2)" }, + outstanding: { label: "Outstanding", color: "var(--destructive)" }, +} satisfies ChartConfig + +const creditConfig = { + used: { label: 'Credit used', color: 'var(--chart-2)' }, +} satisfies ChartConfig + +const AGEING_FILL: Record = { + 'Current': { color: 'var(--chart-2)', opacity: 1 }, + '1-30': { color: 'var(--destructive)', opacity: 0.45 }, + '31-60': { color: 'var(--destructive)', opacity: 0.65 }, + '61-90': { color: 'var(--destructive)', opacity: 0.85 }, + '90+': { color: 'var(--destructive)', opacity: 1 }, +} + +const PRODUCT_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', 'var(--muted-foreground)'] + +function DonutCenter({ title, value }: { title: string; value: string }) { + return ( +