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:
parent
5cb9d5212c
commit
3559afc1f7
34 changed files with 2280 additions and 547 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
'© <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]} />}
|
||||
|
|
|
|||
192
frontend/src/components/Sidebar.tsx
Normal file
192
frontend/src/components/Sidebar.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
frontend/src/layouts/RoleBasedLayout.tsx
Normal file
22
frontend/src/layouts/RoleBasedLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
156
frontend/src/pages/auth/ForgotPassword.tsx
Normal file
156
frontend/src/pages/auth/ForgotPassword.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
Já tenho uma conta{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('login')}
|
||||
className="text-blue-600 hover:underline font-medium"
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
|
|
|||
341
frontend/src/pages/auth/Register.tsx
Normal file
341
frontend/src/pages/auth/Register.tsx
Normal 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">
|
||||
Já tenho uma conta{' '}
|
||||
<Link to="/login" className="text-blue-600 hover:underline font-medium">
|
||||
Entrar
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
frontend/src/utils/cnpj.ts
Normal file
70
frontend/src/utils/cnpj.ts
Normal 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, '')
|
||||
}
|
||||
18
frontend/src/utils/phone.ts
Normal file
18
frontend/src/utils/phone.ts
Normal 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, '')
|
||||
}
|
||||
Loading…
Reference in a new issue