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
161 lines
7.7 KiB
TypeScript
161 lines
7.7 KiB
TypeScript
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>
|
||
)
|
||
}
|