Add B2B marketplace frontend with virtualized catalog

This commit is contained in:
Tiago Yamamoto 2025-12-17 15:56:07 -03:00
parent b0507e29a4
commit 424fbdba1d
24 changed files with 3985 additions and 0 deletions

5
marketplace-front/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.dist
dist
.env*
*.tsbuildinfo

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Marketplace Farmacêutico B2B</title>
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3019
marketplace-front/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
{
"name": "marketplace-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@mercadopago/sdk-react": "^1.0.6",
"axios": "^1.7.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"react-window": "^1.8.10",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/react": "^18.3.7",
"@types/react-dom": "^18.3.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.10",
"typescript": "^5.6.2",
"vite": "^5.4.3"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1,50 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from './pages/Login'
import { DashboardPage } from './pages/Dashboard'
import { CartPage } from './pages/Cart'
import { CheckoutPage } from './pages/Checkout'
import { ProfilePage } from './pages/Profile'
import { ProtectedRoute } from './components/ProtectedRoute'
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/cart"
element={
<ProtectedRoute>
<CartPage />
</ProtectedRoute>
}
/>
<Route
path="/checkout"
element={
<ProtectedRoute>
<CheckoutPage />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute allowedRoles={['admin']}>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>
)
}
export default App

View file

@ -0,0 +1,26 @@
import { Navigate } from 'react-router-dom'
import { ReactNode } from 'react'
import { useAuth, UserRole } from '../context/AuthContext'
interface ProtectedRouteProps {
children: ReactNode
allowedRoles?: UserRole[]
}
export function ProtectedRoute({ children, allowedRoles }: ProtectedRouteProps) {
const { user, loading } = useAuth()
if (loading) {
return <div className="p-6 text-center">Carregando sessão...</div>
}
if (!user) {
return <Navigate to="/login" replace />
}
if (allowedRoles && !allowedRoles.includes(user.role)) {
return <Navigate to="/dashboard" replace />
}
return <>{children}</>
}

View file

@ -0,0 +1,73 @@
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiClient } from '../services/apiClient'
export type UserRole = 'farmacia' | 'admin'
export interface AuthUser {
name: string
role: UserRole
token: string
}
interface AuthContextValue {
user: AuthUser | null
loading: boolean
login: (token: string, role: UserRole, name: string) => void
logout: () => void
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
const AUTH_KEY = 'mp-auth-user'
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => {
const persisted = localStorage.getItem(AUTH_KEY)
return persisted ? (JSON.parse(persisted) as AuthUser) : null
})
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
setLoading(false)
}, [])
useEffect(() => {
if (user) {
localStorage.setItem(AUTH_KEY, JSON.stringify(user))
apiClient.setToken(user.token)
} else {
localStorage.removeItem(AUTH_KEY)
apiClient.setToken(null)
}
}, [user])
const login = (token: string, role: UserRole, name: string) => {
setUser({ token, role, name })
navigate('/dashboard', { replace: true })
}
const logout = () => {
setUser(null)
navigate('/login', { replace: true })
}
const value = useMemo(
() => ({
user,
loading,
login,
logout
}),
[user, loading]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export const useAuth = () => {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

View file

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react'
export interface FilterState {
activeIngredient: string
category: string
lab: string
}
const STORAGE_KEY = 'mp-quick-filters'
export const defaultFilters: FilterState = {
activeIngredient: '',
category: '',
lab: ''
}
export function usePersistentFilters() {
const [filters, setFilters] = useState<FilterState>(() => {
const persisted = localStorage.getItem(STORAGE_KEY)
return persisted ? (JSON.parse(persisted) as FilterState) : defaultFilters
})
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
}, [filters])
return { filters, setFilters }
}

View file

@ -0,0 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color: #0f172a;
background-color: #f3f4f6;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
margin: 0;
background-color: #f3f4f6;
}
.table-header {
background-color: #0F4C81;
color: #ffffff;
}
.action-button {
background-color: #2D9CDB;
color: #ffffff;
}
.card-surface {
background-color: #e5e7eb;
}

View file

@ -0,0 +1,44 @@
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export function Shell({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth()
return (
<div className="min-h-screen bg-gray-100">
<header className="flex items-center justify-between bg-medicalBlue px-6 py-4 text-white shadow-md">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-white/10 text-center text-xl font-bold leading-10">MP</div>
<div>
<p className="text-lg font-semibold">Marketplace Farmacêutico B2B</p>
<p className="text-sm text-gray-100">Dashboard de Compras</p>
</div>
</div>
<nav className="flex items-center gap-4 text-sm font-medium">
<Link to="/dashboard" className="hover:underline">
Compras
</Link>
<Link to="/cart" className="hover:underline">
Carrinho
</Link>
<Link to="/checkout" className="hover:underline">
Checkout
</Link>
<Link to="/profile" className="hover:underline">
Perfil da Empresa
</Link>
{user && (
<button
type="button"
onClick={logout}
className="rounded bg-white/10 px-3 py-1 text-xs font-semibold hover:bg-white/20"
>
Sair ({user.role})
</button>
)}
</nav>
</header>
<main className="px-6 py-5">{children}</main>
</div>
)
}

View file

@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
import { AuthProvider } from './context/AuthContext'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
)

View file

@ -0,0 +1,111 @@
import { Shell } from '../layouts/Shell'
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
export function CartPage() {
const items = useCartStore((state) => state.items)
const groups = useCartStore(selectGroupedCart)
const summary = useCartStore(selectCartSummary)
const updateQuantity = useCartStore((state) => state.updateQuantity)
const removeItem = useCartStore((state) => state.removeItem)
const clearVendor = useCartStore((state) => state.clearVendor)
const clearAll = useCartStore((state) => state.clearAll)
return (
<Shell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Carrinho B2B</h1>
<p className="text-sm text-gray-600">
Agrupamento automático por distribuidora para suportar checkout unificado ou múltiplo.
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">Itens: {summary.totalItems}</p>
<p className="text-lg font-semibold text-medicalBlue">R$ {summary.totalValue.toFixed(2)}</p>
</div>
</div>
{items.length === 0 ? (
<div className="rounded-lg bg-white p-6 text-center text-gray-600 shadow">Nenhum item no carrinho.</div>
) : (
Object.entries(groups).map(([vendorId, group]) => (
<div key={vendorId} className="space-y-2 rounded-lg bg-white p-4 shadow-sm">
<div className="flex items-center justify-between border-b border-gray-200 pb-2">
<div>
<p className="text-lg font-semibold text-gray-800">{group.vendorName}</p>
<p className="text-xs text-gray-500">Checkout dedicado ou combine no fluxo unificado.</p>
</div>
<button
className="text-xs font-semibold text-medicalBlue hover:underline"
onClick={() => clearVendor(vendorId)}
>
Limpar fornecedor
</button>
</div>
<div className="space-y-2">
{group.items.map((item) => (
<div
key={item.id}
className="grid grid-cols-6 items-center gap-3 rounded bg-gray-50 px-3 py-2 text-sm"
>
<div className="col-span-2">
<p className="font-semibold text-gray-800">{item.name}</p>
<p className="text-xs text-gray-500">{item.activeIngredient} Lote {item.batch}</p>
</div>
<div>
<p className="text-xs text-gray-500">Validade</p>
<p className="font-medium text-gray-800">{item.expiry}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Preço</p>
<p className="font-semibold text-medicalBlue">R$ {item.unitPrice.toFixed(2)}</p>
</div>
<div className="flex items-center justify-end gap-2">
<input
type="number"
min={1}
className="w-20 rounded border border-gray-200 px-2 py-1"
value={item.quantity}
onChange={(e) => updateQuantity(item.id, Number(e.target.value))}
/>
<span className="text-xs text-gray-500">unidades</span>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Subtotal</p>
<p className="font-semibold text-gray-800">
R$ {(item.quantity * item.unitPrice).toFixed(2)}
</p>
<button
className="text-xs text-red-500 hover:underline"
onClick={() => removeItem(item.id)}
>
Remover
</button>
</div>
</div>
))}
<div className="flex items-center justify-between rounded bg-gray-100 px-3 py-2 text-sm">
<span className="font-semibold text-gray-700">Total do fornecedor</span>
<span className="font-bold text-medicalBlue">R$ {group.total.toFixed(2)}</span>
</div>
</div>
</div>
))
)}
{items.length > 0 && (
<div className="flex justify-end gap-3">
<button className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold" onClick={clearAll}>
Limpar carrinho
</button>
<a
href="/checkout"
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
>
Seguir para checkout unificado
</a>
</div>
)}
</div>
</Shell>
)
}

View file

@ -0,0 +1,73 @@
import { useEffect, useMemo, useState } from 'react'
import { initMercadoPago, Payment } from '@mercadopago/sdk-react'
import { Shell } from '../layouts/Shell'
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
import { useAuth } from '../context/AuthContext'
const MP_PUBLIC_KEY = import.meta.env.VITE_MP_PUBLIC_KEY || 'TEST-PUBLIC-KEY'
export function CheckoutPage() {
const { user } = useAuth()
const summary = useCartStore(selectCartSummary)
const groups = useCartStore(selectGroupedCart)
const [status, setStatus] = useState<'pendente' | 'pago' | null>(null)
useEffect(() => {
initMercadoPago(MP_PUBLIC_KEY, { locale: 'pt-BR' })
}, [])
const preferenceId = useMemo(() => `pref-${Date.now()}`, [])
return (
<Shell>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-3 rounded-lg bg-white p-4 shadow-sm">
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Checkout e Pagamento</h1>
<p className="text-sm text-gray-600">Split automático por distribuidora, status pendente/pago.</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Comprador</p>
<p className="font-semibold text-gray-800">{user?.name}</p>
</div>
</div>
{Object.entries(groups).map(([vendorId, group]) => (
<div key={vendorId} className="rounded border border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between">
<p className="font-semibold text-gray-800">{group.vendorName}</p>
<p className="text-sm font-bold text-medicalBlue">R$ {group.total.toFixed(2)}</p>
</div>
<p className="text-xs text-gray-600">Itens enviados no split de pagamento.</p>
</div>
))}
</div>
<div className="space-y-3 rounded-lg bg-white p-4 shadow-sm">
<div>
<p className="text-sm font-semibold text-gray-700">Resumo</p>
<p className="text-2xl font-bold text-medicalBlue">R$ {summary.totalValue.toFixed(2)}</p>
</div>
<div className="rounded bg-gray-100 p-3 text-sm">
<p className="font-semibold text-gray-700">Status</p>
<p className="text-gray-600">{status ? status.toUpperCase() : 'Aguardando pagamento'}</p>
</div>
<div className="rounded bg-gray-50 p-3">
<p className="text-sm font-semibold text-gray-700">Pagamento Mercado Pago</p>
<Payment
initialization={{ preferenceId, amount: summary.totalValue || 0.01 }}
customization={{ paymentMethods: { creditCard: 'all', bankTransfer: 'all' } }}
onSubmit={async () => {
setStatus('pendente')
}}
onReady={() => console.log('Payment brick pronto')}
onError={(error) => {
console.error(error)
setStatus(null)
}}
/>
</div>
</div>
</div>
</Shell>
)
}

View file

@ -0,0 +1,176 @@
import { useMemo, useState } from 'react'
import { FixedSizeList as List, ListChildComponentProps } from 'react-window'
import { usePersistentFilters, defaultFilters } from '../hooks/usePersistentFilters'
import { Product } from '../types/product'
import { useCartStore } from '../stores/cartStore'
import { Shell } from '../layouts/Shell'
const mockProducts: Product[] = Array.from({ length: 500 }).map((_, idx) => {
const lab = ['EMS', 'Eurofarma', 'Aché'][idx % 3]
const actives = ['Amoxicilina', 'Dipirona', 'Losartana']
const categories = ['Antibiótico', 'Analgésico', 'Cardiovascular']
return {
id: `prod-${idx + 1}`,
name: `Produto ${idx + 1}`,
activeIngredient: actives[idx % actives.length],
lab,
category: categories[idx % categories.length],
batch: `L${1000 + idx}`,
expiry: `202${(idx % 4) + 5}-0${(idx % 9) + 1}`,
vendorId: `vendor-${idx % 4}`,
vendorName: ['Distribuidora Norte', 'Distribuidora Sul', 'FastMed', 'HealthPro'][idx % 4],
price: 10 + (idx % 20)
}
})
function DenseRow({ index, style, data }: ListChildComponentProps<Product[]>) {
const product = data[index]
const addItem = useCartStore((state) => state.addItem)
return (
<div
style={style}
className="grid grid-cols-9 items-center gap-2 border-b border-gray-200 bg-gray-50 px-3 text-sm hover:bg-gray-100"
>
<div className="truncate font-semibold text-gray-800">{product.name}</div>
<div className="truncate text-gray-700">{product.activeIngredient}</div>
<div className="truncate text-gray-700">{product.lab}</div>
<div className="truncate text-gray-700">{product.batch}</div>
<div className="truncate text-gray-700">{product.expiry}</div>
<div className="truncate text-gray-700">{product.vendorName}</div>
<div className="text-right font-semibold text-medicalBlue">R$ {product.price.toFixed(2)}</div>
<div className="col-span-2 flex items-center justify-end gap-2">
<button
className="rounded bg-healthGreen px-3 py-1 text-xs font-semibold text-white shadow-sm hover:opacity-90"
onClick={() => addItem({
id: product.id,
name: product.name,
activeIngredient: product.activeIngredient,
lab: product.lab,
batch: product.batch,
expiry: product.expiry,
vendorId: product.vendorId,
vendorName: product.vendorName,
unitPrice: product.price
})}
>
Adicionar
</button>
</div>
</div>
)
}
export function DashboardPage() {
const { filters, setFilters } = usePersistentFilters()
const [search, setSearch] = useState('')
const filtered = useMemo(() => {
return mockProducts.filter((p) => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase())
const matchesActive = filters.activeIngredient
? p.activeIngredient === filters.activeIngredient
: true
const matchesCategory = filters.category ? p.category === filters.category : true
const matchesLab = filters.lab ? p.lab === filters.lab : true
return matchesSearch && matchesActive && matchesCategory && matchesLab
})
}, [filters.activeIngredient, filters.category, filters.lab, search])
const labs = Array.from(new Set(mockProducts.map((p) => p.lab)))
const actives = Array.from(new Set(mockProducts.map((p) => p.activeIngredient)))
const categories = Array.from(new Set(mockProducts.map((p) => p.category)))
return (
<Shell>
<div className="mb-4 flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 rounded-lg bg-white p-4 shadow-sm lg:grid-cols-4">
<div className="lg:col-span-2">
<p className="text-sm font-semibold text-gray-700">Busca rápida</p>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Procure por nome ou SKU"
className="mt-2 w-full rounded border border-gray-200 px-3 py-2"
/>
<p className="mt-1 text-xs text-gray-500">Otimizada para milhares de SKUs com renderização virtualizada.</p>
</div>
<div>
<label className="text-sm font-semibold text-gray-700">Princípio Ativo</label>
<select
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
value={filters.activeIngredient}
onChange={(e) => setFilters((prev) => ({ ...prev, activeIngredient: e.target.value }))}
>
<option value="">Todos</option>
{actives.map((active) => (
<option key={active} value={active}>
{active}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-semibold text-gray-700">Categoria</label>
<select
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
value={filters.category}
onChange={(e) => setFilters((prev) => ({ ...prev, category: e.target.value }))}
>
<option value="">Todas</option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-semibold text-gray-700">Laboratório</label>
<select
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
value={filters.lab}
onChange={(e) => setFilters((prev) => ({ ...prev, lab: e.target.value }))}
>
<option value="">Todos</option>
{labs.map((lab) => (
<option key={lab} value={lab}>
{lab}
</option>
))}
</select>
</div>
<div className="lg:col-span-4 flex justify-end">
<button
className="rounded bg-gray-200 px-3 py-2 text-xs font-semibold text-gray-700"
onClick={() => setFilters(defaultFilters)}
>
Limpar filtros
</button>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-200 shadow-inner">
<div className="table-header grid grid-cols-9 gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wide">
<span>Nome</span>
<span>Princípio Ativo</span>
<span>Laboratório</span>
<span>Lote</span>
<span>Validade</span>
<span>Distribuidora</span>
<span className="text-right">Preço</span>
<span className="col-span-2 text-right">Ações</span>
</div>
<List
height={520}
itemCount={filtered.length}
itemSize={52}
width="100%"
itemData={filtered}
>
{DenseRow}
</List>
</div>
</div>
</Shell>
)
}

View file

@ -0,0 +1,53 @@
import { FormEvent, useState } from 'react'
import { useAuth, UserRole } from '../context/AuthContext'
export function LoginPage() {
const { login } = useAuth()
const [role, setRole] = useState<UserRole>('farmacia')
const [name, setName] = useState('Dra. Silva')
const onSubmit = (event: FormEvent) => {
event.preventDefault()
login('fake-jwt-token', role, name)
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<form
onSubmit={onSubmit}
className="w-full max-w-md space-y-4 rounded-lg bg-white p-8 shadow-lg"
>
<h1 className="text-2xl font-bold text-medicalBlue">Acesso ao Marketplace</h1>
<p className="text-sm text-gray-600">Use o nível de acesso para testar as rotas protegidas.</p>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700" htmlFor="name">
Nome do usuário
</label>
<input
id="name"
className="w-full rounded border border-gray-200 px-3 py-2"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700" htmlFor="role">
Perfil
</label>
<select
id="role"
className="w-full rounded border border-gray-200 px-3 py-2"
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
>
<option value="farmacia">Farmácia</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" className="w-full rounded bg-healthGreen px-4 py-2 font-semibold text-white">
Entrar
</button>
</form>
</div>
)
}

View file

@ -0,0 +1,57 @@
import { useState } from 'react'
import { Shell } from '../layouts/Shell'
interface DocumentUpload {
name: string
status: 'validado' | 'pendente'
lastUpdate: string
}
export function ProfilePage() {
const [docs, setDocs] = useState<DocumentUpload[]>([
{ name: 'Alvará Sanitário', status: 'validado', lastUpdate: '2024-06-10' },
{ name: 'CRF Responsável Técnico', status: 'pendente', lastUpdate: '2024-05-02' }
])
const updateStatus = (name: string, status: DocumentUpload['status']) => {
setDocs((prev) => prev.map((doc) => (doc.name === name ? { ...doc, status } : doc)))
}
return (
<Shell>
<div className="space-y-4 rounded-lg bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Perfil da Empresa</h1>
<p className="text-sm text-gray-600">Envie e acompanhe documentos regulatórios da farmácia.</p>
</div>
</div>
<div className="space-y-3">
{docs.map((doc) => (
<div key={doc.name} className="flex items-center justify-between rounded bg-gray-50 px-4 py-3">
<div>
<p className="font-semibold text-gray-800">{doc.name}</p>
<p className="text-xs text-gray-500">Última atualização: {doc.lastUpdate}</p>
</div>
<div className="flex items-center gap-3">
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${
doc.status === 'validado' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}
>
{doc.status.toUpperCase()}
</span>
<button
className="rounded bg-healthGreen px-3 py-1 text-xs font-semibold text-white"
onClick={() => updateStatus(doc.name, doc.status === 'validado' ? 'pendente' : 'validado')}
>
Alternar status
</button>
</div>
</div>
))}
</div>
</div>
</Shell>
)
}

View file

@ -0,0 +1,35 @@
import axios from 'axios'
const instance = axios.create({
baseURL: '/api',
timeout: 8000
})
let token: string | null = null
instance.interceptors.request.use((config) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.warn('Sessão expirada, por favor, faça login novamente.')
}
return Promise.reject(error)
}
)
export const apiClient = {
get: instance.get,
post: instance.post,
put: instance.put,
delete: instance.delete,
setToken: (value: string | null) => {
token = value
}
}

View file

@ -0,0 +1,77 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface CartItem {
id: string
name: string
activeIngredient: string
lab: string
batch: string
expiry: string
vendorId: string
vendorName: string
unitPrice: number
quantity: number
}
interface CartState {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>, quantity?: number) => void
updateQuantity: (id: string, quantity: number) => void
removeItem: (id: string) => void
clearVendor: (vendorId: string) => void
clearAll: () => void
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
addItem: (item, quantity = 1) => {
set((state) => {
const exists = state.items.find((i) => i.id === item.id)
if (exists) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + quantity } : i
)
}
}
return { items: [...state.items, { ...item, quantity }] }
})
},
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i))
})),
removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clearVendor: (vendorId) =>
set((state) => ({ items: state.items.filter((i) => i.vendorId !== vendorId) })),
clearAll: () => set({ items: [] })
}),
{
name: 'mp-cart-storage',
version: 1
}
)
)
export const selectGroupedCart = (state: CartState) => {
const groups = state.items.reduce<Record<string, { vendorName: string; items: CartItem[]; total: number }>>(
(acc, item) => {
const current = acc[item.vendorId] ?? { vendorName: item.vendorName, items: [], total: 0 }
current.items.push(item)
current.total += item.quantity * item.unitPrice
acc[item.vendorId] = current
return acc
},
{}
)
return groups
}
export const selectCartSummary = (state: CartState) => {
const totalItems = state.items.reduce((acc, item) => acc + item.quantity, 0)
const totalValue = state.items.reduce((acc, item) => acc + item.quantity * item.unitPrice, 0)
return { totalItems, totalValue }
}

View file

@ -0,0 +1,12 @@
export interface Product {
id: string
name: string
activeIngredient: string
lab: string
category: string
batch: string
expiry: string
vendorId: string
vendorName: string
price: number
}

View file

@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
healthGreen: '#2D9CDB',
medicalBlue: '#0F4C81'
}
}
},
plugins: []
} satisfies Config

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vite/client"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
})