From 357c3cf1f93349dd11573d888dda21081fd6a83b Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 19:26:08 -0600 Subject: [PATCH] feat(ux-ui): add Calendar, Kanban, Chart, CommandPalette, OnboardingTour, DashboardWidgets Co-Authored-By: Claude Opus 4.5 --- .../organisms/Calendar/Calendar.tsx | 123 ++++++ .../organisms/Calendar/CalendarEvent.tsx | 32 ++ .../organisms/Calendar/CalendarGrid.tsx | 154 ++++++++ .../organisms/Calendar/CalendarHeader.tsx | 91 +++++ .../components/organisms/Calendar/index.ts | 5 + .../components/organisms/Calendar/types.ts | 58 +++ .../components/organisms/Chart/AreaChart.tsx | 90 +++++ .../components/organisms/Chart/BarChart.tsx | 81 ++++ .../organisms/Chart/ChartContainer.tsx | 21 ++ .../organisms/Chart/ChartLegend.tsx | 35 ++ .../organisms/Chart/ChartTooltip.tsx | 38 ++ .../components/organisms/Chart/LineChart.tsx | 81 ++++ .../components/organisms/Chart/PieChart.tsx | 93 +++++ .../components/organisms/Chart/index.ts | 63 ++++ .../components/organisms/Chart/types.ts | 105 ++++++ .../CommandPalette/CommandPalette.tsx | 324 ++++++++++++++++ .../CommandPalette/CommandPaletteGroup.tsx | 44 +++ .../CommandPalette/CommandPaletteItem.tsx | 99 +++++ .../CommandPalette/CommandPaletteProvider.tsx | 350 ++++++++++++++++++ .../CommandPaletteWithRouter.tsx | 34 ++ .../organisms/CommandPalette/index.ts | 22 ++ .../organisms/CommandPalette/types.ts | 101 +++++ .../CommandPalette/useCommandPalette.ts | 195 ++++++++++ .../DashboardWidgets/DashboardGrid.tsx | 113 ++++++ .../DashboardWidgets/PerformanceChart.tsx | 117 ++++++ .../DashboardWidgets/QuickActions.tsx | 187 ++++++++++ .../DashboardWidgets/RecentActivity.tsx | 211 +++++++++++ .../organisms/DashboardWidgets/StatCard.tsx | 148 ++++++++ .../DashboardWidgets/UpcomingTasks.tsx | 281 ++++++++++++++ .../DashboardWidgets/WidgetContainer.tsx | 126 +++++++ .../organisms/DashboardWidgets/index.ts | 25 ++ .../organisms/DashboardWidgets/types.ts | 160 ++++++++ .../components/organisms/Kanban/Kanban.tsx | 171 +++++++++ .../organisms/Kanban/KanbanAddCard.tsx | 108 ++++++ .../organisms/Kanban/KanbanCard.tsx | 118 ++++++ .../organisms/Kanban/KanbanColumn.tsx | 215 +++++++++++ .../components/organisms/Kanban/index.ts | 14 + .../components/organisms/Kanban/types.ts | 138 +++++++ .../components/organisms/Modal/Modal.tsx | 1 + .../OnboardingTour/OnboardingTour.tsx | 77 ++++ .../organisms/OnboardingTour/TourProvider.tsx | 192 ++++++++++ .../organisms/OnboardingTour/TourStep.tsx | 160 ++++++++ .../organisms/OnboardingTour/TourTooltip.tsx | 203 ++++++++++ .../organisms/OnboardingTour/index.ts | 6 + .../organisms/OnboardingTour/types.ts | 68 ++++ .../organisms/OnboardingTour/useTour.ts | 56 +++ src/shared/components/organisms/index.ts | 4 + 47 files changed, 5138 insertions(+) create mode 100644 src/shared/components/organisms/Calendar/Calendar.tsx create mode 100644 src/shared/components/organisms/Calendar/CalendarEvent.tsx create mode 100644 src/shared/components/organisms/Calendar/CalendarGrid.tsx create mode 100644 src/shared/components/organisms/Calendar/CalendarHeader.tsx create mode 100644 src/shared/components/organisms/Calendar/index.ts create mode 100644 src/shared/components/organisms/Calendar/types.ts create mode 100644 src/shared/components/organisms/Chart/AreaChart.tsx create mode 100644 src/shared/components/organisms/Chart/BarChart.tsx create mode 100644 src/shared/components/organisms/Chart/ChartContainer.tsx create mode 100644 src/shared/components/organisms/Chart/ChartLegend.tsx create mode 100644 src/shared/components/organisms/Chart/ChartTooltip.tsx create mode 100644 src/shared/components/organisms/Chart/LineChart.tsx create mode 100644 src/shared/components/organisms/Chart/PieChart.tsx create mode 100644 src/shared/components/organisms/Chart/index.ts create mode 100644 src/shared/components/organisms/Chart/types.ts create mode 100644 src/shared/components/organisms/CommandPalette/CommandPalette.tsx create mode 100644 src/shared/components/organisms/CommandPalette/CommandPaletteGroup.tsx create mode 100644 src/shared/components/organisms/CommandPalette/CommandPaletteItem.tsx create mode 100644 src/shared/components/organisms/CommandPalette/CommandPaletteProvider.tsx create mode 100644 src/shared/components/organisms/CommandPalette/CommandPaletteWithRouter.tsx create mode 100644 src/shared/components/organisms/CommandPalette/index.ts create mode 100644 src/shared/components/organisms/CommandPalette/types.ts create mode 100644 src/shared/components/organisms/CommandPalette/useCommandPalette.ts create mode 100644 src/shared/components/organisms/DashboardWidgets/DashboardGrid.tsx create mode 100644 src/shared/components/organisms/DashboardWidgets/PerformanceChart.tsx create mode 100644 src/shared/components/organisms/DashboardWidgets/QuickActions.tsx create mode 100644 src/shared/components/organisms/DashboardWidgets/RecentActivity.tsx create mode 100644 src/shared/components/organisms/DashboardWidgets/StatCard.tsx create mode 100644 src/shared/components/organisms/DashboardWidgets/UpcomingTasks.tsx create mode 100644 src/shared/components/organisms/DashboardWidgets/WidgetContainer.tsx create mode 100644 src/shared/components/organisms/DashboardWidgets/index.ts create mode 100644 src/shared/components/organisms/DashboardWidgets/types.ts create mode 100644 src/shared/components/organisms/Kanban/Kanban.tsx create mode 100644 src/shared/components/organisms/Kanban/KanbanAddCard.tsx create mode 100644 src/shared/components/organisms/Kanban/KanbanCard.tsx create mode 100644 src/shared/components/organisms/Kanban/KanbanColumn.tsx create mode 100644 src/shared/components/organisms/Kanban/index.ts create mode 100644 src/shared/components/organisms/Kanban/types.ts create mode 100644 src/shared/components/organisms/OnboardingTour/OnboardingTour.tsx create mode 100644 src/shared/components/organisms/OnboardingTour/TourProvider.tsx create mode 100644 src/shared/components/organisms/OnboardingTour/TourStep.tsx create mode 100644 src/shared/components/organisms/OnboardingTour/TourTooltip.tsx create mode 100644 src/shared/components/organisms/OnboardingTour/index.ts create mode 100644 src/shared/components/organisms/OnboardingTour/types.ts create mode 100644 src/shared/components/organisms/OnboardingTour/useTour.ts diff --git a/src/shared/components/organisms/Calendar/Calendar.tsx b/src/shared/components/organisms/Calendar/Calendar.tsx new file mode 100644 index 0000000..fa88b3d --- /dev/null +++ b/src/shared/components/organisms/Calendar/Calendar.tsx @@ -0,0 +1,123 @@ +import { useState, useCallback } from 'react'; +import { cn } from '@utils/cn'; +import { CalendarHeader } from './CalendarHeader'; +import { CalendarGrid } from './CalendarGrid'; +import type { CalendarProps, CalendarView } from './types'; + +export function Calendar({ + events, + view: controlledView, + onViewChange, + onEventClick, + onDateClick, + onEventDrop: _onEventDrop, + selectedDate: controlledSelectedDate, + onSelectedDateChange, + locale = 'es-ES', + className, +}: CalendarProps) { + const [internalView, setInternalView] = useState('month'); + const [internalSelectedDate, setInternalSelectedDate] = useState(new Date()); + const [currentDate, setCurrentDate] = useState(new Date()); + + const view = controlledView ?? internalView; + const selectedDate = controlledSelectedDate ?? internalSelectedDate; + + const handleViewChange = useCallback( + (newView: CalendarView) => { + if (onViewChange) { + onViewChange(newView); + } else { + setInternalView(newView); + } + }, + [onViewChange] + ); + + const handleSelectedDateChange = useCallback( + (date: Date) => { + if (onSelectedDateChange) { + onSelectedDateChange(date); + } else { + setInternalSelectedDate(date); + } + onDateClick?.(date); + }, + [onSelectedDateChange, onDateClick] + ); + + const handlePrevious = useCallback(() => { + setCurrentDate((prev) => { + const newDate = new Date(prev); + if (view === 'month') { + newDate.setMonth(newDate.getMonth() - 1); + } else if (view === 'week') { + newDate.setDate(newDate.getDate() - 7); + } else { + newDate.setDate(newDate.getDate() - 1); + } + return newDate; + }); + }, [view]); + + const handleNext = useCallback(() => { + setCurrentDate((prev) => { + const newDate = new Date(prev); + if (view === 'month') { + newDate.setMonth(newDate.getMonth() + 1); + } else if (view === 'week') { + newDate.setDate(newDate.getDate() + 7); + } else { + newDate.setDate(newDate.getDate() + 1); + } + return newDate; + }); + }, [view]); + + const handleToday = useCallback(() => { + setCurrentDate(new Date()); + handleSelectedDateChange(new Date()); + }, [handleSelectedDateChange]); + + return ( +
+ + + {view === 'month' && ( + + )} + + {view === 'week' && ( +
+ Vista semanal - En desarrollo +
+ )} + + {view === 'day' && ( +
+ Vista diaria - En desarrollo +
+ )} +
+ ); +} diff --git a/src/shared/components/organisms/Calendar/CalendarEvent.tsx b/src/shared/components/organisms/Calendar/CalendarEvent.tsx new file mode 100644 index 0000000..dba9957 --- /dev/null +++ b/src/shared/components/organisms/Calendar/CalendarEvent.tsx @@ -0,0 +1,32 @@ +import { cn } from '@utils/cn'; +import type { CalendarEventProps } from './types'; + +const colorClasses = { + primary: 'bg-primary-100 text-primary-800 border-primary-200', + success: 'bg-success-100 text-success-800 border-success-200', + warning: 'bg-warning-100 text-warning-800 border-warning-200', + danger: 'bg-danger-100 text-danger-800 border-danger-200', + info: 'bg-info-100 text-info-800 border-info-200', +}; + +export function CalendarEvent({ event, onClick }: CalendarEventProps) { + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(event); + }; + + return ( + + ); +} diff --git a/src/shared/components/organisms/Calendar/CalendarGrid.tsx b/src/shared/components/organisms/Calendar/CalendarGrid.tsx new file mode 100644 index 0000000..44b5fe1 --- /dev/null +++ b/src/shared/components/organisms/Calendar/CalendarGrid.tsx @@ -0,0 +1,154 @@ +import { useMemo } from 'react'; +import { cn } from '@utils/cn'; +import { CalendarEvent } from './CalendarEvent'; +import type { CalendarGridProps, DayCell, CalendarEvent as CalendarEventType } from './types'; + +const WEEKDAYS = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom']; +const MAX_VISIBLE_EVENTS = 3; + +function isSameDay(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +} + +function getMonthDays(currentDate: Date, events: CalendarEventType[], selectedDate?: Date): DayCell[] { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const firstDayOfMonth = new Date(year, month, 1); + // lastDayOfMonth is not needed since we always render 42 cells (6 weeks) + void new Date(year, month + 1, 0); + + let startDay = firstDayOfMonth.getDay() - 1; + if (startDay < 0) startDay = 6; + + const startDate = new Date(firstDayOfMonth); + startDate.setDate(startDate.getDate() - startDay); + + const days: DayCell[] = []; + const totalCells = 42; + + for (let i = 0; i < totalCells; i++) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + date.setHours(0, 0, 0, 0); + + const dayOfWeek = date.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + + const dayEvents = events.filter((event) => { + const eventStart = new Date(event.start); + eventStart.setHours(0, 0, 0, 0); + return isSameDay(eventStart, date); + }); + + days.push({ + date, + isCurrentMonth: date.getMonth() === month, + isToday: isSameDay(date, today), + isSelected: selectedDate ? isSameDay(date, selectedDate) : false, + isWeekend, + events: dayEvents, + }); + } + + return days; +} + +export function CalendarGrid({ + currentDate, + events, + selectedDate, + onDateClick, + onEventClick, +}: CalendarGridProps) { + const days = useMemo( + () => getMonthDays(currentDate, events, selectedDate), + [currentDate, events, selectedDate] + ); + + const handleDateClick = (date: Date) => { + onDateClick?.(date); + }; + + return ( +
+
+ {WEEKDAYS.map((day, index) => ( +
= 5 ? 'bg-gray-50 text-gray-500' : 'text-gray-700' + )} + > + {day} +
+ ))} +
+ +
+ {days.map((day, index) => { + const visibleEvents = day.events.slice(0, MAX_VISIBLE_EVENTS); + const hiddenCount = day.events.length - MAX_VISIBLE_EVENTS; + + return ( +
handleDateClick(day.date)} + className={cn( + 'min-h-[100px] cursor-pointer border-b border-r p-1 transition-colors last:border-r-0', + '[&:nth-child(7n)]:border-r-0', + day.isCurrentMonth ? 'bg-white' : 'bg-gray-50', + day.isWeekend && day.isCurrentMonth && 'bg-gray-50/50', + day.isToday && 'bg-primary-50', + day.isSelected && 'ring-2 ring-inset ring-primary-500', + 'hover:bg-gray-100' + )} + > +
+ + {day.date.getDate()} + +
+ +
+ {visibleEvents.map((event) => ( + + ))} + {hiddenCount > 0 && ( + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/shared/components/organisms/Calendar/CalendarHeader.tsx b/src/shared/components/organisms/Calendar/CalendarHeader.tsx new file mode 100644 index 0000000..e6dcf57 --- /dev/null +++ b/src/shared/components/organisms/Calendar/CalendarHeader.tsx @@ -0,0 +1,91 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { cn } from '@utils/cn'; +import { Button } from '@components/atoms/Button'; +import type { CalendarHeaderProps, CalendarView } from './types'; + +const viewLabels: Record = { + month: 'Mes', + week: 'Semana', + day: 'Dia', +}; + +export function CalendarHeader({ + currentDate, + view, + onViewChange, + onPrevious, + onNext, + onToday, + locale = 'es-ES', +}: CalendarHeaderProps) { + const formatTitle = () => { + if (view === 'month') { + return currentDate.toLocaleDateString(locale, { + month: 'long', + year: 'numeric', + }); + } + if (view === 'week') { + const startOfWeek = new Date(currentDate); + const day = startOfWeek.getDay(); + const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); + startOfWeek.setDate(diff); + + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); + + return `${startOfWeek.toLocaleDateString(locale, { + day: 'numeric', + month: 'short', + })} - ${endOfWeek.toLocaleDateString(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + })}`; + } + return currentDate.toLocaleDateString(locale, { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }); + }; + + return ( +
+
+ + + +
+ +

+ {formatTitle()} +

+ +
+ {(['month', 'week', 'day'] as CalendarView[]).map((v) => ( + + ))} +
+
+ ); +} diff --git a/src/shared/components/organisms/Calendar/index.ts b/src/shared/components/organisms/Calendar/index.ts new file mode 100644 index 0000000..7127ce6 --- /dev/null +++ b/src/shared/components/organisms/Calendar/index.ts @@ -0,0 +1,5 @@ +export * from './Calendar'; +export * from './CalendarHeader'; +export * from './CalendarGrid'; +export * from './CalendarEvent'; +export type { CalendarEvent as CalendarEventType, CalendarView, CalendarProps, CalendarHeaderProps, CalendarGridProps, CalendarEventProps, DayCell } from './types'; diff --git a/src/shared/components/organisms/Calendar/types.ts b/src/shared/components/organisms/Calendar/types.ts new file mode 100644 index 0000000..e700065 --- /dev/null +++ b/src/shared/components/organisms/Calendar/types.ts @@ -0,0 +1,58 @@ +export interface CalendarEvent { + id: string; + title: string; + start: Date; + end?: Date; + allDay?: boolean; + color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; + description?: string; + metadata?: Record; +} + +export type CalendarView = 'month' | 'week' | 'day'; + +export interface CalendarProps { + events: CalendarEvent[]; + view?: CalendarView; + onViewChange?: (view: CalendarView) => void; + onEventClick?: (event: CalendarEvent) => void; + onDateClick?: (date: Date) => void; + onEventDrop?: (event: CalendarEvent, newStart: Date) => void; + selectedDate?: Date; + onSelectedDateChange?: (date: Date) => void; + locale?: string; + className?: string; +} + +export interface CalendarHeaderProps { + currentDate: Date; + view: CalendarView; + onViewChange: (view: CalendarView) => void; + onPrevious: () => void; + onNext: () => void; + onToday: () => void; + locale?: string; +} + +export interface CalendarGridProps { + currentDate: Date; + events: CalendarEvent[]; + selectedDate?: Date; + onDateClick?: (date: Date) => void; + onEventClick?: (event: CalendarEvent) => void; + locale?: string; +} + +export interface CalendarEventProps { + event: CalendarEvent; + onClick?: (event: CalendarEvent) => void; +} + +export interface DayCell { + date: Date; + isCurrentMonth: boolean; + isToday: boolean; + isSelected: boolean; + isWeekend: boolean; + events: CalendarEvent[]; +} diff --git a/src/shared/components/organisms/Chart/AreaChart.tsx b/src/shared/components/organisms/Chart/AreaChart.tsx new file mode 100644 index 0000000..951fb7a --- /dev/null +++ b/src/shared/components/organisms/Chart/AreaChart.tsx @@ -0,0 +1,90 @@ +import { cn } from '@utils/cn'; +import { ChartContainer } from './ChartContainer'; +import type { AreaChartProps } from './types'; +import { CHART_COLORS_FALLBACK } from './types'; + +/** + * AreaChart - Area chart component wrapper + * @description Displays data as an area chart. Requires recharts library. + * + * @example + * ```tsx + * + * ``` + */ +export function AreaChart({ + data, + series, + height = 300, + showGrid: _showGrid = true, + showLegend: _showLegend = true, + showTooltip: _showTooltip = true, + stacked: _stacked = false, + curved: _curved = true, + fillOpacity: _fillOpacity = 0.3, + className, +}: AreaChartProps) { + // Placeholder implementation - recharts not installed + const hasData = data && data.length > 0; + const effectiveSeries = series || [{ dataKey: 'value', name: 'Value' }]; + + return ( + +
+ {/* Visual representation placeholder */} +
+ + + + + + + + + + +
+ +
+

+ Area Chart +

+

+ {hasData + ? `${data.length} data points, ${effectiveSeries.length} series` + : 'No data'} +

+

+ Install recharts: npm install recharts +

+
+
+
+ ); +} diff --git a/src/shared/components/organisms/Chart/BarChart.tsx b/src/shared/components/organisms/Chart/BarChart.tsx new file mode 100644 index 0000000..04182e8 --- /dev/null +++ b/src/shared/components/organisms/Chart/BarChart.tsx @@ -0,0 +1,81 @@ +import { cn } from '@utils/cn'; +import { ChartContainer } from './ChartContainer'; +import type { BarChartProps } from './types'; +import { CHART_COLORS_FALLBACK } from './types'; + +/** + * BarChart - Bar chart component wrapper + * @description Displays data as a bar chart. Requires recharts library. + * + * @example + * ```tsx + * + * ``` + */ +export function BarChart({ + data, + series, + height = 300, + showGrid: _showGrid = true, + showLegend: _showLegend = true, + showTooltip: _showTooltip = true, + stacked: _stacked = false, + horizontal: _horizontal = false, + className, +}: BarChartProps) { + // Placeholder implementation - recharts not installed + const hasData = data && data.length > 0; + const effectiveSeries = series || [{ dataKey: 'value', name: 'Value' }]; + + return ( + +
+ {/* Visual representation placeholder */} +
+ {[50, 75, 40, 90, 60, 85].map((h, i) => ( +
+ ))} +
+ +
+

+ Bar Chart +

+

+ {hasData + ? `${data.length} data points, ${effectiveSeries.length} series` + : 'No data'} +

+

+ Install recharts: npm install recharts +

+
+
+ + ); +} diff --git a/src/shared/components/organisms/Chart/ChartContainer.tsx b/src/shared/components/organisms/Chart/ChartContainer.tsx new file mode 100644 index 0000000..f292e23 --- /dev/null +++ b/src/shared/components/organisms/Chart/ChartContainer.tsx @@ -0,0 +1,21 @@ +import { cn } from '@utils/cn'; +import type { ChartContainerProps } from './types'; + +/** + * ChartContainer - Responsive wrapper for chart components + * @description Provides consistent sizing and responsive behavior + */ +export function ChartContainer({ + children, + height = 300, + className, +}: ChartContainerProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/shared/components/organisms/Chart/ChartLegend.tsx b/src/shared/components/organisms/Chart/ChartLegend.tsx new file mode 100644 index 0000000..7021f90 --- /dev/null +++ b/src/shared/components/organisms/Chart/ChartLegend.tsx @@ -0,0 +1,35 @@ +import { cn } from '@utils/cn'; +import type { ChartLegendProps } from './types'; + +const alignClasses = { + left: 'justify-start', + center: 'justify-center', + right: 'justify-end', +}; + +/** + * ChartLegend - Styled legend for chart components + * @description Custom legend styling matching the design system + */ +export function ChartLegend({ + payload, + align = 'center', +}: ChartLegendProps) { + if (!payload || payload.length === 0) { + return null; + } + + return ( +
+ {payload.map((entry, index) => ( +
+ + {entry.value} +
+ ))} +
+ ); +} diff --git a/src/shared/components/organisms/Chart/ChartTooltip.tsx b/src/shared/components/organisms/Chart/ChartTooltip.tsx new file mode 100644 index 0000000..9738dc7 --- /dev/null +++ b/src/shared/components/organisms/Chart/ChartTooltip.tsx @@ -0,0 +1,38 @@ +import type { ChartTooltipProps } from './types'; + +/** + * ChartTooltip - Styled tooltip for chart components + * @description Custom tooltip styling matching the design system + */ +export function ChartTooltip({ + active, + payload, + label, + formatter, +}: ChartTooltipProps) { + if (!active || !payload || payload.length === 0) { + return null; + } + + return ( +
+ {label && ( +

{label}

+ )} +
+ {payload.map((entry, index) => ( +
+ + {entry.name}: + + {formatter ? formatter(entry.value, entry.name) : entry.value} + +
+ ))} +
+
+ ); +} diff --git a/src/shared/components/organisms/Chart/LineChart.tsx b/src/shared/components/organisms/Chart/LineChart.tsx new file mode 100644 index 0000000..705ca64 --- /dev/null +++ b/src/shared/components/organisms/Chart/LineChart.tsx @@ -0,0 +1,81 @@ +import { cn } from '@utils/cn'; +import { ChartContainer } from './ChartContainer'; +import type { LineChartProps } from './types'; +import { CHART_COLORS_FALLBACK } from './types'; + +/** + * LineChart - Line chart component wrapper + * @description Displays data as a line chart. Requires recharts library. + * + * @example + * ```tsx + * + * ``` + */ +export function LineChart({ + data, + series, + height = 300, + showGrid: _showGrid = true, + showLegend: _showLegend = true, + showTooltip: _showTooltip = true, + curved: _curved = true, + showDots: _showDots = true, + className, +}: LineChartProps) { + // Placeholder implementation - recharts not installed + const hasData = data && data.length > 0; + const effectiveSeries = series || [{ dataKey: 'value', name: 'Value' }]; + + return ( + +
+ {/* Visual representation placeholder */} +
+ {[40, 60, 45, 80, 55, 70, 90].map((h, i) => ( +
+ ))} +
+ +
+

+ Line Chart +

+

+ {hasData + ? `${data.length} data points, ${effectiveSeries.length} series` + : 'No data'} +

+

+ Install recharts: npm install recharts +

+
+
+ + ); +} diff --git a/src/shared/components/organisms/Chart/PieChart.tsx b/src/shared/components/organisms/Chart/PieChart.tsx new file mode 100644 index 0000000..da5cba6 --- /dev/null +++ b/src/shared/components/organisms/Chart/PieChart.tsx @@ -0,0 +1,93 @@ +import { cn } from '@utils/cn'; +import { ChartContainer } from './ChartContainer'; +import type { PieChartProps } from './types'; +import { CHART_COLORS_FALLBACK } from './types'; + +/** + * PieChart - Pie/Donut chart component wrapper + * @description Displays data as a pie or donut chart. Requires recharts library. + * + * @example + * ```tsx + * // Pie chart + * + * + * // Donut chart + * + * ``` + */ +export function PieChart({ + data, + height = 300, + innerRadius: _innerRadius = 0, + showLabels: _showLabels = true, + showLegend: _showLegend = true, + showTooltip: _showTooltip = true, + className, +}: PieChartProps) { + // Placeholder implementation - recharts not installed + const hasData = data && data.length > 0; + + return ( + +
+ {/* Visual representation placeholder - pie chart */} +
+ + {/* Donut segments */} + {[ + { offset: 0, percent: 35, color: CHART_COLORS_FALLBACK[0] }, + { offset: 35, percent: 25, color: CHART_COLORS_FALLBACK[1] }, + { offset: 60, percent: 20, color: CHART_COLORS_FALLBACK[2] }, + { offset: 80, percent: 20, color: CHART_COLORS_FALLBACK[3] }, + ].map((segment, i) => ( + + ))} + +
+ +
+

+ Pie Chart +

+

+ {hasData + ? `${data.length} segments` + : 'No data'} +

+

+ Install recharts: npm install recharts +

+
+
+
+ ); +} diff --git a/src/shared/components/organisms/Chart/index.ts b/src/shared/components/organisms/Chart/index.ts new file mode 100644 index 0000000..599723f --- /dev/null +++ b/src/shared/components/organisms/Chart/index.ts @@ -0,0 +1,63 @@ +/** + * Chart Components + * @description Reusable chart wrapper components for data visualization + * + * NOTE: These components require recharts to be installed for full functionality. + * Install with: npm install recharts @types/recharts + * + * @example + * ```tsx + * import { LineChart, BarChart, PieChart, AreaChart } from '@components/organisms/Chart'; + * + * // Line chart + * + * + * // Bar chart + * + * + * // Pie/Donut chart + * + * ``` + */ + +// Types +export type { + ChartDataPoint, + ChartSeries, + BaseChartProps, + LineChartProps, + BarChartProps, + AreaChartProps, + PieChartProps, + ChartContainerProps, + ChartTooltipProps, + ChartLegendProps, +} from './types'; + +export { CHART_COLORS, CHART_COLORS_FALLBACK } from './types'; + +// Components +export { ChartContainer } from './ChartContainer'; +export { ChartTooltip } from './ChartTooltip'; +export { ChartLegend } from './ChartLegend'; +export { LineChart } from './LineChart'; +export { BarChart } from './BarChart'; +export { AreaChart } from './AreaChart'; +export { PieChart } from './PieChart'; diff --git a/src/shared/components/organisms/Chart/types.ts b/src/shared/components/organisms/Chart/types.ts new file mode 100644 index 0000000..a5a94fc --- /dev/null +++ b/src/shared/components/organisms/Chart/types.ts @@ -0,0 +1,105 @@ +/** + * Chart Component Types + * @description Type definitions for chart wrapper components + */ + +export interface ChartDataPoint { + name: string; + value: number; + [key: string]: string | number; +} + +export interface ChartSeries { + dataKey: string; + name: string; + color?: string; + type?: 'line' | 'bar' | 'area'; +} + +export interface BaseChartProps { + data: ChartDataPoint[]; + series?: ChartSeries[]; + height?: number; + showGrid?: boolean; + showLegend?: boolean; + showTooltip?: boolean; + className?: string; +} + +export interface LineChartProps extends BaseChartProps { + curved?: boolean; + showDots?: boolean; +} + +export interface BarChartProps extends BaseChartProps { + stacked?: boolean; + horizontal?: boolean; +} + +export interface AreaChartProps extends BaseChartProps { + stacked?: boolean; + curved?: boolean; + fillOpacity?: number; +} + +export interface PieChartProps { + data: ChartDataPoint[]; + innerRadius?: number; + showLabels?: boolean; + showLegend?: boolean; + showTooltip?: boolean; + height?: number; + className?: string; +} + +export interface ChartContainerProps { + children: React.ReactNode; + height?: number; + className?: string; +} + +export interface ChartTooltipProps { + active?: boolean; + payload?: Array<{ + name: string; + value: number; + color: string; + dataKey: string; + }>; + label?: string; + formatter?: (value: number, name: string) => string; +} + +export interface ChartLegendProps { + payload?: Array<{ + value: string; + color: string; + type?: string; + }>; + align?: 'left' | 'center' | 'right'; + verticalAlign?: 'top' | 'middle' | 'bottom'; +} + +/** + * Chart colors matching the design system + */ +export const CHART_COLORS = [ + 'rgb(var(--color-primary-500, 59 130 246))', + 'rgb(var(--color-secondary-500, 107 114 128))', + 'rgb(var(--color-success-500, 34 197 94))', + 'rgb(var(--color-warning-500, 234 179 8))', + 'rgb(var(--color-danger-500, 239 68 68))', + 'rgb(var(--color-info-500, 6 182 212))', +]; + +/** + * Fallback colors when CSS variables are not available + */ +export const CHART_COLORS_FALLBACK = [ + '#3B82F6', // primary-500 + '#6B7280', // secondary-500 + '#22C55E', // success-500 + '#EAB308', // warning-500 + '#EF4444', // danger-500 + '#06B6D4', // info-500 +]; diff --git a/src/shared/components/organisms/CommandPalette/CommandPalette.tsx b/src/shared/components/organisms/CommandPalette/CommandPalette.tsx new file mode 100644 index 0000000..ee11c51 --- /dev/null +++ b/src/shared/components/organisms/CommandPalette/CommandPalette.tsx @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Search, Command as CommandIcon } from 'lucide-react'; +import { cn } from '@utils/cn'; +import { CommandPaletteGroup } from './CommandPaletteGroup'; +import { fuzzySearchCommands, groupCommands } from './useCommandPalette'; +import type { Command, CommandGroup, CommandPaletteProps } from './types'; + +/** + * Command Palette component + * A modal overlay for quick navigation and actions + */ +export function CommandPalette({ + commands, + placeholder = 'Buscar comandos...', + onClose, + isOpen, +}: CommandPaletteProps) { + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Filter commands based on search query + const filteredCommands = useMemo( + () => fuzzySearchCommands(commands, query), + [commands, query] + ); + + // Group filtered commands + const { groups, ungrouped } = useMemo( + () => groupCommands(filteredCommands), + [filteredCommands] + ); + + // Create array of groups for rendering + const commandGroups = useMemo(() => { + const result: CommandGroup[] = []; + + // Add ungrouped commands first + if (ungrouped.length > 0) { + result.push({ + id: '_ungrouped', + title: 'Acciones', + commands: ungrouped, + }); + } + + // Add other groups + groups.forEach((cmds, groupId) => { + result.push({ + id: groupId, + title: groupId, + commands: cmds, + }); + }); + + return result; + }, [groups, ungrouped]); + + // Flatten commands for keyboard navigation + const flatCommands = useMemo( + () => commandGroups.flatMap((g) => g.commands), + [commandGroups] + ); + + // Reset selection when query changes + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + // Focus input when opened + useEffect(() => { + if (isOpen) { + setQuery(''); + setSelectedIndex(0); + // Small delay to ensure DOM is ready + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + } + }, [isOpen]); + + // Scroll selected item into view + useEffect(() => { + if (listRef.current) { + const selectedElement = listRef.current.querySelector('[data-selected="true"]'); + if (selectedElement) { + selectedElement.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + } + } + }, [selectedIndex]); + + // Execute selected command + const executeCommand = useCallback( + (command: Command) => { + if (command.disabled) return; + command.action(); + onClose?.(); + }, + [onClose] + ); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelectedIndex((prev) => + prev < flatCommands.length - 1 ? prev + 1 : 0 + ); + break; + + case 'ArrowUp': + event.preventDefault(); + setSelectedIndex((prev) => + prev > 0 ? prev - 1 : flatCommands.length - 1 + ); + break; + + case 'Enter': + event.preventDefault(); + if (flatCommands[selectedIndex]) { + executeCommand(flatCommands[selectedIndex]); + } + break; + + case 'Escape': + event.preventDefault(); + onClose?.(); + break; + + case 'Tab': + // Prevent tab from leaving the modal + event.preventDefault(); + break; + } + }, + [flatCommands, selectedIndex, executeCommand, onClose] + ); + + // Handle overlay click + const handleOverlayClick = useCallback(() => { + onClose?.(); + }, [onClose]); + + // Handle item click + const handleItemClick = useCallback( + (command: Command) => { + executeCommand(command); + }, + [executeCommand] + ); + + // Handle item hover + const handleItemHover = useCallback((index: number) => { + setSelectedIndex(index); + }, []); + + // Calculate base index for each group + const getBaseIndex = useCallback( + (groupIndex: number) => { + let baseIndex = 0; + for (let i = 0; i < groupIndex; i++) { + baseIndex += commandGroups[i]?.commands.length ?? 0; + } + return baseIndex; + }, + [commandGroups] + ); + + const content = ( + + {isOpen && ( + <> + {/* Backdrop */} + + ); + + return createPortal(content, document.body); +} diff --git a/src/shared/components/organisms/CommandPalette/CommandPaletteGroup.tsx b/src/shared/components/organisms/CommandPalette/CommandPaletteGroup.tsx new file mode 100644 index 0000000..f031adb --- /dev/null +++ b/src/shared/components/organisms/CommandPalette/CommandPaletteGroup.tsx @@ -0,0 +1,44 @@ +import { cn } from '@utils/cn'; +import { CommandPaletteItem } from './CommandPaletteItem'; +import type { CommandPaletteGroupProps } from './types'; + +/** + * A group of commands with a header + */ +export function CommandPaletteGroup({ + group, + selectedIndex, + baseIndex, + onItemClick, + onItemHover, +}: CommandPaletteGroupProps) { + return ( +
+ {/* Group header */} +
+ {group.title} +
+ + {/* Group items */} +
+ {group.commands.map((command, index) => { + const globalIndex = baseIndex + index; + return ( + onItemClick(command)} + onMouseEnter={() => onItemHover(globalIndex)} + /> + ); + })} +
+
+ ); +} diff --git a/src/shared/components/organisms/CommandPalette/CommandPaletteItem.tsx b/src/shared/components/organisms/CommandPalette/CommandPaletteItem.tsx new file mode 100644 index 0000000..5adeebd --- /dev/null +++ b/src/shared/components/organisms/CommandPalette/CommandPaletteItem.tsx @@ -0,0 +1,99 @@ +import { cn } from '@utils/cn'; +import type { CommandPaletteItemProps } from './types'; + +/** + * Renders keyboard shortcut keys + */ +function ShortcutKeys({ keys }: { keys: string[] }) { + return ( +
+ {keys.map((key, index) => ( + + {key} + + ))} +
+ ); +} + +/** + * A single item in the command palette + */ +export function CommandPaletteItem({ + command, + isSelected, + onClick, + onMouseEnter, +}: CommandPaletteItemProps) { + const Icon = command.icon; + + return ( + + ); +} diff --git a/src/shared/components/organisms/CommandPalette/CommandPaletteProvider.tsx b/src/shared/components/organisms/CommandPalette/CommandPaletteProvider.tsx new file mode 100644 index 0000000..09dea8d --- /dev/null +++ b/src/shared/components/organisms/CommandPalette/CommandPaletteProvider.tsx @@ -0,0 +1,350 @@ +import { useMemo, type ReactNode } from 'react'; +import { + Home, + Users, + Settings, + Building2, + FileText, + CreditCard, + Package, + ShoppingCart, + Truck, + BarChart3, + Bell, + UserPlus, + Moon, + Sun, + LogOut, + HelpCircle, + Briefcase, + Calendar, + FolderKanban, + Users2, + Receipt, +} from 'lucide-react'; +import { CommandPalette } from './CommandPalette'; +import { + CommandPaletteContext, + useCommandPaletteState, +} from './useCommandPalette'; +import type { Command } from './types'; + +interface CommandPaletteProviderProps { + children: ReactNode; + /** Additional commands to include */ + additionalCommands?: Command[]; + /** Whether to include default navigation commands */ + includeDefaultCommands?: boolean; + /** Custom navigate function (for use outside router context) */ + navigate?: (path: string) => void; +} + +/** + * Provider component for the command palette + * Wraps the application and provides context for command registration + * Should be placed inside the router context if using default navigation commands + */ +export function CommandPaletteProvider({ + children, + additionalCommands = [], + includeDefaultCommands = true, + navigate: externalNavigate, +}: CommandPaletteProviderProps) { + // Use provided navigate or fallback to window.location + const navigate = externalNavigate || ((path: string) => { + window.location.href = path; + }); + + // Default navigation commands + const defaultCommands = useMemo(() => { + if (!includeDefaultCommands) return []; + + return [ + // Navigation - Dashboard + { + id: 'nav-dashboard', + title: 'Ir al Dashboard', + description: 'Ver panel principal', + icon: Home, + shortcut: ['G', 'D'], + action: () => navigate('/dashboard'), + group: 'Navegacion', + keywords: ['inicio', 'home', 'principal'], + }, + + // Navigation - Users + { + id: 'nav-users', + title: 'Ir a Usuarios', + description: 'Gestionar usuarios del sistema', + icon: Users, + shortcut: ['G', 'U'], + action: () => navigate('/users'), + group: 'Navegacion', + keywords: ['personas', 'empleados', 'administrar'], + }, + + // Navigation - Companies + { + id: 'nav-companies', + title: 'Ir a Empresas', + description: 'Gestionar empresas', + icon: Building2, + action: () => navigate('/companies'), + group: 'Navegacion', + keywords: ['organizaciones', 'negocios'], + }, + + // Navigation - Partners + { + id: 'nav-partners', + title: 'Ir a Partners', + description: 'Gestionar partners', + icon: Users2, + action: () => navigate('/partners'), + group: 'Navegacion', + keywords: ['socios', 'aliados', 'colaboradores'], + }, + + // Navigation - Settings + { + id: 'nav-settings', + title: 'Ir a Configuracion', + description: 'Ajustes del sistema', + icon: Settings, + shortcut: ['G', 'S'], + action: () => navigate('/settings'), + group: 'Navegacion', + keywords: ['ajustes', 'preferencias', 'opciones'], + }, + + // Navigation - Inventory + { + id: 'nav-inventory', + title: 'Ir a Inventario', + description: 'Gestion de inventario', + icon: Package, + action: () => navigate('/inventory'), + group: 'Navegacion', + keywords: ['productos', 'almacen', 'stock'], + }, + + // Navigation - Sales + { + id: 'nav-sales', + title: 'Ir a Ventas', + description: 'Modulo de ventas', + icon: ShoppingCart, + action: () => navigate('/sales'), + group: 'Navegacion', + keywords: ['pedidos', 'ordenes', 'clientes'], + }, + + // Navigation - Purchases + { + id: 'nav-purchases', + title: 'Ir a Compras', + description: 'Modulo de compras', + icon: Truck, + action: () => navigate('/purchases'), + group: 'Navegacion', + keywords: ['proveedores', 'ordenes', 'adquisiciones'], + }, + + // Navigation - Financial + { + id: 'nav-financial', + title: 'Ir a Finanzas', + description: 'Modulo financiero', + icon: Receipt, + action: () => navigate('/financial'), + group: 'Navegacion', + keywords: ['contabilidad', 'pagos', 'cobros', 'finanzas'], + }, + + // Navigation - CRM + { + id: 'nav-crm', + title: 'Ir a CRM', + description: 'Gestion de clientes', + icon: Briefcase, + action: () => navigate('/crm'), + group: 'Navegacion', + keywords: ['clientes', 'prospectos', 'oportunidades'], + }, + + // Navigation - Projects + { + id: 'nav-projects', + title: 'Ir a Proyectos', + description: 'Gestion de proyectos', + icon: FolderKanban, + action: () => navigate('/projects'), + group: 'Navegacion', + keywords: ['tareas', 'kanban', 'actividades'], + }, + + // Navigation - HR + { + id: 'nav-hr', + title: 'Ir a RRHH', + description: 'Recursos Humanos', + icon: Users, + action: () => navigate('/hr'), + group: 'Navegacion', + keywords: ['empleados', 'nomina', 'personal', 'recursos humanos'], + }, + + // Navigation - Reports + { + id: 'nav-reports', + title: 'Ir a Reportes', + description: 'Reportes y estadisticas', + icon: BarChart3, + action: () => navigate('/reports'), + group: 'Navegacion', + keywords: ['estadisticas', 'graficas', 'analisis'], + }, + + // Navigation - Billing + { + id: 'nav-billing', + title: 'Ir a Facturacion', + description: 'Planes y facturacion', + icon: CreditCard, + action: () => navigate('/billing'), + group: 'Navegacion', + keywords: ['pagos', 'suscripcion', 'plan'], + }, + + // Navigation - Calendar + { + id: 'nav-calendar', + title: 'Ir a Calendario', + description: 'Ver calendario de eventos', + icon: Calendar, + action: () => navigate('/calendar'), + group: 'Navegacion', + keywords: ['eventos', 'agenda', 'citas'], + }, + + // Navigation - Notifications + { + id: 'nav-notifications', + title: 'Ver Notificaciones', + description: 'Centro de notificaciones', + icon: Bell, + action: () => navigate('/notifications'), + group: 'Navegacion', + keywords: ['alertas', 'mensajes', 'avisos'], + }, + + // Actions - Create user + { + id: 'action-create-user', + title: 'Crear nuevo usuario', + description: 'Agregar un nuevo usuario al sistema', + icon: UserPlus, + shortcut: ['Ctrl', 'Shift', 'U'], + action: () => navigate('/users/new'), + group: 'Acciones', + keywords: ['agregar', 'nuevo', 'registrar', 'persona'], + }, + + // Actions - Create invoice + { + id: 'action-create-invoice', + title: 'Crear nueva factura', + description: 'Generar una nueva factura', + icon: FileText, + shortcut: ['Ctrl', 'Shift', 'I'], + action: () => navigate('/invoices/new'), + group: 'Acciones', + keywords: ['agregar', 'nueva', 'documento', 'venta'], + }, + + // Actions - Toggle dark mode + { + id: 'action-toggle-theme', + title: 'Cambiar tema', + description: 'Alternar entre modo claro y oscuro', + icon: Moon, + shortcut: ['Ctrl', 'Shift', 'T'], + action: () => { + const root = document.documentElement; + root.classList.toggle('dark'); + }, + group: 'Acciones', + keywords: ['oscuro', 'claro', 'dark', 'light', 'apariencia'], + }, + + // Actions - Light mode + { + id: 'action-light-mode', + title: 'Modo claro', + description: 'Activar tema claro', + icon: Sun, + action: () => { + document.documentElement.classList.remove('dark'); + }, + group: 'Acciones', + keywords: ['light', 'dia', 'brillante'], + }, + + // Actions - Dark mode + { + id: 'action-dark-mode', + title: 'Modo oscuro', + description: 'Activar tema oscuro', + icon: Moon, + action: () => { + document.documentElement.classList.add('dark'); + }, + group: 'Acciones', + keywords: ['dark', 'noche', 'negro'], + }, + + // Actions - Help + { + id: 'action-help', + title: 'Ayuda', + description: 'Ver documentacion y ayuda', + icon: HelpCircle, + shortcut: ['F1'], + action: () => navigate('/help'), + group: 'Acciones', + keywords: ['soporte', 'documentacion', 'guia', 'tutorial'], + }, + + // Actions - Logout + { + id: 'action-logout', + title: 'Cerrar sesion', + description: 'Salir de la aplicacion', + icon: LogOut, + action: () => navigate('/logout'), + group: 'Acciones', + keywords: ['salir', 'desconectar', 'logout'], + }, + ]; + }, [includeDefaultCommands, navigate]); + + // Combine default and additional commands + const initialCommands = useMemo( + () => [...defaultCommands, ...additionalCommands], + [defaultCommands, additionalCommands] + ); + + const state = useCommandPaletteState(initialCommands); + + return ( + + {children} + + + ); +} diff --git a/src/shared/components/organisms/CommandPalette/CommandPaletteWithRouter.tsx b/src/shared/components/organisms/CommandPalette/CommandPaletteWithRouter.tsx new file mode 100644 index 0000000..f1cda50 --- /dev/null +++ b/src/shared/components/organisms/CommandPalette/CommandPaletteWithRouter.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { CommandPaletteProvider } from './CommandPaletteProvider'; +import type { Command } from './types'; + +interface CommandPaletteWithRouterProps { + children: ReactNode; + /** Additional commands to include */ + additionalCommands?: Command[]; + /** Whether to include default navigation commands */ + includeDefaultCommands?: boolean; +} + +/** + * Command Palette Provider that uses React Router's useNavigate + * Must be used inside a Router context (BrowserRouter, RouterProvider, etc.) + */ +export function CommandPaletteWithRouter({ + children, + additionalCommands = [], + includeDefaultCommands = true, +}: CommandPaletteWithRouterProps) { + const navigate = useNavigate(); + + return ( + + {children} + + ); +} diff --git a/src/shared/components/organisms/CommandPalette/index.ts b/src/shared/components/organisms/CommandPalette/index.ts new file mode 100644 index 0000000..e29b7f0 --- /dev/null +++ b/src/shared/components/organisms/CommandPalette/index.ts @@ -0,0 +1,22 @@ +export { CommandPalette } from './CommandPalette'; +export { CommandPaletteItem } from './CommandPaletteItem'; +export { CommandPaletteGroup } from './CommandPaletteGroup'; +export { CommandPaletteProvider } from './CommandPaletteProvider'; +export { CommandPaletteWithRouter } from './CommandPaletteWithRouter'; +export { + useCommandPalette, + useCommandPaletteState, + useRegisterCommand, + useRegisterCommands, + CommandPaletteContext, + fuzzySearchCommands, + groupCommands, +} from './useCommandPalette'; +export type { + Command, + CommandGroup, + CommandPaletteProps, + CommandPaletteItemProps, + CommandPaletteGroupProps, + CommandPaletteContextValue, +} from './types'; diff --git a/src/shared/components/organisms/CommandPalette/types.ts b/src/shared/components/organisms/CommandPalette/types.ts new file mode 100644 index 0000000..6f17cff --- /dev/null +++ b/src/shared/components/organisms/CommandPalette/types.ts @@ -0,0 +1,101 @@ +import type { ComponentType } from 'react'; + +/** + * Represents a single command in the command palette + */ +export interface Command { + /** Unique identifier for the command */ + id: string; + /** Display title for the command */ + title: string; + /** Optional description shown below the title */ + description?: string; + /** Optional icon component */ + icon?: ComponentType<{ className?: string }>; + /** Keyboard shortcut keys (e.g., ['Ctrl', 'N']) */ + shortcut?: string[]; + /** Function to execute when command is selected */ + action: () => void; + /** Group identifier for grouping commands */ + group?: string; + /** Additional search terms for fuzzy matching */ + keywords?: string[]; + /** Whether the command is disabled */ + disabled?: boolean; +} + +/** + * Represents a group of commands + */ +export interface CommandGroup { + /** Unique identifier for the group */ + id: string; + /** Display title for the group */ + title: string; + /** Commands in this group */ + commands: Command[]; +} + +/** + * Props for the CommandPalette component + */ +export interface CommandPaletteProps { + /** Array of commands to display */ + commands: Command[]; + /** Placeholder text for the search input */ + placeholder?: string; + /** Callback when the palette is closed */ + onClose?: () => void; + /** Whether the palette is open */ + isOpen: boolean; +} + +/** + * Props for a single command palette item + */ +export interface CommandPaletteItemProps { + /** The command to display */ + command: Command; + /** Whether the item is selected */ + isSelected: boolean; + /** Callback when the item is clicked */ + onClick: () => void; + /** Callback when the item is hovered */ + onMouseEnter: () => void; +} + +/** + * Props for a command group header + */ +export interface CommandPaletteGroupProps { + /** The group to display */ + group: CommandGroup; + /** Index of the currently selected item */ + selectedIndex: number; + /** Base index for the group items */ + baseIndex: number; + /** Callback when an item is clicked */ + onItemClick: (command: Command) => void; + /** Callback when an item is hovered */ + onItemHover: (index: number) => void; +} + +/** + * Context value for the command palette provider + */ +export interface CommandPaletteContextValue { + /** Whether the palette is open */ + isOpen: boolean; + /** Open the command palette */ + open: () => void; + /** Close the command palette */ + close: () => void; + /** Toggle the command palette */ + toggle: () => void; + /** Register a command */ + registerCommand: (command: Command) => void; + /** Unregister a command by id */ + unregisterCommand: (id: string) => void; + /** All registered commands */ + commands: Command[]; +} diff --git a/src/shared/components/organisms/CommandPalette/useCommandPalette.ts b/src/shared/components/organisms/CommandPalette/useCommandPalette.ts new file mode 100644 index 0000000..34b42e9 --- /dev/null +++ b/src/shared/components/organisms/CommandPalette/useCommandPalette.ts @@ -0,0 +1,195 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type { Command, CommandPaletteContextValue } from './types'; + +/** + * Context for the command palette + */ +export const CommandPaletteContext = createContext( + undefined +); + +/** + * Hook to access the command palette context + * @throws Error if used outside CommandPaletteProvider + */ +export function useCommandPalette(): CommandPaletteContextValue { + const context = useContext(CommandPaletteContext); + if (!context) { + throw new Error('useCommandPalette must be used within a CommandPaletteProvider'); + } + return context; +} + +/** + * Hook to create command palette state and actions + * Used internally by CommandPaletteProvider + */ +export function useCommandPaletteState(initialCommands: Command[] = []) { + const [isOpen, setIsOpen] = useState(false); + const [commands, setCommands] = useState(initialCommands); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + const toggle = useCallback(() => setIsOpen((prev) => !prev), []); + + const registerCommand = useCallback((command: Command) => { + setCommands((prev) => { + // Replace if exists, otherwise add + const exists = prev.some((c) => c.id === command.id); + if (exists) { + return prev.map((c) => (c.id === command.id ? command : c)); + } + return [...prev, command]; + }); + }, []); + + const unregisterCommand = useCallback((id: string) => { + setCommands((prev) => prev.filter((c) => c.id !== id)); + }, []); + + // Global keyboard listener for Cmd+K / Ctrl+K + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Check for Cmd+K (Mac) or Ctrl+K (Windows/Linux) + if ((event.metaKey || event.ctrlKey) && event.key === 'k') { + event.preventDefault(); + toggle(); + } + + // Escape to close + if (event.key === 'Escape' && isOpen) { + event.preventDefault(); + close(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, toggle, close]); + + const value = useMemo( + () => ({ + isOpen, + open, + close, + toggle, + registerCommand, + unregisterCommand, + commands, + }), + [isOpen, open, close, toggle, registerCommand, unregisterCommand, commands] + ); + + return value; +} + +/** + * Hook to register a command when a component mounts + * Automatically unregisters on unmount + */ +export function useRegisterCommand(command: Command) { + const { registerCommand, unregisterCommand } = useCommandPalette(); + + useEffect(() => { + registerCommand(command); + return () => unregisterCommand(command.id); + }, [command, registerCommand, unregisterCommand]); +} + +/** + * Hook to register multiple commands + */ +export function useRegisterCommands(commands: Command[]) { + const { registerCommand, unregisterCommand } = useCommandPalette(); + + useEffect(() => { + commands.forEach(registerCommand); + return () => { + commands.forEach((cmd) => unregisterCommand(cmd.id)); + }; + }, [commands, registerCommand, unregisterCommand]); +} + +/** + * Fuzzy search implementation for commands + * Searches in title, description, and keywords + */ +export function fuzzySearchCommands(commands: Command[], query: string): Command[] { + if (!query.trim()) { + return commands; + } + + const searchTerm = query.toLowerCase().trim(); + const searchTerms = searchTerm.split(/\s+/); + + return commands + .map((command) => { + const searchableText = [ + command.title, + command.description || '', + ...(command.keywords || []), + command.group || '', + ] + .join(' ') + .toLowerCase(); + + // Calculate score based on matches + let score = 0; + + // Exact title match gets highest score + if (command.title.toLowerCase() === searchTerm) { + score += 100; + } else if (command.title.toLowerCase().startsWith(searchTerm)) { + score += 75; + } else if (command.title.toLowerCase().includes(searchTerm)) { + score += 50; + } + + // Check each search term + for (const term of searchTerms) { + if (searchableText.includes(term)) { + score += 10; + } + } + + // Check keywords specifically + if (command.keywords) { + for (const keyword of command.keywords) { + if (keyword.toLowerCase().includes(searchTerm)) { + score += 25; + } + } + } + + return { command, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ command }) => command); +} + +/** + * Group commands by their group property + */ +export function groupCommands(commands: Command[]) { + const groups: Map = new Map(); + const ungrouped: Command[] = []; + + for (const command of commands) { + if (command.group) { + const existing = groups.get(command.group) || []; + groups.set(command.group, [...existing, command]); + } else { + ungrouped.push(command); + } + } + + return { groups, ungrouped }; +} diff --git a/src/shared/components/organisms/DashboardWidgets/DashboardGrid.tsx b/src/shared/components/organisms/DashboardWidgets/DashboardGrid.tsx new file mode 100644 index 0000000..1a62c51 --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/DashboardGrid.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { cn } from '@utils/cn'; +import { usePermissions } from '@shared/hooks/usePermissions'; +import type { Widget, DashboardConfig, WidgetSize } from './types'; + +/** + * Widget size classes mapping + */ +const SIZE_CLASSES: Record = { + sm: 'col-span-1', + md: 'col-span-1 lg:col-span-2', + lg: 'col-span-1 lg:col-span-3', + full: 'col-span-full', +}; + +export interface DashboardGridProps { + config: DashboardConfig; + className?: string; +} + +/** + * DashboardGrid - Grid layout for dashboard widgets + * @description Renders widgets filtered by permissions in a responsive grid + */ +export function DashboardGrid({ config, className }: DashboardGridProps) { + const { hasAnyPermission, hasAnyRole, isAdmin } = usePermissions(); + + // Filter widgets based on permissions and roles + const visibleWidgets = useMemo(() => { + return config.widgets.filter((widget) => { + // Check default visibility + if (widget.defaultVisible === false) { + return false; + } + + // Admin sees all widgets + if (isAdmin) { + return true; + } + + // Check required permission + if (widget.requiredPermission) { + if (!hasAnyPermission(widget.requiredPermission)) { + return false; + } + } + + // Check required roles + if (widget.requiredRole && widget.requiredRole.length > 0) { + if (!hasAnyRole(...widget.requiredRole)) { + return false; + } + } + + return true; + }); + }, [config.widgets, isAdmin, hasAnyPermission, hasAnyRole]); + + if (visibleWidgets.length === 0) { + return ( +
+ No hay widgets disponibles para mostrar +
+ ); + } + + return ( +
+ {visibleWidgets.map((widget) => { + const WidgetComponent = widget.component; + const sizeClass = SIZE_CLASSES[widget.size || 'md']; + + return ( +
+ +
+ ); + })} +
+ ); +} + +/** + * Helper function to create a widget configuration + */ +export function createWidget( + config: Omit & { + component: React.ComponentType; + } +): Widget { + return config as Widget; +} + +/** + * Helper function to create a dashboard configuration + */ +export function createDashboardConfig( + widgets: Widget[], + options?: Partial> +): DashboardConfig { + return { + widgets, + layout: options?.layout || 'grid', + }; +} diff --git a/src/shared/components/organisms/DashboardWidgets/PerformanceChart.tsx b/src/shared/components/organisms/DashboardWidgets/PerformanceChart.tsx new file mode 100644 index 0000000..41d6068 --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/PerformanceChart.tsx @@ -0,0 +1,117 @@ +import { cn } from '@utils/cn'; +import { LineChart } from '../Chart/LineChart'; +import { BarChart } from '../Chart/BarChart'; +import { AreaChart } from '../Chart/AreaChart'; +import type { PerformanceChartProps } from './types'; + +/** + * Demo chart data + */ +const DEMO_DATA = [ + { name: 'Ene', value: 12000, ventas: 12000, gastos: 8000 }, + { name: 'Feb', value: 15000, ventas: 15000, gastos: 9500 }, + { name: 'Mar', value: 18000, ventas: 18000, gastos: 10000 }, + { name: 'Abr', value: 14000, ventas: 14000, gastos: 8500 }, + { name: 'May', value: 21000, ventas: 21000, gastos: 11000 }, + { name: 'Jun', value: 25000, ventas: 25000, gastos: 12500 }, +]; + +/** + * PerformanceChart - Chart widget wrapper + * @description Wraps chart components with loading state and title + */ +export function PerformanceChart({ + title: _title = 'Rendimiento', + data = DEMO_DATA, + type = 'line', + height = 250, + className, + loading = false, +}: PerformanceChartProps) { + if (loading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+ No hay datos disponibles +
+ ); + } + + const chartProps = { + data, + height, + showGrid: true, + showLegend: false, + showTooltip: true, + series: [ + { dataKey: 'value', name: 'Valor', color: '#3B82F6' }, + ], + }; + + const renderChart = () => { + switch (type) { + case 'bar': + return ; + case 'area': + return ; + case 'line': + default: + return ; + } + }; + + return
{renderChart()}
; +} + +/** + * Loading skeleton for PerformanceChart + */ +function PerformanceChartSkeleton({ + height, + className, +}: { + height: number; + className?: string; +}) { + return ( +
+
+ {/* Simulated bar chart skeleton */} + {[40, 60, 45, 80, 55, 70, 65].map((h, i) => ( +
+ ))} +
+
+ ); +} + +/** + * Pre-configured performance charts for common use cases + */ +export function SalesPerformanceChart(props: Omit) { + return ; +} + +export function RevenuePerformanceChart(props: Omit) { + return ; +} + +export function OrdersPerformanceChart(props: Omit) { + return ; +} diff --git a/src/shared/components/organisms/DashboardWidgets/QuickActions.tsx b/src/shared/components/organisms/DashboardWidgets/QuickActions.tsx new file mode 100644 index 0000000..8560dc1 --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/QuickActions.tsx @@ -0,0 +1,187 @@ +import { Link } from 'react-router-dom'; +import { + Plus, + Users, + ShoppingCart, + FileText, + Package, + type LucideIcon, +} from 'lucide-react'; +import { cn } from '@utils/cn'; +import { usePermissions } from '@shared/hooks/usePermissions'; +import type { QuickActionsProps, QuickActionItem } from './types'; + +/** + * Default quick actions for ERP + */ +const DEFAULT_ACTIONS: QuickActionItem[] = [ + { + id: 'new-sale', + label: 'Nueva Venta', + icon: ShoppingCart, + href: '/sales/orders/new', + permission: 'sales:create', + variant: 'primary', + }, + { + id: 'new-customer', + label: 'Nuevo Cliente', + icon: Users, + href: '/customers/new', + permission: 'customers:create', + variant: 'default', + }, + { + id: 'new-invoice', + label: 'Nueva Factura', + icon: FileText, + href: '/invoices/new', + permission: 'invoices:create', + variant: 'default', + }, + { + id: 'new-product', + label: 'Nuevo Producto', + icon: Package, + href: '/products/new', + permission: 'products:create', + variant: 'default', + }, +]; + +/** + * QuickActions - Grid of quick action buttons + * @description Displays action buttons filtered by user permissions + */ +export function QuickActions({ + actions = DEFAULT_ACTIONS, + columns = 4, + className, +}: QuickActionsProps) { + const { hasAnyPermission, isAdmin } = usePermissions(); + + // Filter actions based on permissions + const filteredActions = actions.filter((action) => { + if (isAdmin) return true; + if (!action.permission) return true; + return hasAnyPermission(action.permission); + }); + + if (filteredActions.length === 0) { + return ( +
+ No hay acciones disponibles +
+ ); + } + + return ( +
+ {filteredActions.map((action) => ( + + ))} +
+ ); +} + +/** + * Individual quick action button + */ +function QuickActionButton({ action }: { action: QuickActionItem }) { + const { label, icon: Icon, href, onClick, variant = 'default' } = action; + + const variantClasses = { + default: + 'bg-gray-50 hover:bg-gray-100 text-gray-700 border-gray-200', + primary: + 'bg-primary-50 hover:bg-primary-100 text-primary-700 border-primary-200', + success: + 'bg-success-50 hover:bg-success-100 text-success-700 border-success-200', + warning: + 'bg-warning-50 hover:bg-warning-100 text-warning-700 border-warning-200', + danger: + 'bg-danger-50 hover:bg-danger-100 text-danger-700 border-danger-200', + }; + + const buttonContent = ( + <> + + {label} + + ); + + const baseClasses = cn( + 'flex flex-col items-center justify-center p-4 rounded-lg border', + 'transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2', + variantClasses[variant] + ); + + if (href) { + return ( + + {buttonContent} + + ); + } + + return ( + + ); +} + +/** + * Quick action button for creating a custom action + */ +export interface CreateQuickActionProps { + label: string; + icon?: LucideIcon; + href?: string; + onClick?: () => void; + className?: string; +} + +export function CreateQuickAction({ + label, + icon: Icon = Plus, + href, + onClick, + className, +}: CreateQuickActionProps) { + const baseClasses = cn( + 'flex items-center gap-2 px-4 py-2 rounded-lg border border-dashed', + 'border-gray-300 text-gray-600 hover:border-primary-400 hover:text-primary-600', + 'transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500', + className + ); + + const content = ( + <> + + {label} + + ); + + if (href) { + return ( + + {content} + + ); + } + + return ( + + ); +} diff --git a/src/shared/components/organisms/DashboardWidgets/RecentActivity.tsx b/src/shared/components/organisms/DashboardWidgets/RecentActivity.tsx new file mode 100644 index 0000000..68927e1 --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/RecentActivity.tsx @@ -0,0 +1,211 @@ +import { Link } from 'react-router-dom'; +import { + Plus, + Pencil, + Trash2, + LogIn, + Activity, + type LucideIcon, +} from 'lucide-react'; +import { cn } from '@utils/cn'; +import { formatDistanceToNow } from '@utils/formatters'; +import type { RecentActivityProps, ActivityItem } from './types'; + +/** + * Activity type configuration + */ +const ACTIVITY_CONFIG: Record< + ActivityItem['type'], + { icon: LucideIcon; color: string; bgColor: string } +> = { + create: { + icon: Plus, + color: 'text-success-600', + bgColor: 'bg-success-50', + }, + update: { + icon: Pencil, + color: 'text-primary-600', + bgColor: 'bg-primary-50', + }, + delete: { + icon: Trash2, + color: 'text-danger-600', + bgColor: 'bg-danger-50', + }, + login: { + icon: LogIn, + color: 'text-gray-600', + bgColor: 'bg-gray-100', + }, + other: { + icon: Activity, + color: 'text-gray-600', + bgColor: 'bg-gray-100', + }, +}; + +/** + * Demo activities for development + */ +const DEMO_ACTIVITIES: ActivityItem[] = [ + { + id: '1', + type: 'create', + message: 'Se creó la orden de venta SO-001239', + user: 'Juan Pérez', + timestamp: new Date(Date.now() - 5 * 60 * 1000), + link: '/sales/orders/SO-001239', + }, + { + id: '2', + type: 'update', + message: 'Se actualizó el cliente Empresa ABC', + user: 'María García', + timestamp: new Date(Date.now() - 30 * 60 * 1000), + link: '/customers/1', + }, + { + id: '3', + type: 'create', + message: 'Se registró un nuevo producto: Widget X', + user: 'Carlos López', + timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000), + link: '/products/45', + }, + { + id: '4', + type: 'login', + message: 'Inicio de sesión desde nueva ubicación', + timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000), + }, + { + id: '5', + type: 'delete', + message: 'Se eliminó la cotización QT-000123', + user: 'Ana Martínez', + timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, +]; + +/** + * RecentActivity - Activity feed widget + * @description Displays a list of recent system activities + */ +export function RecentActivity({ + activities = DEMO_ACTIVITIES, + maxItems = 5, + className, + loading = false, +}: RecentActivityProps) { + if (loading) { + return ; + } + + const displayedActivities = activities.slice(0, maxItems); + + if (displayedActivities.length === 0) { + return ( +
+ No hay actividad reciente +
+ ); + } + + return ( +
+ {displayedActivities.map((activity, index) => ( + + ))} +
+ ); +} + +/** + * Individual activity row + */ +function ActivityItemRow({ + activity, + isLast, +}: { + activity: ActivityItem; + isLast: boolean; +}) { + const config = ACTIVITY_CONFIG[activity.type]; + const Icon = config.icon; + + const timeAgo = formatDistanceToNow( + typeof activity.timestamp === 'string' + ? new Date(activity.timestamp) + : activity.timestamp + ); + + const content = ( +
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+

{activity.message}

+
+ {activity.user && {activity.user}} + {activity.user && ·} + {timeAgo} +
+
+
+ ); + + const containerClasses = cn( + 'block py-3', + !isLast && 'border-b border-gray-100', + activity.link && 'hover:bg-gray-50 -mx-2 px-2 rounded-md transition-colors' + ); + + if (activity.link) { + return ( + + {content} + + ); + } + + return
{content}
; +} + +/** + * Loading skeleton for RecentActivity + */ +function RecentActivitySkeleton({ + count, + className, +}: { + count: number; + className?: string; +}) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/shared/components/organisms/DashboardWidgets/StatCard.tsx b/src/shared/components/organisms/DashboardWidgets/StatCard.tsx new file mode 100644 index 0000000..9751a6a --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/StatCard.tsx @@ -0,0 +1,148 @@ +import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react'; +import { cn } from '@utils/cn'; +import { formatCurrency } from '@utils/formatters'; +import type { StatCardProps } from './types'; + +/** + * StatCard - KPI stat card for dashboard + * @description Displays a metric with optional trend indicator and icon + */ +export function StatCard({ + title, + value, + change, + changeType = 'neutral', + changeLabel = 'vs mes anterior', + icon: Icon, + format = 'number', + className, + loading = false, +}: StatCardProps) { + const formattedValue = formatValue(value, format); + + if (loading) { + return ; + } + + return ( +
+
+
+

{title}

+

+ {formattedValue} +

+
+ {Icon && ( +
+ +
+ )} +
+ + {change !== undefined && ( +
+ + + {changeType === 'increase' && '+'} + {change}% + + {changeLabel} +
+ )} +
+ ); +} + +/** + * Format value based on format type + */ +function formatValue( + value: number | string, + format: 'currency' | 'number' | 'percent' +): string { + if (typeof value === 'string') { + return value; + } + + switch (format) { + case 'currency': + return formatCurrency(value); + case 'percent': + return `${value.toFixed(1)}%`; + case 'number': + default: + return value.toLocaleString(); + } +} + +/** + * Trend indicator icon + */ +function TrendIndicator({ + type, +}: { + type: 'increase' | 'decrease' | 'neutral'; +}) { + const iconProps = { className: 'h-4 w-4' }; + + switch (type) { + case 'increase': + return ; + case 'decrease': + return ; + case 'neutral': + default: + return ; + } +} + +/** + * Loading skeleton for StatCard + */ +function StatCardSkeleton({ className }: { className?: string }) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +/** + * StatCard with custom icon component + * @description Convenience component for StatCard with explicit icon prop + */ +export interface StatCardWithIconProps extends Omit { + icon: LucideIcon; +} + +export function StatCardWithIcon({ + icon: Icon, + ...props +}: StatCardWithIconProps) { + return ; +} diff --git a/src/shared/components/organisms/DashboardWidgets/UpcomingTasks.tsx b/src/shared/components/organisms/DashboardWidgets/UpcomingTasks.tsx new file mode 100644 index 0000000..d708fea --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/UpcomingTasks.tsx @@ -0,0 +1,281 @@ +import { Link } from 'react-router-dom'; +import { + Calendar, + AlertCircle, + Clock, + CheckCircle2, + Circle, +} from 'lucide-react'; +import { cn } from '@utils/cn'; +import { formatDistanceToNow } from '@utils/formatters'; +import type { UpcomingTasksProps, TaskItem } from './types'; + +/** + * Priority configuration + */ +const PRIORITY_CONFIG: Record< + NonNullable, + { label: string; color: string; bgColor: string } +> = { + low: { + label: 'Baja', + color: 'text-gray-600', + bgColor: 'bg-gray-100', + }, + medium: { + label: 'Media', + color: 'text-primary-600', + bgColor: 'bg-primary-50', + }, + high: { + label: 'Alta', + color: 'text-warning-600', + bgColor: 'bg-warning-50', + }, + urgent: { + label: 'Urgente', + color: 'text-danger-600', + bgColor: 'bg-danger-50', + }, +}; + +/** + * Status configuration + */ +const STATUS_CONFIG: Record< + NonNullable, + { icon: typeof Circle; color: string } +> = { + pending: { + icon: Circle, + color: 'text-gray-400', + }, + in_progress: { + icon: Clock, + color: 'text-primary-500', + }, + completed: { + icon: CheckCircle2, + color: 'text-success-500', + }, + overdue: { + icon: AlertCircle, + color: 'text-danger-500', + }, +}; + +/** + * Demo tasks for development + */ +const DEMO_TASKS: TaskItem[] = [ + { + id: '1', + title: 'Revisar cotización pendiente QT-001234', + dueDate: new Date(Date.now() + 2 * 60 * 60 * 1000), + priority: 'urgent', + status: 'pending', + assignee: 'Yo', + link: '/sales/quotations/QT-001234', + }, + { + id: '2', + title: 'Contactar cliente Empresa XYZ', + dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000), + priority: 'high', + status: 'in_progress', + link: '/customers/5', + }, + { + id: '3', + title: 'Actualizar inventario productos importados', + dueDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + priority: 'medium', + status: 'pending', + assignee: 'Carlos López', + }, + { + id: '4', + title: 'Generar reporte mensual de ventas', + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + priority: 'low', + status: 'pending', + }, + { + id: '5', + title: 'Revisar facturas vencidas', + dueDate: new Date(Date.now() - 24 * 60 * 60 * 1000), + priority: 'urgent', + status: 'overdue', + link: '/invoices?filter=overdue', + }, +]; + +/** + * UpcomingTasks - Tasks/reminders widget + * @description Displays a list of upcoming tasks and deadlines + */ +export function UpcomingTasks({ + tasks = DEMO_TASKS, + maxItems = 5, + className, + loading = false, +}: UpcomingTasksProps) { + if (loading) { + return ; + } + + // Sort tasks by due date and priority + const sortedTasks = [...tasks] + .sort((a, b) => { + // Overdue tasks first + const aOverdue = a.status === 'overdue'; + const bOverdue = b.status === 'overdue'; + if (aOverdue && !bOverdue) return -1; + if (!aOverdue && bOverdue) return 1; + + // Then by due date + const aDate = a.dueDate ? new Date(a.dueDate).getTime() : Infinity; + const bDate = b.dueDate ? new Date(b.dueDate).getTime() : Infinity; + return aDate - bDate; + }) + .slice(0, maxItems); + + if (sortedTasks.length === 0) { + return ( +
+ +

No hay tareas pendientes

+
+ ); + } + + return ( +
+ {sortedTasks.map((task) => ( + + ))} +
+ ); +} + +/** + * Individual task row + */ +function TaskItemRow({ task }: { task: TaskItem }) { + const status = task.status || 'pending'; + const statusConfig = STATUS_CONFIG[status]; + const StatusIcon = statusConfig.icon; + + const priorityConfig = task.priority + ? PRIORITY_CONFIG[task.priority] + : null; + + const dueDate = task.dueDate + ? typeof task.dueDate === 'string' + ? new Date(task.dueDate) + : task.dueDate + : null; + + const isOverdue = + status === 'overdue' || (dueDate && dueDate < new Date() && status !== 'completed'); + + const content = ( +
+ {/* Status icon */} + + + {/* Content */} +
+

+ {task.title} +

+ +
+ {/* Due date */} + {dueDate && ( + + {isOverdue + ? `Vencida hace ${formatDistanceToNow(dueDate)}` + : `Vence ${formatDistanceToNow(dueDate)}`} + + )} + + {/* Priority badge */} + {priorityConfig && ( + + {priorityConfig.label} + + )} + + {/* Assignee */} + {task.assignee && ( + · {task.assignee} + )} +
+
+
+ ); + + const containerClasses = cn( + 'block p-3 rounded-lg border border-gray-100', + 'hover:border-gray-200 hover:bg-gray-50 transition-colors', + isOverdue && 'border-danger-200 bg-danger-50/30' + ); + + if (task.link) { + return ( + + {content} + + ); + } + + return
{content}
; +} + +/** + * Loading skeleton for UpcomingTasks + */ +function UpcomingTasksSkeleton({ + count, + className, +}: { + count: number; + className?: string; +}) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/shared/components/organisms/DashboardWidgets/WidgetContainer.tsx b/src/shared/components/organisms/DashboardWidgets/WidgetContainer.tsx new file mode 100644 index 0000000..fb6c2e9 --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/WidgetContainer.tsx @@ -0,0 +1,126 @@ +import { useState, useCallback, type ReactNode } from 'react'; +import { ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'; +import { cn } from '@utils/cn'; +import type { WidgetContainerProps } from './types'; + +/** + * WidgetContainer - Common wrapper for dashboard widgets + * @description Provides consistent header, actions, collapsible behavior, and loading/error states + */ +export function WidgetContainer({ + title, + actions, + children, + collapsible = false, + defaultCollapsed = false, + loading = false, + error = null, + className, + headerClassName, + contentClassName, +}: WidgetContainerProps) { + const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); + + const handleToggleCollapse = useCallback(() => { + if (collapsible) { + setIsCollapsed((prev) => !prev); + } + }, [collapsible]); + + return ( +
+ {/* Header */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleToggleCollapse(); + } + } + : undefined + } + > +

{title}

+
+ {actions && !isCollapsed && ( +
e.stopPropagation()}>{actions}
+ )} + {collapsible && ( + + {isCollapsed ? ( + + ) : ( + + )} + + )} +
+
+ + {/* Content */} + {!isCollapsed && ( +
+ {loading ? ( + + ) : error ? ( + + ) : ( + children + )} +
+ )} +
+ ); +} + +/** + * Loading skeleton for widget content + */ +function WidgetLoadingSkeleton() { + return ( +
+
+
+
+
+
+ ); +} + +/** + * Error display for widget + */ +function WidgetError({ message }: { message: string }) { + return ( +
+ +

{message}

+
+ ); +} + +export interface WidgetActionsProps { + children: ReactNode; +} + +/** + * Container for widget header actions + */ +export function WidgetActions({ children }: WidgetActionsProps) { + return
{children}
; +} diff --git a/src/shared/components/organisms/DashboardWidgets/index.ts b/src/shared/components/organisms/DashboardWidgets/index.ts new file mode 100644 index 0000000..832db28 --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/index.ts @@ -0,0 +1,25 @@ +/** + * Dashboard Widgets + * @description Role-based dashboard widget components + */ + +// Types +export * from './types'; + +// Core components +export { WidgetContainer, WidgetActions } from './WidgetContainer'; +export { StatCard, StatCardWithIcon } from './StatCard'; +export { QuickActions, CreateQuickAction } from './QuickActions'; +export { RecentActivity } from './RecentActivity'; +export { UpcomingTasks } from './UpcomingTasks'; +export { + PerformanceChart, + SalesPerformanceChart, + RevenuePerformanceChart, + OrdersPerformanceChart, +} from './PerformanceChart'; +export { + DashboardGrid, + createWidget, + createDashboardConfig, +} from './DashboardGrid'; diff --git a/src/shared/components/organisms/DashboardWidgets/types.ts b/src/shared/components/organisms/DashboardWidgets/types.ts new file mode 100644 index 0000000..f218db8 --- /dev/null +++ b/src/shared/components/organisms/DashboardWidgets/types.ts @@ -0,0 +1,160 @@ +/** + * Dashboard Widget Types + * @description Type definitions for role-based dashboard widgets + */ + +import type { ReactNode, ComponentType } from 'react'; +import type { LucideIcon } from 'lucide-react'; + +/** + * Widget size options for grid layout + */ +export type WidgetSize = 'sm' | 'md' | 'lg' | 'full'; + +/** + * Widget configuration + */ +export interface Widget { + id: string; + title: string; + component: ComponentType; + requiredPermission?: string; + requiredRole?: string[]; + size?: WidgetSize; + defaultVisible?: boolean; +} + +/** + * Dashboard layout configuration + */ +export interface DashboardConfig { + widgets: Widget[]; + layout?: 'grid' | 'masonry'; +} + +/** + * Props passed to widget components + */ +export interface WidgetComponentProps { + className?: string; +} + +/** + * StatCard props + */ +export interface StatCardProps { + title: string; + value: number | string; + change?: number; + changeType?: 'increase' | 'decrease' | 'neutral'; + changeLabel?: string; + icon?: LucideIcon; + format?: 'currency' | 'number' | 'percent'; + className?: string; + loading?: boolean; +} + +/** + * Quick action item + */ +export interface QuickActionItem { + id: string; + label: string; + icon: LucideIcon; + href?: string; + onClick?: () => void; + permission?: string; + variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger'; +} + +/** + * QuickActions props + */ +export interface QuickActionsProps { + actions?: QuickActionItem[]; + columns?: 2 | 3 | 4; + className?: string; +} + +/** + * Activity item + */ +export interface ActivityItem { + id: string; + type: 'create' | 'update' | 'delete' | 'login' | 'other'; + message: string; + user?: string; + timestamp: Date | string; + link?: string; +} + +/** + * RecentActivity props + */ +export interface RecentActivityProps { + activities?: ActivityItem[]; + maxItems?: number; + className?: string; + loading?: boolean; +} + +/** + * Task item + */ +export interface TaskItem { + id: string; + title: string; + dueDate?: Date | string; + priority?: 'low' | 'medium' | 'high' | 'urgent'; + status?: 'pending' | 'in_progress' | 'completed' | 'overdue'; + assignee?: string; + link?: string; +} + +/** + * UpcomingTasks props + */ +export interface UpcomingTasksProps { + tasks?: TaskItem[]; + maxItems?: number; + className?: string; + loading?: boolean; +} + +/** + * PerformanceChart props + */ +export interface PerformanceChartProps { + title?: string; + data?: Array<{ name: string; value: number; [key: string]: string | number }>; + type?: 'line' | 'bar' | 'area'; + height?: number; + className?: string; + loading?: boolean; +} + +/** + * WidgetContainer props + */ +export interface WidgetContainerProps { + title: string; + actions?: ReactNode; + children: ReactNode; + collapsible?: boolean; + defaultCollapsed?: boolean; + loading?: boolean; + error?: string | null; + className?: string; + headerClassName?: string; + contentClassName?: string; +} + +/** + * Widget grid size classes mapping + */ +export const WIDGET_SIZE_CLASSES: Record = { + sm: 'col-span-1', + md: 'col-span-1 lg:col-span-2', + lg: 'col-span-1 lg:col-span-3', + full: 'col-span-full', +}; diff --git a/src/shared/components/organisms/Kanban/Kanban.tsx b/src/shared/components/organisms/Kanban/Kanban.tsx new file mode 100644 index 0000000..015d2e1 --- /dev/null +++ b/src/shared/components/organisms/Kanban/Kanban.tsx @@ -0,0 +1,171 @@ +import { useState, type KeyboardEvent } from 'react'; +import { Plus, X } from 'lucide-react'; +import { cn } from '@utils/cn'; +import { KanbanColumn } from './KanbanColumn'; +import type { KanbanProps, KanbanItem } from './types'; + +/** + * Generic Kanban board component + * + * @example + * ```tsx + * const columns = [ + * { id: 'todo', title: 'To Do', color: 'blue', items: [...] }, + * { id: 'in-progress', title: 'In Progress', color: 'amber', items: [...] }, + * { id: 'done', title: 'Done', color: 'green', items: [...] }, + * ]; + * + * console.log('moved', itemId)} + * allowAddCard + * /> + * ``` + */ +export function Kanban({ + columns, + onItemClick, + onItemMove, + onAddItem, + onColumnAdd, + renderCard, + renderColumnStats, + className, + allowAddCard = false, + allowAddColumn = false, + emptyColumnMessage = 'No items', + dragOverMessage = 'Drop here', +}: KanbanProps) { + const [isAddingColumn, setIsAddingColumn] = useState(false); + const [newColumnTitle, setNewColumnTitle] = useState(''); + + const handleDrop = (targetColumnId: string) => (item: KanbanItem, newIndex: number) => { + // Find the source column + const sourceColumn = columns.find((col) => + col.items.some((i) => i.id === item.id) + ); + + if (!sourceColumn) return; + + // Don't trigger if dropping in the same column at the same position + if (sourceColumn.id === targetColumnId) { + const currentIndex = sourceColumn.items.findIndex((i) => i.id === item.id); + if (currentIndex === newIndex) return; + } + + onItemMove?.(item.id, sourceColumn.id, targetColumnId, newIndex); + }; + + const handleAddItem = (columnId: string) => (title: string) => { + onAddItem?.(columnId, title); + }; + + const handleAddColumn = () => { + const trimmedTitle = newColumnTitle.trim(); + if (trimmedTitle) { + onColumnAdd?.(trimmedTitle); + setNewColumnTitle(''); + setIsAddingColumn(false); + } + }; + + const handleCancelAddColumn = () => { + setNewColumnTitle(''); + setIsAddingColumn(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + handleAddColumn(); + } else if (e.key === 'Escape') { + handleCancelAddColumn(); + } + }; + + return ( +
+
+ {/* Columns */} + {columns.map((column) => ( + onItemClick?.(item, column.id)} + onAddItem={allowAddCard ? handleAddItem(column.id) : undefined} + renderCard={renderCard ? (item) => renderCard(item, column.id) : undefined} + renderStats={renderColumnStats} + allowAddCard={allowAddCard} + emptyMessage={emptyColumnMessage} + dragOverMessage={dragOverMessage} + /> + ))} + + {/* Add Column Button/Form */} + {allowAddColumn && onColumnAdd && ( +
+ {isAddingColumn ? ( +
+ setNewColumnTitle(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter column title..." + autoFocus + className={cn( + 'w-full rounded border border-gray-300 px-3 py-2 text-sm', + 'placeholder:text-gray-400', + 'focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500' + )} + /> +
+ + +
+
+ ) : ( + + )} +
+ )} +
+
+ ); +} + +export default Kanban; diff --git a/src/shared/components/organisms/Kanban/KanbanAddCard.tsx b/src/shared/components/organisms/Kanban/KanbanAddCard.tsx new file mode 100644 index 0000000..900ff81 --- /dev/null +++ b/src/shared/components/organisms/Kanban/KanbanAddCard.tsx @@ -0,0 +1,108 @@ +import { useState, useRef, useEffect, type KeyboardEvent } from 'react'; +import { Plus, X } from 'lucide-react'; +import { cn } from '@utils/cn'; +import type { KanbanAddCardProps } from './types'; + +/** + * Quick add card form component for Kanban columns + */ +export function KanbanAddCard({ + onAdd, + placeholder = 'Enter a title...', + buttonText = 'Add card', +}: KanbanAddCardProps) { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + const handleSubmit = () => { + const trimmedTitle = title.trim(); + if (trimmedTitle) { + onAdd(trimmedTitle); + setTitle(''); + setIsEditing(false); + } + }; + + const handleCancel = () => { + setTitle(''); + setIsEditing(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }; + + if (!isEditing) { + return ( + + ); + } + + return ( +
+