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
});
// Algoritmo dinâmico de fatiamento de grade
const generateSlots = (startTimeStr: string, endTimeStr: string, intervalMin: number) => {
const [profileSchedules, setProfileSchedules] = useState<any>({
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 [sh, sm] = startTimeStr.split(':').map(Number);
const [eh, em] = endTimeStr.split(':').map(Number);
let currentMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
while (currentMinutes < endMinutes) {
const h = Math.floor(currentMinutes / 60);
const m = currentMinutes % 60;
const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
generated.push(timeStr);
currentMinutes += intervalMin;
for (const shift of shifts) {
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;
const endMinutes = eh * 60 + em;
while (currentMinutes < endMinutes) {
const h = Math.floor(currentMinutes / 60);
const m = currentMinutes % 60;
const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
if (!generated.includes(timeStr)) {
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(() => {
@ -74,11 +91,16 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
bookingAdvanceMaxDays: profile.bookingAdvanceMaxDays || 30,
});
// Gerar slots com horários do tenant
const startTime = profile.startTime || '09:00';
const endTime = profile.endTime || '19:00';
const interval = profile.interval || 30;
setSlots(generateSlots(startTime, endTime, interval));
setProfileSchedules({
dailySchedules: profile.dailySchedules || null,
profSchedules: profile.profSchedules || {},
interval: profile.interval || 30,
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
if (profile.themeColor) {
@ -86,7 +108,6 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
document.documentElement.style.setProperty('--accent-glow', `${profile.themeColor}26`);
}
} else {
// Fallback
setSlots(generateSlots('09:00', '19:00', 30));
}
@ -108,6 +129,36 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
fetchData();
}, [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 maxDays = establishment.bookingAdvanceMaxDays || 30;
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,
bookingAdvanceMin: settings.booking_advance_min || 60,
bookingAdvanceMaxDays: settings.booking_advance_max_days || 30,
dailySchedules: settings.daily_schedules || null,
profSchedules: settings.prof_schedules || {},
});
} catch (error: any) {
console.error('GET Profile error:', error);

View File

@ -1,5 +1,5 @@
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 = [
{ id: 'general', label: 'Geral', icon: Store },
@ -67,16 +67,41 @@ export default function Settings() {
// Custom Schedules per day & per professional
const [professionals, setProfessionals] = useState<any[]>([]);
const [selectedProfId, setSelectedProfId] = useState<string>('all');
const [dailySchedules, setDailySchedules] = useState<{ active: boolean; start: string; end: string }[]>([
{ active: false, start: '09:00', end: '18:00' }, // Dom
{ active: true, start: '09:00', end: '19:00' }, // Seg
{ active: true, start: '09:00', end: '19:00' }, // Ter
{ active: true, start: '09:00', end: '19:00' }, // Qua
{ active: true, start: '09:00', end: '19:00' }, // Qui
{ active: true, start: '09:00', end: '19:00' }, // Sex
{ active: true, start: '09:00', end: '17:00' }, // Sáb
const [dailySchedules, setDailySchedules] = useState<{ active: boolean; shifts: { start: string; end: string }[] }>([
{ active: false, shifts: [{ start: '09:00', end: '18:00' }] }, // Dom
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Seg
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Ter
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Qua
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Qui
{ active: true, shifts: [{ start: '08:00', end: '12:00' }, { start: '13:00', end: '18:00' }] }, // Sex
{ 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
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)));
if (data.dailySchedules) {
setDailySchedules(data.dailySchedules);
setDailySchedules(normalizeSchedule(data.dailySchedules));
}
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
@ -384,9 +413,7 @@ export default function Settings() {
? dailySchedules
: (profSchedules[selectedProfId] || dailySchedules);
const updateSched = (index: number, field: 'active' | 'start' | 'end', val: any) => {
const updated = [...activeSched];
updated[index] = { ...updated[index], [field]: val };
const saveActiveSched = (updated: any[]) => {
if (selectedProfId === 'all') {
setDailySchedules(updated);
} 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) => {
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 (
<div key={day} style={{
display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap',
padding: '14px 0', borderBottom: i < 6 ? '1px solid var(--border-color)' : 'none'
padding: '16px', borderRadius: 'var(--radius-md)', background: 'var(--bg-primary)',
border: '1px solid var(--border-color)', marginBottom: '12px'
}}>
<div style={{ width: '100px', fontWeight: 600, fontSize: '14px' }}>{day}</div>
<div className={`toggle ${item.active ? 'active' : ''}`}
onClick={() => updateSched(i, 'active', !item.active)} />
{item.active ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="time" className="form-input" style={{ width: '110px', padding: '6px 10px' }}
value={item.start} onChange={e => updateSched(i, 'start', e.target.value)} />
<span style={{ color: 'var(--text-muted)' }}>até</span>
<input type="time" className="form-input" style={{ width: '110px', padding: '6px 10px' }}
value={item.end} onChange={e => updateSched(i, 'end', e.target.value)} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div className={`toggle ${item.active ? 'active' : ''}`}
onClick={() => toggleDayActive(i)} />
<span style={{ fontWeight: 700, fontSize: '15px' }}>{day}</span>
<span style={{ fontSize: '12px', fontWeight: 600, color: item.active ? 'var(--success)' : 'var(--text-muted)' }}>
{item.active ? '• Aberto' : '• Fechado'}
</span>
</div>
{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>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Fechado neste dia</span>
)}
</div>
);