feat(ux-ui): add Calendar, Kanban, Chart, CommandPalette, OnboardingTour, DashboardWidgets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 19:26:08 -06:00
parent 04c61d0a71
commit 357c3cf1f9
47 changed files with 5138 additions and 0 deletions

View File

@ -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<CalendarView>('month');
const [internalSelectedDate, setInternalSelectedDate] = useState<Date>(new Date());
const [currentDate, setCurrentDate] = useState<Date>(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 (
<div
className={cn(
'flex flex-col overflow-hidden rounded-lg border bg-white',
className
)}
>
<CalendarHeader
currentDate={currentDate}
view={view}
onViewChange={handleViewChange}
onPrevious={handlePrevious}
onNext={handleNext}
onToday={handleToday}
locale={locale}
/>
{view === 'month' && (
<CalendarGrid
currentDate={currentDate}
events={events}
selectedDate={selectedDate}
onDateClick={handleSelectedDateChange}
onEventClick={onEventClick}
locale={locale}
/>
)}
{view === 'week' && (
<div className="flex flex-1 items-center justify-center p-8 text-gray-500">
Vista semanal - En desarrollo
</div>
)}
{view === 'day' && (
<div className="flex flex-1 items-center justify-center p-8 text-gray-500">
Vista diaria - En desarrollo
</div>
)}
</div>
);
}

View File

@ -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 (
<button
type="button"
onClick={handleClick}
className={cn(
'w-full truncate rounded border px-1.5 py-0.5 text-left text-xs font-medium',
'transition-colors hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-offset-1',
colorClasses[event.color || 'primary']
)}
title={event.title}
>
{event.title}
</button>
);
}

View File

@ -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 (
<div className="flex-1 bg-white">
<div className="grid grid-cols-7 border-b">
{WEEKDAYS.map((day, index) => (
<div
key={day}
className={cn(
'border-r py-2 text-center text-xs font-medium uppercase tracking-wider last:border-r-0',
index >= 5 ? 'bg-gray-50 text-gray-500' : 'text-gray-700'
)}
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 grid-rows-6">
{days.map((day, index) => {
const visibleEvents = day.events.slice(0, MAX_VISIBLE_EVENTS);
const hiddenCount = day.events.length - MAX_VISIBLE_EVENTS;
return (
<div
key={index}
onClick={() => 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'
)}
>
<div className="flex items-start justify-between">
<span
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-full text-sm',
day.isToday && 'bg-primary-600 font-semibold text-white',
!day.isToday && day.isCurrentMonth && 'text-gray-900',
!day.isToday && !day.isCurrentMonth && 'text-gray-400'
)}
>
{day.date.getDate()}
</span>
</div>
<div className="mt-1 space-y-1">
{visibleEvents.map((event) => (
<CalendarEvent
key={event.id}
event={event}
onClick={onEventClick}
/>
))}
{hiddenCount > 0 && (
<button
type="button"
className="w-full text-left text-xs text-gray-500 hover:text-gray-700"
onClick={(e) => {
e.stopPropagation();
handleDateClick(day.date);
}}
>
+{hiddenCount} mas
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -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<CalendarView, string> = {
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 (
<div className="flex items-center justify-between border-b bg-white px-4 py-3">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onPrevious}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={onNext}>
<ChevronRight className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={onToday}>
Hoy
</Button>
</div>
<h2 className="text-lg font-semibold capitalize text-gray-900">
{formatTitle()}
</h2>
<div className="flex items-center gap-1">
{(['month', 'week', 'day'] as CalendarView[]).map((v) => (
<button
key={v}
type="button"
onClick={() => onViewChange(v)}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
view === v
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-100'
)}
>
{viewLabels[v]}
</button>
))}
</div>
</div>
);
}

View File

@ -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';

View File

@ -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<string, unknown>;
}
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[];
}

View File

@ -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
* <AreaChart
* data={[
* { name: 'Jan', value: 100, baseline: 80 },
* { name: 'Feb', value: 150, baseline: 90 },
* ]}
* series={[
* { dataKey: 'value', name: 'Actual', color: '#3B82F6' },
* { dataKey: 'baseline', name: 'Baseline', color: '#22C55E' },
* ]}
* height={300}
* stacked={false}
* fillOpacity={0.3}
* showGrid
* showLegend
* />
* ```
*/
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 (
<ChartContainer height={height} className={className}>
<div
className={cn(
'flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50'
)}
>
{/* Visual representation placeholder */}
<div className="relative mb-4 h-20 w-48">
<svg viewBox="0 0 200 80" className="h-full w-full">
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_COLORS_FALLBACK[0]} stopOpacity={0.4} />
<stop offset="100%" stopColor={CHART_COLORS_FALLBACK[0]} stopOpacity={0.1} />
</linearGradient>
</defs>
<path
d="M0,60 Q40,40 80,50 T160,30 T200,45 L200,80 L0,80 Z"
fill="url(#areaGradient)"
/>
<path
d="M0,60 Q40,40 80,50 T160,30 T200,45"
fill="none"
stroke={CHART_COLORS_FALLBACK[0]}
strokeWidth="2"
/>
</svg>
</div>
<div className="text-center">
<p className="text-sm font-medium text-gray-600">
Area Chart
</p>
<p className="mt-1 text-xs text-gray-500">
{hasData
? `${data.length} data points, ${effectiveSeries.length} series`
: 'No data'}
</p>
<p className="mt-2 text-xs text-amber-600">
Install recharts: npm install recharts
</p>
</div>
</div>
</ChartContainer>
);
}

View File

@ -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
* <BarChart
* data={[
* { name: 'Q1', value: 100, target: 120 },
* { name: 'Q2', value: 150, target: 130 },
* ]}
* series={[
* { dataKey: 'value', name: 'Actual', color: '#3B82F6' },
* { dataKey: 'target', name: 'Target', color: '#22C55E' },
* ]}
* height={300}
* stacked={false}
* showGrid
* showLegend
* />
* ```
*/
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 (
<ChartContainer height={height} className={className}>
<div
className={cn(
'flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50'
)}
>
{/* Visual representation placeholder */}
<div className="mb-4 flex items-end gap-2">
{[50, 75, 40, 90, 60, 85].map((h, i) => (
<div
key={i}
className="w-10 rounded-t transition-all"
style={{
height: `${h}px`,
backgroundColor: CHART_COLORS_FALLBACK[i % CHART_COLORS_FALLBACK.length],
opacity: 0.6,
}}
/>
))}
</div>
<div className="text-center">
<p className="text-sm font-medium text-gray-600">
Bar Chart
</p>
<p className="mt-1 text-xs text-gray-500">
{hasData
? `${data.length} data points, ${effectiveSeries.length} series`
: 'No data'}
</p>
<p className="mt-2 text-xs text-amber-600">
Install recharts: npm install recharts
</p>
</div>
</div>
</ChartContainer>
);
}

View File

@ -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 (
<div
className={cn('w-full', className)}
style={{ height, minHeight: height }}
>
{children}
</div>
);
}

View File

@ -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 (
<div className={cn('flex flex-wrap gap-4 px-4 py-2', alignClasses[align])}>
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-gray-600">{entry.value}</span>
</div>
))}
</div>
);
}

View File

@ -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 (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-lg">
{label && (
<p className="mb-1 text-sm font-medium text-gray-900">{label}</p>
)}
<div className="space-y-1">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-gray-600">{entry.name}:</span>
<span className="font-medium text-gray-900">
{formatter ? formatter(entry.value, entry.name) : entry.value}
</span>
</div>
))}
</div>
</div>
);
}

View File

@ -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
* <LineChart
* data={[
* { name: 'Jan', value: 100, revenue: 200 },
* { name: 'Feb', value: 150, revenue: 250 },
* ]}
* series={[
* { dataKey: 'value', name: 'Ventas', color: '#3B82F6' },
* { dataKey: 'revenue', name: 'Ingresos', color: '#22C55E' },
* ]}
* height={300}
* showGrid
* showLegend
* showTooltip
* />
* ```
*/
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 (
<ChartContainer height={height} className={className}>
<div
className={cn(
'flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50'
)}
>
{/* Visual representation placeholder */}
<div className="mb-4 flex items-end gap-1">
{[40, 60, 45, 80, 55, 70, 90].map((h, i) => (
<div
key={i}
className="w-8 rounded-t transition-all"
style={{
height: `${h}px`,
backgroundColor: CHART_COLORS_FALLBACK[0],
opacity: 0.3,
}}
/>
))}
</div>
<div className="text-center">
<p className="text-sm font-medium text-gray-600">
Line Chart
</p>
<p className="mt-1 text-xs text-gray-500">
{hasData
? `${data.length} data points, ${effectiveSeries.length} series`
: 'No data'}
</p>
<p className="mt-2 text-xs text-amber-600">
Install recharts: npm install recharts
</p>
</div>
</div>
</ChartContainer>
);
}

View File

@ -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
* <PieChart
* data={[
* { name: 'Category A', value: 400 },
* { name: 'Category B', value: 300 },
* { name: 'Category C', value: 200 },
* ]}
* height={300}
* showLabels
* showLegend
* />
*
* // Donut chart
* <PieChart
* data={data}
* innerRadius={60}
* height={300}
* />
* ```
*/
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 (
<ChartContainer height={height} className={className}>
<div
className={cn(
'flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50'
)}
>
{/* Visual representation placeholder - pie chart */}
<div className="relative mb-4 h-24 w-24">
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
{/* 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) => (
<circle
key={i}
cx="50"
cy="50"
r="40"
fill="none"
stroke={segment.color}
strokeWidth="20"
strokeDasharray={`${segment.percent * 2.51} 251`}
strokeDashoffset={-segment.offset * 2.51}
opacity={0.7}
/>
))}
</svg>
</div>
<div className="text-center">
<p className="text-sm font-medium text-gray-600">
Pie Chart
</p>
<p className="mt-1 text-xs text-gray-500">
{hasData
? `${data.length} segments`
: 'No data'}
</p>
<p className="mt-2 text-xs text-amber-600">
Install recharts: npm install recharts
</p>
</div>
</div>
</ChartContainer>
);
}

View File

@ -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
* <LineChart
* data={salesData}
* series={[{ dataKey: 'revenue', name: 'Revenue' }]}
* height={300}
* showGrid
* showLegend
* />
*
* // Bar chart
* <BarChart
* data={quarterlyData}
* series={[
* { dataKey: 'actual', name: 'Actual', color: '#3B82F6' },
* { dataKey: 'target', name: 'Target', color: '#22C55E' },
* ]}
* stacked={false}
* />
*
* // Pie/Donut chart
* <PieChart
* data={categoryData}
* innerRadius={60} // Creates donut chart
* showLabels
* />
* ```
*/
// 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';

View File

@ -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
];

View File

@ -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<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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<CommandGroup[]>(() => {
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 = (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={handleOverlayClick}
aria-hidden="true"
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={{ duration: 0.15 }}
className={cn(
'w-full max-w-[600px] overflow-hidden rounded-xl shadow-2xl',
'bg-white dark:bg-gray-800',
'border border-gray-200 dark:border-gray-700'
)}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="command-palette-title"
>
{/* Search input */}
<div className="flex items-center gap-3 border-b border-gray-200 px-4 dark:border-gray-700">
<Search className="h-5 w-5 text-gray-400 dark:text-gray-500" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={cn(
'h-14 flex-1 bg-transparent text-base outline-none',
'text-gray-900 placeholder-gray-400',
'dark:text-gray-100 dark:placeholder-gray-500'
)}
id="command-palette-title"
aria-label="Buscar comandos"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
<div className="flex items-center gap-1">
<kbd
className={cn(
'hidden sm:inline-flex h-5 items-center rounded px-1.5',
'border border-gray-200 bg-gray-100',
'text-[10px] font-medium text-gray-500',
'dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400'
)}
>
ESC
</kbd>
</div>
</div>
{/* Results */}
<div
ref={listRef}
className={cn(
'max-h-[400px] overflow-y-auto',
'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-300',
'dark:scrollbar-thumb-gray-600'
)}
>
{filteredCommands.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center py-12 text-center">
<CommandIcon className="h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
No se encontraron comandos
</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
Intenta con otro termino de busqueda
</p>
</div>
) : (
/* Command groups */
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{commandGroups.map((group, groupIndex) => (
<CommandPaletteGroup
key={group.id}
group={group}
selectedIndex={selectedIndex}
baseIndex={getBaseIndex(groupIndex)}
onItemClick={handleItemClick}
onItemHover={handleItemHover}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div
className={cn(
'flex items-center justify-between gap-4 border-t px-4 py-2.5',
'border-gray-200 bg-gray-50 text-xs text-gray-500',
'dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400'
)}
>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
</kbd>
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
</kbd>
<span className="ml-1">navegar</span>
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
</kbd>
<span className="ml-1">seleccionar</span>
</span>
</div>
<div className="flex items-center gap-1">
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
</kbd>
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
K
</kbd>
<span className="ml-1">abrir/cerrar</span>
</div>
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
}

View File

@ -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 (
<div className="py-2">
{/* Group header */}
<div
className={cn(
'px-3 py-1.5 text-xs font-semibold uppercase tracking-wider',
'text-gray-500 dark:text-gray-400'
)}
>
{group.title}
</div>
{/* Group items */}
<div className="space-y-0.5 px-1">
{group.commands.map((command, index) => {
const globalIndex = baseIndex + index;
return (
<CommandPaletteItem
key={command.id}
command={command}
isSelected={selectedIndex === globalIndex}
onClick={() => onItemClick(command)}
onMouseEnter={() => onItemHover(globalIndex)}
/>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,99 @@
import { cn } from '@utils/cn';
import type { CommandPaletteItemProps } from './types';
/**
* Renders keyboard shortcut keys
*/
function ShortcutKeys({ keys }: { keys: string[] }) {
return (
<div className="flex items-center gap-1">
{keys.map((key, index) => (
<kbd
key={index}
className={cn(
'inline-flex h-5 min-w-[20px] items-center justify-center rounded',
'border border-gray-200 bg-gray-100 px-1.5',
'text-[10px] font-medium text-gray-500',
'dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400'
)}
>
{key}
</kbd>
))}
</div>
);
}
/**
* A single item in the command palette
*/
export function CommandPaletteItem({
command,
isSelected,
onClick,
onMouseEnter,
}: CommandPaletteItemProps) {
const Icon = command.icon;
return (
<button
type="button"
onClick={onClick}
onMouseEnter={onMouseEnter}
disabled={command.disabled}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left',
'transition-colors duration-100',
isSelected
? 'bg-primary-50 dark:bg-primary-900/30'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
command.disabled && 'cursor-not-allowed opacity-50'
)}
>
{/* Icon */}
{Icon && (
<span
className={cn(
'flex-shrink-0',
isSelected
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-400 dark:text-gray-500'
)}
>
<Icon className="h-5 w-5" />
</span>
)}
{/* Title and description */}
<div className="flex-1 min-w-0">
<div
className={cn(
'text-sm font-medium truncate',
isSelected
? 'text-primary-900 dark:text-primary-100'
: 'text-gray-900 dark:text-gray-100'
)}
>
{command.title}
</div>
{command.description && (
<div
className={cn(
'text-xs truncate mt-0.5',
isSelected
? 'text-primary-700 dark:text-primary-300'
: 'text-gray-500 dark:text-gray-400'
)}
>
{command.description}
</div>
)}
</div>
{/* Keyboard shortcut */}
{command.shortcut && command.shortcut.length > 0 && (
<ShortcutKeys keys={command.shortcut} />
)}
</button>
);
}

View File

@ -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<Command[]>(() => {
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 (
<CommandPaletteContext.Provider value={state}>
{children}
<CommandPalette
commands={state.commands}
isOpen={state.isOpen}
onClose={state.close}
/>
</CommandPaletteContext.Provider>
);
}

View File

@ -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 (
<CommandPaletteProvider
additionalCommands={additionalCommands}
includeDefaultCommands={includeDefaultCommands}
navigate={navigate}
>
{children}
</CommandPaletteProvider>
);
}

View File

@ -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';

View File

@ -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[];
}

View File

@ -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<CommandPaletteContextValue | undefined>(
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<Command[]>(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<CommandPaletteContextValue>(
() => ({
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<string, Command[]> = 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 };
}

View File

@ -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<WidgetSize, string> = {
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 (
<div className="text-center py-12 text-gray-500">
No hay widgets disponibles para mostrar
</div>
);
}
return (
<div
className={cn(
'grid gap-6',
config.layout === 'masonry'
? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
className
)}
>
{visibleWidgets.map((widget) => {
const WidgetComponent = widget.component;
const sizeClass = SIZE_CLASSES[widget.size || 'md'];
return (
<div key={widget.id} className={sizeClass}>
<WidgetComponent />
</div>
);
})}
</div>
);
}
/**
* Helper function to create a widget configuration
*/
export function createWidget<T extends object>(
config: Omit<Widget, 'component'> & {
component: React.ComponentType<T>;
}
): Widget {
return config as Widget;
}
/**
* Helper function to create a dashboard configuration
*/
export function createDashboardConfig(
widgets: Widget[],
options?: Partial<Omit<DashboardConfig, 'widgets'>>
): DashboardConfig {
return {
widgets,
layout: options?.layout || 'grid',
};
}

View File

@ -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 <PerformanceChartSkeleton height={height} className={className} />;
}
if (!data || data.length === 0) {
return (
<div
className={cn(
'flex items-center justify-center text-gray-500',
className
)}
style={{ height }}
>
No hay datos disponibles
</div>
);
}
const chartProps = {
data,
height,
showGrid: true,
showLegend: false,
showTooltip: true,
series: [
{ dataKey: 'value', name: 'Valor', color: '#3B82F6' },
],
};
const renderChart = () => {
switch (type) {
case 'bar':
return <BarChart {...chartProps} />;
case 'area':
return <AreaChart {...chartProps} />;
case 'line':
default:
return <LineChart {...chartProps} curved showDots />;
}
};
return <div className={className}>{renderChart()}</div>;
}
/**
* Loading skeleton for PerformanceChart
*/
function PerformanceChartSkeleton({
height,
className,
}: {
height: number;
className?: string;
}) {
return (
<div
className={cn('animate-pulse', className)}
style={{ height }}
>
<div className="w-full h-full bg-gray-100 rounded-lg flex items-end justify-around p-4 gap-2">
{/* Simulated bar chart skeleton */}
{[40, 60, 45, 80, 55, 70, 65].map((h, i) => (
<div
key={i}
className="bg-gray-200 rounded-t w-full"
style={{ height: `${h}%` }}
/>
))}
</div>
</div>
);
}
/**
* Pre-configured performance charts for common use cases
*/
export function SalesPerformanceChart(props: Omit<PerformanceChartProps, 'title'>) {
return <PerformanceChart title="Ventas" type="area" {...props} />;
}
export function RevenuePerformanceChart(props: Omit<PerformanceChartProps, 'title'>) {
return <PerformanceChart title="Ingresos" type="line" {...props} />;
}
export function OrdersPerformanceChart(props: Omit<PerformanceChartProps, 'title'>) {
return <PerformanceChart title="Pedidos" type="bar" {...props} />;
}

View File

@ -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 (
<div className="text-center py-4 text-gray-500">
No hay acciones disponibles
</div>
);
}
return (
<div
className={cn(
'grid gap-3',
columns === 2 && 'grid-cols-2',
columns === 3 && 'grid-cols-2 sm:grid-cols-3',
columns === 4 && 'grid-cols-2 sm:grid-cols-4',
className
)}
>
{filteredActions.map((action) => (
<QuickActionButton key={action.id} action={action} />
))}
</div>
);
}
/**
* 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 = (
<>
<Icon className="h-5 w-5 mb-1" />
<span className="text-xs font-medium">{label}</span>
</>
);
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 (
<Link to={href} className={baseClasses}>
{buttonContent}
</Link>
);
}
return (
<button type="button" onClick={onClick} className={baseClasses}>
{buttonContent}
</button>
);
}
/**
* 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 = (
<>
<Icon className="h-4 w-4" />
<span className="text-sm font-medium">{label}</span>
</>
);
if (href) {
return (
<Link to={href} className={baseClasses}>
{content}
</Link>
);
}
return (
<button type="button" onClick={onClick} className={baseClasses}>
{content}
</button>
);
}

View File

@ -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 <RecentActivitySkeleton count={maxItems} className={className} />;
}
const displayedActivities = activities.slice(0, maxItems);
if (displayedActivities.length === 0) {
return (
<div className={cn('text-center py-8 text-gray-500', className)}>
No hay actividad reciente
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{displayedActivities.map((activity, index) => (
<ActivityItemRow
key={activity.id}
activity={activity}
isLast={index === displayedActivities.length - 1}
/>
))}
</div>
);
}
/**
* 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 = (
<div className="flex items-start gap-3">
{/* Icon */}
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full flex-shrink-0',
config.bgColor
)}
>
<Icon className={cn('h-4 w-4', config.color)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 line-clamp-2">{activity.message}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
{activity.user && <span>{activity.user}</span>}
{activity.user && <span>·</span>}
<span>{timeAgo}</span>
</div>
</div>
</div>
);
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 (
<Link to={activity.link} className={containerClasses}>
{content}
</Link>
);
}
return <div className={containerClasses}>{content}</div>;
}
/**
* Loading skeleton for RecentActivity
*/
function RecentActivitySkeleton({
count,
className,
}: {
count: number;
className?: string;
}) {
return (
<div className={cn('space-y-4', className)}>
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="flex items-start gap-3 animate-pulse">
<div className="h-8 w-8 bg-gray-200 rounded-full" />
<div className="flex-1">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="mt-2 h-3 bg-gray-200 rounded w-1/3" />
</div>
</div>
))}
</div>
);
}

View File

@ -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 <StatCardSkeleton className={className} />;
}
return (
<div
className={cn(
'rounded-lg border bg-white shadow-sm p-6',
className
)}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-500 truncate">{title}</p>
<p className="mt-1 text-2xl font-semibold text-gray-900 truncate">
{formattedValue}
</p>
</div>
{Icon && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary-50 flex-shrink-0 ml-4">
<Icon className="h-6 w-6 text-primary-600" />
</div>
)}
</div>
{change !== undefined && (
<div className="mt-4 flex items-center">
<TrendIndicator type={changeType} />
<span
className={cn(
'ml-1 text-sm font-medium',
changeType === 'increase' && 'text-success-600',
changeType === 'decrease' && 'text-danger-600',
changeType === 'neutral' && 'text-gray-500'
)}
>
{changeType === 'increase' && '+'}
{change}%
</span>
<span className="ml-2 text-sm text-gray-500">{changeLabel}</span>
</div>
)}
</div>
);
}
/**
* 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 <TrendingUp {...iconProps} className={cn(iconProps.className, 'text-success-500')} />;
case 'decrease':
return <TrendingDown {...iconProps} className={cn(iconProps.className, 'text-danger-500')} />;
case 'neutral':
default:
return <Minus {...iconProps} className={cn(iconProps.className, 'text-gray-400')} />;
}
}
/**
* Loading skeleton for StatCard
*/
function StatCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-lg border bg-white shadow-sm p-6', className)}>
<div className="animate-pulse">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="h-4 bg-gray-200 rounded w-24" />
<div className="mt-2 h-8 bg-gray-200 rounded w-32" />
</div>
<div className="h-12 w-12 bg-gray-200 rounded-lg" />
</div>
<div className="mt-4 flex items-center gap-2">
<div className="h-4 w-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-16" />
</div>
</div>
</div>
);
}
/**
* StatCard with custom icon component
* @description Convenience component for StatCard with explicit icon prop
*/
export interface StatCardWithIconProps extends Omit<StatCardProps, 'icon'> {
icon: LucideIcon;
}
export function StatCardWithIcon({
icon: Icon,
...props
}: StatCardWithIconProps) {
return <StatCard {...props} icon={Icon} />;
}

View File

@ -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<TaskItem['priority']>,
{ 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<TaskItem['status']>,
{ 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 <UpcomingTasksSkeleton count={maxItems} className={className} />;
}
// 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 (
<div className={cn('text-center py-8 text-gray-500', className)}>
<Calendar className="h-8 w-8 mx-auto mb-2 text-gray-400" />
<p>No hay tareas pendientes</p>
</div>
);
}
return (
<div className={cn('space-y-3', className)}>
{sortedTasks.map((task) => (
<TaskItemRow key={task.id} task={task} />
))}
</div>
);
}
/**
* 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 = (
<div className="flex items-start gap-3">
{/* Status icon */}
<StatusIcon className={cn('h-5 w-5 mt-0.5 flex-shrink-0', statusConfig.color)} />
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={cn(
'text-sm font-medium line-clamp-2',
status === 'completed' ? 'text-gray-400 line-through' : 'text-gray-900'
)}
>
{task.title}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
{/* Due date */}
{dueDate && (
<span
className={cn(
'text-xs',
isOverdue ? 'text-danger-600 font-medium' : 'text-gray-500'
)}
>
{isOverdue
? `Vencida hace ${formatDistanceToNow(dueDate)}`
: `Vence ${formatDistanceToNow(dueDate)}`}
</span>
)}
{/* Priority badge */}
{priorityConfig && (
<span
className={cn(
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium',
priorityConfig.bgColor,
priorityConfig.color
)}
>
{priorityConfig.label}
</span>
)}
{/* Assignee */}
{task.assignee && (
<span className="text-xs text-gray-500">· {task.assignee}</span>
)}
</div>
</div>
</div>
);
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 (
<Link to={task.link} className={containerClasses}>
{content}
</Link>
);
}
return <div className={containerClasses}>{content}</div>;
}
/**
* Loading skeleton for UpcomingTasks
*/
function UpcomingTasksSkeleton({
count,
className,
}: {
count: number;
className?: string;
}) {
return (
<div className={cn('space-y-3', className)}>
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 animate-pulse"
>
<div className="h-5 w-5 bg-gray-200 rounded-full" />
<div className="flex-1">
<div className="h-4 bg-gray-200 rounded w-4/5" />
<div className="mt-2 flex gap-2">
<div className="h-3 bg-gray-200 rounded w-20" />
<div className="h-3 bg-gray-200 rounded w-12" />
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -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 (
<div
className={cn(
'rounded-lg border bg-white shadow-sm overflow-hidden',
className
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between px-6 py-4 border-b',
collapsible && 'cursor-pointer hover:bg-gray-50 transition-colors',
headerClassName
)}
onClick={handleToggleCollapse}
role={collapsible ? 'button' : undefined}
tabIndex={collapsible ? 0 : undefined}
onKeyDown={
collapsible
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleToggleCollapse();
}
}
: undefined
}
>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<div className="flex items-center gap-2">
{actions && !isCollapsed && (
<div onClick={(e) => e.stopPropagation()}>{actions}</div>
)}
{collapsible && (
<span className="text-gray-400">
{isCollapsed ? (
<ChevronDown className="h-5 w-5" />
) : (
<ChevronUp className="h-5 w-5" />
)}
</span>
)}
</div>
</div>
{/* Content */}
{!isCollapsed && (
<div className={cn('px-6 py-4', contentClassName)}>
{loading ? (
<WidgetLoadingSkeleton />
) : error ? (
<WidgetError message={error} />
) : (
children
)}
</div>
)}
</div>
);
}
/**
* Loading skeleton for widget content
*/
function WidgetLoadingSkeleton() {
return (
<div className="animate-pulse space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="h-4 bg-gray-200 rounded w-2/3" />
</div>
);
}
/**
* Error display for widget
*/
function WidgetError({ message }: { message: string }) {
return (
<div className="flex items-center gap-3 text-danger-600 bg-danger-50 rounded-md p-4">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{message}</p>
</div>
);
}
export interface WidgetActionsProps {
children: ReactNode;
}
/**
* Container for widget header actions
*/
export function WidgetActions({ children }: WidgetActionsProps) {
return <div className="flex items-center gap-2">{children}</div>;
}

View File

@ -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';

View File

@ -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<WidgetComponentProps>;
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<WidgetSize, string> = {
sm: 'col-span-1',
md: 'col-span-1 lg:col-span-2',
lg: 'col-span-1 lg:col-span-3',
full: 'col-span-full',
};

View File

@ -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: [...] },
* ];
*
* <Kanban
* columns={columns}
* onItemMove={(itemId, from, to) => 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<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleAddColumn();
} else if (e.key === 'Escape') {
handleCancelAddColumn();
}
};
return (
<div className={cn('flex-1 overflow-x-auto', className)}>
<div className="flex gap-4 min-w-max p-1">
{/* Columns */}
{columns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
onDrop={handleDrop(column.id)}
onItemClick={(item) => 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 && (
<div className="min-w-[280px] max-w-[320px]">
{isAddingColumn ? (
<div className="rounded-lg border-2 border-dashed border-gray-300 bg-white p-3">
<input
type="text"
value={newColumnTitle}
onChange={(e) => 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'
)}
/>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={handleAddColumn}
disabled={!newColumnTitle.trim()}
className={cn(
'rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white',
'hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
>
Add column
</button>
<button
type="button"
onClick={handleCancelAddColumn}
className={cn(
'rounded-md p-1.5 text-gray-500',
'hover:bg-gray-100 hover:text-gray-700',
'transition-colors duration-150'
)}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setIsAddingColumn(true)}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-lg',
'border-2 border-dashed border-gray-300 bg-gray-50',
'px-4 py-8 text-sm text-gray-500',
'hover:border-gray-400 hover:bg-gray-100 hover:text-gray-700',
'transition-colors duration-150'
)}
>
<Plus className="h-5 w-5" />
<span>Add column</span>
</button>
)}
</div>
)}
</div>
</div>
);
}
export default Kanban;

View File

@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
handleCancel();
}
};
if (!isEditing) {
return (
<button
type="button"
onClick={() => setIsEditing(true)}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm',
'text-gray-500 hover:bg-gray-100 hover:text-gray-700',
'transition-colors duration-150'
)}
>
<Plus className="h-4 w-4" />
<span>{buttonText}</span>
</button>
);
}
return (
<div className="rounded-lg border bg-white p-2 shadow-sm">
<textarea
ref={inputRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={2}
className={cn(
'w-full resize-none rounded border-0 p-2 text-sm',
'placeholder:text-gray-400',
'focus:outline-none focus:ring-0'
)}
/>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={!title.trim()}
className={cn(
'rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white',
'hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
>
Add
</button>
<button
type="button"
onClick={handleCancel}
className={cn(
'rounded-md p-1.5 text-gray-500',
'hover:bg-gray-100 hover:text-gray-700',
'transition-colors duration-150'
)}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}
export default KanbanAddCard;

View File

@ -0,0 +1,118 @@
import type { DragEvent } from 'react';
import { Calendar } from 'lucide-react';
import { cn } from '@utils/cn';
import type { KanbanCardProps } from './types';
/**
* Format a date for display
*/
const formatDate = (date: Date): string => {
return date.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
};
/**
* Get initials from a name
*/
const getInitials = (name: string): string => {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return `${parts[0]?.[0] || ''}${parts[1]?.[0] || ''}`.toUpperCase();
}
return name.slice(0, 2).toUpperCase();
};
/**
* Generic Kanban card component with drag support
*/
export function KanbanCard({ item, columnId, onDragStart, onClick }: KanbanCardProps) {
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(
'application/json',
JSON.stringify({ item, sourceColumnId: columnId })
);
onDragStart(e, item);
};
return (
<div
draggable
onDragStart={handleDragStart}
onClick={() => onClick?.(item)}
className={cn(
'cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all',
'hover:shadow-md hover:border-gray-300',
'active:cursor-grabbing active:shadow-lg'
)}
>
{/* Labels */}
{item.labels && item.labels.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{item.labels.map((label, index) => (
<span
key={index}
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
`bg-${label.color}-100 text-${label.color}-700`
)}
style={{
backgroundColor: `var(--color-${label.color}-100, #e0e7ff)`,
color: `var(--color-${label.color}-700, #4338ca)`,
}}
>
{label.text}
</span>
))}
</div>
)}
{/* Title */}
<h4 className="font-medium text-gray-900 line-clamp-2">{item.title}</h4>
{/* Description */}
{item.description && (
<p className="mt-1 text-sm text-gray-500 line-clamp-2">{item.description}</p>
)}
{/* Footer */}
{(item.dueDate || item.assignee) && (
<div className="mt-3 flex items-center justify-between pt-2 border-t border-gray-100">
{/* Due Date */}
{item.dueDate ? (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Calendar className="h-3.5 w-3.5" />
<span>{formatDate(item.dueDate)}</span>
</div>
) : (
<div />
)}
{/* Assignee */}
{item.assignee && (
<div
className={cn(
'relative inline-flex items-center justify-center overflow-hidden rounded-full',
'h-6 w-6 text-xs bg-gray-100'
)}
title={item.assignee.name}
>
{item.assignee.avatar ? (
<img
src={item.assignee.avatar}
alt={item.assignee.name}
className="h-full w-full object-cover"
/>
) : (
<span className="font-medium text-gray-600">
{getInitials(item.assignee.name)}
</span>
)}
</div>
)}
</div>
)}
</div>
);
}
export default KanbanCard;

View File

@ -0,0 +1,215 @@
import { useState, type DragEvent } from 'react';
import { cn } from '@utils/cn';
import { KanbanCard } from './KanbanCard';
import { KanbanAddCard } from './KanbanAddCard';
import type { KanbanColumnProps, KanbanItem, ColumnColors } from './types';
/**
* Color configurations for different column themes
*/
const columnColors: Record<string, ColumnColors> = {
blue: {
bg: 'bg-blue-50',
border: 'border-blue-200',
header: 'bg-blue-100',
text: 'text-blue-700',
},
indigo: {
bg: 'bg-indigo-50',
border: 'border-indigo-200',
header: 'bg-indigo-100',
text: 'text-indigo-700',
},
amber: {
bg: 'bg-amber-50',
border: 'border-amber-200',
header: 'bg-amber-100',
text: 'text-amber-700',
},
green: {
bg: 'bg-green-50',
border: 'border-green-200',
header: 'bg-green-100',
text: 'text-green-700',
},
red: {
bg: 'bg-red-50',
border: 'border-red-200',
header: 'bg-red-100',
text: 'text-red-700',
},
purple: {
bg: 'bg-purple-50',
border: 'border-purple-200',
header: 'bg-purple-100',
text: 'text-purple-700',
},
pink: {
bg: 'bg-pink-50',
border: 'border-pink-200',
header: 'bg-pink-100',
text: 'text-pink-700',
},
gray: {
bg: 'bg-gray-50',
border: 'border-gray-200',
header: 'bg-gray-100',
text: 'text-gray-700',
},
};
/**
* Generic Kanban column component with drag-and-drop support
*/
export function KanbanColumn({
column,
onDrop,
onItemClick,
onAddItem,
renderCard,
renderStats,
allowAddCard = false,
emptyMessage = 'No items',
dragOverMessage = 'Drop here',
}: KanbanColumnProps) {
const [isDragOver, setIsDragOver] = useState(false);
// Get colors with fallback - gray is guaranteed to exist
const colorKey = column.color && column.color in columnColors ? column.color : 'gray';
const colors = columnColors[colorKey] as ColumnColors;
const itemCount = column.items.length;
const isOverLimit = column.limit !== undefined && itemCount > column.limit;
const handleDragStart = (_e: DragEvent<HTMLDivElement>, _item: KanbanItem) => {
// Data transfer is handled by KanbanCard
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
try {
const data = e.dataTransfer.getData('application/json');
const { item, sourceColumnId } = JSON.parse(data) as {
item: KanbanItem;
sourceColumnId: string;
};
// Only trigger drop if item is from a different column
// or we want to reorder within the same column
if (sourceColumnId !== column.id || column.items.some((i) => i.id === item.id)) {
// Calculate drop index based on mouse position
const dropIndex = column.items.length;
onDrop(item, dropIndex);
}
} catch (err) {
console.error('Error parsing dropped data:', err);
}
};
return (
<div
className={cn(
'flex flex-col rounded-lg border-2 transition-all min-w-[280px] max-w-[320px] w-full',
colors.bg,
colors.border,
isDragOver && 'border-primary-400 ring-2 ring-primary-200'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Column Header */}
<div className={cn('rounded-t-md p-3', colors.header)}>
<div className="flex items-center justify-between">
<h3 className={cn('font-semibold', colors.text)}>{column.title}</h3>
<div className="flex items-center gap-2">
{/* WIP Limit Indicator */}
{column.limit !== undefined && (
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs font-medium',
isOverLimit
? 'bg-red-100 text-red-700'
: 'bg-white/50 text-gray-600'
)}
title={`WIP Limit: ${column.limit}`}
>
{itemCount}/{column.limit}
</span>
)}
{/* Item Count */}
{column.limit === undefined && (
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs font-medium',
colors.bg,
colors.text
)}
>
{itemCount}
</span>
)}
</div>
</div>
{/* Custom Stats */}
{renderStats && <div className="mt-1">{renderStats(column)}</div>}
</div>
{/* Cards Container */}
<div
className={cn(
'flex-1 space-y-2 overflow-y-auto p-2',
'min-h-[200px] max-h-[calc(100vh-320px)]'
)}
>
{column.items.length === 0 ? (
<div
className={cn(
'flex h-20 items-center justify-center rounded-lg border-2 border-dashed',
colors.border,
'text-sm text-gray-400'
)}
>
{isDragOver ? dragOverMessage : emptyMessage}
</div>
) : (
column.items.map((item) =>
renderCard ? (
<div key={item.id}>{renderCard(item)}</div>
) : (
<KanbanCard
key={item.id}
item={item}
columnId={column.id}
onDragStart={handleDragStart}
onClick={onItemClick}
/>
)
)
)}
</div>
{/* Quick Add Card */}
{allowAddCard && onAddItem && (
<div className="p-2 pt-0">
<KanbanAddCard onAdd={onAddItem} />
</div>
)}
</div>
);
}
export default KanbanColumn;

View File

@ -0,0 +1,14 @@
export { Kanban } from './Kanban';
export { KanbanColumn } from './KanbanColumn';
export { KanbanCard } from './KanbanCard';
export { KanbanAddCard } from './KanbanAddCard';
export type {
KanbanItem,
KanbanColumn as KanbanColumnType,
KanbanProps,
KanbanColumnProps,
KanbanCardProps,
KanbanAddCardProps,
ColumnColors,
} from './types';

View File

@ -0,0 +1,138 @@
import type { ReactNode } from 'react';
/**
* Represents a single item/card in the Kanban board
*/
export interface KanbanItem {
/** Unique identifier for the item */
id: string;
/** Title displayed on the card */
title: string;
/** Optional description/subtitle */
description?: string;
/** Optional labels/tags */
labels?: Array<{
text: string;
color: string;
}>;
/** Optional assignee information */
assignee?: {
name: string;
avatar?: string;
};
/** Optional due date */
dueDate?: Date;
/** Custom metadata for domain-specific data */
metadata?: Record<string, unknown>;
}
/**
* Represents a column in the Kanban board
*/
export interface KanbanColumn {
/** Unique identifier for the column */
id: string;
/** Title displayed in the column header */
title: string;
/** Optional color theme for the column (tailwind color name without prefix) */
color?: 'blue' | 'indigo' | 'amber' | 'green' | 'red' | 'purple' | 'pink' | 'gray';
/** Items in this column */
items: KanbanItem[];
/** Optional WIP (Work in Progress) limit */
limit?: number;
}
/**
* Column color configuration
*/
export interface ColumnColors {
bg: string;
border: string;
header: string;
text: string;
}
/**
* Props for the main Kanban board component
*/
export interface KanbanProps {
/** Array of columns to display */
columns: KanbanColumn[];
/** Callback when an item is clicked */
onItemClick?: (item: KanbanItem, columnId: string) => void;
/** Callback when an item is moved between columns */
onItemMove?: (
itemId: string,
fromColumnId: string,
toColumnId: string,
newIndex: number
) => void;
/** Callback when a new item is added via quick add */
onAddItem?: (columnId: string, title: string) => void;
/** Callback when a new column is added */
onColumnAdd?: (title: string) => void;
/** Custom render function for cards */
renderCard?: (item: KanbanItem, columnId: string) => ReactNode;
/** Custom render function for column header stats */
renderColumnStats?: (column: KanbanColumn) => ReactNode;
/** Additional CSS classes */
className?: string;
/** Allow quick add card at bottom of columns */
allowAddCard?: boolean;
/** Allow adding new columns */
allowAddColumn?: boolean;
/** Empty state message when a column has no items */
emptyColumnMessage?: string;
/** Drag over message shown in drop zone */
dragOverMessage?: string;
}
/**
* Props for individual Kanban column
*/
export interface KanbanColumnProps {
/** Column data */
column: KanbanColumn;
/** Callback when an item is dropped in this column */
onDrop: (item: KanbanItem, newIndex: number) => void;
/** Callback when an item is clicked */
onItemClick?: (item: KanbanItem) => void;
/** Callback for quick add */
onAddItem?: (title: string) => void;
/** Custom render function for cards */
renderCard?: (item: KanbanItem) => ReactNode;
/** Custom render function for column stats */
renderStats?: (column: KanbanColumn) => ReactNode;
/** Allow quick add card */
allowAddCard?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Drag over message */
dragOverMessage?: string;
}
/**
* Props for individual Kanban card
*/
export interface KanbanCardProps {
/** Item data */
item: KanbanItem;
/** Column ID this card belongs to */
columnId: string;
/** Callback when drag starts */
onDragStart: (e: React.DragEvent<HTMLDivElement>, item: KanbanItem) => void;
/** Callback when card is clicked */
onClick?: (item: KanbanItem) => void;
}
/**
* Props for quick add card form
*/
export interface KanbanAddCardProps {
/** Callback when a new card is submitted */
onAdd: (title: string) => void;
/** Placeholder text for input */
placeholder?: string;
/** Button text */
buttonText?: string;
}

View File

@ -87,6 +87,7 @@ export function Modal({
type="button"
onClick={onClose}
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Cerrar"
>
<X className="h-5 w-5" />
</button>

View File

@ -0,0 +1,77 @@
import { useContext, useEffect } from 'react';
import { TourContext } from './TourProvider';
import { TourStep } from './TourStep';
import type { TourConfig } from './types';
export interface OnboardingTourProps {
config: TourConfig;
autoStart?: boolean;
}
export function OnboardingTour({ config, autoStart = false }: OnboardingTourProps) {
const context = useContext(TourContext);
if (!context) {
throw new Error('OnboardingTour must be used within a TourProvider');
}
const {
state,
configs,
registerTour,
unregisterTour,
startTour,
nextStep,
prevStep,
skipTour,
hasCompletedTour,
} = context;
// Register tour config on mount
useEffect(() => {
registerTour(config);
return () => unregisterTour(config.id);
}, [config, registerTour, unregisterTour]);
// Auto-start tour if configured and not completed
useEffect(() => {
if (autoStart && !hasCompletedTour(config.id) && !state.isActive) {
// Small delay to ensure elements are rendered
const timeoutId = setTimeout(() => {
startTour(config.id);
}, 500);
return () => clearTimeout(timeoutId);
}
}, [autoStart, config.id, hasCompletedTour, state.isActive, startTour]);
// Don't render if tour is not active or this is not the current tour
if (!state.isActive || state.tourId !== config.id) {
return null;
}
const currentConfig = configs.get(config.id);
if (!currentConfig) {
return null;
}
const currentStep = currentConfig.steps[state.currentStep];
if (!currentStep) {
return null;
}
const isFirst = state.currentStep === 0;
const isLast = state.currentStep === currentConfig.steps.length - 1;
return (
<TourStep
step={currentStep}
currentIndex={state.currentStep}
totalSteps={currentConfig.steps.length}
onNext={nextStep}
onPrev={prevStep}
onSkip={skipTour}
isFirst={isFirst}
isLast={isLast}
/>
);
}

View File

@ -0,0 +1,192 @@
import { createContext, useCallback, useMemo, useState, useEffect } from 'react';
import type { TourConfig, TourState, TourContextValue, TourProviderProps } from './types';
const STORAGE_KEY_PREFIX = 'erp-tour-completed';
export const TourContext = createContext<TourContextValue | null>(null);
const initialState: TourState = {
isActive: false,
currentStep: 0,
tourId: null,
};
export function TourProvider({
children,
storageKey = STORAGE_KEY_PREFIX,
}: TourProviderProps) {
const [state, setState] = useState<TourState>(initialState);
const [configs, setConfigs] = useState<Map<string, TourConfig>>(new Map());
const [completedTours, setCompletedTours] = useState<Set<string>>(new Set());
// Load completed tours from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
setCompletedTours(new Set(parsed));
}
}
} catch {
// Ignore localStorage errors
}
}, [storageKey]);
// Persist completed tours to localStorage
const persistCompletedTours = useCallback(
(tours: Set<string>) => {
try {
localStorage.setItem(storageKey, JSON.stringify(Array.from(tours)));
} catch {
// Ignore localStorage errors
}
},
[storageKey]
);
const registerTour = useCallback((config: TourConfig) => {
setConfigs((prev) => {
const next = new Map(prev);
next.set(config.id, config);
return next;
});
}, []);
const unregisterTour = useCallback((tourId: string) => {
setConfigs((prev) => {
const next = new Map(prev);
next.delete(tourId);
return next;
});
}, []);
const startTour = useCallback(
(tourId: string) => {
const config = configs.get(tourId);
if (!config || config.steps.length === 0) {
console.warn(`Tour "${tourId}" not found or has no steps`);
return;
}
// Don't start if already completed (unless reset)
if (completedTours.has(tourId)) {
return;
}
setState({
isActive: true,
currentStep: 0,
tourId,
});
},
[configs, completedTours]
);
const endTour = useCallback(() => {
const currentConfig = state.tourId ? configs.get(state.tourId) : null;
if (currentConfig) {
currentConfig.onComplete?.();
// Mark as completed
setCompletedTours((prev) => {
const next = new Set(prev);
next.add(state.tourId!);
persistCompletedTours(next);
return next;
});
}
setState(initialState);
}, [state.tourId, configs, persistCompletedTours]);
const nextStep = useCallback(() => {
const currentConfig = state.tourId ? configs.get(state.tourId) : null;
if (!currentConfig) return;
const isLast = state.currentStep >= currentConfig.steps.length - 1;
if (isLast) {
endTour();
} else {
setState((prev) => ({
...prev,
currentStep: prev.currentStep + 1,
}));
}
}, [state.tourId, state.currentStep, configs, endTour]);
const prevStep = useCallback(() => {
if (state.currentStep > 0) {
setState((prev) => ({
...prev,
currentStep: prev.currentStep - 1,
}));
}
}, [state.currentStep]);
const skipTour = useCallback(() => {
const currentConfig = state.tourId ? configs.get(state.tourId) : null;
if (currentConfig) {
currentConfig.onSkip?.();
// Mark as completed even when skipped
setCompletedTours((prev) => {
const next = new Set(prev);
next.add(state.tourId!);
persistCompletedTours(next);
return next;
});
}
setState(initialState);
}, [state.tourId, configs, persistCompletedTours]);
const hasCompletedTour = useCallback(
(tourId: string) => completedTours.has(tourId),
[completedTours]
);
const resetTour = useCallback(
(tourId: string) => {
setCompletedTours((prev) => {
const next = new Set(prev);
next.delete(tourId);
persistCompletedTours(next);
return next;
});
},
[persistCompletedTours]
);
const value = useMemo<TourContextValue>(
() => ({
state,
configs,
registerTour,
unregisterTour,
startTour,
endTour,
nextStep,
prevStep,
skipTour,
hasCompletedTour,
resetTour,
}),
[
state,
configs,
registerTour,
unregisterTour,
startTour,
endTour,
nextStep,
prevStep,
skipTour,
hasCompletedTour,
resetTour,
]
);
return <TourContext.Provider value={value}>{children}</TourContext.Provider>;
}

View File

@ -0,0 +1,160 @@
import { useEffect, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { TourTooltip } from './TourTooltip';
import type { TourStepProps } from './types';
const DEFAULT_SPOTLIGHT_PADDING = 8;
export function TourStep({
step,
currentIndex,
totalSteps,
onNext,
onPrev,
onSkip,
isFirst,
isLast,
}: TourStepProps) {
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
// targetElement is tracked for potential future use (e.g., focus management)
const [, setTargetElement] = useState<Element | null>(null);
const spotlightPadding = step.spotlightPadding ?? DEFAULT_SPOTLIGHT_PADDING;
const updateTargetRect = useCallback(() => {
const element = document.querySelector(step.target);
if (element) {
setTargetElement(element);
setTargetRect(element.getBoundingClientRect());
} else {
setTargetElement(null);
setTargetRect(null);
}
}, [step.target]);
useEffect(() => {
updateTargetRect();
// Scroll element into view
const element = document.querySelector(step.target);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
// Update rect after scroll completes
const timeoutId = setTimeout(updateTargetRect, 300);
return () => clearTimeout(timeoutId);
}
}, [step.target, updateTargetRect]);
useEffect(() => {
const handleResize = () => updateTargetRect();
const handleScroll = () => updateTargetRect();
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll, true);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll, true);
};
}, [updateTargetRect]);
const handleNext = () => {
step.onNext?.();
onNext();
};
const handlePrev = () => {
step.onPrev?.();
onPrev();
};
const spotlightStyle = targetRect
? {
top: targetRect.top - spotlightPadding,
left: targetRect.left - spotlightPadding,
width: targetRect.width + spotlightPadding * 2,
height: targetRect.height + spotlightPadding * 2,
}
: null;
const content = (
<AnimatePresence mode="wait">
<div key={step.id} className="fixed inset-0 z-[10000]">
{/* Overlay with spotlight cutout */}
{!step.disableOverlay && (
<svg
className="fixed inset-0 h-full w-full"
style={{ pointerEvents: 'none' }}
>
<defs>
<mask id={`spotlight-mask-${step.id}`}>
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{spotlightStyle && (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
x={spotlightStyle.left}
y={spotlightStyle.top}
width={spotlightStyle.width}
height={spotlightStyle.height}
rx="8"
fill="black"
/>
)}
</mask>
</defs>
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.5)"
mask={`url(#spotlight-mask-${step.id})`}
style={{ pointerEvents: 'auto' }}
onClick={onSkip}
/>
</svg>
)}
{/* Spotlight border highlight */}
{spotlightStyle && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="pointer-events-none fixed rounded-lg ring-2 ring-primary-500 ring-offset-2"
style={{
top: spotlightStyle.top,
left: spotlightStyle.left,
width: spotlightStyle.width,
height: spotlightStyle.height,
}}
/>
)}
{/* Tooltip */}
<TourTooltip
step={step}
currentIndex={currentIndex}
totalSteps={totalSteps}
onNext={handleNext}
onPrev={handlePrev}
onSkip={onSkip}
isFirst={isFirst}
isLast={isLast}
targetRect={targetRect}
/>
</div>
</AnimatePresence>
);
return createPortal(content, document.body);
}

View File

@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { cn } from '@utils/cn';
import type { TourTooltipProps } from './types';
const TOOLTIP_OFFSET = 12;
const ARROW_SIZE = 8;
export function TourTooltip({
step,
currentIndex,
totalSteps,
onNext,
onPrev,
onSkip,
isFirst,
isLast,
targetRect,
}: TourTooltipProps) {
const placement = step.placement ?? 'bottom';
const position = useMemo(() => {
if (!targetRect) {
return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
}
const scrollX = window.scrollX;
const scrollY = window.scrollY;
switch (placement) {
case 'top':
return {
top: targetRect.top + scrollY - TOOLTIP_OFFSET,
left: targetRect.left + scrollX + targetRect.width / 2,
transform: 'translate(-50%, -100%)',
};
case 'bottom':
return {
top: targetRect.bottom + scrollY + TOOLTIP_OFFSET,
left: targetRect.left + scrollX + targetRect.width / 2,
transform: 'translate(-50%, 0)',
};
case 'left':
return {
top: targetRect.top + scrollY + targetRect.height / 2,
left: targetRect.left + scrollX - TOOLTIP_OFFSET,
transform: 'translate(-100%, -50%)',
};
case 'right':
return {
top: targetRect.top + scrollY + targetRect.height / 2,
left: targetRect.right + scrollX + TOOLTIP_OFFSET,
transform: 'translate(0, -50%)',
};
default:
return {
top: targetRect.bottom + scrollY + TOOLTIP_OFFSET,
left: targetRect.left + scrollX + targetRect.width / 2,
transform: 'translate(-50%, 0)',
};
}
}, [targetRect, placement]);
const arrowStyles = useMemo(() => {
const base = {
position: 'absolute' as const,
width: 0,
height: 0,
borderStyle: 'solid' as const,
};
switch (placement) {
case 'top':
return {
...base,
bottom: -ARROW_SIZE,
left: '50%',
transform: 'translateX(-50%)',
borderWidth: `${ARROW_SIZE}px ${ARROW_SIZE}px 0 ${ARROW_SIZE}px`,
borderColor: 'white transparent transparent transparent',
};
case 'bottom':
return {
...base,
top: -ARROW_SIZE,
left: '50%',
transform: 'translateX(-50%)',
borderWidth: `0 ${ARROW_SIZE}px ${ARROW_SIZE}px ${ARROW_SIZE}px`,
borderColor: 'transparent transparent white transparent',
};
case 'left':
return {
...base,
right: -ARROW_SIZE,
top: '50%',
transform: 'translateY(-50%)',
borderWidth: `${ARROW_SIZE}px 0 ${ARROW_SIZE}px ${ARROW_SIZE}px`,
borderColor: 'transparent transparent transparent white',
};
case 'right':
return {
...base,
left: -ARROW_SIZE,
top: '50%',
transform: 'translateY(-50%)',
borderWidth: `${ARROW_SIZE}px ${ARROW_SIZE}px ${ARROW_SIZE}px 0`,
borderColor: 'transparent white transparent transparent',
};
default:
return base;
}
}, [placement]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
className="fixed z-[10001] max-w-sm"
style={{
top: typeof position.top === 'number' ? `${position.top}px` : position.top,
left: typeof position.left === 'number' ? `${position.left}px` : position.left,
transform: position.transform,
}}
>
<div
className={cn(
'relative rounded-lg bg-white shadow-xl',
'dark:bg-gray-800 dark:border dark:border-gray-700'
)}
>
{/* Arrow */}
<div style={arrowStyles} className="dark:border-gray-800" />
{/* Header */}
<div className="flex items-start justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-700">
<h3 className="font-semibold text-gray-900 dark:text-white">{step.title}</h3>
<button
type="button"
onClick={onSkip}
className="ml-4 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
aria-label="Cerrar tour"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="px-4 py-3">
<div className="text-sm text-gray-600 dark:text-gray-300">
{typeof step.content === 'string' ? <p>{step.content}</p> : step.content}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 dark:border-gray-700">
{/* Progress indicator */}
<div className="flex items-center gap-1.5">
{Array.from({ length: totalSteps }).map((_, index) => (
<div
key={index}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
index === currentIndex
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
)}
/>
))}
</div>
{/* Navigation buttons */}
<div className="flex items-center gap-2">
{!isFirst && (
<button
type="button"
onClick={onPrev}
className="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<ChevronLeft className="h-4 w-4" />
Anterior
</button>
)}
<button
type="button"
onClick={onNext}
className={cn(
'flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium',
'bg-primary-600 text-white hover:bg-primary-700',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
)}
>
{isLast ? 'Finalizar' : 'Siguiente'}
{!isLast && <ChevronRight className="h-4 w-4" />}
</button>
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,6 @@
export type { TourStep as TourStepType, TourConfig, TourState, TourContextValue, TourProviderProps, TourTooltipProps, TourStepProps } from './types';
export * from './OnboardingTour';
export * from './TourProvider';
export * from './TourStep';
export * from './TourTooltip';
export * from './useTour';

View File

@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
export interface TourStep {
id: string;
target: string; // CSS selector for element to highlight
title: string;
content: string | ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
spotlightPadding?: number;
disableOverlay?: boolean;
onNext?: () => void;
onPrev?: () => void;
}
export interface TourConfig {
id: string; // Tour identifier (e.g., 'dashboard-intro')
steps: TourStep[];
onComplete?: () => void;
onSkip?: () => void;
}
export interface TourState {
isActive: boolean;
currentStep: number;
tourId: string | null;
}
export interface TourContextValue {
state: TourState;
configs: Map<string, TourConfig>;
registerTour: (config: TourConfig) => void;
unregisterTour: (tourId: string) => void;
startTour: (tourId: string) => void;
endTour: () => void;
nextStep: () => void;
prevStep: () => void;
skipTour: () => void;
hasCompletedTour: (tourId: string) => boolean;
resetTour: (tourId: string) => void;
}
export interface TourProviderProps {
children: ReactNode;
storageKey?: string;
}
export interface TourTooltipProps {
step: TourStep;
currentIndex: number;
totalSteps: number;
onNext: () => void;
onPrev: () => void;
onSkip: () => void;
isFirst: boolean;
isLast: boolean;
targetRect: DOMRect | null;
}
export interface TourStepProps {
step: TourStep;
currentIndex: number;
totalSteps: number;
onNext: () => void;
onPrev: () => void;
onSkip: () => void;
isFirst: boolean;
isLast: boolean;
}

View File

@ -0,0 +1,56 @@
import { useContext } from 'react';
import { TourContext } from './TourProvider';
import type { TourStep } from './types';
export interface UseTourReturn {
startTour: (tourId: string) => void;
endTour: () => void;
nextStep: () => void;
prevStep: () => void;
skipTour: () => void;
currentStep: TourStep | null;
isActive: boolean;
progress: { current: number; total: number };
hasCompletedTour: (tourId: string) => boolean;
resetTour: (tourId: string) => void;
}
export function useTour(): UseTourReturn {
const context = useContext(TourContext);
if (!context) {
throw new Error('useTour must be used within a TourProvider');
}
const {
state,
configs,
startTour,
endTour,
nextStep,
prevStep,
skipTour,
hasCompletedTour,
resetTour,
} = context;
const currentConfig = state.tourId ? configs.get(state.tourId) : null;
const currentStep = currentConfig?.steps[state.currentStep] ?? null;
const totalSteps = currentConfig?.steps.length ?? 0;
return {
startTour,
endTour,
nextStep,
prevStep,
skipTour,
currentStep,
isActive: state.isActive,
progress: {
current: state.currentStep + 1,
total: totalSteps,
},
hasCompletedTour,
resetTour,
};
}

View File

@ -8,3 +8,7 @@ export * from './Pagination';
export * from './DatePicker';
export * from './Sidebar';
export * from './Breadcrumbs';
export * from './Calendar';
export * from './Kanban';
export * from './Chart';
export * from './DashboardWidgets';