saveinmed/marketplace/src/pages/Inventory.tsx
Tiago Yamamoto 607d942072 feat: implement 2-step product registration with new catalog fields
Backend:
- Add ean_code, manufacturer, category, subcategory, observations to Product model
- Create migration 0006_product_catalog_fields.sql
- Update repository queries (CreateProduct, ListProducts, ListRecords)

Frontend:
- Create ProductCreate.tsx with 2-step wizard form
- Add route /products/new
- Add 'Cadastrar Produto' button to Inventory page

Seeder:
- Update CREATE TABLE products with new columns
- Update generateProducts with EAN codes and manufacturers

Docs:
- Update database-schema.md with new fields
2025-12-23 17:09:38 -03:00

161 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Shell } from '../layouts/Shell'
import { apiClient } from '../services/apiClient'
import { formatCents } from '../utils/format'
interface InventoryItem {
product_id: string
seller_id: string
name: string
batch: string
expires_at: string
quantity: number
price_cents: number
}
export function InventoryPage() {
const [inventory, setInventory] = useState<InventoryItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expiringDays, setExpiringDays] = useState('')
useEffect(() => {
loadInventory()
}, [expiringDays])
const loadInventory = async () => {
try {
setLoading(true)
const params = expiringDays ? `?expires_in_days=${expiringDays}` : ''
const response = await apiClient.get<{ items: InventoryItem[]; total: number }>(`/v1/inventory${params}`)
setInventory(response?.items || [])
setError(null)
} catch (err) {
setError('Erro ao carregar estoque')
console.error(err)
} finally {
setLoading(false)
}
}
const adjustStock = async (productId: string, delta: number, reason: string) => {
try {
await apiClient.post('/v1/inventory/adjust', {
product_id: productId,
delta,
reason
})
await loadInventory()
} catch (err) {
console.error('Erro ao ajustar estoque:', err)
}
}
const isExpiringSoon = (date: string) => {
const expires = new Date(date)
const now = new Date()
const diffDays = Math.ceil((expires.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return diffDays <= 30
}
return (
<Shell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Meus Produtos</h1>
<p className="text-sm text-gray-600">Controle de inventário e validades</p>
</div>
<div className="flex items-center gap-3">
<select
value={expiringDays}
onChange={(e) => setExpiringDays(e.target.value)}
className="rounded border border-gray-200 px-3 py-2 text-sm"
>
<option value="">Todos os produtos</option>
<option value="7">Vencendo em 7 dias</option>
<option value="30">Vencendo em 30 dias</option>
<option value="90">Vencendo em 90 dias</option>
</select>
<button
onClick={loadInventory}
className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
>
Atualizar
</button>
<Link
to="/products/new"
className="rounded bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
>
+ Cadastrar Produto
</Link>
</div>
</div>
{loading && (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-medicalBlue"></div>
</div>
)}
{error && (
<div className="rounded bg-red-100 p-4 text-red-700">{error}</div>
)}
{!loading && inventory.length === 0 && (
<div className="rounded bg-gray-100 p-8 text-center text-gray-600">
Nenhum item no estoque
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Produto</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Lote</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Validade</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-gray-600">Quantidade</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-gray-600">Preço</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase text-gray-600">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{inventory.map((item) => (
<tr key={`${item.product_id}-${item.batch}`} className={isExpiringSoon(item.expires_at) ? 'bg-yellow-50' : ''}>
<td className="px-4 py-3 text-sm font-medium text-gray-800">{item.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.batch}</td>
<td className={`px-4 py-3 text-sm ${isExpiringSoon(item.expires_at) ? 'font-semibold text-orange-600' : 'text-gray-600'}`}>
{new Date(item.expires_at).toLocaleDateString('pt-BR')}
{isExpiringSoon(item.expires_at) && <span className="ml-2 text-xs"></span>}
</td>
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-800">{item.quantity}</td>
<td className="px-4 py-3 text-right text-sm text-medicalBlue">
{formatCents(item.price_cents)}
</td>
<td className="px-4 py-3 text-center">
<div className="flex justify-center gap-2">
<button
onClick={() => adjustStock(item.product_id, 10, 'Entrada manual')}
className="rounded bg-green-500 px-2 py-1 text-xs text-white"
>
+10
</button>
<button
onClick={() => adjustStock(item.product_id, -10, 'Saída manual')}
className="rounded bg-red-500 px-2 py-1 text-xs text-white"
>
-10
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Shell>
)
}