Fix few issues

This commit is contained in:
Urtzi Alfaro
2025-09-26 12:12:17 +02:00
parent d573c38621
commit a27f159e24
32 changed files with 2694 additions and 575 deletions

View File

@@ -5,7 +5,7 @@ import { BrowserRouter } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next';
import { Toaster } from 'react-hot-toast';
import { ErrorBoundary } from './components/layout/ErrorBoundary';
import { LoadingSpinner } from './components/ui/LoadingSpinner';
import { LoadingSpinner } from './components/ui';
import { AppRouter } from './router/AppRouter';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';

View File

@@ -4,6 +4,7 @@ import { StatusCard, StatusIndicatorConfig } from '../../ui/StatusCard/StatusCar
import { statusColors } from '../../../styles/colors';
import { ProductionBatchResponse, ProductionStatus, ProductionPriority } from '../../../api/types/production';
import { useTranslation } from 'react-i18next';
import { TextOverflowPrevention, safeText } from '../../../utils/textUtils';
export interface ProductionStatusCardProps {
batch: ProductionBatchResponse;
@@ -76,7 +77,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
return {
color: getStatusColorForProduction(status),
text: t(`production:status.${status}`),
text: t(`production:status.${status.toLowerCase()}`),
icon: Icon,
isCritical,
isHighlight
@@ -157,7 +158,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
if (onView) {
actions.push({
label: 'Ver Detalles',
label: 'Ver',
icon: Eye,
priority: 'primary' as const,
onClick: () => onView(batch)
@@ -166,7 +167,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
if (batch.status === ProductionStatus.PENDING && onStart) {
actions.push({
label: 'Iniciar Producción',
label: 'Iniciar',
icon: Play,
priority: 'primary' as const,
onClick: () => onStart(batch)
@@ -205,7 +206,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
if (batch.status === ProductionStatus.QUALITY_CHECK && onQualityCheck) {
console.log('ProductionStatusCard - Adding quality check button for batch:', batch.batch_number);
actions.push({
label: 'Control Calidad',
label: 'Calidad',
icon: Package,
priority: 'primary' as const,
onClick: () => onQualityCheck(batch)
@@ -268,16 +269,18 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
}
if (batch.staff_assigned && batch.staff_assigned.length > 0) {
metadata.push(`Personal: ${batch.staff_assigned.join(', ')}`);
const staff = TextOverflowPrevention.production.staffList(batch.staff_assigned);
metadata.push(`Personal: ${staff}`);
}
if (batch.equipment_used && batch.equipment_used.length > 0) {
metadata.push(`Equipos: ${batch.equipment_used.join(', ')}`);
const equipment = TextOverflowPrevention.production.equipmentList(batch.equipment_used);
metadata.push(`Equipos: ${equipment}`);
}
const qualityInfo = getQualityStatusInfo(batch);
if (qualityInfo) {
metadata.push(qualityInfo);
metadata.push(safeText(qualityInfo, qualityInfo, 50));
}
if (batch.priority === ProductionPriority.URGENT) {
@@ -299,21 +302,22 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
if (batch.actual_quantity && batch.planned_quantity) {
const completionPercent = Math.round((batch.actual_quantity / batch.planned_quantity) * 100);
return {
label: 'Progreso Cantidad',
label: 'Progreso',
value: `${batch.actual_quantity}/${batch.planned_quantity} (${completionPercent}%)`
};
}
if (batch.staff_assigned && batch.staff_assigned.length > 0) {
const staff = TextOverflowPrevention.mobile.staff(batch.staff_assigned);
return {
label: 'Personal Asignado',
value: batch.staff_assigned.join(', ')
label: 'Personal',
value: staff
};
}
return {
label: 'Prioridad',
value: t(`production:priority.${batch.priority}`)
value: t(`production:priority.${batch.priority.toLowerCase()}`)
};
};
@@ -324,10 +328,10 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
<StatusCard
id={batch.id}
statusIndicator={statusConfig}
title={batch.product_name}
subtitle={`Lote: ${batch.batch_number}`}
title={TextOverflowPrevention.production.productName(batch.product_name)}
subtitle={`Lote: ${TextOverflowPrevention.production.batchNumber(batch.batch_number)}`}
primaryValue={batch.planned_quantity}
primaryValueLabel="unidades"
primaryValueLabel="uds"
secondaryInfo={getSecondaryInfo()}
progress={progress !== undefined ? {
label: getProgressLabel(batch),

View File

@@ -18,13 +18,12 @@ import {
} from 'lucide-react';
import {
Button,
Input,
Card,
Badge,
Select,
StatusCard,
Modal,
StatsGrid
StatsGrid,
SearchAndFilter,
type FilterConfig
} from '../../ui';
import { LoadingSpinner } from '../../ui';
import { PageHeader } from '../../layout';
@@ -285,59 +284,45 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
columns={4}
/>
{/* Filters */}
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--text-tertiary)]" />
<Input
placeholder="Buscar plantillas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select
value={selectedCheckType}
onChange={(e) => setSelectedCheckType(e.target.value as QualityCheckType | '')}
placeholder="Tipo de control"
>
<option value="">Todos los tipos</option>
{Object.entries(QUALITY_CHECK_TYPE_CONFIG).map(([type, config]) => (
<option key={type} value={type}>
{config.label}
</option>
))}
</Select>
<Select
value={selectedStage}
onChange={(e) => setSelectedStage(e.target.value as ProcessStage | '')}
placeholder="Etapa del proceso"
>
<option value="">Todas las etapas</option>
{Object.entries(PROCESS_STAGE_LABELS).map(([stage, label]) => (
<option key={stage} value={stage}>
{label}
</option>
))}
</Select>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnly"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="activeOnly" className="text-sm text-[var(--text-secondary)]">
Solo activas
</label>
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar plantillas..."
filters={[
{
key: 'checkType',
label: 'Tipo de control',
type: 'dropdown',
value: selectedCheckType,
onChange: (value) => setSelectedCheckType(value as QualityCheckType | ''),
placeholder: 'Todos los tipos',
options: Object.entries(QUALITY_CHECK_TYPE_CONFIG).map(([type, config]) => ({
value: type,
label: config.label
}))
},
{
key: 'stage',
label: 'Etapa del proceso',
type: 'dropdown',
value: selectedStage,
onChange: (value) => setSelectedStage(value as ProcessStage | ''),
placeholder: 'Todas las etapas',
options: Object.entries(PROCESS_STAGE_LABELS).map(([stage, label]) => ({
value: stage,
label: label
}))
},
{
key: 'activeOnly',
label: 'Solo activas',
type: 'checkbox',
value: showActiveOnly,
onChange: (value) => setShowActiveOnly(value as boolean)
}
] as FilterConfig[]}
/>
{/* Templates Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">

View File

@@ -18,30 +18,33 @@ interface CreateRecipeModalProps {
*/
// Custom Ingredients Component for AddModal
const IngredientsComponent: React.FC<{
value: RecipeIngredientCreate[];
value: RecipeIngredientCreate[] | undefined | null;
onChange: (value: RecipeIngredientCreate[]) => void;
availableIngredients: Array<{value: string; label: string}>;
unitOptions: Array<{value: MeasurementUnit; label: string}>;
}> = ({ value, onChange, availableIngredients, unitOptions }) => {
// Ensure value is always an array
const ingredientsArray = Array.isArray(value) ? value : [];
const addIngredient = () => {
const newIngredient: RecipeIngredientCreate = {
ingredient_id: '',
quantity: 1,
unit: MeasurementUnit.GRAMS,
ingredient_order: value.length + 1,
ingredient_order: ingredientsArray.length + 1,
is_optional: false
};
onChange([...value, newIngredient]);
onChange([...ingredientsArray, newIngredient]);
};
const removeIngredient = (index: number) => {
if (value.length > 1) {
onChange(value.filter((_, i) => i !== index));
if (ingredientsArray.length > 1) {
onChange(ingredientsArray.filter((_, i) => i !== index));
}
};
const updateIngredient = (index: number, field: keyof RecipeIngredientCreate, newValue: any) => {
const updated = value.map((ingredient, i) =>
const updated = ingredientsArray.map((ingredient, i) =>
i === index ? { ...ingredient, [field]: newValue } : ingredient
);
onChange(updated);
@@ -62,14 +65,14 @@ const IngredientsComponent: React.FC<{
</div>
<div className="space-y-3 max-h-64 overflow-y-auto">
{value.map((ingredient, index) => (
{ingredientsArray.map((ingredient, index) => (
<div key={index} className="p-3 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--text-primary)]">Ingrediente #{index + 1}</span>
<button
type="button"
onClick={() => removeIngredient(index)}
disabled={value.length <= 1}
disabled={ingredientsArray.length <= 1}
className="p-1 text-red-500 hover:text-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 className="w-4 h-4" />

View File

@@ -1,40 +1,200 @@
import React from 'react';
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
interface LoadingSpinnerProps {
overlay?: boolean;
export interface LoadingSpinnerProps {
/** Tamaño del spinner */
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Variante del spinner */
variant?: 'spinner' | 'dots' | 'pulse' | 'skeleton';
/** Color personalizado */
color?: 'primary' | 'secondary' | 'white' | 'gray';
/** Texto de carga opcional */
text?: string;
size?: 'sm' | 'md' | 'lg';
/** Mostrar como overlay de pantalla completa */
overlay?: boolean;
/** Centrar el spinner */
centered?: boolean;
/** Clase CSS adicional */
className?: string;
/** Props adicionales para accesibilidad */
'aria-label'?: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
overlay = false,
const LoadingSpinner = forwardRef<HTMLDivElement, LoadingSpinnerProps>(({
size = 'md',
variant = 'spinner',
color = 'primary',
text,
size = 'md'
}) => {
overlay = false,
centered = false,
className,
'aria-label': ariaLabel = 'Cargando',
...props
}, ref) => {
const sizeClasses = {
sm: 'w-4 h-4',
xs: 'w-4 h-4',
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-12 h-12'
lg: 'w-12 h-12',
xl: 'w-16 h-16'
};
const spinner = (
<div className="flex flex-col items-center justify-center">
<div className={`animate-spin rounded-full border-4 border-[var(--border-secondary)] border-t-[var(--color-primary)] ${sizeClasses[size]}`}></div>
{text && (
<p className="mt-4 text-[var(--text-secondary)] text-sm">{text}</p>
const colorClasses = {
primary: 'text-[var(--color-primary)]',
secondary: 'text-[var(--color-secondary)]',
white: 'text-white',
gray: 'text-[var(--text-tertiary)]'
};
const textSizeClasses = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl'
};
const SpinnerIcon = () => (
<svg
className={clsx(
'animate-spin',
sizeClasses[size],
colorClasses[color]
)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
role="img"
aria-label={ariaLabel}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
const DotsSpinner = () => {
const dotSize = size === 'xs' ? 'w-1 h-1' : size === 'sm' ? 'w-1.5 h-1.5' : size === 'md' ? 'w-2 h-2' : size === 'lg' ? 'w-3 h-3' : 'w-4 h-4';
return (
<div className="flex space-x-1" role="img" aria-label={ariaLabel}>
{[0, 1, 2].map((i) => (
<div
key={i}
className={clsx(
'rounded-full animate-pulse',
dotSize,
color === 'white' ? 'bg-white' :
color === 'primary' ? 'bg-[var(--color-primary)]' :
color === 'secondary' ? 'bg-[var(--color-secondary)]' :
'bg-[var(--text-tertiary)]'
)}
style={{
animationDelay: `${i * 0.2}s`,
animationDuration: '1.4s'
}}
/>
))}
</div>
);
};
const PulseSpinner = () => (
<div
className={clsx(
'animate-pulse rounded-full',
sizeClasses[size],
color === 'white' ? 'bg-white' :
color === 'primary' ? 'bg-[var(--color-primary)]' :
color === 'secondary' ? 'bg-[var(--color-secondary)]' :
'bg-[var(--text-tertiary)]'
)}
role="img"
aria-label={ariaLabel}
/>
);
const SkeletonSpinner = () => {
const skeletonHeight = size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-6' : size === 'lg' ? 'h-8' : 'h-12';
return (
<div className="space-y-2 animate-pulse" role="img" aria-label={ariaLabel}>
<div className={clsx('bg-[var(--bg-quaternary)] rounded', skeletonHeight, 'w-full')} />
<div className={clsx('bg-[var(--bg-quaternary)] rounded', skeletonHeight, 'w-3/4')} />
<div className={clsx('bg-[var(--bg-quaternary)] rounded', skeletonHeight, 'w-1/2')} />
</div>
);
};
const renderSpinner = () => {
switch (variant) {
case 'dots':
return <DotsSpinner />;
case 'pulse':
return <PulseSpinner />;
case 'skeleton':
return <SkeletonSpinner />;
default:
return <SpinnerIcon />;
}
};
const containerClasses = clsx(
'flex items-center',
{
'justify-center': centered,
'flex-col': text && variant !== 'skeleton',
'gap-3': text,
},
className
);
const content = (
<div className={containerClasses} ref={ref} {...props}>
{renderSpinner()}
{text && variant !== 'skeleton' && (
<span
className={clsx(
'font-medium mt-4',
textSizeClasses[size],
colorClasses[color],
{
'text-white': overlay && color !== 'white',
'text-[var(--text-secondary)]': !overlay && color !== 'white'
}
)}
role="status"
aria-live="polite"
>
{text}
</span>
)}
</div>
);
if (overlay) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[var(--bg-primary)] rounded-lg p-6">
{spinner}
{content}
</div>
</div>
);
}
return spinner;
};
return content;
});
LoadingSpinner.displayName = 'LoadingSpinner';
export { LoadingSpinner };

View File

@@ -1,195 +0,0 @@
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
export interface LoadingSpinnerProps {
/** Tamaño del spinner */
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Variante del spinner */
variant?: 'spinner' | 'dots' | 'pulse' | 'skeleton';
/** Color personalizado */
color?: 'primary' | 'secondary' | 'white' | 'gray';
/** Texto de carga opcional */
text?: string;
/** Mostrar como overlay de pantalla completa */
overlay?: boolean;
/** Centrar el spinner */
centered?: boolean;
/** Clase CSS adicional */
className?: string;
/** Props adicionales */
'aria-label'?: string;
}
const LoadingSpinner = forwardRef<HTMLDivElement, LoadingSpinnerProps>(({
size = 'md',
variant = 'spinner',
color = 'primary',
text,
overlay = false,
centered = false,
className,
'aria-label': ariaLabel = 'Cargando',
...props
}, ref) => {
const sizeClasses = {
xs: 'w-4 h-4',
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-12 h-12',
xl: 'w-16 h-16'
};
const colorClasses = {
primary: 'text-color-primary',
secondary: 'text-color-secondary',
white: 'text-white',
gray: 'text-text-tertiary'
};
const textSizeClasses = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl'
};
// Componente Spinner (rotación)
const SpinnerIcon = () => (
<svg
className={clsx(
'animate-spin',
sizeClasses[size],
colorClasses[color]
)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
role="img"
aria-label={ariaLabel}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
// Componente Dots (puntos que aparecen y desaparecen)
const DotsSpinner = () => {
const dotSize = size === 'xs' ? 'w-1 h-1' : size === 'sm' ? 'w-1.5 h-1.5' : size === 'md' ? 'w-2 h-2' : size === 'lg' ? 'w-3 h-3' : 'w-4 h-4';
return (
<div className="flex space-x-1" role="img" aria-label={ariaLabel}>
{[0, 1, 2].map((i) => (
<div
key={i}
className={clsx(
'rounded-full animate-pulse',
dotSize,
colorClasses[color] === 'text-white' ? 'bg-white' :
colorClasses[color] === 'text-color-primary' ? 'bg-color-primary' :
colorClasses[color] === 'text-color-secondary' ? 'bg-color-secondary' :
'bg-text-tertiary'
)}
style={{
animationDelay: `${i * 0.2}s`,
animationDuration: '1.4s'
}}
/>
))}
</div>
);
};
// Componente Pulse (respiración)
const PulseSpinner = () => (
<div
className={clsx(
'animate-pulse rounded-full',
sizeClasses[size],
colorClasses[color] === 'text-white' ? 'bg-white' :
colorClasses[color] === 'text-color-primary' ? 'bg-color-primary' :
colorClasses[color] === 'text-color-secondary' ? 'bg-color-secondary' :
'bg-text-tertiary'
)}
role="img"
aria-label={ariaLabel}
/>
);
// Componente Skeleton (placeholder animado)
const SkeletonSpinner = () => {
const skeletonHeight = size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-6' : size === 'lg' ? 'h-8' : 'h-12';
return (
<div className="space-y-2 animate-pulse" role="img" aria-label={ariaLabel}>
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-full')} />
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-3/4')} />
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-1/2')} />
</div>
);
};
const renderSpinner = () => {
switch (variant) {
case 'dots':
return <DotsSpinner />;
case 'pulse':
return <PulseSpinner />;
case 'skeleton':
return <SkeletonSpinner />;
default:
return <SpinnerIcon />;
}
};
const containerClasses = clsx(
'flex items-center',
{
'justify-center': centered,
'flex-col': text && variant !== 'skeleton',
'gap-3': text,
'fixed inset-0 bg-black/30 backdrop-blur-sm z-modal': overlay,
'min-h-[200px]': overlay
},
className
);
const content = (
<div className={containerClasses} ref={ref} {...props}>
{renderSpinner()}
{text && variant !== 'skeleton' && (
<span
className={clsx(
'font-medium',
textSizeClasses[size],
colorClasses[color],
{
'text-white': overlay && color !== 'white'
}
)}
role="status"
aria-live="polite"
>
{text}
</span>
)}
</div>
);
return content;
});
LoadingSpinner.displayName = 'LoadingSpinner';
export default LoadingSpinner;

View File

@@ -1,2 +0,0 @@
export { default as LoadingSpinner } from './LoadingSpinner';
export type { LoadingSpinnerProps } from './LoadingSpinner';

View File

@@ -0,0 +1,89 @@
/**
* ResponsiveText Component
* Automatically adjusts text size and truncation based on screen size
*/
import React from 'react';
import { TextOverflowPrevention, getScreenSize, overflowClasses } from '../../../utils/textUtils';
export interface ResponsiveTextProps {
text: string;
maxLength?: {
mobile?: number;
tablet?: number;
desktop?: number;
};
className?: string;
title?: string;
truncationType?: 'statusCard' | 'production' | 'mobile';
textType?: 'title' | 'subtitle' | 'label' | 'metadata' | 'action';
showTooltip?: boolean;
as?: keyof JSX.IntrinsicElements;
}
export const ResponsiveText: React.FC<ResponsiveTextProps> = ({
text,
maxLength,
className = '',
title,
truncationType = 'statusCard',
textType = 'title',
showTooltip = true,
as: Component = 'span'
}) => {
const screenSize = React.useMemo(() => getScreenSize(), []);
// Get truncation engine based on type
const getTruncationEngine = () => {
switch (truncationType) {
case 'mobile':
return TextOverflowPrevention.mobile;
case 'production':
return TextOverflowPrevention.production;
default:
return TextOverflowPrevention.statusCard;
}
};
const engine = getTruncationEngine();
// Get truncated text based on text type
const getTruncatedText = () => {
if (maxLength) {
const currentMaxLength = maxLength[screenSize] || maxLength.desktop || text.length;
return text.length > currentMaxLength
? text.substring(0, currentMaxLength - 3) + '...'
: text;
}
// Use predefined truncation methods
switch (textType) {
case 'title':
return engine.title ? engine.title(text) : text;
case 'subtitle':
return engine.subtitle ? engine.subtitle(text) : text;
case 'label':
return engine.primaryValueLabel ? engine.primaryValueLabel(text) : text;
case 'metadata':
return engine.metadataItem ? engine.metadataItem(text) : text;
case 'action':
return engine.actionLabel ? engine.actionLabel(text) : text;
default:
return text;
}
};
const truncatedText = getTruncatedText();
const shouldShowTooltip = showTooltip && truncatedText !== text;
return (
<Component
className={`${overflowClasses.truncate} ${className}`}
title={shouldShowTooltip ? (title || text) : title}
>
{truncatedText}
</Component>
);
};
export default ResponsiveText;

View File

@@ -0,0 +1,2 @@
export { ResponsiveText } from './ResponsiveText';
export type { ResponsiveTextProps } from './ResponsiveText';

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { Search, Filter } from 'lucide-react';
import { Input } from '../Input';
import { Button } from '../Button';
import { Card } from '../Card';
import { Select } from '../Select';
import { Toggle } from '../Toggle';
export interface FilterOption {
value: string;
label: string;
count?: number;
}
export interface FilterConfig {
key: string;
label: string;
type: 'dropdown' | 'buttons' | 'checkbox';
options?: FilterOption[];
value: string | string[] | boolean;
onChange: (value: string | string[] | boolean) => void;
placeholder?: string;
multiple?: boolean;
}
export interface SearchAndFilterProps {
// Search configuration
searchValue: string;
onSearchChange: (value: string) => void;
searchPlaceholder?: string;
// Filter configuration
filters?: FilterConfig[];
// Layout configuration
className?: string;
showFilterIcon?: boolean;
// Mobile responsive behavior
stackOnMobile?: boolean;
}
export const SearchAndFilter: React.FC<SearchAndFilterProps> = ({
searchValue,
onSearchChange,
searchPlaceholder = "Buscar...",
filters = [],
className = "",
showFilterIcon = true,
stackOnMobile = true
}) => {
const renderDropdownFilter = (filter: FilterConfig) => {
const currentValue = filter.value as string;
// Convert FilterOptions to SelectOptions format
const selectOptions = [
{ value: '', label: filter.placeholder || `Todos ${filter.label.toLowerCase()}` },
...(filter.options?.map(option => ({
value: option.value,
label: option.count !== undefined ? `${option.label} (${option.count})` : option.label
})) || [])
];
return (
<div key={filter.key} className="min-w-[140px]">
<Select
value={currentValue}
onChange={(value) => filter.onChange(value)}
options={selectOptions}
placeholder={filter.placeholder}
/>
</div>
);
};
const renderButtonFilter = (filter: FilterConfig) => {
const currentValues = Array.isArray(filter.value) ? filter.value : [filter.value];
return (
<div key={filter.key} className="flex items-center gap-2 flex-wrap">
{filter.options?.map((option) => {
const isActive = currentValues.includes(option.value);
return (
<Button
key={option.value}
variant={isActive ? "primary" : "outline"}
size="sm"
onClick={() => {
if (filter.multiple) {
const newValues = isActive
? currentValues.filter(v => v !== option.value)
: [...currentValues, option.value];
filter.onChange(newValues);
} else {
filter.onChange(isActive ? "" : option.value);
}
}}
className={`text-sm px-3 py-1.5 ${
isActive
? 'bg-[var(--primary)] text-white'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
{option.label}
{option.count !== undefined && (
<span className={`ml-1 ${isActive ? 'text-white/80' : 'text-[var(--text-tertiary)]'}`}>
({option.count})
</span>
)}
</Button>
);
})}
</div>
);
};
const renderCheckboxFilter = (filter: FilterConfig) => {
const isChecked = filter.value as boolean;
return (
<div key={filter.key} className="flex items-center">
<Toggle
checked={isChecked}
onChange={(checked) => filter.onChange(checked)}
label={filter.label}
size="sm"
/>
</div>
);
};
const renderFilter = (filter: FilterConfig) => {
switch (filter.type) {
case 'dropdown':
return renderDropdownFilter(filter);
case 'buttons':
return renderButtonFilter(filter);
case 'checkbox':
return renderCheckboxFilter(filter);
default:
return null;
}
};
return (
<Card className={`p-4 ${className}`}>
<div className={`flex gap-4 ${stackOnMobile ? 'flex-col sm:flex-row' : 'flex-row'}`}>
{/* Search Input */}
<div className="flex-1 min-w-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
type="text"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 w-full"
/>
</div>
</div>
{/* Filters */}
{filters.length > 0 && (
<div className={`flex items-center gap-3 ${stackOnMobile ? 'flex-wrap' : ''}`}>
{showFilterIcon && (
<Filter className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
)}
{filters.map(renderFilter)}
</div>
)}
</div>
</Card>
);
};
export default SearchAndFilter;

View File

@@ -0,0 +1 @@
export { SearchAndFilter, type SearchAndFilterProps, type FilterConfig, type FilterOption } from './SearchAndFilter';

View File

@@ -4,6 +4,7 @@ import { Card } from '../Card';
import { Button } from '../Button';
import { ProgressBar } from '../ProgressBar';
import { statusColors } from '../../../styles/colors';
import { TextOverflowPrevention, overflowClasses, getScreenSize, safeText } from '../../../utils/textUtils';
export interface StatusIndicatorConfig {
color: string;
@@ -95,6 +96,13 @@ export const StatusCard: React.FC<StatusCardProps> = ({
const StatusIcon = statusIndicator.icon;
const hasInteraction = onClick || actions.length > 0;
// Detect screen size for responsive truncation
const screenSize = React.useMemo(() => getScreenSize(), []);
const isMobile = screenSize === 'mobile';
// Apply truncation based on screen size
const truncationEngine = isMobile ? TextOverflowPrevention.mobile : TextOverflowPrevention.statusCard;
// Sort actions by priority
const sortedActions = [...actions].sort((a, b) => {
const priorityOrder = { primary: 0, secondary: 1, tertiary: 2 };
@@ -149,8 +157,11 @@ export const StatusCard: React.FC<StatusCardProps> = ({
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-[var(--text-primary)] text-base sm:text-lg leading-tight mb-1 truncate">
{title}
<div
className={`font-semibold text-[var(--text-primary)] text-base sm:text-lg leading-tight mb-1 ${overflowClasses.truncate}`}
title={title}
>
{truncationEngine.title(title)}
</div>
<div className="flex items-center gap-2 mb-1">
<div
@@ -160,7 +171,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
: statusIndicator.isHighlight
? 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300 shadow-sm'
: 'ring-1 shadow-sm'
}`}
} max-w-[120px] sm:max-w-[150px]`}
style={{
backgroundColor: statusIndicator.isCritical || statusIndicator.isHighlight
? undefined
@@ -172,28 +183,42 @@ export const StatusCard: React.FC<StatusCardProps> = ({
}}
>
{statusIndicator.isCritical && (
<span className="mr-2 text-sm">🚨</span>
<span className="mr-1 text-sm flex-shrink-0">🚨</span>
)}
{statusIndicator.isHighlight && (
<span className="mr-1.5"></span>
<span className="mr-1 flex-shrink-0"></span>
)}
{statusIndicator.text}
<span
className={`${overflowClasses.truncate} flex-1`}
title={statusIndicator.text}
>
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 12 : 15)}
</span>
</div>
</div>
{subtitle && (
<div className="text-sm text-[var(--text-secondary)] truncate">
{subtitle}
<div
className={`text-sm text-[var(--text-secondary)] ${overflowClasses.truncate}`}
title={subtitle}
>
{truncationEngine.subtitle(subtitle)}
</div>
)}
</div>
</div>
<div className="text-right flex-shrink-0 ml-4 min-w-0">
<div className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none truncate">
{primaryValue}
<div className="text-right flex-shrink-0 ml-4 min-w-0 max-w-[120px] sm:max-w-[150px]">
<div
className={`text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none ${overflowClasses.truncate}`}
title={primaryValue?.toString()}
>
{safeText(primaryValue?.toString(), '0', isMobile ? 10 : 15)}
</div>
{primaryValueLabel && (
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1 truncate">
{primaryValueLabel}
<div
className={`text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1 ${overflowClasses.truncate}`}
title={primaryValueLabel}
>
{truncationEngine.primaryValueLabel(primaryValueLabel)}
</div>
)}
</div>
@@ -201,12 +226,18 @@ export const StatusCard: React.FC<StatusCardProps> = ({
{/* Secondary info - Mobile optimized */}
{secondaryInfo && (
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-[var(--text-secondary)] truncate flex-shrink-0">
{secondaryInfo.label}
<div className="flex items-center justify-between text-sm gap-2 min-w-0">
<span
className={`text-[var(--text-secondary)] flex-shrink-0 ${overflowClasses.truncate} max-w-[100px] sm:max-w-[120px]`}
title={secondaryInfo.label}
>
{truncationEngine.secondaryLabel(secondaryInfo.label)}
</span>
<span className="font-medium text-[var(--text-primary)] truncate text-right">
{secondaryInfo.value}
<span
className={`font-medium text-[var(--text-primary)] text-right flex-1 ${overflowClasses.truncate}`}
title={secondaryInfo.value}
>
{truncationEngine.secondaryValue(secondaryInfo.value)}
</span>
</div>
)}
@@ -220,7 +251,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
size="md"
variant="default"
showLabel={true}
label={progress.label}
label={truncationEngine.progressLabel(progress.label)}
animated={progress.percentage < 100}
customColor={progress.color || statusIndicator.color}
className="w-full"
@@ -230,12 +261,23 @@ export const StatusCard: React.FC<StatusCardProps> = ({
)}
{/* Metadata - Improved mobile layout */}
{/* Metadata - Improved mobile layout with overflow prevention */}
{metadata.length > 0 && (
<div className="text-xs text-[var(--text-secondary)] space-y-1">
{metadata.map((item, index) => (
<div key={index} className="truncate" title={item}>{item}</div>
{metadata.slice(0, isMobile ? 3 : 4).map((item, index) => (
<div
key={index}
className={`${overflowClasses.truncate} ${overflowClasses.breakWords}`}
title={item}
>
{truncationEngine.metadataItem(item)}
</div>
))}
{metadata.length > (isMobile ? 3 : 4) && (
<div className="text-[var(--text-tertiary)] italic">
+{metadata.length - (isMobile ? 3 : 4)} más...
</div>
)}
</div>
)}
@@ -248,18 +290,24 @@ export const StatusCard: React.FC<StatusCardProps> = ({
{/* Primary action as a subtle text button */}
{primaryActions.length > 0 && (
<button
onClick={primaryActions[0].onClick}
onClick={(e) => {
e.stopPropagation();
primaryActions[0].onClick();
}}
className={`
flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium rounded-lg
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0 max-w-[120px] sm:max-w-[150px]
${primaryActions[0].destructive
? 'text-red-600 hover:bg-red-50 hover:text-red-700'
: 'text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)] hover:text-[var(--color-primary-700)]'
}
`}
title={primaryActions[0].label}
>
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
<span className="truncate">{primaryActions[0].label}</span>
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" })}
<span className={`${overflowClasses.truncate} flex-1`}>
{truncationEngine.actionLabel(primaryActions[0].label)}
</span>
</button>
)}
@@ -268,7 +316,10 @@ export const StatusCard: React.FC<StatusCardProps> = ({
{secondaryActions.map((action, index) => (
<button
key={`action-${index}`}
onClick={action.onClick}
onClick={(e) => {
e.stopPropagation();
action.onClick();
}}
title={action.label}
className={`
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
@@ -286,7 +337,10 @@ export const StatusCard: React.FC<StatusCardProps> = ({
{primaryActions.slice(1).map((action, index) => (
<button
key={`primary-icon-${index}`}
onClick={action.onClick}
onClick={(e) => {
e.stopPropagation();
action.onClick();
}}
title={action.label}
className={`
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95

View File

@@ -25,6 +25,8 @@ export { TenantSwitcher } from './TenantSwitcher';
export { LanguageSelector, CompactLanguageSelector } from './LanguageSelector';
export { LoadingSpinner } from './LoadingSpinner';
export { EmptyState } from './EmptyState';
export { ResponsiveText } from './ResponsiveText';
export { SearchAndFilter } from './SearchAndFilter';
// Export types
export type { ButtonProps } from './Button';
@@ -50,4 +52,6 @@ export type { EditViewModalProps, EditViewModalField, EditViewModalSection, Edit
export type { AddModalProps, AddModalField, AddModalSection } from './AddModal';
export type { DialogModalProps, DialogModalAction } from './DialogModal';
export type { LoadingSpinnerProps } from './LoadingSpinner';
export type { EmptyStateProps } from './EmptyState';
export type { EmptyStateProps } from './EmptyState';
export type { ResponsiveTextProps } from './ResponsiveText';
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
import { Button, Card, Badge, Modal, Table, Select, Input, StatsGrid, StatusCard } from '../../../../components/ui';
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useCurrentTenant } from '../../../../stores/tenant.store';
@@ -265,31 +265,28 @@ const ModelsConfigPage: React.FC = () => {
</Card>
)}
{/* Filters */}
<Card className="p-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar ingrediente..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="w-full sm:w-48">
<Select
value={statusFilter}
onChange={(value) => setStatusFilter(value as string)}
options={[
{ value: 'all', label: 'Todos los estados' },
{ value: 'no_model', label: 'Sin modelo' },
{ value: 'active', label: 'Activo' },
{ value: 'training', label: 'Entrenando' },
{ value: 'error', label: 'Error' },
]}
/>
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar ingrediente..."
filters={[
{
key: 'status',
label: 'Estado',
type: 'dropdown',
value: statusFilter,
onChange: (value) => setStatusFilter(value as string),
placeholder: 'Todos los estados',
options: [
{ value: 'no_model', label: 'Sin modelo' },
{ value: 'active', label: 'Activo' },
{ value: 'training', label: 'Entrenando' },
{ value: 'error', label: 'Error' }
]
}
] as FilterConfig[]}
/>
{/* Models Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -345,6 +342,13 @@ const ModelsConfigPage: React.FC = () => {
label: 'Último entrenamiento',
value: new Date(status.lastTrainingDate).toLocaleDateString('es-ES')
} : undefined}
onClick={() => {
if (status.hasModel) {
handleViewModelDetails(status.ingredient);
} else {
handleStartTraining(status.ingredient);
}
}}
actions={[
// Primary action - View details or train model
{

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2, Archive, TrendingUp, History } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, Card } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
@@ -20,6 +20,8 @@ import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate
const InventoryPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null);
// Modal states for focused actions
@@ -206,6 +208,19 @@ const InventoryPage: React.FC = () => {
);
}
// Apply status filter
if (statusFilter) {
items = items.filter(ingredient => {
const status = getInventoryStatusConfig(ingredient);
return status.text.toLowerCase().includes(statusFilter.toLowerCase());
});
}
// Apply category filter
if (categoryFilter) {
items = items.filter(ingredient => ingredient.category === categoryFilter);
}
// Sort by priority: expired → out of stock → low stock → normal → overstock
// Within each priority level, sort by most critical items first
@@ -259,7 +274,7 @@ const InventoryPage: React.FC = () => {
return aPriority - bPriority;
});
}, [ingredients, searchTerm]);
}, [ingredients, searchTerm, statusFilter, categoryFilter]);
// Helper function to get category display name
const getCategoryDisplayName = (category?: string): string => {
@@ -502,19 +517,40 @@ const InventoryPage: React.FC = () => {
{/* Simplified Controls */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar artículos por nombre, categoría o proveedor..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar artículos por nombre, categoría o proveedor..."
filters={[
{
key: 'status',
label: 'Estado',
type: 'dropdown',
value: statusFilter,
onChange: (value) => setStatusFilter(value as string),
placeholder: 'Todos los estados',
options: [
{ value: 'normal', label: 'Normal' },
{ value: 'bajo', label: 'Stock Bajo' },
{ value: 'sin stock', label: 'Sin Stock' },
{ value: 'caducado', label: 'Caducado' },
{ value: 'sobrestock', label: 'Sobrestock' }
]
},
{
key: 'category',
label: 'Categoría',
type: 'dropdown',
value: categoryFilter,
onChange: (value) => setCategoryFilter(value as string),
placeholder: 'Todas las categorías',
options: Array.from(new Set(ingredients.map(item => item.category)))
.filter(Boolean)
.map(category => ({ value: category, label: getCategoryDisplayName(category) }))
}
] as FilterConfig[]}
/>
{/* Inventory Items Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -551,6 +587,7 @@ const InventoryPage: React.FC = () => {
percentage: stockPercentage,
color: statusConfig.color
} : undefined}
onClick={() => handleShowInfo(ingredient)}
actions={[
// Primary action - View item details
{

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, AlertTriangle, Settings, CheckCircle, Eye, Wrench, Thermometer, Activity, Search, Filter, Bell, History, Calendar, Edit, Trash2 } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Badge } from '../../../../components/ui/Badge';
import { LoadingSpinner } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
@@ -150,7 +150,8 @@ const MOCK_EQUIPMENT: Equipment[] = [
const MaquinariaPage: React.FC = () => {
const { t } = useTranslation(['equipment', 'common']);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<Equipment['status'] | 'all'>('all');
const [statusFilter, setStatusFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [selectedItem, setSelectedItem] = useState<Equipment | null>(null);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [showEquipmentModal, setShowEquipmentModal] = useState(false);
@@ -231,11 +232,12 @@ const MaquinariaPage: React.FC = () => {
eq.location.toLowerCase().includes(searchTerm.toLowerCase()) ||
eq.type.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || eq.status === statusFilter;
const matchesStatus = !statusFilter || eq.status === statusFilter;
const matchesType = !typeFilter || eq.type === typeFilter;
return matchesSearch && matchesStatus;
return matchesSearch && matchesStatus && matchesType;
});
}, [MOCK_EQUIPMENT, searchTerm, statusFilter]);
}, [MOCK_EQUIPMENT, searchTerm, statusFilter, typeFilter]);
const equipmentStats = useMemo(() => {
const total = MOCK_EQUIPMENT.length;
@@ -342,36 +344,44 @@ const MaquinariaPage: React.FC = () => {
columns={3}
/>
{/* Controls */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
placeholder={t('common:forms.search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-[var(--text-tertiary)]" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as Equipment['status'] | 'all')}
className="px-3 py-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]"
>
<option value="all">{t('common:forms.select_option')}</option>
<option value="operational">{t('equipment_status.operational')}</option>
<option value="warning">{t('equipment_status.warning')}</option>
<option value="maintenance">{t('equipment_status.maintenance')}</option>
<option value="down">{t('equipment_status.down')}</option>
</select>
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder={t('common:forms.search_placeholder')}
filters={[
{
key: 'status',
label: t('fields.status'),
type: 'dropdown',
value: statusFilter,
onChange: (value) => setStatusFilter(value as string),
placeholder: t('common:forms.select_option'),
options: [
{ value: 'operational', label: t('equipment_status.operational') },
{ value: 'warning', label: t('equipment_status.warning') },
{ value: 'maintenance', label: t('equipment_status.maintenance') },
{ value: 'down', label: t('equipment_status.down') }
]
},
{
key: 'type',
label: 'Tipo',
type: 'dropdown',
value: typeFilter,
onChange: (value) => setTypeFilter(value as string),
placeholder: 'Todos los tipos',
options: [
{ value: 'oven', label: 'Horno' },
{ value: 'mixer', label: 'Batidora' },
{ value: 'proofer', label: 'Fermentadora' },
{ value: 'freezer', label: 'Congelador' },
{ value: 'packaging', label: 'Empaquetado' },
{ value: 'other', label: 'Otro' }
]
}
] as FilterConfig[]}
/>
{/* Equipment Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -397,6 +407,7 @@ const MaquinariaPage: React.FC = () => {
label: t('fields.uptime'),
value: `${equipment.uptime.toFixed(1)}%`
}}
onClick={() => handleShowMaintenanceDetails(equipment)}
actions={[
{
label: t('actions.view_details'),

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Loader, Euro } from 'lucide-react';
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs } from '../../../../components/ui';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import {
@@ -28,6 +28,7 @@ import { useTranslation } from 'react-i18next';
const OrdersPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'orders' | 'customers'>('orders');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedOrder, setSelectedOrder] = useState<OrderResponse | null>(null);
@@ -119,8 +120,10 @@ const OrdersPage: React.FC = () => {
};
const filteredOrders = orders.filter(order => {
return order.order_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.id.toLowerCase().includes(searchTerm.toLowerCase());
const matchesSearch = order.order_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.id.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = !statusFilter || order.status === statusFilter;
return matchesSearch && matchesStatus;
});
const filteredCustomers = customers.filter(customer => {
@@ -321,19 +324,29 @@ const OrdersPage: React.FC = () => {
columns={3}
/>
{/* Simplified Controls */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder={activeTab === 'orders' ? 'Buscar pedidos por número o ID...' : 'Buscar clientes por nombre, código o email...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder={activeTab === 'orders'
? 'Buscar pedidos por número o ID...'
: 'Buscar clientes por nombre, código o email...'
}
filters={activeTab === 'orders' ? [
{
key: 'status',
label: 'Estado',
type: 'dropdown',
value: statusFilter,
onChange: (value) => setStatusFilter(value as string),
placeholder: 'Todos los estados',
options: Object.values(OrderStatus).map(status => ({
value: status,
label: t(`orders:order_status.${status.toLowerCase()}`)
}))
}
] as FilterConfig[] : []}
/>
{/* Content Grid - Mobile-first responsive */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -367,6 +380,12 @@ const OrdersPage: React.FC = () => {
`${t(`orders:delivery_methods.${order.delivery_method.toLowerCase()}`)}`,
...(paymentNote ? [paymentNote] : [])
]}
onClick={() => {
setSelectedOrder(order);
setIsCreating(false);
setModalMode('view');
setShowForm(true);
}}
actions={[
{
label: 'Ver Detalles',
@@ -418,6 +437,12 @@ const OrdersPage: React.FC = () => {
customer.email || 'Sin email',
`Desde ${new Date(customer.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' })}`
]}
onClick={() => {
setSelectedCustomer(customer);
setIsCreating(false);
setModalMode('view');
setShowForm(true);
}}
actions={[
{
label: 'Ver Detalles',

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Plus, ShoppingCart, Truck, Euro, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play, Zap, User } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal } from '../../../../components/ui';
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
@@ -16,6 +16,7 @@ import { useTenantStore } from '../../../../stores/tenant.store';
const ProcurementPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedPlan, setSelectedPlan] = useState<any>(null);
@@ -199,8 +200,10 @@ const ProcurementPage: React.FC = () => {
const matchesSearch = plan.plan_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
plan.status.toLowerCase().includes(searchTerm.toLowerCase()) ||
(plan.special_requirements && plan.special_requirements.toLowerCase().includes(searchTerm.toLowerCase()));
return matchesSearch;
const matchesStatus = !statusFilter || plan.status === statusFilter;
return matchesSearch && matchesStatus;
}) || [];
@@ -350,18 +353,29 @@ const ProcurementPage: React.FC = () => {
)}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar planes por número, estado o notas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
</div>
</Card>
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar planes por número, estado o notas..."
filters={[
{
key: 'status',
label: 'Estado',
type: 'dropdown',
value: statusFilter,
onChange: (value) => setStatusFilter(value as string),
placeholder: 'Todos los estados',
options: [
{ value: 'draft', label: 'Borrador' },
{ value: 'pending_approval', label: 'Pendiente Aprobación' },
{ value: 'approved', label: 'Aprobado' },
{ value: 'in_execution', label: 'En Ejecución' },
{ value: 'completed', label: 'Completado' },
{ value: 'cancelled', label: 'Cancelado' }
]
}
] as FilterConfig[]}
/>
{/* Procurement Plans Grid - Mobile-Optimized */}
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
@@ -461,9 +475,9 @@ const ProcurementPage: React.FC = () => {
id={plan.plan_number}
statusIndicator={statusConfig}
title={`Plan ${plan.plan_number}`}
subtitle={`${new Date(plan.plan_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' })} • ${plan.procurement_strategy}`}
subtitle={`${new Date(plan.plan_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })} • ${plan.procurement_strategy}`}
primaryValue={plan.total_requirements}
primaryValueLabel="requerimientos"
primaryValueLabel="reqs"
secondaryInfo={{
label: 'Presupuesto',
value: `${formatters.compact(plan.total_estimated_cost)}`
@@ -476,7 +490,7 @@ const ProcurementPage: React.FC = () => {
metadata={[
`Período: ${new Date(plan.plan_period_start).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`,
`Creado: ${new Date(plan.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`,
...(plan.special_requirements ? [`Req. especiales: ${plan.special_requirements}`] : [])
...(plan.special_requirements ? [`Especiales: ${plan.special_requirements.length > 30 ? plan.special_requirements.substring(0, 30) + '...' : plan.special_requirements}`] : [])
]}
actions={actions}
/>
@@ -639,7 +653,7 @@ const ProcurementPage: React.FC = () => {
isCritical: true
}}
title={requirement.product_name}
subtitle={`${requirement.requirement_number}${requirement.supplier_name || 'Sin proveedor'} • Plan: ${filteredPlans.find(p => p.id === selectedPlanForRequirements)?.plan_number || 'N/A'}`}
subtitle={`${requirement.requirement_number}${requirement.supplier_name || 'Sin proveedor'}`}
primaryValue={requirement.required_quantity}
primaryValueLabel={requirement.unit_of_measure}
secondaryInfo={{

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, Zap, User, PlusCircle } from 'lucide-react';
import { Button, Input, Card, StatsGrid, EditViewModal, Toggle } from '../../../../components/ui';
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { statusColors } from '../../../../styles/colors';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { LoadingSpinner } from '../../../../components/ui';
@@ -28,6 +28,8 @@ import { ProcessStage } from '../../../../api/types/qualityTemplates';
const ProductionPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState('');
const [selectedBatch, setSelectedBatch] = useState<ProductionBatchResponse | null>(null);
const [showBatchModal, setShowBatchModal] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -200,17 +202,32 @@ const ProductionPage: React.FC = () => {
const batches = activeBatchesData?.batches || [];
const filteredBatches = useMemo(() => {
if (!searchQuery) return batches;
let filtered = batches;
const searchLower = searchQuery.toLowerCase();
return batches.filter(batch =>
batch.product_name.toLowerCase().includes(searchLower) ||
batch.batch_number.toLowerCase().includes(searchLower) ||
(batch.staff_assigned && batch.staff_assigned.some(staff =>
staff.toLowerCase().includes(searchLower)
))
);
}, [batches, searchQuery]);
// Apply search filter
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
filtered = filtered.filter(batch =>
batch.product_name.toLowerCase().includes(searchLower) ||
batch.batch_number.toLowerCase().includes(searchLower) ||
(batch.staff_assigned && batch.staff_assigned.some(staff =>
staff.toLowerCase().includes(searchLower)
))
);
}
// Apply status filter
if (statusFilter) {
filtered = filtered.filter(batch => batch.status === statusFilter);
}
// Apply priority filter
if (priorityFilter) {
filtered = filtered.filter(batch => batch.priority === priorityFilter);
}
return filtered;
}, [batches, searchQuery, statusFilter, priorityFilter]);
// Calculate production stats from real data
const productionStats = useMemo(() => {
@@ -362,19 +379,38 @@ const ProductionPage: React.FC = () => {
{/* Production Batches Section - No tabs needed */}
<>
{/* Search Controls */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar lotes por producto, número de lote o personal..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="Buscar lotes por producto, número de lote o personal..."
filters={[
{
key: 'status',
label: 'Estado',
type: 'dropdown',
value: statusFilter,
onChange: (value) => setStatusFilter(value as string),
placeholder: 'Todos los estados',
options: Object.values(ProductionStatusEnum).map(status => ({
value: status,
label: status.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}))
},
{
key: 'priority',
label: 'Prioridad',
type: 'dropdown',
value: priorityFilter,
onChange: (value) => setPriorityFilter(value as string),
placeholder: 'Todas las prioridades',
options: Object.values(ProductionPriorityEnum).map(priority => ({
value: priority,
label: priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase()
}))
}
] as FilterConfig[]}
/>
{/* Production Batches Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal } from '../../../../components/ui';
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
@@ -14,6 +14,9 @@ import { QualityCheckConfigurationModal } from '../../../../components/domain/re
const RecipesPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [isSignatureOnly, setIsSignatureOnly] = useState(false);
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
@@ -104,15 +107,35 @@ const RecipesPage: React.FC = () => {
};
const filteredRecipes = useMemo(() => {
if (!searchTerm) return recipes;
let filtered = recipes;
const searchLower = searchTerm.toLowerCase();
return recipes.filter(recipe =>
recipe.name.toLowerCase().includes(searchLower) ||
(recipe.description && recipe.description.toLowerCase().includes(searchLower)) ||
(recipe.category && recipe.category.toLowerCase().includes(searchLower))
);
}, [recipes, searchTerm]);
// Apply search filter
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter(recipe =>
recipe.name.toLowerCase().includes(searchLower) ||
(recipe.description && recipe.description.toLowerCase().includes(searchLower)) ||
(recipe.category && recipe.category.toLowerCase().includes(searchLower))
);
}
// Apply category filter
if (categoryFilter) {
filtered = filtered.filter(recipe => recipe.category === categoryFilter);
}
// Apply difficulty filter
if (difficultyFilter) {
filtered = filtered.filter(recipe => recipe.difficulty_level?.toString() === difficultyFilter);
}
// Apply signature filter
if (isSignatureOnly) {
filtered = filtered.filter(recipe => recipe.is_signature_item);
}
return filtered;
}, [recipes, searchTerm, categoryFilter, difficultyFilter, isSignatureOnly]);
const recipeStats = useMemo(() => {
const stats = {
@@ -454,19 +477,51 @@ const RecipesPage: React.FC = () => {
columns={3}
/>
{/* Simplified Controls */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar recetas por nombre, descripción o categoría..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar recetas por nombre, descripción o categoría..."
filters={[
{
key: 'category',
label: 'Categoría',
type: 'dropdown',
value: categoryFilter,
onChange: (value) => setCategoryFilter(value as string),
placeholder: 'Todas las categorías',
options: [
{ value: 'bread', label: 'Pan' },
{ value: 'pastry', label: 'Bollería' },
{ value: 'cake', label: 'Tarta' },
{ value: 'cookie', label: 'Galleta' },
{ value: 'other', label: 'Otro' }
]
},
{
key: 'difficulty',
label: 'Dificultad',
type: 'dropdown',
value: difficultyFilter,
onChange: (value) => setDifficultyFilter(value as string),
placeholder: 'Todas las dificultades',
options: [
{ value: '1', label: 'Nivel 1 - Fácil' },
{ value: '2', label: 'Nivel 2 - Medio' },
{ value: '3', label: 'Nivel 3 - Difícil' },
{ value: '4', label: 'Nivel 4 - Muy Difícil' },
{ value: '5', label: 'Nivel 5 - Extremo' }
]
},
{
key: 'signature',
label: 'Solo recetas especiales',
type: 'checkbox',
value: isSignatureOnly,
onChange: (value) => setIsSignatureOnly(value as boolean)
}
] as FilterConfig[]}
/>
{/* Recipes Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -500,6 +555,11 @@ const RecipesPage: React.FC = () => {
`Rendimiento: ${recipe.yield_quantity} ${recipe.yield_unit}`,
`${recipe.ingredients?.length || 0} ingredientes principales`
]}
onClick={() => {
setSelectedRecipe(recipe);
setModalMode('view');
setShowForm(true);
}}
actions={[
// Primary action - View recipe details
{

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader } from 'lucide-react';
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal } from '../../../../components/ui';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers';
@@ -12,6 +12,8 @@ import { useTranslation } from 'react-i18next';
const SuppliersPage: React.FC = () => {
const [activeTab] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedSupplier, setSelectedSupplier] = useState<any>(null);
@@ -72,8 +74,12 @@ const SuppliersPage: React.FC = () => {
return t(`suppliers:payment_terms.${terms.toLowerCase()}`);
};
// Filtering is now handled by the API query parameters
const filteredSuppliers = suppliers;
// Apply additional client-side filtering
const filteredSuppliers = suppliers.filter(supplier => {
const matchesStatus = !statusFilter || supplier.status === statusFilter;
const matchesType = !typeFilter || supplier.supplier_type === typeFilter;
return matchesStatus && matchesType;
});
const supplierStats = statisticsData || {
total_suppliers: 0,
@@ -191,19 +197,38 @@ const SuppliersPage: React.FC = () => {
columns={3}
/>
{/* Simplified Controls */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar proveedores por nombre, código, email o contacto..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar proveedores por nombre, código, email o contacto..."
filters={[
{
key: 'status',
label: 'Estado',
type: 'dropdown',
value: statusFilter,
onChange: (value) => setStatusFilter(value as string),
placeholder: 'Todos los estados',
options: Object.values(SupplierStatus).map(status => ({
value: status,
label: t(`suppliers:status.${status.toLowerCase()}`)
}))
},
{
key: 'type',
label: 'Tipo',
type: 'dropdown',
value: typeFilter,
onChange: (value) => setTypeFilter(value as string),
placeholder: 'Todos los tipos',
options: Object.values(SupplierType).map(type => ({
value: type,
label: t(`suppliers:types.${type.toLowerCase()}`)
}))
}
] as FilterConfig[]}
/>
{/* Suppliers Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -229,6 +254,12 @@ const SuppliersPage: React.FC = () => {
supplier.phone || 'Sin teléfono',
`Creado: ${new Date(supplier.created_at).toLocaleDateString('es-ES')}`
]}
onClick={() => {
setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('view');
setShowForm(true);
}}
actions={[
// Primary action - View supplier details
{

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-react';
import { Button, Card, Input, StatusCard, getStatusColor, StatsGrid } from '../../../../components/ui';
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
import { PageHeader } from '../../../../components/layout';
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
@@ -345,38 +345,27 @@ const TeamPage: React.FC = () => {
gap="md"
/>
{/* Filters and Search */}
<Card className="p-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
<Input
placeholder="Buscar miembros del equipo..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{roles.map((role) => (
<button
key={role.value}
onClick={() => setSelectedRole(role.value)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
selectedRole === role.value
? 'bg-blue-600 text-white'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-quaternary)]'
}`}
>
{role.label} ({role.count})
</button>
))}
</div>
</div>
</Card>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar miembros del equipo..."
filters={[
{
key: 'role',
label: 'Rol',
type: 'buttons',
value: selectedRole,
onChange: (value) => setSelectedRole(value as string),
multiple: false,
options: roles.map(role => ({
value: role.value,
label: role.label,
count: role.count
}))
}
] as FilterConfig[]}
/>
{/* Add Member Button */}
{canManageTeam && filteredMembers.length > 0 && (

View File

@@ -350,23 +350,14 @@ export const routesConfig: RouteConfig[] = [
requiresAuth: true,
showInNavigation: true,
children: [
{
path: '/app/database/recipes',
name: 'Recipes',
component: 'RecipesPage',
title: 'Recetas',
icon: 'production',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/orders',
name: 'Orders',
component: 'OrdersPage',
title: 'Pedidos',
icon: 'orders',
{
path: '/app/database/information',
name: 'Information',
component: 'InformationPage',
title: 'Información',
icon: 'settings',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
@@ -389,6 +380,26 @@ export const routesConfig: RouteConfig[] = [
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/recipes',
name: 'Recipes',
component: 'RecipesPage',
title: 'Recetas',
icon: 'production',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/orders',
name: 'Orders',
component: 'OrdersPage',
title: 'Pedidos',
icon: 'orders',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/maquinaria',
@@ -401,13 +412,13 @@ export const routesConfig: RouteConfig[] = [
showInBreadcrumbs: true,
},
{
path: '/app/database/information',
name: 'Information',
component: 'InformationPage',
title: 'Información',
path: '/app/database/quality-templates',
name: 'QualityTemplates',
component: 'QualityTemplatesPage',
title: 'Plantillas de Calidad',
icon: 'settings',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
@@ -431,18 +442,7 @@ export const routesConfig: RouteConfig[] = [
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/quality-templates',
name: 'QualityTemplates',
component: 'QualityTemplatesPage',
title: 'Plantillas de Calidad',
icon: 'settings',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
}
],
},

View File

@@ -0,0 +1,190 @@
# Text Overflow Prevention System
This comprehensive system prevents text overflow in UI components across all screen sizes and content types.
## Quick Start
```typescript
import { TextOverflowPrevention, ResponsiveText } from '../utils/textUtils';
// Automatic truncation for StatusCard
const title = TextOverflowPrevention.statusCard.title("Very Long Product Name That Could Overflow");
// Responsive text component
<ResponsiveText
text="Long text that adapts to screen size"
textType="title"
truncationType="mobile"
/>
```
## Core Features
### 1. Automatic Truncation Engines
- **StatusCard**: Optimized for status cards (35 chars title, 50 chars subtitle)
- **Mobile**: Aggressive truncation for mobile devices (25 chars title, 35 chars subtitle)
- **Production**: Specialized for production content (equipment lists, staff lists)
### 2. Responsive Text Component
- Automatically adjusts based on screen size
- Supports tooltips for truncated content
- Multiple text types (title, subtitle, label, metadata, action)
### 3. Screen-Size Detection
- Mobile: < 768px
- Tablet: 768px - 1024px
- Desktop: > 1024px
## Usage Examples
### Basic Truncation
```typescript
import { TextOverflowPrevention } from '../utils/textUtils';
// StatusCard truncation
const title = TextOverflowPrevention.statusCard.title(longTitle);
const subtitle = TextOverflowPrevention.statusCard.subtitle(longSubtitle);
// Mobile-optimized truncation
const mobileTitle = TextOverflowPrevention.mobile.title(longTitle);
// Production-specific truncation
const equipment = TextOverflowPrevention.production.equipmentList(['oven-01', 'mixer-02']);
const staff = TextOverflowPrevention.production.staffList(['Juan', 'María', 'Carlos']);
```
### ResponsiveText Component
```tsx
import { ResponsiveText } from '../components/ui';
// Basic usage
<ResponsiveText
text="Product Name That Could Be Very Long"
textType="title"
className="font-bold text-lg"
/>
// Custom responsive lengths
<ResponsiveText
text="Long description text"
maxLength={{ mobile: 30, tablet: 50, desktop: 100 }}
showTooltip={true}
/>
// Different truncation types
<ResponsiveText
text="Equipment: oven-01, mixer-02, proofer-03"
truncationType="production"
textType="metadata"
/>
```
### Array Truncation
```typescript
import { truncateArray } from '../utils/textUtils';
// Truncate equipment list
const equipment = truncateArray(['oven-01', 'mixer-02', 'proofer-03'], 2, 15);
// Result: ['oven-01', 'mixer-02', '+1 más']
// Truncate staff list
const staff = truncateArray(['Juan Perez', 'María González'], 1, 20);
// Result: ['Juan Perez', '+1 más']
```
### CSS Classes for Overflow Prevention
```typescript
import { overflowClasses } from '../utils/textUtils';
// Available classes
overflowClasses.truncate // 'truncate'
overflowClasses.breakWords // 'break-words'
overflowClasses.ellipsis // 'overflow-hidden text-ellipsis whitespace-nowrap'
overflowClasses.multilineEllipsis // 'overflow-hidden line-clamp-2'
```
## Implementation in Components
### Enhanced StatusCard
The StatusCard component now automatically:
- Truncates titles, subtitles, and metadata
- Adjusts truncation based on screen size
- Shows tooltips for truncated content
- Limits metadata items (3 on mobile, 4 on desktop)
- Provides responsive action button labels
### Production Components
Production components use specialized truncation:
- Equipment lists: Max 3 items, 20 chars each
- Staff lists: Max 3 items, 25 chars each
- Batch numbers: Max 20 chars
- Product names: Max 30 chars with word preservation
## Configuration
### Truncation Lengths
| Context | Mobile | Desktop | Preserve Words |
|---------|--------|---------|----------------|
| Title | 25 chars | 35 chars | Yes |
| Subtitle | 35 chars | 50 chars | Yes |
| Primary Label | 8 chars | 12 chars | No |
| Secondary Label | 10 chars | 15 chars | No |
| Metadata | 45 chars | 60 chars | Yes |
| Actions | 8 chars | 12 chars | No |
### Custom Configuration
```typescript
import { truncateText, TruncateOptions } from '../utils/textUtils';
const options: TruncateOptions = {
maxLength: 25,
suffix: '...',
preserveWords: true
};
const result = truncateText("Very long text content", options);
```
## Best Practices
1. **Always use the system**: Don't implement manual truncation
2. **Choose the right engine**: StatusCard for cards, Mobile for aggressive truncation, Production for specialized content
3. **Enable tooltips**: Users should be able to see full content on hover
4. **Test on mobile**: Always verify truncation works on small screens
5. **Preserve word boundaries**: Use `preserveWords: true` for readable text
## Maintenance
To add new truncation types:
1. Add method to `TextOverflowPrevention` class
2. Update `ResponsiveText` component to support new type
3. Add configuration to truncation engines
4. Update this documentation
## Migration Guide
### From Manual Truncation
```typescript
// Before
const title = text.length > 30 ? text.substring(0, 30) + '...' : text;
// After
const title = TextOverflowPrevention.statusCard.title(text);
```
### From Basic Truncate Classes
```tsx
// Before
<div className="truncate" title={text}>{text}</div>
// After
<ResponsiveText text={text} textType="title" />
```
## Browser Support
- Modern browsers with CSS Grid and Flexbox support
- Mobile Safari, Chrome Mobile, Firefox Mobile
- Responsive design works from 320px to 1920px+ screen widths

View File

@@ -0,0 +1,191 @@
/**
* Text Overflow Prevention Utilities
* Comprehensive system to prevent text overflow in UI components
*/
export interface TruncateOptions {
maxLength: number;
suffix?: string;
preserveWords?: boolean;
}
export interface ResponsiveTruncateOptions {
mobile: number;
tablet: number;
desktop: number;
suffix?: string;
preserveWords?: boolean;
}
/**
* Truncate text to a specific length with ellipsis
*/
export const truncateText = (
text: string | null | undefined,
options: TruncateOptions
): string => {
if (!text) return '';
const { maxLength, suffix = '...', preserveWords = false } = options;
if (text.length <= maxLength) return text;
let truncated = text.slice(0, maxLength - suffix.length);
if (preserveWords) {
const lastSpaceIndex = truncated.lastIndexOf(' ');
if (lastSpaceIndex > 0) {
truncated = truncated.slice(0, lastSpaceIndex);
}
}
return truncated + suffix;
};
/**
* Get responsive truncate length based on screen size
*/
export const getResponsiveTruncateLength = (
options: ResponsiveTruncateOptions,
screenSize: 'mobile' | 'tablet' | 'desktop' = 'mobile'
): number => {
return options[screenSize];
};
/**
* Truncate text responsively based on screen size
*/
export const truncateResponsive = (
text: string | null | undefined,
options: ResponsiveTruncateOptions,
screenSize: 'mobile' | 'tablet' | 'desktop' = 'mobile'
): string => {
const maxLength = getResponsiveTruncateLength(options, screenSize);
return truncateText(text, {
maxLength,
suffix: options.suffix,
preserveWords: options.preserveWords
});
};
/**
* Truncate array of strings (for metadata, tags, etc.)
*/
export const truncateArray = (
items: string[],
maxItems: number,
maxItemLength?: number
): string[] => {
let result = items.slice(0, maxItems);
if (maxItemLength) {
result = result.map(item =>
truncateText(item, { maxLength: maxItemLength, preserveWords: true })
);
}
if (items.length > maxItems) {
result.push(`+${items.length - maxItems} más`);
}
return result;
};
/**
* Smart truncation for different content types
*/
export class TextOverflowPrevention {
// StatusCard specific truncation
static statusCard = {
title: (text: string) => truncateText(text, { maxLength: 35, preserveWords: true }),
subtitle: (text: string) => truncateText(text, { maxLength: 50, preserveWords: true }),
primaryValueLabel: (text: string) => truncateText(text, { maxLength: 12 }),
secondaryLabel: (text: string) => truncateText(text, { maxLength: 15 }),
secondaryValue: (text: string) => truncateText(text, { maxLength: 25, preserveWords: true }),
metadataItem: (text: string) => truncateText(text, { maxLength: 60, preserveWords: true }),
actionLabel: (text: string) => truncateText(text, { maxLength: 12 }),
progressLabel: (text: string) => truncateText(text, { maxLength: 30, preserveWords: true }),
};
// Mobile specific truncation (more aggressive)
static mobile = {
title: (text: string) => truncateText(text, { maxLength: 25, preserveWords: true }),
subtitle: (text: string) => truncateText(text, { maxLength: 35, preserveWords: true }),
primaryValueLabel: (text: string) => truncateText(text, { maxLength: 8 }),
secondaryLabel: (text: string) => truncateText(text, { maxLength: 10 }),
secondaryValue: (text: string) => truncateText(text, { maxLength: 20, preserveWords: true }),
metadataItem: (text: string) => truncateText(text, { maxLength: 45, preserveWords: true }),
actionLabel: (text: string) => truncateText(text, { maxLength: 8 }),
progressLabel: (text: string) => truncateText(text, { maxLength: 25, preserveWords: true }),
equipment: (items: string[]) => truncateArray(items, 2, 15).join(', '),
staff: (items: string[]) => truncateArray(items, 2, 20).join(', '),
};
// Production specific truncation
static production = {
equipmentList: (items: string[]) => truncateArray(items, 3, 20).join(', '),
staffList: (items: string[]) => truncateArray(items, 3, 25).join(', '),
batchNumber: (text: string) => truncateText(text, { maxLength: 20 }),
productName: (text: string) => truncateText(text, { maxLength: 30, preserveWords: true }),
notes: (text: string) => truncateText(text, { maxLength: 100, preserveWords: true }),
};
}
/**
* CSS class utilities for overflow prevention
*/
export const overflowClasses = {
truncate: 'truncate',
truncateWithTooltip: 'truncate cursor-help',
breakWords: 'break-words',
breakAll: 'break-all',
wrapAnywhere: 'break-words hyphens-auto',
ellipsis: 'overflow-hidden text-ellipsis whitespace-nowrap',
multilineEllipsis: 'overflow-hidden line-clamp-2',
responsiveText: 'text-sm sm:text-base lg:text-lg',
responsiveTruncate: 'truncate sm:text-clip lg:text-clip',
} as const;
/**
* Generate responsive classes for different screen sizes
*/
export const getResponsiveClasses = (
baseClasses: string,
mobileClasses?: string,
tabletClasses?: string,
desktopClasses?: string
): string => {
return [
baseClasses,
mobileClasses,
tabletClasses && `sm:${tabletClasses}`,
desktopClasses && `lg:${desktopClasses}`,
].filter(Boolean).join(' ');
};
/**
* Hook-like function to determine screen size for truncation
*/
export const getScreenSize = (): 'mobile' | 'tablet' | 'desktop' => {
if (typeof window === 'undefined') return 'desktop';
const width = window.innerWidth;
if (width < 768) return 'mobile';
if (width < 1024) return 'tablet';
return 'desktop';
};
/**
* Safe text rendering with overflow prevention
*/
export const safeText = (
text: string | null | undefined,
fallback: string = '',
maxLength?: number
): string => {
if (!text) return fallback;
if (!maxLength) return text;
return truncateText(text, { maxLength, preserveWords: true });
};
export default TextOverflowPrevention;