feat: cancelamento online pelo cliente, tolerancia de atraso configuravel, modal detalhes agendamento com atender/cancelar, e fix semana click bug
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m42s Details

This commit is contained in:
Sidney 2026-07-19 23:28:45 -03:00
parent 1039c90c8f
commit e11fa8d780
5 changed files with 246 additions and 17 deletions

View File

@ -21,6 +21,7 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
const [dbServices, setDbServices] = useState<any[]>([]); const [dbServices, setDbServices] = useState<any[]>([]);
const [dbProfessionals, setDbProfessionals] = useState<any[]>([]); const [dbProfessionals, setDbProfessionals] = useState<any[]>([]);
const [activeBookingInfo, setActiveBookingInfo] = useState<any>(null);
// Dados do tenant vindos do banco // Dados do tenant vindos do banco
const [establishment, setEstablishment] = useState({ const [establishment, setEstablishment] = useState({
@ -74,6 +75,28 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
fetch(`/api/tenant/services?slug=${slug}`) fetch(`/api/tenant/services?slug=${slug}`)
]); ]);
const savedBooking = localStorage.getItem(`agendapro_active_booking_${slug}`);
if (savedBooking) {
try {
const parsed = JSON.parse(savedBooking);
const statusRes = await fetch(`/api/tenant/agenda/appointment?id=${parsed.id}`);
if (statusRes.ok) {
const apt = await statusRes.json();
if (apt.status === 'pending' || apt.status === 'confirmed') {
setActiveBookingInfo(parsed);
setBooked(true);
} else {
localStorage.removeItem(`agendapro_active_booking_${slug}`);
}
} else {
localStorage.removeItem(`agendapro_active_booking_${slug}`);
}
} catch (e) {
localStorage.removeItem(`agendapro_active_booking_${slug}`);
}
}
// Carregar perfil do tenant // Carregar perfil do tenant
if (profileRes.ok) { if (profileRes.ok) {
const profile = await profileRes.json(); const profile = await profileRes.json();
@ -214,6 +237,15 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
}); });
if (res.ok) { if (res.ok) {
const aptData = await res.json();
localStorage.setItem(`agendapro_active_booking_${slug}`, JSON.stringify({
id: aptData.id,
service: svc?.name,
prof: prof?.name,
date: selectedDate,
time: selectedTime,
price: svc?.price
}));
setBooked(true); setBooked(true);
} else { } else {
const errData = await res.json(); const errData = await res.json();
@ -224,10 +256,40 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
} }
}; };
const cancelBooking = async () => {
if (!confirm('Tem certeza que deseja cancelar este agendamento?')) return;
try {
const idToCancel = activeBookingInfo?.id;
if (!idToCancel) return;
const res = await fetch('/api/tenant/agenda', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: idToCancel, status: 'cancelled' })
});
if (res.ok) {
showToast('Agendamento cancelado com sucesso.', 'success');
localStorage.removeItem(`agendapro_active_booking_${slug}`);
setActiveBookingInfo(null);
setBooked(false);
setStep(1);
setSelectedTime(null);
}
} catch (e) {
console.error(e);
showToast('Erro ao cancelar agendamento.', 'error');
}
};
if (booked) { if (booked) {
const renderSvcName = activeBookingInfo?.service || svc?.name;
const renderProfName = activeBookingInfo?.prof || prof?.name;
const renderDate = activeBookingInfo?.date || selectedDate;
const renderTime = activeBookingInfo?.time || selectedTime;
const renderPrice = activeBookingInfo?.price !== undefined ? activeBookingInfo.price : svc?.price;
return ( return (
<div style={{ minHeight: '100vh', background: 'var(--bg-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}> <div style={{ minHeight: '100vh', background: 'var(--bg-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
<div style={{ textAlign: 'center', maxWidth: '480px', animation: 'slideUp 0.5s ease' }}> <div style={{ textAlign: 'center', maxWidth: '480px', animation: 'slideUp 0.5s ease', width: '100%' }}>
<div style={{ <div style={{
width: 80, height: 80, borderRadius: '50%', background: 'var(--success-bg)', display: 'flex', width: 80, height: 80, borderRadius: '50%', background: 'var(--success-bg)', display: 'flex',
alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px', border: '3px solid var(--success)' alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px', border: '3px solid var(--success)'
@ -236,27 +298,32 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
</div> </div>
<h1 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '12px' }}>Agendamento Confirmado! 🎉</h1> <h1 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '12px' }}>Agendamento Confirmado! 🎉</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '16px', marginBottom: '32px' }}> <p style={{ color: 'var(--text-secondary)', fontSize: '16px', marginBottom: '32px' }}>
Seu horário foi reservado com sucesso. Seu horário está reservado e ativo.
</p> </p>
<div style={{ <div style={{
background: 'var(--bg-card)', border: '1px solid var(--border-color)', background: 'var(--bg-card)', border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-lg)', padding: '24px', textAlign: 'left' borderRadius: 'var(--radius-lg)', padding: '24px', textAlign: 'left', marginBottom: '20px'
}}> }}>
<div style={{ display: 'grid', gap: '16px' }}> <div style={{ display: 'grid', gap: '16px' }}>
<div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Serviço</span><div style={{ fontWeight: 600 }}>{svc?.name}</div></div> <div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Serviço</span><div style={{ fontWeight: 600 }}>{renderSvcName}</div></div>
<div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Profissional</span><div style={{ fontWeight: 600 }}>{prof?.name}</div></div> <div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Profissional</span><div style={{ fontWeight: 600 }}>{renderProfName}</div></div>
<div className="grid-2"> <div className="grid-2">
<div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Data</span><div style={{ fontWeight: 600 }}>{selectedDate ? new Date(selectedDate + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }) : ''}</div></div> <div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Data</span><div style={{ fontWeight: 600 }}>{renderDate ? new Date(renderDate + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }) : ''}</div></div>
<div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Horário</span><div style={{ fontWeight: 600 }}>{selectedTime}</div></div> <div><span style={{ fontSize: '12px', color: 'var(--text-muted)', textTransform: 'uppercase' }}>Horário</span><div style={{ fontWeight: 600 }}>{renderTime}</div></div>
</div> </div>
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--text-secondary)' }}>Total</span> <span style={{ color: 'var(--text-secondary)' }}>Total</span>
<span style={{ fontSize: '24px', fontWeight: 800, color: 'var(--accent)' }}>R$ {svc?.price.toFixed(2)}</span> <span style={{ fontSize: '24px', fontWeight: 800, color: 'var(--accent)' }}>R$ {renderPrice?.toFixed(2)}</span>
</div> </div>
</div> </div>
</div> </div>
<button className="btn btn-outline" style={{ width: '100%', borderColor: 'var(--danger)', color: 'var(--danger)' }} onClick={cancelBooking}>
Cancelar este agendamento
</button>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginTop: '20px' }}> <p style={{ fontSize: '13px', color: 'var(--text-muted)', marginTop: '20px' }}>
Você receberá uma confirmação por e-mail/WhatsApp. Você não pode realizar outro agendamento enquanto este estiver ativo.
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Missing appointment id' }, { status: 400 });
}
try {
const res = await query(`
SELECT
a.id, a.professional_id as "professionalId",
TO_CHAR(a.date, 'YYYY-MM-DD') as date,
a.client_name as client,
s.name as service,
EXTRACT(HOUR FROM a.start_time) as "startHour",
EXTRACT(MINUTE FROM a.start_time) as "startMin",
EXTRACT(EPOCH FROM (a.end_time - a.start_time))/60 as duration,
a.status, a.price,
p.name as "professionalName"
FROM appointments a
LEFT JOIN services s ON a.service_id = s.id
LEFT JOIN users p ON a.professional_id = p.id
WHERE a.id = $1
`, [id]);
if (res.rows.length === 0) {
return NextResponse.json({ error: 'Appointment not found' }, { status: 404 });
}
const apt = res.rows[0];
return NextResponse.json({
...apt,
startHour: Number(apt.startHour),
startMin: Number(apt.startMin),
duration: Number(apt.duration),
price: Number(apt.price)
});
} catch (error: any) {
console.error('GET single appointment error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -59,6 +59,7 @@ export async function GET(req: NextRequest) {
bookingAdvanceMin: settings.booking_advance_min || 60, bookingAdvanceMin: settings.booking_advance_min || 60,
bookingAdvanceMaxDays: settings.booking_advance_max_days || 30, bookingAdvanceMaxDays: settings.booking_advance_max_days || 30,
cancellationLimitMin: settings.cancellation_limit_min || 120, cancellationLimitMin: settings.cancellation_limit_min || 120,
toleranceMinutes: settings.tolerance_minutes || 15,
notifyNewAppointment: settings.notify_new_appointment !== false, notifyNewAppointment: settings.notify_new_appointment !== false,
notifyCancellation: settings.notify_cancellation !== false, notifyCancellation: settings.notify_cancellation !== false,
notifyClientReminder: settings.notify_client_reminder !== false, notifyClientReminder: settings.notify_client_reminder !== false,
@ -95,6 +96,7 @@ export async function PUT(req: NextRequest) {
booking_advance_min: body.bookingAdvanceMin || 60, booking_advance_min: body.bookingAdvanceMin || 60,
booking_advance_max_days: body.bookingAdvanceMaxDays || 30, booking_advance_max_days: body.bookingAdvanceMaxDays || 30,
cancellation_limit_min: body.cancellationLimitMin || 120, cancellation_limit_min: body.cancellationLimitMin || 120,
tolerance_minutes: body.toleranceMinutes || 15,
notify_new_appointment: body.notifyNewAppointment !== false, notify_new_appointment: body.notifyNewAppointment !== false,
notify_cancellation: body.notifyCancellation !== false, notify_cancellation: body.notifyCancellation !== false,
notify_client_reminder: body.notifyClientReminder !== false, notify_client_reminder: body.notifyClientReminder !== false,

View File

@ -76,6 +76,7 @@ export default function Agenda() {
const [newTime, setNewTime] = useState('10:00'); const [newTime, setNewTime] = useState('10:00');
const [newNotes, setNewNotes] = useState(''); const [newNotes, setNewNotes] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [viewingAppointment, setViewingAppointment] = useState<any>(null);
useEffect(() => { useEffect(() => {
if (professionals.length > 0 && !newProf) { if (professionals.length > 0 && !newProf) {
@ -237,6 +238,7 @@ export default function Agenda() {
transition: 'all 0.2s', zIndex: 2, display: 'flex', flexDirection: isSmall ? 'row' : 'column', transition: 'all 0.2s', zIndex: 2, display: 'flex', flexDirection: isSmall ? 'row' : 'column',
alignItems: isSmall ? 'center' : 'flex-start', gap: isSmall ? '8px' : '0' alignItems: isSmall ? 'center' : 'flex-start', gap: isSmall ? '8px' : '0'
}} }}
onClick={() => setViewingAppointment(apt)}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1.02)'; (e.currentTarget as HTMLElement).style.zIndex = '10'; }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1.02)'; (e.currentTarget as HTMLElement).style.zIndex = '10'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1)'; (e.currentTarget as HTMLElement).style.zIndex = '2'; }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1)'; (e.currentTarget as HTMLElement).style.zIndex = '2'; }}
> >
@ -275,10 +277,9 @@ export default function Agenda() {
return ( return (
<div <div
key={day.toISOString()} key={day.toISOString()}
onClick={() => setCurrentDate(day)}
style={{ style={{
padding: '14px 8px', textAlign: 'center', borderRight: '1px solid var(--border-color)', padding: '14px 8px', textAlign: 'center', borderRight: '1px solid var(--border-color)',
background: isSelectedDay ? 'var(--accent-glow)' : 'var(--bg-secondary)', cursor: 'pointer', background: isTodayDay ? 'var(--accent-glow)' : 'var(--bg-secondary)',
transition: 'all 0.2s' transition: 'all 0.2s'
}} }}
> >
@ -311,8 +312,8 @@ export default function Agenda() {
padding: '8px', borderRadius: 'var(--radius-sm)', padding: '8px', borderRadius: 'var(--radius-sm)',
background: statusColors[apt.status] || 'var(--bg-secondary)', background: statusColors[apt.status] || 'var(--bg-secondary)',
borderLeft: `3px solid ${statusBorders[apt.status] || 'var(--accent)'}`, borderLeft: `3px solid ${statusBorders[apt.status] || 'var(--accent)'}`,
fontSize: '12px' fontSize: '12px', cursor: 'pointer'
}}> }} onClick={() => setViewingAppointment(apt)}>
<div style={{ fontWeight: 700 }}>{String(apt.startHour).padStart(2, '0')}:{String(apt.startMin).padStart(2, '0')}</div> <div style={{ fontWeight: 700 }}>{String(apt.startHour).padStart(2, '0')}:{String(apt.startMin).padStart(2, '0')}</div>
<div style={{ fontWeight: 600, marginTop: '2px' }}>{apt.client}</div> <div style={{ fontWeight: 600, marginTop: '2px' }}>{apt.client}</div>
<div style={{ fontSize: '11px', opacity: 0.8 }}>{apt.service}</div> <div style={{ fontSize: '11px', opacity: 0.8 }}>{apt.service}</div>
@ -360,7 +361,7 @@ export default function Agenda() {
.map(apt => { .map(apt => {
const prof = professionals.find(p => p.id === apt.professionalId); const prof = professionals.find(p => p.id === apt.professionalId);
return ( return (
<div key={apt.id} className="card" style={{ padding: '16px', borderLeft: `4px solid ${statusBorders[apt.status] || 'var(--accent)'}` }}> <div key={apt.id} className="card" style={{ padding: '16px', borderLeft: `4px solid ${statusBorders[apt.status] || 'var(--accent)'}`, cursor: 'pointer' }} onClick={() => setViewingAppointment(apt)}>
<div className="flex-between mb-2"> <div className="flex-between mb-2">
<span className="badge" style={{ background: statusColors[apt.status], color: statusBorders[apt.status] }}> <span className="badge" style={{ background: statusColors[apt.status], color: statusBorders[apt.status] }}>
{statusLabels[apt.status] || apt.status} {statusLabels[apt.status] || apt.status}
@ -399,6 +400,110 @@ export default function Agenda() {
</> </>
)} )}
{viewingAppointment && (
<div className="modal-overlay" onClick={() => setViewingAppointment(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Detalhes do Agendamento</h2>
<button className="btn-ghost btn-icon" onClick={() => setViewingAppointment(null)}><X size={20} /></button>
</div>
<div className="modal-body">
<div style={{ marginBottom: '16px' }}>
<span className="badge" style={{ background: statusColors[viewingAppointment.status], color: statusBorders[viewingAppointment.status], fontSize: '14px', padding: '6px 12px' }}>
{statusLabels[viewingAppointment.status] || viewingAppointment.status}
</span>
</div>
<h3 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '4px' }}>{viewingAppointment.client}</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px', fontSize: '14px' }}>
{viewingAppointment.service} R$ {viewingAppointment.price} ({viewingAppointment.duration} min)
</p>
<div className="grid-2">
<div>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Data</span>
<div style={{ fontWeight: 600 }}>{viewingAppointment.date.split('-').reverse().join('/')}</div>
</div>
<div>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Horário</span>
<div style={{ fontWeight: 600 }}>{String(viewingAppointment.startHour).padStart(2, '0')}:{String(viewingAppointment.startMin).padStart(2, '0')}</div>
</div>
</div>
</div>
<div className="modal-footer" style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
{(viewingAppointment.status === 'pending' || viewingAppointment.status === 'confirmed') && (
<>
<button className="btn btn-primary" onClick={async () => {
if (!confirm('Iniciar este atendimento agora?')) return;
setSubmitting(true);
// Lógica: Atender o próximo cliente e cancelar automaticamente os anteriores (No-show)
const prevAppointments = appointments.filter(a =>
a.professionalId === viewingAppointment.professionalId &&
a.date === viewingAppointment.date &&
(a.startHour * 60 + a.startMin) < (viewingAppointment.startHour * 60 + viewingAppointment.startMin) &&
(a.status === 'pending' || a.status === 'confirmed')
);
try {
// Cancela os anteriores
for (const pApt of prevAppointments) {
await fetch('/api/tenant/agenda', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: pApt.id, status: 'cancelled' })
});
}
// Conclui o atual
const res = await fetch('/api/tenant/agenda', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: viewingAppointment.id, status: 'completed' })
});
if (res.ok) {
showToast(prevAppointments.length > 0 ? `Atendimento concluído e ${prevAppointments.length} agendamento(s) anterior(es) cancelado(s) por falta.` : 'Atendimento concluído com sucesso!', 'success');
fetchAgenda();
setViewingAppointment(null);
}
} catch (e) {
console.error(e);
showToast('Erro ao atualizar.', 'error');
} finally {
setSubmitting(false);
}
}} disabled={submitting}>
<CheckCircle2 size={16} /> Atender Cliente
</button>
<button className="btn btn-outline" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }} onClick={async () => {
if (!confirm('Tem certeza que deseja cancelar este agendamento?')) return;
setSubmitting(true);
try {
const res = await fetch('/api/tenant/agenda', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: viewingAppointment.id, status: 'cancelled' })
});
if (res.ok) {
showToast('Agendamento cancelado!', 'success');
fetchAgenda();
setViewingAppointment(null);
}
} catch (e) {
console.error(e);
} finally {
setSubmitting(false);
}
}} disabled={submitting}>
<XCircle size={16} /> Cancelar Agendamento
</button>
</>
)}
<button className="btn btn-secondary" onClick={() => setViewingAppointment(null)}>Fechar</button>
</div>
</div>
</div>
)}
{showModal && ( {showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}> <div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}> <div className="modal" onClick={e => e.stopPropagation()}>

View File

@ -52,6 +52,7 @@ export default function Settings() {
const [bookingAdvanceMin, setBookingAdvanceMin] = useState(60); const [bookingAdvanceMin, setBookingAdvanceMin] = useState(60);
const [bookingAdvanceMaxDays, setBookingAdvanceMaxDays] = useState(30); const [bookingAdvanceMaxDays, setBookingAdvanceMaxDays] = useState(30);
const [cancellationLimitMin, setCancellationLimitMin] = useState(120); const [cancellationLimitMin, setCancellationLimitMin] = useState(120);
const [toleranceMinutes, setToleranceMinutes] = useState(15);
// Notifications // Notifications
const [notifyNew, setNotifyNew] = useState(true); const [notifyNew, setNotifyNew] = useState(true);
@ -140,6 +141,7 @@ export default function Settings() {
setBookingAdvanceMin(data.bookingAdvanceMin || 60); setBookingAdvanceMin(data.bookingAdvanceMin || 60);
setBookingAdvanceMaxDays(data.bookingAdvanceMaxDays || 30); setBookingAdvanceMaxDays(data.bookingAdvanceMaxDays || 30);
setCancellationLimitMin(data.cancellationLimitMin || 120); setCancellationLimitMin(data.cancellationLimitMin || 120);
setToleranceMinutes(data.toleranceMinutes || 15);
setNotifyNew(data.notifyNewAppointment !== false); setNotifyNew(data.notifyNewAppointment !== false);
setNotifyCancel(data.notifyCancellation !== false); setNotifyCancel(data.notifyCancellation !== false);
setNotifyClient(data.notifyClientReminder !== false); setNotifyClient(data.notifyClientReminder !== false);
@ -214,6 +216,7 @@ export default function Settings() {
bookingAdvanceMin, bookingAdvanceMin,
bookingAdvanceMaxDays, bookingAdvanceMaxDays,
cancellationLimitMin, cancellationLimitMin,
toleranceMinutes,
notifyNewAppointment: notifyNew, notifyNewAppointment: notifyNew,
notifyCancellation: notifyCancel, notifyCancellation: notifyCancel,
notifyClientReminder: notifyClient, notifyClientReminder: notifyClient,
@ -633,10 +636,16 @@ export default function Settings() {
<input className="form-input" type="number" value={bookingAdvanceMaxDays} onChange={e => setBookingAdvanceMaxDays(Number(e.target.value))} /> <input className="form-input" type="number" value={bookingAdvanceMaxDays} onChange={e => setBookingAdvanceMaxDays(Number(e.target.value))} />
</div> </div>
</div> </div>
<div className="grid-2">
<div className="form-group"> <div className="form-group">
<label className="form-label">Limite para cancelamento (minutos antes)</label> <label className="form-label">Limite para cancelamento (minutos antes)</label>
<input className="form-input" type="number" value={cancellationLimitMin} onChange={e => setCancellationLimitMin(Number(e.target.value))} /> <input className="form-input" type="number" value={cancellationLimitMin} onChange={e => setCancellationLimitMin(Number(e.target.value))} />
</div> </div>
<div className="form-group">
<label className="form-label">Tempo de tolerância para atrasos (minutos)</label>
<input className="form-input" type="number" value={toleranceMinutes} onChange={e => setToleranceMinutes(Number(e.target.value))} />
</div>
</div>
<button className="btn btn-primary mt-4" onClick={handleSave} disabled={saving}> <button className="btn btn-primary mt-4" onClick={handleSave} disabled={saving}>
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'} <Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}
</button> </button>