Improve frontend 5
This commit is contained in:
312
frontend/src/components/ui/BakerySelector/BakerySelector.tsx
Normal file
312
frontend/src/components/ui/BakerySelector/BakerySelector.tsx
Normal 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;
|
||||
2
frontend/src/components/ui/BakerySelector/index.ts
Normal file
2
frontend/src/components/ui/BakerySelector/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BakerySelector } from './BakerySelector';
|
||||
export type { default as BakerySelector } from './BakerySelector';
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user