changes: - mock up dashbaord

- Datetimepicker
- Edit dashboard options
- Dashaord creation modal
- Networks selection dorpdown
This commit is contained in:
2025-10-19 16:32:54 -06:00
parent 16980d86de
commit b035dd705d
8 changed files with 2336 additions and 3 deletions

View File

@@ -0,0 +1,423 @@
/**
* CreateDashboardDialog Component
* Modal dialog for creating a new dashboard with optional template copy
*/
'use client';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, PlusCircle, Copy, Sparkles } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
createDashboardSchema,
type CreateDashboardFormValues,
} from './schemas/dashboard-schemas';
import type { NetworkGroup, DashboardListItem } from '../_types/dashboard.types';
import { toast } from 'sonner';
export interface CreateDashboardDialogProps {
/**
* Whether the dialog is open
*/
open: boolean;
/**
* Callback when dialog open state changes
*/
onOpenChange: (open: boolean) => void;
/**
* Available network groups to select from
*/
networkGroups: NetworkGroup[];
/**
* Available dashboards to copy from (templates)
*/
availableDashboards?: DashboardListItem[];
/**
* Callback when dashboard is successfully created
*/
onSuccess?: (values: CreateDashboardFormValues) => void;
/**
* Whether the form is currently submitting
*/
isSubmitting?: boolean;
}
export function CreateDashboardDialog({
open,
onOpenChange,
networkGroups,
availableDashboards = [],
onSuccess,
isSubmitting = false,
}: CreateDashboardDialogProps) {
const [showCopyOptions, setShowCopyOptions] = React.useState(false);
const form = useForm<CreateDashboardFormValues>({
resolver: zodResolver(createDashboardSchema),
defaultValues: {
name: '',
selectedNetworkGroups: [],
copyFrom: undefined,
},
});
// Reset form when dialog opens/closes
React.useEffect(() => {
if (!open) {
form.reset();
setShowCopyOptions(false);
}
}, [open, form]);
const onSubmit = async (values: CreateDashboardFormValues) => {
try {
// TODO: Replace with actual API call
// const newDashboard = await createDashboard(values);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
const copyFromDashboard = availableDashboards.find(
(d) => d.id === values.copyFrom
);
toast.success('Dashboard creado exitosamente', {
description: values.copyFrom
? `Copiado desde "${copyFromDashboard?.name}"`
: 'Dashboard creado desde cero',
});
// Simulate redirect (in real app, would navigate to new dashboard)
console.log('Dashboard created:', values);
console.log('TODO: Redirect to new dashboard page');
onSuccess?.(values);
onOpenChange(false);
} catch (error) {
toast.error('Error al crear el dashboard', {
description: 'Por favor, intenta de nuevo más tarde',
});
console.error('Error creating dashboard:', error);
}
};
const handleCancel = () => {
form.reset();
setShowCopyOptions(false);
onOpenChange(false);
};
const selectedGroupsCount = form.watch('selectedNetworkGroups')?.length || 0;
const copyFromValue = form.watch('copyFrom');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<PlusCircle className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>Nuevo Dashboard</DialogTitle>
<DialogDescription>
Crea un nuevo dashboard personalizado
</DialogDescription>
</div>
</div>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex-1 overflow-hidden flex flex-col"
>
<div className="flex-1 overflow-y-auto space-y-6 px-1">
{/* Dashboard Name */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium mb-3">
Información General
</h3>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Nombre del Dashboard{' '}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Ej: Dashboard Ejecutivo Q1 2025"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription>
Un nombre descriptivo y único para tu dashboard
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
{/* Copy from Template */}
{availableDashboards.length > 0 && (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">
Copiar desde Dashboard Existente
</h3>
<Badge
variant="secondary"
className="gap-1 text-xs h-5"
>
<Sparkles className="h-3 w-3" />
Opcional
</Badge>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCopyOptions(!showCopyOptions)}
>
{showCopyOptions ? 'Ocultar' : 'Mostrar'}
</Button>
</div>
{showCopyOptions && (
<FormField
control={form.control}
name="copyFrom"
render={({ field }) => (
<FormItem>
<FormLabel>Dashboard Template</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isSubmitting}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Seleccionar dashboard para copiar..." />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableDashboards.map((dashboard) => (
<SelectItem
key={dashboard.id}
value={dashboard.id}
>
<div className="flex items-center gap-2">
<Copy className="h-3 w-3" />
<span>{dashboard.name}</span>
<span className="text-xs text-muted-foreground">
({dashboard.widgetCount} widgets)
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{copyFromValue
? 'Se copiarán todos los widgets y configuración del dashboard seleccionado'
: 'Puedes crear un dashboard vacío o copiar la configuración de uno existente'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<Separator />
</>
)}
{/* Network Groups Selection */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">Grupos de Redes</h3>
<p className="text-sm text-muted-foreground mt-1">
Selecciona los grupos de redes para este dashboard
</p>
</div>
{selectedGroupsCount > 0 && (
<Badge variant="secondary" className="ml-2">
{selectedGroupsCount}{' '}
{selectedGroupsCount === 1
? 'seleccionado'
: 'seleccionados'}
</Badge>
)}
</div>
<FormField
control={form.control}
name="selectedNetworkGroups"
render={() => (
<FormItem>
<ScrollArea className="h-[240px] rounded-md border p-4">
<div className="space-y-3">
{networkGroups.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No hay grupos de redes disponibles
</div>
) : (
networkGroups.map((group) => (
<FormField
key={group.id}
control={form.control}
name="selectedNetworkGroups"
render={({ field }) => {
const isChecked = field.value?.includes(
group.id
);
return (
<FormItem
key={group.id}
className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border p-4 hover:bg-accent/50 transition-colors"
>
<FormControl>
<Checkbox
checked={isChecked}
disabled={isSubmitting}
onCheckedChange={(checked) => {
const currentValue =
field.value || [];
const newValue = checked
? [...currentValue, group.id]
: currentValue.filter(
(id) => id !== group.id
);
field.onChange(newValue);
}}
/>
</FormControl>
<div className="flex-1 space-y-1">
<FormLabel className="text-sm font-medium cursor-pointer">
{group.name}
</FormLabel>
{group.description && (
<p className="text-xs text-muted-foreground">
{group.description}
</p>
)}
{group.networkCount !== undefined && (
<div className="flex items-center gap-1 pt-1">
<Badge
variant="outline"
className="text-xs"
>
{group.networkCount}{' '}
{group.networkCount === 1
? 'red'
: 'redes'}
</Badge>
</div>
)}
</div>
</FormItem>
);
}}
/>
))
)}
</div>
</ScrollArea>
<FormDescription className="mt-2">
{selectedGroupsCount === 0 ? (
<span className="text-amber-600 dark:text-amber-500">
Si no seleccionas ningún grupo, se mostrarán
todas las redes
</span>
) : (
`Las redes se filtrarán según los grupos seleccionados`
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator className="my-4" />
<DialogFooter className="flex-shrink-0">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creando...
</>
) : (
<>
<PlusCircle className="mr-2 h-4 w-4" />
Crear Dashboard
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,326 @@
/**
* EditDashboardDialog Component
* Modal dialog for editing dashboard name and associated network groups
*/
'use client';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Save, Settings2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
editDashboardSchema,
type EditDashboardFormValues,
} from './schemas/dashboard-schemas';
import type { Dashboard, NetworkGroup } from '../_types/dashboard.types';
import { toast } from 'sonner';
export interface EditDashboardDialogProps {
/**
* Whether the dialog is open
*/
open: boolean;
/**
* Callback when dialog open state changes
*/
onOpenChange: (open: boolean) => void;
/**
* Current dashboard data to edit
*/
dashboard: Dashboard;
/**
* Available network groups to select from
*/
networkGroups: NetworkGroup[];
/**
* Callback when dashboard is successfully updated
*/
onSuccess?: (values: EditDashboardFormValues) => void;
/**
* Whether the form is currently submitting
*/
isSubmitting?: boolean;
}
export function EditDashboardDialog({
open,
onOpenChange,
dashboard,
networkGroups,
onSuccess,
isSubmitting = false,
}: EditDashboardDialogProps) {
const form = useForm<EditDashboardFormValues>({
resolver: zodResolver(editDashboardSchema),
defaultValues: {
name: dashboard.name,
selectedNetworkGroups: dashboard.selectedNetworkGroups,
},
});
// Reset form when dialog opens with new dashboard data
React.useEffect(() => {
if (open) {
form.reset({
name: dashboard.name,
selectedNetworkGroups: dashboard.selectedNetworkGroups,
});
}
}, [open, dashboard, form]);
const onSubmit = async (values: EditDashboardFormValues) => {
try {
// TODO: Replace with actual API call
// await updateDashboard({ id: dashboard.id, ...values });
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('Dashboard actualizado exitosamente', {
description: `"${values.name}" ha sido actualizado`,
});
onSuccess?.(values);
onOpenChange(false);
} catch (error) {
toast.error('Error al actualizar el dashboard', {
description: 'Por favor, intenta de nuevo más tarde',
});
console.error('Error updating dashboard:', error);
}
};
const handleCancel = () => {
form.reset();
onOpenChange(false);
};
const selectedGroupsCount = form.watch('selectedNetworkGroups')?.length || 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Settings2 className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>Editar Dashboard</DialogTitle>
<DialogDescription>
Modifica el nombre y los grupos de redes asociados
</DialogDescription>
</div>
</div>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex-1 overflow-hidden flex flex-col"
>
<div className="flex-1 overflow-y-auto space-y-6 px-1">
{/* Dashboard Name */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium mb-3">
Información General
</h3>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Nombre del Dashboard{' '}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Ej: Dashboard Ejecutivo"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription>
Un nombre descriptivo para identificar este dashboard
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
{/* Network Groups Selection */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">Grupos de Redes</h3>
<p className="text-sm text-muted-foreground mt-1">
Selecciona los grupos de redes para este dashboard
</p>
</div>
{selectedGroupsCount > 0 && (
<Badge variant="secondary" className="ml-2">
{selectedGroupsCount}{' '}
{selectedGroupsCount === 1
? 'seleccionado'
: 'seleccionados'}
</Badge>
)}
</div>
<FormField
control={form.control}
name="selectedNetworkGroups"
render={() => (
<FormItem>
<ScrollArea className="h-[240px] rounded-md border p-4">
<div className="space-y-3">
{networkGroups.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No hay grupos de redes disponibles
</div>
) : (
networkGroups.map((group) => (
<FormField
key={group.id}
control={form.control}
name="selectedNetworkGroups"
render={({ field }) => {
const isChecked = field.value?.includes(
group.id
);
return (
<FormItem
key={group.id}
className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border p-4 hover:bg-accent/50 transition-colors"
>
<FormControl>
<Checkbox
checked={isChecked}
disabled={isSubmitting}
onCheckedChange={(checked) => {
const currentValue =
field.value || [];
const newValue = checked
? [...currentValue, group.id]
: currentValue.filter(
(id) => id !== group.id
);
field.onChange(newValue);
}}
/>
</FormControl>
<div className="flex-1 space-y-1">
<FormLabel className="text-sm font-medium cursor-pointer">
{group.name}
</FormLabel>
{group.description && (
<p className="text-xs text-muted-foreground">
{group.description}
</p>
)}
{group.networkCount !== undefined && (
<div className="flex items-center gap-1 pt-1">
<Badge
variant="outline"
className="text-xs"
>
{group.networkCount}{' '}
{group.networkCount === 1
? 'red'
: 'redes'}
</Badge>
</div>
)}
</div>
</FormItem>
);
}}
/>
))
)}
</div>
</ScrollArea>
<FormDescription className="mt-2">
{selectedGroupsCount === 0 ? (
<span className="text-amber-600 dark:text-amber-500">
Si no seleccionas ningún grupo, se mostrarán
todas las redes
</span>
) : (
`Las redes se filtrarán según los grupos seleccionados`
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator className="my-4" />
<DialogFooter className="flex-shrink-0">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Guardando...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Guardar Cambios
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,460 @@
/**
* NetworkMultiSelect Component
* Multi-select dropdown for network selection with badge display
* Features: Search, "All" option, condensed display, badge pills
*/
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown, X, Building2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { Network } from '../_types/dashboard.types';
export interface NetworkMultiSelectProps {
/**
* Available networks to select from
*/
networks: Network[];
/**
* Currently selected network IDs
*/
selectedNetworkIds: number[];
/**
* Callback when selection changes
*/
onSelectionChange: (networkIds: number[]) => void;
/**
* Placeholder text when no networks selected
*/
placeholder?: string;
/**
* Whether the select is disabled
*/
disabled?: boolean;
/**
* Whether to show the "All" option
*/
showAllOption?: boolean;
/**
* Loading state
*/
isLoading?: boolean;
/**
* Additional CSS classes
*/
className?: string;
/**
* Maximum number of badges to show before condensing
*/
maxDisplayBadges?: number;
}
const ALL_NETWORKS_ID = 0;
export function NetworkMultiSelect({
networks,
selectedNetworkIds,
onSelectionChange,
placeholder = 'Seleccionar sucursales',
disabled = false,
showAllOption = true,
isLoading = false,
className,
maxDisplayBadges = 1,
}: NetworkMultiSelectProps) {
const [open, setOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
// Check if "All" is selected
const isAllSelected = selectedNetworkIds.includes(ALL_NETWORKS_ID);
// Get selected networks for display
const selectedNetworks = React.useMemo(() => {
if (isAllSelected) {
return [{ id: ALL_NETWORKS_ID, name: 'Todas las Sucursales' }];
}
return networks.filter((network) =>
selectedNetworkIds.includes(network.id)
);
}, [networks, selectedNetworkIds, isAllSelected]);
// Filter networks by search query
const filteredNetworks = React.useMemo(() => {
if (!searchQuery) return networks;
const query = searchQuery.toLowerCase();
return networks.filter(
(network) =>
network.name.toLowerCase().includes(query) ||
network.location?.toLowerCase().includes(query)
);
}, [networks, searchQuery]);
// Group networks by status for better organization
const activeNetworks = filteredNetworks.filter(
(n) => n.status === 'active'
);
const maintenanceNetworks = filteredNetworks.filter(
(n) => n.status === 'maintenance'
);
const inactiveNetworks = filteredNetworks.filter(
(n) => n.status === 'inactive'
);
const handleSelectAll = () => {
if (isAllSelected) {
onSelectionChange([]);
} else {
onSelectionChange([ALL_NETWORKS_ID]);
}
};
const handleToggleNetwork = (networkId: number) => {
// If selecting "All", replace all selections
if (networkId === ALL_NETWORKS_ID) {
handleSelectAll();
return;
}
// If "All" is currently selected, deselect it
if (isAllSelected) {
onSelectionChange([networkId]);
return;
}
// Toggle individual network
const newSelection = selectedNetworkIds.includes(networkId)
? selectedNetworkIds.filter((id) => id !== networkId)
: [...selectedNetworkIds, networkId];
onSelectionChange(newSelection);
};
const handleRemoveNetwork = (networkId: number, e: React.MouseEvent) => {
e.stopPropagation();
if (networkId === ALL_NETWORKS_ID) {
onSelectionChange([]);
return;
}
const newSelection = selectedNetworkIds.filter((id) => id !== networkId);
onSelectionChange(newSelection);
};
const handleClearAll = (e: React.MouseEvent) => {
e.stopPropagation();
onSelectionChange([]);
};
// Format display text
const getDisplayText = () => {
if (selectedNetworks.length === 0) {
return placeholder;
}
if (isAllSelected) {
return 'Todas las Sucursales';
}
if (selectedNetworks.length === 1) {
return selectedNetworks[0].name;
}
return `${selectedNetworks.length} Sucursales`;
};
const displayBadges = selectedNetworks.slice(0, maxDisplayBadges);
const remainingCount = selectedNetworks.length - maxDisplayBadges;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || isLoading}
className={cn(
'justify-between min-w-[280px] h-auto min-h-[40px]',
selectedNetworks.length === 0 && 'text-muted-foreground',
className
)}
>
<div className="flex items-center gap-1 flex-1 flex-wrap">
<Building2 className="mr-2 h-4 w-4 shrink-0" />
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</span>
) : selectedNetworks.length > 0 ? (
<div className="flex items-center gap-1 flex-wrap">
{displayBadges.map((network) => (
<Badge
key={network.id}
variant="secondary"
className="pl-2 pr-1 py-0.5 text-xs font-normal"
>
{network.name}
{!disabled && (
<button
className="ml-1 rounded-sm opacity-70 hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
onClick={(e) => handleRemoveNetwork(network.id, e)}
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
))}
{remainingCount > 0 && (
<Badge variant="outline" className="text-xs font-normal">
+{remainingCount}
</Badge>
)}
</div>
) : (
<span>{placeholder}</span>
)}
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
{selectedNetworks.length > 0 && !disabled && !isLoading && (
<button
onClick={handleClearAll}
className="rounded-sm opacity-70 hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
>
<X className="h-4 w-4" />
</button>
)}
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Buscar sucursal..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>No se encontraron sucursales.</CommandEmpty>
{/* All Networks Option */}
{showAllOption && !searchQuery && (
<>
<CommandGroup>
<CommandItem
value="all-networks"
onSelect={handleSelectAll}
className="cursor-pointer"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isAllSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50'
)}
>
{isAllSelected && <Check className="h-4 w-4" />}
</div>
<span className="font-medium">Todas las Sucursales</span>
<span className="ml-auto text-xs text-muted-foreground">
{networks.length}
</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
</>
)}
{/* Active Networks */}
{activeNetworks.length > 0 && (
<CommandGroup heading="Activas">
<ScrollArea className="max-h-[200px]">
{activeNetworks.map((network) => {
const isSelected = selectedNetworkIds.includes(network.id);
return (
<CommandItem
key={network.id}
value={`${network.id}-${network.name}`}
onSelect={() => handleToggleNetwork(network.id)}
className="cursor-pointer"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50'
)}
>
{isSelected && <Check className="h-4 w-4" />}
</div>
<div className="flex-1">
<div className="text-sm font-medium">
{network.name}
</div>
{network.location && (
<div className="text-xs text-muted-foreground">
{network.location}
</div>
)}
</div>
<div className="ml-2 flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500" />
</div>
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
)}
{/* Maintenance Networks */}
{maintenanceNetworks.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="En Mantenimiento">
{maintenanceNetworks.map((network) => {
const isSelected = selectedNetworkIds.includes(network.id);
return (
<CommandItem
key={network.id}
value={`${network.id}-${network.name}`}
onSelect={() => handleToggleNetwork(network.id)}
className="cursor-pointer opacity-75"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50'
)}
>
{isSelected && <Check className="h-4 w-4" />}
</div>
<div className="flex-1">
<div className="text-sm font-medium">
{network.name}
</div>
{network.location && (
<div className="text-xs text-muted-foreground">
{network.location}
</div>
)}
</div>
<div className="ml-2 flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-yellow-500" />
</div>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{/* Inactive Networks */}
{inactiveNetworks.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Inactivas">
{inactiveNetworks.map((network) => {
const isSelected = selectedNetworkIds.includes(network.id);
return (
<CommandItem
key={network.id}
value={`${network.id}-${network.name}`}
onSelect={() => handleToggleNetwork(network.id)}
className="cursor-pointer opacity-50"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50'
)}
>
{isSelected && <Check className="h-4 w-4" />}
</div>
<div className="flex-1">
<div className="text-sm font-medium">
{network.name}
</div>
{network.location && (
<div className="text-xs text-muted-foreground">
{network.location}
</div>
)}
</div>
<div className="ml-2 flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-gray-400" />
</div>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
{/* Footer */}
{selectedNetworks.length > 0 && (
<>
<Separator />
<div className="flex items-center justify-between p-2 text-xs text-muted-foreground">
<span>
{selectedNetworks.length}{' '}
{selectedNetworks.length === 1 ? 'sucursal' : 'sucursales'}{' '}
{selectedNetworks.length === 1
? 'seleccionada'
: 'seleccionadas'}
</span>
{!disabled && (
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 text-xs"
>
Limpiar todo
</Button>
)}
</div>
</>
)}
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,41 @@
/**
* Dashboard Form Schemas
* Zod schemas for dashboard create/edit forms
*/
import { z } from 'zod';
/**
* Edit Dashboard Form Schema
*/
export const editDashboardSchema = z.object({
name: z
.string()
.min(3, 'El nombre debe tener al menos 3 caracteres')
.max(100, 'El nombre no puede exceder 100 caracteres')
.trim(),
selectedNetworkGroups: z
.array(z.number())
.optional()
.default([]),
});
export type EditDashboardFormValues = z.infer<typeof editDashboardSchema>;
/**
* Create Dashboard Form Schema
*/
export const createDashboardSchema = z.object({
name: z
.string()
.min(3, 'El nombre debe tener al menos 3 caracteres')
.max(100, 'El nombre no puede exceder 100 caracteres')
.trim(),
selectedNetworkGroups: z
.array(z.number())
.optional()
.default([]),
copyFrom: z.string().optional(),
});
export type CreateDashboardFormValues = z.infer<typeof createDashboardSchema>;

View File

@@ -0,0 +1,545 @@
/**
* Dashboard Mock Data
* Mock data for development and testing
*/
import {
NetworkGroup,
Network,
Dashboard,
DashboardListItem,
DateRangePreset,
} from '../_types/dashboard.types';
import {
startOfDay,
endOfDay,
subDays,
startOfMonth,
endOfMonth,
subMonths,
startOfYear,
} from 'date-fns';
// ============================================================================
// NETWORK GROUPS MOCK DATA
// ============================================================================
export const mockNetworkGroups: NetworkGroup[] = [
{
id: 1,
name: 'Zona Norte',
description: 'Sucursales de la región norte',
networkCount: 8,
},
{
id: 2,
name: 'Zona Sur',
description: 'Sucursales de la región sur',
networkCount: 6,
},
{
id: 3,
name: 'Zona Centro',
description: 'Sucursales de la región centro',
networkCount: 12,
},
{
id: 4,
name: 'Zona Este',
description: 'Sucursales de la región este',
networkCount: 5,
},
{
id: 5,
name: 'Zona Oeste',
description: 'Sucursales de la región oeste',
networkCount: 7,
},
];
// ============================================================================
// NETWORKS MOCK DATA
// ============================================================================
export const mockNetworks: Network[] = [
// Zona Norte (id: 1)
{
id: 101,
name: 'Sucursal Monterrey Centro',
networkGroupId: 1,
selected: true,
location: 'Monterrey, NL',
status: 'active',
},
{
id: 102,
name: 'Sucursal San Pedro',
networkGroupId: 1,
selected: true,
location: 'San Pedro, NL',
status: 'active',
},
{
id: 103,
name: 'Sucursal Guadalupe',
networkGroupId: 1,
selected: false,
location: 'Guadalupe, NL',
status: 'active',
},
{
id: 104,
name: 'Sucursal Apodaca',
networkGroupId: 1,
selected: false,
location: 'Apodaca, NL',
status: 'maintenance',
},
{
id: 105,
name: 'Sucursal Santa Catarina',
networkGroupId: 1,
selected: false,
location: 'Santa Catarina, NL',
status: 'active',
},
{
id: 106,
name: 'Sucursal Escobedo',
networkGroupId: 1,
selected: false,
location: 'Escobedo, NL',
status: 'active',
},
{
id: 107,
name: 'Sucursal García',
networkGroupId: 1,
selected: false,
location: 'García, NL',
status: 'active',
},
{
id: 108,
name: 'Sucursal Juárez',
networkGroupId: 1,
selected: false,
location: 'Juárez, NL',
status: 'inactive',
},
// Zona Sur (id: 2)
{
id: 201,
name: 'Sucursal Cancún Plaza',
networkGroupId: 2,
selected: false,
location: 'Cancún, QR',
status: 'active',
},
{
id: 202,
name: 'Sucursal Playa del Carmen',
networkGroupId: 2,
selected: false,
location: 'Playa del Carmen, QR',
status: 'active',
},
{
id: 203,
name: 'Sucursal Tulum',
networkGroupId: 2,
selected: false,
location: 'Tulum, QR',
status: 'active',
},
{
id: 204,
name: 'Sucursal Mérida Centro',
networkGroupId: 2,
selected: false,
location: 'Mérida, YUC',
status: 'active',
},
{
id: 205,
name: 'Sucursal Chetumal',
networkGroupId: 2,
selected: false,
location: 'Chetumal, QR',
status: 'maintenance',
},
{
id: 206,
name: 'Sucursal Campeche',
networkGroupId: 2,
selected: false,
location: 'Campeche, CAM',
status: 'active',
},
// Zona Centro (id: 3)
{
id: 301,
name: 'Sucursal CDMX Polanco',
networkGroupId: 3,
selected: false,
location: 'Polanco, CDMX',
status: 'active',
},
{
id: 302,
name: 'Sucursal CDMX Roma',
networkGroupId: 3,
selected: false,
location: 'Roma, CDMX',
status: 'active',
},
{
id: 303,
name: 'Sucursal CDMX Condesa',
networkGroupId: 3,
selected: false,
location: 'Condesa, CDMX',
status: 'active',
},
{
id: 304,
name: 'Sucursal CDMX Santa Fe',
networkGroupId: 3,
selected: false,
location: 'Santa Fe, CDMX',
status: 'active',
},
{
id: 305,
name: 'Sucursal CDMX Insurgentes',
networkGroupId: 3,
selected: false,
location: 'Insurgentes, CDMX',
status: 'active',
},
{
id: 306,
name: 'Sucursal Querétaro Centro',
networkGroupId: 3,
selected: false,
location: 'Querétaro, QRO',
status: 'active',
},
{
id: 307,
name: 'Sucursal San Luis Potosí',
networkGroupId: 3,
selected: false,
location: 'San Luis Potosí, SLP',
status: 'active',
},
{
id: 308,
name: 'Sucursal Aguascalientes',
networkGroupId: 3,
selected: false,
location: 'Aguascalientes, AGS',
status: 'active',
},
{
id: 309,
name: 'Sucursal Toluca',
networkGroupId: 3,
selected: false,
location: 'Toluca, MEX',
status: 'maintenance',
},
{
id: 310,
name: 'Sucursal Pachuca',
networkGroupId: 3,
selected: false,
location: 'Pachuca, HGO',
status: 'active',
},
{
id: 311,
name: 'Sucursal Cuernavaca',
networkGroupId: 3,
selected: false,
location: 'Cuernavaca, MOR',
status: 'active',
},
{
id: 312,
name: 'Sucursal Puebla Centro',
networkGroupId: 3,
selected: false,
location: 'Puebla, PUE',
status: 'active',
},
// Zona Este (id: 4)
{
id: 401,
name: 'Sucursal Veracruz Puerto',
networkGroupId: 4,
selected: false,
location: 'Veracruz, VER',
status: 'active',
},
{
id: 402,
name: 'Sucursal Xalapa',
networkGroupId: 4,
selected: false,
location: 'Xalapa, VER',
status: 'active',
},
{
id: 403,
name: 'Sucursal Tampico',
networkGroupId: 4,
selected: false,
location: 'Tampico, TAM',
status: 'active',
},
{
id: 404,
name: 'Sucursal Reynosa',
networkGroupId: 4,
selected: false,
location: 'Reynosa, TAM',
status: 'active',
},
{
id: 405,
name: 'Sucursal Matamoros',
networkGroupId: 4,
selected: false,
location: 'Matamoros, TAM',
status: 'inactive',
},
// Zona Oeste (id: 5)
{
id: 501,
name: 'Sucursal Guadalajara Centro',
networkGroupId: 5,
selected: false,
location: 'Guadalajara, JAL',
status: 'active',
},
{
id: 502,
name: 'Sucursal Zapopan',
networkGroupId: 5,
selected: false,
location: 'Zapopan, JAL',
status: 'active',
},
{
id: 503,
name: 'Sucursal Tijuana',
networkGroupId: 5,
selected: false,
location: 'Tijuana, BC',
status: 'active',
},
{
id: 504,
name: 'Sucursal Mexicali',
networkGroupId: 5,
selected: false,
location: 'Mexicali, BC',
status: 'active',
},
{
id: 505,
name: 'Sucursal Hermosillo',
networkGroupId: 5,
selected: false,
location: 'Hermosillo, SON',
status: 'active',
},
{
id: 506,
name: 'Sucursal Culiacán',
networkGroupId: 5,
selected: false,
location: 'Culiacán, SIN',
status: 'maintenance',
},
{
id: 507,
name: 'Sucursal Mazatlán',
networkGroupId: 5,
selected: false,
location: 'Mazatlán, SIN',
status: 'active',
},
];
// ============================================================================
// DATE RANGE PRESETS
// ============================================================================
export const dateRangePresets: DateRangePreset[] = [
{
label: 'Hoy',
getValue: () => ({
from: startOfDay(new Date()),
to: endOfDay(new Date()),
}),
},
{
label: 'Ayer',
getValue: () => ({
from: startOfDay(subDays(new Date(), 1)),
to: endOfDay(subDays(new Date(), 1)),
}),
},
{
label: 'Últimos 7 Días',
getValue: () => ({
from: startOfDay(subDays(new Date(), 6)),
to: endOfDay(new Date()),
}),
},
{
label: 'Últimos 30 Días',
getValue: () => ({
from: startOfDay(subDays(new Date(), 29)),
to: endOfDay(new Date()),
}),
},
{
label: 'Este Mes',
getValue: () => ({
from: startOfMonth(new Date()),
to: endOfMonth(new Date()),
}),
},
{
label: 'Último Mes',
getValue: () => ({
from: startOfMonth(subMonths(new Date(), 1)),
to: endOfMonth(subMonths(new Date(), 1)),
}),
},
{
label: 'Year to Date',
getValue: () => ({
from: startOfYear(new Date()),
to: endOfDay(new Date()),
}),
},
];
// ============================================================================
// DASHBOARD MOCK DATA
// ============================================================================
export const mockCurrentDashboard: Dashboard = {
id: 'dash-001',
name: 'Dashboard Principal',
startDate: subDays(new Date(), 7),
endDate: new Date(),
selectedNetworks: [101, 102], // Monterrey Centro, San Pedro
selectedNetworkGroups: [1], // Zona Norte
widgets: [
{ i: '1', type: 'kpi' },
{ i: '2', type: 'bar-chart' },
{ i: '3', type: 'clock' },
{ i: '4', type: 'area-chart' },
{ i: '5', type: 'pie-chart' },
],
createdAt: new Date('2024-01-15'),
updatedAt: new Date(),
};
export const mockDashboardList: DashboardListItem[] = [
{
id: 'dash-001',
name: 'Dashboard Principal',
networkGroupCount: 1,
widgetCount: 5,
lastModified: new Date(),
},
{
id: 'dash-002',
name: 'Dashboard Ejecutivo',
networkGroupCount: 3,
widgetCount: 8,
lastModified: subDays(new Date(), 2),
},
{
id: 'dash-003',
name: 'Dashboard Operaciones',
networkGroupCount: 2,
widgetCount: 6,
lastModified: subDays(new Date(), 5),
},
{
id: 'dash-004',
name: 'Dashboard Regional Norte',
networkGroupCount: 1,
widgetCount: 4,
lastModified: subDays(new Date(), 10),
},
{
id: 'dash-005',
name: 'Dashboard Analytics',
networkGroupCount: 5,
widgetCount: 12,
lastModified: subDays(new Date(), 15),
},
];
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Get networks filtered by network group IDs
*/
export function getNetworksByGroups(groupIds: number[]): Network[] {
if (groupIds.length === 0) {
return mockNetworks;
}
return mockNetworks.filter((network) =>
groupIds.includes(network.networkGroupId)
);
}
/**
* Get selected networks
*/
export function getSelectedNetworks(): Network[] {
return mockNetworks.filter((network) => network.selected);
}
/**
* Toggle network selection
*/
export function toggleNetworkSelection(
networks: Network[],
networkId: number
): Network[] {
return networks.map((network) =>
network.id === networkId
? { ...network, selected: !network.selected }
: network
);
}
/**
* Select all networks
*/
export function selectAllNetworks(networks: Network[]): Network[] {
return networks.map((network) => ({ ...network, selected: true }));
}
/**
* Deselect all networks
*/
export function deselectAllNetworks(networks: Network[]): Network[] {
return networks.map((network) => ({ ...network, selected: false }));
}

View File

@@ -0,0 +1,83 @@
/**
* Dashboard Types
* Type definitions for Dynamic Dashboard feature
*/
export interface Dashboard {
id: string;
name: string;
startDate: Date;
endDate: Date;
selectedNetworks: number[];
selectedNetworkGroups: number[];
widgets: DashboardWidget[];
createdAt?: Date;
updatedAt?: Date;
}
export interface DashboardWidget {
i: string;
type: string;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
}
export interface NetworkGroup {
id: number;
name: string;
description?: string;
networkCount?: number;
}
export interface Network {
id: number;
name: string;
networkGroupId: number;
selected: boolean;
location?: string;
status?: 'active' | 'inactive' | 'maintenance';
}
export interface DateRangePreset {
label: string;
getValue: () => { from: Date; to: Date };
}
export interface DashboardFilters {
dateRange: {
from: Date;
to: Date;
};
selectedNetworks: number[];
}
// Form types for create/edit dialogs
export interface CreateDashboardFormValues {
name: string;
selectedNetworkGroups: number[];
copyFrom?: string; // Optional: dashboard ID to copy from
}
export interface EditDashboardFormValues {
name: string;
selectedNetworkGroups: number[];
}
// Widget data types (for future API integration)
export interface WidgetDataResponse {
widgetId: string;
data: any;
lastUpdated: Date;
error?: string;
}
// Dashboard list item (for dashboard selector/management)
export interface DashboardListItem {
id: string;
name: string;
networkGroupCount: number;
widgetCount: number;
lastModified: Date;
}

View File

@@ -2,8 +2,19 @@
import { useState, useEffect } from 'react';
import { Responsive, WidthProvider, Layout as GridLayout } from 'react-grid-layout';
import { Plus, Copy, Trash2, Edit3, Lock } from 'lucide-react';
import { Plus, Copy, Trash2, Edit3, Lock, Settings2 } from 'lucide-react';
import { DateRange } from 'react-day-picker';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Sheet,
SheetContent,
@@ -12,16 +23,29 @@ import {
SheetTitle,
SheetTrigger
} from '@/components/ui/sheet';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import {
WidgetCatalogue,
widgetTypes,
type WidgetType
} from './_components/WidgetCatalogue';
import { NetworkMultiSelect } from './_components/NetworkMultiSelect';
import { EditDashboardDialog } from './_components/EditDashboardDialog';
import { CreateDashboardDialog } from './_components/CreateDashboardDialog';
import { KPIWidget } from './_components/widgets/KPIWidget';
import { BarChartWidget } from './_components/widgets/BarChartWidget';
import { AreaChartWidget } from './_components/widgets/AreaChartWidget';
import { PieChartWidget } from './_components/widgets/PieChartWidget';
import { ClockWidget } from './_components/widgets/ClockWidget';
import {
dateRangePresets,
mockNetworks,
mockCurrentDashboard,
mockNetworkGroups,
mockDashboardList,
} from './_mocks/dashboard-mocks';
import type { Dashboard } from './_types/dashboard.types';
import type { CreateDashboardFormValues } from './_components/schemas/dashboard-schemas';
import { toast } from 'sonner';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
@@ -74,6 +98,24 @@ export default function DynamicDashboard() {
const [mounted, setMounted] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
// Date range state - default to last 7 days
const [dateRange, setDateRange] = useState<DateRange | undefined>(() => {
const preset = dateRangePresets.find(p => p.label === 'Últimos 7 Días');
return preset?.getValue();
});
// Network selection state - default to currently selected networks from mock
const [selectedNetworkIds, setSelectedNetworkIds] = useState<number[]>(() => {
return mockCurrentDashboard.selectedNetworks;
});
// Dashboard state - for editing
const [currentDashboard, setCurrentDashboard] = useState<Dashboard>(mockCurrentDashboard);
// Dialog states
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const initializeDefaultLayout = () => {
const defaultWidgets: Widget[] = [
{ i: '1', type: 'kpi' },
@@ -260,6 +302,78 @@ export default function DynamicDashboard() {
setLayouts(allLayouts);
};
// Handle date range change
const handleDateRangeChange = (range: DateRange | undefined) => {
setDateRange(range);
if (range?.from && range?.to) {
const formattedFrom = format(range.from, 'PPP', { locale: es });
const formattedTo = format(range.to, 'PPP', { locale: es });
toast.success(`Rango actualizado: ${formattedFrom} - ${formattedTo}`);
// TODO: Here you would trigger widget data refresh with new date range
console.log('Date range changed:', {
from: range.from,
to: range.to,
});
}
};
// Convert date range presets to the format expected by DateRangePicker
const formattedPresets = dateRangePresets.map(preset => ({
label: preset.label,
value: preset.getValue(),
}));
// Handle network selection change
const handleNetworkSelectionChange = (networkIds: number[]) => {
setSelectedNetworkIds(networkIds);
// Show toast notification
if (networkIds.length === 0) {
toast.warning('Debe seleccionar al menos una sucursal');
return;
}
if (networkIds.includes(0)) {
// "All" selected
toast.success('Mostrando todas las sucursales');
} else {
const count = networkIds.length;
toast.success(
`Filtro actualizado: ${count} ${count === 1 ? 'sucursal' : 'sucursales'}`
);
}
// TODO: Here you would trigger widget data refresh with new network selection
console.log('Network selection changed:', networkIds);
};
// Handle edit dashboard success
const handleEditDashboardSuccess = (values: { name: string; selectedNetworkGroups?: number[] }) => {
// Update current dashboard
setCurrentDashboard({
...currentDashboard,
name: values.name,
selectedNetworkGroups: values.selectedNetworkGroups || [],
});
// TODO: Here you would update the dashboard via API
console.log('Dashboard updated:', values);
};
// Handle create dashboard success
const handleCreateDashboardSuccess = (values: CreateDashboardFormValues) => {
// TODO: In real app, would redirect to new dashboard
console.log('Dashboard created:', values);
console.log('TODO: Navigate to /dashboard/dynamicDashboard?id=NEW_ID');
// For now, just show success state
toast.success('Redirigiendo al nuevo dashboard...', {
description: 'Esta funcionalidad se completará con la integración del backend',
});
};
if (!mounted) {
return null; // Prevent SSR mismatch
}
@@ -297,14 +411,69 @@ export default function DynamicDashboard() {
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Dynamic Dashboard</h2>
<h2 className="text-2xl font-bold tracking-tight">{currentDashboard.name}</h2>
<p className="text-sm text-muted-foreground">
{isEditMode
? 'Drag, resize & customize your widgets'
: 'View your dashboard'}
</p>
</div>
<div className="flex gap-2">
<div className="flex items-center gap-2">
{/* Date Range Picker */}
<DateRangePicker
value={dateRange}
onChange={handleDateRangeChange}
presets={formattedPresets}
placeholder="Seleccionar período"
numberOfMonths={2}
showClearButton={true}
/>
{/* Network Multi-Select */}
<NetworkMultiSelect
networks={mockNetworks}
selectedNetworkIds={selectedNetworkIds}
onSelectionChange={handleNetworkSelectionChange}
placeholder="Seleccionar sucursales"
showAllOption={true}
maxDisplayBadges={1}
/>
{/* Settings Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Settings2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Opciones del Dashboard</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
<Settings2 className="mr-2 h-4 w-4" />
Editar Dashboard
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsEditMode(!isEditMode)}>
{isEditMode ? (
<>
<Lock className="mr-2 h-4 w-4" />
Bloquear Layout
</>
) : (
<>
<Edit3 className="mr-2 h-4 w-4" />
Editar Layout
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Nuevo Dashboard
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={isEditMode ? 'default' : 'outline'}
onClick={() => setIsEditMode(!isEditMode)}
@@ -346,6 +515,24 @@ export default function DynamicDashboard() {
</div>
</div>
{/* Edit Dashboard Dialog */}
<EditDashboardDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
dashboard={currentDashboard}
networkGroups={mockNetworkGroups}
onSuccess={handleEditDashboardSuccess}
/>
{/* Create Dashboard Dialog */}
<CreateDashboardDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
networkGroups={mockNetworkGroups}
availableDashboards={mockDashboardList}
onSuccess={handleCreateDashboardSuccess}
/>
{/* Dashboard Content */}
<div className="flex-1">
{widgets.length === 0 ? (

View File

@@ -0,0 +1,268 @@
/**
* DateRangePicker Component
* Enterprise-grade date range picker with presets
* Supports dual calendar view and predefined ranges
*/
'use client';
import * as React from 'react';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import { CalendarIcon, X } from 'lucide-react';
import { DateRange } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
export interface DateRangePickerProps {
/**
* Currently selected date range
*/
value?: DateRange;
/**
* Callback when date range changes
*/
onChange?: (range: DateRange | undefined) => void;
/**
* Date range presets to display
*/
presets?: Array<{
label: string;
value: DateRange;
}>;
/**
* Placeholder text when no range is selected
*/
placeholder?: string;
/**
* Format string for displaying dates
*/
dateFormat?: string;
/**
* Whether the picker is disabled
*/
disabled?: boolean;
/**
* Additional CSS classes
*/
className?: string;
/**
* Minimum selectable date
*/
minDate?: Date;
/**
* Maximum selectable date
*/
maxDate?: Date;
/**
* Number of months to display
*/
numberOfMonths?: number;
/**
* Whether to show a clear button
*/
showClearButton?: boolean;
/**
* Whether to align the popover to the right
*/
align?: 'start' | 'center' | 'end';
}
export function DateRangePicker({
value,
onChange,
presets = [],
placeholder = 'Seleccionar rango de fechas',
dateFormat = 'MMM d, yyyy',
disabled = false,
className,
minDate,
maxDate,
numberOfMonths = 2,
showClearButton = true,
align = 'end',
}: DateRangePickerProps) {
const [open, setOpen] = React.useState(false);
const [selectedRange, setSelectedRange] = React.useState<DateRange | undefined>(value);
// Sync internal state with prop changes
React.useEffect(() => {
setSelectedRange(value);
}, [value]);
const handleSelect = (range: DateRange | undefined) => {
setSelectedRange(range);
// Only trigger onChange when we have a complete range
if (range?.from && range?.to) {
onChange?.(range);
}
};
const handlePresetClick = (presetRange: DateRange) => {
setSelectedRange(presetRange);
onChange?.(presetRange);
setOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
setSelectedRange(undefined);
onChange?.(undefined);
};
const formatDateRange = () => {
if (!selectedRange?.from) {
return placeholder;
}
if (!selectedRange.to) {
return format(selectedRange.from, dateFormat, { locale: es });
}
return `${format(selectedRange.from, dateFormat, { locale: es })} - ${format(selectedRange.to, dateFormat, { locale: es })}`;
};
const hasSelection = selectedRange?.from && selectedRange?.to;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className={cn(
'justify-start text-left font-normal min-w-[280px]',
!hasSelection && 'text-muted-foreground',
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
<span className="flex-1 truncate">{formatDateRange()}</span>
{showClearButton && hasSelection && !disabled && (
<X
className="ml-2 h-4 w-4 opacity-50 hover:opacity-100 transition-opacity"
onClick={handleClear}
/>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align={align}
sideOffset={4}
>
<div className="flex">
{/* Presets Sidebar */}
{presets.length > 0 && (
<>
<div className="flex flex-col gap-1 p-3 pr-0">
<div className="text-xs font-medium text-muted-foreground mb-1 px-2">
Rangos rápidos
</div>
<div className="flex flex-col gap-0.5">
{presets.map((preset, index) => (
<Button
key={index}
variant="ghost"
size="sm"
className={cn(
'justify-start font-normal text-xs h-8',
selectedRange?.from &&
selectedRange?.to &&
preset.value.from &&
preset.value.to &&
selectedRange.from.getTime() === preset.value.from.getTime() &&
selectedRange.to.getTime() === preset.value.to.getTime() &&
'bg-accent'
)}
onClick={() => handlePresetClick(preset.value)}
>
{preset.label}
</Button>
))}
</div>
</div>
<Separator orientation="vertical" className="h-auto" />
</>
)}
{/* Calendar(s) */}
<div className="p-3">
<Calendar
mode="range"
selected={selectedRange}
onSelect={handleSelect}
numberOfMonths={numberOfMonths}
disabled={(date) => {
if (minDate && date < minDate) return true;
if (maxDate && date > maxDate) return true;
return false;
}}
locale={es}
initialFocus
/>
</div>
</div>
{/* Footer with action buttons */}
{hasSelection && (
<>
<Separator />
<div className="flex items-center justify-between p-3 pt-2">
<div className="text-xs text-muted-foreground">
{selectedRange.from && selectedRange.to && (
<>
{Math.ceil(
(selectedRange.to.getTime() - selectedRange.from.getTime()) /
(1000 * 60 * 60 * 24)
) + 1}{' '}
días seleccionados
</>
)}
</div>
<div className="flex gap-2">
{showClearButton && (
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="h-7 text-xs"
>
Limpiar
</Button>
)}
<Button
size="sm"
onClick={() => setOpen(false)}
className="h-7 text-xs"
>
Aplicar
</Button>
</div>
</div>
</>
)}
</PopoverContent>
</Popover>
);
}