ADD new frontend
This commit is contained in:
371
frontend/src/utils/constants.ts
Normal file
371
frontend/src/utils/constants.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Application constants and configuration values
|
||||
*/
|
||||
|
||||
// Application metadata
|
||||
export const APP_INFO = {
|
||||
name: 'Bakery IA',
|
||||
version: '1.0.0',
|
||||
description: 'Sistema inteligente de gestión para panaderías',
|
||||
author: 'Bakery IA Team',
|
||||
support_email: 'support@bakeryia.com',
|
||||
website: 'https://bakeryia.com',
|
||||
} as const;
|
||||
|
||||
// API configuration
|
||||
export const API_CONFIG = {
|
||||
base_url: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1',
|
||||
timeout: 30000,
|
||||
retry_attempts: 3,
|
||||
retry_delay: 1000,
|
||||
} as const;
|
||||
|
||||
// Authentication
|
||||
export const AUTH_CONFIG = {
|
||||
token_key: 'access_token',
|
||||
refresh_token_key: 'refresh_token',
|
||||
user_data_key: 'user_data',
|
||||
tenant_id_key: 'tenant_id',
|
||||
session_timeout: 24 * 60 * 60 * 1000, // 24 hours
|
||||
refresh_threshold: 5 * 60 * 1000, // 5 minutes before expiry
|
||||
} as const;
|
||||
|
||||
// Pagination defaults
|
||||
export const PAGINATION = {
|
||||
default_page_size: 20,
|
||||
max_page_size: 100,
|
||||
page_size_options: [10, 20, 50, 100],
|
||||
} as const;
|
||||
|
||||
// Date and time formats
|
||||
export const DATE_FORMATS = {
|
||||
display: 'dd/MM/yyyy',
|
||||
display_with_time: 'dd/MM/yyyy HH:mm',
|
||||
iso: 'yyyy-MM-dd',
|
||||
iso_with_time: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||
time_only: 'HH:mm',
|
||||
month_year: 'MM/yyyy',
|
||||
full_date: 'EEEE, dd MMMM yyyy',
|
||||
} as const;
|
||||
|
||||
// Spanish locale settings
|
||||
export const LOCALE_SETTINGS = {
|
||||
locale: 'es-ES',
|
||||
timezone: 'Europe/Madrid',
|
||||
currency: 'EUR',
|
||||
date_separator: '/',
|
||||
decimal_separator: ',',
|
||||
thousand_separator: '.',
|
||||
} as const;
|
||||
|
||||
// Business hours
|
||||
export const BUSINESS_HOURS = {
|
||||
default_open: '06:00',
|
||||
default_close: '20:00',
|
||||
days_of_week: ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo'],
|
||||
work_days: ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado'],
|
||||
} as const;
|
||||
|
||||
// Units of measure
|
||||
export const UNITS_OF_MEASURE = {
|
||||
weight: {
|
||||
kg: { label: 'Kilogramo', symbol: 'kg', factor: 1000 },
|
||||
g: { label: 'Gramo', symbol: 'g', factor: 1 },
|
||||
lb: { label: 'Libra', symbol: 'lb', factor: 453.592 },
|
||||
oz: { label: 'Onza', symbol: 'oz', factor: 28.3495 },
|
||||
},
|
||||
volume: {
|
||||
l: { label: 'Litro', symbol: 'l', factor: 1000 },
|
||||
ml: { label: 'Mililitro', symbol: 'ml', factor: 1 },
|
||||
cup: { label: 'Taza', symbol: 'taza', factor: 240 },
|
||||
tbsp: { label: 'Cucharada', symbol: 'cda', factor: 15 },
|
||||
tsp: { label: 'Cucharadita', symbol: 'cdta', factor: 5 },
|
||||
},
|
||||
count: {
|
||||
piece: { label: 'Pieza', symbol: 'pz', factor: 1 },
|
||||
dozen: { label: 'Docena', symbol: 'doc', factor: 12 },
|
||||
package: { label: 'Paquete', symbol: 'paq', factor: 1 },
|
||||
bag: { label: 'Bolsa', symbol: 'bolsa', factor: 1 },
|
||||
box: { label: 'Caja', symbol: 'caja', factor: 1 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Temperature units
|
||||
export const TEMPERATURE_UNITS = {
|
||||
celsius: { label: 'Celsius', symbol: '°C' },
|
||||
fahrenheit: { label: 'Fahrenheit', symbol: '°F' },
|
||||
} as const;
|
||||
|
||||
// Product categories
|
||||
export const PRODUCT_CATEGORIES = {
|
||||
bread: {
|
||||
label: 'Pan',
|
||||
subcategories: ['Pan blanco', 'Pan integral', 'Pan de centeno', 'Pan especial'],
|
||||
},
|
||||
pastries: {
|
||||
label: 'Bollería',
|
||||
subcategories: ['Croissants', 'Magdalenas', 'Donuts', 'Ensaimadas'],
|
||||
},
|
||||
cakes: {
|
||||
label: 'Tartas',
|
||||
subcategories: ['Tartas de cumpleaños', 'Tartas de boda', 'Cheesecakes', 'Tartas de frutas'],
|
||||
},
|
||||
cookies: {
|
||||
label: 'Galletas',
|
||||
subcategories: ['Galletas caseras', 'Galletas decoradas', 'Galletas integrales'],
|
||||
},
|
||||
seasonal: {
|
||||
label: 'Productos estacionales',
|
||||
subcategories: ['Roscón de Reyes', 'Torrijas', 'Polvorones', 'Turrones'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Ingredient categories
|
||||
export const INGREDIENT_CATEGORIES = {
|
||||
flours: {
|
||||
label: 'Harinas',
|
||||
items: ['Harina de trigo', 'Harina integral', 'Harina de centeno', 'Harina de maíz'],
|
||||
},
|
||||
sugars: {
|
||||
label: 'Azúcares',
|
||||
items: ['Azúcar blanco', 'Azúcar moreno', 'Azúcar glass', 'Miel'],
|
||||
},
|
||||
fats: {
|
||||
label: 'Grasas',
|
||||
items: ['Mantequilla', 'Margarina', 'Aceite de oliva', 'Aceite de girasol'],
|
||||
},
|
||||
dairy: {
|
||||
label: 'Lácteos',
|
||||
items: ['Leche', 'Nata', 'Queso', 'Yogur'],
|
||||
},
|
||||
eggs: {
|
||||
label: 'Huevos',
|
||||
items: ['Huevos frescos', 'Clara de huevo', 'Yema de huevo'],
|
||||
},
|
||||
leavening: {
|
||||
label: 'Levaduras',
|
||||
items: ['Levadura fresca', 'Levadura seca', 'Levadura química', 'Bicarbonato'],
|
||||
},
|
||||
spices: {
|
||||
label: 'Especias',
|
||||
items: ['Canela', 'Vainilla', 'Anís', 'Cardamomo'],
|
||||
},
|
||||
additives: {
|
||||
label: 'Aditivos',
|
||||
items: ['Conservantes', 'Colorantes', 'Aromas', 'Mejorantes'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Quality statuses
|
||||
export const QUALITY_STATUSES = [
|
||||
{ value: 'excellent', label: 'Excelente', color: '#22c55e' },
|
||||
{ value: 'good', label: 'Bueno', color: '#84cc16' },
|
||||
{ value: 'fair', label: 'Regular', color: '#eab308' },
|
||||
{ value: 'poor', label: 'Malo', color: '#f97316' },
|
||||
{ value: 'damaged', label: 'Dañado', color: '#ef4444' },
|
||||
{ value: 'expired', label: 'Caducado', color: '#dc2626' },
|
||||
{ value: 'quarantine', label: 'Cuarentena', color: '#8b5cf6' },
|
||||
] as const;
|
||||
|
||||
// Alert severities
|
||||
export const ALERT_SEVERITIES = [
|
||||
{ value: 'low', label: 'Baja', color: '#6b7280' },
|
||||
{ value: 'medium', label: 'Media', color: '#f59e0b' },
|
||||
{ value: 'high', label: 'Alta', color: '#f97316' },
|
||||
{ value: 'critical', label: 'Crítica', color: '#dc2626' },
|
||||
] as const;
|
||||
|
||||
// Order statuses
|
||||
export const ORDER_STATUSES = [
|
||||
{ value: 'pending', label: 'Pendiente', color: '#6b7280' },
|
||||
{ value: 'confirmed', label: 'Confirmado', color: '#3b82f6' },
|
||||
{ value: 'in_preparation', label: 'En preparación', color: '#f59e0b' },
|
||||
{ value: 'ready', label: 'Listo', color: '#10b981' },
|
||||
{ value: 'delivered', label: 'Entregado', color: '#22c55e' },
|
||||
{ value: 'cancelled', label: 'Cancelado', color: '#ef4444' },
|
||||
] as const;
|
||||
|
||||
// Production statuses
|
||||
export const PRODUCTION_STATUSES = [
|
||||
{ value: 'planned', label: 'Planificado', color: '#6b7280' },
|
||||
{ value: 'ready_to_start', label: 'Listo para iniciar', color: '#3b82f6' },
|
||||
{ value: 'in_progress', label: 'En progreso', color: '#f59e0b' },
|
||||
{ value: 'quality_check', label: 'Control de calidad', color: '#8b5cf6' },
|
||||
{ value: 'completed', label: 'Completado', color: '#22c55e' },
|
||||
{ value: 'cancelled', label: 'Cancelado', color: '#ef4444' },
|
||||
{ value: 'on_hold', label: 'En pausa', color: '#f97316' },
|
||||
{ value: 'failed', label: 'Fallido', color: '#dc2626' },
|
||||
] as const;
|
||||
|
||||
// Payment methods
|
||||
export const PAYMENT_METHODS = [
|
||||
{ value: 'cash', label: 'Efectivo', icon: '💵' },
|
||||
{ value: 'card', label: 'Tarjeta', icon: '💳' },
|
||||
{ value: 'digital_wallet', label: 'Wallet digital', icon: '📱' },
|
||||
{ value: 'bank_transfer', label: 'Transferencia', icon: '🏦' },
|
||||
{ value: 'check', label: 'Cheque', icon: '📝' },
|
||||
{ value: 'store_credit', label: 'Crédito tienda', icon: '🎫' },
|
||||
] as const;
|
||||
|
||||
// Weather conditions
|
||||
export const WEATHER_CONDITIONS = [
|
||||
{ value: 'sunny', label: 'Soleado', icon: '☀️', impact: 'positive' },
|
||||
{ value: 'cloudy', label: 'Nublado', icon: '☁️', impact: 'neutral' },
|
||||
{ value: 'rainy', label: 'Lluvioso', icon: '🌧️', impact: 'negative' },
|
||||
{ value: 'stormy', label: 'Tormentoso', icon: '⛈️', impact: 'negative' },
|
||||
{ value: 'snowy', label: 'Nevado', icon: '❄️', impact: 'negative' },
|
||||
{ value: 'foggy', label: 'Con niebla', icon: '🌫️', impact: 'negative' },
|
||||
{ value: 'windy', label: 'Ventoso', icon: '💨', impact: 'neutral' },
|
||||
] as const;
|
||||
|
||||
// File upload limits
|
||||
export const FILE_UPLOAD = {
|
||||
max_size: 10 * 1024 * 1024, // 10MB
|
||||
allowed_types: {
|
||||
images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
documents: ['application/pdf', 'text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
all: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
},
|
||||
max_files: 5,
|
||||
} as const;
|
||||
|
||||
// Notification settings
|
||||
export const NOTIFICATION_SETTINGS = {
|
||||
default_duration: 5000,
|
||||
max_notifications: 10,
|
||||
positions: ['top-right', 'top-left', 'bottom-right', 'bottom-left'],
|
||||
types: ['success', 'error', 'warning', 'info'],
|
||||
} as const;
|
||||
|
||||
// Chart colors
|
||||
export const CHART_COLORS = {
|
||||
primary: ['#3b82f6', '#1d4ed8', '#1e40af', '#1e3a8a'],
|
||||
success: ['#22c55e', '#16a34a', '#15803d', '#166534'],
|
||||
warning: ['#f59e0b', '#d97706', '#b45309', '#92400e'],
|
||||
error: ['#ef4444', '#dc2626', '#b91c1c', '#991b1b'],
|
||||
neutral: ['#6b7280', '#4b5563', '#374151', '#1f2937'],
|
||||
rainbow: [
|
||||
'#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1',
|
||||
],
|
||||
} as const;
|
||||
|
||||
// Breakpoints for responsive design
|
||||
export const BREAKPOINTS = {
|
||||
xs: '320px',
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
} as const;
|
||||
|
||||
// Z-index layers
|
||||
export const Z_INDEX = {
|
||||
base: 0,
|
||||
dropdown: 10,
|
||||
sticky: 20,
|
||||
fixed: 30,
|
||||
overlay: 40,
|
||||
modal: 50,
|
||||
popover: 60,
|
||||
tooltip: 70,
|
||||
toast: 80,
|
||||
loading: 90,
|
||||
maximum: 100,
|
||||
} as const;
|
||||
|
||||
// Animation durations
|
||||
export const ANIMATION_DURATION = {
|
||||
fast: '150ms',
|
||||
normal: '250ms',
|
||||
slow: '350ms',
|
||||
} as const;
|
||||
|
||||
// Border radius values
|
||||
export const BORDER_RADIUS = {
|
||||
none: '0',
|
||||
sm: '0.25rem',
|
||||
base: '0.375rem',
|
||||
md: '0.5rem',
|
||||
lg: '0.75rem',
|
||||
xl: '1rem',
|
||||
full: '9999px',
|
||||
} as const;
|
||||
|
||||
// Shadow values
|
||||
export const SHADOWS = {
|
||||
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
base: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
} as const;
|
||||
|
||||
// Local storage keys
|
||||
export const STORAGE_KEYS = {
|
||||
theme: 'app_theme',
|
||||
language: 'app_language',
|
||||
sidebar_collapsed: 'sidebar_collapsed',
|
||||
user_preferences: 'user_preferences',
|
||||
recent_searches: 'recent_searches',
|
||||
draft_forms: 'draft_forms',
|
||||
tutorial_completed: 'tutorial_completed',
|
||||
} as const;
|
||||
|
||||
// Regular expressions
|
||||
export const REGEX_PATTERNS = {
|
||||
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
phone_spain: /^[6789]\d{8}$/,
|
||||
postal_code_spain: /^[0-5]\d{4}$/,
|
||||
alphanumeric: /^[a-zA-Z0-9]+$/,
|
||||
numbers_only: /^\d+$/,
|
||||
letters_only: /^[a-zA-ZáéíóúüñÁÉÍÓÚÜÑ\s]+$/,
|
||||
strong_password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
||||
url: /^https?:\/\/([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/,
|
||||
slug: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||
hex_color: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
} as const;
|
||||
|
||||
// Error codes
|
||||
export const ERROR_CODES = {
|
||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||
AUTHENTICATION_FAILED: 'AUTH_001',
|
||||
UNAUTHORIZED: 'AUTH_002',
|
||||
FORBIDDEN: 'AUTH_003',
|
||||
VALIDATION_ERROR: 'VAL_001',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
SERVER_ERROR: 'SERVER_ERROR',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
MAINTENANCE_MODE: 'MAINTENANCE',
|
||||
} as const;
|
||||
|
||||
// Success messages
|
||||
export const SUCCESS_MESSAGES = {
|
||||
SAVED: 'Guardado correctamente',
|
||||
CREATED: 'Creado correctamente',
|
||||
UPDATED: 'Actualizado correctamente',
|
||||
DELETED: 'Eliminado correctamente',
|
||||
SENT: 'Enviado correctamente',
|
||||
IMPORTED: 'Importado correctamente',
|
||||
EXPORTED: 'Exportado correctamente',
|
||||
LOGGED_IN: 'Sesión iniciada',
|
||||
LOGGED_OUT: 'Sesión cerrada',
|
||||
} as const;
|
||||
|
||||
// Error messages
|
||||
export const ERROR_MESSAGES = {
|
||||
REQUIRED_FIELD: 'Este campo es obligatorio',
|
||||
INVALID_EMAIL: 'Email no válido',
|
||||
INVALID_PHONE: 'Teléfono no válido',
|
||||
WEAK_PASSWORD: 'La contraseña debe ser más segura',
|
||||
PASSWORDS_NOT_MATCH: 'Las contraseñas no coinciden',
|
||||
NETWORK_ERROR: 'Error de conexión',
|
||||
SERVER_ERROR: 'Error del servidor',
|
||||
UNAUTHORIZED: 'No autorizado',
|
||||
FORBIDDEN: 'Acceso denegado',
|
||||
NOT_FOUND: 'No encontrado',
|
||||
VALIDATION_ERROR: 'Error de validación',
|
||||
FILE_TOO_LARGE: 'Archivo demasiado grande',
|
||||
INVALID_FILE_TYPE: 'Tipo de archivo no válido',
|
||||
} as const;
|
||||
433
frontend/src/utils/currency.ts
Normal file
433
frontend/src/utils/currency.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Currency utilities for formatting and calculations
|
||||
*/
|
||||
|
||||
// Currency configuration
|
||||
export const CURRENCY_CONFIG = {
|
||||
EUR: {
|
||||
code: 'EUR',
|
||||
symbol: '€',
|
||||
name: 'Euro',
|
||||
decimals: 2,
|
||||
locale: 'es-ES',
|
||||
},
|
||||
USD: {
|
||||
code: 'USD',
|
||||
symbol: '$',
|
||||
name: 'US Dollar',
|
||||
decimals: 2,
|
||||
locale: 'en-US',
|
||||
},
|
||||
GBP: {
|
||||
code: 'GBP',
|
||||
symbol: '£',
|
||||
name: 'British Pound',
|
||||
decimals: 2,
|
||||
locale: 'en-GB',
|
||||
},
|
||||
} as const;
|
||||
|
||||
type CurrencyCode = keyof typeof CURRENCY_CONFIG;
|
||||
|
||||
// Format currency amount
|
||||
export const formatCurrency = (
|
||||
amount: number,
|
||||
currencyCode: CurrencyCode = 'EUR',
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string => {
|
||||
if (typeof amount !== 'number' || isNaN(amount)) {
|
||||
return formatCurrency(0, currencyCode, options);
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currencyCode];
|
||||
|
||||
const defaultOptions: Intl.NumberFormatOptions = {
|
||||
style: 'currency',
|
||||
currency: config.code,
|
||||
minimumFractionDigits: config.decimals,
|
||||
maximumFractionDigits: config.decimals,
|
||||
};
|
||||
|
||||
const formatOptions = { ...defaultOptions, ...options };
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat(config.locale, formatOptions).format(amount);
|
||||
} catch (error) {
|
||||
// Fallback to manual formatting if Intl.NumberFormat fails
|
||||
const formattedAmount = amount.toFixed(config.decimals);
|
||||
return `${formattedAmount} ${config.symbol}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Format currency without symbol (just the number)
|
||||
export const formatCurrencyAmount = (
|
||||
amount: number,
|
||||
currencyCode: CurrencyCode = 'EUR',
|
||||
decimals?: number
|
||||
): string => {
|
||||
if (typeof amount !== 'number' || isNaN(amount)) {
|
||||
return formatCurrencyAmount(0, currencyCode, decimals);
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currencyCode];
|
||||
const decimalPlaces = decimals ?? config.decimals;
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat(config.locale, {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(amount);
|
||||
} catch (error) {
|
||||
return amount.toFixed(decimalPlaces);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse currency string to number
|
||||
export const parseCurrency = (
|
||||
currencyString: string,
|
||||
currencyCode: CurrencyCode = 'EUR'
|
||||
): number => {
|
||||
if (!currencyString || typeof currencyString !== 'string') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currencyCode];
|
||||
|
||||
// Remove currency symbols and spaces
|
||||
const cleanString = currencyString
|
||||
.replace(new RegExp(`[${config.symbol}\\s]`, 'g'), '')
|
||||
.replace(/,/g, '.') // Replace comma with dot for decimal separator
|
||||
.trim();
|
||||
|
||||
const parsed = parseFloat(cleanString);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
// Add two currency amounts
|
||||
export const addCurrency = (amount1: number, amount2: number): number => {
|
||||
if (typeof amount1 !== 'number' || typeof amount2 !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use proper decimal arithmetic to avoid floating point errors
|
||||
return Math.round((amount1 + amount2) * 100) / 100;
|
||||
};
|
||||
|
||||
// Subtract two currency amounts
|
||||
export const subtractCurrency = (amount1: number, amount2: number): number => {
|
||||
if (typeof amount1 !== 'number' || typeof amount2 !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((amount1 - amount2) * 100) / 100;
|
||||
};
|
||||
|
||||
// Multiply currency amount
|
||||
export const multiplyCurrency = (amount: number, multiplier: number): number => {
|
||||
if (typeof amount !== 'number' || typeof multiplier !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(amount * multiplier * 100) / 100;
|
||||
};
|
||||
|
||||
// Divide currency amount
|
||||
export const divideCurrency = (amount: number, divisor: number): number => {
|
||||
if (typeof amount !== 'number' || typeof divisor !== 'number' || divisor === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((amount / divisor) * 100) / 100;
|
||||
};
|
||||
|
||||
// Calculate percentage of amount
|
||||
export const calculatePercentage = (amount: number, percentage: number): number => {
|
||||
if (typeof amount !== 'number' || typeof percentage !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return multiplyCurrency(amount, percentage / 100);
|
||||
};
|
||||
|
||||
// Add percentage to amount
|
||||
export const addPercentage = (amount: number, percentage: number): number => {
|
||||
const percentageAmount = calculatePercentage(amount, percentage);
|
||||
return addCurrency(amount, percentageAmount);
|
||||
};
|
||||
|
||||
// Subtract percentage from amount
|
||||
export const subtractPercentage = (amount: number, percentage: number): number => {
|
||||
const percentageAmount = calculatePercentage(amount, percentage);
|
||||
return subtractCurrency(amount, percentageAmount);
|
||||
};
|
||||
|
||||
// Calculate tax amount
|
||||
export const calculateTax = (amount: number, taxRate: number): number => {
|
||||
return calculatePercentage(amount, taxRate);
|
||||
};
|
||||
|
||||
// Calculate amount before tax (gross to net)
|
||||
export const calculateNetAmount = (grossAmount: number, taxRate: number): number => {
|
||||
if (typeof grossAmount !== 'number' || typeof taxRate !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return divideCurrency(grossAmount, 1 + (taxRate / 100));
|
||||
};
|
||||
|
||||
// Calculate amount with tax (net to gross)
|
||||
export const calculateGrossAmount = (netAmount: number, taxRate: number): number => {
|
||||
if (typeof netAmount !== 'number' || typeof taxRate !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return multiplyCurrency(netAmount, 1 + (taxRate / 100));
|
||||
};
|
||||
|
||||
// Calculate discount amount
|
||||
export const calculateDiscount = (amount: number, discountRate: number): number => {
|
||||
return calculatePercentage(amount, discountRate);
|
||||
};
|
||||
|
||||
// Apply discount to amount
|
||||
export const applyDiscount = (amount: number, discountRate: number): number => {
|
||||
const discountAmount = calculateDiscount(amount, discountRate);
|
||||
return subtractCurrency(amount, discountAmount);
|
||||
};
|
||||
|
||||
// Calculate profit margin
|
||||
export const calculateProfitMargin = (revenue: number, cost: number): number => {
|
||||
if (typeof revenue !== 'number' || typeof cost !== 'number' || revenue === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const profit = subtractCurrency(revenue, cost);
|
||||
return Math.round((profit / revenue) * 10000) / 100; // Return as percentage with 2 decimal places
|
||||
};
|
||||
|
||||
// Calculate markup
|
||||
export const calculateMarkup = (cost: number, markupPercentage: number): number => {
|
||||
return addPercentage(cost, markupPercentage);
|
||||
};
|
||||
|
||||
// Calculate selling price from cost and desired margin
|
||||
export const calculateSellingPrice = (cost: number, marginPercentage: number): number => {
|
||||
if (typeof cost !== 'number' || typeof marginPercentage !== 'number' || marginPercentage >= 100) {
|
||||
return cost;
|
||||
}
|
||||
|
||||
return divideCurrency(cost, 1 - (marginPercentage / 100));
|
||||
};
|
||||
|
||||
// Calculate cost from selling price and margin
|
||||
export const calculateCostFromMargin = (sellingPrice: number, marginPercentage: number): number => {
|
||||
if (typeof sellingPrice !== 'number' || typeof marginPercentage !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return multiplyCurrency(sellingPrice, 1 - (marginPercentage / 100));
|
||||
};
|
||||
|
||||
// Format currency range (e.g., "€10 - €20")
|
||||
export const formatCurrencyRange = (
|
||||
minAmount: number,
|
||||
maxAmount: number,
|
||||
currencyCode: CurrencyCode = 'EUR'
|
||||
): string => {
|
||||
if (typeof minAmount !== 'number' || typeof maxAmount !== 'number') {
|
||||
return formatCurrency(0, currencyCode);
|
||||
}
|
||||
|
||||
if (minAmount === maxAmount) {
|
||||
return formatCurrency(minAmount, currencyCode);
|
||||
}
|
||||
|
||||
const formattedMin = formatCurrency(minAmount, currencyCode);
|
||||
const formattedMax = formatCurrency(maxAmount, currencyCode);
|
||||
|
||||
return `${formattedMin} - ${formattedMax}`;
|
||||
};
|
||||
|
||||
// Round currency to nearest cent
|
||||
export const roundCurrency = (amount: number): number => {
|
||||
if (typeof amount !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(amount * 100) / 100;
|
||||
};
|
||||
|
||||
// Check if amount is valid currency value
|
||||
export const isValidCurrencyAmount = (amount: any): boolean => {
|
||||
return typeof amount === 'number' && !isNaN(amount) && isFinite(amount) && amount >= 0;
|
||||
};
|
||||
|
||||
// Convert between currencies (simplified - in real app, use exchange rates API)
|
||||
export const convertCurrency = (
|
||||
amount: number,
|
||||
fromCurrency: CurrencyCode,
|
||||
toCurrency: CurrencyCode,
|
||||
exchangeRate: number = 1
|
||||
): number => {
|
||||
if (!isValidCurrencyAmount(amount) || typeof exchangeRate !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (fromCurrency === toCurrency) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
return multiplyCurrency(amount, exchangeRate);
|
||||
};
|
||||
|
||||
// Format currency for input fields (without currency symbol)
|
||||
export const formatCurrencyInput = (amount: number | string): string => {
|
||||
if (typeof amount === 'string') {
|
||||
const parsed = parseCurrency(amount);
|
||||
return parsed === 0 && amount !== '0' ? amount : formatCurrencyAmount(parsed);
|
||||
}
|
||||
|
||||
if (typeof amount === 'number' && !isNaN(amount)) {
|
||||
return formatCurrencyAmount(amount);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// Calculate total from array of amounts
|
||||
export const calculateTotal = (amounts: number[]): number => {
|
||||
if (!Array.isArray(amounts)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return amounts.reduce((total, amount) => {
|
||||
if (isValidCurrencyAmount(amount)) {
|
||||
return addCurrency(total, amount);
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Calculate average from array of amounts
|
||||
export const calculateAverage = (amounts: number[]): number => {
|
||||
if (!Array.isArray(amounts) || amounts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const validAmounts = amounts.filter(isValidCurrencyAmount);
|
||||
if (validAmounts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const total = calculateTotal(validAmounts);
|
||||
return divideCurrency(total, validAmounts.length);
|
||||
};
|
||||
|
||||
// Find minimum amount in array
|
||||
export const findMinimumAmount = (amounts: number[]): number => {
|
||||
if (!Array.isArray(amounts) || amounts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const validAmounts = amounts.filter(isValidCurrencyAmount);
|
||||
return validAmounts.length > 0 ? Math.min(...validAmounts) : 0;
|
||||
};
|
||||
|
||||
// Find maximum amount in array
|
||||
export const findMaximumAmount = (amounts: number[]): number => {
|
||||
if (!Array.isArray(amounts) || amounts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const validAmounts = amounts.filter(isValidCurrencyAmount);
|
||||
return validAmounts.length > 0 ? Math.max(...validAmounts) : 0;
|
||||
};
|
||||
|
||||
// Format compact currency (e.g., €1.2K, €1.5M)
|
||||
export const formatCompactCurrency = (
|
||||
amount: number,
|
||||
currencyCode: CurrencyCode = 'EUR'
|
||||
): string => {
|
||||
if (!isValidCurrencyAmount(amount)) {
|
||||
return formatCurrency(0, currencyCode);
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currencyCode];
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat(config.locale, {
|
||||
style: 'currency',
|
||||
currency: config.code,
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(amount);
|
||||
} catch (error) {
|
||||
// Fallback for browsers that don't support compact notation
|
||||
if (amount >= 1000000) {
|
||||
return `${config.symbol}${(amount / 1000000).toFixed(1)}M`;
|
||||
} else if (amount >= 1000) {
|
||||
return `${config.symbol}${(amount / 1000).toFixed(1)}K`;
|
||||
} else {
|
||||
return formatCurrency(amount, currencyCode);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate compound interest
|
||||
export const calculateCompoundInterest = (
|
||||
principal: number,
|
||||
rate: number,
|
||||
time: number,
|
||||
compound: number = 1
|
||||
): number => {
|
||||
if (!isValidCurrencyAmount(principal) || typeof rate !== 'number' || typeof time !== 'number') {
|
||||
return principal;
|
||||
}
|
||||
|
||||
const amount = principal * Math.pow(1 + (rate / 100) / compound, compound * time);
|
||||
return roundCurrency(amount);
|
||||
};
|
||||
|
||||
// Currency formatting options for different contexts
|
||||
export const getCurrencyFormatOptions = (context: 'display' | 'input' | 'compact' | 'accounting') => {
|
||||
const baseOptions = {
|
||||
style: 'currency' as const,
|
||||
currency: 'EUR' as const,
|
||||
};
|
||||
|
||||
switch (context) {
|
||||
case 'display':
|
||||
return {
|
||||
...baseOptions,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
case 'input':
|
||||
return {
|
||||
...baseOptions,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
case 'compact':
|
||||
return {
|
||||
...baseOptions,
|
||||
notation: 'compact' as const,
|
||||
compactDisplay: 'short' as const,
|
||||
maximumFractionDigits: 1,
|
||||
};
|
||||
|
||||
case 'accounting':
|
||||
return {
|
||||
...baseOptions,
|
||||
currencySign: 'accounting' as const,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
default:
|
||||
return baseOptions;
|
||||
}
|
||||
};
|
||||
583
frontend/src/utils/date.ts
Normal file
583
frontend/src/utils/date.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Date and time utilities with Spanish locale support
|
||||
*/
|
||||
|
||||
import { format, formatDistance, formatRelative, parseISO, isValid, addDays, subDays, startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, differenceInDays, differenceInHours, differenceInMinutes } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
// Default locale for Spanish
|
||||
const defaultLocale = es;
|
||||
|
||||
// Format date for display
|
||||
export const formatDate = (
|
||||
date: Date | string,
|
||||
formatStr: string = 'dd/MM/yyyy',
|
||||
locale = defaultLocale
|
||||
): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return format(dateObj, formatStr, { locale });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Format date and time for display
|
||||
export const formatDateTime = (
|
||||
date: Date | string,
|
||||
formatStr: string = 'dd/MM/yyyy HH:mm',
|
||||
locale = defaultLocale
|
||||
): string => {
|
||||
return formatDate(date, formatStr, locale);
|
||||
};
|
||||
|
||||
// Format time only
|
||||
export const formatTime = (
|
||||
date: Date | string,
|
||||
formatStr: string = 'HH:mm',
|
||||
locale = defaultLocale
|
||||
): string => {
|
||||
return formatDate(date, formatStr, locale);
|
||||
};
|
||||
|
||||
// Format date for API (ISO string)
|
||||
export const formatDateForAPI = (date: Date | string): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return dateObj.toISOString().split('T')[0];
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Format datetime for API (full ISO string)
|
||||
export const formatDateTimeForAPI = (date: Date | string): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return dateObj.toISOString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Relative time formatting ("hace 2 horas")
|
||||
export const formatRelativeTime = (
|
||||
date: Date | string,
|
||||
baseDate: Date = new Date(),
|
||||
locale = defaultLocale
|
||||
): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return formatDistance(dateObj, baseDate, { addSuffix: true, locale });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Contextual relative formatting ("mañana a las 14:00")
|
||||
export const formatRelativeDate = (
|
||||
date: Date | string,
|
||||
baseDate: Date = new Date(),
|
||||
locale = defaultLocale
|
||||
): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return formatRelative(dateObj, baseDate, { locale });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get start of day
|
||||
export const getStartOfDay = (date: Date | string = new Date()): Date => {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
return startOfDay(dateObj);
|
||||
};
|
||||
|
||||
// Get end of day
|
||||
export const getEndOfDay = (date: Date | string = new Date()): Date => {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
return endOfDay(dateObj);
|
||||
};
|
||||
|
||||
// Get start of week (Monday)
|
||||
export const getStartOfWeek = (date: Date | string = new Date()): Date => {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
return startOfWeek(dateObj, { weekStartsOn: 1, locale: defaultLocale });
|
||||
};
|
||||
|
||||
// Get end of week (Sunday)
|
||||
export const getEndOfWeek = (date: Date | string = new Date()): Date => {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
return endOfWeek(dateObj, { weekStartsOn: 1, locale: defaultLocale });
|
||||
};
|
||||
|
||||
// Get start of month
|
||||
export const getStartOfMonth = (date: Date | string = new Date()): Date => {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
return startOfMonth(dateObj);
|
||||
};
|
||||
|
||||
// Get end of month
|
||||
export const getEndOfMonth = (date: Date | string = new Date()): Date => {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
return endOfMonth(dateObj);
|
||||
};
|
||||
|
||||
// Add days to date
|
||||
export const addDaysToDate = (date: Date | string, days: number): Date => {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
return addDays(dateObj, days);
|
||||
};
|
||||
|
||||
// Subtract days from date
|
||||
export const subtractDaysFromDate = (date: Date | string, days: number): Date => {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
return subDays(dateObj, days);
|
||||
};
|
||||
|
||||
// Calculate age from birth date
|
||||
export const calculateAge = (birthDate: Date | string): number => {
|
||||
try {
|
||||
const birth = typeof birthDate === 'string' ? parseISO(birthDate) : birthDate;
|
||||
const today = new Date();
|
||||
|
||||
if (!isValid(birth)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDifference = today.getMonth() - birth.getMonth();
|
||||
|
||||
if (monthDifference < 0 || (monthDifference === 0 && today.getDate() < birth.getDate())) {
|
||||
age--;
|
||||
}
|
||||
|
||||
return age;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if date is today
|
||||
export const isToday = (date: Date | string): boolean => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
const today = new Date();
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return formatDate(dateObj, 'yyyy-MM-dd') === formatDate(today, 'yyyy-MM-dd');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if date is yesterday
|
||||
export const isYesterday = (date: Date | string): boolean => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
const yesterday = subDays(new Date(), 1);
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return formatDate(dateObj, 'yyyy-MM-dd') === formatDate(yesterday, 'yyyy-MM-dd');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if date is tomorrow
|
||||
export const isTomorrow = (date: Date | string): boolean => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
const tomorrow = addDays(new Date(), 1);
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return formatDate(dateObj, 'yyyy-MM-dd') === formatDate(tomorrow, 'yyyy-MM-dd');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if date is in the past
|
||||
export const isPastDate = (date: Date | string): boolean => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return dateObj < new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if date is in the future
|
||||
export const isFutureDate = (date: Date | string): boolean => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return dateObj > new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Get difference in days
|
||||
export const getDaysAgo = (date: Date | string): number => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return differenceInDays(new Date(), dateObj);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Get difference in hours
|
||||
export const getHoursAgo = (date: Date | string): number => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return differenceInHours(new Date(), dateObj);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Get difference in minutes
|
||||
export const getMinutesAgo = (date: Date | string): number => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return differenceInMinutes(new Date(), dateObj);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Get day of week name
|
||||
export const getDayOfWeekName = (
|
||||
date: Date | string,
|
||||
format: 'long' | 'short' | 'narrow' = 'long',
|
||||
locale = defaultLocale
|
||||
): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formatMap = {
|
||||
long: 'EEEE',
|
||||
short: 'EEE',
|
||||
narrow: 'EEEEE',
|
||||
};
|
||||
|
||||
return formatDate(dateObj, formatMap[format], locale);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get month name
|
||||
export const getMonthName = (
|
||||
date: Date | string,
|
||||
format: 'long' | 'short' | 'narrow' = 'long',
|
||||
locale = defaultLocale
|
||||
): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formatMap = {
|
||||
long: 'MMMM',
|
||||
short: 'MMM',
|
||||
narrow: 'MMMMM',
|
||||
};
|
||||
|
||||
return formatDate(dateObj, formatMap[format], locale);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get business hours for a date
|
||||
export const getBusinessHours = (date: Date | string = new Date()) => {
|
||||
const dayOfWeek = getDayOfWeek(date);
|
||||
|
||||
// Default business hours (can be customized)
|
||||
const businessHours = {
|
||||
monday: { open: '06:00', close: '20:00' },
|
||||
tuesday: { open: '06:00', close: '20:00' },
|
||||
wednesday: { open: '06:00', close: '20:00' },
|
||||
thursday: { open: '06:00', close: '20:00' },
|
||||
friday: { open: '06:00', close: '20:00' },
|
||||
saturday: { open: '07:00', close: '21:00' },
|
||||
sunday: { open: '08:00', close: '15:00' },
|
||||
};
|
||||
|
||||
return businessHours[dayOfWeek as keyof typeof businessHours];
|
||||
};
|
||||
|
||||
// Get day of week (0 = Sunday, 1 = Monday, etc.)
|
||||
export const getDayOfWeek = (date: Date | string = new Date()): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return 'monday';
|
||||
}
|
||||
|
||||
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||||
return dayNames[dateObj.getDay()];
|
||||
} catch {
|
||||
return 'monday';
|
||||
}
|
||||
};
|
||||
|
||||
// Check if date is a weekend
|
||||
export const isWeekend = (date: Date | string = new Date()): boolean => {
|
||||
const dayOfWeek = getDayOfWeek(date);
|
||||
return dayOfWeek === 'saturday' || dayOfWeek === 'sunday';
|
||||
};
|
||||
|
||||
// Check if date is a weekday
|
||||
export const isWeekday = (date: Date | string = new Date()): boolean => {
|
||||
return !isWeekend(date);
|
||||
};
|
||||
|
||||
// Generate date range
|
||||
export const generateDateRange = (
|
||||
startDate: Date | string,
|
||||
endDate: Date | string,
|
||||
step: number = 1
|
||||
): Date[] => {
|
||||
try {
|
||||
const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
|
||||
const end = typeof endDate === 'string' ? parseISO(endDate) : endDate;
|
||||
|
||||
if (!isValid(start) || !isValid(end) || start > end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dates: Date[] = [];
|
||||
let currentDate = new Date(start);
|
||||
|
||||
while (currentDate <= end) {
|
||||
dates.push(new Date(currentDate));
|
||||
currentDate = addDays(currentDate, step);
|
||||
}
|
||||
|
||||
return dates;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Get date ranges for common periods
|
||||
export const getDateRanges = () => {
|
||||
const today = new Date();
|
||||
|
||||
return {
|
||||
today: {
|
||||
start: getStartOfDay(today),
|
||||
end: getEndOfDay(today),
|
||||
},
|
||||
yesterday: {
|
||||
start: getStartOfDay(subDays(today, 1)),
|
||||
end: getEndOfDay(subDays(today, 1)),
|
||||
},
|
||||
thisWeek: {
|
||||
start: getStartOfWeek(today),
|
||||
end: getEndOfWeek(today),
|
||||
},
|
||||
lastWeek: {
|
||||
start: getStartOfWeek(subDays(today, 7)),
|
||||
end: getEndOfWeek(subDays(today, 7)),
|
||||
},
|
||||
thisMonth: {
|
||||
start: getStartOfMonth(today),
|
||||
end: getEndOfMonth(today),
|
||||
},
|
||||
lastMonth: {
|
||||
start: getStartOfMonth(subDays(today, 30)),
|
||||
end: getEndOfMonth(subDays(today, 30)),
|
||||
},
|
||||
last7Days: {
|
||||
start: getStartOfDay(subDays(today, 6)),
|
||||
end: getEndOfDay(today),
|
||||
},
|
||||
last30Days: {
|
||||
start: getStartOfDay(subDays(today, 29)),
|
||||
end: getEndOfDay(today),
|
||||
},
|
||||
last90Days: {
|
||||
start: getStartOfDay(subDays(today, 89)),
|
||||
end: getEndOfDay(today),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Parse date string safely
|
||||
export const parseDate = (dateString: string): Date | null => {
|
||||
try {
|
||||
if (!dateString) return null;
|
||||
|
||||
const parsed = parseISO(dateString);
|
||||
return isValid(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Format duration in human readable format
|
||||
export const formatDurationFromDates = (
|
||||
startDate: Date | string,
|
||||
endDate: Date | string
|
||||
): string => {
|
||||
try {
|
||||
const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
|
||||
const end = typeof endDate === 'string' ? parseISO(endDate) : endDate;
|
||||
|
||||
if (!isValid(start) || !isValid(end)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return formatDistance(start, end, { locale: defaultLocale });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get season from date
|
||||
export const getSeason = (date: Date | string = new Date()): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return 'spring';
|
||||
}
|
||||
|
||||
const month = dateObj.getMonth();
|
||||
|
||||
if (month >= 2 && month <= 4) return 'spring'; // Mar, Apr, May
|
||||
if (month >= 5 && month <= 7) return 'summer'; // Jun, Jul, Aug
|
||||
if (month >= 8 && month <= 10) return 'fall'; // Sep, Oct, Nov
|
||||
return 'winter'; // Dec, Jan, Feb
|
||||
} catch {
|
||||
return 'spring';
|
||||
}
|
||||
};
|
||||
|
||||
// Spanish holidays (basic implementation)
|
||||
export const isSpanishHoliday = (date: Date | string): boolean => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const month = dateObj.getMonth() + 1; // 1-based month
|
||||
const day = dateObj.getDate();
|
||||
|
||||
// Fixed holidays
|
||||
const fixedHolidays = [
|
||||
{ month: 1, day: 1 }, // New Year
|
||||
{ month: 1, day: 6 }, // Epiphany
|
||||
{ month: 5, day: 1 }, // Labor Day
|
||||
{ month: 8, day: 15 }, // Assumption
|
||||
{ month: 10, day: 12 }, // National Day
|
||||
{ month: 11, day: 1 }, // All Saints
|
||||
{ month: 12, day: 6 }, // Constitution Day
|
||||
{ month: 12, day: 8 }, // Immaculate Conception
|
||||
{ month: 12, day: 25 }, // Christmas
|
||||
];
|
||||
|
||||
return fixedHolidays.some(holiday => holiday.month === month && holiday.day === day);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Time zone utilities
|
||||
export const formatDateInTimezone = (
|
||||
date: Date | string,
|
||||
timezone: string = 'Europe/Madrid',
|
||||
formatStr: string = 'dd/MM/yyyy HH:mm'
|
||||
): string => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
if (!isValid(dateObj)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('es-ES', {
|
||||
timeZone: timezone,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(dateObj);
|
||||
} catch {
|
||||
return formatDate(date, formatStr);
|
||||
}
|
||||
};
|
||||
388
frontend/src/utils/format.ts
Normal file
388
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Formatting utilities for various data types
|
||||
*/
|
||||
|
||||
// Number formatting
|
||||
export const formatNumber = (
|
||||
value: number,
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string => {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const defaultOptions: Intl.NumberFormatOptions = {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
return new Intl.NumberFormat('es-ES', { ...defaultOptions, ...options }).format(value);
|
||||
};
|
||||
|
||||
// Currency formatting
|
||||
export const formatCurrency = (
|
||||
amount: number,
|
||||
currency: string = 'EUR',
|
||||
locale: string = 'es-ES'
|
||||
): string => {
|
||||
if (typeof amount !== 'number' || isNaN(amount)) {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(0);
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Percentage formatting
|
||||
export const formatPercentage = (
|
||||
value: number,
|
||||
decimals: number = 1,
|
||||
locale: string = 'es-ES'
|
||||
): string => {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return '0%';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value / 100);
|
||||
};
|
||||
|
||||
// Compact number formatting (1K, 1M, etc.)
|
||||
export const formatCompactNumber = (
|
||||
value: number,
|
||||
locale: string = 'es-ES'
|
||||
): string => {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Decimal formatting with specific precision
|
||||
export const formatDecimal = (
|
||||
value: number,
|
||||
decimals: number = 2,
|
||||
locale: string = 'es-ES'
|
||||
): string => {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// File size formatting
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (typeof bytes !== 'number' || isNaN(bytes)) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
|
||||
return `${formatDecimal(size, i === 0 ? 0 : 1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
// Duration formatting (milliseconds to human readable)
|
||||
export const formatDuration = (milliseconds: number): string => {
|
||||
if (typeof milliseconds !== 'number' || isNaN(milliseconds) || milliseconds < 0) {
|
||||
return '0s';
|
||||
}
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours % 24}h`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
// Short duration formatting (for performance metrics)
|
||||
export const formatShortDuration = (milliseconds: number): string => {
|
||||
if (typeof milliseconds !== 'number' || isNaN(milliseconds)) {
|
||||
return '0ms';
|
||||
}
|
||||
|
||||
if (milliseconds < 1000) {
|
||||
return `${Math.round(milliseconds)}ms`;
|
||||
}
|
||||
|
||||
const seconds = milliseconds / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${formatDecimal(seconds, 1)}s`;
|
||||
}
|
||||
|
||||
const minutes = seconds / 60;
|
||||
return `${formatDecimal(minutes, 1)}m`;
|
||||
};
|
||||
|
||||
// Text formatting
|
||||
export const truncateText = (
|
||||
text: string,
|
||||
maxLength: number,
|
||||
suffix: string = '...'
|
||||
): string => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text.slice(0, maxLength - suffix.length) + suffix;
|
||||
};
|
||||
|
||||
// Capitalize first letter
|
||||
export const capitalize = (text: string): string => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
// Title case formatting
|
||||
export const titleCase = (text: string): string => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Kebab case to title case
|
||||
export const kebabToTitle = (text: string): string => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Snake case to title case
|
||||
export const snakeToTitle = (text: string): string => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Camel case to title case
|
||||
export const camelToTitle = (text: string): string => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, str => str.toUpperCase())
|
||||
.trim();
|
||||
};
|
||||
|
||||
// Phone number formatting
|
||||
export const formatPhoneNumber = (phone: string): string => {
|
||||
if (!phone || typeof phone !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove all non-digit characters
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
|
||||
// Spanish phone number format
|
||||
if (digits.length === 9) {
|
||||
return `${digits.slice(0, 3)} ${digits.slice(3, 6)} ${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
// International format
|
||||
if (digits.length === 11 && digits.startsWith('34')) {
|
||||
return `+34 ${digits.slice(2, 5)} ${digits.slice(5, 8)} ${digits.slice(8)}`;
|
||||
}
|
||||
|
||||
// Return original if doesn't match expected formats
|
||||
return phone;
|
||||
};
|
||||
|
||||
// Unit of measure formatting
|
||||
export const formatUnit = (value: number, unit: string): string => {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return `0 ${unit}`;
|
||||
}
|
||||
|
||||
const formattedValue = value % 1 === 0 ? value.toString() : formatDecimal(value);
|
||||
return `${formattedValue} ${unit}`;
|
||||
};
|
||||
|
||||
// Weight formatting with unit conversion
|
||||
export const formatWeight = (grams: number): string => {
|
||||
if (typeof grams !== 'number' || isNaN(grams)) {
|
||||
return '0 g';
|
||||
}
|
||||
|
||||
if (grams >= 1000) {
|
||||
const kg = grams / 1000;
|
||||
return `${formatDecimal(kg)} kg`;
|
||||
}
|
||||
|
||||
return `${formatDecimal(grams, 0)} g`;
|
||||
};
|
||||
|
||||
// Volume formatting with unit conversion
|
||||
export const formatVolume = (milliliters: number): string => {
|
||||
if (typeof milliliters !== 'number' || isNaN(milliliters)) {
|
||||
return '0 ml';
|
||||
}
|
||||
|
||||
if (milliliters >= 1000) {
|
||||
const liters = milliliters / 1000;
|
||||
return `${formatDecimal(liters)} l`;
|
||||
}
|
||||
|
||||
return `${formatDecimal(milliliters, 0)} ml`;
|
||||
};
|
||||
|
||||
// Temperature formatting
|
||||
export const formatTemperature = (
|
||||
celsius: number,
|
||||
unit: 'C' | 'F' = 'C'
|
||||
): string => {
|
||||
if (typeof celsius !== 'number' || isNaN(celsius)) {
|
||||
return `0°${unit}`;
|
||||
}
|
||||
|
||||
if (unit === 'F') {
|
||||
const fahrenheit = (celsius * 9/5) + 32;
|
||||
return `${formatDecimal(fahrenheit, 0)}°F`;
|
||||
}
|
||||
|
||||
return `${formatDecimal(celsius, 0)}°C`;
|
||||
};
|
||||
|
||||
// List formatting (join with commas and "and")
|
||||
export const formatList = (
|
||||
items: string[],
|
||||
conjunction: string = 'y'
|
||||
): string => {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (items.length === 1) {
|
||||
return items[0];
|
||||
}
|
||||
|
||||
if (items.length === 2) {
|
||||
return `${items[0]} ${conjunction} ${items[1]}`;
|
||||
}
|
||||
|
||||
const lastItem = items[items.length - 1];
|
||||
const otherItems = items.slice(0, -1);
|
||||
return `${otherItems.join(', ')} ${conjunction} ${lastItem}`;
|
||||
};
|
||||
|
||||
// Address formatting
|
||||
export const formatAddress = (address: {
|
||||
street?: string;
|
||||
city?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
}): string => {
|
||||
const parts = [];
|
||||
|
||||
if (address.street) parts.push(address.street);
|
||||
if (address.postal_code && address.city) {
|
||||
parts.push(`${address.postal_code} ${address.city}`);
|
||||
} else if (address.city) {
|
||||
parts.push(address.city);
|
||||
} else if (address.postal_code) {
|
||||
parts.push(address.postal_code);
|
||||
}
|
||||
if (address.country) parts.push(address.country);
|
||||
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
// HTML stripping (for displaying rich text as plain text)
|
||||
export const stripHtml = (html: string): string => {
|
||||
if (!html || typeof html !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Create a temporary div element to strip HTML tags
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
};
|
||||
|
||||
// URL slug generation
|
||||
export const slugify = (text: string): string => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w ]+/g, '')
|
||||
.replace(/ +/g, '-');
|
||||
};
|
||||
|
||||
// Name formatting (last name, first name)
|
||||
export const formatName = (
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
format: 'first_last' | 'last_first' | 'initials' = 'first_last'
|
||||
): string => {
|
||||
const first = firstName?.trim() || '';
|
||||
const last = lastName?.trim() || '';
|
||||
|
||||
if (!first && !last) return '';
|
||||
if (!first) return last;
|
||||
if (!last) return first;
|
||||
|
||||
switch (format) {
|
||||
case 'last_first':
|
||||
return `${last}, ${first}`;
|
||||
case 'initials':
|
||||
return `${first.charAt(0)}.${last.charAt(0)}.`;
|
||||
case 'first_last':
|
||||
default:
|
||||
return `${first} ${last}`;
|
||||
}
|
||||
};
|
||||
@@ -1,215 +0,0 @@
|
||||
// frontend/src/utils/onboardingRouter.ts
|
||||
/**
|
||||
* Onboarding Router Utility
|
||||
* Determines user's next step based on their current progress
|
||||
*/
|
||||
|
||||
import { onboardingService } from '../api/services/onboarding.service';
|
||||
import type { UserProgress } from '../api/services/onboarding.service';
|
||||
|
||||
export type OnboardingStep =
|
||||
| 'user_registered'
|
||||
| 'bakery_registered'
|
||||
| 'sales_data_uploaded'
|
||||
| 'training_completed'
|
||||
| 'dashboard_accessible';
|
||||
|
||||
export type NextAction =
|
||||
| 'register'
|
||||
| 'login'
|
||||
| 'onboarding_bakery'
|
||||
| 'onboarding_data'
|
||||
| 'onboarding_training'
|
||||
| 'dashboard'
|
||||
| 'landing';
|
||||
|
||||
export interface RoutingDecision {
|
||||
nextAction: NextAction;
|
||||
currentStep: OnboardingStep | null;
|
||||
message?: string;
|
||||
canSkip?: boolean;
|
||||
completionPercentage: number;
|
||||
}
|
||||
|
||||
export class OnboardingRouter {
|
||||
/**
|
||||
* Determine next action for authenticated user
|
||||
*/
|
||||
static async getNextActionForUser(userProgress?: UserProgress): Promise<RoutingDecision> {
|
||||
try {
|
||||
const progress = userProgress || await onboardingService.getUserProgress();
|
||||
|
||||
// Check if user has fully completed onboarding or has high completion percentage
|
||||
const isFullyCompleted = progress.fully_completed || progress.completion_percentage >= 80;
|
||||
const currentStep = progress.current_step as OnboardingStep;
|
||||
|
||||
let nextAction: NextAction;
|
||||
|
||||
if (isFullyCompleted || currentStep === 'training_completed' || currentStep === 'dashboard_accessible') {
|
||||
// User can access dashboard
|
||||
nextAction = 'dashboard';
|
||||
} else {
|
||||
// Use step-based routing
|
||||
nextAction = this.mapStepToAction(currentStep);
|
||||
}
|
||||
|
||||
return {
|
||||
nextAction,
|
||||
currentStep,
|
||||
completionPercentage: progress.completion_percentage,
|
||||
message: this.getStepMessage(currentStep),
|
||||
canSkip: this.canSkipStep(currentStep)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user progress:', error);
|
||||
|
||||
// Fallback logic when API fails
|
||||
return {
|
||||
nextAction: 'onboarding_bakery',
|
||||
currentStep: 'bakery_registered',
|
||||
completionPercentage: 0,
|
||||
message: 'Let\'s set up your bakery information'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine next action for unauthenticated user
|
||||
*/
|
||||
static getNextActionForGuest(): RoutingDecision {
|
||||
return {
|
||||
nextAction: 'landing',
|
||||
currentStep: null,
|
||||
completionPercentage: 0,
|
||||
message: 'Welcome to PanIA - AI for your bakery'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access dashboard with current progress
|
||||
*/
|
||||
static async canAccessDashboard(): Promise<boolean> {
|
||||
try {
|
||||
const progress = await onboardingService.getUserProgress();
|
||||
return progress.fully_completed || progress.completion_percentage >= 80;
|
||||
} catch (error) {
|
||||
console.error('Error checking dashboard access:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dynamic onboarding step within the flow
|
||||
*/
|
||||
static async getOnboardingStepFromProgress(): Promise<{
|
||||
step: number;
|
||||
totalSteps: number;
|
||||
canProceed: boolean;
|
||||
}> {
|
||||
try {
|
||||
const progress = await onboardingService.getUserProgress();
|
||||
const currentStep = progress.current_step as OnboardingStep;
|
||||
|
||||
const stepMap: Record<OnboardingStep, number> = {
|
||||
'user_registered': 1,
|
||||
'bakery_registered': 2,
|
||||
'sales_data_uploaded': 3,
|
||||
'training_completed': 4,
|
||||
'dashboard_accessible': 5
|
||||
};
|
||||
|
||||
const currentStepNumber = stepMap[currentStep] || 1;
|
||||
|
||||
return {
|
||||
step: Math.max(currentStepNumber - 1, 1), // Convert to 1-based onboarding steps
|
||||
totalSteps: 4, // We have 4 onboarding steps (excluding user registration)
|
||||
canProceed: progress.completion_percentage > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting onboarding step:', error);
|
||||
return {
|
||||
step: 1,
|
||||
totalSteps: 4,
|
||||
canProceed: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress when user completes an action
|
||||
*/
|
||||
static async completeStep(
|
||||
step: OnboardingStep,
|
||||
data?: Record<string, any>
|
||||
): Promise<UserProgress> {
|
||||
return await onboardingService.completeStep(step, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has completed a specific step
|
||||
*/
|
||||
static async hasCompletedStep(step: OnboardingStep): Promise<boolean> {
|
||||
try {
|
||||
const progress = await onboardingService.getUserProgress();
|
||||
const stepStatus = progress.steps.find((s: any) => s.step_name === step);
|
||||
return stepStatus?.completed || false;
|
||||
} catch (error) {
|
||||
console.error(`Error checking step completion for ${step}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message for current step
|
||||
*/
|
||||
private static getStepMessage(step: OnboardingStep): string {
|
||||
const messages: Record<OnboardingStep, string> = {
|
||||
'user_registered': 'Welcome! Your account has been created successfully.',
|
||||
'bakery_registered': 'Let\'s set up your bakery information to get started.',
|
||||
'sales_data_uploaded': 'Upload your historical sales data for better predictions.',
|
||||
'training_completed': 'Great! Your AI model is ready. Welcome to your dashboard!',
|
||||
'dashboard_accessible': 'Welcome back! You\'re ready to use your AI-powered dashboard.'
|
||||
};
|
||||
|
||||
return messages[step] || 'Continue setting up your account';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map step to corresponding frontend action
|
||||
*/
|
||||
private static mapStepToAction(step: OnboardingStep): NextAction {
|
||||
const actionMap: Record<OnboardingStep, NextAction> = {
|
||||
'user_registered': 'onboarding_bakery',
|
||||
'bakery_registered': 'onboarding_bakery',
|
||||
'sales_data_uploaded': 'onboarding_data',
|
||||
'training_completed': 'dashboard', // ✅ Users can access dashboard when training is completed
|
||||
'dashboard_accessible': 'dashboard'
|
||||
};
|
||||
|
||||
return actionMap[step] || 'onboarding_bakery';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can skip a specific step
|
||||
*/
|
||||
private static canSkipStep(step: OnboardingStep): boolean {
|
||||
// Define which steps can be skipped
|
||||
const skippableSteps: OnboardingStep[] = [
|
||||
'training_completed' // Users can access dashboard even if training is still in progress
|
||||
];
|
||||
|
||||
return skippableSteps.includes(step);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for components
|
||||
export const useOnboardingRouter = () => {
|
||||
return {
|
||||
getNextActionForUser: OnboardingRouter.getNextActionForUser,
|
||||
getNextActionForGuest: OnboardingRouter.getNextActionForGuest,
|
||||
canAccessDashboard: OnboardingRouter.canAccessDashboard,
|
||||
getOnboardingStepFromProgress: OnboardingRouter.getOnboardingStepFromProgress,
|
||||
completeStep: OnboardingRouter.completeStep,
|
||||
hasCompletedStep: OnboardingRouter.hasCompletedStep,
|
||||
};
|
||||
};
|
||||
453
frontend/src/utils/validation.ts
Normal file
453
frontend/src/utils/validation.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Validation utilities for forms and data
|
||||
*/
|
||||
|
||||
// Email validation
|
||||
export const isValidEmail = (email: string): boolean => {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email.trim());
|
||||
};
|
||||
|
||||
// Spanish phone number validation
|
||||
export const isValidSpanishPhone = (phone: string): boolean => {
|
||||
if (!phone || typeof phone !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove all non-digit characters
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
|
||||
// Spanish mobile: 6/7/8/9 + 8 digits (9 total)
|
||||
// Spanish landline: 9 + 8 digits (9 total)
|
||||
// International: +34 + 9 digits (11 total with country code)
|
||||
const patterns = [
|
||||
/^[6789]\d{8}$/, // 9 digits starting with 6,7,8,9
|
||||
/^34[6789]\d{8}$/, // 34 + 9 digits
|
||||
];
|
||||
|
||||
return patterns.some(pattern => pattern.test(digits));
|
||||
};
|
||||
|
||||
// Password strength validation
|
||||
export interface PasswordValidation {
|
||||
isValid: boolean;
|
||||
score: number; // 0-4
|
||||
feedback: string[];
|
||||
requirements: {
|
||||
minLength: boolean;
|
||||
hasUppercase: boolean;
|
||||
hasLowercase: boolean;
|
||||
hasNumbers: boolean;
|
||||
hasSpecialChars: boolean;
|
||||
noCommonPatterns: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const validatePassword = (password: string): PasswordValidation => {
|
||||
if (!password || typeof password !== 'string') {
|
||||
return {
|
||||
isValid: false,
|
||||
score: 0,
|
||||
feedback: ['La contraseña es requerida'],
|
||||
requirements: {
|
||||
minLength: false,
|
||||
hasUppercase: false,
|
||||
hasLowercase: false,
|
||||
hasNumbers: false,
|
||||
hasSpecialChars: false,
|
||||
noCommonPatterns: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const requirements = {
|
||||
minLength: password.length >= 8,
|
||||
hasUppercase: /[A-Z]/.test(password),
|
||||
hasLowercase: /[a-z]/.test(password),
|
||||
hasNumbers: /\d/.test(password),
|
||||
hasSpecialChars: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password),
|
||||
noCommonPatterns: !isCommonPassword(password),
|
||||
};
|
||||
|
||||
const feedback: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Check requirements and provide feedback
|
||||
if (!requirements.minLength) {
|
||||
feedback.push('Debe tener al menos 8 caracteres');
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (!requirements.hasUppercase) {
|
||||
feedback.push('Debe incluir al menos una letra mayúscula');
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (!requirements.hasLowercase) {
|
||||
feedback.push('Debe incluir al menos una letra minúscula');
|
||||
} else {
|
||||
score += 0.5;
|
||||
}
|
||||
|
||||
if (!requirements.hasNumbers) {
|
||||
feedback.push('Debe incluir al menos un número');
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (!requirements.hasSpecialChars) {
|
||||
feedback.push('Debe incluir al menos un carácter especial');
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (!requirements.noCommonPatterns) {
|
||||
feedback.push('No debe usar contraseñas comunes');
|
||||
score -= 1;
|
||||
} else {
|
||||
score += 0.5;
|
||||
}
|
||||
|
||||
// Length bonus
|
||||
if (password.length >= 12) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
score = Math.max(0, Math.min(4, score));
|
||||
const isValid = score >= 3 && Object.values(requirements).every(req => req);
|
||||
|
||||
return {
|
||||
isValid,
|
||||
score,
|
||||
feedback: feedback.length > 0 ? feedback : ['Contraseña segura'],
|
||||
requirements,
|
||||
};
|
||||
};
|
||||
|
||||
// Common passwords check
|
||||
const isCommonPassword = (password: string): boolean => {
|
||||
const commonPasswords = [
|
||||
'password', '123456', '123456789', 'qwerty', 'abc123',
|
||||
'password123', '12345678', '111111', '1234567890',
|
||||
'letmein', 'welcome', 'monkey', 'dragon', 'pass',
|
||||
'master', 'hello', 'freedom', 'whatever', 'qazwsx',
|
||||
'trustno1', 'jordan23', 'harley', 'robert', 'matthew',
|
||||
];
|
||||
|
||||
return commonPasswords.includes(password.toLowerCase());
|
||||
};
|
||||
|
||||
// URL validation
|
||||
export const isValidUrl = (url: string): boolean => {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Spanish postal code validation
|
||||
export const isValidSpanishPostalCode = (postalCode: string): boolean => {
|
||||
if (!postalCode || typeof postalCode !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Spanish postal codes: 5 digits, first two indicate province
|
||||
const regex = /^[0-5]\d{4}$/;
|
||||
return regex.test(postalCode.trim());
|
||||
};
|
||||
|
||||
// VAT number validation (Spanish CIF/NIF)
|
||||
export const isValidSpanishVAT = (vat: string): boolean => {
|
||||
if (!vat || typeof vat !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cleanVat = vat.replace(/\s/g, '').toUpperCase();
|
||||
|
||||
// NIF format: 8 digits + 1 letter
|
||||
const nifRegex = /^\d{8}[A-Z]$/;
|
||||
if (nifRegex.test(cleanVat)) {
|
||||
return validateNIF(cleanVat);
|
||||
}
|
||||
|
||||
// CIF format: 1 letter + 7 digits + 1 letter/digit
|
||||
const cifRegex = /^[ABCDEFGHJNPQRSUVW]\d{7}[A-J\d]$/;
|
||||
if (cifRegex.test(cleanVat)) {
|
||||
return validateCIF(cleanVat);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// NIF validation with check digit
|
||||
const validateNIF = (nif: string): boolean => {
|
||||
const letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
|
||||
const number = parseInt(nif.substring(0, 8), 10);
|
||||
const letter = nif.charAt(8);
|
||||
return letters.charAt(number % 23) === letter;
|
||||
};
|
||||
|
||||
// CIF validation with check digit
|
||||
const validateCIF = (cif: string): boolean => {
|
||||
const firstLetter = cif.charAt(0);
|
||||
const checkDigit = cif.charAt(8);
|
||||
const numbers = cif.substring(1, 8);
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < numbers.length; i++) {
|
||||
const digit = parseInt(numbers.charAt(i), 10);
|
||||
if (i % 2 === 0) {
|
||||
// Positions 0, 2, 4, 6
|
||||
const doubled = digit * 2;
|
||||
sum += doubled > 9 ? doubled - 9 : doubled;
|
||||
} else {
|
||||
// Positions 1, 3, 5
|
||||
sum += digit;
|
||||
}
|
||||
}
|
||||
|
||||
const units = sum % 10;
|
||||
const controlNumber = units === 0 ? 0 : 10 - units;
|
||||
const controlLetter = 'JABCDEFGHI'.charAt(controlNumber);
|
||||
|
||||
// Some CIF types use letter, others use digit
|
||||
if (['N', 'P', 'Q', 'R', 'S', 'W'].includes(firstLetter)) {
|
||||
return checkDigit === controlLetter;
|
||||
} else {
|
||||
return checkDigit === controlNumber.toString() || checkDigit === controlLetter;
|
||||
}
|
||||
};
|
||||
|
||||
// Credit card validation (Luhn algorithm)
|
||||
export const isValidCreditCard = (cardNumber: string): boolean => {
|
||||
if (!cardNumber || typeof cardNumber !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const digits = cardNumber.replace(/\s/g, '').replace(/-/g, '');
|
||||
|
||||
if (!/^\d+$/.test(digits) || digits.length < 13 || digits.length > 19) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Luhn algorithm
|
||||
let sum = 0;
|
||||
let isEven = false;
|
||||
|
||||
for (let i = digits.length - 1; i >= 0; i--) {
|
||||
let digit = parseInt(digits.charAt(i), 10);
|
||||
|
||||
if (isEven) {
|
||||
digit *= 2;
|
||||
if (digit > 9) {
|
||||
digit -= 9;
|
||||
}
|
||||
}
|
||||
|
||||
sum += digit;
|
||||
isEven = !isEven;
|
||||
}
|
||||
|
||||
return sum % 10 === 0;
|
||||
};
|
||||
|
||||
// Numeric validation
|
||||
export const isNumeric = (value: any): boolean => {
|
||||
return !isNaN(value) && !isNaN(parseFloat(value));
|
||||
};
|
||||
|
||||
// Positive number validation
|
||||
export const isPositiveNumber = (value: any): boolean => {
|
||||
return isNumeric(value) && parseFloat(value) > 0;
|
||||
};
|
||||
|
||||
// Integer validation
|
||||
export const isInteger = (value: any): boolean => {
|
||||
return isNumeric(value) && Number.isInteger(parseFloat(value));
|
||||
};
|
||||
|
||||
// Range validation
|
||||
export const isInRange = (value: number, min: number, max: number): boolean => {
|
||||
return isNumeric(value) && value >= min && value <= max;
|
||||
};
|
||||
|
||||
// Date validation
|
||||
export const isValidDate = (dateString: string): boolean => {
|
||||
if (!dateString || typeof dateString !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date instanceof Date && !isNaN(date.getTime());
|
||||
};
|
||||
|
||||
// Future date validation
|
||||
export const isFutureDate = (dateString: string): boolean => {
|
||||
if (!isValidDate(dateString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
return date > now;
|
||||
};
|
||||
|
||||
// Past date validation
|
||||
export const isPastDate = (dateString: string): boolean => {
|
||||
if (!isValidDate(dateString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
return date < now;
|
||||
};
|
||||
|
||||
// Age validation (18+ years)
|
||||
export const isValidAge = (birthDate: string): boolean => {
|
||||
if (!isValidDate(birthDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const birth = new Date(birthDate);
|
||||
const now = new Date();
|
||||
const age = now.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = now.getMonth() - birth.getMonth();
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) {
|
||||
return age - 1 >= 18;
|
||||
}
|
||||
|
||||
return age >= 18;
|
||||
};
|
||||
|
||||
// Required field validation
|
||||
export const isRequired = (value: any): boolean => {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Minimum length validation
|
||||
export const hasMinLength = (value: string, minLength: number): boolean => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.trim().length >= minLength;
|
||||
};
|
||||
|
||||
// Maximum length validation
|
||||
export const hasMaxLength = (value: string, maxLength: number): boolean => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return true; // Empty values are valid for max length
|
||||
}
|
||||
|
||||
return value.trim().length <= maxLength;
|
||||
};
|
||||
|
||||
// File validation
|
||||
export interface FileValidationOptions {
|
||||
maxSize?: number; // in bytes
|
||||
allowedTypes?: string[];
|
||||
maxFiles?: number;
|
||||
}
|
||||
|
||||
export const validateFiles = (
|
||||
files: FileList | File[],
|
||||
options: FileValidationOptions = {}
|
||||
): { isValid: boolean; errors: string[] } => {
|
||||
const errors: string[] = [];
|
||||
const fileArray = Array.from(files);
|
||||
|
||||
const {
|
||||
maxSize = 10 * 1024 * 1024, // 10MB default
|
||||
allowedTypes = [],
|
||||
maxFiles = 10,
|
||||
} = options;
|
||||
|
||||
// Check number of files
|
||||
if (fileArray.length > maxFiles) {
|
||||
errors.push(`No puedes subir más de ${maxFiles} archivos`);
|
||||
}
|
||||
|
||||
// Check each file
|
||||
fileArray.forEach((file, index) => {
|
||||
// Check file size
|
||||
if (file.size > maxSize) {
|
||||
errors.push(`El archivo "${file.name}" es demasiado grande. Máximo: ${formatFileSize(maxSize)}`);
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
|
||||
errors.push(`Tipo de archivo no permitido: "${file.name}". Tipos permitidos: ${allowedTypes.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
// Format file size for error messages
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
// Validation helper for forms
|
||||
export const createValidator = (rules: Record<string, (value: any) => boolean>) => {
|
||||
return (data: Record<string, any>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
Object.entries(rules).forEach(([field, validator]) => {
|
||||
const value = data[field];
|
||||
if (!validator(value)) {
|
||||
errors[field] = `${field} is invalid`;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
};
|
||||
|
||||
// Common validation patterns
|
||||
export const ValidationPatterns = {
|
||||
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
spanishPhone: /^[6789]\d{8}$/,
|
||||
spanishPostalCode: /^[0-5]\d{4}$/,
|
||||
strongPassword: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
||||
url: /^https?:\/\/([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/,
|
||||
alphanumeric: /^[a-zA-Z0-9]+$/,
|
||||
numbersOnly: /^\d+$/,
|
||||
lettersOnly: /^[a-zA-ZáéíóúüñÁÉÍÓÚÜÑ\s]+$/,
|
||||
noSpecialChars: /^[a-zA-Z0-9\s]+$/,
|
||||
};
|
||||
Reference in New Issue
Block a user