feat: menu lateral colapsavel com ocultar texto e 2 novos modos de visao na agenda (Semanal e Lista)
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m39s
Details
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m39s
Details
This commit is contained in:
parent
50edd54561
commit
1876172d78
|
|
@ -69,12 +69,24 @@ body {
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transition: transform 0.3s ease;
|
transition: width 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 24px 20px;
|
padding: 20px 16px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-header {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
|
|
@ -84,77 +96,41 @@ body {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.desktop-collapse-btn {
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background: var(--gradient-primary);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 20px;
|
|
||||||
color: white;
|
|
||||||
box-shadow: var(--shadow-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 800;
|
|
||||||
background: var(--gradient-primary);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav {
|
|
||||||
flex: 1;
|
|
||||||
padding: 16px 12px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-section-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
padding: 16px 12px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.desktop-collapse-btn:hover {
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.mobile-close-btn {
|
||||||
background: var(--accent-glow);
|
display: none;
|
||||||
color: var(--accent);
|
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item svg { width: 20px; height: 20px; flex-shrink: 0; }
|
.nav-section-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar.collapsed .nav-item {
|
||||||
padding: 16px;
|
justify-content: center;
|
||||||
border-top: 1px solid var(--border-color);
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .nav-item svg {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .plan-badge {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-footer {
|
||||||
|
padding: 16px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-badge {
|
.plan-badge {
|
||||||
|
|
@ -174,6 +150,11 @@ body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 260px;
|
margin-left: 260px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
transition: margin-left 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content.collapsed {
|
||||||
|
margin-left: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
|
@ -997,3 +978,41 @@ tr:hover td { background: var(--bg-card-hover); }
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === VIEW MODE SELECTOR === */
|
||||||
|
.view-mode-selector {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 3px;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 10px rgba(108, 99, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,24 @@ export default function Home() {
|
||||||
const [currentPlan, setCurrentPlan] = useState('Professional');
|
const [currentPlan, setCurrentPlan] = useState('Professional');
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
const [loadingSubscription, setLoadingSubscription] = useState(true);
|
const [loadingSubscription, setLoadingSubscription] = useState(true);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedCollapse = localStorage.getItem('agendapro_sidebar_collapsed');
|
||||||
|
if (savedCollapse === 'true') {
|
||||||
|
setIsSidebarCollapsed(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSidebarCollapse = () => {
|
||||||
|
setIsSidebarCollapsed(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem('agendapro_sidebar_collapsed', String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const checkSubscriptionStatus = async () => {
|
const checkSubscriptionStatus = async () => {
|
||||||
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
|
|
||||||
|
|
@ -130,7 +146,7 @@ export default function Home() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className={`app-layout ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onNavigate={setCurrentPage}
|
onNavigate={setCurrentPage}
|
||||||
|
|
@ -138,8 +154,10 @@ export default function Home() {
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
currentPlan={currentPlan}
|
currentPlan={currentPlan}
|
||||||
isLocked={isLocked}
|
isLocked={isLocked}
|
||||||
|
isCollapsed={isSidebarCollapsed}
|
||||||
|
onToggleCollapse={toggleSidebarCollapse}
|
||||||
/>
|
/>
|
||||||
<main className="main-content">
|
<main className={`main-content ${isSidebarCollapsed ? 'collapsed' : ''}`}>
|
||||||
<Topbar
|
<Topbar
|
||||||
title={pages[currentPage]?.title || 'Dashboard'}
|
title={pages[currentPage]?.title || 'Dashboard'}
|
||||||
subtitle={pages[currentPage]?.subtitle || ''}
|
subtitle={pages[currentPage]?.subtitle || ''}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Plus, Calendar, Clock, User, Phone, CheckCircle, AlertCircle, X, Mail } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Plus, Calendar, Clock, User, Phone, CheckCircle, AlertCircle, X, Mail, ListFilter, CheckCircle2, XCircle, MessageCircle } from 'lucide-react';
|
||||||
import { showToast } from '@/lib/toast';
|
import { showToast } from '@/lib/toast';
|
||||||
|
|
||||||
const hours = Array.from({ length: 12 }, (_, i) => i + 8);
|
const hours = Array.from({ length: 12 }, (_, i) => i + 8);
|
||||||
|
|
@ -16,15 +16,24 @@ const statusBorders: Record<string, string> = {
|
||||||
completed: 'var(--success)',
|
completed: 'var(--success)',
|
||||||
cancelled: 'var(--danger)',
|
cancelled: 'var(--danger)',
|
||||||
};
|
};
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
confirmed: 'Confirmado',
|
||||||
|
pending: 'Pendente',
|
||||||
|
completed: 'Concluído',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
};
|
||||||
|
|
||||||
export default function Agenda() {
|
export default function Agenda() {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [currentDate, setCurrentDate] = useState(today);
|
const [currentDate, setCurrentDate] = useState(today);
|
||||||
|
const [viewMode, setViewMode] = useState<'day' | 'week' | 'list'>('day');
|
||||||
const [appointments, setAppointments] = useState<any[]>([]);
|
const [appointments, setAppointments] = useState<any[]>([]);
|
||||||
const [professionals, setProfessionals] = useState<any[]>([]);
|
const [professionals, setProfessionals] = useState<any[]>([]);
|
||||||
const [services, setServices] = useState<any[]>([]);
|
const [services, setServices] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filterProf, setFilterProf] = useState<string>('all');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
|
|
||||||
const fetchAgenda = async () => {
|
const fetchAgenda = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,18 +98,71 @@ export default function Agenda() {
|
||||||
setNewDate(todayObj.toISOString().split('T')[0]);
|
setNewDate(todayObj.toISOString().split('T')[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getWeekDays = (date: Date) => {
|
||||||
|
const start = new Date(date);
|
||||||
|
const dayOfWeek = start.getDay();
|
||||||
|
start.setDate(start.getDate() - dayOfWeek);
|
||||||
|
const days = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
days.push(d);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekDays = getWeekDays(currentDate);
|
||||||
|
|
||||||
if (loading && professionals.length === 0) {
|
if (loading && professionals.length === 0) {
|
||||||
return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando agenda...</div>;
|
return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando agenda...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="slide-up">
|
<div className="slide-up">
|
||||||
<div className="flex-between mb-6">
|
{/* BARRA SUPERIOR DE NAVEGAÇÃO E SELETOR DE MODO */}
|
||||||
|
<div className="flex-between mb-6" style={{ flexWrap: 'wrap', gap: '12px' }}>
|
||||||
<div className="flex-gap">
|
<div className="flex-gap">
|
||||||
<button className="btn btn-ghost btn-icon" onClick={() => changeDate(-1)}><ChevronLeft size={20} /></button>
|
<button className="btn btn-ghost btn-icon" onClick={() => changeDate(viewMode === 'week' ? -7 : -1)}>
|
||||||
<h2 style={{ fontSize: '16px', fontWeight: 600, textTransform: 'capitalize' }}>{dateStr}</h2>
|
<ChevronLeft size={20} />
|
||||||
<button className="btn btn-ghost btn-icon" onClick={() => changeDate(1)}><ChevronRight size={20} /></button>
|
</button>
|
||||||
|
<h2 style={{ fontSize: '16px', fontWeight: 600, textTransform: 'capitalize' }}>
|
||||||
|
{viewMode === 'week'
|
||||||
|
? `Semana de ${weekDays[0].getDate()}/${weekDays[0].getMonth()+1} a ${weekDays[6].getDate()}/${weekDays[6].getMonth()+1}`
|
||||||
|
: dateStr}
|
||||||
|
</h2>
|
||||||
|
<button className="btn btn-ghost btn-icon" onClick={() => changeDate(viewMode === 'week' ? 7 : 1)}>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SELETOR DE MODO DE VISÃO */}
|
||||||
|
<div className="view-mode-selector">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-mode-btn ${viewMode === 'day' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewMode('day')}
|
||||||
|
title="Visão Diária por Horários (Grade)"
|
||||||
|
>
|
||||||
|
<Clock size={14} /> Dia
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-mode-btn ${viewMode === 'week' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewMode('week')}
|
||||||
|
title="Visão Semanal de 7 Dias"
|
||||||
|
>
|
||||||
|
<Calendar size={14} /> Semana
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-mode-btn ${viewMode === 'list' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
title="Visão em Lista Detalhada"
|
||||||
|
>
|
||||||
|
<ListFilter size={14} /> Lista
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-gap">
|
<div className="flex-gap">
|
||||||
<button className="btn btn-secondary btn-sm" onClick={goToToday}>Hoje</button>
|
<button className="btn btn-secondary btn-sm" onClick={goToToday}>Hoje</button>
|
||||||
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
|
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
|
||||||
|
|
@ -115,81 +177,224 @@ export default function Agenda() {
|
||||||
<p>Você precisa adicionar um profissional na aba Equipe antes de agendar clientes.</p>
|
<p>Você precisa adicionar um profissional na aba Equipe antes de agendar clientes.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
<>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: `70px repeat(${professionals.length}, 1fr)`, borderBottom: '1px solid var(--border-color)' }}>
|
{/* MODO 1: GRADE DIÁRIA POR HORA (MANTIDA ORIGINAL) */}
|
||||||
<div style={{ padding: '14px', borderRight: '1px solid var(--border-color)' }} />
|
{viewMode === 'day' && (
|
||||||
{professionals.map(p => (
|
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||||
<div key={p.id} style={{
|
<div style={{ display: 'grid', gridTemplateColumns: `70px repeat(${professionals.length}, 1fr)`, borderBottom: '1px solid var(--border-color)' }}>
|
||||||
padding: '14px 16px', textAlign: 'center', borderRight: '1px solid var(--border-color)',
|
<div style={{ padding: '14px', borderRight: '1px solid var(--border-color)' }} />
|
||||||
background: 'var(--bg-secondary)', fontWeight: 600, fontSize: '14px'
|
{professionals.map(p => (
|
||||||
}}>
|
<div key={p.id} style={{
|
||||||
<div className="flex-center" style={{ gap: '8px' }}>
|
padding: '14px 16px', textAlign: 'center', borderRight: '1px solid var(--border-color)',
|
||||||
<div style={{ width: 10, height: 10, borderRadius: '50%', background: p.color }} />
|
background: 'var(--bg-secondary)', fontWeight: 600, fontSize: '14px'
|
||||||
{p.name}
|
}}>
|
||||||
</div>
|
<div className="flex-center" style={{ gap: '8px' }}>
|
||||||
</div>
|
<div style={{ width: 10, height: 10, borderRadius: '50%', background: p.color }} />
|
||||||
))}
|
{p.name}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: `70px repeat(${professionals.length}, 1fr)`, position: 'relative' }}>
|
|
||||||
<div>
|
|
||||||
{hours.map(h => (
|
|
||||||
<div key={h} style={{
|
|
||||||
height: '120px', display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
|
|
||||||
paddingRight: '12px', paddingTop: '2px', fontSize: '12px', color: 'var(--text-muted)',
|
|
||||||
borderRight: '1px solid var(--border-color)', borderBottom: '1px solid var(--border-color)'
|
|
||||||
}}>
|
|
||||||
{String(h).padStart(2, '0')}:00
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{professionals.map(prof => {
|
|
||||||
const visibleAppointments = appointments.filter(a => a.professionalId === prof.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={prof.id} style={{ position: 'relative', borderRight: '1px solid var(--border-color)' }}>
|
|
||||||
{hours.map(h => (
|
|
||||||
<div key={h} style={{ height: '120px', borderBottom: '1px solid var(--border-color)' }} />
|
|
||||||
))}
|
|
||||||
{visibleAppointments.map(apt => {
|
|
||||||
const scale = 2; // 2px per minute
|
|
||||||
const top = (apt.startHour - 8) * 60 * scale + (apt.startMin * scale);
|
|
||||||
const height = apt.duration * scale;
|
|
||||||
const isSmall = apt.duration <= 20;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={apt.id} style={{
|
|
||||||
position: 'absolute', top: `${top}px`, left: '4px', right: '4px',
|
|
||||||
height: `${height}px`, borderRadius: 'var(--radius-sm)',
|
|
||||||
background: statusColors[apt.status] || statusColors.confirmed,
|
|
||||||
borderLeft: `3px solid ${statusBorders[apt.status] || statusBorders.confirmed}`,
|
|
||||||
padding: isSmall ? '4px 8px' : '6px 10px', cursor: 'pointer', overflow: 'hidden',
|
|
||||||
transition: 'all 0.2s', zIndex: 2, display: 'flex', flexDirection: isSmall ? 'row' : 'column',
|
|
||||||
alignItems: isSmall ? 'center' : 'flex-start', gap: isSmall ? '8px' : '0'
|
|
||||||
}}
|
|
||||||
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'; }}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{apt.client}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '11px', opacity: 0.8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{isSmall ? `- ${apt.service}` : `${apt.service} • R$ ${apt.price}`}
|
|
||||||
</div>
|
|
||||||
{!isSmall && height >= 60 && (
|
|
||||||
<div style={{ fontSize: '10px', opacity: 0.7, marginTop: '2px' }}>
|
|
||||||
{String(apt.startHour).padStart(2, '0')}:{String(apt.startMin).padStart(2, '0')} - {apt.duration}min
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: `70px repeat(${professionals.length}, 1fr)`, position: 'relative' }}>
|
||||||
|
<div>
|
||||||
|
{hours.map(h => (
|
||||||
|
<div key={h} style={{
|
||||||
|
height: '120px', display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
|
||||||
|
paddingRight: '12px', paddingTop: '2px', fontSize: '12px', color: 'var(--text-muted)',
|
||||||
|
borderRight: '1px solid var(--border-color)', borderBottom: '1px solid var(--border-color)'
|
||||||
|
}}>
|
||||||
|
{String(h).padStart(2, '0')}:00
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{professionals.map(prof => {
|
||||||
|
const visibleAppointments = appointments.filter(a => a.professionalId === prof.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={prof.id} style={{ position: 'relative', borderRight: '1px solid var(--border-color)' }}>
|
||||||
|
{hours.map(h => (
|
||||||
|
<div key={h} style={{ height: '120px', borderBottom: '1px solid var(--border-color)' }} />
|
||||||
|
))}
|
||||||
|
{visibleAppointments.map(apt => {
|
||||||
|
const scale = 2;
|
||||||
|
const top = (apt.startHour - 8) * 60 * scale + (apt.startMin * scale);
|
||||||
|
const height = apt.duration * scale;
|
||||||
|
const isSmall = apt.duration <= 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={apt.id} style={{
|
||||||
|
position: 'absolute', top: `${top}px`, left: '4px', right: '4px',
|
||||||
|
height: `${height}px`, borderRadius: 'var(--radius-sm)',
|
||||||
|
background: statusColors[apt.status] || statusColors.confirmed,
|
||||||
|
borderLeft: `3px solid ${statusBorders[apt.status] || statusBorders.confirmed}`,
|
||||||
|
padding: isSmall ? '4px 8px' : '6px 10px', cursor: 'pointer', overflow: 'hidden',
|
||||||
|
transition: 'all 0.2s', zIndex: 2, display: 'flex', flexDirection: isSmall ? 'row' : 'column',
|
||||||
|
alignItems: isSmall ? 'center' : 'flex-start', gap: isSmall ? '8px' : '0'
|
||||||
|
}}
|
||||||
|
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'; }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{apt.client}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', opacity: 0.8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{isSmall ? `- ${apt.service}` : `${apt.service} • R$ ${apt.price}`}
|
||||||
|
</div>
|
||||||
|
{!isSmall && height >= 60 && (
|
||||||
|
<div style={{ fontSize: '10px', opacity: 0.7, marginTop: '2px' }}>
|
||||||
|
{String(apt.startHour).padStart(2, '0')}:{String(apt.startMin).padStart(2, '0')} - {apt.duration}min
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
|
||||||
</div>
|
{/* MODO 2: VISÃO SEMANAL DE 7 DIAS */}
|
||||||
</div>
|
{viewMode === 'week' && (
|
||||||
|
<div className="card" style={{ padding: 0, overflowX: 'auto' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(140px, 1fr))', borderBottom: '1px solid var(--border-color)' }}>
|
||||||
|
{weekDays.map(day => {
|
||||||
|
const isTodayDay = day.toDateString() === today.toDateString();
|
||||||
|
const isSelectedDay = day.toDateString() === currentDate.toDateString();
|
||||||
|
const dayName = day.toLocaleDateString('pt-BR', { weekday: 'short' });
|
||||||
|
const dayNum = day.getDate();
|
||||||
|
const dayMonth = day.toLocaleDateString('pt-BR', { month: 'short' });
|
||||||
|
|
||||||
|
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',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '11px', textTransform: 'uppercase', color: 'var(--text-muted)' }}>{dayName}</div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 800, color: isTodayDay ? 'var(--accent)' : 'var(--text-primary)' }}>
|
||||||
|
{dayNum} <span style={{ fontSize: '11px', fontWeight: 400 }}>{dayMonth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(140px, 1fr))', minHeight: '380px' }}>
|
||||||
|
{weekDays.map(day => {
|
||||||
|
const dayStr = day.toISOString().split('T')[0];
|
||||||
|
// Para homologação estática, os agendamentos do dia ativo usam a data selecionada
|
||||||
|
const isSelected = day.toDateString() === currentDate.toDateString();
|
||||||
|
const dayApts = isSelected ? appointments : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={day.toISOString()} style={{ padding: '10px', borderRight: '1px solid var(--border-color)', background: 'var(--bg-primary)' }}>
|
||||||
|
{dayApts.length === 0 ? (
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', textAlign: 'center', marginTop: '20px' }}>
|
||||||
|
Sem horários
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{dayApts.map(apt => {
|
||||||
|
const prof = professionals.find(p => p.id === apt.professionalId);
|
||||||
|
return (
|
||||||
|
<div key={apt.id} style={{
|
||||||
|
padding: '8px', borderRadius: 'var(--radius-sm)',
|
||||||
|
background: statusColors[apt.status] || 'var(--bg-secondary)',
|
||||||
|
borderLeft: `3px solid ${statusBorders[apt.status] || 'var(--accent)'}`,
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
<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>
|
||||||
|
{prof && (
|
||||||
|
<div style={{ fontSize: '10px', color: prof.color, marginTop: '4px', fontWeight: 600 }}>
|
||||||
|
● {prof.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MODO 3: VISÃO EM LISTA DETALHADA */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{/* Filtros da Lista */}
|
||||||
|
<div className="card" style={{ padding: '16px', display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-secondary)' }}>Filtrar Lista:</span>
|
||||||
|
<select className="form-select" style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }} value={filterProf} onChange={e => setFilterProf(e.target.value)}>
|
||||||
|
<option value="all">Todos os Profissionais</option>
|
||||||
|
{professionals.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className="form-select" style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||||
|
<option value="all">Todos os Status</option>
|
||||||
|
<option value="confirmed">Confirmados</option>
|
||||||
|
<option value="pending">Pendentes</option>
|
||||||
|
<option value="completed">Concluídos</option>
|
||||||
|
<option value="cancelled">Cancelados</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feed de Cards da Lista */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
|
||||||
|
{appointments
|
||||||
|
.filter(a => filterProf === 'all' || a.professionalId === filterProf)
|
||||||
|
.filter(a => filterStatus === 'all' || a.status === filterStatus)
|
||||||
|
.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 className="flex-between mb-2">
|
||||||
|
<span className="badge" style={{ background: statusColors[apt.status], color: statusBorders[apt.status] }}>
|
||||||
|
{statusLabels[apt.status] || apt.status}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Clock size={12} /> {String(apt.startHour).padStart(2, '0')}:{String(apt.startMin).padStart(2, '0')} ({apt.duration} min)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 style={{ fontSize: '16px', fontWeight: 700, margin: '8px 0 4px' }}>{apt.client}</h4>
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '12px' }}>
|
||||||
|
{apt.service} • <strong style={{ color: 'var(--text-primary)' }}>R$ {apt.price}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex-between" style={{ borderTop: '1px solid var(--border-color)', paddingTop: '10px', fontSize: '12px' }}>
|
||||||
|
{prof ? (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: prof.color }} /> {prof.name}
|
||||||
|
</span>
|
||||||
|
) : <span />}
|
||||||
|
{apt.phone && (
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/55${apt.phone.replace(/\D/g, '')}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: '#25D366', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
<MessageCircle size={14} /> WhatsApp
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Calendar, Users, Scissors, LayoutDashboard, Settings, CreditCard, UserCheck, X, Crown, LogOut } from 'lucide-react';
|
import { Calendar, Users, Scissors, LayoutDashboard, Settings, CreditCard, UserCheck, X, Crown, LogOut, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|
@ -9,6 +9,8 @@ interface SidebarProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
currentPlan?: string;
|
currentPlan?: string;
|
||||||
isLocked?: boolean;
|
isLocked?: boolean;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
|
@ -24,7 +26,7 @@ const navItems = [
|
||||||
{ id: 'subscription', label: 'Assinatura', icon: CreditCard },
|
{ id: 'subscription', label: 'Assinatura', icon: CreditCard },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, currentPlan, isLocked = false }: SidebarProps) {
|
export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, currentPlan, isLocked = false, isCollapsed = false, onToggleCollapse }: SidebarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tenantName, setTenantName] = useState('AgendaPRO');
|
const [tenantName, setTenantName] = useState('AgendaPRO');
|
||||||
|
|
||||||
|
|
@ -39,7 +41,6 @@ export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, curr
|
||||||
localStorage.removeItem('agendapro_token');
|
localStorage.removeItem('agendapro_token');
|
||||||
localStorage.removeItem('agendapro_tenant_name');
|
localStorage.removeItem('agendapro_tenant_name');
|
||||||
localStorage.removeItem('agendapro_plan');
|
localStorage.removeItem('agendapro_plan');
|
||||||
// Limpar cookies de sessão
|
|
||||||
document.cookie = "agendapro_new_tenant=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
document.cookie = "agendapro_new_tenant=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||||
document.cookie = "agendapro_plan=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
document.cookie = "agendapro_plan=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
|
|
@ -48,13 +49,23 @@ export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, curr
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpen && <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 99 }} onClick={onClose} />}
|
{isOpen && <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 99 }} onClick={onClose} />}
|
||||||
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
|
<aside className={`sidebar ${isOpen ? 'open' : ''} ${isCollapsed ? 'collapsed' : ''}`}>
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<div className="sidebar-logo">
|
<div className="sidebar-logo">
|
||||||
<div className="logo-icon">✂️</div>
|
<div className="logo-icon">✂️</div>
|
||||||
<span className="logo-text" style={{ fontSize: '18px', fontWeight: 800 }}>{tenantName}</span>
|
{!isCollapsed && <span className="logo-text" style={{ fontSize: '18px', fontWeight: 800 }}>{tenantName}</span>}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-ghost btn-icon" onClick={onClose} style={{ display: 'none' }}>
|
{onToggleCollapse && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost btn-icon desktop-collapse-btn"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
title={isCollapsed ? "Expandir Menu" : "Ocultar Texto"}
|
||||||
|
>
|
||||||
|
{isCollapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn-ghost btn-icon mobile-close-btn" onClick={onClose}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -62,6 +73,7 @@ export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, curr
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{navItems.map((item, i) => {
|
{navItems.map((item, i) => {
|
||||||
if ('section' in item && item.section) {
|
if ('section' in item && item.section) {
|
||||||
|
if (isCollapsed) return <div key={`s-${i}`} className="nav-section-divider" title={item.section} />;
|
||||||
return <div key={`s-${i}`} className="nav-section-title">{item.section}</div>;
|
return <div key={`s-${i}`} className="nav-section-title">{item.section}</div>;
|
||||||
}
|
}
|
||||||
if ('id' in item && item.id) {
|
if ('id' in item && item.id) {
|
||||||
|
|
@ -72,6 +84,7 @@ export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, curr
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`nav-item ${currentPage === item.id ? 'active' : ''}`}
|
className={`nav-item ${currentPage === item.id ? 'active' : ''}`}
|
||||||
disabled={itemLocked}
|
disabled={itemLocked}
|
||||||
|
title={isCollapsed ? item.label : undefined}
|
||||||
style={itemLocked ? { opacity: 0.4, cursor: 'not-allowed' } : {}}
|
style={itemLocked ? { opacity: 0.4, cursor: 'not-allowed' } : {}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!itemLocked) {
|
if (!itemLocked) {
|
||||||
|
|
@ -81,8 +94,8 @@ export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, curr
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
{item.label}
|
{!isCollapsed && <span className="nav-item-text">{item.label}</span>}
|
||||||
{itemLocked && <span style={{ marginLeft: 'auto', fontSize: '12px' }}>🔒</span>}
|
{itemLocked && !isCollapsed && <span style={{ marginLeft: 'auto', fontSize: '12px' }}>🔒</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -90,13 +103,20 @@ export default function Sidebar({ currentPage, onNavigate, isOpen, onClose, curr
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer" style={{ padding: '20px', borderTop: '1px solid var(--border-color)', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div className="sidebar-footer">
|
||||||
<div className="plan-badge">
|
<div className="plan-badge" title={`Plano ${currentPlan || 'Professional'}`}>
|
||||||
<Crown size={16} />
|
<Crown size={16} />
|
||||||
<span>Plano {currentPlan || 'Professional'}</span>
|
{!isCollapsed && <span>Plano {currentPlan || 'Professional'}</span>}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-secondary" style={{ width: '100%', justifyContent: 'center' }} onClick={handleLogout}>
|
<button
|
||||||
<LogOut size={16} /> Sair do Sistema
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
|
onClick={handleLogout}
|
||||||
|
title={isCollapsed ? "Sair do Sistema" : undefined}
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
{!isCollapsed && <span>Sair do Sistema</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue