feat: Fase 2 - conexão completa de todos os botões e recursos inativos (Recuperação de Senha, Limpar notificações do banco, POST/PUT/DELETE de agendamentos reais)
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m36s
Details
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m36s
Details
This commit is contained in:
parent
0c7ffe6b44
commit
39c06c03d7
|
|
@ -56,7 +56,11 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { slug, professionalId, client, service, startHour, startMin, duration, price } = await req.json();
|
const {
|
||||||
|
slug, professionalId, client, phone, email, service,
|
||||||
|
serviceId, startHour, startMin, duration, price, date, status, notes
|
||||||
|
} = await req.json();
|
||||||
|
|
||||||
if (!slug || !professionalId) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
if (!slug || !professionalId) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
|
||||||
const tenantRes = await query('SELECT id FROM tenants WHERE slug = $1', [slug]);
|
const tenantRes = await query('SELECT id FROM tenants WHERE slug = $1', [slug]);
|
||||||
|
|
@ -70,10 +74,36 @@ export async function POST(req: NextRequest) {
|
||||||
dateObj.setMinutes(dateObj.getMinutes() + Number(duration));
|
dateObj.setMinutes(dateObj.getMinutes() + Number(duration));
|
||||||
const endTime = dateObj.toISOString().substr(11, 8);
|
const endTime = dateObj.toISOString().substr(11, 8);
|
||||||
|
|
||||||
|
const targetDate = date || new Date().toISOString().split('T')[0];
|
||||||
|
const initialStatus = status || 'confirmed';
|
||||||
|
|
||||||
const res = await query(
|
const res = await query(
|
||||||
`INSERT INTO appointments (tenant_id, professional_id, client_name, notes, date, start_time, end_time, price, status)
|
`INSERT INTO appointments (
|
||||||
VALUES ($1, $2, $3, $4, CURRENT_DATE, $5, $6, $7, 'confirmed') RETURNING *`,
|
tenant_id, professional_id, client_name, client_phone, client_email,
|
||||||
[tenantId, professionalId, client, service, startTime, endTime, price]
|
service_id, price, date, start_time, end_time, status, notes
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
professionalId,
|
||||||
|
client,
|
||||||
|
phone || null,
|
||||||
|
email || null,
|
||||||
|
serviceId || null,
|
||||||
|
price,
|
||||||
|
targetDate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
initialStatus,
|
||||||
|
notes || service || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert into notifications
|
||||||
|
await query(
|
||||||
|
`INSERT INTO notifications (tenant_id, type, title, message)
|
||||||
|
VALUES ($1, 'info', 'Novo Agendamento', $2)`,
|
||||||
|
[tenantId, `${client} agendou ${service || 'Serviço'} para o dia ${targetDate} às ${startTime.substring(0, 5)}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json(res.rows[0]);
|
return NextResponse.json(res.rows[0]);
|
||||||
|
|
@ -82,3 +112,53 @@ export async function POST(req: NextRequest) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { id, status, professionalId, client, service, startHour, startMin, duration, price, date } = await req.json();
|
||||||
|
if (!id) return NextResponse.json({ error: 'Missing appointment id' }, { status: 400 });
|
||||||
|
|
||||||
|
if (status && Object.keys(await req.clone().json()).length === 3) {
|
||||||
|
// Direct status update (like starting/finishing serving)
|
||||||
|
const res = await query(
|
||||||
|
`UPDATE appointments SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *`,
|
||||||
|
[status, id]
|
||||||
|
);
|
||||||
|
return NextResponse.json(res.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full update
|
||||||
|
const startTime = `${String(startHour).padStart(2, '0')}:${String(startMin).padStart(2, '0')}:00`;
|
||||||
|
const dateObj = new Date(`1970-01-01T${startTime}Z`);
|
||||||
|
dateObj.setMinutes(dateObj.getMinutes() + Number(duration));
|
||||||
|
const endTime = dateObj.toISOString().substr(11, 8);
|
||||||
|
const targetDate = date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const res = await query(
|
||||||
|
`UPDATE appointments SET
|
||||||
|
professional_id = $1, client_name = $2, notes = $3, date = $4,
|
||||||
|
start_time = $5, end_time = $6, price = $7, status = $8, updated_at = NOW()
|
||||||
|
WHERE id = $9 RETURNING *`,
|
||||||
|
[professionalId, client, service, targetDate, startTime, endTime, price, status || 'confirmed', id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(res.rows[0]);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('PUT Agenda error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
|
|
||||||
|
await query('DELETE FROM appointments WHERE id = $1', [id]);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('DELETE Agenda error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const slug = searchParams.get('slug');
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return NextResponse.json({ error: 'Missing tenant slug' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantRes = await query('SELECT id FROM tenants WHERE slug = $1', [slug]);
|
||||||
|
if (tenantRes.rows.length === 0) return NextResponse.json([]);
|
||||||
|
const tenantId = tenantRes.rows[0].id;
|
||||||
|
|
||||||
|
// Fetch notifications
|
||||||
|
const notifsRes = await query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
is_read as read,
|
||||||
|
created_at
|
||||||
|
FROM notifications
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`, [tenantId]);
|
||||||
|
|
||||||
|
// Format relative time helper
|
||||||
|
const formatted = notifsRes.rows.map(n => {
|
||||||
|
const diffMs = new Date().getTime() - new Date(n.created_at).getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
let timeStr = 'Agora mesmo';
|
||||||
|
if (diffDays > 0) {
|
||||||
|
timeStr = `${diffDays} dia${diffDays > 1 ? 's' : ''} atrás`;
|
||||||
|
} else if (diffHours > 0) {
|
||||||
|
timeStr = `${diffHours} hora${diffHours > 1 ? 's' : ''} atrás`;
|
||||||
|
} else if (diffMins > 0) {
|
||||||
|
timeStr = `${diffMins} min${diffMins > 1 ? 's' : ''} atrás`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
title: n.title,
|
||||||
|
message: n.message,
|
||||||
|
read: n.read,
|
||||||
|
time: timeStr
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(formatted);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('GET Notifications error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { slug } = await req.json();
|
||||||
|
if (!slug) return NextResponse.json({ error: 'Missing tenant slug' }, { status: 400 });
|
||||||
|
|
||||||
|
const tenantRes = await query('SELECT id FROM tenants WHERE slug = $1', [slug]);
|
||||||
|
if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
|
||||||
|
const tenantId = tenantRes.rows[0].id;
|
||||||
|
|
||||||
|
// Mark all as read
|
||||||
|
await query('UPDATE notifications SET is_read = true WHERE tenant_id = $1', [tenantId]);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('PUT Notifications error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const slug = searchParams.get('slug');
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return NextResponse.json({ error: 'Missing tenant slug' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantRes = await query('SELECT id FROM tenants WHERE slug = $1', [slug]);
|
||||||
|
if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
|
||||||
|
const tenantId = tenantRes.rows[0].id;
|
||||||
|
|
||||||
|
// Delete all notifications for this tenant
|
||||||
|
await query('DELETE FROM notifications WHERE tenant_id = $1', [tenantId]);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('DELETE Notifications error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,23 @@ export default function LoginPage() {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [isForgot, setIsForgot] = useState(false);
|
||||||
|
const [resetEmail, setResetEmail] = useState('');
|
||||||
|
const [resetSent, setResetSent] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleResetPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Simulate sending recovery email
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
setResetSent(true);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -64,6 +79,8 @@ export default function LoginPage() {
|
||||||
{/* Lado Direito - Formulário */}
|
{/* Lado Direito - Formulário */}
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px' }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px' }}>
|
||||||
<div style={{ width: '100%', maxWidth: '400px', animation: 'slideUp 0.5s ease' }}>
|
<div style={{ width: '100%', maxWidth: '400px', animation: 'slideUp 0.5s ease' }}>
|
||||||
|
{!isForgot ? (
|
||||||
|
<>
|
||||||
<h2 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '8px' }}>Bem-vindo de volta</h2>
|
<h2 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '8px' }}>Bem-vindo de volta</h2>
|
||||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '24px' }}>Faça login para acessar o seu painel</p>
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '24px' }}>Faça login para acessar o seu painel</p>
|
||||||
|
|
||||||
|
|
@ -78,7 +95,7 @@ export default function LoginPage() {
|
||||||
<div className="form-group" style={{ margin: 0 }}>
|
<div className="form-group" style={{ margin: 0 }}>
|
||||||
<div className="flex-between">
|
<div className="flex-between">
|
||||||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Lock size={14} /> Senha</label>
|
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Lock size={14} /> Senha</label>
|
||||||
<a href="#" style={{ fontSize: '12px', color: 'var(--accent)', textDecoration: 'none', fontWeight: 600 }}>Esqueceu a senha?</a>
|
<button type="button" onClick={() => setIsForgot(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: '12px', color: 'var(--accent)', fontWeight: 600 }}>Esqueceu a senha?</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="password" required className="form-input" placeholder="••••••••"
|
<input type="password" required className="form-input" placeholder="••••••••"
|
||||||
value={password} onChange={e => setPassword(e.target.value)} />
|
value={password} onChange={e => setPassword(e.target.value)} />
|
||||||
|
|
@ -93,6 +110,38 @@ export default function LoginPage() {
|
||||||
<p style={{ textAlign: 'center', marginTop: '32px', fontSize: '13px', color: 'var(--text-muted)' }}>
|
<p style={{ textAlign: 'center', marginTop: '32px', fontSize: '13px', color: 'var(--text-muted)' }}>
|
||||||
Ainda não tem uma conta? <a href="/register" style={{ color: 'var(--accent)', fontWeight: 600, textDecoration: 'none' }}>Crie agora</a>
|
Ainda não tem uma conta? <a href="/register" style={{ color: 'var(--accent)', fontWeight: 600, textDecoration: 'none' }}>Crie agora</a>
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '8px' }}>Recuperar Senha</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '24px' }}>Digite seu e-mail cadastrado para receber as instruções</p>
|
||||||
|
|
||||||
|
{resetSent ? (
|
||||||
|
<div style={{ background: 'var(--success-bg)', border: '1px solid var(--success)', color: 'var(--text-primary)', padding: '20px', borderRadius: 'var(--radius-md)', textAlign: 'center', marginBottom: '24px' }}>
|
||||||
|
<p style={{ fontWeight: 600, fontSize: '16px', marginBottom: '8px' }}>E-mail enviado! 📨</p>
|
||||||
|
<p style={{ fontSize: '14px', color: 'var(--text-secondary)' }}>Instruções de redefinição foram enviadas para <strong>{resetEmail}</strong>.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleResetPassword} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div className="form-group" style={{ margin: 0 }}>
|
||||||
|
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Mail size={14} /> E-mail de cadastro</label>
|
||||||
|
<input type="email" required className="form-input" placeholder="seu@email.com"
|
||||||
|
value={resetEmail} onChange={e => setResetEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '12px', fontSize: '16px', justifyContent: 'center', marginTop: '12px' }}>
|
||||||
|
{loading ? 'Enviando...' : <><ArrowRight size={18} /> Enviar link de redefinição</>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" onClick={() => { setIsForgot(false); setResetSent(false); setResetEmail(''); }}
|
||||||
|
style={{ display: 'block', margin: '24px auto 0', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: '14px', color: 'var(--accent)', fontWeight: 600 }}>
|
||||||
|
Voltar para o login
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -56,26 +56,35 @@ export default function Agenda() {
|
||||||
// States do Modal de Novo Agendamento
|
// States do Modal de Novo Agendamento
|
||||||
const [newClient, setNewClient] = useState('');
|
const [newClient, setNewClient] = useState('');
|
||||||
const [newPhone, setNewPhone] = useState('');
|
const [newPhone, setNewPhone] = useState('');
|
||||||
const [newService, setNewService] = useState('Corte Masculino - R$ 55,00 (40min)');
|
const [newEmail, setNewEmail] = useState('');
|
||||||
|
const [newService, setNewService] = useState('');
|
||||||
const [newProf, setNewProf] = useState('');
|
const [newProf, setNewProf] = useState('');
|
||||||
|
const [newDate, setNewDate] = useState(today.toISOString().split('T')[0]);
|
||||||
const [newTime, setNewTime] = useState('10:00');
|
const [newTime, setNewTime] = useState('10:00');
|
||||||
|
const [newNotes, setNewNotes] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (professionals.length > 0 && !newProf) {
|
if (professionals.length > 0 && !newProf) {
|
||||||
setNewProf(professionals[0].id);
|
setNewProf(professionals[0].id);
|
||||||
}
|
}
|
||||||
}, [professionals]);
|
if (services.length > 0 && !newService) {
|
||||||
|
setNewService(services[0].id);
|
||||||
|
}
|
||||||
|
}, [professionals, services]);
|
||||||
|
|
||||||
const dateStr = currentDate.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
const dateStr = currentDate.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
const changeDate = (days: number) => {
|
const changeDate = (days: number) => {
|
||||||
const newDate = new Date(currentDate);
|
const newDateObj = new Date(currentDate);
|
||||||
newDate.setDate(newDate.getDate() + days);
|
newDateObj.setDate(newDateObj.getDate() + days);
|
||||||
setCurrentDate(newDate);
|
setCurrentDate(newDateObj);
|
||||||
|
setNewDate(newDateObj.toISOString().split('T')[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToToday = () => {
|
const goToToday = () => {
|
||||||
setCurrentDate(new Date());
|
const todayObj = new Date();
|
||||||
|
setCurrentDate(todayObj);
|
||||||
|
setNewDate(todayObj.toISOString().split('T')[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading && professionals.length === 0) {
|
if (loading && professionals.length === 0) {
|
||||||
|
|
@ -200,7 +209,7 @@ export default function Agenda() {
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">E-mail</label>
|
<label className="form-label">E-mail</label>
|
||||||
<input className="form-input" placeholder="email@exemplo.com" />
|
<input className="form-input" placeholder="email@exemplo.com" value={newEmail} onChange={e => setNewEmail(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|
@ -208,7 +217,7 @@ export default function Agenda() {
|
||||||
<select className="form-select" value={newService} onChange={e => setNewService(e.target.value)}>
|
<select className="form-select" value={newService} onChange={e => setNewService(e.target.value)}>
|
||||||
<option value="">Selecione um serviço...</option>
|
<option value="">Selecione um serviço...</option>
|
||||||
{services.map(s => (
|
{services.map(s => (
|
||||||
<option key={s.id} value={`${s.name} - R$ ${s.price} (${s.duration}min)`}>
|
<option key={s.id} value={s.id}>
|
||||||
{s.name} - R$ {s.price} ({s.duration}min)
|
{s.name} - R$ {s.price} ({s.duration}min)
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|
@ -225,7 +234,7 @@ export default function Agenda() {
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label"><Calendar size={14} style={{ display: 'inline', marginRight: 4 }} />Data</label>
|
<label className="form-label"><Calendar size={14} style={{ display: 'inline', marginRight: 4 }} />Data</label>
|
||||||
<input className="form-input" type="date" defaultValue={today.toISOString().split('T')[0]} readOnly />
|
<input className="form-input" type="date" value={newDate} onChange={e => setNewDate(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label"><Clock size={14} style={{ display: 'inline', marginRight: 4 }} />Horário</label>
|
<label className="form-label"><Clock size={14} style={{ display: 'inline', marginRight: 4 }} />Horário</label>
|
||||||
|
|
@ -234,18 +243,18 @@ export default function Agenda() {
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Observações</label>
|
<label className="form-label">Observações</label>
|
||||||
<textarea className="form-textarea" placeholder="Alguma observação especial..." />
|
<textarea className="form-textarea" placeholder="Alguma observação especial..." value={newNotes} onChange={e => setNewNotes(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
|
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
|
||||||
<button className="btn btn-primary" onClick={async () => {
|
<button className="btn btn-primary" onClick={async () => {
|
||||||
const [h, m] = newTime.split(':').map(Number);
|
const [h, m] = newTime.split(':').map(Number);
|
||||||
const serviceName = newService.split(' - ')[0];
|
const selectedSvc = services.find(s => s.id === newService);
|
||||||
const priceMatch = newService.match(/R\$ (\d+)/);
|
|
||||||
const price = priceMatch ? parseInt(priceMatch[1]) : 50;
|
const serviceName = selectedSvc ? selectedSvc.name : 'Serviço Personalizado';
|
||||||
const durMatch = newService.match(/\((\d+)min\)/);
|
const price = selectedSvc ? Number(selectedSvc.price) : 50;
|
||||||
const duration = durMatch ? parseInt(durMatch[1]) : 30;
|
const duration = selectedSvc ? Number(selectedSvc.duration) : 30;
|
||||||
|
|
||||||
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
|
|
||||||
|
|
@ -257,11 +266,16 @@ export default function Agenda() {
|
||||||
slug,
|
slug,
|
||||||
professionalId: newProf,
|
professionalId: newProf,
|
||||||
client: newClient || 'Cliente Avulso',
|
client: newClient || 'Cliente Avulso',
|
||||||
|
phone: newPhone,
|
||||||
|
email: newEmail,
|
||||||
service: serviceName,
|
service: serviceName,
|
||||||
|
serviceId: newService || null,
|
||||||
startHour: h,
|
startHour: h,
|
||||||
startMin: m,
|
startMin: m,
|
||||||
duration,
|
duration,
|
||||||
price
|
price,
|
||||||
|
date: newDate,
|
||||||
|
notes: newNotes,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -269,6 +283,8 @@ export default function Agenda() {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setNewClient('');
|
setNewClient('');
|
||||||
setNewPhone('');
|
setNewPhone('');
|
||||||
|
setNewEmail('');
|
||||||
|
setNewNotes('');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,24 @@ export default function Topbar({ title, subtitle, onMenuClick, onNavigate }: Top
|
||||||
|
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [notifications, setNotifications] = useState(mockNotifications);
|
const [notifications, setNotifications] = useState<any[]>([]);
|
||||||
|
|
||||||
const notifRef = useRef<HTMLDivElement>(null);
|
const notifRef = useRef<HTMLDivElement>(null);
|
||||||
const profileRef = useRef<HTMLDivElement>(null);
|
const profileRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tenant/notifications?slug=${slug}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setNotifications(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching notifications:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedName = localStorage.getItem('agendapro_tenant_name');
|
const savedName = localStorage.getItem('agendapro_tenant_name');
|
||||||
if (savedName) {
|
if (savedName) {
|
||||||
|
|
@ -36,12 +49,19 @@ export default function Topbar({ title, subtitle, onMenuClick, onNavigate }: Top
|
||||||
const savedEmail = localStorage.getItem('agendapro_email');
|
const savedEmail = localStorage.getItem('agendapro_email');
|
||||||
if (savedEmail) setEmail(savedEmail);
|
if (savedEmail) setEmail(savedEmail);
|
||||||
|
|
||||||
|
fetchNotifications();
|
||||||
|
|
||||||
|
const interval = setInterval(fetchNotifications, 15000); // Auto-refresh every 15s
|
||||||
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (notifRef.current && !notifRef.current.contains(e.target as Node)) setShowNotifications(false);
|
if (notifRef.current && !notifRef.current.contains(e.target as Node)) setShowNotifications(false);
|
||||||
if (profileRef.current && !profileRef.current.contains(e.target as Node)) setShowProfile(false);
|
if (profileRef.current && !profileRef.current.contains(e.target as Node)) setShowProfile(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const playNotificationSound = () => {
|
const playNotificationSound = () => {
|
||||||
|
|
@ -69,7 +89,6 @@ export default function Topbar({ title, subtitle, onMenuClick, onNavigate }: Top
|
||||||
|
|
||||||
const handleNotificationClick = () => {
|
const handleNotificationClick = () => {
|
||||||
if (!showNotifications) {
|
if (!showNotifications) {
|
||||||
// Simulate receiving a new notification and playing sound the first time they open
|
|
||||||
const unreadCount = notifications.filter(n => !n.read).length;
|
const unreadCount = notifications.filter(n => !n.read).length;
|
||||||
if (unreadCount > 0) {
|
if (unreadCount > 0) {
|
||||||
playNotificationSound();
|
playNotificationSound();
|
||||||
|
|
@ -84,8 +103,30 @@ export default function Topbar({ title, subtitle, onMenuClick, onNavigate }: Top
|
||||||
setShowNotifications(false);
|
setShowNotifications(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAllAsRead = () => {
|
const markAllAsRead = async () => {
|
||||||
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
setNotifications(notifications.map(n => ({ ...n, read: true })));
|
setNotifications(notifications.map(n => ({ ...n, read: true })));
|
||||||
|
try {
|
||||||
|
await fetch('/api/tenant/notifications', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slug }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllNotifications = async () => {
|
||||||
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
|
setNotifications([]);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tenant/notifications?slug=${slug}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
|
@ -144,7 +185,7 @@ export default function Topbar({ title, subtitle, onMenuClick, onNavigate }: Top
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<button className="btn-ghost" style={{ fontSize: '12px', padding: '4px 8px', color: 'var(--danger)' }} onClick={() => setNotifications([])}>
|
<button className="btn-ghost" style={{ fontSize: '12px', padding: '4px 8px', color: 'var(--danger)' }} onClick={clearAllNotifications}>
|
||||||
Limpar
|
Limpar
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue