diff --git a/app/(protected)/productos/productos-client.tsx b/app/(protected)/productos/productos-client.tsx index a2e72d0..f3a1ef6 100644 --- a/app/(protected)/productos/productos-client.tsx +++ b/app/(protected)/productos/productos-client.tsx @@ -1,13 +1,15 @@ 'use client' -import { useState } from 'react' -import { Package, Plus } from 'lucide-react' +import { useState, useSyncExternalStore } from 'react' +import { Package, Plus, ShoppingCart } from 'lucide-react' import { ProductCard } from '@/components/product-card' import { Modal } from '@/components/ui/modal' import { ProductFormModal } from '@/components/product-form-modal' +import { ShoppingCartModal } from '@/components/shopping-cart-modal' import { EmptyState } from '@/components/ui/empty-state' import { Button } from '@/components/ui/button' import { useQuery, useQueryClient } from '@tanstack/react-query' +import { getCart } from '@/lib/shopping-cart' interface Supply { id: string @@ -60,8 +62,18 @@ interface ProductosClientProps { export function ProductosClient({ products: initialProducts, categories, allSupplies, company }: ProductosClientProps) { const queryClient = useQueryClient() const [modalOpen, setModalOpen] = useState(false) + const [cartModalOpen, setCartModalOpen] = useState(false) const [editingProduct, setEditingProduct] = useState(null) + const cartCount = useSyncExternalStore( + (onChange) => { + window.addEventListener('cart-updated', onChange) + return () => window.removeEventListener('cart-updated', onChange) + }, + () => getCart().reduce((acc, item) => acc + item.lots, 0), + () => 0, + ) + const { data: products } = useQuery({ queryKey: ['products'], queryFn: async () => { @@ -109,12 +121,26 @@ export function ProductosClient({ products: initialProducts, categories, allSupp Define tus productos y sus recetas para un control automático de inventario.

- +
+ + +
{/* Product List */} @@ -141,7 +167,7 @@ export function ProductosClient({ products: initialProducts, categories, allSupp )} - {/* Modal */} + {/* Modals */} setModalOpen(false)}>
+ + setCartModalOpen(false)} /> ) } \ No newline at end of file diff --git a/components/product-card.tsx b/components/product-card.tsx index b6b0fdf..749c238 100644 --- a/components/product-card.tsx +++ b/components/product-card.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { formatCurrency, cn } from '@/lib/utils' import { calculateUnitCost, calculateRentabilidad, getDisplayPrice, getDisplayMargin } from '@/lib/pricing' -import { Trash2, ShoppingBag, Edit2, ChevronDown, Plus, Package, BookOpen } from 'lucide-react' +import { Trash2, ShoppingBag, Edit2, ChevronDown, Plus, Package, BookOpen, ShoppingCart } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { UNIDADES_POR_TIPO } from '@/lib/constants' import { getUnitType } from '@/lib/conversions' @@ -16,6 +16,8 @@ import { RecipeForm } from './product-forms' import { NumericInput } from './ui/numeric-input' import { ConfirmationModal } from './ui/confirmation-modal' import { Modal } from '@/components/ui/modal' +import { addToCart } from '@/lib/shopping-cart' +import { flyToCart } from '@/lib/fly-to-cart' import { sileo as toast } from 'sileo' export function ProductCard({ @@ -35,6 +37,7 @@ export function ProductCard({ const [isProducing, setIsProducing] = useState(false) const [isConsuming, setIsConsuming] = useState(false) const [isRecipeOpen, setIsRecipeOpen] = useState(false) + const [lotInput, setLotInput] = useState(1) const [consumeQuantity, setConsumeQuantity] = useState(1) const [recipeItemToDelete, setRecipeItemToDelete] = useState(null) const [productToDelete, setProductToDelete] = useState(null) @@ -197,16 +200,51 @@ export function ProductCard({
{product.recipeItems.length > 0 && ( -

0 - ? "bg-amber-500/10 text-amber-700 dark:text-amber-300" - : "bg-muted/50 text-muted-foreground" - )}> - {product.maxPossibleBatches > 0 - ? `Insumos suficientes para ${product.maxPossibleBatches} lote(s).` - : "Insumos insuficientes para esta receta."} -

+
+
+
+ +
+
+

+ Lista de Compra +

+

+ {product.maxPossibleBatches > 0 + ? `Insumos suficientes para ${product.maxPossibleBatches} lote(s).` + : "Insumos insuficientes para esta receta."} +

+
+
+
+ + +
+
)} {Number(product.currentStock) > 0 && ( diff --git a/components/shopping-cart-modal.tsx b/components/shopping-cart-modal.tsx new file mode 100644 index 0000000..3f09268 --- /dev/null +++ b/components/shopping-cart-modal.tsx @@ -0,0 +1,213 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Document, Page, Text, View, StyleSheet, usePDF } from '@react-pdf/renderer' +import { Modal } from '@/components/ui/modal' +import { Button } from '@/components/ui/button' +import { NumericInput } from '@/components/ui/numeric-input' +import { Trash2, Download, ShoppingCart } from 'lucide-react' +import { getCart, getConsolidatedList, updateLots, removeFromCart, clearCart, type ConsolidatedSupply } from '@/lib/shopping-cart' +import { formatCurrency } from '@/lib/utils' + +const styles = StyleSheet.create({ + page: { padding: 40, fontFamily: 'Helvetica' }, + header: { marginBottom: 30 }, + title: { fontSize: 22, fontWeight: 'bold', marginBottom: 5 }, + subtitle: { fontSize: 11, color: '#666', marginBottom: 3 }, + section: { marginBottom: 20 }, + sectionTitle: { fontSize: 13, fontWeight: 'bold', marginBottom: 8, borderBottom: '1 solid #ddd', paddingBottom: 4 }, + table: { marginBottom: 10 }, + tableHeader: { flexDirection: 'row', backgroundColor: '#f5f5f5', borderBottom: '1 solid #ddd', padding: 6 }, + tableHeaderCell: { fontSize: 9, fontWeight: 'bold', color: '#666', flex: 1 }, + tableRow: { flexDirection: 'row', borderBottom: '1 solid #eee', padding: 6 }, + tableCell: { fontSize: 9, flex: 1 }, + detailCell: { fontSize: 7, color: '#999', flex: 1 }, + footer: { position: 'absolute', bottom: 30, left: 40, right: 40, borderTop: '1 solid #ddd', paddingTop: 8, fontSize: 8, color: '#999' }, +}) + +function ShoppingListPDF({ list, date }: { list: ConsolidatedSupply[]; date: string }) { + return ( + + + + Lista de Compra + Generada el: {date} + + + + Insumos Requeridos + + + Insumo + Cantidad Total + Unidad + Para Producto(s) + + {list.length === 0 ? ( + + No hay insumos en la lista + + ) : ( + list.map((item, i) => ( + + + {item.name} + {Number(item.totalQuantity.toFixed(2))} + {item.unit} + + {item.products.map((p) => `${p.name} (${p.lots} lote${p.lots > 1 ? 's' : ''})`).join(', ')} + + + + )) + )} + + + + + Loki - Sistema de Gestión Empresarial + + + + ) +} + +interface ShoppingCartModalProps { + isOpen: boolean + onClose: () => void +} + +export function ShoppingCartModal({ isOpen, onClose }: ShoppingCartModalProps) { + const [cart, setCart] = useState(getCart()) + const [consolidated, setConsolidated] = useState([]) + + useEffect(() => { + if (isOpen) { + setCart(getCart()) + setConsolidated(getConsolidatedList()) + } + }, [isOpen]) + + const refresh = () => { + setCart(getCart()) + setConsolidated(getConsolidatedList()) + } + + const totalLots = cart.reduce((acc, item) => acc + item.lots, 0) + const totalSupplies = consolidated.reduce((acc, item) => acc + item.totalQuantity, 0) + + return ( + +
+ {cart.length === 0 ? ( +
+ +

La lista está vacía

+

Agrega productos desde las tarjetas de catálogo.

+
+ ) : ( + <> + {/* Product list */} +
+

+ Productos ({cart.length}) — {totalLots} lote{totalLots !== 1 ? 's' : ''} +

+ {cart.map((item) => ( +
+
+

{item.productName}

+

+ {item.recipeItems.length} insumo{item.recipeItems.length !== 1 ? 's' : ''} por lote +

+
+
+ { updateLots(item.productId, v); refresh() }} + min={1} + className="h-8 w-16 text-xs font-bold text-center" + /> + +
+
+ ))} +
+ + {/* Consolidated list */} +
+

+ Insumos Requeridos ({consolidated.length}) — {totalSupplies.toFixed(1)} unidades totales +

+
+ + + + + + + + + + {consolidated.map((item, i) => ( + + + + + + ))} + +
InsumoCantidadPara
{item.name} + + {Number(item.totalQuantity.toFixed(2))} {item.unit} + + + {item.products.map((p) => `${p.name} (${p.lots} lote${p.lots > 1 ? 's' : ''})`).join(', ')} +
+
+
+ + {/* Actions */} +
+ + +
+ + )} +
+
+ ) +} + +function DownloadButton({ consolidated }: { consolidated: ConsolidatedSupply[] }) { + const [instance] = usePDF({ + document: , + }) + + const handleClick = () => { + if (!instance.url) return + const a = document.createElement('a') + a.href = instance.url + a.download = `lista-compra-${new Date().toISOString().split('T')[0]}.pdf` + a.click() + } + + return ( + + ) +} diff --git a/lib/fly-to-cart.ts b/lib/fly-to-cart.ts new file mode 100644 index 0000000..8888932 --- /dev/null +++ b/lib/fly-to-cart.ts @@ -0,0 +1,36 @@ +export function flyToCart(from: HTMLElement, lots: number, onDone?: () => void) { + const target = document.querySelector('[data-cart-badge]') + if (!target) return + + const fromRect = from.getBoundingClientRect() + const toRect = target.getBoundingClientRect() + + const el = document.createElement('div') + el.textContent = `+${lots}` + el.className = + 'fixed z-[99999] px-3 h-12 rounded-full bg-primary text-primary-foreground text-base font-black flex items-center justify-center shadow-xl pointer-events-none' + el.style.willChange = 'transform, opacity' + el.style.left = '0' + el.style.top = '0' + el.style.transition = 'transform 2000ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 700ms ease-out 1000ms' + + const startX = fromRect.left + fromRect.width / 2 + const startY = fromRect.top + const endX = toRect.left + toRect.width / 2 - 15 + const endY = toRect.top + + el.style.transform = `translate(${startX}px, ${startY}px) scale(1)` + el.style.opacity = '1' + + document.body.appendChild(el) + + requestAnimationFrame(() => { + el.style.transform = `translate(${endX}px, ${endY}px) scale(0.5)` + el.style.opacity = '0' + }) + + setTimeout(() => { + el.remove() + onDone?.() + }, 1500) +} diff --git a/lib/shopping-cart.ts b/lib/shopping-cart.ts new file mode 100644 index 0000000..5098868 --- /dev/null +++ b/lib/shopping-cart.ts @@ -0,0 +1,106 @@ +'use client' + +const STORAGE_KEY = 'loki-shopping-cart' + +export interface CartRecipeItem { + name: string + quantity: number + unit: string +} + +export interface CartItem { + productId: string + productName: string + lots: number + recipeItems: CartRecipeItem[] +} + +export interface ConsolidatedSupply { + name: string + unit: string + totalQuantity: number + products: { name: string; lots: number; quantity: number }[] +} + +export function getCart(): CartItem[] { + if (typeof window === 'undefined') return [] + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } +} + +export function saveCart(items: CartItem[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('cart-updated')) + } +} + +export function addToCart( + productId: string, + productName: string, + lots: number, + recipeItems: CartRecipeItem[] +) { + const cart = getCart() + const existing = cart.find((i) => i.productId === productId) + if (existing) { + existing.lots += lots + } else { + cart.push({ productId, productName, lots, recipeItems }) + } + saveCart(cart) +} + +export function updateLots(productId: string, lots: number) { + const cart = getCart() + const item = cart.find((i) => i.productId === productId) + if (item) { + if (lots <= 0) { + removeFromCart(productId) + return + } + item.lots = lots + saveCart(cart) + } +} + +export function removeFromCart(productId: string) { + const cart = getCart().filter((i) => i.productId !== productId) + saveCart(cart) +} + +export function clearCart() { + localStorage.removeItem(STORAGE_KEY) + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('cart-updated')) + } +} + +export function getConsolidatedList(): ConsolidatedSupply[] { + const cart = getCart() + const map = new Map() + + for (const item of cart) { + for (const ri of item.recipeItems) { + const totalQty = ri.quantity * item.lots + const existing = map.get(ri.name) + if (existing) { + existing.totalQuantity += totalQty + existing.products.push({ name: item.productName, lots: item.lots, quantity: totalQty }) + } else { + map.set(ri.name, { + name: ri.name, + unit: ri.unit, + totalQuantity: totalQty, + products: [{ name: item.productName, lots: item.lots, quantity: totalQty }], + }) + } + } + } + + return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name)) +}