mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 11:24:26 +00:00
feat: Add kiosk management, artist selection, and schedule management
- Add KiosksManagement component with full CRUD for kiosks - Add ScheduleManagement for staff schedules with break reminders - Update booking flow to allow artist selection by customers - Add staff_services API for assigning services to artists - Update staff management UI with service assignment dialog - Add auto-break reminder when schedule >= 8 hours - Update availability API to filter artists by service - Add kiosk management to Aperture dashboard - Clean up ralphy artifacts and logs
This commit is contained in:
@@ -5,8 +5,15 @@ import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
|
||||
/**
|
||||
* AuthGuard component that shows loading state while authentication is being determined
|
||||
* Redirect logic is now handled by AuthProvider to avoid conflicts
|
||||
* @description Authentication guard component that protects routes requiring login
|
||||
* @param {Object} props - Component props
|
||||
* @param {React.ReactNode} props.children - Child components to render when authenticated
|
||||
* @returns {JSX.Element} Loading state while auth is determined, or children when authenticated
|
||||
* @audit BUSINESS RULE: AuthGuard is a client-side guard for protected routes
|
||||
* @audit SECURITY: Prevents rendering protected content until authentication verified
|
||||
* @audit Validate: Loading state shown while auth provider determines user session
|
||||
* @audit PERFORMANCE: No API calls - relies on AuthProvider's cached session state
|
||||
* @audit Note: Actual redirect logic handled by AuthProvider to avoid conflicts
|
||||
*/
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { loading: authLoading } = useAuth()
|
||||
|
||||
@@ -10,6 +10,21 @@ interface DatePickerProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Custom date picker component for booking flow with month navigation and date selection
|
||||
* @param {DatePickerProps} props - Component props including selected date, selection callback, and constraints
|
||||
* @param {Date | null} props.selectedDate - Currently selected date value
|
||||
* @param {(date: Date) => void} props.onDateSelect - Callback invoked when user selects a date
|
||||
* @param {Date} props.minDate - Optional minimum selectable date (defaults to today if not provided)
|
||||
* @param {boolean} props.disabled - Optional flag to disable all interactions
|
||||
* @returns {JSX.Element} Interactive calendar grid with month navigation and date selection
|
||||
* @audit BUSINESS RULE: Calendar starts on Monday (Spanish locale convention)
|
||||
* @audit BUSINESS RULE: Disabled dates cannot be selected (past dates via minDate)
|
||||
* @audit SECURITY: Client-side only component with no external data access
|
||||
* @audit Validate: minDate is enforced via date comparison before selection
|
||||
* @audit PERFORMANCE: Uses date-fns for efficient date calculations
|
||||
* @audit UI: Today's date indicated with visual marker (dot indicator)
|
||||
*/
|
||||
export default function DatePicker({ selectedDate, onDateSelect, minDate, disabled }: DatePickerProps) {
|
||||
const [currentMonth, setCurrentMonth] = useState(selectedDate ? new Date(selectedDate) : new Date())
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @description Calendar view component with drag-and-drop rescheduling functionality
|
||||
* @description Calendar view component with drag-and-drop rescheduling and booking creation
|
||||
* @audit BUSINESS RULE: Calendar shows only bookings for selected date and filters
|
||||
* @audit SECURITY: Component requires authenticated admin/manager user context
|
||||
* @audit PERFORMANCE: Auto-refresh every 30 seconds for real-time updates
|
||||
@@ -16,7 +16,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Calendar, ChevronLeft, ChevronRight, Clock, User, MapPin } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Calendar, ChevronLeft, ChevronRight, Clock, User, MapPin, Plus } from 'lucide-react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { checkStaffCanPerformService, checkForConflicts, rescheduleBooking } from '@/lib/calendar-utils'
|
||||
|
||||
interface Booking {
|
||||
id: string
|
||||
@@ -68,6 +72,7 @@ interface Staff {
|
||||
id: string
|
||||
display_name: string
|
||||
role: string
|
||||
location_id: string
|
||||
}
|
||||
|
||||
interface Location {
|
||||
@@ -163,9 +168,10 @@ interface TimeSlotProps {
|
||||
bookings: Booking[]
|
||||
staffId: string
|
||||
onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void
|
||||
onSlotClick?: (time: Date, staffId: string) => void
|
||||
}
|
||||
|
||||
function TimeSlot({ time, bookings, staffId, onBookingDrop }: TimeSlotProps) {
|
||||
function TimeSlot({ time, bookings, staffId, onBookingDrop, onSlotClick }: TimeSlotProps) {
|
||||
const timeBookings = bookings.filter(booking =>
|
||||
booking.staff.id === staffId &&
|
||||
parseISO(booking.startTime).getHours() === time.getHours() &&
|
||||
@@ -173,7 +179,15 @@ function TimeSlot({ time, bookings, staffId, onBookingDrop }: TimeSlotProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-r border-gray-200 min-h-[60px] relative">
|
||||
<div
|
||||
className="border-r border-gray-200 min-h-[60px] relative"
|
||||
onClick={() => onSlotClick && timeBookings.length === 0 && onSlotClick(time, staffId)}
|
||||
>
|
||||
{timeBookings.length === 0 && onSlotClick && (
|
||||
<div className="absolute inset-0 hover:bg-blue-50 cursor-pointer transition-colors flex items-center justify-center opacity-0 hover:opacity-100">
|
||||
<Plus className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
{timeBookings.map(booking => (
|
||||
<SortableBooking
|
||||
key={booking.id}
|
||||
@@ -190,34 +204,12 @@ interface StaffColumnProps {
|
||||
bookings: Booking[]
|
||||
businessHours: { start: string, end: string }
|
||||
onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void
|
||||
onSlotClick?: (time: Date, staffId: string) => void
|
||||
}
|
||||
|
||||
function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: StaffColumnProps) {
|
||||
function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop, onSlotClick }: StaffColumnProps) {
|
||||
const staffBookings = bookings.filter(booking => booking.staff.id === staff.id)
|
||||
|
||||
// Check for conflicts (overlapping bookings)
|
||||
const conflicts = []
|
||||
for (let i = 0; i < staffBookings.length; i++) {
|
||||
for (let j = i + 1; j < staffBookings.length; j++) {
|
||||
const booking1 = staffBookings[i]
|
||||
const booking2 = staffBookings[j]
|
||||
|
||||
const start1 = parseISO(booking1.startTime)
|
||||
const end1 = parseISO(booking1.endTime)
|
||||
const start2 = parseISO(booking2.startTime)
|
||||
const end2 = parseISO(booking2.endTime)
|
||||
|
||||
// Check if bookings overlap
|
||||
if (start1 < end2 && start2 < end1) {
|
||||
conflicts.push({
|
||||
booking1: booking1.id,
|
||||
booking2: booking2.id,
|
||||
time: Math.min(start1.getTime(), start2.getTime())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timeSlots = []
|
||||
|
||||
const [startHour, startMinute] = businessHours.start.split(':').map(Number)
|
||||
@@ -231,7 +223,7 @@ function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: St
|
||||
|
||||
while (currentTime < endTime) {
|
||||
timeSlots.push(new Date(currentTime))
|
||||
currentTime = addMinutes(currentTime, 15) // 15-minute slots
|
||||
currentTime = addMinutes(currentTime, 15)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -247,15 +239,6 @@ function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: St
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Conflict indicator */}
|
||||
{conflicts.length > 0 && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<div className="bg-red-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
⚠️ {conflicts.length} conflicto{conflicts.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timeSlots.map((timeSlot, index) => (
|
||||
<div key={index} className="border-b border-gray-100 min-h-[60px]">
|
||||
<TimeSlot
|
||||
@@ -263,6 +246,7 @@ function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: St
|
||||
bookings={staffBookings}
|
||||
staffId={staff.id}
|
||||
onBookingDrop={onBookingDrop}
|
||||
onSlotClick={onSlotClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -288,6 +272,121 @@ export default function CalendarView() {
|
||||
const [rescheduleError, setRescheduleError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
const [showCreateBooking, setShowCreateBooking] = useState(false)
|
||||
const [createBookingData, setCreateBookingData] = useState<{
|
||||
time: Date | null
|
||||
staffId: string | null
|
||||
customerId: string
|
||||
serviceId: string
|
||||
locationId: string
|
||||
notes: string
|
||||
}>({
|
||||
time: null,
|
||||
staffId: null,
|
||||
customerId: '',
|
||||
serviceId: '',
|
||||
locationId: '',
|
||||
notes: ''
|
||||
})
|
||||
const [createBookingError, setCreateBookingError] = useState<string | null>(null)
|
||||
const [services, setServices] = useState<any[]>([])
|
||||
const [customers, setCustomers] = useState<any[]>([])
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/services')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setServices(data.services || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching services:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/customers')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setCustomers(data.customers || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching customers:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices()
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
const handleSlotClick = (time: Date, staffId: string) => {
|
||||
const locationId = selectedLocations.length > 0 ? selectedLocations[0] : (calendarData?.locations[0]?.id || '')
|
||||
setCreateBookingData({
|
||||
time,
|
||||
staffId,
|
||||
customerId: '',
|
||||
serviceId: '',
|
||||
locationId,
|
||||
notes: ''
|
||||
})
|
||||
setShowCreateBooking(true)
|
||||
setCreateBookingError(null)
|
||||
}
|
||||
|
||||
const handleCreateBooking = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setCreateBookingError(null)
|
||||
|
||||
if (!createBookingData.time || !createBookingData.staffId || !createBookingData.customerId || !createBookingData.serviceId || !createBookingData.locationId) {
|
||||
setCreateBookingError('Todos los campos son obligatorios')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const startTimeUtc = createBookingData.time.toISOString()
|
||||
|
||||
const response = await fetch('/api/bookings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_id: createBookingData.customerId,
|
||||
service_id: createBookingData.serviceId,
|
||||
location_id: createBookingData.locationId,
|
||||
start_time_utc: startTimeUtc,
|
||||
staff_id: createBookingData.staffId,
|
||||
notes: createBookingData.notes || null
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setShowCreateBooking(false)
|
||||
setCreateBookingData({
|
||||
time: null,
|
||||
staffId: null,
|
||||
customerId: '',
|
||||
serviceId: '',
|
||||
locationId: '',
|
||||
notes: ''
|
||||
})
|
||||
await fetchCalendarData()
|
||||
} else {
|
||||
setCreateBookingError(result.error || 'Error al crear la cita')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating booking:', error)
|
||||
setCreateBookingError('Error de conexión al crear la cita')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCalendarData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -325,11 +424,10 @@ export default function CalendarView() {
|
||||
fetchCalendarData()
|
||||
}, [fetchCalendarData])
|
||||
|
||||
// Auto-refresh every 30 seconds for real-time updates
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchCalendarData()
|
||||
}, 30000) // 30 seconds
|
||||
}, 30000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchCalendarData])
|
||||
@@ -353,34 +451,22 @@ export default function CalendarView() {
|
||||
setCurrentDate(new Date())
|
||||
}
|
||||
|
||||
const handleStaffFilter = (staffIds: string[]) => {
|
||||
setSelectedStaff(staffIds)
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!over) return
|
||||
|
||||
const bookingId = active.id as string
|
||||
const targetStaffId = over.id as string
|
||||
const targetInfo = over.id as string
|
||||
|
||||
// Find the booking
|
||||
const booking = calendarData?.bookings.find(b => b.id === bookingId)
|
||||
if (!booking) return
|
||||
|
||||
// For now, we'll implement a simple time slot change
|
||||
// In a real implementation, you'd need to calculate the exact time from drop position
|
||||
// For demo purposes, we'll move to the next available slot
|
||||
const [targetStaffId, targetTime] = targetInfo.includes('-') ? targetInfo.split('-') : [targetInfo, null]
|
||||
|
||||
try {
|
||||
setRescheduleError(null)
|
||||
|
||||
// Calculate new start time (for demo, move to next hour)
|
||||
const currentStart = parseISO(booking.startTime)
|
||||
const newStartTime = new Date(currentStart.getTime() + (60 * 60 * 1000)) // +1 hour
|
||||
const currentStart = parseISO(bookingId)
|
||||
const newStartTime = new Date(currentStart.getTime() + (60 * 60 * 1000))
|
||||
|
||||
// Call the reschedule API
|
||||
const response = await fetch(`/api/aperture/bookings/${bookingId}/reschedule`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -389,14 +475,13 @@ export default function CalendarView() {
|
||||
body: JSON.stringify({
|
||||
bookingId,
|
||||
newStartTime: newStartTime.toISOString(),
|
||||
newStaffId: targetStaffId !== booking.staff.id ? targetStaffId : undefined,
|
||||
newStaffId: targetStaffId,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
// Refresh calendar data
|
||||
await fetchCalendarData()
|
||||
setRescheduleError(null)
|
||||
} else {
|
||||
@@ -423,7 +508,136 @@ export default function CalendarView() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header Controls */}
|
||||
<Dialog open={showCreateBooking} onOpenChange={setShowCreateBooking}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crear Nueva Cita</DialogTitle>
|
||||
<DialogDescription>
|
||||
{createBookingData.time && (
|
||||
<span className="text-sm">
|
||||
{format(createBookingData.time, 'EEEE, d MMMM yyyy HH:mm', { locale: es })}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleCreateBooking} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customer">Cliente</Label>
|
||||
<Select
|
||||
value={createBookingData.customerId}
|
||||
onValueChange={(value) => setCreateBookingData({ ...createBookingData, customerId: value })}
|
||||
>
|
||||
<SelectTrigger id="customer">
|
||||
<SelectValue placeholder="Seleccionar cliente" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map(customer => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.first_name} {customer.last_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="service">Servicio</Label>
|
||||
<Select
|
||||
value={createBookingData.serviceId}
|
||||
onValueChange={(value) => setCreateBookingData({ ...createBookingData, serviceId: value })}
|
||||
>
|
||||
<SelectTrigger id="service">
|
||||
<SelectValue placeholder="Seleccionar servicio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.filter(s => s.location_id === createBookingData.locationId).map(service => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name} ({service.duration_minutes} min) - ${service.base_price}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">Ubicación</Label>
|
||||
<Select
|
||||
value={createBookingData.locationId}
|
||||
onValueChange={(value) => setCreateBookingData({ ...createBookingData, locationId: value })}
|
||||
>
|
||||
<SelectTrigger id="location">
|
||||
<SelectValue placeholder="Seleccionar ubicación" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{calendarData.locations.map(location => (
|
||||
<SelectItem key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="staff">Staff Asignado</Label>
|
||||
<Select
|
||||
value={createBookingData.staffId || ''}
|
||||
onValueChange={(value) => setCreateBookingData({ ...createBookingData, staffId: value })}
|
||||
>
|
||||
<SelectTrigger id="staff">
|
||||
<SelectValue placeholder="Seleccionar staff" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{calendarData.staff.filter(staffMember => staffMember.location_id === createBookingData.locationId).map(staffMember => (
|
||||
<SelectItem key={staffMember.id} value={staffMember.id}>
|
||||
{staffMember.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notas</Label>
|
||||
<Input
|
||||
id="notes"
|
||||
value={createBookingData.notes}
|
||||
onChange={(e) => setCreateBookingData({ ...createBookingData, notes: e.target.value })}
|
||||
placeholder="Notas adicionales (opcional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createBookingError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-800 text-sm">{createBookingError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateBooking(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creando...' : 'Crear Cita'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -459,11 +673,7 @@ export default function CalendarView() {
|
||||
<Select
|
||||
value={selectedLocations.length === 0 ? 'all' : selectedLocations[0]}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'all') {
|
||||
setSelectedLocations([])
|
||||
} else {
|
||||
setSelectedLocations([value])
|
||||
}
|
||||
value === 'all' ? setSelectedLocations([]) : setSelectedLocations([value])
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
@@ -485,11 +695,7 @@ export default function CalendarView() {
|
||||
<Select
|
||||
value={selectedStaff.length === 0 ? 'all' : selectedStaff[0]}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'all') {
|
||||
setSelectedStaff([])
|
||||
} else {
|
||||
setSelectedStaff([value])
|
||||
}
|
||||
value === 'all' ? setSelectedStaff([]) : setSelectedStaff([value])
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
@@ -515,7 +721,6 @@ export default function CalendarView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<DndContext
|
||||
@@ -524,7 +729,6 @@ export default function CalendarView() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Time Column */}
|
||||
<div className="w-20 bg-gray-50 border-r">
|
||||
<div className="p-3 border-b font-semibold text-sm text-center">
|
||||
Hora
|
||||
@@ -533,7 +737,7 @@ export default function CalendarView() {
|
||||
const timeSlots = []
|
||||
const [startHour] = calendarData.businessHours.start.split(':').map(Number)
|
||||
const [endHour] = calendarData.businessHours.end.split(':').map(Number)
|
||||
|
||||
|
||||
for (let hour = startHour; hour <= endHour; hour++) {
|
||||
timeSlots.push(
|
||||
<div key={hour} className="border-b border-gray-100 p-2 text-xs text-center min-h-[60px] flex items-center justify-center">
|
||||
@@ -546,7 +750,6 @@ export default function CalendarView() {
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Staff Columns */}
|
||||
<div className="flex flex-1 overflow-x-auto">
|
||||
{calendarData.staff.map(staff => (
|
||||
<StaffColumn
|
||||
@@ -555,6 +758,7 @@ export default function CalendarView() {
|
||||
date={currentDate}
|
||||
bookings={calendarData.bookings}
|
||||
businessHours={calendarData.businessHours}
|
||||
onSlotClick={handleSlotClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -564,4 +768,4 @@ export default function CalendarView() {
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Kiosk booking confirmation interface for customers arriving with appointments
|
||||
* @audit BUSINESS RULE: Customers confirm appointments by entering 6-character short ID
|
||||
* @audit SECURITY: Authenticated via x-kiosk-api-key header for all API calls
|
||||
* @audit Validate: Only pending bookings can be confirmed; already confirmed shows warning
|
||||
* @audit PERFORMANCE: Large touch-friendly input optimized for self-service kiosks
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -12,7 +20,17 @@ interface BookingConfirmationProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* BookingConfirmation component that allows confirming a booking by short ID.
|
||||
* @description Booking confirmation component for kiosk self-service check-in
|
||||
* @param {string} apiKey - Kiosk API key for authentication
|
||||
* @param {Function} onConfirm - Callback when booking is successfully confirmed
|
||||
* @param {Function} onCancel - Callback when customer cancels the process
|
||||
* @returns {JSX.Element} Input form for 6-character booking code with confirmation options
|
||||
* @audit BUSINESS RULE: Search by short_id (6 characters) for quick customer lookup
|
||||
* @audit BUSINESS RULE: Only pending bookings can be confirmed; other statuses show error
|
||||
* @audit SECURITY: All API calls require valid kiosk API key in header
|
||||
* @audit Validate: Short ID must be exactly 6 characters
|
||||
* @audit PERFORMANCE: Single API call to fetch booking by short_id
|
||||
* @audit AUDIT: Booking confirmations logged through /api/kiosk/bookings/[shortId]/confirm
|
||||
*/
|
||||
export function BookingConfirmation({ apiKey, onConfirm, onCancel }: BookingConfirmationProps) {
|
||||
const [shortId, setShortId] = useState('')
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Kiosk walk-in booking flow for in-store service reservations
|
||||
* @audit BUSINESS RULE: Walk-in flow designed for touch screen with large buttons and simple navigation
|
||||
* @audit SECURITY: Authenticated via x-kiosk-api-key header for all API calls
|
||||
* @audit Validate: Multi-step flow with service → customer → confirm → success states
|
||||
* @audit PERFORMANCE: Optimized for offline-capable touch interface
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -14,7 +22,17 @@ interface WalkInFlowProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* WalkInFlow component that manages the walk-in booking process in steps.
|
||||
* @description Walk-in booking flow component for kiosk terminals
|
||||
* @param {string} apiKey - Kiosk API key for authentication
|
||||
* @param {Function} onComplete - Callback when walk-in booking is completed successfully
|
||||
* @param {Function} onCancel - Callback when customer cancels the walk-in process
|
||||
* @returns {JSX.Element} Multi-step wizard for service selection, customer info, and confirmation
|
||||
* @audit BUSINESS RULE: 4-step flow: services → customer info → resource assignment → success
|
||||
* @audit BUSINESS RULE: Resources auto-assigned based on availability and service priority
|
||||
* @audit SECURITY: All API calls require valid kiosk API key in header
|
||||
* @audit Validate: Customer name and service selection required before booking
|
||||
* @audit PERFORMANCE: Single-page flow optimized for touch interaction
|
||||
* @audit AUDIT: Walk-in bookings logged through /api/kiosk/walkin endpoint
|
||||
*/
|
||||
export function WalkInFlow({ apiKey, onComplete, onCancel }: WalkInFlowProps) {
|
||||
const [step, setStep] = useState<'services' | 'customer' | 'confirm' | 'success'>('services')
|
||||
|
||||
388
components/kiosks-management.tsx
Normal file
388
components/kiosks-management.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Edit, Trash2, Smartphone, MapPin, Key, Wifi } from 'lucide-react'
|
||||
|
||||
interface Kiosk {
|
||||
id: string
|
||||
device_name: string
|
||||
display_name: string
|
||||
api_key: string
|
||||
ip_address?: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
location?: {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export default function KiosksManagement() {
|
||||
const [kiosks, setKiosks] = useState<Kiosk[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingKiosk, setEditingKiosk] = useState<Kiosk | null>(null)
|
||||
const [showApiKey, setShowApiKey] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
device_name: '',
|
||||
display_name: '',
|
||||
location_id: '',
|
||||
ip_address: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchKiosks()
|
||||
fetchLocations()
|
||||
}, [])
|
||||
|
||||
const fetchKiosks = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/aperture/kiosks')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setKiosks(data.kiosks)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching kiosks:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLocations = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/aperture/locations')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setLocations(data.locations || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching locations:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const url = editingKiosk
|
||||
? `/api/aperture/kiosks/${editingKiosk.id}`
|
||||
: '/api/aperture/kiosks'
|
||||
|
||||
const method = editingKiosk ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
await fetchKiosks()
|
||||
setDialogOpen(false)
|
||||
setEditingKiosk(null)
|
||||
setFormData({ device_name: '', display_name: '', location_id: '', ip_address: '' })
|
||||
} else {
|
||||
alert(data.error || 'Error saving kiosk')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving kiosk:', error)
|
||||
alert('Error saving kiosk')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (kiosk: Kiosk) => {
|
||||
setEditingKiosk(kiosk)
|
||||
setFormData({
|
||||
device_name: kiosk.device_name,
|
||||
display_name: kiosk.display_name,
|
||||
location_id: kiosk.location?.id || '',
|
||||
ip_address: kiosk.ip_address || ''
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (kiosk: Kiosk) => {
|
||||
if (!confirm(`¿Estás seguro de que quieres eliminar el kiosko "${kiosk.device_name}"?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/aperture/kiosks/${kiosk.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
await fetchKiosks()
|
||||
} else {
|
||||
alert(data.error || 'Error deleting kiosk')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting kiosk:', error)
|
||||
alert('Error deleting kiosk')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKioskStatus = async (kiosk: Kiosk) => {
|
||||
try {
|
||||
const response = await fetch(`/api/aperture/kiosks/${kiosk.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...kiosk,
|
||||
is_active: !kiosk.is_active
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
await fetchKiosks()
|
||||
} else {
|
||||
alert(data.error || 'Error updating kiosk status')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling kiosk status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const copyApiKey = (apiKey: string) => {
|
||||
navigator.clipboard.writeText(apiKey)
|
||||
setShowApiKey(apiKey)
|
||||
setTimeout(() => setShowApiKey(null), 2000)
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingKiosk(null)
|
||||
setFormData({ device_name: '', display_name: '', location_id: '', ip_address: '' })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Gestión de Kioskos</h2>
|
||||
<p className="text-gray-600">Administra los dispositivos kiosko para check-in</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Kiosko
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
Dispositivos Kiosko
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{kiosks.length} dispositivos registrados
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Cargando kioskos...</div>
|
||||
) : kiosks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No hay kioskos registrados. Agrega uno para comenzar.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Dispositivo</TableHead>
|
||||
<TableHead>Ubicación</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead className="text-right">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{kiosks.map((kiosk) => (
|
||||
<TableRow key={kiosk.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<Smartphone className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{kiosk.device_name}</div>
|
||||
{kiosk.display_name !== kiosk.device_name && (
|
||||
<div className="text-sm text-gray-500">{kiosk.display_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{kiosk.location?.name || 'Sin ubicación'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{kiosk.ip_address ? (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Wifi className="w-3 h-3" />
|
||||
{kiosk.ip_address}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">Sin IP</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => copyApiKey(kiosk.api_key)}
|
||||
className="flex items-center gap-1 text-sm font-mono bg-gray-100 px-2 py-1 rounded hover:bg-gray-200 transition-colors"
|
||||
title="Click para copiar"
|
||||
>
|
||||
<Key className="w-3 h-3" />
|
||||
{showApiKey === kiosk.api_key ? 'Copiado!' : `${kiosk.api_key.slice(0, 8)}...`}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={kiosk.is_active ? 'default' : 'secondary'}
|
||||
className={kiosk.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}
|
||||
>
|
||||
{kiosk.is_active ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleKioskStatus(kiosk)}
|
||||
>
|
||||
{kiosk.is_active ? 'Desactivar' : 'Activar'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(kiosk)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(kiosk)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingKiosk ? 'Editar Kiosko' : 'Nuevo Kiosko'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingKiosk ? 'Modifica la información del kiosko' : 'Agrega un nuevo dispositivo kiosko'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="device_name" className="text-right">
|
||||
Nombre *
|
||||
</Label>
|
||||
<Input
|
||||
id="device_name"
|
||||
value={formData.device_name}
|
||||
onChange={(e) => setFormData({...formData, device_name: e.target.value})}
|
||||
className="col-span-3"
|
||||
placeholder="Ej. Kiosko Principal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="display_name" className="text-right">
|
||||
Display
|
||||
</Label>
|
||||
<Input
|
||||
id="display_name"
|
||||
value={formData.display_name}
|
||||
onChange={(e) => setFormData({...formData, display_name: e.target.value})}
|
||||
className="col-span-3"
|
||||
placeholder="Nombre a mostrar"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="location_id" className="text-right">
|
||||
Ubicación *
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.location_id}
|
||||
onValueChange={(value) => setFormData({...formData, location_id: value})}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Seleccionar ubicación" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations.map((location) => (
|
||||
<SelectItem key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="ip_address" className="text-right">
|
||||
IP
|
||||
</Label>
|
||||
<Input
|
||||
id="ip_address"
|
||||
value={formData.ip_address}
|
||||
onChange={(e) => setFormData({...formData, ip_address: e.target.value})}
|
||||
className="col-span-3"
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
{editingKiosk ? 'Actualizar' : 'Crear'} Kiosko
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,17 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
/** @description Elegant loading screen with Anchor 23 branding */
|
||||
/**
|
||||
* @description Elegant branded loading screen with Anchor:23 logo reveal animation
|
||||
* @param {Object} props - Component props
|
||||
* @param {() => void} props.onComplete - Callback invoked when loading animation completes
|
||||
* @returns {JSX.Element} Full-screen loading overlay with animated logo and progress bar
|
||||
* @audit BUSINESS RULE: Loading screen provides brand consistency during app initialization
|
||||
* @audit SECURITY: Client-side only animation with no external data access
|
||||
* @audit Validate: onComplete callback triggers app state transition to loaded
|
||||
* @audit PERFORMANCE: Uses CSS animations for smooth GPU-accelerated transitions
|
||||
* @audit UI: Features SVG logo with clip-path reveal animation and gradient progress bar
|
||||
*/
|
||||
export function LoadingScreen({ onComplete }: { onComplete: () => void }) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [showLogo, setShowLogo] = useState(false)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Payroll management interface for calculating and tracking staff compensation
|
||||
* @audit BUSINESS RULE: Payroll includes base salary, service commissions (10%), and tips (5%)
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via useAuth hook
|
||||
* @audit Validate: Payroll period must have valid start and end dates
|
||||
* @audit AUDIT: Payroll calculations logged through /api/aperture/payroll endpoint
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -42,6 +50,16 @@ interface PayrollCalculation {
|
||||
hours_worked: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Payroll management component with calculation, listing, and reporting features
|
||||
* @returns {JSX.Element} Complete payroll interface with period selection, staff filtering, and calculation modal
|
||||
* @audit BUSINESS RULE: Calculates payroll from completed bookings within the selected period
|
||||
* @audit BUSINESS RULE: Commission is 10% of service revenue, tips are 5% of service revenue
|
||||
* @audit SECURITY: Requires authenticated admin/manager role; staff cannot access payroll
|
||||
* @audit Validate: Ensures period dates are valid before calculation
|
||||
* @audit PERFORMANCE: Auto-sets default period to current month on mount
|
||||
* @audit AUDIT: Payroll records stored and retrievable for financial reporting
|
||||
*/
|
||||
export default function PayrollManagement() {
|
||||
const { user } = useAuth()
|
||||
const [payrollRecords, setPayrollRecords] = useState<PayrollRecord[]>([])
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Point of Sale (POS) interface for processing service and product sales with multiple payment methods
|
||||
* @audit BUSINESS RULE: POS handles service/product sales with cash, card, transfer, giftcard, and membership payments
|
||||
* @audit SECURITY: Requires authenticated staff member (cashier) via useAuth hook
|
||||
* @audit Validate: Payment amounts must match cart total before processing
|
||||
* @audit AUDIT: All sales transactions logged through /api/aperture/pos endpoint
|
||||
* @audit PERFORMANCE: Optimized for touch interface with large touch targets
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -39,6 +48,17 @@ interface SaleResult {
|
||||
receipt: any
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Point of Sale component with cart management, customer selection, and multi-payment support
|
||||
* @returns {JSX.Element} Complete POS interface with service/product catalog, cart, and payment processing
|
||||
* @audit BUSINESS RULE: Cart items can be services or products with quantity management
|
||||
* @audit BUSINESS RULE: Multiple partial payments supported (split payments)
|
||||
* @audit SECURITY: Requires authenticated staff member; validates user permissions
|
||||
* @audit Validate: Cart cannot be empty when processing payment
|
||||
* @audit Validate: Payment total must equal or exceed cart subtotal
|
||||
* @audit PERFORMANCE: Auto-fetches services, products, and customers on mount
|
||||
* @audit AUDIT: Sales processed through /api/aperture/pos with full transaction logging
|
||||
*/
|
||||
export default function POSSystem() {
|
||||
const { user } = useAuth()
|
||||
const [cart, setCart] = useState<POSItem[]>([])
|
||||
|
||||
447
components/schedule-management.tsx
Normal file
447
components/schedule-management.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Edit, Trash2, Clock, Coffee, Calendar } from 'lucide-react'
|
||||
|
||||
interface StaffSchedule {
|
||||
id: string
|
||||
staff_id: string
|
||||
date: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
is_available: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface Staff {
|
||||
id: string
|
||||
display_name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ key: 'monday', label: 'Lunes' },
|
||||
{ key: 'tuesday', label: 'Martes' },
|
||||
{ key: 'wednesday', label: 'Miércoles' },
|
||||
{ key: 'thursday', label: 'Jueves' },
|
||||
{ key: 'friday', label: 'Viernes' },
|
||||
{ key: 'saturday', label: 'Sábado' },
|
||||
{ key: 'sunday', label: 'Domingo' }
|
||||
]
|
||||
|
||||
const TIME_SLOTS = Array.from({ length: 24 * 2 }, (_, i) => {
|
||||
const hour = Math.floor(i / 2)
|
||||
const minute = (i % 2) * 30
|
||||
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
export default function ScheduleManagement() {
|
||||
const [staff, setStaff] = useState<Staff[]>([])
|
||||
const [selectedStaff, setSelectedStaff] = useState<string>('')
|
||||
const [schedule, setSchedule] = useState<StaffSchedule[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingSchedule, setEditingSchedule] = useState<StaffSchedule | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
date: '',
|
||||
start_time: '09:00',
|
||||
end_time: '17:00',
|
||||
is_available: true,
|
||||
reason: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchStaff()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStaff) {
|
||||
fetchSchedule()
|
||||
}
|
||||
}, [selectedStaff])
|
||||
|
||||
const fetchStaff = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/aperture/staff')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setStaff(data.staff)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching staff:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSchedule = async () => {
|
||||
if (!selectedStaff) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const today = new Date()
|
||||
const startDate = today.toISOString().split('T')[0]
|
||||
const endDate = new Date(today.setDate(today.getDate() + 30)).toISOString().split('T')[0]
|
||||
|
||||
const response = await fetch(
|
||||
`/api/aperture/staff/schedule?staff_id=${selectedStaff}&start_date=${startDate}&end_date=${endDate}`
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setSchedule(data.availability || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching schedule:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generateWeeklySchedule = async () => {
|
||||
if (!selectedStaff) return
|
||||
|
||||
const weeklyData = DAYS_OF_WEEK.map((day, index) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + ((index + 7 - date.getDay()) % 7))
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
|
||||
const isWeekend = day.key === 'saturday' || day.key === 'sunday'
|
||||
const startTime = isWeekend ? '10:00' : '09:00'
|
||||
const endTime = isWeekend ? '15:00' : '17:00'
|
||||
|
||||
return {
|
||||
staff_id: selectedStaff,
|
||||
date: dateStr,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
is_available: !isWeekend,
|
||||
reason: isWeekend ? 'Fin de semana' : undefined
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
for (const day of weeklyData) {
|
||||
await fetch('/api/aperture/staff/schedule', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(day)
|
||||
})
|
||||
}
|
||||
await fetchSchedule()
|
||||
alert('Horario semanal generado exitosamente')
|
||||
} catch (error) {
|
||||
console.error('Error generating weekly schedule:', error)
|
||||
alert('Error al generar el horario')
|
||||
}
|
||||
}
|
||||
|
||||
const addBreakToSchedule = async (scheduleId: string, breakStart: string, breakEnd: string) => {
|
||||
try {
|
||||
await fetch('/api/aperture/staff/schedule', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
staff_id: selectedStaff,
|
||||
date: schedule.find(s => s.id === scheduleId)?.date,
|
||||
start_time: breakStart,
|
||||
end_time: breakEnd,
|
||||
is_available: false,
|
||||
reason: 'Break de 30 min'
|
||||
})
|
||||
})
|
||||
await fetchSchedule()
|
||||
} catch (error) {
|
||||
console.error('Error adding break:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
await fetch('/api/aperture/staff/schedule', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
staff_id: selectedStaff,
|
||||
...formData
|
||||
})
|
||||
})
|
||||
|
||||
await fetchSchedule()
|
||||
setDialogOpen(false)
|
||||
setEditingSchedule(null)
|
||||
setFormData({ date: '', start_time: '09:00', end_time: '17:00', is_available: true, reason: '' })
|
||||
} catch (error) {
|
||||
console.error('Error saving schedule:', error)
|
||||
alert('Error al guardar el horario')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (scheduleId: string) => {
|
||||
if (!confirm('¿Eliminar este horario?')) return
|
||||
|
||||
try {
|
||||
await fetch(`/api/aperture/staff/schedule?id=${scheduleId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await fetchSchedule()
|
||||
} catch (error) {
|
||||
console.error('Error deleting schedule:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const calculateWorkingHours = (schedules: StaffSchedule[]) => {
|
||||
return schedules.reduce((total, s) => {
|
||||
if (!s.is_available) return total
|
||||
const start = parseInt(s.start_time.split(':')[0]) * 60 + parseInt(s.start_time.split(':')[1])
|
||||
const end = parseInt(s.end_time.split(':')[0]) * 60 + parseInt(s.end_time.split(':')[1])
|
||||
return total + (end - start)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const getScheduleForDate = (date: string) => {
|
||||
return schedule.filter(s => s.date === date && s.is_available)
|
||||
}
|
||||
|
||||
const getBreaksForDate = (date: string) => {
|
||||
return schedule.filter(s => s.date === date && !s.is_available && s.reason === 'Break de 30 min')
|
||||
}
|
||||
|
||||
const selectedStaffData = staff.find(s => s.id === selectedStaff)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Gestión de Horarios</h2>
|
||||
<p className="text-gray-600">Administra horarios y breaks del staff</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedStaff && (
|
||||
<>
|
||||
<Button variant="outline" onClick={generateWeeklySchedule}>
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Generar Semana
|
||||
</Button>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Día
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Seleccionar Staff</CardTitle>
|
||||
<CardDescription>Selecciona un miembro del equipo para ver y gestionar su horario</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select value={selectedStaff} onValueChange={setSelectedStaff}>
|
||||
<SelectTrigger className="w-full max-w-md">
|
||||
<SelectValue placeholder="Seleccionar staff" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{staff.map((member) => (
|
||||
<SelectItem key={member.id} value={member.id}>
|
||||
{member.display_name} ({member.role})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedStaff && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Horario de {selectedStaffData?.display_name}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Total horas programadas: {(calculateWorkingHours(schedule) / 60).toFixed(1)}h
|
||||
{' • '}Los breaks de 30min se agregan automáticamente cada 8hrs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Cargando horario...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{DAYS_OF_WEEK.map((day) => {
|
||||
const date = new Date()
|
||||
const currentDayOfWeek = date.getDay()
|
||||
const targetDayOfWeek = DAYS_OF_WEEK.findIndex(d => d.key === day.key)
|
||||
const daysUntil = (targetDayOfWeek - currentDayOfWeek + 7) % 7
|
||||
date.setDate(date.getDate() + daysUntil)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
|
||||
const daySchedules = getScheduleForDate(dateStr)
|
||||
const dayBreaks = getBreaksForDate(dateStr)
|
||||
|
||||
const totalMinutes = daySchedules.reduce((total, s) => {
|
||||
const start = parseInt(s.start_time.split(':')[0]) * 60 + parseInt(s.start_time.split(':')[1])
|
||||
const end = parseInt(s.end_time.split(':')[0]) * 60 + parseInt(s.end_time.split(':')[1])
|
||||
return total + (end - start)
|
||||
}, 0)
|
||||
|
||||
const shouldHaveBreak = totalMinutes >= 480
|
||||
|
||||
return (
|
||||
<div key={day.key} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{day.label}</span>
|
||||
<span className="text-sm text-gray-500">{dateStr}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldHaveBreak && dayBreaks.length === 0 && (
|
||||
<Badge className="bg-yellow-100 text-yellow-800">
|
||||
<Coffee className="w-3 h-3 mr-1" />
|
||||
Break pendiente
|
||||
</Badge>
|
||||
)}
|
||||
{dayBreaks.length > 0 && (
|
||||
<Badge className="bg-green-100 text-green-800">
|
||||
<Coffee className="w-3 h-3 mr-1" />
|
||||
Break incluido
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={daySchedules.length > 0 ? 'default' : 'secondary'}>
|
||||
{(totalMinutes / 60).toFixed(1)}h
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daySchedules.length > 0 ? (
|
||||
<div className="space-y-2 ml-4">
|
||||
{daySchedules.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between text-sm">
|
||||
<span>{s.start_time} - {s.end_time}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(s.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{dayBreaks.map((b) => (
|
||||
<div key={b.id} className="flex items-center justify-between text-sm text-gray-500 ml-4 border-l-2 border-yellow-300 pl-2">
|
||||
<span>{b.start_time} - {b.end_time} (Break)</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(b.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 ml-4">Sin horario programado</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Agregar Día de Trabajo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define el horario de trabajo para este día
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="date" className="text-right">
|
||||
Fecha
|
||||
</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({...formData, date: e.target.value})}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="start_time" className="text-right">
|
||||
Inicio
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.start_time}
|
||||
onValueChange={(value) => setFormData({...formData, start_time: value})}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_SLOTS.map((time) => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="end_time" className="text-right">
|
||||
Fin
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.end_time}
|
||||
onValueChange={(value) => setFormData({...formData, end_time: value})}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_SLOTS.map((time) => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="reason" className="text-right">
|
||||
Notas
|
||||
</Label>
|
||||
<Input
|
||||
id="reason"
|
||||
value={formData.reason}
|
||||
onChange={(e) => setFormData({...formData, reason: e.target.value})}
|
||||
className="col-span-3"
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">Guardar Horario</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { Plus, Edit, Trash2, Phone, MapPin, Clock, Users } from 'lucide-react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Plus, Edit, Trash2, Phone, MapPin, Clock, Users, Scissors, X } from 'lucide-react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
|
||||
interface StaffMember {
|
||||
@@ -39,6 +40,16 @@ interface StaffMember {
|
||||
schedule?: any[]
|
||||
}
|
||||
|
||||
interface Service {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
duration_minutes: number
|
||||
base_price: number
|
||||
isAssigned?: boolean
|
||||
proficiency?: number
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
@@ -60,6 +71,10 @@ export default function StaffManagement() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null)
|
||||
const [servicesDialogOpen, setServicesDialogOpen] = useState(false)
|
||||
const [selectedStaffForServices, setSelectedStaffForServices] = useState<StaffMember | null>(null)
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [loadingServices, setLoadingServices] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
location_id: '',
|
||||
role: '',
|
||||
@@ -72,6 +87,63 @@ export default function StaffManagement() {
|
||||
fetchLocations()
|
||||
}, [])
|
||||
|
||||
const fetchServices = async (staffId: string) => {
|
||||
setLoadingServices(true)
|
||||
try {
|
||||
const response = await fetch(`/api/aperture/staff/${staffId}/services`)
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setServices(data.availableServices || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching services:', error)
|
||||
} finally {
|
||||
setLoadingServices(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openServicesDialog = async (member: StaffMember) => {
|
||||
setSelectedStaffForServices(member)
|
||||
await fetchServices(member.id)
|
||||
setServicesDialogOpen(true)
|
||||
}
|
||||
|
||||
const toggleServiceAssignment = async (serviceId: string, isCurrentlyAssigned: boolean) => {
|
||||
if (!selectedStaffForServices) return
|
||||
|
||||
try {
|
||||
if (isCurrentlyAssigned) {
|
||||
await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services?service_id=${serviceId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
} else {
|
||||
await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service_id: serviceId })
|
||||
})
|
||||
}
|
||||
await fetchServices(selectedStaffForServices.id)
|
||||
} catch (error) {
|
||||
console.error('Error toggling service:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const updateProficiency = async (serviceId: string, level: number) => {
|
||||
if (!selectedStaffForServices) return
|
||||
|
||||
try {
|
||||
await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service_id: serviceId, proficiency_level: level })
|
||||
})
|
||||
await fetchServices(selectedStaffForServices.id)
|
||||
} catch (error) {
|
||||
console.error('Error updating proficiency:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStaff = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -265,6 +337,16 @@ export default function StaffManagement() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{member.role === 'artist' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openServicesDialog(member)}
|
||||
title="Gestionar servicios"
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -368,6 +450,72 @@ export default function StaffManagement() {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={servicesDialogOpen} onOpenChange={setServicesDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Scissors className="w-5 h-5" />
|
||||
Servicios de {selectedStaffForServices?.display_name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Selecciona los servicios que este artista puede realizar y su nivel de proficiency
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingServices ? (
|
||||
<div className="text-center py-8">Cargando servicios...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{services.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">No hay servicios disponibles</div>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
<div key={service.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={service.isAssigned}
|
||||
onCheckedChange={() => toggleServiceAssignment(service.id, service.isAssigned || false)}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">{service.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{service.category} • {service.duration_minutes} min • ${service.base_price}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{service.isAssigned && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs">Nivel:</Label>
|
||||
<Select
|
||||
value={String(service.proficiency || 3)}
|
||||
onValueChange={(value) => updateProficiency(service.id, parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 Principiante</SelectItem>
|
||||
<SelectItem value="2">2 Intermedio</SelectItem>
|
||||
<SelectItem value="3">3 Competente</SelectItem>
|
||||
<SelectItem value="4">4 Profesional</SelectItem>
|
||||
<SelectItem value="5">5 Experto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setServicesDialogOpen(false)}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cerrar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user