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
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m42s
Details
This commit is contained in:
parent
1039c90c8f
commit
e11fa8d780
|
|
@ -21,6 +21,7 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
|
||||
const [dbServices, setDbServices] = useState<any[]>([]);
|
||||
const [dbProfessionals, setDbProfessionals] = useState<any[]>([]);
|
||||
const [activeBookingInfo, setActiveBookingInfo] = useState<any>(null);
|
||||
|
||||
// Dados do tenant vindos do banco
|
||||
const [establishment, setEstablishment] = useState({
|
||||
|
|
@ -73,6 +74,28 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
fetch(`/api/tenant/agenda?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
|
||||
if (profileRes.ok) {
|
||||
|
|
@ -214,6 +237,15 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
});
|
||||
|
||||
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);
|
||||
} else {
|
||||
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) {
|
||||
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 (
|
||||
<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={{
|
||||
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)'
|
||||
|
|
@ -236,27 +298,32 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
</div>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '12px' }}>Agendamento Confirmado! 🎉</h1>
|
||||
<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>
|
||||
<div style={{
|
||||
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><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' }}>Profissional</span><div style={{ fontWeight: 600 }}>{prof?.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 }}>{renderProfName}</div></div>
|
||||
<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' }}>Horário</span><div style={{ fontWeight: 600 }}>{selectedTime}</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 }}>{renderTime}</div></div>
|
||||
</div>
|
||||
<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={{ 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>
|
||||
|
||||
<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' }}>
|
||||
Você receberá uma confirmação por e-mail/WhatsApp.
|
||||
Você não pode realizar outro agendamento enquanto este estiver ativo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ export async function GET(req: NextRequest) {
|
|||
bookingAdvanceMin: settings.booking_advance_min || 60,
|
||||
bookingAdvanceMaxDays: settings.booking_advance_max_days || 30,
|
||||
cancellationLimitMin: settings.cancellation_limit_min || 120,
|
||||
toleranceMinutes: settings.tolerance_minutes || 15,
|
||||
notifyNewAppointment: settings.notify_new_appointment !== false,
|
||||
notifyCancellation: settings.notify_cancellation !== 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_max_days: body.bookingAdvanceMaxDays || 30,
|
||||
cancellation_limit_min: body.cancellationLimitMin || 120,
|
||||
tolerance_minutes: body.toleranceMinutes || 15,
|
||||
notify_new_appointment: body.notifyNewAppointment !== false,
|
||||
notify_cancellation: body.notifyCancellation !== false,
|
||||
notify_client_reminder: body.notifyClientReminder !== false,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export default function Agenda() {
|
|||
const [newTime, setNewTime] = useState('10:00');
|
||||
const [newNotes, setNewNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [viewingAppointment, setViewingAppointment] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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',
|
||||
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'; }}
|
||||
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 (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
onClick={() => setCurrentDate(day)}
|
||||
style={{
|
||||
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'
|
||||
}}
|
||||
>
|
||||
|
|
@ -311,8 +312,8 @@ export default function Agenda() {
|
|||
padding: '8px', borderRadius: 'var(--radius-sm)',
|
||||
background: statusColors[apt.status] || 'var(--bg-secondary)',
|
||||
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: 600, marginTop: '2px' }}>{apt.client}</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.8 }}>{apt.service}</div>
|
||||
|
|
@ -360,7 +361,7 @@ export default function Agenda() {
|
|||
.map(apt => {
|
||||
const prof = professionals.find(p => p.id === apt.professionalId);
|
||||
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">
|
||||
<span className="badge" style={{ background: statusColors[apt.status], color: statusBorders[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 && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export default function Settings() {
|
|||
const [bookingAdvanceMin, setBookingAdvanceMin] = useState(60);
|
||||
const [bookingAdvanceMaxDays, setBookingAdvanceMaxDays] = useState(30);
|
||||
const [cancellationLimitMin, setCancellationLimitMin] = useState(120);
|
||||
const [toleranceMinutes, setToleranceMinutes] = useState(15);
|
||||
|
||||
// Notifications
|
||||
const [notifyNew, setNotifyNew] = useState(true);
|
||||
|
|
@ -140,6 +141,7 @@ export default function Settings() {
|
|||
setBookingAdvanceMin(data.bookingAdvanceMin || 60);
|
||||
setBookingAdvanceMaxDays(data.bookingAdvanceMaxDays || 30);
|
||||
setCancellationLimitMin(data.cancellationLimitMin || 120);
|
||||
setToleranceMinutes(data.toleranceMinutes || 15);
|
||||
setNotifyNew(data.notifyNewAppointment !== false);
|
||||
setNotifyCancel(data.notifyCancellation !== false);
|
||||
setNotifyClient(data.notifyClientReminder !== false);
|
||||
|
|
@ -214,6 +216,7 @@ export default function Settings() {
|
|||
bookingAdvanceMin,
|
||||
bookingAdvanceMaxDays,
|
||||
cancellationLimitMin,
|
||||
toleranceMinutes,
|
||||
notifyNewAppointment: notifyNew,
|
||||
notifyCancellation: notifyCancel,
|
||||
notifyClientReminder: notifyClient,
|
||||
|
|
@ -633,9 +636,15 @@ export default function Settings() {
|
|||
<input className="form-input" type="number" value={bookingAdvanceMaxDays} onChange={e => setBookingAdvanceMaxDays(Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<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))} />
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<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))} />
|
||||
</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}>
|
||||
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}
|
||||
|
|
|
|||
Loading…
Reference in New Issue