feat: agregar endpoints atómicos, auth de descuentos y limpiar deuda técnica

This commit is contained in:
Blxckbxll24 committed 2026-10-05 14:11:22 -05:00
1 parent 2d6c29a1f3
commit 1aa64e4bd0
50 files changed
+726 -303

No files matched your search

@@ -1,26 +0,0 @@
import { Icon, type IconifyIcon } from "@iconify/react";
interface SectionPlaceholderProps {
icon: IconifyIcon;
title: string;
description: string;
}
export function SectionPlaceholder({ icon, title, description }: SectionPlaceholderProps) {
return (
<div>
<h1 className="text-xl font-semibold text-foreground">{title}</h1>
<div className="mt-4 flex min-h-[50vh] flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-border bg-surface/50 p-8 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={icon} width={32} height={32} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<div className="flex flex-col items-center gap-2">
<span className="rounded-full bg-warning-soft px-2.5 py-1 text-xs font-medium text-warning-soft-foreground">
Próximamente
</span>
<p className="max-w-sm text-sm text-muted">{description}</p>
</div>
</div>
</div>
);
}
@@ -2,19 +2,13 @@
import { Icon } from "@iconify/react";
import { useEffect, useRef, useState } from "react";
import { useTheme } from "../hooks/use-theme";
import { iconMonitor, iconMoon, iconSun } from "../icons";
const MODES = ["light", "dark", "system"] as const;
type Mode = (typeof MODES)[number];
const ICONS: Record<Mode, typeof iconSun> = { light: iconSun, dark: iconMoon, system: iconMonitor };
const LABELS: Record<Mode, string> = { light: "Claro", dark: "Oscuro", system: "Automático" };
import { THEME_ICONS, THEME_LABELS, THEME_MODES, type ThemeMode, useTheme } from "../hooks/use-theme";
import { iconMonitor } from "../icons";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const mounted = theme !== undefined;
const current: Mode = mounted && MODES.includes(theme as Mode) ? (theme as Mode) : "system";
const current: ThemeMode = mounted && THEME_MODES.includes(theme as ThemeMode) ? (theme as ThemeMode) : "system";
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
@@ -34,7 +28,7 @@ export function ThemeToggle() {
};
}, [open]);
function select(mode: Mode) {
function select(mode: ThemeMode) {
setTheme(mode);
setOpen(false);
}
@@ -46,11 +40,11 @@ export function ThemeToggle() {
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={`Tema: ${LABELS[current]}. Cambiar tema`}
title={`Tema: ${LABELS[current]}`}
aria-label={`Tema: ${THEME_LABELS[current]}. Cambiar tema`}
title={`Tema: ${THEME_LABELS[current]}`}
className="flex size-11 shrink-0 items-center justify-center rounded-lg text-muted transition hover:bg-foreground/10 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Icon icon={mounted ? ICONS[current] : iconMonitor} width={18} height={18} aria-hidden="true" />
<Icon icon={mounted ? THEME_ICONS[current] : iconMonitor} width={18} height={18} aria-hidden="true" />
</button>
{open && (
@@ -59,7 +53,7 @@ export function ThemeToggle() {
aria-label="Tema"
className="absolute right-0 top-[calc(100%+8px)] z-50 w-40 overflow-hidden rounded-xl border border-border bg-surface p-1.5 shadow-lg"
>
{MODES.map((mode) => (
{THEME_MODES.map((mode) => (
<button
key={mode}
type="button"
@@ -72,8 +66,8 @@ export function ThemeToggle() {
: "text-foreground hover:bg-foreground/5"
}`}
>
<Icon icon={ICONS[mode]} width={16} height={16} aria-hidden="true" />
{LABELS[mode]}
<Icon icon={THEME_ICONS[mode]} width={16} height={16} aria-hidden="true" />
{THEME_LABELS[mode]}
</button>
))}
</div>
+15 -1
View File
@@ -1,8 +1,22 @@
"use client";
import { createContext, useCallback, useContext, useLayoutEffect, useState } from "react";
import { iconMonitor, iconMoon, iconSun } from "../icons";
export type ThemeMode = "light" | "dark" | "system";
export const THEME_MODES = ["light", "dark", "system"] as const;
export type ThemeMode = (typeof THEME_MODES)[number];
export const THEME_ICONS: Record<ThemeMode, typeof iconSun> = {
light: iconSun,
dark: iconMoon,
system: iconMonitor,
};
export const THEME_LABELS: Record<ThemeMode, string> = {
light: "Claro",
dark: "Oscuro",
system: "Automático",
};
const STORAGE_KEY = "theme";
+4 -6
View File
@@ -1,6 +1,4 @@
export interface AuthUser {
id: number;
name: string;
email: string;
role: string;
}
import type { AppUser } from "./entities";
export type AuthUser = AppUser;
@@ -4,14 +4,17 @@ import { Avatar } from "@heroui/react";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { useTheme } from "../../_core/hooks/use-theme";
import {
THEME_ICONS,
THEME_LABELS,
THEME_MODES,
type ThemeMode,
useTheme,
} from "../../_core/hooks/use-theme";
import {
iconChevronDown,
iconIdentificationBadge,
iconLogout,
iconMonitor,
iconMoon,
iconSun,
} from "../../_core/icons";
import type { AuthUser } from "../../_core/types/auth-user";
@@ -20,12 +23,6 @@ interface UserMenuProps {
onLogout: () => void;
}
const THEME_MODES = ["light", "dark", "system"] as const;
type ThemeMode = (typeof THEME_MODES)[number];
const THEME_ICONS: Record<ThemeMode, typeof iconSun> = { light: iconSun, dark: iconMoon, system: iconMonitor };
const THEME_LABELS: Record<ThemeMode, string> = { light: "Claro", dark: "Oscuro", system: "Automático" };
export function UserMenu({ user, onLogout }: UserMenuProps) {
const [open, setOpen] = useState(false);
const reduceMotion = useReducedMotion();
+1 -1
View File
@@ -36,7 +36,7 @@ export default function AdminCustomersPage() {
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
@@ -3,7 +3,7 @@
import { Icon } from "@iconify/react";
import { Label, ListBox, Select } from "@heroui/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { notFound, useParams } from "next/navigation";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { iconArchive, iconArrowLeft, iconBox, iconScale } from "../../../_core/icons";
@@ -25,6 +25,11 @@ type MovementType = "in" | "out";
export default function AdminInventoryDetailPage() {
const params = useParams<{ id: string }>();
const inventoryId = Number(params.id);
if (Number.isNaN(inventoryId)) {
notFound();
}
const queryClient = useQueryClient();
const { data: inventory = null } = inventoriesResource.useDetail(inventoryId);
@@ -63,7 +68,7 @@ export default function AdminInventoryDetailPage() {
setDialogType(type);
}
async function handleSubmitMovement(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmitMovement(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (!dialogType) return;
const formData = new FormData(e.currentTarget);
@@ -97,7 +102,7 @@ export default function AdminInventoryDetailPage() {
setCountOpen(true);
}
async function handleCountSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleCountSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (!stock) return;
setCounting(true);
@@ -37,7 +37,7 @@ export default function AdminCategoriesPage() {
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const name = String(new FormData(e.currentTarget).get("name") ?? "").trim();
setSubmitError(null);
+1 -1
View File
@@ -38,7 +38,7 @@ export default function AdminInventoryPage() {
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const name = String(new FormData(e.currentTarget).get("name") ?? "").trim();
setSubmitError(null);
@@ -41,8 +41,6 @@ export default function AdminProductsPage() {
const createProduct = productsResource.useCreate();
const updateProduct = productsResource.useUpdate();
const removeProduct = productsResource.useRemove();
const createHistory = productHistoryResource.useCreate();
const updateHistory = productHistoryResource.useUpdate();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Product | null>(null);
@@ -103,18 +101,13 @@ export default function AdminProductsPage() {
}
async function syncCategories(productId: number) {
const current = links.filter((l) => l.productId === productId).map((l) => l.categoryId);
const toAdd = selectedCategoryIds.filter((id) => !current.includes(id));
const toRemove = current.filter((id) => !selectedCategoryIds.includes(id));
await Promise.all([
...toAdd.map((categoryId) => apiClient.post("/product-categories", { productId, categoryId })),
...toRemove.map((categoryId) => apiClient.delete(`/product-categories/${productId}/${categoryId}`)),
]);
await apiClient.patch(`/products/${productId}/categories`, {
categoryIds: selectedCategoryIds,
});
queryClient.invalidateQueries({ queryKey: productCategoriesResource.key });
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
@@ -151,7 +144,7 @@ export default function AdminProductsPage() {
}
}
async function handlePriceSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handlePriceSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (!pricing) return;
const formData = new FormData(e.currentTarget);
@@ -161,11 +154,11 @@ export default function AdminProductsPage() {
setSubmitting(true);
setSubmitError(null);
try {
const current = activePriceByProduct.get(pricing.id);
if (current) {
await updateHistory.mutateAsync({ id: current.id, data: { status: "archived" } });
}
await createHistory.mutateAsync({ productId: pricing.id, unitPrice, costUnit, status: "active" });
await apiClient.post(`/products/${pricing.id}/price`, {
unitPrice,
costUnit,
});
queryClient.invalidateQueries({ queryKey: productHistoryResource.key });
setPricing(null);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo actualizar el precio.");
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { notFound, useParams } from "next/navigation";
import { useMemo } from "react";
import { iconArrowLeft, iconBox, iconDelivery } from "../../../_core/icons";
import { inventoryMovementsResource, productsResource, suppliersResource } from "../../../_core/services/entities";
@@ -12,6 +12,10 @@ export default function AdminSupplierDetailPage() {
const params = useParams<{ id: string }>();
const supplierId = Number(params.id);
if (Number.isNaN(supplierId)) {
notFound();
}
const { data: supplier = null } = suppliersResource.useDetail(supplierId);
const { data: products = [] } = productsResource.useList();
const { data: movements = [] } = inventoryMovementsResource.useList();
+1 -1
View File
@@ -37,7 +37,7 @@ export default function AdminSuppliersPage() {
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
+1 -1
View File
@@ -43,7 +43,7 @@ export default function AdminUsersPage() {
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
@@ -29,7 +29,7 @@ export function PasswordForm({ user, error, submitting, onSubmit, onBack }: Pass
passwordRef.current?.focus();
}, [error]);
function handleSubmit(e: React.FormEvent) {
function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
onSubmit(password);
}
@@ -62,7 +62,7 @@ export function CashRegisterControl() {
const totalOut = sessionMovements.filter((m) => m.type === "out").reduce((sum, m) => sum + Number(m.amount), 0);
const expectedCash = session ? Number(session.openingCash) + totalIn - totalOut : 0;
async function handleOpen(e: React.FormEvent<HTMLFormElement>) {
async function handleOpen(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const inventoryId = Number(formData.get("inventoryId"));
@@ -78,7 +78,7 @@ export function CashRegisterControl() {
}
}
async function handleMovement(e: React.FormEvent<HTMLFormElement>) {
async function handleMovement(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (!session || !movementDialog) return;
const formData = new FormData(e.currentTarget);
@@ -102,7 +102,7 @@ export function CashRegisterControl() {
}
}
async function handleClose(e: React.FormEvent<HTMLFormElement>) {
async function handleClose(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (!session) return;
const formData = new FormData(e.currentTarget);
+152 -12
View File
@@ -27,8 +27,9 @@ import {
} from "../_core/services/entities";
import { useSessionUser } from "../_core/hooks/use-session-user";
import { Dialog } from "../_core/components/dialog";
import { SelectInput, TextInput } from "../_core/components/form-controls";
import type { Product, ProductHistoryRow } from "../_core/types/entities";
import { Field, SelectInput, TextInput } from "../_core/components/form-controls";
import type { AppUser, Product, ProductHistoryRow } from "../_core/types/entities";
import { fetchUsers, login } from "../login/_services/auth.service";
import { useCashSession } from "./_components/cash-session-context";
interface CartLine {
@@ -74,6 +75,14 @@ export default function PosSalePage() {
const [barcode, setBarcode] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [discount, setDiscount] = useState(0);
const [discountAuthorizedBy, setDiscountAuthorizedBy] = useState<number | null>(null);
const [authDialogOpen, setAuthDialogOpen] = useState(false);
const [pendingDiscount, setPendingDiscount] = useState<number | null>(null);
const [adminUsers, setAdminUsers] = useState<AppUser[]>([]);
const [authAdminId, setAuthAdminId] = useState<number | null>(null);
const [authPassword, setAuthPassword] = useState("");
const [authError, setAuthError] = useState<string | null>(null);
const [authorizing, setAuthorizing] = useState(false);
const [customerId, setCustomerId] = useState<string>("");
const [paymentMethod, setPaymentMethod] = useState("cash");
const [checkoutError, setCheckoutError] = useState<string | null>(null);
@@ -82,6 +91,16 @@ export default function PosSalePage() {
const barcodeRef = useRef<HTMLInputElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchUsers()
.then((users) => {
const admins = users.filter((u) => u.role === "admin");
setAdminUsers(admins);
if (admins.length > 0) setAuthAdminId(admins[0].id);
})
.catch(() => {});
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const isShortcut = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
@@ -169,7 +188,7 @@ export default function PosSalePage() {
setQuery("");
}
function handleBarcodeSubmit(e: React.FormEvent) {
function handleBarcodeSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const code = barcode.trim();
if (!code) return;
@@ -204,12 +223,53 @@ export default function PosSalePage() {
function clearCart() {
setCart([]);
setDiscount(0);
setDiscountAuthorizedBy(null);
setCustomerId("");
setCheckoutError(null);
}
const subtotal = cart.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0);
const total = Math.max(0, subtotal - discount);
const maxUnauthDiscount = Number((subtotal * 0.15).toFixed(2));
const effectiveDiscount = Math.min(discount, subtotal);
const total = Math.max(0, subtotal - effectiveDiscount);
function handleDiscountChange(rawValue: number) {
const clamped = Math.min(subtotal, Math.max(0, rawValue));
if (clamped <= maxUnauthDiscount || user?.role === "admin" || discountAuthorizedBy !== null) {
setDiscount(clamped);
if (clamped <= maxUnauthDiscount && discountAuthorizedBy !== null) {
setDiscountAuthorizedBy(null);
}
} else {
setPendingDiscount(clamped);
setAuthPassword("");
setAuthError(null);
setAuthDialogOpen(true);
}
}
async function handleAuthorizeDiscount(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (!authAdminId || pendingDiscount === null) return;
setAuthorizing(true);
setAuthError(null);
try {
const authorizedUser = await login(authAdminId, authPassword);
if (authorizedUser.role !== "admin") {
setAuthError("El usuario seleccionado no tiene permisos de administrador.");
return;
}
setDiscount(pendingDiscount);
setDiscountAuthorizedBy(authorizedUser.id);
setAuthDialogOpen(false);
setPendingDiscount(null);
setAuthPassword("");
} catch {
setAuthError("Contraseña incorrecta de administrador.");
} finally {
setAuthorizing(false);
}
}
async function handleCheckout() {
if (!session || !user || cart.length === 0) return;
@@ -218,7 +278,7 @@ export default function PosSalePage() {
try {
const cartRecord = await apiClient.post<{ id: number }>("/shop-carts", {
totalPrice: total.toFixed(2),
discount: discount.toFixed(2),
discount: effectiveDiscount.toFixed(2),
status: "open",
userId: user.id,
customerId: customerId ? Number(customerId) : null,
@@ -265,7 +325,7 @@ export default function PosSalePage() {
cartId: cartRecord.id,
lines: cart,
subtotal,
discount,
discount: effectiveDiscount,
total,
paymentMethod,
customerName: customerId ? customers.find((c) => String(c.id) === customerId)?.name ?? null : null,
@@ -449,17 +509,28 @@ export default function PosSalePage() {
</div>
<div>
<label htmlFor="pos-discount" className="mb-1.5 block text-sm font-medium text-foreground">
Descuento
</label>
<div className="mb-1.5 flex items-center justify-between">
<label htmlFor="pos-discount" className="text-sm font-medium text-foreground">
Descuento
</label>
<span className="text-xs text-muted">
Máx. sin autorizar: {money(maxUnauthDiscount)} (15%)
</span>
</div>
<TextInput
id="pos-discount"
type="number"
min={0}
max={subtotal}
step="0.01"
value={discount}
onChange={(e) => setDiscount(Math.max(0, Number(e.target.value)))}
value={effectiveDiscount}
onChange={(e) => handleDiscountChange(Number(e.target.value))}
/>
{effectiveDiscount > maxUnauthDiscount && (
<p className="mt-1 text-xs text-warning">
Descuento superior al 15% (autorizado por administrador)
</p>
)}
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3 text-sm">
@@ -469,7 +540,7 @@ export default function PosSalePage() {
</div>
<div className="flex justify-between text-muted">
<span>Descuento</span>
<span className="tabular-nums">-{money(discount)}</span>
<span className="tabular-nums">-{money(effectiveDiscount)}</span>
</div>
<div className="flex justify-between text-base font-semibold text-foreground">
<span>Total</span>
@@ -566,6 +637,75 @@ export default function PosSalePage() {
</div>
)}
</Dialog>
<Dialog
open={authDialogOpen}
onClose={() => {
setAuthDialogOpen(false);
setPendingDiscount(null);
}}
title="Autorización de descuento"
widthClassName="max-w-sm"
>
<form onSubmit={handleAuthorizeDiscount} className="flex flex-col gap-4" noValidate>
<p className="text-sm text-muted">
El descuento solicitado ({money(pendingDiscount ?? 0)}) supera el 15% permitido sin autorización ({money(maxUnauthDiscount)}). Se requiere la clave de un administrador.
</p>
<Field label="Administrador" htmlFor="auth-admin" required>
<SelectInput
id="auth-admin"
value={authAdminId ?? ""}
onChange={(e) => setAuthAdminId(Number(e.target.value))}
>
{adminUsers.map((a) => (
<option key={a.id} value={a.id}>
{a.name} ({a.email})
</option>
))}
</SelectInput>
</Field>
<Field label="Contraseña de administrador" htmlFor="auth-password" required>
<TextInput
id="auth-password"
type="password"
required
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder="••••••••"
autoFocus
/>
</Field>
{authError && (
<p role="alert" className="text-sm text-danger">
{authError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => {
setAuthDialogOpen(false);
setPendingDiscount(null);
}}
disabled={authorizing}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={authorizing || !authPassword}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover disabled:opacity-50"
>
{authorizing ? "Verificando…" : "Autorizar descuento"}
</button>
</div>
</form>
</Dialog>
</div>
);
}