feat: agregar endpoints atómicos, auth de descuentos y limpiar deuda técnica
This commit is contained in:
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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user