ADD new frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 10:41:04 +02:00
parent 9c247a5f99
commit 0fd273cfce
492 changed files with 114979 additions and 1632 deletions

View 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;

View 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
View 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);
}
};

View 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 `${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}`;
}
};

View File

@@ -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,
};
};

View 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]+$/,
};