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/jackc/pgx/v5 v5.7.6
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/json-iterator/go v1.1.12
|
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/stretchr/testify v1.8.1
|
github.com/stretchr/testify v1.8.1
|
||||||
github.com/swaggo/http-swagger v1.3.4
|
github.com/swaggo/http-swagger v1.3.4
|
||||||
|
|
@ -29,8 +28,6 @@ require (
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.6 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/stretchr/objx v0.5.0 // 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/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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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/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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
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/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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
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/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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"react-window": "^1.8.10",
|
"react-window": "^1.8.10",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@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:
|
react-leaflet:
|
||||||
specifier: ^4.2.1
|
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)
|
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':
|
'@testing-library/user-event':
|
||||||
specifier: ^14.6.1
|
specifier: ^14.6.1
|
||||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.19.12
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.7
|
specifier: ^18.3.7
|
||||||
version: 18.3.27
|
version: 18.3.27
|
||||||
|
|
@ -62,10 +68,10 @@ importers:
|
||||||
version: 1.8.8
|
version: 1.8.8
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.7.0
|
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':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^4.0.16
|
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:
|
autoprefixer:
|
||||||
specifier: ^10.4.20
|
specifier: ^10.4.20
|
||||||
version: 10.4.23(postcss@8.5.6)
|
version: 10.4.23(postcss@8.5.6)
|
||||||
|
|
@ -83,10 +89,10 @@ importers:
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.3
|
specifier: ^5.4.3
|
||||||
version: 5.4.21
|
version: 5.4.21(@types/node@22.19.12)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.16
|
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:
|
packages:
|
||||||
|
|
||||||
|
|
@ -620,56 +626,67 @@ packages:
|
||||||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||||
|
|
@ -758,6 +775,9 @@ packages:
|
||||||
'@types/leaflet@1.9.21':
|
'@types/leaflet@1.9.21':
|
||||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||||
|
|
||||||
|
'@types/node@22.19.12':
|
||||||
|
resolution: {integrity: sha512-0QEp0aPJYSyf6RrTjDB7HlKgNMTY+V2C7ESTaVt6G9gQ0rPLzTGz7OF2NXTLR5vcy7HJEtIUsyWLsfX0kTqJBA==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.15':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
|
|
@ -1385,6 +1405,11 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
react: ^18.3.1
|
||||||
|
|
||||||
|
react-icons@5.5.0:
|
||||||
|
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
|
@ -1558,6 +1583,9 @@ packages:
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@6.21.0:
|
||||||
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3:
|
update-browserslist-db@1.2.3:
|
||||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -2237,6 +2265,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/geojson': 7946.0.16
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
|
'@types/node@22.19.12':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@types/prop-types@15.7.15': {}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7(@types/react@18.3.27)':
|
'@types/react-dom@18.3.7(@types/react@18.3.27)':
|
||||||
|
|
@ -2252,7 +2284,7 @@ snapshots:
|
||||||
'@types/prop-types': 15.7.15
|
'@types/prop-types': 15.7.15
|
||||||
csstype: 3.2.3
|
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:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@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
|
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.17.0
|
react-refresh: 0.17.0
|
||||||
vite: 5.4.21
|
vite: 5.4.21(@types/node@22.19.12)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
'@vitest/utils': 4.0.16
|
'@vitest/utils': 4.0.16
|
||||||
|
|
@ -2277,7 +2309,7 @@ snapshots:
|
||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
std-env: 3.10.0
|
std-env: 3.10.0
|
||||||
tinyrainbow: 3.0.3
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|
@ -2290,13 +2322,13 @@ snapshots:
|
||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.0.3
|
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:
|
dependencies:
|
||||||
'@vitest/spy': 4.0.16
|
'@vitest/spy': 4.0.16
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
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':
|
'@vitest/pretty-format@4.0.16':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -2879,6 +2911,10 @@ snapshots:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
|
react-icons@5.5.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
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):
|
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: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
|
|
@ -3091,15 +3129,16 @@ snapshots:
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite@5.4.21:
|
vite@5.4.21(@types/node@22.19.12):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
rollup: 4.54.0
|
rollup: 4.54.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.12
|
||||||
fsevents: 2.3.3
|
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:
|
dependencies:
|
||||||
esbuild: 0.27.2
|
esbuild: 0.27.2
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
|
@ -3108,13 +3147,14 @@ snapshots:
|
||||||
rollup: 4.54.0
|
rollup: 4.54.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.12
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 1.21.7
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.0.16
|
'@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/pretty-format': 4.0.16
|
||||||
'@vitest/runner': 4.0.16
|
'@vitest/runner': 4.0.16
|
||||||
'@vitest/snapshot': 4.0.16
|
'@vitest/snapshot': 4.0.16
|
||||||
|
|
@ -3131,9 +3171,10 @@ snapshots:
|
||||||
tinyexec: 1.0.2
|
tinyexec: 1.0.2
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinyrainbow: 3.0.3
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.12
|
||||||
jsdom: 27.4.0
|
jsdom: 27.4.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
import { LoginPage } from '@/pages/auth/Login'
|
import { LoginPage } from '@/pages/auth/Login'
|
||||||
|
import { ForgotPasswordPage } from '@/pages/auth/ForgotPassword'
|
||||||
|
import { RegisterPage } from '@/pages/auth/Register'
|
||||||
|
|
||||||
// Marketplace
|
// Marketplace
|
||||||
import { CartPage } from '@/pages/marketplace/Cart'
|
import { CartPage } from '@/pages/marketplace/Cart'
|
||||||
|
|
@ -46,6 +48,8 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
|
||||||
{/* Admin Dashboard with Header Layout */}
|
{/* Admin Dashboard with Header Layout */}
|
||||||
<Route
|
<Route
|
||||||
|
|
@ -102,7 +106,7 @@ function App() {
|
||||||
<Route
|
<Route
|
||||||
path="/cart"
|
path="/cart"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute allowedRoles={['owner', 'seller', 'employee']}>
|
||||||
<CartPage />
|
<CartPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +114,7 @@ function App() {
|
||||||
<Route
|
<Route
|
||||||
path="/orders"
|
path="/orders"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute allowedRoles={['owner', 'seller', 'employee']}>
|
||||||
<UserOrdersPage />
|
<UserOrdersPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +122,7 @@ function App() {
|
||||||
<Route
|
<Route
|
||||||
path="/inventory"
|
path="/inventory"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||||
<InventoryPage />
|
<InventoryPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +178,7 @@ function App() {
|
||||||
<Route
|
<Route
|
||||||
path="/checkout"
|
path="/checkout"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute allowedRoles={['owner', 'seller', 'employee']}>
|
||||||
<CheckoutPage />
|
<CheckoutPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import logoImg from '../assets/logo.png'
|
||||||
|
|
||||||
const navItems = [
|
const adminNavItems = [
|
||||||
{ path: '/dashboard', label: 'Início' },
|
{ path: '/dashboard', label: 'Início' },
|
||||||
{ path: '/dashboard/users', label: 'Usuários' },
|
{ path: '/dashboard/users', label: 'Usuários' },
|
||||||
{ path: '/dashboard/companies', label: 'Empresas' },
|
{ path: '/dashboard/companies', label: 'Empresas' },
|
||||||
|
|
@ -18,6 +19,10 @@ export function Header() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
|
@ -32,18 +37,17 @@ export function Header() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
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="mx-auto max-w-7xl px-4">
|
||||||
<div className="flex h-16 items-center justify-between">
|
<div className="flex h-16 items-center justify-between">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link to="/dashboard" className="flex items-center gap-2">
|
<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">
|
<img src={logoImg} alt="SaveInMed" className="h-10 w-auto" />
|
||||||
<span className="text-xl font-bold">💊</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold">SaveInMed</span>
|
<span className="text-xl font-bold">SaveInMed</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation — only for admin */}
|
||||||
|
{navItems.length > 0 && (
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
<nav className="hidden md:flex items-center gap-1">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = location.pathname === item.path ||
|
const isActive = location.pathname === item.path ||
|
||||||
|
|
@ -62,6 +66,7 @@ export function Header() {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="relative flex items-center gap-4" ref={dropdownRef}>
|
<div className="relative flex items-center gap-4" ref={dropdownRef}>
|
||||||
|
|
@ -87,7 +92,7 @@ export function Header() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{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
|
<Link
|
||||||
to="/dashboard/profile"
|
to="/dashboard/profile"
|
||||||
className="block px-4 py-2 hover:bg-gray-100"
|
className="block px-4 py-2 hover:bg-gray-100"
|
||||||
|
|
@ -107,7 +112,8 @@ export function Header() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<nav className="md:hidden border-t border-white/20 px-4 py-2 flex gap-2 overflow-x-auto">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = location.pathname === item.path
|
const isActive = location.pathname === item.path
|
||||||
|
|
@ -123,6 +129,7 @@ export function Header() {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'leaflet/dist/leaflet.css'
|
||||||
import icon from 'leaflet/dist/images/marker-icon.png'
|
import icon from 'leaflet/dist/images/marker-icon.png'
|
||||||
import iconShadow from 'leaflet/dist/images/marker-shadow.png'
|
import iconShadow from 'leaflet/dist/images/marker-shadow.png'
|
||||||
|
|
||||||
let DefaultIcon = L.icon({
|
const DefaultIcon = L.icon({
|
||||||
iconUrl: icon,
|
iconUrl: icon,
|
||||||
shadowUrl: iconShadow,
|
shadowUrl: iconShadow,
|
||||||
iconSize: [25, 41],
|
iconSize: [25, 41],
|
||||||
|
|
@ -16,6 +16,12 @@ let DefaultIcon = L.icon({
|
||||||
|
|
||||||
L.Marker.prototype.options.icon = DefaultIcon
|
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 {
|
interface LocationPickerProps {
|
||||||
initialLat?: number
|
initialLat?: number
|
||||||
initialLng?: 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">
|
<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%' }}>
|
<MapContainer center={center} zoom={13} scrollWheelZoom={true} style={{ height: '100%', width: '100%' }}>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution={import.meta.env.VITE_MAP_ATTRIBUTION}
|
attribution={TILE_ATTR}
|
||||||
url={import.meta.env.VITE_MAP_TILE_LAYER}
|
url={TILE_URL}
|
||||||
/>
|
/>
|
||||||
<LocationMarker onLocationSelect={onLocationSelect} />
|
<LocationMarker onLocationSelect={onLocationSelect} />
|
||||||
{(initialLat && initialLng) && <Marker position={[initialLat, initialLng]} />}
|
{(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 { useCartStore, selectCartSummary } from '../stores/cartStore'
|
||||||
import { formatCurrency } from '../utils/format'
|
import { formatCurrency } from '../utils/format'
|
||||||
import logoImg from '../assets/logo.png'
|
import logoImg from '../assets/logo.png'
|
||||||
|
import { FaRightFromBracket } from 'react-icons/fa6'
|
||||||
|
|
||||||
// Cart dropdown content component
|
// Cart dropdown content component
|
||||||
function CartDropdownContent() {
|
function CartDropdownContent() {
|
||||||
|
|
@ -90,15 +91,12 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<img src={logoImg} alt="SaveInMed" className="h-10 w-auto" />
|
<img src={logoImg} alt="SaveInMed" className="h-10 w-auto" />
|
||||||
<div>
|
<span className="text-xl font-bold">SaveInMed</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-4 text-sm font-medium">
|
<nav className="flex items-center gap-4 text-sm font-medium">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|
@ -108,14 +106,26 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||||
)}
|
)}
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<>
|
<>
|
||||||
<Link to="/seller" className="hover:underline">
|
<Link to="/seller" className="hover:underline whitespace-nowrap">
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/orders" className="hover:underline">
|
<Link to="/inventory" className="hover:underline whitespace-nowrap">
|
||||||
Meus Pedidos
|
Estoque
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/inventory" className="hover:underline">
|
<Link to="/search" className="hover:underline whitespace-nowrap">
|
||||||
Meus Produtos
|
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>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -152,11 +162,13 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{user && (
|
{user && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Profile dropdown */}
|
||||||
<div className="relative flex items-center" ref={profileMenuRef}>
|
<div className="relative flex items-center" ref={profileMenuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsProfileOpen((prev) => !prev)}
|
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-haspopup="true"
|
||||||
aria-expanded={isProfileOpen}
|
aria-expanded={isProfileOpen}
|
||||||
>
|
>
|
||||||
|
|
@ -197,10 +209,13 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="px-6 py-5">{children}</main>
|
<main className="mx-auto max-w-7xl px-6 py-6">{children}</main>
|
||||||
</div>
|
</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 { FormEvent, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useAuth, UserRole } from '@/context/AuthContext'
|
import { useAuth, UserRole } from '@/context/AuthContext'
|
||||||
import { authService } from '@/services/auth'
|
import { authService } from '@/services/auth'
|
||||||
import { logger } from '@/utils/logger'
|
import { logger } from '@/utils/logger'
|
||||||
import { decodeJwtPayload } from '@/utils/jwt'
|
import { decodeJwtPayload } from '@/utils/jwt'
|
||||||
|
import { formatCNPJ, validateCNPJ } from '@/utils/cnpj'
|
||||||
import logoImg from '@/assets/logo.png' // Ensure logo import is handled
|
import logoImg from '@/assets/logo.png' // Ensure logo import is handled
|
||||||
|
|
||||||
// Eye icon components for password visibility toggle
|
// Eye icon components for password visibility toggle
|
||||||
|
|
@ -22,12 +24,31 @@ const EyeSlashIcon = () => (
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login')
|
||||||
|
|
||||||
|
// Login form state
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
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 => {
|
const resolveRole = (role?: string): UserRole => {
|
||||||
logger.info('🔐 [Login] Resolving role:', role)
|
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) => {
|
const onSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -155,6 +278,12 @@ export function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -176,15 +305,208 @@ export function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-gray-500 py-12">
|
<form onSubmit={onRegisterSubmit} className="p-8 space-y-4">
|
||||||
<p>Funcionalidade de cadastro em breve.</p>
|
{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
|
<button
|
||||||
onClick={() => setActiveTab('login')}
|
type="button"
|
||||||
className="mt-4 text-blue-600 hover:underline text-sm"
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
</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 { useEffect, useState } from 'react'
|
||||||
import { adminService, Company, CreateCompanyRequest } from '@/services/adminService'
|
import { adminService, Company, CreateCompanyRequest } from '@/services/adminService'
|
||||||
import { useCepLookup } from '@/hooks/useCepLookup'
|
import { useCepLookup } from '@/hooks/useCepLookup'
|
||||||
|
import { formatCNPJ, validateCNPJ } from '@/utils/cnpj'
|
||||||
|
import { formatPhone } from '@/utils/phone'
|
||||||
|
|
||||||
export function CompaniesPage() {
|
export function CompaniesPage() {
|
||||||
const [companies, setCompanies] = useState<Company[]>([])
|
const [companies, setCompanies] = useState<Company[]>([])
|
||||||
|
|
@ -14,14 +16,19 @@ export function CompaniesPage() {
|
||||||
const [formData, setFormData] = useState<CreateCompanyRequest>({
|
const [formData, setFormData] = useState<CreateCompanyRequest>({
|
||||||
cnpj: '',
|
cnpj: '',
|
||||||
corporate_name: '',
|
corporate_name: '',
|
||||||
|
fantasy_name: '',
|
||||||
category: 'farmacia',
|
category: 'farmacia',
|
||||||
license_number: '',
|
license_number: '',
|
||||||
latitude: -16.3281,
|
latitude: -16.3281,
|
||||||
longitude: -48.9530,
|
longitude: -48.9530,
|
||||||
city: 'Anápolis',
|
city: 'Anápolis',
|
||||||
state: 'GO'
|
state: 'GO',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
founded_at: '',
|
||||||
})
|
})
|
||||||
const [cep, setCep] = useState('')
|
const [cep, setCep] = useState('')
|
||||||
|
const [cnpjError, setCnpjError] = useState<string | null>(null)
|
||||||
const { loading: cepLoading, error: cepError, lookup } = useCepLookup()
|
const { loading: cepLoading, error: cepError, lookup } = useCepLookup()
|
||||||
|
|
||||||
const handleCepChange = async (value: string) => {
|
const handleCepChange = async (value: string) => {
|
||||||
|
|
@ -62,15 +69,39 @@ export function CompaniesPage() {
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
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 {
|
try {
|
||||||
if (editingCompany) {
|
if (editingCompany) {
|
||||||
await adminService.updateCompany(editingCompany.id, formData)
|
await adminService.updateCompany(editingCompany.id, payload)
|
||||||
} else {
|
} else {
|
||||||
await adminService.createCompany(formData)
|
await adminService.createCompany(payload)
|
||||||
}
|
}
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
loadCompanies()
|
void loadCompanies()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving company:', err)
|
console.error('Error saving company:', err)
|
||||||
alert('Erro ao salvar empresa')
|
alert('Erro ao salvar empresa')
|
||||||
|
|
@ -99,38 +130,52 @@ export function CompaniesPage() {
|
||||||
|
|
||||||
const openEdit = async (company: Company) => {
|
const openEdit = async (company: Company) => {
|
||||||
setEditingCompany(company)
|
setEditingCompany(company)
|
||||||
|
setCnpjError(null)
|
||||||
setFormData({
|
setFormData({
|
||||||
cnpj: company.cnpj,
|
cnpj: formatCNPJ(company.cnpj ?? ''),
|
||||||
corporate_name: company.corporate_name,
|
corporate_name: company.corporate_name ?? '',
|
||||||
category: company.category,
|
fantasy_name: company.fantasy_name ?? '',
|
||||||
license_number: company.license_number,
|
category: company.category ?? 'farmacia',
|
||||||
latitude: company.latitude,
|
license_number: company.license_number ?? '',
|
||||||
longitude: company.longitude,
|
latitude: typeof company.latitude === 'number' ? company.latitude : -16.3281,
|
||||||
city: company.city,
|
longitude: typeof company.longitude === 'number' ? company.longitude : -48.9530,
|
||||||
state: company.state
|
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)
|
setShowModal(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const docs = await adminService.getCompanyDocuments(company.id)
|
const docs = await adminService.getCompanyDocuments(company.id)
|
||||||
setCompanyDocuments(docs)
|
setCompanyDocuments(Array.isArray(docs) ? docs : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load company docs', err)
|
console.error('Failed to load company docs', err)
|
||||||
|
setCompanyDocuments([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setEditingCompany(null)
|
setEditingCompany(null)
|
||||||
setCep('')
|
setCep('')
|
||||||
|
setCnpjError(null)
|
||||||
|
setCompanyDocuments([])
|
||||||
setFormData({
|
setFormData({
|
||||||
cnpj: '',
|
cnpj: '',
|
||||||
corporate_name: '',
|
corporate_name: '',
|
||||||
|
fantasy_name: '',
|
||||||
category: 'farmacia',
|
category: 'farmacia',
|
||||||
license_number: '',
|
license_number: '',
|
||||||
latitude: -16.3281,
|
latitude: -16.3281,
|
||||||
longitude: -48.9530,
|
longitude: -48.9530,
|
||||||
city: 'Anápolis',
|
city: 'Anápolis',
|
||||||
state: 'GO'
|
state: 'GO',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
founded_at: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +221,8 @@ export function CompaniesPage() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={openCreate}
|
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
|
+ Nova Empresa
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -185,7 +231,7 @@ export function CompaniesPage() {
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<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>
|
<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">Razão Social</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">CNPJ</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">CNPJ</th>
|
||||||
|
|
@ -278,51 +324,125 @@ export function CompaniesPage() {
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{showModal && (
|
{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">
|
<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">
|
<h2 className="mb-4 text-xl font-bold">
|
||||||
{editingCompany ? 'Editar Empresa' : 'Nova Empresa'}
|
{editingCompany ? 'Editar Empresa' : 'Nova Empresa'}
|
||||||
</h2>
|
</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-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">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">Razão Social</label>
|
<label className="block text-sm font-medium text-gray-700">Razão Social</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.corporate_name}
|
value={formData.corporate_name}
|
||||||
onChange={(e) => setFormData({ ...formData, corporate_name: e.target.value })}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Data de Abertura */}
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="date"
|
||||||
value={formData.cnpj}
|
value={formData.founded_at || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, cnpj: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, founded_at: 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>
|
</div>
|
||||||
|
|
||||||
|
{/* Categoria */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Categoria</label>
|
<label className="block text-sm font-medium text-gray-700">Categoria</label>
|
||||||
<select
|
<select
|
||||||
value={formData.category}
|
value={formData.category}
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
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="farmacia">Farmácia</option>
|
||||||
<option value="distribuidora">Distribuidora</option>
|
<option value="distribuidora">Distribuidora</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">Licença Sanitária (Número)</label>
|
<label className="block text-sm font-medium text-gray-700">Licença Sanitária (Número)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.license_number}
|
value={formData.license_number}
|
||||||
onChange={(e) => setFormData({ ...formData, license_number: e.target.value })}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -358,15 +478,15 @@ export function CompaniesPage() {
|
||||||
file:bg-blue-50 file:text-blue-700
|
file:bg-blue-50 file:text-blue-700
|
||||||
hover:file:bg-blue-100"
|
hover:file:bg-blue-100"
|
||||||
/>
|
/>
|
||||||
{companyDocuments.length > 0 && (
|
{(companyDocuments ?? []).length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-sm font-medium text-gray-700 mb-2">Documentos Anexados:</p>
|
<p className="text-sm font-medium text-gray-700 mb-2">Documentos Anexados:</p>
|
||||||
<ul className="space-y-2">
|
<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">
|
<li key={doc.id} className="flex items-center justify-between p-2 text-sm border rounded bg-gray-50">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="font-semibold text-gray-800 mr-2">{doc.type}:</span>
|
<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>
|
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">{doc.status}</span>
|
||||||
</div>
|
</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">
|
<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)}
|
onChange={(e) => handleCepChange(e.target.value)}
|
||||||
placeholder="00000-000"
|
placeholder="00000-000"
|
||||||
maxLength={9}
|
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>}
|
{cepError && <p className="mt-1 text-xs text-red-500">{cepError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -400,7 +520,7 @@ export function CompaniesPage() {
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.city}
|
value={formData.city}
|
||||||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -410,7 +530,7 @@ export function CompaniesPage() {
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.state}
|
value={formData.state}
|
||||||
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
|
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}
|
maxLength={2}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -420,9 +540,12 @@ export function CompaniesPage() {
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={formData.latitude}
|
value={Number.isFinite(formData.latitude) ? formData.latitude : ''}
|
||||||
onChange={(e) => setFormData({ ...formData, latitude: parseFloat(e.target.value) })}
|
onChange={(e) => {
|
||||||
className="mt-1 w-full rounded border px-3 py-2"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -431,9 +554,12 @@ export function CompaniesPage() {
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={formData.longitude}
|
value={Number.isFinite(formData.longitude) ? formData.longitude : ''}
|
||||||
onChange={(e) => setFormData({ ...formData, longitude: parseFloat(e.target.value) })}
|
onChange={(e) => {
|
||||||
className="mt-1 w-full rounded border px-3 py-2"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -448,7 +574,8 @@ export function CompaniesPage() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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
|
Salvar
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { apiClient } from '@/services/apiClient'
|
import { apiClient } from '@/services/apiClient'
|
||||||
|
import { FaUserGroup, FaBuilding, FaBoxOpen, FaClipboardList } from 'react-icons/fa6'
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
totalUsers: number
|
totalUsers: number
|
||||||
|
|
@ -76,10 +77,10 @@ export function DashboardHome() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{ title: 'Usuários', value: stats.totalUsers, icon: '👥', color: 'from-blue-500 to-blue-600' },
|
{ title: 'Usuários', value: stats.totalUsers, icon: <FaUserGroup size={26} color="black" /> },
|
||||||
{ title: 'Empresas', value: stats.totalCompanies, icon: '🏢', color: 'from-purple-500 to-purple-600' },
|
{ title: 'Empresas', value: stats.totalCompanies, icon: <FaBuilding size={26} color="black" /> },
|
||||||
{ title: 'Produtos', value: stats.totalProducts, icon: '💊', color: 'from-green-500 to-green-600' },
|
{ title: 'Produtos', value: stats.totalProducts, icon: <FaBoxOpen size={26} color="black" /> },
|
||||||
{ title: 'Pedidos', value: stats.totalOrders, icon: '📦', color: 'from-orange-500 to-orange-600' }
|
{ title: 'Pedidos', value: stats.totalOrders, icon: <FaClipboardList size={26} color="black" /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -91,16 +92,16 @@ export function DashboardHome() {
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<div
|
<div
|
||||||
key={card.title}
|
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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-white/80">{card.title}</p>
|
<p className="text-sm font-medium text-gray-500">{card.title}</p>
|
||||||
<p className="mt-1 text-3xl font-bold">
|
<p className="mt-1 text-3xl font-bold text-gray-900">
|
||||||
{loading ? '...' : card.value.toLocaleString('pt-BR')}
|
{loading ? '...' : card.value.toLocaleString('pt-BR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-4xl opacity-80">{card.icon}</span>
|
<span>{card.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export function LogisticsPage() {
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<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>
|
<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">Data</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Transportadora</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Transportadora</th>
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export function OrdersPage() {
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<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>
|
<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">ID</th>
|
||||||
<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">Data</th>
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,8 @@ export function ProductsPage() {
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Produtos</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Produtos</h1>
|
||||||
<button
|
<button
|
||||||
onClick={openCreate}
|
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
|
+ Novo Produto
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -169,7 +170,7 @@ export function ProductsPage() {
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<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>
|
<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">Produto</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Loja</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Loja</th>
|
||||||
|
|
@ -276,7 +277,7 @@ export function ProductsPage() {
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{showModal && (
|
{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">
|
<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">
|
<h2 className="mb-4 text-xl font-bold">
|
||||||
{editingProduct ? 'Editar Produto' : 'Novo Produto'}
|
{editingProduct ? 'Editar Produto' : 'Novo Produto'}
|
||||||
|
|
@ -372,7 +373,8 @@ export function ProductsPage() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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
|
Salvar
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,8 @@ export function ProfilePage() {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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'}
|
{loading ? 'Salvando...' : 'Salvar Alterações'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function ReviewsPage() {
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<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>
|
<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">Data</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Pedido</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 'leaflet/dist/leaflet.css'
|
||||||
import { adminService, ShippingSettings } from '@/services/adminService'
|
import { adminService, ShippingSettings } from '@/services/adminService'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { FaTruck, FaLocationDot, FaStore, FaCircleInfo, FaFloppyDisk } from 'react-icons/fa6'
|
||||||
|
import { Shell } from '@/layouts/Shell'
|
||||||
|
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
|
|
||||||
|
|
@ -103,14 +105,24 @@ export function ShippingSettingsPage() {
|
||||||
|
|
||||||
const center: [number, number] = [settings.latitude, settings.longitude]
|
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 (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
<Shell>
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
<span className="text-2xl">🚚</span>
|
<div className="flex items-center gap-3 mb-6">
|
||||||
Configurações de Entrega e Retirada
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gray-100">
|
||||||
</h1>
|
<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">
|
<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="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
<span className="text-xl">📍</span>
|
<FaLocationDot size={18} color="black" />
|
||||||
Entrega Própria
|
Entrega Própria
|
||||||
</h2>
|
</h2>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<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 })} />
|
<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>
|
<span className="ml-3 text-sm font-medium text-gray-900">{settings.active ? 'Ativado' : 'Desativado'}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-fit">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
<span className="text-xl">🏪</span>
|
<FaStore size={18} color="black" />
|
||||||
Retirada na Loja
|
Retirada na Loja
|
||||||
</h2>
|
</h2>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<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 })}
|
onChange={e => setSettings({ ...settings, pickup_hours: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-blue-50 p-4 rounded-lg flex gap-3 text-blue-700 text-sm">
|
<div className="bg-blue-50 p-4 rounded-lg flex gap-3 text-sm" style={{ color: '#0F4C81' }}>
|
||||||
<span className="text-xl">ℹ️</span>
|
<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>
|
<p>Ao ativar a retirada, os clientes poderão escolher buscar o pedido na loja sem custo de frete.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -257,12 +269,14 @@ export function ShippingSettingsPage() {
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
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'}
|
{saving ? 'Salvando...' : 'Salvar Configurações'}
|
||||||
<span className="text-sm">💾</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Shell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,8 @@ export function UsersPage() {
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Usuários</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Usuários</h1>
|
||||||
<button
|
<button
|
||||||
onClick={openCreate}
|
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
|
+ Novo Usuário
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -123,7 +124,7 @@ export function UsersPage() {
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<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>
|
<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">Nome</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Username</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Username</th>
|
||||||
|
|
@ -204,7 +205,7 @@ export function UsersPage() {
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{showModal && (
|
{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">
|
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
<h2 className="mb-4 text-xl font-bold">
|
<h2 className="mb-4 text-xl font-bold">
|
||||||
{editingUser ? 'Editar Usuário' : 'Novo Usuário'}
|
{editingUser ? 'Editar Usuário' : 'Novo Usuário'}
|
||||||
|
|
@ -291,7 +292,8 @@ export function UsersPage() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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
|
Salvar
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,336 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
<div className="min-h-screen bg-gray-100">
|
||||||
<div className="mx-auto max-w-7xl">
|
<Header />
|
||||||
<div className="flex items-center justify-between rounded-lg bg-white p-6 shadow">
|
|
||||||
<div>
|
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Painel do Entregador</h1>
|
{/* Page Title */}
|
||||||
<p className="text-gray-600">Bem-vindo, {user?.name}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mb-6 flex gap-1 border-b border-gray-200">
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={() => setActiveTab('pending')}
|
||||||
className="rounded bg-red-600 px-4 py-2 font-bold text-white hover:bg-red-700"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 rounded-lg bg-white p-6 shadow">
|
{/* Loading */}
|
||||||
<h3 className="text-lg font-bold">Minhas Entregas</h3>
|
{loading && (
|
||||||
<p className="mt-2 text-gray-600">Visualize as entregas pendentes e o mapa de rotas.</p>
|
<div className="flex items-center justify-center py-16">
|
||||||
{/* Map Integration would go here */}
|
<div className="h-10 w-10 animate-spin rounded-full border-4 border-gray-200" style={{ borderTopColor: '#0F4C81' }} />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,58 @@
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Header } from '@/components/Header'
|
||||||
|
import { FaMagnifyingGlass, FaClipboardList } from 'react-icons/fa6'
|
||||||
|
|
||||||
export function EmployeeDashboardPage() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
<div className="min-h-screen bg-gray-100">
|
||||||
<div className="mx-auto max-w-7xl">
|
<Header />
|
||||||
<div className="flex items-center justify-between rounded-lg bg-white p-6 shadow">
|
|
||||||
<div>
|
<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>
|
<h1 className="text-2xl font-bold text-gray-900">Painel do Colaborador</h1>
|
||||||
<p className="text-gray-600">Bem-vindo, {user?.name}</p>
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
</div>
|
Olá, {user?.name} — acesse os recursos disponíveis para você
|
||||||
<div className="flex gap-3">
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-6 md:grid-cols-3">
|
{/* Cards Grid */}
|
||||||
<Link to="/search" className="rounded-lg bg-white p-6 shadow hover:shadow-lg transition-shadow">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<h3 className="text-lg font-bold text-green-600">🛒 Comprar Medicamentos</h3>
|
{cards.map((card) => (
|
||||||
<p className="mt-2 text-gray-600">Encontrar medicamentos próximos à venda.</p>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,8 @@ export function InventoryPage() {
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleImportClick}
|
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
|
Importar CSV
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -124,7 +125,8 @@ export function InventoryPage() {
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/products/new"
|
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
|
+ Cadastrar Produto
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -149,14 +151,14 @@ export function InventoryPage() {
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
|
||||||
<tr>
|
<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-white">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-white">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-left text-xs font-semibold uppercase text-white">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-white">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-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-gray-600">Ações</th>
|
<th className="px-4 py-3 text-center text-xs font-semibold uppercase text-white">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { Shell } from '@/layouts/Shell'
|
import { Shell } from '@/layouts/Shell'
|
||||||
import { apiClient } from '@/services/apiClient'
|
import { apiClient } from '@/services/apiClient'
|
||||||
import { formatCents } from '@/utils/format'
|
import { formatCents } from '@/utils/format'
|
||||||
|
import { FaChartLine, FaBoxOpen, FaReceipt, FaArrowsRotate } from 'react-icons/fa6'
|
||||||
|
|
||||||
interface SellerDashboardData {
|
interface SellerDashboardData {
|
||||||
seller_id: string
|
seller_id: string
|
||||||
|
|
@ -22,6 +23,7 @@ interface SellerDashboardData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SellerDashboardPage() {
|
export function SellerDashboardPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
const [data, setData] = useState<SellerDashboardData | null>(null)
|
const [data, setData] = useState<SellerDashboardData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -52,74 +54,92 @@ export function SellerDashboardPage() {
|
||||||
|
|
||||||
const formatCurrency = (cents: number | undefined | null) => formatCents(cents)
|
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 (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-medicalBlue">Dashboard do Vendedor</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard do Vendedor</h1>
|
||||||
<p className="text-sm text-gray-600">Métricas e indicadores de performance</p>
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
Olá, {user?.name} · métricas e indicadores de performance
|
||||||
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={loadDashboard}
|
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
|
Atualizar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-10">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-medicalBlue"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: '#0F4C81' }}></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
{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 && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 p-6 text-white">
|
{kpiCards.map((card) => (
|
||||||
<p className="text-sm opacity-80">Total de Vendas</p>
|
<div key={card.label} className="rounded-xl bg-white p-6 shadow flex items-center gap-4">
|
||||||
<p className="text-3xl font-bold">{formatCurrency(data.total_sales_cents)}</p>
|
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-gray-100">
|
||||||
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-gradient-to-br from-green-500 to-green-600 p-6 text-white">
|
<div>
|
||||||
<p className="text-sm opacity-80">Pedidos</p>
|
<p className="text-sm text-gray-500">{card.label}</p>
|
||||||
<p className="text-3xl font-bold">{data.orders_count}</p>
|
<p className="text-2xl font-bold text-gray-900 mt-0.5">{card.value}</p>
|
||||||
</div>
|
</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>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom cards */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Top Products */}
|
{/* 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>
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">Top Produtos</h2>
|
||||||
{data.top_products.length === 0 ? (
|
{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">
|
<div className="space-y-3">
|
||||||
{data.top_products.map((product, idx) => (
|
{data.top_products.map((product, idx) => (
|
||||||
<div key={product.product_id} className="flex items-center justify-between">
|
<div key={product.product_id} className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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}
|
{idx + 1}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -127,7 +147,7 @@ export function SellerDashboardPage() {
|
||||||
<p className="text-xs text-gray-500">{product.total_quantity} unidades</p>
|
<p className="text-xs text-gray-500">{product.total_quantity} unidades</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-medicalBlue">
|
<span className="font-semibold text-gray-800">
|
||||||
{formatCurrency(product.revenue_cents)}
|
{formatCurrency(product.revenue_cents)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,18 +157,21 @@ export function SellerDashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Low Stock Alerts */}
|
{/* Low Stock Alerts */}
|
||||||
<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">
|
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
⚠️ Alertas de Estoque Baixo
|
<span className="text-amber-500">⚠</span>
|
||||||
|
Alertas de Estoque Baixo
|
||||||
</h2>
|
</h2>
|
||||||
{data.low_stock_alerts.length === 0 ? (
|
{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">
|
<div className="space-y-3">
|
||||||
{data.low_stock_alerts.map((product) => (
|
{data.low_stock_alerts.map((product) => (
|
||||||
<div
|
<div
|
||||||
key={product.id}
|
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="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">
|
<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> = {
|
const roleLabels: Record<string, string> = {
|
||||||
'Dono': '👑 Dono',
|
'Dono': 'Dono',
|
||||||
'Gerente': '🏢 Gerente',
|
'Gerente': 'Gerente',
|
||||||
'Comprador': '🛒 Comprador',
|
'Comprador': 'Comprador',
|
||||||
'Colaborador': '👤 Colaborador',
|
'Colaborador': 'Colaborador',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeamPage() {
|
export function TeamPage() {
|
||||||
|
|
@ -81,13 +81,14 @@ export function TeamPage() {
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<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>
|
<p className="text-sm text-gray-500 mt-1">Gerencie os membros da sua farmácia</p>
|
||||||
</div>
|
</div>
|
||||||
{canInvite && (
|
{canInvite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setInviteOpen(true)}
|
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
|
+ Convidar Membro
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -105,12 +106,12 @@ export function TeamPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
<table className="w-full">
|
<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>
|
<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-white 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-white 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-white 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">Desde</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
|
@ -192,7 +193,8 @@ export function TeamPage() {
|
||||||
<button
|
<button
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
disabled={inviting}
|
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'}
|
{inviting ? 'Convidando...' : 'Convidar'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Shell } from '@/layouts/Shell'
|
import { Shell } from '@/layouts/Shell'
|
||||||
import { financialService, LedgerEntry } from '@/services/financialService'
|
import { financialService, LedgerEntry } from '@/services/financialService'
|
||||||
|
import { FaMoneyCheck } from 'react-icons/fa6'
|
||||||
|
|
||||||
export function WalletPage() {
|
export function WalletPage() {
|
||||||
const [balance, setBalance] = useState<number>(0)
|
const [balance, setBalance] = useState<number>(0)
|
||||||
|
|
@ -65,8 +66,9 @@ export function WalletPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (cents: number) => {
|
const formatCurrency = (cents: number | null | undefined) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(cents / 100)
|
const safe = typeof cents === 'number' && !isNaN(cents) ? cents : 0
|
||||||
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(safe / 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -80,14 +82,16 @@ export function WalletPage() {
|
||||||
<button
|
<button
|
||||||
onClick={handleWithdrawal}
|
onClick={handleWithdrawal}
|
||||||
disabled={requesting || balance <= 0}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Balance Card */}
|
{/* 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>
|
<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>
|
<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>
|
<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="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">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -106,12 +110,12 @@ export function WalletPage() {
|
||||||
<div className="p-8 text-center text-gray-500">Nenhuma transação encontrada.</div>
|
<div className="p-8 text-center text-gray-500">Nenhuma transação encontrada.</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-left">
|
<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>
|
<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-white 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-white 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-white 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 text-right">Valor</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ export function CartPage() {
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/inventory"
|
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
|
Explorar Produtos
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { formatCents } from '@/utils/format'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ReviewModal } from '@/components/ReviewModal'
|
import { ReviewModal } from '@/components/ReviewModal'
|
||||||
|
import { FaCartShopping, FaMoneyBillWave, FaBoxOpen } from 'react-icons/fa6'
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -157,7 +158,8 @@ export function OrdersPage() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadOrders}
|
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">
|
<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" />
|
<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">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<span className="text-2xl">🛒</span>
|
<FaCartShopping size={22} color="black" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold">Pedidos Feitos</p>
|
<p className="font-semibold">Pedidos Feitos</p>
|
||||||
<p className="text-xs text-gray-400">Suas compras de outras farmácias</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">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<span className="text-2xl">💰</span>
|
<FaMoneyBillWave size={22} color="black" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold">Pedidos Recebidos</p>
|
<p className="font-semibold">Pedidos Recebidos</p>
|
||||||
<p className="text-xs text-gray-400">Vendas para outras farmácias</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 && (
|
{!loading && orders.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<span className="text-5xl block mb-4">
|
<FaBoxOpen size={40} color="black" className="mx-auto mb-4" />
|
||||||
{activeTab === 'compras' ? '🛒' : '💰'}
|
|
||||||
</span>
|
|
||||||
<h3 className="text-lg font-medium text-gray-700">
|
<h3 className="text-lg font-medium text-gray-700">
|
||||||
Nenhum pedido {activeTab === 'compras' ? 'feito' : 'recebido'} ainda
|
Nenhum pedido {activeTab === 'compras' ? 'feito' : 'recebido'} ainda
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -306,10 +306,12 @@ export function OrdersPage() {
|
||||||
const isCompleted = ['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) >= idx
|
const isCompleted = ['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) >= idx
|
||||||
return (
|
return (
|
||||||
<div key={status} className="flex items-center">
|
<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
|
<div
|
||||||
? 'bg-blue-600 text-white'
|
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'
|
: 'bg-gray-200 text-gray-500'
|
||||||
}`}>
|
}`}
|
||||||
|
style={isCompleted ? { backgroundColor: '#0F4C81' } : {}}>
|
||||||
{isCompleted ? '✓' : idx + 1}
|
{isCompleted ? '✓' : idx + 1}
|
||||||
</div>
|
</div>
|
||||||
{idx < 3 && (
|
{idx < 3 && (
|
||||||
|
|
@ -334,7 +336,8 @@ export function OrdersPage() {
|
||||||
{order.status === 'Pendente' && (
|
{order.status === 'Pendente' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => updateStatus(order.id, 'Pago')}
|
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
|
💳 Confirmar Pagamento
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -411,7 +414,8 @@ export function OrdersPage() {
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleReorder(order.id)}
|
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
|
🔄 Comprar Novamente
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface Company {
|
||||||
id: string
|
id: string
|
||||||
cnpj: string
|
cnpj: string
|
||||||
corporate_name: string
|
corporate_name: string
|
||||||
|
fantasy_name?: string
|
||||||
category: string
|
category: string
|
||||||
license_number: string
|
license_number: string
|
||||||
is_verified: boolean
|
is_verified: boolean
|
||||||
|
|
@ -50,6 +51,9 @@ export interface Company {
|
||||||
longitude: number
|
longitude: number
|
||||||
city: string
|
city: string
|
||||||
state: string
|
state: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
founded_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -64,17 +68,22 @@ export interface CompanyPage {
|
||||||
export interface CreateCompanyRequest {
|
export interface CreateCompanyRequest {
|
||||||
cnpj: string
|
cnpj: string
|
||||||
corporate_name: string
|
corporate_name: string
|
||||||
|
fantasy_name?: string
|
||||||
category: string
|
category: string
|
||||||
license_number: string
|
license_number: string
|
||||||
latitude: number
|
latitude: number
|
||||||
longitude: number
|
longitude: number
|
||||||
city: string
|
city: string
|
||||||
state: string
|
state: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
founded_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCompanyRequest {
|
export interface UpdateCompanyRequest {
|
||||||
cnpj?: string
|
cnpj?: string
|
||||||
corporate_name?: string
|
corporate_name?: string
|
||||||
|
fantasy_name?: string
|
||||||
category?: string
|
category?: string
|
||||||
license_number?: string
|
license_number?: string
|
||||||
is_verified?: boolean
|
is_verified?: boolean
|
||||||
|
|
@ -82,6 +91,9 @@ export interface UpdateCompanyRequest {
|
||||||
longitude?: number
|
longitude?: number
|
||||||
city?: string
|
city?: string
|
||||||
state?: string
|
state?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
founded_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyDocument {
|
export interface CompanyDocument {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,24 @@ export interface AuthLoginPayload {
|
||||||
password: string
|
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 = {
|
export const authService = {
|
||||||
login: async (payload: AuthLoginPayload) => {
|
login: async (payload: AuthLoginPayload) => {
|
||||||
logger.info('🔐 [authService] Making request to /v1/auth/login with:', payload)
|
logger.info('🔐 [authService] Making request to /v1/auth/login with:', payload)
|
||||||
|
|
@ -20,5 +38,23 @@ export const authService = {
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await apiClient.post('/v1/auth/logout')
|
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