feat: grade de atendimento multi-turno flexivel com botao rapido de horario de almoco, criacao/exclusao de turnos e personalizacao por profissional
Build and Deploy (Gitea Actions) / build-and-deploy (push) Failing after 44s Details

This commit is contained in:
Sidney 2026-07-19 22:54:46 -03:00
parent 8f933b3ba5
commit 8ce7a4340e
3 changed files with 243 additions and 52 deletions

View File

@ -29,23 +29,40 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
allowOnlineBooking: true, bookingAdvanceMin: 60, bookingAdvanceMaxDays: 30 allowOnlineBooking: true, bookingAdvanceMin: 60, bookingAdvanceMaxDays: 30
}); });
// Algoritmo dinâmico de fatiamento de grade const [profileSchedules, setProfileSchedules] = useState<any>({
const generateSlots = (startTimeStr: string, endTimeStr: string, intervalMin: number) => { dailySchedules: null,
profSchedules: {},
interval: 30,
startTime: '09:00',
endTime: '19:00'
});
// Algoritmo dinâmico de fatiamento de grade multi-turno
const generateSlotsForShifts = (shifts: { start: string; end: string }[], intervalMin: number) => {
const generated: string[] = []; const generated: string[] = [];
const [sh, sm] = startTimeStr.split(':').map(Number); for (const shift of shifts) {
const [eh, em] = endTimeStr.split(':').map(Number); if (!shift.start || !shift.end) continue;
const [sh, sm] = shift.start.split(':').map(Number);
const [eh, em] = shift.end.split(':').map(Number);
let currentMinutes = sh * 60 + sm; let currentMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em; const endMinutes = eh * 60 + em;
while (currentMinutes < endMinutes) { while (currentMinutes < endMinutes) {
const h = Math.floor(currentMinutes / 60); const h = Math.floor(currentMinutes / 60);
const m = currentMinutes % 60; const m = currentMinutes % 60;
const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
generated.push(timeStr); if (!generated.includes(timeStr)) {
currentMinutes += intervalMin; generated.push(timeStr);
}
currentMinutes += intervalMin;
}
} }
return generated; return generated.sort();
};
const generateSlots = (startTimeStr: string, endTimeStr: string, intervalMin: number) => {
return generateSlotsForShifts([{ start: startTimeStr, end: endTimeStr }], intervalMin);
}; };
useEffect(() => { useEffect(() => {
@ -74,11 +91,16 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
bookingAdvanceMaxDays: profile.bookingAdvanceMaxDays || 30, bookingAdvanceMaxDays: profile.bookingAdvanceMaxDays || 30,
}); });
// Gerar slots com horários do tenant setProfileSchedules({
const startTime = profile.startTime || '09:00'; dailySchedules: profile.dailySchedules || null,
const endTime = profile.endTime || '19:00'; profSchedules: profile.profSchedules || {},
const interval = profile.interval || 30; interval: profile.interval || 30,
setSlots(generateSlots(startTime, endTime, interval)); startTime: profile.startTime || '09:00',
endTime: profile.endTime || '19:00'
});
// Slots iniciais
setSlots(generateSlots(profile.startTime || '09:00', profile.endTime || '19:00', profile.interval || 30));
// Aplicar cor do tema // Aplicar cor do tema
if (profile.themeColor) { if (profile.themeColor) {
@ -86,7 +108,6 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
document.documentElement.style.setProperty('--accent-glow', `${profile.themeColor}26`); document.documentElement.style.setProperty('--accent-glow', `${profile.themeColor}26`);
} }
} else { } else {
// Fallback
setSlots(generateSlots('09:00', '19:00', 30)); setSlots(generateSlots('09:00', '19:00', 30));
} }
@ -108,6 +129,36 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
fetchData(); fetchData();
}, [slug]); }, [slug]);
// Recalcular horários quando mudar data ou profissional
useEffect(() => {
if (!selectedDate) return;
const dateObj = new Date(selectedDate + 'T12:00:00');
const dayOfWeek = dateObj.getDay();
const interval = profileSchedules.interval || 30;
let dayConfig = null;
if (selectedProfessional && profileSchedules.profSchedules?.[selectedProfessional]) {
dayConfig = profileSchedules.profSchedules[selectedProfessional][dayOfWeek];
}
if (!dayConfig && profileSchedules.dailySchedules) {
dayConfig = profileSchedules.dailySchedules[dayOfWeek];
}
if (dayConfig) {
if (!dayConfig.active) {
setSlots([]);
return;
}
if (Array.isArray(dayConfig.shifts) && dayConfig.shifts.length > 0) {
setSlots(generateSlotsForShifts(dayConfig.shifts, interval));
return;
}
}
setSlots(generateSlots(profileSchedules.startTime || '09:00', profileSchedules.endTime || '19:00', interval));
}, [selectedDate, selectedProfessional, profileSchedules]);
const today = new Date(); const today = new Date();
const maxDays = establishment.bookingAdvanceMaxDays || 30; const maxDays = establishment.bookingAdvanceMaxDays || 30;
const dates = Array.from({ length: maxDays }, (_, i) => { const dates = Array.from({ length: maxDays }, (_, i) => {

View File

@ -47,6 +47,8 @@ export async function GET(req: NextRequest) {
allowOnlineBooking: settings.allow_online_booking !== false, allowOnlineBooking: settings.allow_online_booking !== false,
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,
dailySchedules: settings.daily_schedules || null,
profSchedules: settings.prof_schedules || {},
}); });
} catch (error: any) { } catch (error: any) {
console.error('GET Profile error:', error); console.error('GET Profile error:', error);

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Store, Clock, Globe, Bell, Palette, Shield, Save, Copy, ExternalLink, Check, Link, Loader } from 'lucide-react'; import { Store, Clock, Globe, Bell, Palette, Shield, Save, Copy, ExternalLink, Check, Link, Loader, Plus, Trash2 } from 'lucide-react';
const tabs = [ const tabs = [
{ id: 'general', label: 'Geral', icon: Store }, { id: 'general', label: 'Geral', icon: Store },
@ -67,16 +67,41 @@ export default function Settings() {
// Custom Schedules per day & per professional // Custom Schedules per day & per professional
const [professionals, setProfessionals] = useState<any[]>([]); const [professionals, setProfessionals] = useState<any[]>([]);
const [selectedProfId, setSelectedProfId] = useState<string>('all'); const [selectedProfId, setSelectedProfId] = useState<string>('all');
const [dailySchedules, setDailySchedules] = useState<{ active: boolean; start: string; end: string }[]>([ const [dailySchedules, setDailySchedules] = useState<{ active: boolean; shifts: { start: string; end: string }[] }>([
{ active: false, start: '09:00', end: '18:00' }, // Dom { active: false, shifts: [{ start: '09:00', end: '18:00' }] }, // Dom
{ active: true, start: '09:00', end: '19:00' }, // Seg { active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Seg
{ active: true, start: '09:00', end: '19:00' }, // Ter { active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Ter
{ active: true, start: '09:00', end: '19:00' }, // Qua { active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Qua
{ active: true, start: '09:00', end: '19:00' }, // Qui { active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Qui
{ active: true, start: '09:00', end: '19:00' }, // Sex { active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Sex
{ active: true, start: '09:00', end: '17:00' }, // Sáb { active: true, shifts: [{ start: '08:00', end: '17:00' }] }, // Sáb
]); ]);
const [profSchedules, setProfSchedules] = useState<Record<string, { active: boolean; start: string; end: string }[]>>({}); const [profSchedules, setProfSchedules] = useState<Record<string, { active: boolean; shifts: { start: string; end: string }[] }[]>>({});
const normalizeSchedule = (rawList: any) => {
if (!Array.isArray(rawList) || rawList.length === 0) {
return [
{ active: false, shifts: [{ start: '09:00', end: '18:00' }] },
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] },
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] },
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] },
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] },
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] },
{ active: true, shifts: [{ start: '08:00', end: '17:00' }] },
];
}
return [0, 1, 2, 3, 4, 5, 6].map((dayIdx) => {
const item = rawList[dayIdx];
if (!item) return { active: dayIdx > 0, shifts: [{ start: '09:00', end: '18:00' }] };
if (Array.isArray(item.shifts) && item.shifts.length > 0) {
return { active: !!item.active, shifts: item.shifts };
}
return {
active: !!item.active,
shifts: [{ start: item.start || '09:00', end: item.end || '19:00' }]
};
});
};
// Security // Security
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
@ -127,10 +152,14 @@ export default function Settings() {
setWorkingDays([0, 1, 2, 3, 4, 5, 6].map(d => wd.includes(d))); setWorkingDays([0, 1, 2, 3, 4, 5, 6].map(d => wd.includes(d)));
if (data.dailySchedules) { if (data.dailySchedules) {
setDailySchedules(data.dailySchedules); setDailySchedules(normalizeSchedule(data.dailySchedules));
} }
if (data.profSchedules) { if (data.profSchedules) {
setProfSchedules(data.profSchedules); const normalizedMap: Record<string, any> = {};
Object.keys(data.profSchedules).forEach(k => {
normalizedMap[k] = normalizeSchedule(data.profSchedules[k]);
});
setProfSchedules(normalizedMap);
} }
// Carrega profissionais para horario individual // Carrega profissionais para horario individual
@ -384,9 +413,7 @@ export default function Settings() {
? dailySchedules ? dailySchedules
: (profSchedules[selectedProfId] || dailySchedules); : (profSchedules[selectedProfId] || dailySchedules);
const updateSched = (index: number, field: 'active' | 'start' | 'end', val: any) => { const saveActiveSched = (updated: any[]) => {
const updated = [...activeSched];
updated[index] = { ...updated[index], [field]: val };
if (selectedProfId === 'all') { if (selectedProfId === 'all') {
setDailySchedules(updated); setDailySchedules(updated);
} else { } else {
@ -394,27 +421,138 @@ export default function Settings() {
} }
}; };
const toggleDayActive = (i: number) => {
const updated = [...activeSched];
const current = updated[i] || { active: false, shifts: [{ start: '09:00', end: '18:00' }] };
updated[i] = { ...current, active: !current.active };
saveActiveSched(updated);
};
const addShift = (i: number) => {
const updated = [...activeSched];
const current = updated[i] || { active: true, shifts: [{ start: '09:00', end: '18:00' }] };
const shifts = [...(current.shifts || [])];
shifts.push({ start: '19:00', end: '22:00' });
updated[i] = { ...current, active: true, shifts };
saveActiveSched(updated);
};
const addLunchBreak = (i: number) => {
const updated = [...activeSched];
const current = updated[i] || { active: true, shifts: [{ start: '08:00', end: '18:00' }] };
const firstStart = current.shifts?.[0]?.start || '08:00';
const lastEnd = current.shifts?.[current.shifts.length - 1]?.end || '18:00';
updated[i] = {
active: true,
shifts: [
{ start: firstStart, end: '12:00' },
{ start: '13:00', end: lastEnd }
]
};
saveActiveSched(updated);
};
const updateShiftTime = (dayIdx: number, shiftIdx: number, field: 'start' | 'end', val: string) => {
const updated = [...activeSched];
const current = updated[dayIdx];
if (!current) return;
const shifts = [...current.shifts];
shifts[shiftIdx] = { ...shifts[shiftIdx], [field]: val };
updated[dayIdx] = { ...current, shifts };
saveActiveSched(updated);
};
const removeShift = (dayIdx: number, shiftIdx: number) => {
const updated = [...activeSched];
const current = updated[dayIdx];
if (!current) return;
const shifts = current.shifts.filter((_, idx) => idx !== shiftIdx);
updated[dayIdx] = { ...current, shifts: shifts.length > 0 ? shifts : [{ start: '09:00', end: '18:00' }] };
saveActiveSched(updated);
};
const copySegToWeekdays = () => {
const updated = [...activeSched];
const seg = updated[1];
if (!seg) return;
for (let idx = 1; idx <= 5; idx++) {
updated[idx] = JSON.parse(JSON.stringify(seg));
}
saveActiveSched(updated);
};
return ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'].map((day, i) => { return ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'].map((day, i) => {
const item = activeSched[i] || { active: i > 0, start: '09:00', end: '19:00' }; const item = activeSched[i] || { active: i > 0, shifts: [{ start: '09:00', end: '19:00' }] };
const shifts = item.shifts && item.shifts.length > 0 ? item.shifts : [{ start: '09:00', end: '19:00' }];
return ( return (
<div key={day} style={{ <div key={day} style={{
display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap', padding: '16px', borderRadius: 'var(--radius-md)', background: 'var(--bg-primary)',
padding: '14px 0', borderBottom: i < 6 ? '1px solid var(--border-color)' : 'none' border: '1px solid var(--border-color)', marginBottom: '12px'
}}> }}>
<div style={{ width: '100px', fontWeight: 600, fontSize: '14px' }}>{day}</div> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '12px' }}>
<div className={`toggle ${item.active ? 'active' : ''}`} <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
onClick={() => updateSched(i, 'active', !item.active)} /> <div className={`toggle ${item.active ? 'active' : ''}`}
onClick={() => toggleDayActive(i)} />
{item.active ? ( <span style={{ fontWeight: 700, fontSize: '15px' }}>{day}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <span style={{ fontSize: '12px', fontWeight: 600, color: item.active ? 'var(--success)' : 'var(--text-muted)' }}>
<input type="time" className="form-input" style={{ width: '110px', padding: '6px 10px' }} {item.active ? '• Aberto' : '• Fechado'}
value={item.start} onChange={e => updateSched(i, 'start', e.target.value)} /> </span>
<span style={{ color: 'var(--text-muted)' }}>até</span> </div>
<input type="time" className="form-input" style={{ width: '110px', padding: '6px 10px' }}
value={item.end} onChange={e => updateSched(i, 'end', e.target.value)} /> {item.active && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-sm btn-ghost" title="Dividir horário em 2 turnos com intervalo de almoço (12:00 - 13:00)"
onClick={() => addLunchBreak(i)} style={{ fontSize: '12px', gap: '4px', background: 'var(--bg-card)' }}>
🍱 Add Almoço (12h-13h)
</button>
<button type="button" className="btn btn-sm btn-ghost" title="Adicionar um novo turno ou horário de atendimento"
onClick={() => addShift(i)} style={{ fontSize: '12px', gap: '4px', background: 'var(--bg-card)' }}>
<Plus size={14} /> Novo Turno
</button>
{i === 1 && (
<button type="button" className="btn btn-sm btn-ghost" title="Copiar este horário para Segunda a Sexta"
onClick={copySegToWeekdays} style={{ fontSize: '12px', gap: '4px', background: 'var(--bg-card)', color: 'var(--accent)' }}>
<Copy size={13} /> Copiar Seg-Sex
</button>
)}
</div>
)}
</div>
{item.active && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '12px' }}>
{shifts.map((shift, sIdx) => (
<div key={sIdx} style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '12px', color: 'var(--text-muted)', minWidth: '60px' }}>
{shifts.length > 1 ? `Turno ${sIdx + 1}:` : 'Horário:'}
</span>
<input
type="time" className="form-input" style={{ width: '120px', padding: '6px 10px' }}
value={shift.start}
onChange={e => updateShiftTime(i, sIdx, 'start', e.target.value)}
/>
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>até</span>
<input
type="time" className="form-input" style={{ width: '120px', padding: '6px 10px' }}
value={shift.end}
onChange={e => updateShiftTime(i, sIdx, 'end', e.target.value)}
/>
{shifts.length > 1 && (
<button
type="button"
className="btn btn-sm btn-ghost"
title="Excluir este turno"
onClick={() => removeShift(i, sIdx)}
style={{ color: 'var(--danger)', padding: '6px' }}
>
<Trash2 size={15} />
</button>
)}
</div>
))}
</div> </div>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Fechado neste dia</span>
)} )}
</div> </div>
); );