feat(ui): padronizar paleta #0F4C81 e estrutura em múltiplas telas

- SellerDashboard: migrado para Shell (header topo), removida sidebar lateral,
  cards KPI brancos com react-icons pretos (FaChartLine, FaBoxOpen, FaReceipt)
- Shell: adicionados todos os links de nav para owner/seller no header
  (Estoque, Buscar Produtos, Pedidos, Carteira, Equipe, Config. Entrega)
- Wallet: ícone FaMoneyCheck no botão Solicitar Saque, card saldo com #0F4C81,
  thead da tabela com #0F4C81, fix R$ NaN (formatCurrency null-safe)
- Team: botões e thead com #0F4C81, emojis removidos dos roleLabels
- ShippingSettings: wrapped com Shell (mantém header), emojis substituídos por
  react-icons pretos (FaTruck, FaLocationDot, FaStore, FaCircleInfo, FaFloppyDisk),
  botão Salvar com #0F4C81
- Orders: removido box cinza de fundo dos ícones nas abas e estado vazio
- LocationPicker: fallback seguro para OpenStreetMap quando VITE_MAP_TILE_LAYER
  não está definido (corrige tela branca em /search)
- Inventory/Cart: cores dos botões e thead atualizadas para #0F4C81
This commit is contained in:
eycksilva 2026-02-26 15:56:03 -03:00
parent 5cb9d5212c
commit 3559afc1f7
34 changed files with 2280 additions and 547 deletions

View file

@ -9,7 +9,6 @@ require (
github.com/jackc/pgx/v5 v5.7.6
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/json-iterator/go v1.1.12
github.com/lib/pq v1.10.9
github.com/stretchr/testify v1.8.1
github.com/swaggo/http-swagger v1.3.4
@ -29,8 +28,6 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect

View file

@ -24,7 +24,6 @@ github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -39,8 +38,6 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@ -57,10 +54,6 @@ github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View file

@ -18,6 +18,7 @@
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.26.0",
"react-window": "^1.8.10",

View file

@ -29,6 +29,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@18.3.1)
react-leaflet:
specifier: ^4.2.1
version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -51,6 +54,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^22.0.0
version: 22.19.12
'@types/react':
specifier: ^18.3.7
version: 18.3.27
@ -62,10 +68,10 @@ importers:
version: 1.8.8
'@vitejs/plugin-react':
specifier: ^4.7.0
version: 4.7.0(vite@5.4.21)
version: 4.7.0(vite@5.4.21(@types/node@22.19.12))
'@vitest/coverage-v8':
specifier: ^4.0.16
version: 4.0.16(vitest@4.0.16(jiti@1.21.7)(jsdom@27.4.0))
version: 4.0.16(vitest@4.0.16(@types/node@22.19.12)(jiti@1.21.7)(jsdom@27.4.0))
autoprefixer:
specifier: ^10.4.20
version: 10.4.23(postcss@8.5.6)
@ -83,10 +89,10 @@ importers:
version: 5.9.3
vite:
specifier: ^5.4.3
version: 5.4.21
version: 5.4.21(@types/node@22.19.12)
vitest:
specifier: ^4.0.16
version: 4.0.16(jiti@1.21.7)(jsdom@27.4.0)
version: 4.0.16(@types/node@22.19.12)(jiti@1.21.7)(jsdom@27.4.0)
packages:
@ -620,56 +626,67 @@ packages:
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
@ -758,6 +775,9 @@ packages:
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/node@22.19.12':
resolution: {integrity: sha512-0QEp0aPJYSyf6RrTjDB7HlKgNMTY+V2C7ESTaVt6G9gQ0rPLzTGz7OF2NXTLR5vcy7HJEtIUsyWLsfX0kTqJBA==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@ -1385,6 +1405,11 @@ packages:
peerDependencies:
react: ^18.3.1
react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies:
react: '*'
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@ -1558,6 +1583,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@ -2237,6 +2265,10 @@ snapshots:
dependencies:
'@types/geojson': 7946.0.16
'@types/node@22.19.12':
dependencies:
undici-types: 6.21.0
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.27)':
@ -2252,7 +2284,7 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.12))':
dependencies:
'@babel/core': 7.28.5
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
@ -2260,11 +2292,11 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21
vite: 5.4.21(@types/node@22.19.12)
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.0.16(vitest@4.0.16(jiti@1.21.7)(jsdom@27.4.0))':
'@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@22.19.12)(jiti@1.21.7)(jsdom@27.4.0))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.16
@ -2277,7 +2309,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.16(jiti@1.21.7)(jsdom@27.4.0)
vitest: 4.0.16(@types/node@22.19.12)(jiti@1.21.7)(jsdom@27.4.0)
transitivePeerDependencies:
- supports-color
@ -2290,13 +2322,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.16(vite@7.3.0(jiti@1.21.7))':
'@vitest/mocker@4.0.16(vite@7.3.0(@types/node@22.19.12)(jiti@1.21.7))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.0(jiti@1.21.7)
vite: 7.3.0(@types/node@22.19.12)(jiti@1.21.7)
'@vitest/pretty-format@4.0.16':
dependencies:
@ -2879,6 +2911,10 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-icons@5.5.0(react@18.3.1):
dependencies:
react: 18.3.1
react-is@17.0.2: {}
react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@ -3079,6 +3115,8 @@ snapshots:
typescript@5.9.3: {}
undici-types@6.21.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
@ -3091,15 +3129,16 @@ snapshots:
util-deprecate@1.0.2: {}
vite@5.4.21:
vite@5.4.21(@types/node@22.19.12):
dependencies:
esbuild: 0.21.5
postcss: 8.5.6
rollup: 4.54.0
optionalDependencies:
'@types/node': 22.19.12
fsevents: 2.3.3
vite@7.3.0(jiti@1.21.7):
vite@7.3.0(@types/node@22.19.12)(jiti@1.21.7):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
@ -3108,13 +3147,14 @@ snapshots:
rollup: 4.54.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.19.12
fsevents: 2.3.3
jiti: 1.21.7
vitest@4.0.16(jiti@1.21.7)(jsdom@27.4.0):
vitest@4.0.16(@types/node@22.19.12)(jiti@1.21.7)(jsdom@27.4.0):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@7.3.0(jiti@1.21.7))
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@22.19.12)(jiti@1.21.7))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@ -3131,9 +3171,10 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.0(jiti@1.21.7)
vite: 7.3.0(@types/node@22.19.12)(jiti@1.21.7)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.12
jsdom: 27.4.0
transitivePeerDependencies:
- jiti

View file

@ -2,6 +2,8 @@ import { Navigate, Route, Routes } from 'react-router-dom'
// Auth
import { LoginPage } from '@/pages/auth/Login'
import { ForgotPasswordPage } from '@/pages/auth/ForgotPassword'
import { RegisterPage } from '@/pages/auth/Register'
// Marketplace
import { CartPage } from '@/pages/marketplace/Cart'
@ -46,6 +48,8 @@ function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Admin Dashboard with Header Layout */}
<Route
@ -102,7 +106,7 @@ function App() {
<Route
path="/cart"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={['owner', 'seller', 'employee']}>
<CartPage />
</ProtectedRoute>
}
@ -110,7 +114,7 @@ function App() {
<Route
path="/orders"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={['owner', 'seller', 'employee']}>
<UserOrdersPage />
</ProtectedRoute>
}
@ -118,7 +122,7 @@ function App() {
<Route
path="/inventory"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={['owner', 'seller']}>
<InventoryPage />
</ProtectedRoute>
}
@ -174,7 +178,7 @@ function App() {
<Route
path="/checkout"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={['owner', 'seller', 'employee']}>
<CheckoutPage />
</ProtectedRoute>
}

View file

@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import logoImg from '../assets/logo.png'
const navItems = [
const adminNavItems = [
{ path: '/dashboard', label: 'Início' },
{ path: '/dashboard/users', label: 'Usuários' },
{ path: '/dashboard/companies', label: 'Empresas' },
@ -18,6 +19,10 @@ export function Header() {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Só mostrar nav para admin. Entregador, seller, employee não precisam dos itens de admin.
const isAdmin = user?.role === 'admin'
const navItems = isAdmin ? adminNavItems : []
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@ -32,18 +37,17 @@ export function Header() {
}, [])
return (
<header className="bg-gradient-to-r from-blue-900 to-blue-700 text-white shadow-lg">
<header style={{ backgroundColor: '#0F4C81' }} className="text-white shadow-lg">
<div className="mx-auto max-w-7xl px-4">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Link to="/dashboard" className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/20">
<span className="text-xl font-bold">💊</span>
</div>
<img src={logoImg} alt="SaveInMed" className="h-10 w-auto" />
<span className="text-xl font-bold">SaveInMed</span>
</Link>
{/* Navigation */}
{/* Navigation — only for admin */}
{navItems.length > 0 && (
<nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => {
const isActive = location.pathname === item.path ||
@ -62,6 +66,7 @@ export function Header() {
)
})}
</nav>
)}
{/* User info */}
<div className="relative flex items-center gap-4" ref={dropdownRef}>
@ -87,7 +92,7 @@ export function Header() {
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg bg-white py-2 text-sm text-gray-700 shadow-lg">
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg bg-white py-2 text-sm text-gray-700 shadow-lg z-50">
<Link
to="/dashboard/profile"
className="block px-4 py-2 hover:bg-gray-100"
@ -107,7 +112,8 @@ export function Header() {
</div>
</div>
{/* Mobile Navigation */}
{/* Mobile Navigation — only for admin */}
{navItems.length > 0 && (
<nav className="md:hidden border-t border-white/20 px-4 py-2 flex gap-2 overflow-x-auto">
{navItems.map((item) => {
const isActive = location.pathname === item.path
@ -123,6 +129,7 @@ export function Header() {
)
})}
</nav>
)}
</header>
)
}

View file

@ -1,5 +1,5 @@
import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet'
import { useState, useEffect } from 'react'
import { useState } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
@ -7,7 +7,7 @@ import 'leaflet/dist/leaflet.css'
import icon from 'leaflet/dist/images/marker-icon.png'
import iconShadow from 'leaflet/dist/images/marker-shadow.png'
let DefaultIcon = L.icon({
const DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
@ -16,6 +16,12 @@ let DefaultIcon = L.icon({
L.Marker.prototype.options.icon = DefaultIcon
// Use env vars with safe fallback to OpenStreetMap
const TILE_URL = import.meta.env.VITE_MAP_TILE_LAYER ||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
const TILE_ATTR = import.meta.env.VITE_MAP_ATTRIBUTION ||
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
interface LocationPickerProps {
initialLat?: number
initialLng?: number
@ -46,8 +52,8 @@ export const LocationPicker = ({ initialLat, initialLng, onLocationSelect }: Loc
<div className="h-[300px] w-full rounded-lg overflow-hidden border border-gray-300">
<MapContainer center={center} zoom={13} scrollWheelZoom={true} style={{ height: '100%', width: '100%' }}>
<TileLayer
attribution={import.meta.env.VITE_MAP_ATTRIBUTION}
url={import.meta.env.VITE_MAP_TILE_LAYER}
attribution={TILE_ATTR}
url={TILE_URL}
/>
<LocationMarker onLocationSelect={onLocationSelect} />
{(initialLat && initialLng) && <Marker position={[initialLat, initialLng]} />}

View file

@ -0,0 +1,192 @@
import { useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useAuth, UserRole } from '@/context/AuthContext'
import { Menu, X } from 'lucide-react'
import logoImg from '../assets/logo.png'
interface SidebarItem {
label: string
path: string
icon: JSX.Element
roles: UserRole[]
}
/**
* Mapa de permissões por role:
*
* admin usa o Header horizontal, sem Sidebar
* owner Meu Painel, Estoque, Produtos, Pedidos, Carteira, Equipe, Config. Entrega
* seller Meu Painel, Estoque, Produtos, Pedidos, Carteira, Equipe, Config. Entrega
* employee usa Header horizontal sem Sidebar (reescrito)
* delivery usa Header horizontal sem Sidebar (reescrito)
* customer Sidebar não é usado
*/
const menuItems: SidebarItem[] = [
// ─── Owner / Seller ───────────────────────────────────
{
label: 'Meu Painel',
path: '/seller',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 12l2-4m0 0l7-4 7 4M5 8v10a1 1 0 001 1h12a1 1 0 001-1V8m-9 4v4m0 0h4m-4 0V9" />
</svg>
),
roles: ['owner', 'seller'],
},
{
label: 'Estoque',
path: '/inventory',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M20 7l-8-4-8 4m0 0l8 4m-8-4v10l8 4m0-10l8 4m-8-4v10M4 12v5a2 2 0 002 2h12a2 2 0 002-2v-5" />
</svg>
),
roles: ['owner', 'seller'],
},
{
label: 'Buscar Produtos',
path: '/search',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
roles: ['owner', 'seller'],
// employee NÃO vê Produtos no sidebar (ele tem o próprio dashboard)
},
{
label: 'Pedidos',
path: '/orders',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
),
roles: ['owner', 'seller'],
},
{
label: 'Carteira',
path: '/wallet',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
roles: ['owner', 'seller'],
},
{
label: 'Equipe',
path: '/team',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.856-1.487M15 10a3 3 0 11-6 0 3 3 0 016 0zM6 20a9 9 0 0118 0v2h2v-2a11 11 0 00-22 0v2h2v-2z" />
</svg>
),
roles: ['owner', 'seller'],
},
{
label: 'Config. de Entrega',
path: '/shipping-settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
</svg>
),
roles: ['owner', 'seller'],
},
]
interface SidebarProps {
/** @deprecated — passe userRole via prop ou deixe o Sidebar ler do useAuth automaticamente */
userRole?: UserRole
collapsed?: boolean
onNavigate?: () => void
}
export function Sidebar({ userRole: userRoleProp, collapsed: initialCollapsed, onNavigate }: SidebarProps) {
const [collapsed, setCollapsed] = useState(initialCollapsed || false)
const location = useLocation()
const { user } = useAuth()
// Prioriza a prop (legado), mas usa a role do contexto se não houver prop
const role: UserRole = userRoleProp ?? user?.role ?? 'seller'
// Filtra itens com base na role real
const visibleItems = menuItems.filter(item => item.roles.includes(role))
// Se não há itens visíveis para esta role, não renderiza o Sidebar
if (visibleItems.length === 0) return null
return (
<>
{/* Mobile toggle button */}
<button
onClick={() => setCollapsed(!collapsed)}
className="md:hidden fixed top-4 right-4 z-50 p-2 text-white rounded-lg hover:opacity-80"
style={{ backgroundColor: '#0F4C81' }}
>
{collapsed ? <Menu className="w-6 h-6" /> : <X className="w-6 h-6" />}
</button>
{/* Sidebar */}
<aside
className={`${collapsed ? 'hidden' : 'block'
} md:block fixed md:relative left-0 top-0 h-screen md:h-auto w-64 text-white overflow-y-auto transition-all duration-300 z-40 md:z-auto`}
style={{ backgroundColor: '#0F4C81' }}
>
<div className="p-6 pt-16 md:pt-6">
{/* Logo */}
<div className="mb-8 flex items-center gap-2">
<img src={logoImg} alt="SaveInMed" className="h-8 w-auto" />
<span className="text-lg font-bold">SaveInMed</span>
</div>
{/* Role badge */}
<div className="mb-5 px-3 py-1.5 rounded-full text-xs font-semibold bg-white/15 text-white/90 w-fit capitalize">
{role === 'owner' ? 'Proprietário' : role === 'seller' ? 'Vendedor' : role}
</div>
<nav className="space-y-1">
{visibleItems.map((item) => {
const isActive = location.pathname === item.path ||
(item.path !== '/' && location.pathname.startsWith(item.path))
return (
<Link
key={item.path}
to={item.path}
onClick={() => {
onNavigate?.()
setCollapsed(true)
}}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${isActive
? 'bg-white/20 text-white shadow'
: 'text-white/75 hover:text-white hover:bg-white/10'
}`}
>
{item.icon}
<span className="text-sm font-medium">{item.label}</span>
</Link>
)
})}
</nav>
</div>
</aside>
{/* Backdrop for mobile */}
{!collapsed && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-30"
onClick={() => setCollapsed(true)}
/>
)}
</>
)
}

View file

@ -0,0 +1,22 @@
import { Outlet } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { Sidebar } from '@/components/Sidebar'
export function RoleBasedLayout() {
const { user } = useAuth()
if (!user) {
return null
}
return (
<div className="flex min-h-screen bg-gray-100">
<Sidebar userRole={user.role} />
<div className="flex-1">
<main className="p-6">
<Outlet />
</main>
</div>
</div>
)
}

View file

@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext'
import { useCartStore, selectCartSummary } from '../stores/cartStore'
import { formatCurrency } from '../utils/format'
import logoImg from '../assets/logo.png'
import { FaRightFromBracket } from 'react-icons/fa6'
// Cart dropdown content component
function CartDropdownContent() {
@ -90,15 +91,12 @@ export function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-100">
<header className="flex items-center justify-between bg-medicalBlue px-6 py-4 text-white shadow-md">
<header style={{ backgroundColor: '#0F4C81' }} className="text-white shadow-md">
<div className="mx-auto max-w-7xl px-6">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-3">
<img src={logoImg} alt="SaveInMed" className="h-10 w-auto" />
<div>
<p className="text-lg font-semibold">SaveInMed</p>
<p className="text-sm text-gray-100">
{isAdmin ? 'Painel Administrativo' : isOwner ? 'Painel do Dono' : 'Marketplace B2B'}
</p>
</div>
<span className="text-xl font-bold">SaveInMed</span>
</div>
<nav className="flex items-center gap-4 text-sm font-medium">
{isAdmin && (
@ -108,14 +106,26 @@ export function Shell({ children }: { children: React.ReactNode }) {
)}
{isOwner && (
<>
<Link to="/seller" className="hover:underline">
<Link to="/seller" className="hover:underline whitespace-nowrap">
Dashboard
</Link>
<Link to="/orders" className="hover:underline">
Meus Pedidos
<Link to="/inventory" className="hover:underline whitespace-nowrap">
Estoque
</Link>
<Link to="/inventory" className="hover:underline">
Meus Produtos
<Link to="/search" className="hover:underline whitespace-nowrap">
Buscar Produtos
</Link>
<Link to="/orders" className="hover:underline whitespace-nowrap">
Pedidos
</Link>
<Link to="/wallet" className="hover:underline whitespace-nowrap">
Carteira
</Link>
<Link to="/team" className="hover:underline whitespace-nowrap">
Equipe
</Link>
<Link to="/shipping-settings" className="hover:underline whitespace-nowrap">
Config. Entrega
</Link>
</>
)}
@ -152,11 +162,13 @@ export function Shell({ children }: { children: React.ReactNode }) {
</div>
</div>
{user && (
<div className="flex items-center gap-2">
{/* Profile dropdown */}
<div className="relative flex items-center" ref={profileMenuRef}>
<button
type="button"
onClick={() => setIsProfileOpen((prev) => !prev)}
className="flex items-center gap-3 rounded bg-white/10 px-3 py-2 text-left text-xs font-semibold hover:bg-white/20 whitespace-nowrap"
className="flex items-center gap-2 rounded bg-white/10 px-3 py-2 text-left text-xs font-semibold hover:bg-white/20 whitespace-nowrap"
aria-haspopup="true"
aria-expanded={isProfileOpen}
>
@ -197,10 +209,13 @@ export function Shell({ children }: { children: React.ReactNode }) {
</div>
)}
</div>
</div>
)}
</nav>
</div>
</div>
</header>
<main className="px-6 py-5">{children}</main>
<main className="mx-auto max-w-7xl px-6 py-6">{children}</main>
</div>
)
}

View file

@ -0,0 +1,156 @@
import { FormEvent, useState } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
import { authService } from '@/services/auth'
import logoImg from '@/assets/logo.png'
export function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [submitted, setSubmitted] = useState(false)
const onSubmit = async (event: FormEvent) => {
event.preventDefault()
setLoading(true)
setErrorMessage(null)
try {
await authService.forgotPassword({ email })
setSuccessMessage('Um link de recuperação foi enviado para seu e-mail. Verifique sua caixa de entrada.')
setSubmitted(true)
setEmail('')
} catch (error) {
const fallback = 'Não foi possível enviar o link de recuperação. Tente novamente.'
if (axios.isAxiosError(error)) {
setErrorMessage(error.response?.data?.error ?? fallback)
} else if (error instanceof Error) {
setErrorMessage(error.message)
} else {
setErrorMessage(fallback)
}
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
<div className="w-full max-w-[400px] overflow-hidden rounded-2xl bg-white shadow-xl">
{/* Blue Header with Logo */}
<div className="flex flex-col items-center bg-blue-600 pb-8 pt-10 text-white">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm">
<img src={logoImg} alt="Logo" className="h-10 w-auto brightness-0 invert" />
</div>
<h1 className="text-2xl font-bold">SaveInMed</h1>
<p className="text-blue-100 text-sm">Recuperar Acesso</p>
</div>
{/* Form Content */}
<form onSubmit={onSubmit} className="p-8 space-y-5">
{successMessage && !submitted && (
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-600 border border-green-100">
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{successMessage}</span>
</div>
)}
{errorMessage && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600 border border-red-100">
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{errorMessage}</span>
</div>
)}
{submitted ? (
<div className="text-center space-y-4">
<div className="flex justify-center">
<svg className="h-12 w-12 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-lg font-bold text-gray-900">E-mail enviado com sucesso!</h2>
<p className="text-sm text-gray-600">
Um link de recuperação foi enviado para <strong>{email}</strong>. Clique no link para redefinir sua senha.
</p>
<div className="pt-4 space-y-2">
<button
type="button"
onClick={() => setSubmitted(false)}
className="w-full rounded-xl bg-blue-600 py-3 font-semibold text-white shadow-lg shadow-blue-200 hover:bg-blue-700 hover:shadow-xl active:translate-y-0.5 focus:ring-2 focus:ring-blue-200 transition-all"
>
Enviar para outro e-mail
</button>
<Link
to="/login"
className="w-full block text-center rounded-xl border border-gray-300 py-3 font-semibold text-gray-700 hover:bg-gray-50 transition-all"
>
Voltar para login
</Link>
</div>
</div>
) : (
<>
<div>
<p className="text-sm text-gray-600 mb-4">
Digite o e-mail associado à sua conta e enviaremos um link para recuperar sua senha.
</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 ml-1">Email</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-400">
<span className="text-lg">@</span>
</div>
<input
type="email"
placeholder="seu@email.com"
className="w-full rounded-xl border border-gray-200 py-2.5 pl-10 pr-3 text-gray-800 placeholder-gray-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all outline-none"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
</div>
<div className="pt-2">
<button
type="submit"
className="w-full rounded-xl bg-blue-600 py-3 font-semibold text-white shadow-lg shadow-blue-200 hover:bg-blue-700 hover:shadow-xl active:translate-y-0.5 focus:ring-2 focus:ring-blue-200 transition-all disabled:opacity-70 flex items-center justify-center gap-2"
disabled={loading}
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Enviando...
</>
) : (
<>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
Enviar Link de Recuperação
</>
)}
</button>
</div>
<div className="text-center">
<Link
to="/login"
className="text-sm text-blue-600 hover:underline"
>
Voltar para login
</Link>
</div>
</>
)}
</form>
</div>
</div>
)
}

View file

@ -1,9 +1,11 @@
import { FormEvent, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { useAuth, UserRole } from '@/context/AuthContext'
import { authService } from '@/services/auth'
import { logger } from '@/utils/logger'
import { decodeJwtPayload } from '@/utils/jwt'
import { formatCNPJ, validateCNPJ } from '@/utils/cnpj'
import logoImg from '@/assets/logo.png' // Ensure logo import is handled
// Eye icon components for password visibility toggle
@ -22,12 +24,31 @@ const EyeSlashIcon = () => (
export function LoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login')
// Login form state
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login')
// Register form state
const [registerData, setRegisterData] = useState({
email: '',
username: '',
password: '',
passwordConfirm: '',
name: '',
company_name: '',
cnpj: '',
})
const [showRegisterPassword, setShowRegisterPassword] = useState(false)
const [showRegisterPasswordConfirm, setShowRegisterPasswordConfirm] = useState(false)
const [registerLoading, setRegisterLoading] = useState(false)
const [registerErrorMessage, setRegisterErrorMessage] = useState<string | null>(null)
const [registerValidationErrors, setRegisterValidationErrors] = useState<Record<string, string>>({})
const resolveRole = (role?: string): UserRole => {
logger.info('🔐 [Login] Resolving role:', role)
@ -41,6 +62,108 @@ export function LoginPage() {
}
}
const handleRegisterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
if (name === 'cnpj') {
setRegisterData({
...registerData,
[name]: formatCNPJ(value),
})
} else {
setRegisterData({
...registerData,
[name]: value,
})
}
// Clear validation error for this field
if (registerValidationErrors[name]) {
setRegisterValidationErrors({
...registerValidationErrors,
[name]: '',
})
}
}
const validateRegisterForm = (): boolean => {
const errors: Record<string, string> = {}
if (!registerData.email) errors.email = 'E-mail é obrigatório'
if (!registerData.email.includes('@')) errors.email = 'E-mail inválido'
if (!registerData.username) errors.username = 'Usuário é obrigatório'
if (registerData.username.length < 3) errors.username = 'Usuário deve ter pelo menos 3 caracteres'
if (!registerData.password) errors.password = 'Senha é obrigatória'
if (registerData.password.length < 8) errors.password = 'Senha deve ter pelo menos 8 caracteres'
if (registerData.password !== registerData.passwordConfirm) errors.passwordConfirm = 'As senhas não correspondem'
if (!registerData.name) errors.name = 'Nome é obrigatório'
if (registerData.name.length < 3) errors.name = 'Nome deve ter pelo menos 3 caracteres'
if (!registerData.company_name) errors.company_name = 'Razão Social é obrigatória'
if (!registerData.cnpj) {
errors.cnpj = 'CNPJ é obrigatório'
} else if (registerData.cnpj.replace(/\D/g, '').length !== 14) {
errors.cnpj = 'CNPJ deve ter 14 dígitos'
} else if (!validateCNPJ(registerData.cnpj)) {
errors.cnpj = 'CNPJ inválido. Verifique o dígito verificador'
}
setRegisterValidationErrors(errors)
return Object.keys(errors).length === 0
}
const onRegisterSubmit = async (event: FormEvent) => {
event.preventDefault()
if (!validateRegisterForm()) {
setRegisterErrorMessage('Por favor, corrija os erros no formulário.')
return
}
setRegisterLoading(true)
setRegisterErrorMessage(null)
try {
await authService.register({
email: registerData.email,
username: registerData.username,
password: registerData.password,
name: registerData.name,
company_name: registerData.company_name,
cnpj: registerData.cnpj.replace(/\D/g, ''),
})
// Success - show success message and reset form
setRegisterData({
email: '',
username: '',
password: '',
passwordConfirm: '',
name: '',
company_name: '',
cnpj: '',
})
setActiveTab('login')
setRegisterErrorMessage(null)
} catch (error) {
const fallback = 'Não foi possível criar a conta. Tente novamente.'
if (axios.isAxiosError(error)) {
setRegisterErrorMessage(error.response?.data?.error ?? fallback)
} else if (error instanceof Error) {
setRegisterErrorMessage(error.message)
} else {
setRegisterErrorMessage(fallback)
}
} finally {
setRegisterLoading(false)
}
}
const onSubmit = async (event: FormEvent) => {
event.preventDefault()
setLoading(true)
@ -155,6 +278,12 @@ export function LoginPage() {
</div>
</div>
<div className="flex gap-3 text-sm">
<a href="/forgot-password" className="flex-1 text-blue-600 hover:underline text-center">
Esqueci a senha
</a>
</div>
<div className="pt-2">
<button
type="submit"
@ -176,15 +305,208 @@ export function LoginPage() {
</div>
</form>
) : (
<div className="p-8 text-center text-gray-500 py-12">
<p>Funcionalidade de cadastro em breve.</p>
<form onSubmit={onRegisterSubmit} className="p-8 space-y-4">
{registerErrorMessage && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600 border border-red-100">
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{registerErrorMessage}</span>
</div>
)}
{/* Personal Information */}
<div className="border-b border-gray-200 pb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Informações Pessoais</h3>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Nome Completo *</label>
<input
type="text"
name="name"
placeholder="João Silva"
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
registerValidationErrors.name ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={registerData.name}
onChange={handleRegisterChange}
/>
{registerValidationErrors.name && <p className="text-xs text-red-600 mt-1">{registerValidationErrors.name}</p>}
</div>
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Email *</label>
<input
type="email"
name="email"
placeholder="seu@email.com"
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
registerValidationErrors.email ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={registerData.email}
onChange={handleRegisterChange}
/>
{registerValidationErrors.email && <p className="text-xs text-red-600 mt-1">{registerValidationErrors.email}</p>}
</div>
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Usuário *</label>
<input
type="text"
name="username"
placeholder="joaosilva"
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
registerValidationErrors.username ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={registerData.username}
onChange={handleRegisterChange}
/>
{registerValidationErrors.username && <p className="text-xs text-red-600 mt-1">{registerValidationErrors.username}</p>}
</div>
</div>
</div>
{/* Company Information */}
<div className="border-b border-gray-200 pb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Informações da Empresa</h3>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Razão Social *</label>
<input
type="text"
name="company_name"
placeholder="Farmácia Central LTDA"
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
registerValidationErrors.company_name ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={registerData.company_name}
onChange={handleRegisterChange}
/>
{registerValidationErrors.company_name && <p className="text-xs text-red-600 mt-1">{registerValidationErrors.company_name}</p>}
</div>
<div>
<label className="text-sm font-medium text-gray-700 ml-1">CNPJ (00.000.000/0000-00) *</label>
<input
type="text"
name="cnpj"
placeholder="00.000.000/0000-00"
maxLength={18}
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none font-mono ${
registerValidationErrors.cnpj ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={registerData.cnpj}
onChange={handleRegisterChange}
/>
{registerValidationErrors.cnpj && <p className="text-xs text-red-600 mt-1">{registerValidationErrors.cnpj}</p>}
</div>
</div>
</div>
{/* Password Information */}
<div className="pb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Definir Senha</h3>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Senha *</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-400">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
<input
type={showRegisterPassword ? "text" : "password"}
name="password"
placeholder="••••••••"
className={`w-full rounded-lg border py-2.5 pl-10 pr-10 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
registerValidationErrors.password ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={registerData.password}
onChange={handleRegisterChange}
/>
<button
onClick={() => setActiveTab('login')}
className="mt-4 text-blue-600 hover:underline text-sm"
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
onClick={() => setShowRegisterPassword(!showRegisterPassword)}
>
Voltar para login
{showRegisterPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /></svg>
)}
</button>
</div>
{registerValidationErrors.password && <p className="text-xs text-red-600 mt-1">{registerValidationErrors.password}</p>}
</div>
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Confirmar Senha *</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-400">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
<input
type={showRegisterPasswordConfirm ? "text" : "password"}
name="passwordConfirm"
placeholder="••••••••"
className={`w-full rounded-lg border py-2.5 pl-10 pr-10 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
registerValidationErrors.passwordConfirm ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={registerData.passwordConfirm}
onChange={handleRegisterChange}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
onClick={() => setShowRegisterPasswordConfirm(!showRegisterPasswordConfirm)}
>
{showRegisterPasswordConfirm ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /></svg>
)}
</button>
</div>
{registerValidationErrors.passwordConfirm && <p className="text-xs text-red-600 mt-1">{registerValidationErrors.passwordConfirm}</p>}
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"
className="w-full rounded-xl bg-blue-600 py-3 font-semibold text-white shadow-lg shadow-blue-200 hover:bg-blue-700 hover:shadow-xl active:translate-y-0.5 focus:ring-2 focus:ring-blue-200 transition-all disabled:opacity-70 flex items-center justify-center gap-2"
disabled={registerLoading}
>
{registerLoading ? (
<>
<svg className="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Criando conta...
</>
) : (
<>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" /></svg>
Criar Conta
</>
)}
</button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
tenho uma conta{' '}
<button
type="button"
onClick={() => setActiveTab('login')}
className="text-blue-600 hover:underline font-medium"
>
Entrar
</button>
</p>
</div>
</form>
)}
</div>

View file

@ -0,0 +1,341 @@
import { FormEvent, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import axios from 'axios'
import { authService } from '@/services/auth'
import { formatCNPJ, validateCNPJ } from '@/utils/cnpj'
import logoImg from '@/assets/logo.png'
interface RegistrationFormData {
email: string
username: string
password: string
passwordConfirm: string
name: string
company_name: string
cnpj: string
}
export function RegisterPage() {
const navigate = useNavigate()
const [formData, setFormData] = useState<RegistrationFormData>({
email: '',
username: '',
password: '',
passwordConfirm: '',
name: '',
company_name: '',
cnpj: '',
})
const [showPassword, setShowPassword] = useState(false)
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false)
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
if (name === 'cnpj') {
setFormData({
...formData,
[name]: formatCNPJ(value),
})
} else {
setFormData({
...formData,
[name]: value,
})
}
// Clear validation error for this field
if (validationErrors[name]) {
setValidationErrors({
...validationErrors,
[name]: '',
})
}
}
const validateForm = (): boolean => {
const errors: Record<string, string> = {}
if (!formData.email) errors.email = 'E-mail é obrigatório'
if (!formData.email.includes('@')) errors.email = 'E-mail inválido'
if (!formData.username) errors.username = 'Usuário é obrigatório'
if (formData.username.length < 3) errors.username = 'Usuário deve ter pelo menos 3 caracteres'
if (!formData.password) errors.password = 'Senha é obrigatória'
if (formData.password.length < 8) errors.password = 'Senha deve ter pelo menos 8 caracteres'
if (formData.password !== formData.passwordConfirm) errors.passwordConfirm = 'As senhas não correspondem'
if (!formData.name) errors.name = 'Nome é obrigatório'
if (formData.name.length < 3) errors.name = 'Nome deve ter pelo menos 3 caracteres'
if (!formData.company_name) errors.company_name = 'Razão Social é obrigatória'
if (!formData.cnpj) {
errors.cnpj = 'CNPJ é obrigatório'
} else if (formData.cnpj.replace(/\D/g, '').length !== 14) {
errors.cnpj = 'CNPJ deve ter 14 dígitos'
} else if (!validateCNPJ(formData.cnpj)) {
errors.cnpj = 'CNPJ inválido. Verifique o dígito verificador'
}
setValidationErrors(errors)
return Object.keys(errors).length === 0
}
const onSubmit = async (event: FormEvent) => {
event.preventDefault()
if (!validateForm()) {
setErrorMessage('Por favor, corrija os erros no formulário.')
return
}
setLoading(true)
setErrorMessage(null)
try {
await authService.register({
email: formData.email,
username: formData.username,
password: formData.password,
name: formData.name,
company_name: formData.company_name,
cnpj: formData.cnpj.replace(/\D/g, ''),
})
// Show success and redirect to login
navigate('/login?registered=true')
} catch (error) {
const fallback = 'Não foi possível criar a conta. Tente novamente.'
if (axios.isAxiosError(error)) {
setErrorMessage(error.response?.data?.error ?? fallback)
} else if (error instanceof Error) {
setErrorMessage(error.message)
} else {
setErrorMessage(fallback)
}
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
<div className="w-full max-w-[500px] overflow-hidden rounded-2xl bg-white shadow-xl">
{/* Blue Header with Logo */}
<div className="flex flex-col items-center bg-blue-600 pb-8 pt-10 text-white">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm">
<img src={logoImg} alt="Logo" className="h-10 w-auto brightness-0 invert" />
</div>
<h1 className="text-2xl font-bold">SaveInMed</h1>
<p className="text-blue-100 text-sm">Criar Nova Conta</p>
</div>
{/* Form Content */}
<form onSubmit={onSubmit} className="p-8 space-y-4">
{errorMessage && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600 border border-red-100">
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{errorMessage}</span>
</div>
)}
{/* Personal Information */}
<div className="border-b border-gray-200 pb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Informações Pessoais</h3>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Nome Completo *</label>
<input
type="text"
name="name"
placeholder="João Silva"
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
validationErrors.name ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={formData.name}
onChange={handleChange}
/>
{validationErrors.name && <p className="text-xs text-red-600 mt-1">{validationErrors.name}</p>}
</div>
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Email *</label>
<input
type="email"
name="email"
placeholder="seu@email.com"
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
validationErrors.email ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={formData.email}
onChange={handleChange}
/>
{validationErrors.email && <p className="text-xs text-red-600 mt-1">{validationErrors.email}</p>}
</div>
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Usuário *</label>
<input
type="text"
name="username"
placeholder="joaosilva"
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
validationErrors.username ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={formData.username}
onChange={handleChange}
/>
{validationErrors.username && <p className="text-xs text-red-600 mt-1">{validationErrors.username}</p>}
</div>
</div>
</div>
{/* Company Information */}
<div className="border-b border-gray-200 pb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Informações da Empresa</h3>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Razão Social *</label>
<input
type="text"
name="company_name"
placeholder="Farmácia Central LTDA"
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
validationErrors.company_name ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={formData.company_name}
onChange={handleChange}
/>
{validationErrors.company_name && <p className="text-xs text-red-600 mt-1">{validationErrors.company_name}</p>}
</div>
<div>
<label className="text-sm font-medium text-gray-700 ml-1">CNPJ (Formatação esperada: 00.000.000/0000-00) *</label>
<input
type="text"
name="cnpj"
placeholder="00.000.000/0000-00"
maxLength={18}
className={`w-full rounded-lg border py-2.5 px-3 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none font-mono ${
validationErrors.cnpj ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={formData.cnpj}
onChange={handleChange}
/>
{validationErrors.cnpj && <p className="text-xs text-red-600 mt-1">{validationErrors.cnpj}</p>}
</div>
</div>
</div>
{/* Password Information */}
<div className="pb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Definir Senha</h3>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Senha *</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-400">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
<input
type={showPassword ? "text" : "password"}
name="password"
placeholder="••••••••"
className={`w-full rounded-lg border py-2.5 pl-10 pr-10 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
validationErrors.password ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={formData.password}
onChange={handleChange}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /></svg>
)}
</button>
</div>
{validationErrors.password && <p className="text-xs text-red-600 mt-1">{validationErrors.password}</p>}
</div>
<div>
<label className="text-sm font-medium text-gray-700 ml-1">Confirmar Senha *</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-400">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
<input
type={showPasswordConfirm ? "text" : "password"}
name="passwordConfirm"
placeholder="••••••••"
className={`w-full rounded-lg border py-2.5 pl-10 pr-10 text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-blue-100 transition-all outline-none ${
validationErrors.passwordConfirm ? 'border-red-500 focus:border-red-500' : 'border-gray-200 focus:border-blue-500'
}`}
value={formData.passwordConfirm}
onChange={handleChange}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
onClick={() => setShowPasswordConfirm(!showPasswordConfirm)}
>
{showPasswordConfirm ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /></svg>
)}
</button>
</div>
{validationErrors.passwordConfirm && <p className="text-xs text-red-600 mt-1">{validationErrors.passwordConfirm}</p>}
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"
className="w-full rounded-xl bg-blue-600 py-3 font-semibold text-white shadow-lg shadow-blue-200 hover:bg-blue-700 hover:shadow-xl active:translate-y-0.5 focus:ring-2 focus:ring-blue-200 transition-all disabled:opacity-70 flex items-center justify-center gap-2"
disabled={loading}
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Criando conta...
</>
) : (
<>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" /></svg>
Criar Conta
</>
)}
</button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
tenho uma conta{' '}
<Link to="/login" className="text-blue-600 hover:underline font-medium">
Entrar
</Link>
</p>
</div>
</form>
</div>
</div>
)
}

View file

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { adminService, Company, CreateCompanyRequest } from '@/services/adminService'
import { useCepLookup } from '@/hooks/useCepLookup'
import { formatCNPJ, validateCNPJ } from '@/utils/cnpj'
import { formatPhone } from '@/utils/phone'
export function CompaniesPage() {
const [companies, setCompanies] = useState<Company[]>([])
@ -14,14 +16,19 @@ export function CompaniesPage() {
const [formData, setFormData] = useState<CreateCompanyRequest>({
cnpj: '',
corporate_name: '',
fantasy_name: '',
category: 'farmacia',
license_number: '',
latitude: -16.3281,
longitude: -48.9530,
city: 'Anápolis',
state: 'GO'
state: 'GO',
phone: '',
email: '',
founded_at: '',
})
const [cep, setCep] = useState('')
const [cnpjError, setCnpjError] = useState<string | null>(null)
const { loading: cepLoading, error: cepError, lookup } = useCepLookup()
const handleCepChange = async (value: string) => {
@ -62,15 +69,39 @@ export function CompaniesPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Only validate CNPJ on create (not on edit, since existing CNPJs may be legacy/test values)
if (!editingCompany) {
if (!formData.cnpj) {
setCnpjError('CNPJ é obrigatório')
return
}
const cnpjDigits = formData.cnpj.replace(/\D/g, '')
if (cnpjDigits.length !== 14) {
setCnpjError('CNPJ deve ter 14 dígitos')
return
}
if (!validateCNPJ(formData.cnpj)) {
setCnpjError('CNPJ inválido. Verifique o dígito verificador')
return
}
}
// Strip CNPJ formatting before sending to API
const payload = {
...formData,
cnpj: formData.cnpj.replace(/\D/g, ''),
}
try {
if (editingCompany) {
await adminService.updateCompany(editingCompany.id, formData)
await adminService.updateCompany(editingCompany.id, payload)
} else {
await adminService.createCompany(formData)
await adminService.createCompany(payload)
}
setShowModal(false)
resetForm()
loadCompanies()
void loadCompanies()
} catch (err) {
console.error('Error saving company:', err)
alert('Erro ao salvar empresa')
@ -99,38 +130,52 @@ export function CompaniesPage() {
const openEdit = async (company: Company) => {
setEditingCompany(company)
setCnpjError(null)
setFormData({
cnpj: company.cnpj,
corporate_name: company.corporate_name,
category: company.category,
license_number: company.license_number,
latitude: company.latitude,
longitude: company.longitude,
city: company.city,
state: company.state
cnpj: formatCNPJ(company.cnpj ?? ''),
corporate_name: company.corporate_name ?? '',
fantasy_name: company.fantasy_name ?? '',
category: company.category ?? 'farmacia',
license_number: company.license_number ?? '',
latitude: typeof company.latitude === 'number' ? company.latitude : -16.3281,
longitude: typeof company.longitude === 'number' ? company.longitude : -48.9530,
city: company.city ?? '',
state: company.state ?? '',
phone: company.phone ?? '',
email: company.email ?? '',
founded_at: company.founded_at
? company.founded_at.slice(0, 10)
: '',
})
setShowModal(true)
try {
const docs = await adminService.getCompanyDocuments(company.id)
setCompanyDocuments(docs)
setCompanyDocuments(Array.isArray(docs) ? docs : [])
} catch (err) {
console.error('Failed to load company docs', err)
setCompanyDocuments([])
}
}
const resetForm = () => {
setEditingCompany(null)
setCep('')
setCnpjError(null)
setCompanyDocuments([])
setFormData({
cnpj: '',
corporate_name: '',
fantasy_name: '',
category: 'farmacia',
license_number: '',
latitude: -16.3281,
longitude: -48.9530,
city: 'Anápolis',
state: 'GO'
state: 'GO',
phone: '',
email: '',
founded_at: '',
})
}
@ -176,7 +221,8 @@ export function CompaniesPage() {
</div>
<button
onClick={openCreate}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
className="rounded-lg px-4 py-2 text-sm font-medium text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
+ Nova Empresa
</button>
@ -185,7 +231,7 @@ export function CompaniesPage() {
{/* Table */}
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900 text-white">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Razão Social</th>
<th className="px-4 py-3 text-left text-sm font-medium">CNPJ</th>
@ -278,51 +324,125 @@ export function CompaniesPage() {
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 text-xl font-bold">
{editingCompany ? 'Editar Empresa' : 'Nova Empresa'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* CNPJ */}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">
CNPJ
{editingCompany && <span className="ml-2 text-xs text-gray-400">(não editável)</span>}
</label>
<input
type="text"
value={formData.cnpj}
onChange={(e) => {
const formatted = formatCNPJ(e.target.value)
setFormData({ ...formData, cnpj: formatted })
if (cnpjError) setCnpjError(null)
}}
maxLength={18}
placeholder="00.000.000/0000-00"
disabled={!!editingCompany}
className={`mt-1 w-full rounded border px-3 py-2 font-mono focus:ring-1 outline-none ${editingCompany
? 'border-gray-200 bg-gray-100 text-gray-500 cursor-not-allowed'
: cnpjError
? 'border-red-500 bg-red-50 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}`}
required={!editingCompany}
/>
{cnpjError && (
<p className="mt-1 text-xs text-red-600">{cnpjError}</p>
)}
</div>
{/* Nome Fantasia */}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Nome Fantasia</label>
<input
type="text"
value={formData.fantasy_name || ''}
onChange={(e) => setFormData({ ...formData, fantasy_name: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
placeholder="Nome fantasia da empresa"
/>
</div>
{/* Razão Social */}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Razão Social</label>
<input
type="text"
value={formData.corporate_name}
onChange={(e) => setFormData({ ...formData, corporate_name: e.target.value })}
className="mt-1 w-full rounded border px-3 py-2"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
{/* Data de Abertura */}
<div>
<label className="block text-sm font-medium text-gray-700">CNPJ</label>
<label className="block text-sm font-medium text-gray-700">Data de Abertura</label>
<input
type="text"
value={formData.cnpj}
onChange={(e) => setFormData({ ...formData, cnpj: e.target.value })}
className="mt-1 w-full rounded border px-3 py-2"
required
type="date"
value={formData.founded_at || ''}
onChange={(e) => setFormData({ ...formData, founded_at: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
</div>
{/* Categoria */}
<div>
<label className="block text-sm font-medium text-gray-700">Categoria</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="mt-1 w-full rounded border px-3 py-2"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
>
<option value="farmacia">Farmácia</option>
<option value="distribuidora">Distribuidora</option>
<option value="admin">Admin</option>
</select>
</div>
{/* Telefone */}
<div>
<label className="block text-sm font-medium text-gray-700">Telefone</label>
<input
type="tel"
value={formData.phone || ''}
onChange={(e) => setFormData({ ...formData, phone: formatPhone(e.target.value) })}
placeholder="(00) 00000-0000"
maxLength={15}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="contato@empresa.com.br"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
</div>
{/* Licença Sanitária */}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Licença Sanitária (Número)</label>
<input
type="text"
value={formData.license_number}
onChange={(e) => setFormData({ ...formData, license_number: e.target.value })}
className="mt-1 w-full rounded border px-3 py-2"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
@ -358,15 +478,15 @@ export function CompaniesPage() {
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
{companyDocuments.length > 0 && (
{(companyDocuments ?? []).length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium text-gray-700 mb-2">Documentos Anexados:</p>
<ul className="space-y-2">
{companyDocuments.map(doc => (
{(companyDocuments ?? []).map(doc => (
<li key={doc.id} className="flex items-center justify-between p-2 text-sm border rounded bg-gray-50">
<div className="flex items-center">
<span className="font-semibold text-gray-800 mr-2">{doc.type}:</span>
<span className="text-gray-600 truncate max-w-xs">{doc.url.split('/').pop()}</span>
<span className="text-gray-600 truncate max-w-xs">{(doc.url ?? '').split('/').pop()}</span>
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">{doc.status}</span>
</div>
<a href={(import.meta.env.VITE_API_URL?.replace(/\/api\/?$/, '') || 'http://localhost:8214') + doc.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 ml-4 font-medium">
@ -390,7 +510,7 @@ export function CompaniesPage() {
onChange={(e) => handleCepChange(e.target.value)}
placeholder="00000-000"
maxLength={9}
className="mt-1 w-full rounded border px-3 py-2"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
{cepError && <p className="mt-1 text-xs text-red-500">{cepError}</p>}
</div>
@ -400,7 +520,7 @@ export function CompaniesPage() {
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="mt-1 w-full rounded border px-3 py-2"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
@ -410,7 +530,7 @@ export function CompaniesPage() {
type="text"
value={formData.state}
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
className="mt-1 w-full rounded border px-3 py-2"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
maxLength={2}
required
/>
@ -420,9 +540,12 @@ export function CompaniesPage() {
<input
type="number"
step="any"
value={formData.latitude}
onChange={(e) => setFormData({ ...formData, latitude: parseFloat(e.target.value) })}
className="mt-1 w-full rounded border px-3 py-2"
value={Number.isFinite(formData.latitude) ? formData.latitude : ''}
onChange={(e) => {
const v = parseFloat(e.target.value)
setFormData({ ...formData, latitude: Number.isFinite(v) ? v : 0 })
}}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
@ -431,9 +554,12 @@ export function CompaniesPage() {
<input
type="number"
step="any"
value={formData.longitude}
onChange={(e) => setFormData({ ...formData, longitude: parseFloat(e.target.value) })}
className="mt-1 w-full rounded border px-3 py-2"
value={Number.isFinite(formData.longitude) ? formData.longitude : ''}
onChange={(e) => {
const v = parseFloat(e.target.value)
setFormData({ ...formData, longitude: Number.isFinite(v) ? v : 0 })
}}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
@ -448,7 +574,8 @@ export function CompaniesPage() {
</button>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
className="rounded px-4 py-2 text-sm text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
Salvar
</button>

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { apiClient } from '@/services/apiClient'
import { FaUserGroup, FaBuilding, FaBoxOpen, FaClipboardList } from 'react-icons/fa6'
interface DashboardStats {
totalUsers: number
@ -76,10 +77,10 @@ export function DashboardHome() {
}
const cards = [
{ title: 'Usuários', value: stats.totalUsers, icon: '👥', color: 'from-blue-500 to-blue-600' },
{ title: 'Empresas', value: stats.totalCompanies, icon: '🏢', color: 'from-purple-500 to-purple-600' },
{ title: 'Produtos', value: stats.totalProducts, icon: '💊', color: 'from-green-500 to-green-600' },
{ title: 'Pedidos', value: stats.totalOrders, icon: '📦', color: 'from-orange-500 to-orange-600' }
{ title: 'Usuários', value: stats.totalUsers, icon: <FaUserGroup size={26} color="black" /> },
{ title: 'Empresas', value: stats.totalCompanies, icon: <FaBuilding size={26} color="black" /> },
{ title: 'Produtos', value: stats.totalProducts, icon: <FaBoxOpen size={26} color="black" /> },
{ title: 'Pedidos', value: stats.totalOrders, icon: <FaClipboardList size={26} color="black" /> },
]
return (
@ -91,16 +92,16 @@ export function DashboardHome() {
{cards.map((card) => (
<div
key={card.title}
className={`rounded-xl bg-gradient-to-br ${card.color} p-6 text-white shadow-lg`}
className="rounded-xl p-6 shadow-lg bg-white"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/80">{card.title}</p>
<p className="mt-1 text-3xl font-bold">
<p className="text-sm font-medium text-gray-500">{card.title}</p>
<p className="mt-1 text-3xl font-bold text-gray-900">
{loading ? '...' : card.value.toLocaleString('pt-BR')}
</p>
</div>
<span className="text-4xl opacity-80">{card.icon}</span>
<span>{card.icon}</span>
</div>
</div>
))}

View file

@ -60,7 +60,7 @@ export function LogisticsPage() {
{/* Table */}
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900 text-white">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Data</th>
<th className="px-4 py-3 text-left text-sm font-medium">Transportadora</th>

View file

@ -85,7 +85,7 @@ export function OrdersPage() {
{/* Table */}
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900 text-white">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">ID</th>
<th className="px-4 py-3 text-left text-sm font-medium">Data</th>

View file

@ -160,7 +160,8 @@ export function ProductsPage() {
<h1 className="text-2xl font-bold text-gray-900">Produtos</h1>
<button
onClick={openCreate}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
className="rounded-lg px-4 py-2 text-sm font-medium text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
+ Novo Produto
</button>
@ -169,7 +170,7 @@ export function ProductsPage() {
{/* Table */}
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900 text-white">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Produto</th>
<th className="px-4 py-3 text-left text-sm font-medium">Loja</th>
@ -276,7 +277,7 @@ export function ProductsPage() {
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 text-xl font-bold">
{editingProduct ? 'Editar Produto' : 'Novo Produto'}
@ -372,7 +373,8 @@ export function ProductsPage() {
</button>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
className="rounded px-4 py-2 text-sm text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
Salvar
</button>

View file

@ -144,7 +144,8 @@ export function ProfilePage() {
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-blue-300"
className="flex w-full justify-center rounded-md px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
style={{ backgroundColor: '#0F4C81' }}
>
{loading ? 'Salvando...' : 'Salvar Alterações'}
</button>

View file

@ -59,7 +59,7 @@ export function ReviewsPage() {
{/* Table */}
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900 text-white">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Data</th>
<th className="px-4 py-3 text-left text-sm font-medium">Pedido</th>

View file

@ -3,6 +3,8 @@ import { MapContainer, TileLayer, Circle, useMapEvents, Marker, Popup } from 're
import 'leaflet/dist/leaflet.css'
import { adminService, ShippingSettings } from '@/services/adminService'
import { useAuth } from '@/context/AuthContext'
import { FaTruck, FaLocationDot, FaStore, FaCircleInfo, FaFloppyDisk } from 'react-icons/fa6'
import { Shell } from '@/layouts/Shell'
import L from 'leaflet'
@ -103,14 +105,24 @@ export function ShippingSettingsPage() {
const center: [number, number] = [settings.latitude, settings.longitude]
if (loading) return <div className="p-8 text-center">Carregando configurações de frete...</div>
if (loading) return (
<Shell>
<div className="p-8 text-center text-gray-500">Carregando configurações de frete...</div>
</Shell>
)
return (
<div className="p-6 max-w-6xl mx-auto space-y-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<span className="text-2xl">🚚</span>
Configurações de Entrega e Retirada
</h1>
<Shell>
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gray-100">
<FaTruck size={24} color="black" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Configurações de Entrega e Retirada</h1>
<p className="text-sm text-gray-500">Defina as opções de frete e retirada da sua loja</p>
</div>
</div>
<form onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
@ -118,12 +130,12 @@ export function ShippingSettingsPage() {
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="text-xl">📍</span>
<FaLocationDot size={18} color="black" />
Entrega Própria
</h2>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" checked={settings.active} onChange={e => setSettings({ ...settings, active: e.target.checked })} />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all" style={{ '--tw-ring-color': '#0F4C81' } as React.CSSProperties}></div>
<span className="ml-3 text-sm font-medium text-gray-900">{settings.active ? 'Ativado' : 'Desativado'}</span>
</label>
</div>
@ -215,7 +227,7 @@ export function ShippingSettingsPage() {
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-fit">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="text-xl">🏪</span>
<FaStore size={18} color="black" />
Retirada na Loja
</h2>
<label className="relative inline-flex items-center cursor-pointer">
@ -245,8 +257,8 @@ export function ShippingSettingsPage() {
onChange={e => setSettings({ ...settings, pickup_hours: e.target.value })}
/>
</div>
<div className="bg-blue-50 p-4 rounded-lg flex gap-3 text-blue-700 text-sm">
<span className="text-xl"></span>
<div className="bg-blue-50 p-4 rounded-lg flex gap-3 text-sm" style={{ color: '#0F4C81' }}>
<FaCircleInfo size={18} className="flex-shrink-0 mt-0.5" />
<p>Ao ativar a retirada, os clientes poderão escolher buscar o pedido na loja sem custo de frete.</p>
</div>
</div>
@ -257,12 +269,14 @@ export function ShippingSettingsPage() {
<button
onClick={handleSave}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors disabled:opacity-50"
className="flex items-center gap-2 px-6 py-2.5 rounded-lg font-semibold text-white hover:opacity-90 transition disabled:opacity-50"
style={{ backgroundColor: '#0F4C81' }}
>
<FaFloppyDisk size={16} color="white" />
{saving ? 'Salvando...' : 'Salvar Configurações'}
<span className="text-sm">💾</span>
</button>
</div>
</div>
</Shell>
)
}

View file

@ -114,7 +114,8 @@ export function UsersPage() {
<h1 className="text-2xl font-bold text-gray-900">Usuários</h1>
<button
onClick={openCreate}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
className="rounded-lg px-4 py-2 text-sm font-medium text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
+ Novo Usuário
</button>
@ -123,7 +124,7 @@ export function UsersPage() {
{/* Table */}
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900 text-white">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Nome</th>
<th className="px-4 py-3 text-left text-sm font-medium">Username</th>
@ -204,7 +205,7 @@ export function UsersPage() {
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h2 className="mb-4 text-xl font-bold">
{editingUser ? 'Editar Usuário' : 'Novo Usuário'}
@ -291,7 +292,8 @@ export function UsersPage() {
</button>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
className="rounded px-4 py-2 text-sm text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
Salvar
</button>

View file

@ -1,29 +1,336 @@
import { useState, useEffect } from 'react'
import { useAuth } from '@/context/AuthContext'
import { Header } from '@/components/Header'
import { Package, MapPin, Clock, CheckCircle, AlertCircle } from 'lucide-react'
interface OrderItem {
id: string
product_name: string
quantity: number
unit_price: number
}
interface Address {
street: string
number: string
city: string
state: string
zip: string
}
interface Order {
id: string
number: string
status: string
seller: {
id: string
name: string
company_name: string
}
items: OrderItem[]
shipping_address: Address
total_amount: number
created_at: string
ready_for_delivery_at?: string
}
export function DeliveryDashboardPage() {
const { user, logout } = useAuth()
const { user } = useAuth()
const [orders, setOrders] = useState<Order[]>([])
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedOrder, setExpandedOrder] = useState<string | null>(null)
useEffect(() => {
setLoading(true)
setTimeout(() => {
setOrders([
{
id: '1',
number: 'PED-001',
status: 'ready_for_delivery',
seller: {
id: 'seller-1',
name: 'Farmácia Central',
company_name: 'Farmácia Central LTDA',
},
items: [
{ id: 'item-1', product_name: 'Dipirona 500mg', quantity: 5, unit_price: 15.90 },
{ id: 'item-2', product_name: 'Vitamina C 1000mg', quantity: 3, unit_price: 28.50 },
],
shipping_address: {
street: 'Rua das Flores',
number: '123',
city: 'São Paulo',
state: 'SP',
zip: '01234-567',
},
total_amount: 165.20,
created_at: '2024-02-25T10:30:00Z',
ready_for_delivery_at: '2024-02-26T08:00:00Z',
},
{
id: '2',
number: 'PED-002',
status: 'ready_for_delivery',
seller: {
id: 'seller-2',
name: 'Distribuidora MedPharma',
company_name: 'MedPharma Distribuidora LTDA',
},
items: [
{ id: 'item-3', product_name: 'Ibuprofeno 400mg', quantity: 10, unit_price: 12.30 },
{ id: 'item-4', product_name: 'Paracetamol 750mg', quantity: 8, unit_price: 8.90 },
],
shipping_address: {
street: 'Av. Paulista',
number: '1000',
city: 'São Paulo',
state: 'SP',
zip: '01311-100',
},
total_amount: 194.40,
created_at: '2024-02-25T14:20:00Z',
ready_for_delivery_at: '2024-02-26T09:00:00Z',
},
])
setLoading(false)
}, 800)
}, [])
const handleAcceptDelivery = (orderId: string) => {
setOrders(orders.map(order =>
order.id === orderId ? { ...order, status: 'in_transit' } : order
))
}
const handleStartDelivery = (orderId: string) => {
setOrders(orders.map(order =>
order.id === orderId ? { ...order, status: 'shipped' } : order
))
}
const handleCompleteDelivery = (orderId: string) => {
setOrders(orders.map(order =>
order.id === orderId ? { ...order, status: 'delivered' } : order
))
}
const pendingOrders = orders.filter(o => ['ready_for_delivery', 'in_transit'].includes(o.status))
const completedOrders = orders.filter(o => ['delivered', 'completed'].includes(o.status))
const displayOrders = activeTab === 'pending' ? pendingOrders : completedOrders
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="mx-auto max-w-7xl">
<div className="flex items-center justify-between rounded-lg bg-white p-6 shadow">
<div>
<h1 className="text-2xl font-bold text-gray-900">Painel do Entregador</h1>
<p className="text-gray-600">Bem-vindo, {user?.name}</p>
<div className="min-h-screen bg-gray-100">
<Header />
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Page Title */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Minhas Entregas</h1>
<p className="text-sm text-gray-500 mt-1">
Olá, {user?.name} gerencie suas entregas pendentes e histórico
</p>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-1 border-b border-gray-200">
<button
onClick={logout}
className="rounded bg-red-600 px-4 py-2 font-bold text-white hover:bg-red-700"
onClick={() => setActiveTab('pending')}
className={`pb-3 px-4 text-sm font-medium transition-colors ${activeTab === 'pending'
? 'border-b-2 text-white rounded-t-lg px-4 py-2'
: 'text-gray-500 hover:text-gray-900'
}`}
style={activeTab === 'pending' ? { borderColor: '#0F4C81', backgroundColor: '#0F4C81' } : {}}
>
Sair
Entregas Disponíveis ({pendingOrders.length})
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-4 text-sm font-medium transition-colors ${activeTab === 'history'
? 'border-b-2 text-white rounded-t-lg px-4 py-2'
: 'text-gray-500 hover:text-gray-900'
}`}
style={activeTab === 'history' ? { borderColor: '#0F4C81', backgroundColor: '#0F4C81' } : {}}
>
Histórico ({completedOrders.length})
</button>
</div>
<div className="mt-8 rounded-lg bg-white p-6 shadow">
<h3 className="text-lg font-bold">Minhas Entregas</h3>
<p className="mt-2 text-gray-600">Visualize as entregas pendentes e o mapa de rotas.</p>
{/* Map Integration would go here */}
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-16">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-gray-200" style={{ borderTopColor: '#0F4C81' }} />
</div>
)}
{/* Error */}
{error && (
<div className="mb-6 flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
<div>
<h3 className="font-semibold text-red-900">Erro ao carregar entregas</h3>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
)}
{/* Empty State */}
{!loading && displayOrders.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 shadow">
<Package className="mb-4 h-16 w-16 text-gray-300" />
<h3 className="text-lg font-semibold text-gray-700">
{activeTab === 'pending' ? 'Nenhuma entrega disponível' : 'Nenhuma entrega concluída'}
</h3>
<p className="mt-1 max-w-xs text-center text-sm text-gray-500">
{activeTab === 'pending'
? 'Você será notificado quando novas entregas estiverem prontas'
: 'Suas entregas concluídas aparecerão aqui'}
</p>
</div>
)}
{/* Orders Grid */}
{!loading && displayOrders.length > 0 && (
<div className="grid gap-4 lg:grid-cols-2">
{displayOrders.map((order) => (
<div
key={order.id}
className="overflow-hidden rounded-xl bg-white shadow hover:shadow-md transition-shadow"
>
{/* Order Header */}
<div className="flex items-center justify-between px-5 py-4" style={{ backgroundColor: '#0F4C81' }}>
<div>
<h3 className="text-base font-bold text-white">{order.number}</h3>
<p className="text-xs text-white/70">{order.seller.company_name}</p>
</div>
<span className="text-xl font-bold text-white">
R$ {order.total_amount.toFixed(2)}
</span>
</div>
{/* Order Body */}
<div className="space-y-3 px-5 py-4">
{/* Status Badge */}
<div>
{order.status === 'ready_for_delivery' && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-800">
<Clock className="h-4 w-4" />
Pronto para Entrega
</span>
)}
{order.status === 'in_transit' && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800">
<AlertCircle className="h-4 w-4" />
Saiu para Entrega
</span>
)}
{order.status === 'delivered' && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-800">
<CheckCircle className="h-4 w-4" />
Entregue
</span>
)}
</div>
{/* Address */}
<div className="flex items-start gap-3 rounded-lg bg-gray-50 p-3">
<MapPin className="mt-0.5 h-5 w-5 flex-shrink-0" style={{ color: '#0F4C81' }} />
<div>
<p className="text-sm font-semibold text-gray-900">Endereço de Entrega</p>
<p className="text-sm text-gray-600">
{order.shipping_address.street}, {order.shipping_address.number}
</p>
<p className="text-xs text-gray-500">
{order.shipping_address.city}, {order.shipping_address.state} {order.shipping_address.zip}
</p>
</div>
</div>
<p className="text-sm text-gray-500">
<span className="font-semibold text-gray-700">{order.items.length}</span> {order.items.length === 1 ? 'item' : 'itens'}
</p>
</div>
{/* Expandable Items */}
<div className="border-t border-gray-100">
<button
onClick={() => setExpandedOrder(expandedOrder === order.id ? null : order.id)}
className="flex w-full items-center justify-between px-5 py-3 text-sm font-medium text-gray-500 hover:bg-gray-50 transition-colors"
>
<span>{expandedOrder === order.id ? 'Ocultar' : 'Ver'} detalhes dos itens</span>
<svg
className={`h-4 w-4 transition-transform ${expandedOrder === order.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedOrder === order.id && (
<div className="border-t border-gray-100 bg-gray-50 px-5 py-4">
<div className="space-y-2">
{order.items.map((item) => (
<div key={item.id} className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-800">{item.product_name}</p>
<p className="text-xs text-gray-500">Qtd: {item.quantity}</p>
</div>
<p className="text-sm font-semibold text-gray-900">
R$ {(item.unit_price * item.quantity).toFixed(2)}
</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Action Buttons */}
{activeTab === 'pending' && (
<div className="flex gap-3 border-t border-gray-100 bg-gray-50 px-5 py-4">
{order.status === 'ready_for_delivery' && (
<>
<button
onClick={() => handleAcceptDelivery(order.id)}
className="flex-1 rounded-lg bg-green-600 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
>
Aceitar Entrega
</button>
<button
disabled
className="flex-1 cursor-not-allowed rounded-lg bg-gray-200 py-2 text-sm font-medium text-gray-400"
>
Recusar
</button>
</>
)}
{order.status === 'in_transit' && (
<>
<button
onClick={() => handleStartDelivery(order.id)}
className="flex-1 rounded-lg py-2 text-sm font-medium text-white hover:opacity-90 transition-colors"
style={{ backgroundColor: '#0F4C81' }}
>
Saiu para Entrega
</button>
<button
onClick={() => handleCompleteDelivery(order.id)}
className="flex-1 rounded-lg bg-green-600 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
>
Marcar Entregue
</button>
</>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)

View file

@ -1,48 +1,58 @@
import { useAuth } from '@/context/AuthContext'
import { Link } from 'react-router-dom'
import { Header } from '@/components/Header'
import { FaMagnifyingGlass, FaClipboardList } from 'react-icons/fa6'
export function EmployeeDashboardPage() {
const { user, logout } = useAuth()
const { user } = useAuth()
const cards = [
{
title: 'Comprar Medicamentos',
description: 'Encontre medicamentos próximos à venda.',
path: '/search',
icon: <FaMagnifyingGlass size={32} color="black" />,
},
{
title: 'Pedidos',
description: 'Visualizar e acompanhar seus pedidos.',
path: '/orders',
icon: <FaClipboardList size={32} color="black" />,
},
]
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="mx-auto max-w-7xl">
<div className="flex items-center justify-between rounded-lg bg-white p-6 shadow">
<div>
<div className="min-h-screen bg-gray-100">
<Header />
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Page Title */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Painel do Colaborador</h1>
<p className="text-gray-600">Bem-vindo, {user?.name}</p>
</div>
<div className="flex gap-3">
<Link
to="/search"
className="rounded bg-green-600 px-4 py-2 font-bold text-white hover:bg-green-700"
>
🛒 Comprar Medicamentos
</Link>
<button
onClick={logout}
className="rounded bg-red-600 px-4 py-2 font-bold text-white hover:bg-red-700"
>
Sair
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
Olá, {user?.name} acesse os recursos disponíveis para você
</p>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-3">
<Link to="/search" className="rounded-lg bg-white p-6 shadow hover:shadow-lg transition-shadow">
<h3 className="text-lg font-bold text-green-600">🛒 Comprar Medicamentos</h3>
<p className="mt-2 text-gray-600">Encontrar medicamentos próximos à venda.</p>
{/* Cards Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card) => (
<Link
key={card.path}
to={card.path}
className="group flex items-center gap-4 rounded-xl bg-white p-6 shadow hover:shadow-md transition-all"
>
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-gray-100 group-hover:bg-gray-200 transition-colors">
{card.icon}
</div>
<div>
<h3 className="text-base font-semibold text-gray-900 group-hover:text-[#0F4C81] transition-colors">
{card.title}
</h3>
<p className="mt-0.5 text-sm text-gray-500">{card.description}</p>
</div>
</Link>
<div className="rounded-lg bg-white p-6 shadow">
<h3 className="text-lg font-bold">Pedidos</h3>
<p className="mt-2 text-gray-600">Gerenciar pedidos recebidos.</p>
{/* Link to Orders */}
</div>
<div className="rounded-lg bg-white p-6 shadow">
<h3 className="text-lg font-bold">Estoque</h3>
<p className="mt-2 text-gray-600">Consultar e ajustar estoque.</p>
{/* Link to Inventory */}
</div>
))}
</div>
</div>
</div>

View file

@ -112,7 +112,8 @@ export function InventoryPage() {
/>
<button
onClick={handleImportClick}
className="rounded border border-blue-600 px-4 py-2 text-sm font-semibold text-blue-600 hover:bg-blue-50"
className="rounded border px-4 py-2 text-sm font-semibold hover:opacity-90"
style={{ borderColor: '#0F4C81', color: '#0F4C81' }}
>
Importar CSV
</button>
@ -124,7 +125,8 @@ export function InventoryPage() {
</button>
<Link
to="/products/new"
className="rounded bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
className="rounded px-4 py-2 text-sm font-semibold text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
+ Cadastrar Produto
</Link>
@ -149,14 +151,14 @@ export function InventoryPage() {
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Produto</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Lote</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Validade</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-gray-600">Quantidade</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-gray-600">Preço</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase text-gray-600">Ações</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">Produto</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">Lote</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">Validade</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-white">Quantidade</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-white">Preço</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase text-white">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { Shell } from '@/layouts/Shell'
import { apiClient } from '@/services/apiClient'
import { formatCents } from '@/utils/format'
import { FaChartLine, FaBoxOpen, FaReceipt, FaArrowsRotate } from 'react-icons/fa6'
interface SellerDashboardData {
seller_id: string
@ -22,6 +23,7 @@ interface SellerDashboardData {
}
export function SellerDashboardPage() {
const { user } = useAuth()
const [data, setData] = useState<SellerDashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -52,74 +54,92 @@ export function SellerDashboardPage() {
const formatCurrency = (cents: number | undefined | null) => formatCents(cents)
const kpiCards = data ? [
{
label: 'Total de Vendas',
value: formatCurrency(data.total_sales_cents),
icon: <FaChartLine size={28} color="black" />,
},
{
label: 'Pedidos',
value: String(data.orders_count),
icon: <FaBoxOpen size={28} color="black" />,
},
{
label: 'Ticket Médio',
value: data.orders_count > 0
? formatCurrency(data.total_sales_cents / data.orders_count)
: 'R$ 0,00',
icon: <FaReceipt size={28} color="black" />,
},
] : []
return (
<Shell>
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Dashboard do Vendedor</h1>
<p className="text-sm text-gray-600">Métricas e indicadores de performance</p>
<h1 className="text-2xl font-bold text-gray-900">Dashboard do Vendedor</h1>
<p className="text-sm text-gray-500 mt-0.5">
Olá, {user?.name} · métricas e indicadores de performance
</p>
</div>
<div className="flex gap-3">
<Link
to="/search"
className="rounded bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
>
🛒 Comprar Medicamentos
</Link>
<button
onClick={loadDashboard}
className="rounded bg-medicalBlue px-4 py-2 text-sm font-semibold text-white"
className="flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition"
style={{ backgroundColor: '#0F4C81' }}
>
<FaArrowsRotate size={14} />
Atualizar
</button>
</div>
</div>
{/* Loading */}
{loading && (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-medicalBlue"></div>
<div className="flex justify-center py-10">
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: '#0F4C81' }}></div>
</div>
)}
{/* Error */}
{error && (
<div className="rounded bg-red-100 p-4 text-red-700">{error}</div>
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-red-700 text-sm">{error}</div>
)}
{data && (
<>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 p-6 text-white">
<p className="text-sm opacity-80">Total de Vendas</p>
<p className="text-3xl font-bold">{formatCurrency(data.total_sales_cents)}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{kpiCards.map((card) => (
<div key={card.label} className="rounded-xl bg-white p-6 shadow flex items-center gap-4">
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-gray-100">
{card.icon}
</div>
<div className="rounded-lg bg-gradient-to-br from-green-500 to-green-600 p-6 text-white">
<p className="text-sm opacity-80">Pedidos</p>
<p className="text-3xl font-bold">{data.orders_count}</p>
<div>
<p className="text-sm text-gray-500">{card.label}</p>
<p className="text-2xl font-bold text-gray-900 mt-0.5">{card.value}</p>
</div>
<div className="rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 p-6 text-white">
<p className="text-sm opacity-80">Ticket Médio</p>
<p className="text-3xl font-bold">
{data.orders_count > 0
? formatCurrency(data.total_sales_cents / data.orders_count)
: 'R$ 0,00'}
</p>
</div>
))}
</div>
{/* Bottom cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Products */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<div className="rounded-xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Top Produtos</h2>
{data.top_products.length === 0 ? (
<p className="text-gray-500 text-sm">Nenhum produto vendido ainda</p>
<p className="text-gray-400 text-sm">Nenhum produto vendido ainda</p>
) : (
<div className="space-y-3">
{data.top_products.map((product, idx) => (
<div key={product.product_id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-sm font-semibold">
<span
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ backgroundColor: '#0F4C81' }}
>
{idx + 1}
</span>
<div>
@ -127,7 +147,7 @@ export function SellerDashboardPage() {
<p className="text-xs text-gray-500">{product.total_quantity} unidades</p>
</div>
</div>
<span className="font-semibold text-medicalBlue">
<span className="font-semibold text-gray-800">
{formatCurrency(product.revenue_cents)}
</span>
</div>
@ -137,18 +157,21 @@ export function SellerDashboardPage() {
</div>
{/* Low Stock Alerts */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Alertas de Estoque Baixo
<div className="rounded-xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<span className="text-amber-500"></span>
Alertas de Estoque Baixo
</h2>
{data.low_stock_alerts.length === 0 ? (
<p className="text-green-600 text-sm"> Todos os produtos com estoque adequado</p>
<p className="text-green-600 text-sm flex items-center gap-1">
<span></span> Todos os produtos com estoque adequado
</p>
) : (
<div className="space-y-3">
{data.low_stock_alerts.map((product) => (
<div
key={product.id}
className="flex items-center justify-between rounded bg-red-50 p-3"
className="flex items-center justify-between rounded-lg bg-red-50 px-4 py-3"
>
<span className="font-medium text-gray-800">{product.name}</span>
<span className="rounded bg-red-100 px-2 py-1 text-xs font-semibold text-red-800">

View file

@ -19,10 +19,10 @@ interface InviteForm {
}
const roleLabels: Record<string, string> = {
'Dono': '👑 Dono',
'Gerente': '🏢 Gerente',
'Comprador': '🛒 Comprador',
'Colaborador': '👤 Colaborador',
'Dono': 'Dono',
'Gerente': 'Gerente',
'Comprador': 'Comprador',
'Colaborador': 'Colaborador',
}
export function TeamPage() {
@ -81,13 +81,14 @@ export function TeamPage() {
<div className="max-w-4xl mx-auto p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">👥 Minha Equipe</h1>
<h1 className="text-2xl font-bold text-gray-900">Minha Equipe</h1>
<p className="text-sm text-gray-500 mt-1">Gerencie os membros da sua farmácia</p>
</div>
{canInvite && (
<button
onClick={() => setInviteOpen(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
className="px-4 py-2 rounded-lg font-medium text-white hover:opacity-90 transition"
style={{ backgroundColor: '#0F4C81' }}
>
+ Convidar Membro
</button>
@ -105,12 +106,12 @@ export function TeamPage() {
) : (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<thead className="text-white border-b border-gray-200" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Nome</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Função</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Desde</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Nome</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Função</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Desde</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
@ -192,7 +193,8 @@ export function TeamPage() {
<button
onClick={handleInvite}
disabled={inviting}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
className="px-4 py-2 rounded-lg text-white transition disabled:opacity-50 hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
{inviting ? 'Convidando...' : 'Convidar'}
</button>

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Shell } from '@/layouts/Shell'
import { financialService, LedgerEntry } from '@/services/financialService'
import { FaMoneyCheck } from 'react-icons/fa6'
export function WalletPage() {
const [balance, setBalance] = useState<number>(0)
@ -65,8 +66,9 @@ export function WalletPage() {
}
}
const formatCurrency = (cents: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(cents / 100)
const formatCurrency = (cents: number | null | undefined) => {
const safe = typeof cents === 'number' && !isNaN(cents) ? cents : 0
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(safe / 100)
}
return (
@ -80,14 +82,16 @@ export function WalletPage() {
<button
onClick={handleWithdrawal}
disabled={requesting || balance <= 0}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg font-medium shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="px-6 py-2 rounded-lg font-medium shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-white hover:opacity-90 transition"
style={{ backgroundColor: '#0F4C81' }}
>
{requesting ? 'Processando...' : '💸 Solicitar Saque'}
<FaMoneyCheck size={18} color="white" />
{requesting ? 'Processando...' : 'Solicitar Saque'}
</button>
</div>
{/* Balance Card */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 rounded-xl shadow-lg p-8 text-white">
<div className="rounded-xl shadow-lg p-8 text-white" style={{ backgroundColor: '#0F4C81' }}>
<p className="text-blue-100 uppercase text-xs font-semibold tracking-wider mb-1">Saldo Disponível</p>
<div className="text-4xl font-bold">{formatCurrency(balance)}</div>
<p className="text-blue-200 text-sm mt-2">Valores de vendas confirmadas e entregues.</p>
@ -97,7 +101,7 @@ export function WalletPage() {
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
<h3 className="font-semibold text-gray-800">Extrato de Transações</h3>
<button onClick={loadFinancials} className="text-blue-600 text-sm hover:underline">Atualizar</button>
<button onClick={loadFinancials} className="text-sm hover:underline font-medium" style={{ color: '#0F4C81' }}>Atualizar</button>
</div>
{loading ? (
@ -106,12 +110,12 @@ export function WalletPage() {
<div className="p-8 text-center text-gray-500">Nenhuma transação encontrada.</div>
) : (
<table className="w-full text-left">
<thead className="bg-gray-50 border-b border-gray-100">
<thead className="text-white border-b border-gray-100" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-6 py-3 text-xs font-medium text-gray-500 uppercase">Data</th>
<th className="px-6 py-3 text-xs font-medium text-gray-500 uppercase">Descrição</th>
<th className="px-6 py-3 text-xs font-medium text-gray-500 uppercase">Tipo</th>
<th className="px-6 py-3 text-xs font-medium text-gray-500 uppercase text-right">Valor</th>
<th className="px-6 py-3 text-xs font-medium text-white uppercase">Data</th>
<th className="px-6 py-3 text-xs font-medium text-white uppercase">Descrição</th>
<th className="px-6 py-3 text-xs font-medium text-white uppercase">Tipo</th>
<th className="px-6 py-3 text-xs font-medium text-white uppercase text-right">Valor</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">

View file

@ -56,7 +56,8 @@ export function CartPage() {
</p>
<a
href="/inventory"
className="mt-6 inline-flex items-center justify-center rounded-lg bg-[#0056b3] px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:opacity-90"
className="mt-6 inline-flex items-center justify-center rounded-lg px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
Explorar Produtos
</a>

View file

@ -7,6 +7,7 @@ import { formatCents } from '@/utils/format'
import { useAuth } from '@/context/AuthContext'
import { useNavigate } from 'react-router-dom'
import { ReviewModal } from '@/components/ReviewModal'
import { FaCartShopping, FaMoneyBillWave, FaBoxOpen } from 'react-icons/fa6'
interface Order {
id: string
@ -157,7 +158,8 @@ export function OrdersPage() {
</div>
<button
onClick={loadOrders}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 transition-colors"
className="flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition-colors"
style={{ backgroundColor: '#0F4C81' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
@ -177,7 +179,7 @@ export function OrdersPage() {
}`}
>
<div className="flex items-center justify-center gap-3">
<span className="text-2xl">🛒</span>
<FaCartShopping size={22} color="black" />
<div className="text-left">
<p className="font-semibold">Pedidos Feitos</p>
<p className="text-xs text-gray-400">Suas compras de outras farmácias</p>
@ -195,7 +197,7 @@ export function OrdersPage() {
}`}
>
<div className="flex items-center justify-center gap-3">
<span className="text-2xl">💰</span>
<FaMoneyBillWave size={22} color="black" />
<div className="text-left">
<p className="font-semibold">Pedidos Recebidos</p>
<p className="text-xs text-gray-400">Vendas para outras farmácias</p>
@ -243,9 +245,7 @@ export function OrdersPage() {
{!loading && orders.length === 0 && (
<div className="text-center py-12">
<span className="text-5xl block mb-4">
{activeTab === 'compras' ? '🛒' : '💰'}
</span>
<FaBoxOpen size={40} color="black" className="mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-700">
Nenhum pedido {activeTab === 'compras' ? 'feito' : 'recebido'} ainda
</h3>
@ -306,10 +306,12 @@ export function OrdersPage() {
const isCompleted = ['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) >= idx
return (
<div key={status} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${isCompleted
? 'bg-blue-600 text-white'
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${isCompleted
? 'text-white'
: 'bg-gray-200 text-gray-500'
}`}>
}`}
style={isCompleted ? { backgroundColor: '#0F4C81' } : {}}>
{isCompleted ? '✓' : idx + 1}
</div>
{idx < 3 && (
@ -334,7 +336,8 @@ export function OrdersPage() {
{order.status === 'Pendente' && (
<button
onClick={() => updateStatus(order.id, 'Pago')}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition-colors"
style={{ backgroundColor: '#0F4C81' }}
>
💳 Confirmar Pagamento
</button>
@ -411,7 +414,8 @@ export function OrdersPage() {
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => handleReorder(order.id)}
className="flex items-center gap-2 rounded-lg border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 transition-colors shadow-sm"
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-blue-50 transition-colors shadow-sm"
style={{ borderColor: '#0F4C81', color: '#0F4C81' }}
>
🔄 Comprar Novamente
</button>

View file

@ -43,6 +43,7 @@ export interface Company {
id: string
cnpj: string
corporate_name: string
fantasy_name?: string
category: string
license_number: string
is_verified: boolean
@ -50,6 +51,9 @@ export interface Company {
longitude: number
city: string
state: string
phone?: string
email?: string
founded_at?: string
created_at: string
updated_at: string
}
@ -64,17 +68,22 @@ export interface CompanyPage {
export interface CreateCompanyRequest {
cnpj: string
corporate_name: string
fantasy_name?: string
category: string
license_number: string
latitude: number
longitude: number
city: string
state: string
phone?: string
email?: string
founded_at?: string
}
export interface UpdateCompanyRequest {
cnpj?: string
corporate_name?: string
fantasy_name?: string
category?: string
license_number?: string
is_verified?: boolean
@ -82,6 +91,9 @@ export interface UpdateCompanyRequest {
longitude?: number
city?: string
state?: string
phone?: string
email?: string
founded_at?: string
}
export interface CompanyDocument {

View file

@ -11,6 +11,24 @@ export interface AuthLoginPayload {
password: string
}
export interface AuthRegisterPayload {
email: string
username: string
password: string
name: string
company_name: string
cnpj: string
}
export interface ForgotPasswordPayload {
email: string
}
export interface ResetPasswordPayload {
token: string
password: string
}
export const authService = {
login: async (payload: AuthLoginPayload) => {
logger.info('🔐 [authService] Making request to /v1/auth/login with:', payload)
@ -20,5 +38,23 @@ export const authService = {
},
logout: async () => {
await apiClient.post('/v1/auth/logout')
},
register: async (payload: AuthRegisterPayload) => {
logger.info('🔐 [authService] Making request to /v1/auth/register with:', { ...payload, password: '***' })
const data = await apiClient.post<AuthResponse>('/v1/auth/register', payload)
logger.info('🔐 [authService] Register response received')
return { token: data.access_token, expiresAt: data.expires_at }
},
forgotPassword: async (payload: ForgotPasswordPayload) => {
logger.info('🔐 [authService] Making request to /v1/auth/forgot-password with:', payload)
const data = await apiClient.post<{ message: string }>('/v1/auth/forgot-password', payload)
logger.info('🔐 [authService] Forgot password response:', data)
return data
},
resetPassword: async (payload: ResetPasswordPayload) => {
logger.info('🔐 [authService] Making request to /v1/auth/reset-password')
const data = await apiClient.post<AuthResponse>('/v1/auth/reset-password', payload)
logger.info('🔐 [authService] Reset password response received')
return { token: data.access_token, expiresAt: data.expires_at }
}
}

View file

@ -0,0 +1,70 @@
/**
* Validates a CNPJ (Cadastro Nacional da Pessoa Jurídica)
* Checks format and verifies check digits
*/
export function validateCNPJ(cnpj: string): boolean {
// Remove non-numeric characters
const cleaned = cnpj.replace(/\D/g, '')
// Must have exactly 14 digits
if (cleaned.length !== 14) {
return false
}
// All same digits are invalid
if (/^(\d)\1{13}$/.test(cleaned)) {
return false
}
// Validate first check digit
let sum = 0
let position = 5
for (let i = 0; i < 8; i++) {
sum += parseInt(cleaned[i]) * position
position -= 1
}
let remainder = sum % 11
const firstDigit = remainder < 2 ? 0 : 11 - remainder
if (parseInt(cleaned[8]) !== firstDigit) {
return false
}
// Validate second check digit
sum = 0
position = 9
for (let i = 0; i < 9; i++) {
sum += parseInt(cleaned[i]) * position
position -= 1
}
remainder = sum % 11
const secondDigit = remainder < 2 ? 0 : 11 - remainder
if (parseInt(cleaned[9]) !== secondDigit) {
return false
}
return true
}
/**
* Formats a CNPJ string to the standard format: 00.000.000/0000-00
* Accepts both formatted and unformatted input
*/
export function formatCNPJ(value: string): string {
const cleaned = value.replace(/\D/g, '').slice(0, 14)
if (cleaned.length === 0) return ''
if (cleaned.length <= 2) return cleaned
if (cleaned.length <= 5) return `${cleaned.slice(0, 2)}.${cleaned.slice(2)}`
if (cleaned.length <= 8) return `${cleaned.slice(0, 2)}.${cleaned.slice(2, 5)}.${cleaned.slice(5)}`
if (cleaned.length <= 12) return `${cleaned.slice(0, 2)}.${cleaned.slice(2, 5)}.${cleaned.slice(5, 8)}/${cleaned.slice(8)}`
return `${cleaned.slice(0, 2)}.${cleaned.slice(2, 5)}.${cleaned.slice(5, 8)}/${cleaned.slice(8, 12)}-${cleaned.slice(12)}`
}
/**
* Removes CNPJ formatting, returning only digits
*/
export function removeCNPJFormatting(cnpj: string): string {
return cnpj.replace(/\D/g, '')
}

View file

@ -0,0 +1,18 @@
/**
* Formats a phone number to the standard Brazilian format: (11) 99999-9999
*/
export function formatPhone(value: string): string {
const cleaned = value.replace(/\D/g, '').slice(0, 11)
if (cleaned.length === 0) return ''
if (cleaned.length <= 2) return cleaned
if (cleaned.length <= 7) return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2)}`
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 7)}-${cleaned.slice(7)}`
}
/**
* Removes phone formatting, returning only digits
*/
export function removePhoneFormatting(phone: string): string {
return phone.replace(/\D/g, '')
}