Initial commit: Dockerized, Postgres, CI/CD pipeline
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 3m36s

This commit is contained in:
Cauê Faleiros
2026-05-04 14:40:14 -03:00
commit 0e5354f1fe
40 changed files with 6640 additions and 0 deletions

184
src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

24
src/App.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Products from './pages/Products';
import ProductDetails from './pages/ProductDetails';
import Clients from './pages/Clients';
import ClientDetails from './pages/ClientDetails';
function App() {
return (
<Routes>
<Route path="/" element={<Navigate to="/graph" replace />} />
<Route path="/" element={<Layout />}>
<Route path="graph" element={<Dashboard />} />
<Route path="products" element={<Products />} />
<Route path="products/:id" element={<ProductDetails />} />
<Route path="clients" element={<Clients />} />
<Route path="clients/:name" element={<ClientDetails />} />
</Route>
</Routes>
);
}
export default App;

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,96 @@
import React, { useRef } from 'react';
import { Calendar } from 'lucide-react';
import type { DateRange } from '../types';
interface DateRangePickerProps {
dateRange: DateRange;
onChange: (range: DateRange) => void;
}
const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }) => {
const startRef = useRef<HTMLInputElement>(null);
const endRef = useRef<HTMLInputElement>(null);
const formatDateForInput = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
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) => {
if (!value) return null;
const [year, month, day] = value.split('-');
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
};
const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStart = parseLocalDate(e.target.value);
if (newStart && !isNaN(newStart.getTime())) {
onChange({ ...dateRange, start: newStart });
}
};
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = parseLocalDate(e.target.value);
if (newEnd && !isNaN(newEnd.getTime())) {
newEnd.setHours(23, 59, 59, 999);
onChange({ ...dateRange, end: newEnd });
}
};
const openPicker = (ref: React.RefObject<HTMLInputElement>) => {
if (ref.current) {
try {
if ('showPicker' in HTMLInputElement.prototype) {
ref.current.showPicker();
} else {
ref.current.focus();
}
} catch (e) {
ref.current.focus();
}
}
};
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">
<Calendar size={16} className="text-dark-muted shrink-0" />
<div className="flex items-center gap-2 text-sm font-medium text-dark-text">
<div
className="relative cursor-pointer hover:text-brand-primary transition-colors"
onClick={() => openPicker(startRef)}
>
{formatShortDate(dateRange.start)}
<input
ref={startRef}
type="date"
value={formatDateForInput(dateRange.start)}
onChange={handleStartChange}
className="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
</div>
<span className="text-dark-muted font-normal text-xs">até</span>
<div
className="relative cursor-pointer hover:text-brand-primary transition-colors"
onClick={() => openPicker(endRef)}
>
{formatShortDate(dateRange.end)}
<input
ref={endRef}
type="date"
value={formatDateForInput(dateRange.end)}
onChange={handleEndChange}
className="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
</div>
</div>
</div>
);
};
export default DateRangePicker;

129
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2 } from 'lucide-react';
import type { DateRange, OrderData } from '../types';
import { fetchData } from '../dataService';
const Layout = () => {
const location = useLocation();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
});
const [dateRange, setDateRange] = useState<DateRange>(() => {
const saved = localStorage.getItem('nexstar_date_range');
if (saved) {
try {
const parsed = JSON.parse(saved);
return { start: new Date(parsed.start), end: new Date(parsed.end) };
} catch (e) { console.error(e); }
}
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 1);
return { start, end };
});
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadInitial = async () => {
setIsLoading(true);
const data = await fetchData();
setOrdersData(data);
setIsLoading(false);
};
loadInitial();
}, []);
useEffect(() => {
localStorage.setItem('nexstar_date_range', JSON.stringify({
start: dateRange.start.toISOString(),
end: dateRange.end.toISOString()
}));
}, [dateRange]);
const toggleSidebar = () => {
const newState = !isSidebarCollapsed;
setIsSidebarCollapsed(newState);
localStorage.setItem('graph_sidebar_collapsed', String(newState));
};
const navigation = [
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
{ name: 'Produtos', href: '/products', icon: Package },
{ name: 'Clientes', href: '/clients', icon: Users },
];
return (
<div className="flex h-screen bg-dark-bg text-dark-text overflow-hidden">
{/* Sidebar */}
<aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}>
<div className={`h-20 px-6 border-b border-dark-border flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
<div className="flex items-center gap-2">
<div className={`p-1.5 bg-brand-primary/20 rounded-lg text-brand-primary`}>
<BarChart3 className="w-6 h-6" />
</div>
{!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
</div>
{!isSidebarCollapsed && (
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
<ChevronLeft className="w-5 h-5" />
</button>
)}
</div>
{isSidebarCollapsed && (
<div className="p-4 border-b border-dark-border flex justify-center">
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
<ChevronRight className="w-5 h-5" />
</button>
</div>
)}
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
{navigation.map((item) => {
const isActive = location.pathname === item.href || (item.href !== '/graph' && location.pathname.startsWith(item.href));
return (
<Link
key={item.name}
to={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-xl transition-all ${
isActive
? 'bg-brand-primary/10 text-brand-primary font-semibold shadow-md shadow-brand-primary/5'
: 'text-dark-muted hover:bg-dark-card hover:text-dark-text'
}`}
>
<item.icon className="w-5 h-5 shrink-0" />
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
</Link>
);
})}
</nav>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col h-screen overflow-hidden">
{/* Header */}
<header className="h-20 bg-dark-header border-b border-dark-border flex items-center px-8 shrink-0">
<h2 className="text-xl font-bold text-dark-text">Painel de Análise</h2>
</header>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-8 relative">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-brand-primary animate-spin" />
</div>
) : (
<Outlet context={{ dateRange, setDateRange, ordersData }} />
)}
</div>
</main>
</div>
);
};
export default Layout;

146
src/data.json Normal file
View File

@@ -0,0 +1,146 @@
[
{
"Nome_Cliente": "Luiz Felipe Oliveira Silva",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 19.9,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 1,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 69.65,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 69.65,
"ID_Produto": "978770094",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X0,50 (50 CENTÍMETROS)",
"Quantidade": 1,
"Valor_Unitario": 9.95
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 65.66,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 20.51889
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 65.66,
"ID_Produto": "978776637",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X0,20 (20 CENTÍMETROS)",
"Quantidade": 1,
"Valor_Unitario": 4.103778
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 24.54,
"ID_Produto": "919483299",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - P",
"Quantidade": 1,
"Valor_Unitario": 12.27009
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 24.54,
"ID_Produto": "919483307",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - G",
"Quantidade": 1,
"Valor_Unitario": 12.27009
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483303",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - M",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483311",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - GG",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "976044109",
"Descricao_Produto": "BASE LISA CAMISETA COR MARINHO TAMANHO - GG",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483255",
"Descricao_Produto": "BASE LISA CAMISETA COR PEROLA TAMANHO - G",
"Quantidade": 1,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483299",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - P",
"Quantidade": 1,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "Francieli Campos",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 224.95,
"ID_Produto": "978761156",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (10 METROS)",
"Quantidade": 1,
"Valor_Unitario": 199
},
{
"Nome_Cliente": "Daniela Gutierrez",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 217.45,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 10,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "Joyce Pretel",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 22.47,
"ID_Produto": "951540701",
"Descricao_Produto": "BONÉ - PRETO",
"Quantidade": 3,
"Valor_Unitario": 7.49
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 59.7,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 19.9
}
]

14
src/dataService.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { OrderData } from './types';
const API_URL = import.meta.env.VITE_API_URL || '/api';
export const fetchData = async (): Promise<OrderData[]> => {
try {
const response = await fetch(`${API_URL}/data`);
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error("Fetch failed", error);
return [];
}
};

31
src/index.css Normal file
View File

@@ -0,0 +1,31 @@
@import "tailwindcss";
@theme {
--color-brand-primary: #9ECAE1;
--color-dark-bg: #0a0a0a;
--color-dark-card: #141414;
--color-dark-header: #141414;
--color-dark-sidebar: #141414;
--color-dark-border: #222222;
--color-dark-input: #1a1a1a;
--color-dark-text: #ededed;
--color-dark-muted: #888888;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@layer base {
body {
font-family: 'Inter', sans-serif;
@apply bg-dark-bg text-dark-text;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #333333;
border-radius: 3px;
}
}

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<HashRouter>
<App />
</HashRouter>
</StrictMode>,
)

145
src/pages/ClientDetails.tsx Normal file
View File

@@ -0,0 +1,145 @@
import { useMemo } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, User, Calendar, Tag, Package, DollarSign } from 'lucide-react';
import type { OrderData } from '../types';
const ClientDetails = () => {
const { name } = useParams<{ name: string }>();
const decodedName = name ? decodeURIComponent(name) : '';
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
const { groupedOrders, totalSpent, totalItems } = useMemo(() => {
const orders = ordersData;
const clientOrders = orders.filter(order => {
const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
return clientName === decodedName;
});
const groupedOrdersMap: Record<string, { date: string, orderTotal: number, items: OrderData[] }> = {};
let totalSpent = 0;
let totalItems = 0;
clientOrders.forEach(order => {
totalSpent += (order.Quantidade * order.Valor_Unitario);
totalItems += order.Quantidade;
// Use date and total order value as a unique cart/order identifier
const key = `${order.Data_Pedido}_${order.Valor_Pedido}`;
if (!groupedOrdersMap[key]) {
groupedOrdersMap[key] = {
date: order.Data_Pedido,
orderTotal: order.Valor_Pedido,
items: []
};
}
groupedOrdersMap[key].items.push(order);
});
// Sort grouped orders by date descending
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
const [dayA, monthA, yearA] = a.date.split('-').map(Number);
const [dayB, monthB, yearB] = b.date.split('-').map(Number);
return new Date(yearB, monthB - 1, dayB).getTime() - new Date(yearA, monthA - 1, dayA).getTime();
});
return { groupedOrders, totalSpent, totalItems };
}, [decodedName, ordersData]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
if (!groupedOrders.length) {
return (
<div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Cliente não encontrado.</p>
<Link to="/clients" className="text-brand-primary hover:underline mt-4 inline-block font-bold">Voltar para clientes</Link>
</div>
);
}
return (
<div className="space-y-6">
{/* Header Area */}
<div className="flex flex-col gap-4">
<Link to="/clients" className="inline-flex items-center text-sm font-bold text-zinc-400 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text transition-colors w-fit">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Link>
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border flex items-center justify-center shadow-sm">
<User className="w-8 h-8 text-brand-primary" />
</div>
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{decodedName}</h1>
<p className="text-zinc-500 dark:text-dark-muted font-medium">Histórico completo de compras</p>
</div>
</div>
<div className="flex gap-8">
<div className="flex flex-col items-center">
<p className="text-xs font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest mb-1">Total Gasto</p>
<p className="text-2xl font-bold text-brand-primary">{formatCurrency(totalSpent)}</p>
</div>
<div className="flex flex-col items-center">
<p className="text-xs font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest mb-1">Itens Comprados</p>
<p className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{totalItems}</p>
</div>
</div>
</div>
</div>
{/* Orders List */}
<div className="flex flex-col gap-6">
{groupedOrders.map((group, groupIndex) => (
<div key={groupIndex} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
<div className="p-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50/50 dark:bg-dark-header flex justify-between items-center">
<h2 className="text-sm font-bold uppercase tracking-wider text-zinc-500 dark:text-dark-muted">
Data do Pedido: {group.date}
</h2>
<span className="text-sm font-bold text-brand-primary">
Total do Pedido: {formatCurrency(group.orderTotal)}
</span>
</div>
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{group.items.map((order, index) => (
<div key={`${order.ID_Produto}-${index}`} className="px-4 py-2 flex flex-col md:flex-row md:items-center justify-between gap-3 hover:bg-zinc-50/50 dark:hover:bg-dark-input/30 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<div className="flex items-center gap-1">
<Tag className="w-3 h-3 text-zinc-400 dark:text-dark-muted" />
<span className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted">ID: {order.ID_Produto}</span>
</div>
</div>
<h3 className="text-sm font-bold text-zinc-900 dark:text-dark-text truncate mb-1">{order.Descricao_Produto}</h3>
<div className="flex gap-4">
<div className="flex items-center gap-1.5 text-[11px]">
<Package className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="text-zinc-500 dark:text-dark-muted font-medium">Qtd: <span className="text-zinc-900 dark:text-dark-text font-bold">{order.Quantidade}</span></span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<DollarSign className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="text-zinc-500 dark:text-dark-muted font-medium">Preço: <span className="text-zinc-900 dark:text-dark-text font-bold">{formatCurrency(order.Valor_Unitario)}</span></span>
</div>
</div>
</div>
<div className="text-right shrink-0">
<p className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest mb-0.5">Subtotal</p>
<p className="text-base font-bold text-zinc-900 dark:text-dark-text">{formatCurrency(order.Quantidade * order.Valor_Unitario)}</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
};
export default ClientDetails;

146
src/pages/Clients.tsx Normal file
View File

@@ -0,0 +1,146 @@
import { useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Filter } from 'lucide-react';
import type { OrderData } from '../types';
type SortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc';
const Clients = () => {
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('recent');
const clientsData = useMemo(() => {
const orders = ordersData;
const clientMap: Record<string, { totalSpent: number, totalItems: number, uniqueOrders: Set<string>, lastPurchase: number }> = {};
orders.forEach(order => {
const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
if (!clientMap[clientName]) {
clientMap[clientName] = { totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0 };
}
// Calculate total spent based on quantity * unit price
clientMap[clientName].totalSpent += (order.Quantidade * order.Valor_Unitario);
clientMap[clientName].totalItems += order.Quantidade;
clientMap[clientName].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`);
const [day, month, year] = order.Data_Pedido.split('-').map(Number);
const orderTime = new Date(year, month - 1, day).getTime();
if (orderTime > clientMap[clientName].lastPurchase) {
clientMap[clientName].lastPurchase = orderTime;
}
});
let result = Object.keys(clientMap).map(name => ({
name,
totalSpent: clientMap[name].totalSpent,
totalItems: clientMap[name].totalItems,
orderCount: clientMap[name].uniqueOrders.size, // Grouped by unique date+value combinations
lastPurchase: clientMap[name].lastPurchase
}));
if (searchTerm) {
result = result.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
return result.sort((a, b) => {
switch (sortBy) {
case 'recent': return b.lastPurchase - a.lastPurchase;
case 'spent_desc': return b.totalSpent - a.totalSpent;
case 'spent_asc': return a.totalSpent - b.totalSpent;
case 'items_desc': return b.totalItems - a.totalItems;
case 'items_asc': return a.totalItems - b.totalItems;
default: return 0;
}
});
}, [searchTerm, sortBy, ordersData]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold mb-2 text-zinc-900 dark:text-dark-text">Clientes</h1>
<p className="text-zinc-500 dark:text-dark-muted font-medium">Métricas de engajamento e histórico de consumo dos seus clientes.</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-4 h-4" />
<select
value={sortBy}
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"
>
<option value="recent">Mais Recentes</option>
<option value="spent_desc">Maior Gasto</option>
<option value="spent_asc">Menor Gasto</option>
<option value="items_desc">Mais Produtos</option>
<option value="items_asc">Menos Produtos</option>
</select>
</div>
<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" />
<input
type="text"
placeholder="Buscar cliente..."
value={searchTerm}
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"
/>
</div>
</div>
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
<tr>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Posição</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Nome do Cliente</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Gasto</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Produtos Comprados</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{clientsData.map((client, index) => (
<tr key={client.name} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<td className="px-6 py-2.5">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold bg-zinc-100 dark:bg-dark-border text-zinc-500 dark:text-dark-muted">
{index + 1}
</span>
</td>
<td className="px-6 py-2.5 font-semibold text-zinc-900 dark:text-dark-text">{client.name}</td>
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(client.totalSpent)}</td>
<td className="px-6 py-2.5 text-zinc-500 dark:text-dark-muted text-xs font-medium">
{client.totalItems} un. ({client.orderCount} {client.orderCount === 1 ? 'pedido' : 'pedidos'})
</td>
<td className="px-6 py-2.5 text-right">
<Link
to={`/clients/${encodeURIComponent(client.name)}`}
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity"
>
Ver detalhes
<ChevronRight className="w-3.5 h-3.5 ml-1" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Clients;

164
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,164 @@
import { useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types';
const COLORS = [
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316',
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6'
];
const Dashboard = () => {
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
const filteredData = useMemo(() => {
const orders = ordersData;
return orders.filter(order => {
const [day, month, year] = order.Data_Pedido.split('-').map(Number);
const orderDate = new Date(year, month - 1, day);
return orderDate >= dateRange.start && orderDate <= dateRange.end;
});
}, [dateRange, ordersData]);
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct } = useMemo(() => {
let revenue = 0;
let totalItems = 0;
const productSalesMap: Record<string, number> = {};
filteredData.forEach(order => {
revenue += (order.Quantidade * order.Valor_Unitario);
totalItems += order.Quantidade;
const productName = order.Descricao_Produto.split(' TAMANHO')[0];
if (productSalesMap[productName]) {
productSalesMap[productName] += order.Quantidade;
} else {
productSalesMap[productName] = order.Quantidade;
}
});
const productsData = Object.keys(productSalesMap).map(key => ({
name: key,
value: productSalesMap[key]
})).sort((a, b) => b.value - a.value).slice(0, 10);
return { totalRevenue: revenue, totalOrders: totalItems, averageOrderValue: revenue / (filteredData.length || 1), salesByProduct: productsData };
}, [filteredData]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<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>
</div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between items-start">
<div>
<p className="text-dark-muted text-sm font-medium mb-1">Receita Total</p>
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(totalRevenue)}</h3>
</div>
<div className="p-3 bg-emerald-500/10 rounded-xl">
<DollarSign className="w-6 h-6 text-emerald-500" />
</div>
</div>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between items-start">
<div>
<p className="text-dark-muted text-sm font-medium mb-1">Total de Produtos Vendidos</p>
<h3 className="text-3xl font-bold text-dark-text">{totalOrders}</h3>
</div>
<div className="p-3 bg-blue-500/10 rounded-xl">
<ShoppingCart className="w-6 h-6 text-blue-500" />
</div>
</div>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between items-start">
<div>
<p className="text-dark-muted text-sm font-medium mb-1">Ticket Médio (Por Item)</p>
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(averageOrderValue)}</h3>
</div>
<div className="p-3 bg-purple-500/10 rounded-xl">
<TrendingUp className="w-6 h-6 text-purple-500" />
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<h3 className="text-lg font-bold mb-6 text-dark-text">Produtos Mais Vendidos</h3>
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={salesByProduct} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
<XAxis
dataKey="name" stroke="#888888" fontSize={10} tickLine={false} axisLine={false}
tickFormatter={(v) => v.length > 12 ? v.substring(0, 12) + '...' : v}
/>
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
cursor={{ fill: '#222222' }}
contentStyle={{
backgroundColor: '#141414', borderColor: 'transparent', borderRadius: '12px',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.5)', border: 'none', color: '#ededed'
}}
itemStyle={{ color: '#ededed' }}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{salesByProduct.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<h3 className="text-lg font-bold mb-6 text-dark-text">Distribuição de Produtos</h3>
<div className="h-80 w-full flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={salesByProduct} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value" stroke="none">
{salesByProduct.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#141414', borderColor: 'transparent', borderRadius: '12px',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.5)', border: 'none', color: '#ededed'
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{salesByProduct.map((entry, index) => (
<div key={entry.name} className="flex items-center text-[10px]">
<span className="w-2.5 h-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: COLORS[index % COLORS.length] }}></span>
<span className="text-dark-muted truncate font-semibold">{entry.name}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,134 @@
import { useMemo } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, Package, DollarSign, Calendar } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types';
const ProductDetails = () => {
const { id } = useParams<{ id: string }>();
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
const orders = ordersData.filter(order => order.ID_Produto === id);
if (orders.length === 0) return { productInfo: null, chartData: [], totalSold: 0, totalRevenue: 0 };
const info = {
id: orders[0].ID_Produto,
name: orders[0].Descricao_Produto.split(' TAMANHO')[0],
price: orders[0].Valor_Unitario
};
const salesByDate: Record<string, number> = {};
let sold = 0;
let revenue = 0;
orders.forEach(order => {
const [day, month, year] = order.Data_Pedido.split('-').map(Number);
const orderDate = new Date(year, month - 1, day);
if (orderDate >= dateRange.start && orderDate <= dateRange.end) {
const dateStr = order.Data_Pedido;
salesByDate[dateStr] = (salesByDate[dateStr] || 0) + order.Quantidade;
sold += order.Quantidade;
revenue += (order.Quantidade * order.Valor_Unitario);
}
});
const chart = Object.keys(salesByDate).map(date => ({
date,
value: salesByDate[date]
})).sort((a, b) => {
const [da, ma, ya] = a.date.split('-').map(Number);
const [db, mb, yb] = b.date.split('-').map(Number);
return new Date(ya, ma - 1, da).getTime() - new Date(yb, mb - 1, db).getTime();
});
return { productInfo: info, chartData: chart, totalSold: sold, totalRevenue: revenue };
}, [id, dateRange, ordersData]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
if (!productInfo) {
return (
<div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Produto não encontrado.</p>
<Link to="/products" className="text-brand-primary hover:underline mt-4 inline-block font-bold">Voltar para produtos</Link>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-col gap-4">
<Link to="/products" className="inline-flex items-center text-sm font-bold text-zinc-400 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text transition-colors w-fit">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Link>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border flex items-center justify-center shadow-sm text-brand-primary">
<Package className="w-8 h-8" />
</div>
<div>
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{productInfo.name}</h1>
</div>
</div>
</div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
</div>
<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>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Unidades Vendidas</p>
<p className="text-3xl font-bold text-dark-text">{totalSold}</p>
</div>
<div className="p-3 bg-brand-primary/10 rounded-xl text-brand-primary">
<Package size={24} />
</div>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Receita Total</p>
<p className="text-3xl font-bold text-brand-primary">{formatCurrency(totalRevenue)}</p>
</div>
<div className="p-3 bg-emerald-500/10 rounded-xl text-emerald-500">
<DollarSign size={24} />
</div>
</div>
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">Volume de Vendas por Data</h3>
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
<XAxis
dataKey="date" stroke="#888888" fontSize={12} tickLine={false} axisLine={false}
/>
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
cursor={{ fill: '#222222' }}
contentStyle={{
backgroundColor: '#141414', borderColor: 'transparent', borderRadius: '12px',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.5)', border: 'none', color: '#ededed'
}}
/>
<Bar dataKey="value" fill="#9ECAE1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
export default ProductDetails;

117
src/pages/Products.tsx Normal file
View File

@@ -0,0 +1,117 @@
import { useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Package, TrendingUp } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types';
const Products = () => {
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
const [searchTerm, setSearchTerm] = useState('');
const productsData = useMemo(() => {
const orders = ordersData;
const productMap: Record<string, { id: string, name: string, totalSold: number, revenue: number, lastPrice: number }> = {};
orders.forEach(order => {
const [day, month, year] = order.Data_Pedido.split('-').map(Number);
const orderDate = new Date(year, month - 1, day);
if (orderDate < dateRange.start || orderDate > dateRange.end) return;
if (!productMap[order.ID_Produto]) {
productMap[order.ID_Produto] = {
id: order.ID_Produto,
name: order.Descricao_Produto.split(' TAMANHO')[0],
totalSold: 0,
revenue: 0,
lastPrice: order.Valor_Unitario
};
}
productMap[order.ID_Produto].totalSold += order.Quantidade;
productMap[order.ID_Produto].revenue += (order.Quantidade * order.Valor_Unitario);
productMap[order.ID_Produto].lastPrice = order.Valor_Unitario;
});
let result = Object.values(productMap);
if (searchTerm) {
result = result.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()) || p.id.includes(searchTerm));
}
return result.sort((a, b) => b.totalSold - a.totalSold);
}, [dateRange, searchTerm, ordersData]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold mb-2 text-zinc-900 dark:text-dark-text">Produtos</h1>
<p className="text-zinc-500 dark:text-dark-muted font-medium">Gestão de catálogo e performance de vendas por item.</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
<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" />
<input
type="text"
placeholder="Buscar por nome ou ID..."
value={searchTerm}
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"
/>
</div>
</div>
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
<tr>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Descrição</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{productsData.map((product) => (
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td>
<td className="px-6 py-2.5">
<div className="font-semibold text-zinc-900 dark:text-dark-text">{product.name}</div>
<div className="text-[10px] text-zinc-400 dark:text-dark-muted font-medium">Preço Atual: {formatCurrency(product.lastPrice)}</div>
</td>
<td className="px-6 py-2.5">
<div className="flex items-center gap-2">
<Package className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
</div>
</td>
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(product.revenue)}</td>
<td className="px-6 py-2.5 text-right">
<Link
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"
>
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
Ver Gráfico
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Products;

14
src/types.ts Normal file
View File

@@ -0,0 +1,14 @@
export interface OrderData {
Nome_Cliente: string;
Data_Pedido: string;
Valor_Pedido: number;
ID_Produto: string;
Descricao_Produto: string;
Quantidade: number;
Valor_Unitario: number;
}
export interface DateRange {
start: Date;
end: Date;
}