From a8b73e22ea25810e93f9b05b1f8473a02d3bfbe1 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sun, 31 Aug 2025 10:46:13 +0200 Subject: [PATCH] Add a ne model and card design across pages --- .../src/components/layout/Sidebar/Sidebar.tsx | 135 ++++-- frontend/src/components/ui/Modal/Modal.tsx | 40 +- .../components/ui/StatusCard/StatusCard.tsx | 226 +++++++++ .../src/components/ui/StatusCard/index.ts | 2 + .../components/ui/StatusModal/StatusModal.tsx | 428 ++++++++++++++++++ .../src/components/ui/StatusModal/index.ts | 7 + frontend/src/components/ui/index.ts | 6 +- .../operations/inventory/InventoryPage.tsx | 356 ++++++++------- .../app/operations/orders/OrdersPage.tsx | 262 +++++++---- .../procurement/ProcurementPage.tsx | 285 +++++++----- .../operations/production/ProductionPage.tsx | 310 +++++-------- .../app/operations/recipes/RecipesPage.tsx | 408 +++++++++-------- frontend/src/styles/colors.d.ts | 131 ++++++ frontend/src/styles/colors.js | 89 ++++ 14 files changed, 1865 insertions(+), 820 deletions(-) create mode 100644 frontend/src/components/ui/StatusCard/StatusCard.tsx create mode 100644 frontend/src/components/ui/StatusCard/index.ts create mode 100644 frontend/src/components/ui/StatusModal/StatusModal.tsx create mode 100644 frontend/src/components/ui/StatusModal/index.ts create mode 100644 frontend/src/styles/colors.d.ts diff --git a/frontend/src/components/layout/Sidebar/Sidebar.tsx b/frontend/src/components/layout/Sidebar/Sidebar.tsx index 8c8fff49..43fd6c8f 100644 --- a/frontend/src/components/layout/Sidebar/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar/Sidebar.tsx @@ -125,6 +125,7 @@ export const Sidebar = forwardRef(({ const isAuthenticated = useIsAuthenticated(); const [expandedItems, setExpandedItems] = useState>(new Set()); + const [hoveredItem, setHoveredItem] = useState(null); const sidebarRef = React.useRef(null); // Get navigation routes from config and convert to navigation items - memoized @@ -302,11 +303,59 @@ export const Sidebar = forwardRef(({ }; }, [isOpen, onClose]); + // Render submenu overlay for collapsed sidebar + const renderSubmenuOverlay = (item: NavigationItem) => { + if (!item.children || item.children.length === 0) return null; + + return ( +
+
+ + {item.label} + +
+
    + {item.children.map(child => ( +
  • + +
  • + ))} +
+
+ ); + }; + // Render navigation item const renderItem = (item: NavigationItem, level = 0) => { const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/'); const isExpanded = expandedItems.has(item.id); const hasChildren = item.children && item.children.length > 0; + const isHovered = hoveredItem === item.id; const ItemIcon = item.icon; const itemContent = ( @@ -317,17 +366,24 @@ export const Sidebar = forwardRef(({ level > 0 && 'pl-6', )} > - {ItemIcon && ( - - )} +
+ {ItemIcon && ( + + )} + + {/* Submenu indicator for collapsed sidebar */} + {isCollapsed && hasChildren && level === 0 && ( +
+ )} +
{!ItemIcon && level > 0 && ( (({ ); const button = ( - + + {/* Submenu overlay for collapsed sidebar */} + {isCollapsed && hasChildren && level === 0 && isHovered && ( +
setHoveredItem(item.id)} + onMouseLeave={() => setHoveredItem(null)} + > + {renderSubmenuOverlay(item)} +
)} - aria-expanded={hasChildren ? isExpanded : undefined} - aria-current={isActive ? 'page' : undefined} - title={isCollapsed ? item.label : undefined} - > - {itemContent} - +
); return ( diff --git a/frontend/src/components/ui/Modal/Modal.tsx b/frontend/src/components/ui/Modal/Modal.tsx index f35767ef..48463a48 100644 --- a/frontend/src/components/ui/Modal/Modal.tsx +++ b/frontend/src/components/ui/Modal/Modal.tsx @@ -116,38 +116,38 @@ const Modal = forwardRef(({ if (!isOpen) return null; const sizeClasses = { - xs: 'max-w-xs', - sm: 'max-w-sm', - md: 'max-w-md', - lg: 'max-w-lg', - xl: 'max-w-xl', - '2xl': 'max-w-2xl', - full: 'max-w-full', + xs: 'max-w-xs w-full', + sm: 'max-w-sm w-full', + md: 'max-w-md w-full', + lg: 'max-w-lg w-full max-h-[90vh] overflow-y-auto', + xl: 'max-w-xl w-full max-h-[90vh] overflow-y-auto', + '2xl': 'max-w-2xl w-full max-h-[90vh] overflow-y-auto', + full: 'max-w-full w-full max-h-[90vh] overflow-y-auto', }; const variantClasses = { default: { - overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex items-center justify-center p-4', + overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4', content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100', }, centered: { - overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex items-center justify-center p-4', + overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4', content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100', }, 'drawer-left': { - overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex justify-start', + overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex justify-start', content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0', }, 'drawer-right': { - overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex justify-end', + overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex justify-end', content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0', }, 'drawer-top': { - overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex flex-col justify-start', + overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex flex-col justify-start', content: 'w-full transform transition-all duration-300 ease-out translate-y-0', }, 'drawer-bottom': { - overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex flex-col justify-end', + overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex flex-col justify-end', content: 'w-full transform transition-all duration-300 ease-out translate-y-0', }, }; @@ -159,7 +159,7 @@ const Modal = forwardRef(({ ); const contentClasses = clsx( - 'relative bg-modal-bg border border-modal-border rounded-lg shadow-xl', + 'relative bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl', 'animate-in zoom-in-95 duration-200', variantClasses[variant].content, sizeClasses[size], @@ -192,7 +192,7 @@ const Modal = forwardRef(({ {showCloseButton && ( + ))} + + )} + + + ); +}; + +export default StatusCard; \ No newline at end of file diff --git a/frontend/src/components/ui/StatusCard/index.ts b/frontend/src/components/ui/StatusCard/index.ts new file mode 100644 index 00000000..42ef26a3 --- /dev/null +++ b/frontend/src/components/ui/StatusCard/index.ts @@ -0,0 +1,2 @@ +export { StatusCard, getStatusColor } from './StatusCard'; +export type { StatusCardProps, StatusIndicatorConfig } from './StatusCard'; \ No newline at end of file diff --git a/frontend/src/components/ui/StatusModal/StatusModal.tsx b/frontend/src/components/ui/StatusModal/StatusModal.tsx new file mode 100644 index 00000000..9ee0e7e6 --- /dev/null +++ b/frontend/src/components/ui/StatusModal/StatusModal.tsx @@ -0,0 +1,428 @@ +import React from 'react'; +import { LucideIcon, Edit, Eye, X } from 'lucide-react'; +import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal'; +import { Button } from '../Button'; +import { Input } from '../Input'; +import { StatusIndicatorConfig, getStatusColor } from '../StatusCard'; +import { formatters } from '../Stats/StatsPresets'; + +export interface StatusModalField { + label: string; + value: string | number | React.ReactNode; + type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number'; + highlight?: boolean; + span?: 1 | 2; // For grid layout + editable?: boolean; // Whether this field can be edited + required?: boolean; // Whether this field is required + placeholder?: string; // Placeholder text for inputs + options?: Array<{label: string; value: string | number}>; // For select fields +} + +export interface StatusModalSection { + title: string; + icon?: LucideIcon; + fields: StatusModalField[]; +} + +export interface StatusModalAction { + label: string; + icon?: LucideIcon; + variant?: 'primary' | 'secondary' | 'outline' | 'danger'; + onClick: () => void | Promise; + disabled?: boolean; + loading?: boolean; +} + +export interface StatusModalProps { + isOpen: boolean; + onClose: () => void; + mode: 'view' | 'edit'; + onModeChange?: (mode: 'view' | 'edit') => void; + + // Content + title: string; + subtitle?: string; + statusIndicator?: StatusIndicatorConfig; + image?: string; + sections: StatusModalSection[]; + + // Actions + actions?: StatusModalAction[]; + showDefaultActions?: boolean; + onEdit?: () => void; + onSave?: () => Promise; + onCancel?: () => void; + + // Layout + size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; + loading?: boolean; +} + +/** + * Format field value based on type + */ +const formatFieldValue = (value: string | number | React.ReactNode, type: StatusModalField['type'] = 'text'): React.ReactNode => { + if (React.isValidElement(value)) return value; + + switch (type) { + case 'currency': + return formatters.currency(Number(value)); + case 'date': + return new Date(String(value)).toLocaleDateString('es-ES'); + case 'datetime': + return new Date(String(value)).toLocaleString('es-ES'); + case 'percentage': + return `${value}%`; + case 'list': + if (Array.isArray(value)) { + return ( +
    + {value.map((item, index) => ( +
  • {String(item)}
  • + ))} +
+ ); + } + return String(value); + case 'status': + return ( + + {String(value)} + + ); + case 'image': + return ( + + ); + default: + return String(value); + } +}; + +/** + * Render editable field based on type and mode + */ +const renderEditableField = ( + field: StatusModalField, + isEditMode: boolean, + onChange?: (value: string | number) => void +): React.ReactNode => { + if (!isEditMode || !field.editable) { + return formatFieldValue(field.value, field.type); + } + + const handleChange = (e: React.ChangeEvent) => { + const value = field.type === 'number' || field.type === 'currency' ? Number(e.target.value) : e.target.value; + onChange?.(value); + }; + + const inputValue = field.type === 'currency' ? Number(String(field.value).replace(/[^0-9.-]+/g, '')) : field.value; + + switch (field.type) { + case 'email': + return ( + + ); + case 'tel': + return ( + + ); + case 'number': + case 'currency': + return ( + + ); + case 'date': + const dateValue = field.value ? new Date(String(field.value)).toISOString().split('T')[0] : ''; + return ( + + ); + case 'list': + return ( +