Compare commits
5 Commits
3bb46cff1a
...
7959e18210
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7959e18210 | ||
|
|
802558510f | ||
|
|
df5f60e540 | ||
|
|
b048c963dd | ||
|
|
b986eafb98 |
@@ -61,32 +61,6 @@ const verifyToken = (req, res, next) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const sseClients = new Set();
|
|
||||||
|
|
||||||
// SSE Endpoint for real-time updates
|
|
||||||
app.get('/api/stream', (req, res) => {
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
|
||||||
res.setHeader('X-Accel-Buffering', 'no'); // Bypass Nginx Proxy Manager buffering
|
|
||||||
res.flushHeaders();
|
|
||||||
|
|
||||||
// Send initial connection event
|
|
||||||
res.write('data: {"connected": true}\n\n');
|
|
||||||
|
|
||||||
// Keep connection alive through Nginx/Cloudflare
|
|
||||||
const keepAlive = setInterval(() => {
|
|
||||||
res.write(':\n\n'); // SSE comment to prevent idle timeout
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
sseClients.add(res);
|
|
||||||
|
|
||||||
req.on('close', () => {
|
|
||||||
clearInterval(keepAlive);
|
|
||||||
sseClients.delete(res);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Login Endpoint
|
// Login Endpoint
|
||||||
app.post('/api/login', (req, res) => {
|
app.post('/api/login', (req, res) => {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
@@ -169,12 +143,6 @@ app.post('/api/data', authenticateAPIKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|
||||||
// Broadcast update to all connected SSE clients
|
|
||||||
const broadcastMessage = `data: {"type": "update"}\n\n`;
|
|
||||||
for (const clientRes of sseClients) {
|
|
||||||
clientRes.write(broadcastMessage);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
console.error("Database insert error:", error);
|
console.error("Database insert error:", error);
|
||||||
|
|||||||
@@ -1,16 +1,45 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { Calendar } from 'lucide-react';
|
import { Calendar, RefreshCw, ChevronDown } from 'lucide-react';
|
||||||
import type { DateRange } from '../types';
|
import type { DateRange } from '../types';
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
onChange: (range: DateRange) => void;
|
onChange: (range: DateRange) => void;
|
||||||
|
refreshInterval?: number;
|
||||||
|
setRefreshInterval?: (interval: number) => void;
|
||||||
|
onManualRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }) => {
|
const PRESETS = [
|
||||||
|
{ label: 'Hoje', getRange: () => { const d = new Date(); d.setHours(0,0,0,0); return { start: d, end: new Date() }; } },
|
||||||
|
{ label: 'Ontem', getRange: () => { const d = new Date(); d.setDate(d.getDate() - 1); d.setHours(0,0,0,0); const end = new Date(d); end.setHours(23,59,59,999); return { start: d, end }; } },
|
||||||
|
{ label: 'Últimos 7 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 7); start.setHours(0,0,0,0); return { start, end }; } },
|
||||||
|
{ label: 'Últimos 30 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 30); start.setHours(0,0,0,0); return { start, end }; } },
|
||||||
|
{ label: 'Este Mês', getRange: () => { const end = new Date(); const start = new Date(end.getFullYear(), end.getMonth(), 1); return { start, end }; } },
|
||||||
|
{ label: 'Mês Passado', getRange: () => { const d = new Date(); const start = new Date(d.getFullYear(), d.getMonth() - 1, 1); const end = new Date(d.getFullYear(), d.getMonth(), 0, 23, 59, 59, 999); return { start, end }; } },
|
||||||
|
{ label: 'Últimos 90 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 90); start.setHours(0,0,0,0); return { start, end }; } },
|
||||||
|
{ label: 'Este Ano', getRange: () => { const end = new Date(); const start = new Date(end.getFullYear(), 0, 1); return { start, end }; } },
|
||||||
|
{ label: 'Todo o Período', getRange: () => { const end = new Date(); const start = new Date(2000, 0, 1); return { start, end }; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFRESH_OPTIONS = [
|
||||||
|
{ label: 'Desligado', value: 0 },
|
||||||
|
{ label: '5s', value: 5000 },
|
||||||
|
{ label: '10s', value: 10000 },
|
||||||
|
{ label: '30s', value: 30000 },
|
||||||
|
{ label: '1m', value: 60000 },
|
||||||
|
{ label: '5m', value: 300000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange, refreshInterval, setRefreshInterval, onManualRefresh }) => {
|
||||||
|
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||||
const startRef = useRef<HTMLInputElement>(null);
|
const startRef = useRef<HTMLInputElement>(null);
|
||||||
const endRef = useRef<HTMLInputElement>(null);
|
const endRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const formatShortDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
const formatDateForInput = (date: Date) => {
|
const formatDateForInput = (date: Date) => {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
@@ -18,10 +47,6 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatShortDate = (date: Date) => {
|
|
||||||
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseLocalDate = (value: string) => {
|
const parseLocalDate = (value: string) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const [year, month, day] = value.split('-');
|
const [year, month, day] = value.split('-');
|
||||||
@@ -58,38 +83,103 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 bg-dark-card border border-dark-border px-3 py-2 rounded-xl shadow-sm hover:border-brand-primary transition-colors">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Date Presets Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsPresetOpen(!isPresetOpen)}
|
||||||
|
className="flex items-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||||
|
>
|
||||||
<Calendar size={16} className="text-dark-muted shrink-0" />
|
<Calendar size={16} className="text-dark-muted shrink-0" />
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-dark-text">
|
<span>{formatShortDate(dateRange.start)} - {formatShortDate(dateRange.end)}</span>
|
||||||
|
<ChevronDown size={14} className="text-dark-muted ml-1" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isPresetOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setIsPresetOpen(false)}></div>
|
||||||
|
<div className="absolute right-0 mt-2 w-56 bg-dark-card border border-dark-border rounded-xl shadow-xl z-20 py-2 max-h-[32rem] overflow-y-auto">
|
||||||
|
<div className="px-4 pb-3 pt-1 border-b border-dark-border mb-2 flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-bold text-dark-muted uppercase tracking-widest">Período Customizado</span>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium text-dark-text w-6">De:</span>
|
||||||
<div
|
<div
|
||||||
className="relative cursor-pointer hover:text-brand-primary transition-colors"
|
className="relative bg-dark-input border border-dark-border text-dark-text text-xs rounded-lg px-2 py-1 focus-within:border-brand-primary w-full cursor-pointer overflow-hidden flex items-center h-6"
|
||||||
onClick={() => openPicker(startRef)}
|
onClick={() => openPicker(startRef)}
|
||||||
>
|
>
|
||||||
{formatShortDate(dateRange.start)}
|
<span className="w-full text-center">{formatShortDate(dateRange.start)}</span>
|
||||||
<input
|
<input
|
||||||
ref={startRef}
|
ref={startRef}
|
||||||
type="date"
|
type="date"
|
||||||
value={formatDateForInput(dateRange.start)}
|
value={formatDateForInput(dateRange.start)}
|
||||||
onChange={handleStartChange}
|
onChange={handleStartChange}
|
||||||
className="absolute opacity-0 w-0 h-0 overflow-hidden"
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-dark-muted font-normal text-xs">até</span>
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium text-dark-text w-6">Até:</span>
|
||||||
<div
|
<div
|
||||||
className="relative cursor-pointer hover:text-brand-primary transition-colors"
|
className="relative bg-dark-input border border-dark-border text-dark-text text-xs rounded-lg px-2 py-1 focus-within:border-brand-primary w-full cursor-pointer overflow-hidden flex items-center h-6"
|
||||||
onClick={() => openPicker(endRef)}
|
onClick={() => openPicker(endRef)}
|
||||||
>
|
>
|
||||||
{formatShortDate(dateRange.end)}
|
<span className="w-full text-center">{formatShortDate(dateRange.end)}</span>
|
||||||
<input
|
<input
|
||||||
ref={endRef}
|
ref={endRef}
|
||||||
type="date"
|
type="date"
|
||||||
value={formatDateForInput(dateRange.end)}
|
value={formatDateForInput(dateRange.end)}
|
||||||
onChange={handleEndChange}
|
onChange={handleEndChange}
|
||||||
className="absolute opacity-0 w-0 h-0 overflow-hidden"
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="px-4 text-xs font-bold text-dark-muted uppercase tracking-widest mb-1 block">Atalhos</span>
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(preset.getRange());
|
||||||
|
setIsPresetOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-dark-muted hover:text-dark-text hover:bg-dark-input transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Refresh Dropdown */}
|
||||||
|
{setRefreshInterval && onManualRefresh && (
|
||||||
|
<div className="flex items-center bg-dark-card border border-dark-border rounded-xl shadow-sm overflow-hidden hover:border-brand-primary transition-colors">
|
||||||
|
<button
|
||||||
|
onClick={onManualRefresh}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium text-dark-text hover:text-brand-primary hover:bg-dark-input transition-colors border-r border-dark-border cursor-pointer"
|
||||||
|
title="Atualizar Agora"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className="text-brand-primary" />
|
||||||
|
<span>Atualizar</span>
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={refreshInterval}
|
||||||
|
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||||
|
className="appearance-none bg-transparent text-sm font-bold text-dark-muted pl-4 pr-10 py-2.5 focus:outline-none cursor-pointer relative"
|
||||||
|
style={{ backgroundImage: `url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 0.75rem center', backgroundSize: '1em' }}
|
||||||
|
>
|
||||||
|
{REFRESH_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.label} value={opt.value} className="bg-dark-card font-medium text-dark-text">
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,39 +26,29 @@ const Layout = () => {
|
|||||||
|
|
||||||
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = async (showLoading = false) => {
|
||||||
const loadInitial = async () => {
|
if (showLoading) setIsLoading(true);
|
||||||
setIsLoading(true);
|
|
||||||
const data = await fetchData();
|
const data = await fetchData();
|
||||||
setOrdersData(data);
|
setOrdersData(data);
|
||||||
setIsLoading(false);
|
if (showLoading) setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
loadInitial();
|
useEffect(() => {
|
||||||
|
loadData(true);
|
||||||
// Set up SSE for real-time updates
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
|
||||||
const sse = new EventSource(`${API_URL}/stream`);
|
|
||||||
|
|
||||||
sse.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.type === 'update') {
|
|
||||||
fetchData().then(newData => {
|
|
||||||
setOrdersData(newData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("SSE parse error", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
sse.close();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshInterval === 0) return;
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
loadData(false);
|
||||||
|
}, refreshInterval);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [refreshInterval]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('nexstar_date_range', JSON.stringify({
|
localStorage.setItem('nexstar_date_range', JSON.stringify({
|
||||||
start: dateRange.start.toISOString(),
|
start: dateRange.start.toISOString(),
|
||||||
@@ -127,7 +117,7 @@ const Layout = () => {
|
|||||||
<div className="p-4 border-t border-dark-border">
|
<div className="p-4 border-t border-dark-border">
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className={`w-full flex items-center text-red-500 hover:bg-red-500/10 px-4 py-3 rounded-xl transition-all ${isSidebarCollapsed ? 'justify-center' : 'space-x-3'}`}
|
className={`w-full flex items-center text-red-500 hover:bg-red-500/10 px-4 py-3 rounded-xl transition-all cursor-pointer ${isSidebarCollapsed ? 'justify-center' : 'space-x-3'}`}
|
||||||
>
|
>
|
||||||
<LogOut className="w-5 h-5 shrink-0" />
|
<LogOut className="w-5 h-5 shrink-0" />
|
||||||
{!isSidebarCollapsed && <span className="font-medium">Sair</span>}
|
{!isSidebarCollapsed && <span className="font-medium">Sair</span>}
|
||||||
@@ -149,7 +139,7 @@ const Layout = () => {
|
|||||||
<Loader2 className="w-8 h-8 text-brand-primary animate-spin" />
|
<Loader2 className="w-8 h-8 text-brand-primary animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Outlet context={{ dateRange, setDateRange, ordersData }} />
|
<Outlet context={{ dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const Clients = () => {
|
|||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||||
className="appearance-none bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border text-zinc-700 dark:text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-all shadow-sm cursor-pointer"
|
className="appearance-none bg-dark-card border border-dark-border text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-colors shadow-sm cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="recent">Mais Recentes</option>
|
<option value="recent">Mais Recentes</option>
|
||||||
<option value="spent_desc">Maior Gasto</option>
|
<option value="spent_desc">Maior Gasto</option>
|
||||||
@@ -107,7 +107,7 @@ const Clients = () => {
|
|||||||
placeholder="Buscar cliente..."
|
placeholder="Buscar cliente..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full md:w-64 bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border text-zinc-900 dark:text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20 transition-all shadow-sm"
|
className="w-full md:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +141,7 @@ const Clients = () => {
|
|||||||
<td className="px-6 py-2.5 text-right">
|
<td className="px-6 py-2.5 text-right">
|
||||||
<Link
|
<Link
|
||||||
to={`/clients/${encodeURIComponent(client.name)}`}
|
to={`/clients/${encodeURIComponent(client.name)}`}
|
||||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity"
|
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
Ver detalhes
|
Ver detalhes
|
||||||
<ChevronRight className="w-3.5 h-3.5 ml-1" />
|
<ChevronRight className="w-3.5 h-3.5 ml-1" />
|
||||||
@@ -163,7 +163,7 @@ const Clients = () => {
|
|||||||
setItemsPerPage(Number(e.target.value));
|
setItemsPerPage(Number(e.target.value));
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="bg-zinc-50 dark:bg-dark-input border border-zinc-200 dark:border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer"
|
className="bg-dark-card border border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer text-dark-text"
|
||||||
>
|
>
|
||||||
<option value={10}>10</option>
|
<option value={10}>10</option>
|
||||||
<option value={20}>20</option>
|
<option value={20}>20</option>
|
||||||
@@ -181,14 +181,14 @@ const Clients = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="p-1 rounded-lg border border-zinc-200 dark:border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-zinc-50 dark:hover:bg-dark-input transition-colors text-zinc-600 dark:text-dark-text"
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
disabled={currentPage === totalPages || totalPages === 0}
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
className="p-1 rounded-lg border border-zinc-200 dark:border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-zinc-50 dark:hover:bg-dark-input transition-colors text-zinc-600 dark:text-dark-text"
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -47,7 +47,14 @@ const CustomTooltip = ({ active, payload, label, isCurrency }: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
|
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{
|
||||||
|
dateRange: DateRange,
|
||||||
|
setDateRange: (range: DateRange) => void,
|
||||||
|
ordersData: OrderData[],
|
||||||
|
refreshInterval: number,
|
||||||
|
setRefreshInterval: (interval: number) => void,
|
||||||
|
loadData: (showLoading?: boolean) => void
|
||||||
|
}>();
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const orders = ordersData;
|
const orders = ordersData;
|
||||||
@@ -112,7 +119,13 @@ const Dashboard = () => {
|
|||||||
<h1 className="text-2xl font-bold mb-2 text-dark-text">Visão Geral</h1>
|
<h1 className="text-2xl font-bold mb-2 text-dark-text">Visão Geral</h1>
|
||||||
<p className="text-dark-muted font-medium">Resumo de vendas e performance dos produtos.</p>
|
<p className="text-dark-muted font-medium">Resumo de vendas e performance dos produtos.</p>
|
||||||
</div>
|
</div>
|
||||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
<DateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
refreshInterval={refreshInterval}
|
||||||
|
setRefreshInterval={setRefreshInterval}
|
||||||
|
onManualRefresh={() => loadData(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
|||||||
|
|
||||||
const ProductDetails = () => {
|
const ProductDetails = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
|
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
||||||
|
dateRange: DateRange,
|
||||||
|
setDateRange: (range: DateRange) => void,
|
||||||
|
ordersData: OrderData[],
|
||||||
|
refreshInterval: number,
|
||||||
|
setRefreshInterval: (interval: number) => void,
|
||||||
|
loadData: (showLoading?: boolean) => void
|
||||||
|
}>();
|
||||||
|
|
||||||
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
||||||
const orders = ordersData.filter(order => order.ID_Produto === id);
|
const orders = ordersData.filter(order => order.ID_Produto === id);
|
||||||
@@ -92,8 +99,10 @@ const ProductDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
<DateRangePicker
|
||||||
</div>
|
dateRange={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
/> </div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import type { OrderData, DateRange } from '../types';
|
|||||||
import { parseOrderDate } from '../dataService';
|
import { parseOrderDate } from '../dataService';
|
||||||
|
|
||||||
const Products = () => {
|
const Products = () => {
|
||||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
|
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
||||||
|
dateRange: DateRange,
|
||||||
|
setDateRange: (range: DateRange) => void,
|
||||||
|
ordersData: OrderData[],
|
||||||
|
refreshInterval: number,
|
||||||
|
setRefreshInterval: (interval: number) => void,
|
||||||
|
loadData: (showLoading?: boolean) => void
|
||||||
|
}>();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
@@ -67,7 +74,10 @@ const Products = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
<DateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
|
||||||
@@ -76,7 +86,7 @@ const Products = () => {
|
|||||||
placeholder="Buscar por nome ou ID..."
|
placeholder="Buscar por nome ou ID..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full md:w-64 bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border text-zinc-900 dark:text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20 transition-all shadow-sm"
|
className="w-full md:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +122,7 @@ const Products = () => {
|
|||||||
<td className="px-6 py-2.5 text-right">
|
<td className="px-6 py-2.5 text-right">
|
||||||
<Link
|
<Link
|
||||||
to={`/products/${product.id}`}
|
to={`/products/${product.id}`}
|
||||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg"
|
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
|
||||||
>
|
>
|
||||||
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
|
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Ver Gráfico
|
Ver Gráfico
|
||||||
@@ -134,7 +144,7 @@ const Products = () => {
|
|||||||
setItemsPerPage(Number(e.target.value));
|
setItemsPerPage(Number(e.target.value));
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="bg-zinc-50 dark:bg-dark-input border border-zinc-200 dark:border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer"
|
className="bg-dark-card border border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer text-dark-text"
|
||||||
>
|
>
|
||||||
<option value={10}>10</option>
|
<option value={10}>10</option>
|
||||||
<option value={20}>20</option>
|
<option value={20}>20</option>
|
||||||
@@ -152,14 +162,14 @@ const Products = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="p-1 rounded-lg border border-zinc-200 dark:border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-zinc-50 dark:hover:bg-dark-input transition-colors text-zinc-600 dark:text-dark-text"
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
disabled={currentPage === totalPages || totalPages === 0}
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
className="p-1 rounded-lg border border-zinc-200 dark:border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-zinc-50 dark:hover:bg-dark-input transition-colors text-zinc-600 dark:text-dark-text"
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user