Files
contexta_fe/src/pages/AppointmentsPage.tsx
2026-04-16 21:30:51 +00:00

427 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { appointmentsAPI, chatbotsAPI } from '@/services/api'
import { Card, Button, Spinner } from '@/components/ui'
import {
Calendar, Clock, User, Phone, Filter, Lock, CheckCircle2,
XCircle, RotateCcw, ChevronDown, Settings, CalendarDays,
} from 'lucide-react'
import type { Appointment, Chatbot, BusinessHoursEntry } from '@/types'
import { cn } from '@/lib/utils'
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
pending: { label: 'Pending', color: 'bg-yellow-100 text-yellow-700', icon: Clock },
confirmed: { label: 'Confirmed', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
cancelled: { label: 'Cancelled', color: 'bg-red-100 text-red-600', icon: XCircle },
completed: { label: 'Completed', color: 'bg-gray-100 text-gray-600', icon: CheckCircle2 },
}
const DAY_LABELS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const DEFAULT_HOURS: BusinessHoursEntry[] = DAY_LABELS.map((_, i) => ({
day_of_week: i,
is_open: i < 5, // MonFri open by default
open_time: '09:00',
close_time: '17:00',
slot_duration_minutes: 60,
}))
// ── Business Hours Settings Panel ─────────────────────────────────────────────
const HoursSettings: React.FC<{ chatbotId: string; onClose: () => void }> = ({ chatbotId, onClose }) => {
const queryClient = useQueryClient()
const [hours, setHours] = useState<BusinessHoursEntry[]>(DEFAULT_HOURS)
const [saved, setSaved] = useState(false)
const { isLoading } = useQuery<BusinessHoursEntry[]>({
queryKey: ['business-hours', chatbotId],
queryFn: () => appointmentsAPI.getHours(chatbotId),
onSuccess: (data) => {
if (data && data.length > 0) {
// Merge fetched data with defaults
const merged = DEFAULT_HOURS.map(d => {
const found = data.find(h => h.day_of_week === d.day_of_week)
return found ? { ...d, ...found } : d
})
setHours(merged)
}
},
} as any)
const save = useMutation({
mutationFn: () => appointmentsAPI.saveHours(chatbotId, hours),
onSuccess: () => {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
queryClient.invalidateQueries({ queryKey: ['business-hours', chatbotId] })
},
})
const update = (idx: number, field: keyof BusinessHoursEntry, value: any) => {
setHours(prev => prev.map((h, i) => i === idx ? { ...h, [field]: value } : h))
}
if (isLoading) return <div className="p-8 text-center"><Spinner className="text-primary-600 mx-auto" /></div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900">Business Hours</h3>
<button onClick={onClose} className="text-xs text-gray-500 hover:text-gray-700"> Back</button>
</div>
<p className="text-xs text-gray-500">Configure when customers can book appointments.</p>
<div className="space-y-2">
{hours.map((h, i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
<div className="w-24 flex-shrink-0">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={h.is_open}
onChange={e => update(i, 'is_open', e.target.checked)}
className="w-3.5 h-3.5 accent-primary-600"
/>
<span className={cn('text-xs font-medium', h.is_open ? 'text-gray-900' : 'text-gray-400')}>
{DAY_LABELS[i].slice(0, 3)}
</span>
</label>
</div>
{h.is_open ? (
<>
<input
type="time"
value={h.open_time}
onChange={e => update(i, 'open_time', e.target.value)}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<span className="text-xs text-gray-400">to</span>
<input
type="time"
value={h.close_time}
onChange={e => update(i, 'close_time', e.target.value)}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<select
value={h.slot_duration_minutes}
onChange={e => update(i, 'slot_duration_minutes', Number(e.target.value))}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-primary-400 ml-auto"
>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>1 hr</option>
<option value={90}>1.5 hr</option>
<option value={120}>2 hr</option>
</select>
</>
) : (
<span className="text-xs text-gray-400 italic">Closed</span>
)}
</div>
))}
</div>
<Button
onClick={() => save.mutate()}
disabled={save.isPending}
className="w-full gap-2"
size="sm"
>
{save.isPending ? <Spinner className="w-4 h-4 text-white" /> : saved ? '✓ Saved!' : 'Save Hours'}
</Button>
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export const AppointmentsPage: React.FC = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [chatbotFilter, setChatbotFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [settingsChatbotId, setSettingsChatbotId] = useState<string | null>(null)
const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'],
queryFn: chatbotsAPI.list,
})
const { data: appointments = [], isLoading, error } = useQuery<Appointment[]>({
queryKey: ['appointments', chatbotFilter, statusFilter],
queryFn: () => appointmentsAPI.list({
chatbot_id: chatbotFilter || undefined,
status: statusFilter || undefined,
}),
retry: false,
})
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
appointmentsAPI.updateStatus(id, status),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['appointments'] }),
})
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
if (isPlanError) {
return (
<div className="p-6 max-w-2xl mx-auto">
<Card className="p-10 text-center">
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-7 h-7 text-primary-600" />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Appointment Booking</h2>
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
Upgrade to Starter to enable appointment booking for your chatbots.
</p>
</Card>
</div>
)
}
const upcoming = appointments.filter(a => new Date(a.slot_start) >= new Date() && a.status !== 'cancelled')
const today = appointments.filter(a => {
const d = new Date(a.slot_start)
const now = new Date()
return d.toDateString() === now.toDateString()
})
const bookingEnabledChatbots = chatbots.filter(c => c.booking_enabled)
return (
<div className="p-4 sm:p-6 max-w-8xl mx-auto space-y-6 text-start">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
<CalendarDays className="w-5 h-5 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Appointments</h1>
<p className="text-sm text-gray-500 mt-0.5">Bookings made through your chatbots</p>
</div>
</div>
</div>
{/* Business hours settings panel */}
{settingsChatbotId && (
<Card className="p-5">
<HoursSettings
chatbotId={settingsChatbotId}
onClose={() => setSettingsChatbotId(null)}
/>
</Card>
)}
{/* Setup prompt when no chatbots have booking enabled */}
{!isLoading && bookingEnabledChatbots.length === 0 && (
<Card className="p-6 border-dashed border-2 border-gray-200">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center flex-shrink-0">
<Calendar className="w-5 h-5 text-amber-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">Enable booking on a chatbot</h3>
<p className="text-sm text-gray-500 mb-3">
Go to a chatbot's Deploy tab and enable "Appointment Booking" to start accepting bookings.
</p>
{chatbots.length > 0 && (
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/chatbots/${chatbots[0].id}/edit`)}
>
Configure chatbot →
</Button>
)}
</div>
</div>
</Card>
)}
{/* Stats cards */}
{appointments.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: 'Today', count: today.length, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: 'Upcoming', count: upcoming.length, color: 'text-primary-600', bg: 'bg-primary-50' },
{ label: 'Confirmed', count: appointments.filter(a => a.status === 'confirmed').length, color: 'text-green-600', bg: 'bg-green-50' },
{ label: 'Pending', count: appointments.filter(a => a.status === 'pending').length, color: 'text-yellow-600', bg: 'bg-yellow-50' },
].map(stat => (
<Card key={stat.label} className="p-4 flex items-center gap-3">
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', stat.bg)}>
<Calendar className={cn('w-4 h-4', stat.color)} />
</div>
<div>
<p className="text-xs text-gray-500 font-medium">{stat.label}</p>
<p className="text-xl font-bold text-gray-900">{stat.count}</p>
</div>
</Card>
))}
</div>
)}
{/* Filters */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 flex-shrink-0">
<Filter className="w-4 h-4 text-gray-400" />
Filter
</div>
<select
value={chatbotFilter}
onChange={e => setChatbotFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer"
>
<option value="">All chatbots</option>
{bookingEnabledChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer"
>
<option value="">All statuses</option>
{Object.entries(STATUS_CONFIG).map(([v, c]) => <option key={v} value={v}>{c.label}</option>)}
</select>
{/* Per-chatbot hours settings */}
{bookingEnabledChatbots.length > 0 && (
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-gray-500">Hours:</span>
<select
value={settingsChatbotId || ''}
onChange={e => setSettingsChatbotId(e.target.value || null)}
className="border border-gray-200 rounded-lg px-2 py-1.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-primary-400 appearance-none cursor-pointer"
>
<option value="">Configure chatbot...</option>
{bookingEnabledChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<Settings className="w-3.5 h-3.5 text-gray-400" />
</div>
)}
</div>
</Card>
{/* Appointments list */}
{isLoading ? (
<div className="flex justify-center py-12"><Spinner className="text-primary-600" /></div>
) : appointments.length === 0 ? (
<Card className="p-14 text-center">
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<Calendar className="w-7 h-7 text-gray-300" />
</div>
<h3 className="font-semibold text-gray-700 mb-2">No appointments yet</h3>
<p className="text-sm text-gray-500 max-w-sm mx-auto">
Once customers book through your chatbot, appointments will appear here.
</p>
</Card>
) : (
<div className="space-y-3">
{appointments.map(appt => {
const sc = STATUS_CONFIG[appt.status] || STATUS_CONFIG.pending
const Icon = sc.icon
const slotDate = new Date(appt.slot_start)
const slotEnd = new Date(appt.slot_end)
const isToday = slotDate.toDateString() === new Date().toDateString()
return (
<Card key={appt.id} className="p-4">
<div className="flex items-start gap-4">
{/* Date block */}
<div className="flex-shrink-0 text-center bg-primary-50 rounded-xl p-2.5 w-14">
<p className="text-xs font-semibold text-primary-500 uppercase">
{slotDate.toLocaleDateString(undefined, { month: 'short' })}
</p>
<p className="text-2xl font-bold text-primary-700 leading-none">{slotDate.getDate()}</p>
{isToday && <p className="text-[10px] text-primary-500 font-medium mt-0.5">Today</p>}
</div>
{/* Details */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<p className="font-semibold text-gray-900 text-sm">{appt.customer_name}</p>
{appt.service && <p className="text-xs text-gray-500 mt-0.5">{appt.service}</p>}
</div>
<span className={cn('flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full flex-shrink-0', sc.color)}>
<Icon className="w-3 h-3" />
{sc.label}
</span>
</div>
<div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{slotDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} {slotEnd.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
<span className="flex items-center gap-1">
<Phone className="w-3 h-3" />
{appt.customer_contact}
</span>
</div>
{appt.notes && (
<p className="text-xs text-gray-400 mt-1.5 italic">"{appt.notes}"</p>
)}
{/* Actions */}
{appt.status === 'pending' && (
<div className="flex gap-2 mt-3">
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'confirmed' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors disabled:opacity-50"
>
<CheckCircle2 className="w-3.5 h-3.5" /> Confirm
</button>
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'cancelled' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50"
>
<XCircle className="w-3.5 h-3.5" /> Decline
</button>
</div>
)}
{appt.status === 'confirmed' && (
<div className="flex gap-2 mt-3">
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'completed' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
<CheckCircle2 className="w-3.5 h-3.5" /> Mark Complete
</button>
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'cancelled' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50"
>
<XCircle className="w-3.5 h-3.5" /> Cancel
</button>
</div>
)}
{appt.status === 'cancelled' && (
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'pending' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 mt-3 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
<RotateCcw className="w-3.5 h-3.5" /> Restore
</button>
)}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
)
}