Explorar el Código

feat(dashboard): Add date range filtering to dashboard widgets (#3818)

David Höck hace 3 meses
padre
commit
16433136e5

+ 11 - 14
packages/dashboard/plugin/api/api-extensions.ts

@@ -1,33 +1,30 @@
 import gql from 'graphql-tag';
 
 export const adminApiExtensions = gql`
-    type MetricSummary {
-        interval: MetricInterval!
-        type: MetricType!
+    type DashboardMetricSummary {
+        type: DashboardMetricType!
         title: String!
-        entries: [MetricSummaryEntry!]!
+        entries: [DashboardMetricSummaryEntry!]!
     }
-    enum MetricInterval {
-        Daily
-    }
-    enum MetricType {
+    enum DashboardMetricType {
         OrderCount
         OrderTotal
         AverageOrderValue
     }
-    type MetricSummaryEntry {
+    type DashboardMetricSummaryEntry {
         label: String!
         value: Float!
     }
-    input MetricSummaryInput {
-        interval: MetricInterval!
-        types: [MetricType!]!
+    input DashboardMetricSummaryInput {
+        types: [DashboardMetricType!]!
         refresh: Boolean
+        startDate: DateTime!
+        endDate: DateTime!
     }
     extend type Query {
         """
-        Get metrics for the given interval and metric types.
+        Get metrics for the given date range and metric types.
         """
-        metricSummary(input: MetricSummaryInput): [MetricSummary!]!
+        dashboardMetricSummary(input: DashboardMetricSummaryInput): [DashboardMetricSummary!]!
     }
 `;

+ 4 - 4
packages/dashboard/plugin/api/metrics.resolver.ts

@@ -2,7 +2,7 @@ import { Args, Query, Resolver } from '@nestjs/graphql';
 import { Allow, Ctx, Permission, RequestContext } from '@vendure/core';
 
 import { MetricsService } from '../service/metrics.service.js';
-import { MetricSummary, MetricSummaryInput } from '../types.js';
+import { DashboardMetricSummary, DashboardMetricSummaryInput } from '../types.js';
 
 @Resolver()
 export class MetricsResolver {
@@ -10,10 +10,10 @@ export class MetricsResolver {
 
     @Query()
     @Allow(Permission.ReadOrder)
-    async metricSummary(
+    async dashboardMetricSummary(
         @Ctx() ctx: RequestContext,
-        @Args('input') input: MetricSummaryInput,
-    ): Promise<MetricSummary[]> {
+        @Args('input') input: DashboardMetricSummaryInput,
+    ): Promise<DashboardMetricSummary[]> {
         return this.service.getMetrics(ctx, input);
     }
 }

+ 9 - 9
packages/dashboard/plugin/config/metrics-strategies.ts

@@ -1,7 +1,7 @@
 import { RequestContext } from '@vendure/core';
 
 import { MetricData } from '../service/metrics.service.js';
-import { MetricInterval, MetricSummaryEntry, MetricType } from '../types.js';
+import { DashboardMetricSummaryEntry, DashboardMetricType } from '../types.js';
 
 /**
  * Calculate your metric data based on the given input.
@@ -10,11 +10,11 @@ import { MetricInterval, MetricSummaryEntry, MetricType } from '../types.js';
  *
  */
 export interface MetricCalculation {
-    type: MetricType;
+    type: DashboardMetricType;
 
     getTitle(ctx: RequestContext): string;
 
-    calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry;
+    calculateEntry(ctx: RequestContext, data: MetricData): DashboardMetricSummaryEntry;
 }
 
 export function getMonthName(monthNr: number): string {
@@ -26,13 +26,13 @@ export function getMonthName(monthNr: number): string {
  * Calculates the average order value per month/week
  */
 export class AverageOrderValueMetric implements MetricCalculation {
-    readonly type = MetricType.AverageOrderValue;
+    readonly type = DashboardMetricType.AverageOrderValue;
 
     getTitle(ctx: RequestContext): string {
         return 'average-order-value';
     }
 
-    calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry {
+    calculateEntry(ctx: RequestContext, data: MetricData): DashboardMetricSummaryEntry {
         const label = data.date.toISOString();
         if (!data.orders.length) {
             return {
@@ -53,13 +53,13 @@ export class AverageOrderValueMetric implements MetricCalculation {
  * Calculates number of orders
  */
 export class OrderCountMetric implements MetricCalculation {
-    readonly type = MetricType.OrderCount;
+    readonly type = DashboardMetricType.OrderCount;
 
     getTitle(ctx: RequestContext): string {
         return 'order-count';
     }
 
-    calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry {
+    calculateEntry(ctx: RequestContext, data: MetricData): DashboardMetricSummaryEntry {
         const label = data.date.toISOString();
         return {
             label,
@@ -72,13 +72,13 @@ export class OrderCountMetric implements MetricCalculation {
  * Calculates order total
  */
 export class OrderTotalMetric implements MetricCalculation {
-    readonly type = MetricType.OrderTotal;
+    readonly type = DashboardMetricType.OrderTotal;
 
     getTitle(ctx: RequestContext): string {
         return 'order-totals';
     }
 
-    calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry {
+    calculateEntry(ctx: RequestContext, data: MetricData): DashboardMetricSummaryEntry {
         const label = data.date.toISOString();
         return {
             label,

+ 53 - 70
packages/dashboard/plugin/service/metrics.service.ts

@@ -1,17 +1,7 @@
 import { Injectable } from '@nestjs/common';
-import { assertNever } from '@vendure/common/lib/shared-utils';
 import { CacheService, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
 import { createHash } from 'crypto';
-import {
-    Duration,
-    endOfDay,
-    getDayOfYear,
-    getISOWeek,
-    getMonth,
-    setDayOfYear,
-    startOfDay,
-    sub,
-} from 'date-fns';
+import { endOfDay, startOfDay } from 'date-fns';
 
 import {
     AverageOrderValueMetric,
@@ -20,7 +10,11 @@ import {
     OrderTotalMetric,
 } from '../config/metrics-strategies.js';
 import { loggerCtx } from '../constants.js';
-import { MetricInterval, MetricSummary, MetricSummaryEntry, MetricSummaryInput } from '../types.js';
+import {
+    DashboardMetricSummary,
+    DashboardMetricSummaryEntry,
+    DashboardMetricSummaryInput,
+} from '../types.js';
 
 export type MetricData = {
     date: Date;
@@ -44,49 +38,48 @@ export class MetricsService {
 
     async getMetrics(
         ctx: RequestContext,
-        { interval, types, refresh }: MetricSummaryInput,
-    ): Promise<MetricSummary[]> {
-        // Set 23:59:59.999 as endDate
-        const endDate = endOfDay(new Date());
+        { types, refresh, startDate, endDate }: DashboardMetricSummaryInput,
+    ): Promise<DashboardMetricSummary[]> {
+        const calculatedStartDate = startOfDay(new Date(startDate));
+        const calculatedEndDate = endOfDay(new Date(endDate));
         // Check if we have cached result
         const hash = createHash('sha1')
             .update(
                 JSON.stringify({
-                    endDate,
+                    startDate: calculatedStartDate,
+                    endDate: calculatedEndDate,
                     types: types.sort(),
-                    interval,
                     channel: ctx.channel.token,
                 }),
             )
             .digest('base64');
         const cacheKey = `MetricsService:${hash}`;
-        const cachedMetricList = await this.cacheService.get<MetricSummary[]>(cacheKey);
+        const cachedMetricList = await this.cacheService.get<DashboardMetricSummary[]>(cacheKey);
         if (cachedMetricList && refresh !== true) {
             Logger.verbose(`Returning cached metrics for channel ${ctx.channel.token}`, loggerCtx);
             return cachedMetricList;
         }
         // No cache, calculating new metrics
         Logger.verbose(
-            `No cache hit, calculating ${interval} metrics until ${endDate.toISOString()} for channel ${
+            `No cache hit, calculating metrics from ${calculatedStartDate.toISOString()} to ${calculatedEndDate.toISOString()} for channel ${
                 ctx.channel.token
             } for all orders`,
             loggerCtx,
         );
-        const data = await this.loadData(ctx, interval, endDate);
-        const metrics: MetricSummary[] = [];
+        const data = await this.loadData(ctx, calculatedStartDate, calculatedEndDate);
+        const metrics: DashboardMetricSummary[] = [];
         for (const type of types) {
             const metric = this.metricCalculations.find(m => m.type === type);
             if (!metric) {
                 continue;
             }
-            // Calculate entry (month or week)
-            const entries: MetricSummaryEntry[] = [];
-            data.forEach(dataPerTick => {
-                entries.push(metric.calculateEntry(ctx, interval, dataPerTick));
+            // Calculate entries for each day
+            const entries: DashboardMetricSummaryEntry[] = [];
+            data.forEach(dataPerDay => {
+                entries.push(metric.calculateEntry(ctx, dataPerDay));
             });
             // Create metric with calculated entries
             metrics.push({
-                interval,
                 title: metric.getTitle(ctx),
                 type: metric.type,
                 entries,
@@ -96,30 +89,13 @@ export class MetricsService {
         return metrics;
     }
 
-    async loadData(
-        ctx: RequestContext,
-        interval: MetricInterval,
-        endDate: Date,
-    ): Promise<Map<number, MetricData>> {
-        let nrOfEntries: number;
-        let backInTimeAmount: Duration;
+    async loadData(ctx: RequestContext, startDate: Date, endDate: Date): Promise<Map<string, MetricData>> {
         const orderRepo = this.connection.getRepository(ctx, Order);
-        // What function to use to get the current Tick of a date (i.e. the week or month number)
-        let getTickNrFn: typeof getMonth | typeof getISOWeek;
-        let maxTick: number;
-        switch (interval) {
-            case MetricInterval.Daily: {
-                nrOfEntries = 30;
-                backInTimeAmount = { days: nrOfEntries };
-                getTickNrFn = getDayOfYear;
-                maxTick = 365;
-                break;
-            }
-            default:
-                assertNever(interval);
-        }
-        const startDate = startOfDay(sub(endDate, backInTimeAmount));
-        const startTick = getTickNrFn(startDate);
+
+        // Calculate number of days between start and end
+        const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
+        const nrOfDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
+
         // Get orders in a loop until we have all
         let skip = 0;
         const take = 1000;
@@ -133,6 +109,9 @@ export class MetricsService {
                 .andWhere('order.orderPlacedAt >= :startDate', {
                     startDate: startDate.toISOString(),
                 })
+                .andWhere('order.orderPlacedAt <= :endDate', {
+                    endDate: endDate.toISOString(),
+                })
                 .skip(skip)
                 .take(take);
             const [items, nrOfOrders] = await query.getManyAndCount();
@@ -140,7 +119,7 @@ export class MetricsService {
             Logger.verbose(
                 `Fetched orders ${skip}-${skip + take} for channel ${
                     ctx.channel.token
-                } for ${interval} metrics`,
+                } for date range metrics`,
                 loggerCtx,
             );
             skip += items.length;
@@ -149,27 +128,31 @@ export class MetricsService {
             }
         }
         Logger.verbose(
-            `Finished fetching all ${orders.length} orders for channel ${ctx.channel.token} for ${interval} metrics`,
+            `Finished fetching all ${orders.length} orders for channel ${ctx.channel.token} for date range metrics`,
             loggerCtx,
         );
-        const dataPerInterval = new Map<number, MetricData>();
-        const ticks = [];
-        for (let i = 1; i <= nrOfEntries; i++) {
-            if (startTick + i >= maxTick) {
-                // make sure we don't go over month 12 or week 52
-                ticks.push(startTick + i - maxTick);
-            } else {
-                ticks.push(startTick + i);
-            }
-        }
-        ticks.forEach(tick => {
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            const ordersInCurrentTick = orders.filter(order => getTickNrFn(order.orderPlacedAt!) === tick);
-            dataPerInterval.set(tick, {
-                orders: ordersInCurrentTick,
-                date: setDayOfYear(endDate, tick),
+
+        const dataPerDay = new Map<string, MetricData>();
+
+        // Create a map entry for each day in the range
+        for (let i = 0; i < nrOfDays; i++) {
+            const currentDate = new Date(startDate);
+            currentDate.setDate(startDate.getDate() + i);
+            const dateKey = currentDate.toISOString().split('T')[0]; // YYYY-MM-DD format
+
+            // Filter orders for this specific day
+            const ordersForDay = orders.filter(order => {
+                if (!order.orderPlacedAt) return false;
+                const orderDate = new Date(order.orderPlacedAt).toISOString().split('T')[0];
+                return orderDate === dateKey;
             });
-        });
-        return dataPerInterval;
+
+            dataPerDay.set(dateKey, {
+                orders: ordersForDay,
+                date: currentDate,
+            });
+        }
+
+        return dataPerDay;
     }
 }

+ 9 - 13
packages/dashboard/plugin/types.ts

@@ -1,27 +1,23 @@
-export type MetricSummary = {
-    interval: MetricInterval;
-    type: MetricType;
+export type DashboardMetricSummary = {
+    type: DashboardMetricType;
     title: string;
-    entries: MetricSummaryEntry[];
+    entries: DashboardMetricSummaryEntry[];
 };
 
-export enum MetricType {
+export enum DashboardMetricType {
     OrderCount = 'OrderCount',
     OrderTotal = 'OrderTotal',
     AverageOrderValue = 'AverageOrderValue',
 }
 
-export enum MetricInterval {
-    Daily = 'Daily',
-}
-
-export type MetricSummaryEntry = {
+export type DashboardMetricSummaryEntry = {
     label: string;
     value: number;
 };
 
-export interface MetricSummaryInput {
-    interval: MetricInterval;
-    types: MetricType[];
+export interface DashboardMetricSummaryInput {
+    types: DashboardMetricType[];
     refresh?: boolean;
+    startDate: string;
+    endDate: string;
 }

+ 41 - 24
packages/dashboard/src/app/routes/_authenticated/index.tsx

@@ -1,3 +1,4 @@
+import { DateRangePicker } from '@/vdb/components/date-range-picker.js';
 import { Button } from '@/vdb/components/ui/button.js';
 import type { GridLayout as GridLayoutType } from '@/vdb/components/ui/grid-layout.js';
 import { GridLayout } from '@/vdb/components/ui/grid-layout.js';
@@ -5,6 +6,10 @@ import {
     getDashboardWidget,
     getDashboardWidgetRegistry,
 } from '@/vdb/framework/dashboard-widget/widget-extensions.js';
+import {
+    DefinedDateRange,
+    WidgetFiltersProvider,
+} from '@/vdb/framework/dashboard-widget/widget-filters-context.js';
 import { DashboardWidgetInstance } from '@/vdb/framework/extension-api/types/widgets.js';
 import {
     FullWidthPageBlock,
@@ -16,7 +21,8 @@ import {
 } from '@/vdb/framework/layout-engine/page-layout.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { createFileRoute } from '@tanstack/react-router';
-import { useEffect, useState, useRef } from 'react';
+import { endOfDay, startOfMonth } from 'date-fns';
+import { useEffect, useRef, useState } from 'react';
 
 export const Route = createFileRoute('/_authenticated/')({
     component: DashboardPage,
@@ -67,12 +73,16 @@ function DashboardPage() {
     const [editMode, setEditMode] = useState(false);
     const [isInitialized, setIsInitialized] = useState(false);
     const prevEditModeRef = useRef(editMode);
+    const [dateRange, setDateRange] = useState<DefinedDateRange>({
+        from: startOfMonth(new Date()),
+        to: endOfDay(new Date()),
+    });
 
     const { settings, setWidgetLayout } = useUserSettings();
 
     useEffect(() => {
         const savedLayouts = settings.widgetLayout || {};
-        
+
         const initialWidgets = Array.from(getDashboardWidgetRegistry().entries()).reduce(
             (acc: DashboardWidgetInstance[], [id, widget]) => {
                 const defaultSize = {
@@ -88,7 +98,7 @@ function DashboardPage() {
 
                 // Check if we have a saved layout for this widget
                 const savedLayout = savedLayouts[id];
-                
+
                 const layout = {
                     w: savedLayout?.w ?? defaultSize.w,
                     h: savedLayout?.h ?? defaultSize.h,
@@ -125,7 +135,7 @@ function DashboardPage() {
         setWidgets(initialWidgets);
         setIsInitialized(true);
     }, [settings.widgetLayout]);
-    
+
     // Save layout when edit mode is turned off
     useEffect(() => {
         // Only save when transitioning from edit mode ON to OFF
@@ -141,7 +151,7 @@ function DashboardPage() {
             });
             setWidgetLayout(layoutConfig);
         }
-        
+
         // Update the ref for next render
         prevEditModeRef.current = editMode;
     }, [editMode, isInitialized, widgets, setWidgetLayout]);
@@ -168,11 +178,16 @@ function DashboardPage() {
             <PageTitle>Insights</PageTitle>
             <PageActionBar>
                 <PageActionBarRight>
-                    <Button 
-                        variant={editMode ? "default" : "outline"} 
+                    <DateRangePicker
+                        dateRange={dateRange}
+                        onDateRangeChange={setDateRange}
+                        className="mr-2"
+                    />
+                    <Button
+                        variant={editMode ? 'default' : 'outline'}
                         onClick={() => setEditMode(prev => !prev)}
                     >
-                        {editMode ? "Save Layout" : "Edit Layout"}
+                        {editMode ? 'Save Layout' : 'Edit Layout'}
                     </Button>
                 </PageActionBarRight>
             </PageActionBar>
@@ -180,22 +195,24 @@ function DashboardPage() {
                 <FullWidthPageBlock blockId="widgets">
                     <div className="w-full">
                         {widgets.length > 0 ? (
-                            <GridLayout
-                                layouts={widgets.map(w => ({ ...w.layout, i: w.id }))}
-                                onLayoutChange={handleLayoutChange}
-                                cols={12}
-                                rowHeight={100}
-                                isDraggable={editMode}
-                                isResizable={editMode}
-                                className="min-h-[400px]"
-                                gutter={10}
-                            >
-                                {
-                                    widgets
-                                        .map(widget => renderWidget(widget))
-                                        .filter(Boolean) as React.ReactElement[]
-                                }
-                            </GridLayout>
+                            <WidgetFiltersProvider filters={{ dateRange }}>
+                                <GridLayout
+                                    layouts={widgets.map(w => ({ ...w.layout, i: w.id }))}
+                                    onLayoutChange={handleLayoutChange}
+                                    cols={12}
+                                    rowHeight={100}
+                                    isDraggable={editMode}
+                                    isResizable={editMode}
+                                    className="min-h-[400px]"
+                                    gutter={10}
+                                >
+                                    {
+                                        widgets
+                                            .map(widget => renderWidget(widget))
+                                            .filter(Boolean) as React.ReactElement[]
+                                    }
+                                </GridLayout>
+                            </WidgetFiltersProvider>
                         ) : (
                             <div
                                 className="flex items-center justify-center text-muted-foreground"

+ 184 - 0
packages/dashboard/src/lib/components/date-range-picker.tsx

@@ -0,0 +1,184 @@
+'use client';
+
+import { Button } from '@/vdb/components/ui/button.js';
+import { Calendar } from '@/vdb/components/ui/calendar.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
+import { DefinedDateRange } from '@/vdb/framework/dashboard-widget/widget-filters-context.js';
+import { cn } from '@/vdb/lib/utils.js';
+import {
+    addDays,
+    endOfDay,
+    endOfMonth,
+    endOfWeek,
+    format,
+    startOfDay,
+    startOfMonth,
+    startOfWeek,
+    subDays
+} from 'date-fns';
+import { CalendarIcon } from 'lucide-react';
+import * as React from 'react';
+import { DateRange } from 'react-day-picker';
+
+interface DateRangePickerProps {
+    className?: string;
+    dateRange: DefinedDateRange;
+    onDateRangeChange: (range: DefinedDateRange) => void;
+}
+
+const presets = [
+    {
+        label: 'Today',
+        getValue: () => ({
+            from: startOfDay(new Date()),
+            to: endOfDay(new Date()),
+        }),
+    },
+    {
+        label: 'Yesterday',
+        getValue: () => ({
+            from: startOfDay(subDays(new Date(), 1)),
+            to: endOfDay(subDays(new Date(), 1)),
+        }),
+    },
+    {
+        label: 'Last 7 days',
+        getValue: () => ({
+            from: startOfDay(subDays(new Date(), 6)),
+            to: endOfDay(new Date()),
+        }),
+    },
+    {
+        label: 'This week',
+        getValue: () => ({
+            from: startOfWeek(new Date(), { weekStartsOn: 1 }),
+            to: endOfWeek(new Date(), { weekStartsOn: 1 }),
+        }),
+    },
+    {
+        label: 'Last 30 days',
+        getValue: () => ({
+            from: startOfDay(subDays(new Date(), 29)),
+            to: endOfDay(new Date()),
+        }),
+    },
+    {
+        label: 'Month to date',
+        getValue: () => ({
+            from: startOfMonth(new Date()),
+            to: endOfDay(new Date()),
+        }),
+    },
+    {
+        label: 'This month',
+        getValue: () => ({
+            from: startOfMonth(new Date()),
+            to: endOfMonth(new Date()),
+        }),
+    },
+    {
+        label: 'Last month',
+        getValue: () => ({
+            from: startOfMonth(subDays(new Date(), 30)),
+            to: endOfMonth(subDays(new Date(), 30)),
+        }),
+    },
+];
+
+export function DateRangePicker({ className, dateRange, onDateRangeChange }: DateRangePickerProps) {
+    const [open, setOpen] = React.useState(false);
+    // Internal state uses react-day-picker's DateRange type for Calendar compatibility
+    const [selectedRange, setSelectedRange] = React.useState<DateRange>({
+        from: dateRange.from,
+        to: dateRange.to
+    });
+
+    React.useEffect(() => {
+        setSelectedRange({
+            from: dateRange.from,
+            to: dateRange.to
+        });
+    }, [dateRange]);
+
+    const handleSelect = (range: DateRange | undefined) => {
+        if (range?.from) {
+            // If no end date is selected, use the from date as the end date
+            const to = range.to || range.from;
+            const finalRange: DefinedDateRange = {
+                from: range.from,
+                to: to
+            };
+            setSelectedRange({ from: range.from, to });
+            onDateRangeChange(finalRange);
+        }
+    };
+
+    const handlePresetClick = (preset: typeof presets[number]) => {
+        const range = preset.getValue();
+        handleSelect(range);
+        setOpen(false);
+    };
+
+    const formatDateRange = () => {
+        if (!selectedRange.from) {
+            return 'Select date range';
+        }
+        if (!selectedRange.to || selectedRange.from === selectedRange.to) {
+            return format(selectedRange.from, 'MMM dd, yyyy');
+        }
+        return `${format(selectedRange.from, 'MMM dd, yyyy')} - ${format(selectedRange.to, 'MMM dd, yyyy')}`;
+    };
+
+    const isPresetActive = (preset: typeof presets[number]) => {
+        if (!selectedRange.from || !selectedRange.to) return false;
+        const presetRange = preset.getValue();
+        return (
+            selectedRange.from.getTime() === presetRange.from.getTime() &&
+            selectedRange.to.getTime() === presetRange.to.getTime()
+        );
+    };
+
+    return (
+        <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+                <Button
+                    variant="outline"
+                    className={cn(
+                        'w-[280px] justify-start text-left font-normal',
+                        className
+                    )}
+                >
+                    <CalendarIcon className="mr-2 h-4 w-4" />
+                    {formatDateRange()}
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="w-auto p-0" align="start">
+                <div className="flex">
+                    <div className="border-r p-2 space-y-0.5 min-w-0 w-32">
+                        {presets.map((preset) => (
+                            <Button
+                                key={preset.label}
+                                variant={isPresetActive(preset) ? 'default' : 'ghost'}
+                                size="sm"
+                                className="w-full justify-start font-normal text-xs h-7 px-2"
+                                onClick={() => handlePresetClick(preset)}
+                            >
+                                {preset.label}
+                            </Button>
+                        ))}
+                    </div>
+                    <div className="p-3">
+                        <Calendar
+                            mode="range"
+                            defaultMonth={selectedRange?.from}
+                            selected={selectedRange}
+                            onSelect={handleSelect}
+                            numberOfMonths={2}
+                            showOutsideDays={false}
+                        />
+                    </div>
+                </div>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 29 - 2
packages/dashboard/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx

@@ -9,13 +9,15 @@ import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
 import { Link } from '@tanstack/react-router';
 import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
 import { formatRelative } from 'date-fns';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
 import { DashboardBaseWidget } from '../base-widget.js';
+import { useWidgetFilters } from '../widget-filters-context.js';
 import { latestOrdersQuery } from './latest-orders-widget.graphql.js';
 
 export const WIDGET_ID = 'latest-orders-widget';
 
 export function LatestOrdersWidget() {
+    const { dateRange } = useWidgetFilters();
     const [sorting, setSorting] = useState<SortingState>([
         {
             id: 'orderPlacedAt',
@@ -24,9 +26,34 @@ export function LatestOrdersWidget() {
     ]);
     const [page, setPage] = useState(1);
     const [pageSize, setPageSize] = useState(10);
-    const [filters, setFilters] = useState<ColumnFiltersState>([]);
+    const [filters, setFilters] = useState<ColumnFiltersState>([
+        {
+            id: 'orderPlacedAt',
+            value: {
+                between: {
+                    start: dateRange.from.toISOString(),
+                    end: dateRange.to.toISOString(),
+                },
+            },
+        },
+    ]);
     const { formatCurrency } = useLocalFormat();
 
+    // Update filters when date range changes
+    useEffect(() => {
+        setFilters([
+            {
+                id: 'orderPlacedAt',
+                value: {
+                    between: {
+                        start: dateRange.from.toISOString(),
+                        end: dateRange.to.toISOString(),
+                    },
+                },
+            },
+        ]);
+    }, [dateRange]);
+
     return (
         <DashboardBaseWidget id={WIDGET_ID} title="Latest Orders" description="Your latest orders">
             <PaginatedListDataTable

+ 10 - 7
packages/dashboard/src/lib/framework/dashboard-widget/metrics-widget/index.tsx

@@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query';
 import { RefreshCw } from 'lucide-react';
 import { useMemo, useState } from 'react';
 import { DashboardBaseWidget } from '../base-widget.js';
+import { useWidgetFilters } from '../widget-filters-context.js';
 import { MetricsChart } from './chart.js';
 import { orderChartDataQuery } from './metrics-widget.graphql.js';
 
@@ -19,37 +20,39 @@ enum DATA_TYPES {
 export function MetricsWidget() {
     const { formatDate, formatCurrency } = useLocalFormat();
     const { activeChannel } = useChannel();
+    const { dateRange } = useWidgetFilters();
     const [dataType, setDataType] = useState<DATA_TYPES>(DATA_TYPES.OrderTotal);
 
-    const { data, isRefetching, refetch } = useQuery({
-        queryKey: ['dashboard-order-metrics', dataType],
+    const { data, refetch, isRefetching } = useQuery({
+        queryKey: ['dashboard-order-metrics', dataType, dateRange],
         queryFn: () => {
             return api.query(orderChartDataQuery, {
                 types: [dataType],
                 refresh: true,
+                startDate: dateRange.from.toISOString(),
+                endDate: dateRange.to.toISOString(),
             });
         },
     });
 
     const chartData = useMemo(() => {
-        const entry = data?.metricSummary.at(0);
+        const entry = data?.dashboardMetricSummary.at(0);
         if (!entry) {
             return undefined;
         }
 
-        const { interval, type, entries } = entry;
+        const { type, entries } = entry;
 
-        const values = entries.map(({ label, value }) => ({
+        const values = entries.map(({ label, value }: { label: string; value: number }) => ({
             name: formatDate(label, { month: 'short', day: 'numeric' }),
             sales: value,
         }));
 
         return {
             values,
-            interval,
             type,
         };
-    }, [data]);
+    }, [data, formatDate]);
 
     return (
         <DashboardBaseWidget

+ 9 - 3
packages/dashboard/src/lib/framework/dashboard-widget/metrics-widget/metrics-widget.graphql.ts

@@ -1,9 +1,15 @@
 import { graphql } from '@/vdb/graphql/graphql.js';
 
 export const orderChartDataQuery = graphql(`
-    query GetOrderChartData($refresh: Boolean, $types: [MetricType!]!) {
-        metricSummary(input: { interval: Daily, types: $types, refresh: $refresh }) {
-            interval
+    query GetOrderChartData(
+        $refresh: Boolean
+        $types: [DashboardMetricType!]!
+        $startDate: DateTime!
+        $endDate: DateTime!
+    ) {
+        dashboardMetricSummary(
+            input: { types: $types, refresh: $refresh, startDate: $startDate, endDate: $endDate }
+        ) {
             type
             entries {
                 label

+ 19 - 75
packages/dashboard/src/lib/framework/dashboard-widget/orders-summary/index.tsx

@@ -1,21 +1,14 @@
 import { AnimatedCurrency, AnimatedNumber } from '@/vdb/components/shared/animated-number.js';
-import { Tabs, TabsList, TabsTrigger } from '@/vdb/components/ui/tabs.js';
 import { api } from '@/vdb/graphql/api.js';
 import { useQuery } from '@tanstack/react-query';
-import { endOfDay, endOfMonth, startOfDay, startOfMonth, subDays, subMonths } from 'date-fns';
-import { useMemo, useState } from 'react';
+import { differenceInDays, subDays } from 'date-fns';
+import { useMemo } from 'react';
 import { DashboardBaseWidget } from '../base-widget.js';
+import { useWidgetFilters } from '../widget-filters-context.js';
 import { orderSummaryQuery } from './order-summary-widget.graphql.js';
 
 const WIDGET_ID = 'orders-summary-widget';
 
-enum Range {
-    Today = 'today',
-    Yesterday = 'yesterday',
-    ThisWeek = 'thisWeek',
-    ThisMonth = 'thisMonth',
-}
-
 interface PercentageChangeProps {
     value: number;
 }
@@ -34,63 +27,24 @@ function PercentageChange({ value }: PercentageChangeProps) {
 }
 
 export function OrdersSummaryWidget() {
-    const [range, setRange] = useState<Range>(Range.Today);
+    const { dateRange } = useWidgetFilters();
 
     const variables = useMemo(() => {
-        const now = new Date();
-
-        switch (range) {
-            case Range.Today: {
-                const today = now;
-                const yesterday = subDays(now, 1);
-
-                return {
-                    start: startOfDay(today).toISOString(),
-                    end: endOfDay(today).toISOString(),
-                    previousStart: startOfDay(yesterday).toISOString(),
-                    previousEnd: endOfDay(yesterday).toISOString(),
-                };
-            }
-            case Range.Yesterday: {
-                const yesterday = subDays(now, 1);
-                const dayBeforeYesterday = subDays(now, 2);
-
-                return {
-                    start: startOfDay(yesterday).toISOString(),
-                    end: endOfDay(yesterday).toISOString(),
-                    previousStart: startOfDay(dayBeforeYesterday).toISOString(),
-                    previousEnd: endOfDay(dayBeforeYesterday).toISOString(),
-                };
-            }
-            case Range.ThisWeek: {
-                const today = now;
-                const sixDaysAgo = subDays(now, 6);
-                const sevenDaysAgo = subDays(now, 7);
-                const thirteenDaysAgo = subDays(now, 13);
-
-                return {
-                    start: startOfDay(sixDaysAgo).toISOString(),
-                    end: endOfDay(today).toISOString(),
-                    previousStart: startOfDay(thirteenDaysAgo).toISOString(),
-                    previousEnd: endOfDay(sevenDaysAgo).toISOString(),
-                };
-            }
-            case Range.ThisMonth: {
-                const lastMonth = subMonths(now, 1);
-                const twoMonthsAgo = subMonths(now, 2);
-
-                return {
-                    start: startOfMonth(lastMonth).toISOString(),
-                    end: endOfMonth(lastMonth).toISOString(),
-                    previousStart: startOfMonth(twoMonthsAgo).toISOString(),
-                    previousEnd: endOfMonth(twoMonthsAgo).toISOString(),
-                };
-            }
-        }
-    }, [range]);
+        const rangeLength = differenceInDays(dateRange.to, dateRange.from) + 1;
+        // For the previous period, we go back by the same range length
+        const previousStart = subDays(dateRange.from, rangeLength);
+        const previousEnd = subDays(dateRange.to, rangeLength);
+
+        return {
+            start: dateRange.from.toISOString(),
+            end: dateRange.to.toISOString(),
+            previousStart: previousStart.toISOString(),
+            previousEnd: previousEnd.toISOString(),
+        };
+    }, [dateRange]);
 
     const { data } = useQuery({
-        queryKey: ['orders-summary', range],
+        queryKey: ['orders-summary', dateRange],
         queryFn: () =>
             api.query(orderSummaryQuery, {
                 start: variables.start,
@@ -99,7 +53,7 @@ export function OrdersSummaryWidget() {
     });
 
     const { data: previousData } = useQuery({
-        queryKey: ['orders-summary', 'previous', range],
+        queryKey: ['orders-summary', 'previous', dateRange],
         queryFn: () =>
             api.query(orderSummaryQuery, {
                 start: variables.previousStart,
@@ -126,16 +80,6 @@ export function OrdersSummaryWidget() {
             id={WIDGET_ID}
             title="Orders Summary"
             description="Your orders summary"
-            actions={
-                <Tabs defaultValue={range} onValueChange={value => setRange(value as Range)}>
-                    <TabsList>
-                        <TabsTrigger value={Range.Today}>Today</TabsTrigger>
-                        <TabsTrigger value={Range.Yesterday}>Yesterday</TabsTrigger>
-                        <TabsTrigger value={Range.ThisWeek}>This Week</TabsTrigger>
-                        <TabsTrigger value={Range.ThisMonth}>This Month</TabsTrigger>
-                    </TabsList>
-                </Tabs>
-            }
         >
             <div className="@container h-full">
                 <div className="flex flex-col h-full @md:flex-row gap-8 items-center justify-center @md:justify-evenly text-center tabular-nums">
@@ -163,4 +107,4 @@ export function OrdersSummaryWidget() {
             </div>
         </DashboardBaseWidget>
     );
-}
+}

+ 33 - 0
packages/dashboard/src/lib/framework/dashboard-widget/widget-filters-context.tsx

@@ -0,0 +1,33 @@
+'use client';
+
+import { createContext, useContext, PropsWithChildren } from 'react';
+
+export interface DefinedDateRange {
+    from: Date;
+    to: Date;
+}
+
+export interface WidgetFilters {
+    dateRange: DefinedDateRange;
+}
+
+const WidgetFiltersContext = createContext<WidgetFilters | undefined>(undefined);
+
+export function WidgetFiltersProvider({
+    children,
+    filters
+}: PropsWithChildren<{ filters: WidgetFilters }>) {
+    return (
+        <WidgetFiltersContext.Provider value={filters}>
+            {children}
+        </WidgetFiltersContext.Provider>
+    );
+}
+
+export function useWidgetFilters() {
+    const context = useContext(WidgetFiltersContext);
+    if (context === undefined) {
+        throw new Error('useWidgetFilters must be used within a WidgetFiltersProvider');
+    }
+    return context;
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 4 - 5
packages/dashboard/src/lib/graphql/graphql-env.d.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 2
packages/dev-server/graphql/graphql-env.d.ts


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio