mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
427 lines
20 KiB
TypeScript
427 lines
20 KiB
TypeScript
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, // Mon–Fri 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>
|
||
)
|
||
}
|