Improve frontend 5

This commit is contained in:
Urtzi Alfaro
2025-08-31 22:14:05 +02:00
parent c494078441
commit bde52d8ca2
16 changed files with 1989 additions and 2237 deletions

View File

@@ -0,0 +1,312 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { clsx } from 'clsx';
import { ChevronDown, Building, Check, Plus } from 'lucide-react';
import { Button } from '../Button';
import { Avatar } from '../Avatar';
interface Bakery {
id: string;
name: string;
logo?: string;
role: 'owner' | 'manager' | 'baker' | 'staff';
status: 'active' | 'inactive';
address?: string;
}
interface BakerySelectorProps {
bakeries: Bakery[];
selectedBakery: Bakery;
onSelectBakery: (bakery: Bakery) => void;
onAddBakery?: () => void;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
const roleLabels = {
owner: 'Propietario',
manager: 'Gerente',
baker: 'Panadero',
staff: 'Personal'
};
const roleColors = {
owner: 'text-color-success',
manager: 'text-color-info',
baker: 'text-color-warning',
staff: 'text-text-secondary'
};
export const BakerySelector: React.FC<BakerySelectorProps> = ({
bakeries,
selectedBakery,
onSelectBakery,
onAddBakery,
className,
size = 'md'
}) => {
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// Calculate dropdown position when opening
useEffect(() => {
if (isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const isMobile = viewportWidth < 640; // sm breakpoint
let top = rect.bottom + window.scrollY + 8;
let left = rect.left + window.scrollX;
let width = Math.max(rect.width, isMobile ? viewportWidth - 32 : 320); // 16px margin on each side for mobile
// Adjust for mobile - center dropdown with margins
if (isMobile) {
left = 16; // 16px margin from left
width = viewportWidth - 32; // 16px margins on both sides
} else {
// Adjust horizontal position to prevent overflow
const dropdownWidth = Math.max(width, 320);
if (left + dropdownWidth > viewportWidth - 16) {
left = viewportWidth - dropdownWidth - 16;
}
if (left < 16) {
left = 16;
}
}
// Adjust vertical position if dropdown would overflow bottom
const dropdownMaxHeight = 320; // Approximate max height
const headerHeight = 64; // Approximate header height
if (top + dropdownMaxHeight > viewportHeight + window.scrollY - 16) {
// Try to position above the button
const topPosition = rect.top + window.scrollY - dropdownMaxHeight - 8;
// Ensure it doesn't go above the header
if (topPosition < window.scrollY + headerHeight) {
// If it can't fit above, position it at the top of the visible area
top = window.scrollY + headerHeight + 8;
} else {
top = topPosition;
}
}
setDropdownPosition({ top, left, width });
}
}, [isOpen]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (buttonRef.current && !buttonRef.current.contains(event.target as Node) &&
dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Close on escape key and handle body scroll lock
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
buttonRef.current?.focus();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
// Prevent body scroll on mobile when dropdown is open
const isMobile = window.innerWidth < 640;
if (isMobile) {
document.body.style.overflow = 'hidden';
}
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore body scroll
document.body.style.overflow = '';
};
}, [isOpen]);
const sizeClasses = {
sm: 'h-10 px-3 text-sm sm:h-8', // Always at least 40px (10) for better touch targets on mobile
md: 'h-12 px-4 text-base sm:h-10', // 48px (12) on mobile, 40px on desktop
lg: 'h-14 px-5 text-lg sm:h-12' // 56px (14) on mobile, 48px on desktop
};
const avatarSizes = {
sm: 'sm' as const, // Changed from xs to sm for better mobile visibility
md: 'sm' as const,
lg: 'md' as const
};
const getBakeryInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<div className={clsx('relative', className)} ref={dropdownRef}>
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-2 sm:gap-3 bg-[var(--bg-primary)] border border-[var(--border-primary)]',
'rounded-lg transition-all duration-200 hover:bg-[var(--bg-secondary)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]',
'active:scale-[0.98] w-full',
sizeClasses[size],
isOpen && 'ring-2 ring-[var(--color-primary)]/20 border-[var(--color-primary)]'
)}
aria-haspopup="true"
aria-expanded={isOpen}
aria-label={`Panadería seleccionada: ${selectedBakery.name}`}
>
<Avatar
src={selectedBakery.logo}
name={selectedBakery.name}
size={avatarSizes[size]}
className="flex-shrink-0"
/>
<div className="flex-1 text-left min-w-0">
<div className="text-[var(--text-primary)] font-medium truncate text-sm sm:text-base">
{selectedBakery.name}
</div>
{size !== 'sm' && (
<div className={clsx('text-xs truncate hidden sm:block', roleColors[selectedBakery.role])}>
{roleLabels[selectedBakery.role]}
</div>
)}
</div>
<ChevronDown
className={clsx(
'flex-shrink-0 transition-transform duration-200 text-[var(--text-secondary)]',
size === 'sm' ? 'w-4 h-4' : 'w-4 h-4', // Consistent sizing
isOpen && 'rotate-180'
)}
/>
</button>
{isOpen && createPortal(
<>
{/* Mobile backdrop */}
<div
className="fixed inset-0 bg-black/20 z-[9998] sm:hidden"
onClick={() => setIsOpen(false)}
/>
<div
ref={dropdownRef}
className="fixed bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-2 z-[9999] sm:min-w-80 sm:max-w-96"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`
}}
>
<div className="px-3 py-2 text-xs font-medium text-[var(--text-tertiary)] border-b border-[var(--border-primary)]">
Mis Panaderías ({bakeries.length})
</div>
<div className="max-h-64 sm:max-h-64 max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-[var(--border-secondary)] scrollbar-track-transparent">
{bakeries.map((bakery) => (
<button
key={bakery.id}
onClick={() => {
onSelectBakery(bakery);
setIsOpen(false);
}}
className={clsx(
'w-full flex items-center gap-3 px-3 py-4 text-left min-h-[60px] sm:py-3 sm:min-h-[48px]',
'hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)] transition-colors',
'focus:outline-none focus:bg-[var(--bg-secondary)]',
'touch-manipulation', // Improves touch responsiveness
selectedBakery.id === bakery.id && 'bg-[var(--bg-secondary)]'
)}
>
<Avatar
src={bakery.logo}
name={bakery.name}
size="sm"
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[var(--text-primary)] font-medium truncate">
{bakery.name}
</span>
{selectedBakery.id === bakery.id && (
<Check className="w-4 h-4 text-[var(--color-primary)] flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className={clsx('text-xs', roleColors[bakery.role])}>
{roleLabels[bakery.role]}
</span>
<span className="text-xs text-[var(--text-tertiary)]"></span>
<span className={clsx(
'text-xs',
bakery.status === 'active' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
)}>
{bakery.status === 'active' ? 'Activa' : 'Inactiva'}
</span>
</div>
{bakery.address && (
<div className="text-xs text-[var(--text-tertiary)] truncate mt-1">
{bakery.address}
</div>
)}
</div>
</button>
))}
</div>
{onAddBakery && (
<>
<div className="border-t border-[var(--border-primary)] my-2"></div>
<button
onClick={() => {
onAddBakery();
setIsOpen(false);
}}
className="w-full flex items-center gap-3 px-3 py-4 text-left min-h-[60px] sm:py-3 sm:min-h-[48px] hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)] transition-colors text-[var(--color-primary)] touch-manipulation"
>
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center flex-shrink-0">
<Plus className="w-4 h-4" />
</div>
<span className="font-medium">Agregar Panadería</span>
</button>
</>
)}
</div>
</>,
document.body
)}
</div>
);
};
export default BakerySelector;

View File

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

View File

@@ -16,6 +16,7 @@ export { ListItem } from './ListItem';
export { StatsCard, StatsGrid } from './Stats';
export { StatusCard, getStatusColor } from './StatusCard';
export { StatusModal } from './StatusModal';
export { BakerySelector } from './BakerySelector';
// Export types
export type { ButtonProps } from './Button';