changes: - mock up dashbaord
- Datetimepicker - Edit dashboard options - Dashaord creation modal - Networks selection dorpdown
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
268
src/SplashPage.Web.Ui/src/components/ui/date-range-picker.tsx
Normal file
268
src/SplashPage.Web.Ui/src/components/ui/date-range-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user