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
Build and Deploy (Gitea Actions) / build-and-deploy (push) Failing after 44s
Details
This commit is contained in:
parent
8f933b3ba5
commit
8ce7a4340e
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue