This commit is contained in:
Emmanuel Rojas committed 2026-09-30 14:45:10 -05:00
1 parent 90125aede9
commit 2d6c29a1f3
404 files changed
+126983 -85

No files matched your search

+2
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@heroui/react": "^3.2.6",
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.104.0",
"framer-motion": "^13.4.0",
"next": "16.3.5",
"react": "19.2.8",
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Dialog } from "./dialog";
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => Promise<void> | void;
title: string;
description: string;
confirmLabel?: string;
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = "Eliminar",
}: ConfirmDialogProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConfirm() {
setSubmitting(true);
setError(null);
try {
await onConfirm();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo completar la acción.");
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onClose={onClose} title={title} widthClassName="max-w-sm">
<p className="text-sm text-muted">{description}</p>
{error && (
<p role="alert" className="mt-3 text-sm text-danger">
{error}
</p>
)}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
onClick={handleConfirm}
disabled={submitting}
className="rounded-lg bg-danger px-3.5 py-2 text-sm font-medium text-danger-foreground transition hover:bg-danger-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
{submitting ? "Eliminando…" : confirmLabel}
</button>
</div>
</Dialog>
);
}
@@ -0,0 +1,27 @@
"use client";
import { Modal } from "@heroui/react";
interface DialogProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
widthClassName?: string;
}
export function Dialog({ open, onClose, title, children, widthClassName = "max-w-md" }: DialogProps) {
return (
<Modal.Backdrop isOpen={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Modal.Container scroll="inside">
<Modal.Dialog className={widthClassName}>
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>{title}</Modal.Heading>
</Modal.Header>
<Modal.Body>{children}</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
);
}
@@ -0,0 +1,41 @@
const fieldClass =
"w-full rounded-field border border-border bg-background px-3 py-2 text-sm text-foreground placeholder-muted outline-none transition focus:border-focus disabled:opacity-50";
export function TextInput({
className,
ref,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} className={`${fieldClass} ${className ?? ""}`} />;
}
export function TextareaInput({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea {...props} className={`${fieldClass} resize-none ${className ?? ""}`} />;
}
export function SelectInput({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return <select {...props} className={`${fieldClass} ${className ?? ""}`} />;
}
interface FieldProps {
label: string;
htmlFor: string;
required?: boolean;
children: React.ReactNode;
}
export function Field({ label, htmlFor, required, children }: FieldProps) {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={htmlFor} className="text-sm font-medium text-foreground">
{label}
{required && (
<span className="ml-0.5 text-danger" aria-hidden="true">
*
</span>
)}
</label>
{children}
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import { Icon } from "@iconify/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { iconCloseSquare } from "../icons";
interface Option {
id: number;
label: string;
}
interface MultiSelectProps {
options: Option[];
selected: number[];
onChange: (ids: number[]) => void;
placeholder?: string;
emptyMessage?: string;
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = "Buscar…",
emptyMessage = "Sin resultados.",
}: MultiSelectProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const selectedOptions = useMemo(
() => selected.map((id) => options.find((o) => o.id === id)).filter((o): o is Option => !!o),
[selected, options],
);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
return options.filter((o) => !q || o.label.toLowerCase().includes(q)).slice(0, 50);
}, [options, query]);
function toggle(id: number) {
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
}
function remove(id: number) {
onChange(selected.filter((s) => s !== id));
}
return (
<div ref={rootRef} className="relative">
<div
onClick={() => setOpen(true)}
className={`flex max-h-24 flex-wrap items-center gap-1.5 overflow-y-auto rounded-field border border-border bg-background p-1.5 transition focus-within:border-focus ${open ? "border-focus" : ""}`}
>
{selectedOptions.map((o) => (
<span
key={o.id}
className="inline-flex items-center gap-1 rounded-full bg-accent-soft py-1 pl-2.5 pr-1 text-xs font-medium text-accent-soft-foreground"
>
{o.label}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
remove(o.id);
}}
aria-label={`Quitar ${o.label}`}
className="flex size-4 items-center justify-center rounded-full transition hover:bg-accent-soft-hover"
>
<Icon icon={iconCloseSquare} width={11} height={11} aria-hidden="true" />
</button>
</span>
))}
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setOpen(true)}
placeholder={selectedOptions.length === 0 ? placeholder : ""}
role="combobox"
aria-expanded={open}
aria-controls="multi-select-listbox"
autoComplete="off"
className="min-w-24 flex-1 bg-transparent px-1.5 py-1 text-sm text-foreground placeholder-muted outline-none"
/>
</div>
{open && (
<ul
id="multi-select-listbox"
role="listbox"
className="absolute left-0 top-full z-20 mt-1.5 max-h-40 w-full overflow-y-auto rounded-field border border-border bg-surface p-1.5 shadow-lg"
>
{results.length === 0 ? (
<li className="px-2.5 py-2 text-sm text-muted">{emptyMessage}</li>
) : (
results.map((o) => {
const isSelected = selected.includes(o.id);
return (
<li key={o.id} role="option" aria-selected={isSelected}>
<button
type="button"
onClick={() => toggle(o.id)}
className={`w-full rounded-lg px-2.5 py-2 text-left text-sm transition ${
isSelected
? "bg-accent-soft text-accent-soft-foreground"
: "text-foreground hover:bg-foreground/5"
}`}
>
{o.label}
</button>
</li>
);
})
)}
</ul>
)}
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { Icon } from "@iconify/react";
import { iconCalendar } from "../icons";
import { useOperatingDay } from "../hooks/use-operating-day";
function formatOperatingDay(date: Date) {
const formatted = new Intl.DateTimeFormat("es-AR", {
weekday: "long",
day: "numeric",
month: "long",
}).format(date);
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
}
/** Muestra el día de operación (fecha comercial vigente) del PDV, según la caja abierta en la base de datos. */
export function OperatingDayBadge() {
const { date } = useOperatingDay();
return (
<div className="hidden h-11 flex-col justify-center px-2 leading-tight lg:flex">
<span className="text-[10px] text-muted">Fecha de operación</span>
<span className="flex items-center gap-1.5 truncate text-xs text-foreground">
<Icon icon={iconCalendar} width={14} height={14} className="text-muted" aria-hidden="true" />
{formatOperatingDay(date)}
</span>
</div>
);
}
@@ -0,0 +1,17 @@
interface PageHeaderProps {
title: string;
description: string;
action?: React.ReactNode;
}
export function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground">{title}</h1>
<p className="text-sm text-muted">{description}</p>
</div>
{action}
</div>
);
}
@@ -0,0 +1,26 @@
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>
);
}
@@ -0,0 +1,10 @@
export function TableShell({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="w-full text-sm">{children}</table>
</div>
);
}
export const thClass = "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-muted";
export const tdClass = "px-4 py-3 text-foreground";
@@ -0,0 +1,83 @@
"use client";
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" };
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 [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
function select(mode: Mode) {
setTheme(mode);
setOpen(false);
}
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={`Tema: ${LABELS[current]}. Cambiar tema`}
title={`Tema: ${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" />
</button>
{open && (
<div
role="menu"
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) => (
<button
key={mode}
type="button"
role="menuitemradio"
aria-checked={current === mode}
onClick={() => select(mode)}
className={`flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm transition ${
current === mode
? "bg-accent-soft text-accent-soft-foreground"
: "text-foreground hover:bg-foreground/5"
}`}
>
<Icon icon={ICONS[mode]} width={16} height={16} aria-hidden="true" />
{LABELS[mode]}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,24 @@
import { operatingDaysResource } from "../services/entities";
import type { OperatingDay } from "../types/entities";
function startOfDay(date: Date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
/**
* Día de operación vigente, controlado manualmente por el administrador y guardado
* en la base de datos (tabla operating_day). No depende del reloj del navegador:
* solo cambia cuando el administrador cierra el día actual.
*/
export function useOperatingDay() {
const { data: days, isLoading } = operatingDaysResource.useList();
const current: OperatingDay | null | undefined = days === undefined ? undefined : (days.find((d) => d.status === "open") ?? null);
const date = current ? new Date(current.openedAt) : new Date();
const start = current ? new Date(current.openedAt) : startOfDay(new Date());
return { current, date, start, isLoading };
}
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { AuthUser } from "../types/auth-user";
function readSessionUser(): AuthUser | null {
if (typeof window === "undefined") return null;
const raw = sessionStorage.getItem("pdv:user");
return raw ? (JSON.parse(raw) as AuthUser) : null;
}
export function useSessionUser() {
const router = useRouter();
const [user, setUser] = useState<AuthUser | null>(readSessionUser);
useEffect(() => {
if (!user) {
router.replace("/login");
}
}, [user, router]);
function logout() {
sessionStorage.removeItem("pdv:user");
setUser(null);
router.replace("/login");
}
return { user, logout };
}
@@ -0,0 +1,62 @@
"use client";
import { createContext, useCallback, useContext, useLayoutEffect, useState } from "react";
export type ThemeMode = "light" | "dark" | "system";
const STORAGE_KEY = "theme";
interface ThemeContextValue {
theme: ThemeMode | undefined;
setTheme: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function readStoredTheme(): ThemeMode | undefined {
if (typeof window === "undefined") return undefined;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") return stored;
} catch {
// localStorage unavailable
}
return "system";
}
function applyTheme(mode: ThemeMode) {
const resolved = mode === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : mode;
document.documentElement.classList.toggle("dark", resolved === "dark");
document.documentElement.style.colorScheme = resolved;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode | undefined>(readStoredTheme);
useLayoutEffect(() => {
if (theme === undefined) return;
applyTheme(theme);
if (theme !== "system") return;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyTheme("system");
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [theme]);
const setTheme = useCallback((mode: ThemeMode) => {
setThemeState(mode);
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// localStorage unavailable
}
}, []);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme debe usarse dentro de ThemeProvider");
return ctx;
}
+215
View File
@@ -0,0 +1,215 @@
import type { IconifyIcon } from "@iconify/react";
/**
* Iconos Solar (set "Bold") extraídos de @iconify-json/solar como objetos offline.
* Evita llamadas de red y el bundling del set completo por unos pocos íconos.
*/
export const iconStorefrontFill: IconifyIcon = {
body: '<g fill="currentColor"><path d="M15.1557 12.2715C15.9738 13.0359 17.071 13.4999 18.268 13.5C19.0851 13.5 19.8452 13.2876 20.5004 12.9189V14C20.5004 17.771 20.4998 19.6565 19.3285 20.8281C18.3855 21.7711 16.9796 21.9553 14.5004 21.9912V18.5C14.5004 17.5655 14.5001 17.0981 14.2992 16.75C14.1676 16.5221 13.9782 16.3328 13.7504 16.2012C13.4024 16.0002 12.9347 16 12.0004 16C11.066 16 10.5984 16.0004 10.2504 16.2012C10.0223 16.3328 9.83223 16.522 9.70057 16.75C9.49968 17.0981 9.50038 17.5655 9.50038 18.5V21.9912C7.02081 21.9553 5.61436 21.7712 4.67128 20.8281C3.49994 19.6565 3.50038 17.771 3.50038 14V12.9189C4.15552 13.2875 4.91481 13.5 5.73182 13.5C6.92875 13.4999 8.02603 13.0358 8.84413 12.2715C9.67139 13.0316 10.7762 13.4999 11.9994 13.5C13.2228 13.5 14.3283 13.0317 15.1557 12.2715Z"/><path d="M16.5824 2C17.4901 2 17.9441 1.99979 18.3334 2.10645C19.1539 2.33137 19.8406 2.89483 20.2221 3.65527C20.403 4.0161 20.4923 4.46149 20.6703 5.35156L21.268 8.34082C21.6465 10.2338 20.1984 12 18.268 12C16.6965 11.9998 15.3804 10.8088 15.224 9.24512L15.1517 8.51758C15.3381 10.3821 13.8733 12 11.9994 12C10.1383 11.9998 8.68104 10.4033 8.84413 8.55469L8.77577 9.24512C8.6194 10.8088 7.30328 11.9998 5.73182 12C3.80137 12 2.35323 10.2338 2.73182 8.34082L3.32948 5.35156C3.5075 4.46148 3.59681 4.0161 3.77772 3.65527C4.15919 2.89483 4.8459 2.33137 5.66639 2.10645C6.05569 1.99979 6.50972 2 7.41737 2H16.5824Z"/></g>',
width: 24,
height: 24,
};
export const iconEye: IconifyIcon = {
body: '<g fill="currentColor"><path d="M9.75 12C9.75 10.7574 10.7574 9.75 12 9.75C13.2426 9.75 14.25 10.7574 14.25 12C14.25 13.2426 13.2426 14.25 12 14.25C10.7574 14.25 9.75 13.2426 9.75 12Z"/><path fill-rule="evenodd" d="M2 12C2 13.6394 2.42496 14.1915 3.27489 15.2957C4.97196 17.5004 7.81811 20 12 20C16.1819 20 19.028 17.5004 20.7251 15.2957C21.575 14.1915 22 13.6394 22 12C22 10.3606 21.575 9.80853 20.7251 8.70433C19.028 6.49956 16.1819 4 12 4C7.81811 4 4.97196 6.49956 3.27489 8.70433C2.42496 9.80853 2 10.3606 2 12ZM12 8.25C9.92893 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92893 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25Z" clip-rule="evenodd"/></g>',
width: 24,
height: 24,
};
export const iconEyeSlash: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M1.60603 6.08062C2.11366 5.86307 2.70154 6.09822 2.9191 6.60585L1.99995 6.99977C2.9191 6.60585 2.91924 6.60618 2.9191 6.60585L2.91858 6.60465C2.9183 6.604 2.91851 6.60447 2.91858 6.60465L2.9225 6.61351C2.92651 6.62253 2.93339 6.63785 2.94319 6.65905C2.96278 6.70147 2.99397 6.76735 3.03696 6.85334C3.12302 7.02546 3.25594 7.27722 3.43737 7.58203C3.80137 8.19355 4.35439 9.00801 5.10775 9.81932C5.28532 10.0105 5.47324 10.2009 5.67173 10.3878C5.68003 10.3954 5.68823 10.4031 5.69633 10.4109C7.18102 11.8012 9.25227 12.9998 12 12.9998C13.2089 12.9998 14.2783 12.769 15.2209 12.398C16.4469 11.9154 17.4745 11.1889 18.3156 10.3995C19.2652 9.50815 19.9627 8.54981 20.4232 7.81076C20.6526 7.44268 20.8207 7.13295 20.9299 6.91886C20.9844 6.81192 21.0241 6.72919 21.0491 6.67538C21.0617 6.64848 21.0706 6.62884 21.0758 6.61704L21.0808 6.60585C21.2985 6.0985 21.8864 5.86312 22.3939 6.08062C22.9015 6.29818 23.1367 6.88606 22.9191 7.39369L22 6.99977C22.9191 7.39369 22.9192 7.39346 22.9191 7.39369L22.9169 7.39871L22.9134 7.40693L22.9019 7.43278C22.8924 7.4541 22.879 7.48354 22.8618 7.52048C22.8274 7.59434 22.7774 7.69831 22.7115 7.8275C22.5799 8.08566 22.384 8.44584 22.1206 8.86844C21.718 9.5146 21.152 10.316 20.4096 11.1241L21.2071 11.9215C21.5976 12.312 21.5976 12.9452 21.2071 13.3357C20.8165 13.7262 20.1834 13.7262 19.7928 13.3357L18.9527 12.4955C18.3884 12.9513 17.757 13.3811 17.0558 13.752L17.8381 14.9544C18.1393 15.4173 18.0083 16.0367 17.5453 16.338C17.0824 16.6392 16.463 16.5081 16.1618 16.0452L15.1763 14.5306C14.4973 14.7388 13.772 14.8863 13 14.9554V16.4998C13 17.0521 12.5522 17.4998 12 17.4998C11.4477 17.4998 11 17.0521 11 16.4998V14.9556C10.2253 14.8864 9.50014 14.7386 8.82334 14.531L7.83814 16.0452C7.53693 16.5081 6.91748 16.6392 6.45457 16.338C5.99165 16.0367 5.86056 15.4173 6.16177 14.9544L6.94417 13.7519C6.24405 13.3814 5.61245 12.9515 5.04746 12.4953L4.20706 13.3357C3.81654 13.7262 3.18337 13.7262 2.79285 13.3357C2.40232 12.9452 2.40232 12.312 2.79285 11.9215L3.59029 11.1241C2.74529 10.2043 2.12772 9.292 1.71879 8.605C1.5096 8.25356 1.35345 7.95845 1.2481 7.74776C1.19539 7.64234 1.15529 7.55783 1.12752 7.49771C1.11363 7.46765 1.10282 7.44366 1.09505 7.42618L1.08566 7.4049L1.08267 7.39801L1.0816 7.39553L1.08117 7.39453C1.08098 7.39409 1.08081 7.39369 1.99995 6.99977L1.08117 7.39453C0.863613 6.8869 1.0984 6.29818 1.60603 6.08062Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconIdentificationBadge: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M10 4H14C17.7712 4 19.6569 4 20.8284 5.17157C22 6.34315 22 8.22876 22 12C22 15.7712 22 17.6569 20.8284 18.8284C19.6569 20 17.7712 20 14 20H10C6.22876 20 4.34315 20 3.17157 18.8284C2 17.6569 2 15.7712 2 12C2 8.22876 2 6.34315 3.17157 5.17157C4.34315 4 6.22876 4 10 4ZM13.25 9C13.25 8.58579 13.5858 8.25 14 8.25H19C19.4142 8.25 19.75 8.58579 19.75 9C19.75 9.41421 19.4142 9.75 19 9.75H14C13.5858 9.75 13.25 9.41421 13.25 9ZM14.25 12C14.25 11.5858 14.5858 11.25 15 11.25H19C19.4142 11.25 19.75 11.5858 19.75 12C19.75 12.4142 19.4142 12.75 19 12.75H15C14.5858 12.75 14.25 12.4142 14.25 12ZM15.25 15C15.25 14.5858 15.5858 14.25 16 14.25H19C19.4142 14.25 19.75 14.5858 19.75 15C19.75 15.4142 19.4142 15.75 19 15.75H16C15.5858 15.75 15.25 15.4142 15.25 15ZM11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM9 17C13 17 13 16.1046 13 15C13 13.8954 11.2091 13 9 13C6.79086 13 5 13.8954 5 15C5 16.1046 5 17 9 17Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconArrowLeft: IconifyIcon = {
body: '<path fill="currentColor" d="M20 11.25C20.4142 11.25 20.75 11.5858 20.75 12C20.75 12.4142 20.4142 12.75 20 12.75H10.75L10.75 18C10.75 18.3034 10.5673 18.5768 10.287 18.6929C10.0068 18.809 9.68417 18.7449 9.46967 18.5304L3.46967 12.5304C3.32902 12.3897 3.25 12.1989 3.25 12C3.25 11.8011 3.32902 11.6103 3.46967 11.4697L9.46967 5.46969C9.68417 5.25519 10.0068 5.19103 10.287 5.30711C10.5673 5.4232 10.75 5.69668 10.75 6.00002L10.75 11.25H20Z"/>',
width: 24,
height: 24,
};
export const iconDashboard: IconifyIcon = {
body: '<g fill="currentColor"><path fill-rule="evenodd" d="M2 6.63415C2 4.07478 4.07478 2 6.63415 2C9.19351 2 11.2683 4.07478 11.2683 6.63415C11.2683 9.19351 9.19351 11.2683 6.63415 11.2683C4.07478 11.2683 2 9.19351 2 6.63415Z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M12.7317 17.3659C12.7317 14.8065 14.8065 12.7317 17.3659 12.7317C19.9252 12.7317 22 14.8065 22 17.3659C22 19.9252 19.9252 22 17.3659 22C14.8065 22 12.7317 19.9252 12.7317 17.3659Z" clip-rule="evenodd"/><path d="M2 17.5C2 15.3787 2 14.318 2.65901 13.659C3.31802 13 4.37868 13 6.5 13C8.62132 13 9.68198 13 10.341 13.659C11 14.318 11 15.3787 11 17.5C11 19.6213 11 20.682 10.341 21.341C9.68198 22 8.62132 22 6.5 22C4.37868 22 3.31802 22 2.65901 21.341C2 20.682 2 19.6213 2 17.5Z"/><path d="M13 6.5C13 4.37868 13 3.31802 13.659 2.65901C14.318 2 15.3787 2 17.5 2C19.6213 2 20.682 2 21.341 2.65901C22 3.31802 22 4.37868 22 6.5C22 8.62132 22 9.68198 21.341 10.341C20.682 11 19.6213 11 17.5 11C15.3787 11 14.318 11 13.659 10.341C13 9.68198 13 8.62132 13 6.5Z"/></g>',
width: 24,
height: 24,
};
export const iconBox: IconifyIcon = {
body: '<g fill="currentColor"><path d="M17.5777 4.43152L15.5777 3.38197C13.8221 2.46066 12.9443 2 12 2C11.0557 2 10.1779 2.46066 8.42229 3.38197L8.10057 3.5508L17.0236 8.64967L21.0403 6.64132C20.3941 5.90949 19.3515 5.36234 17.5777 4.43152Z"/><path d="M21.7484 7.96434L17.75 9.96353V13C17.75 13.4142 17.4142 13.75 17 13.75C16.5858 13.75 16.25 13.4142 16.25 13V10.7135L12.75 12.4635V21.904C13.4679 21.7252 14.2848 21.2965 15.5777 20.618L17.5777 19.5685C19.7294 18.4393 20.8052 17.8748 21.4026 16.8603C22 15.8458 22 14.5833 22 12.0585V11.9415C22 10.0489 22 8.86557 21.7484 7.96434Z"/><path d="M11.25 21.904V12.4635L2.25164 7.96434C2 8.86557 2 10.0489 2 11.9415V12.0585C2 14.5833 2 15.8458 2.5974 16.8603C3.19479 17.8748 4.27062 18.4393 6.42228 19.5685L8.42229 20.618C9.71524 21.2965 10.5321 21.7252 11.25 21.904Z"/><path d="M2.95969 6.64132L12 11.1615L15.4112 9.4559L6.52456 4.37785L6.42229 4.43152C4.64855 5.36234 3.6059 5.90949 2.95969 6.64132Z"/></g>',
width: 24,
height: 24,
};
export const iconTag: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M2.12264 12.816C2.41018 13.8186 3.18295 14.5914 4.72848 16.1369L6.55812 17.9665C9.24711 20.6555 10.5916 22 12.2623 22C13.933 22 15.2775 20.6555 17.9665 17.9665C20.6555 15.2775 22 13.933 22 12.2623C22 10.5916 20.6555 9.24711 17.9665 6.55812L16.1369 4.72848C14.5914 3.18295 13.8186 2.41018 12.816 2.12264C11.8134 1.83509 10.7485 2.08083 8.61875 2.57231L7.39057 2.85574C5.5988 3.26922 4.70292 3.47597 4.08944 4.08944C3.47597 4.70292 3.26922 5.59881 2.85574 7.39057L2.57231 8.61875C2.08083 10.7485 1.83509 11.8134 2.12264 12.816ZM10.1234 7.27098C10.911 8.05856 10.911 9.33549 10.1234 10.1231C9.33581 10.9107 8.05888 10.9107 7.27129 10.1231C6.48371 9.33549 6.48371 8.05856 7.27129 7.27098C8.05888 6.48339 9.33581 6.48339 10.1234 7.27098ZM19.0511 12.0511L12.0721 19.0303C11.7792 19.3232 11.3043 19.3232 11.0114 19.0303C10.7185 18.7375 10.7185 18.2626 11.0114 17.9697L17.9904 10.9904C18.2833 10.6975 18.7582 10.6975 19.0511 10.9904C19.344 11.2833 19.344 11.7582 19.0511 12.0511Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconArchive: IconifyIcon = {
body: '<g fill="currentColor"><path fill-rule="evenodd" d="M20.5 13C20.5 16.7712 20.4997 18.6566 19.3281 19.8281C18.1565 20.9996 16.2712 21 12.5 21H11.5C7.72882 21 5.84345 20.9996 4.67188 19.8281C3.50031 18.6566 3.5 16.7712 3.5 13V8.49805C3.64472 8.50004 3.78966 8.50006 3.93066 8.5H20.0693C20.2103 8.50006 20.3553 8.50004 20.5 8.49805V13ZM10.5 10.5C10.0341 10.5 9.80096 10.5001 9.61719 10.5762C9.37225 10.6777 9.17765 10.8722 9.07617 11.1172C9.00005 11.301 9 11.5341 9 12C9 12.4659 9.00006 12.6991 9.07617 12.8828C9.17768 13.1277 9.37228 13.3224 9.61719 13.4238C9.80096 13.4999 10.0341 13.5 10.5 13.5H13.5C13.9659 13.5 14.199 13.4999 14.3828 13.4238C14.6277 13.3224 14.8223 13.1277 14.9238 12.8828C14.9999 12.6991 15 12.4659 15 12C15 11.5341 14.9999 11.301 14.9238 11.1172C14.8224 10.8722 14.6278 10.6777 14.3828 10.5762C14.199 10.5001 13.9659 10.5 13.5 10.5H10.5Z" clip-rule="evenodd"/><path d="M20 3C20.9428 3 21.4141 3.00008 21.707 3.29297C21.9999 3.58586 22 4.05719 22 5C22 5.94281 21.9999 6.41414 21.707 6.70703C21.4141 6.99992 20.9428 7 20 7H4C3.05719 7 2.58586 6.99992 2.29297 6.70703C2.00008 6.41414 2 5.94281 2 5C2 4.05719 2.00008 3.58586 2.29297 3.29297C2.58586 3.00008 3.05719 3 4 3H20Z"/></g>',
width: 24,
height: 24,
};
export const iconDelivery: IconifyIcon = {
body: '<g fill="currentColor"><path d="M9.56443 8.73049L10.0789 10.5926C10.5639 12.3481 10.8064 13.2259 11.5194 13.6252C12.2323 14.0244 13.1374 13.7892 14.9474 13.3188L16.8673 12.8199C18.6774 12.3495 19.5824 12.1143 19.9941 11.4227C20.4057 10.7312 20.1632 9.85344 19.6782 8.09788L19.1638 6.2358C18.6788 4.48023 18.4363 3.60244 17.7233 3.20319C17.0103 2.80394 16.1052 3.03915 14.2952 3.50955L12.3753 4.00849C10.5652 4.47889 9.66021 4.71409 9.24856 5.40562C8.83692 6.09714 9.07943 6.97493 9.56443 8.73049Z"/><path d="M2.27749 5.24694C2.38823 4.84781 2.80157 4.61402 3.2007 4.72476L4.9044 5.19744C5.82129 5.45183 6.5469 6.15866 6.80003 7.07489L8.95106 14.8609L9.10935 15.4075C9.74249 15.6438 10.2863 16.0866 10.6314 16.6747L10.9414 16.579L19.8115 14.2739C20.2124 14.1697 20.6219 14.4102 20.7261 14.8111C20.8303 15.212 20.5897 15.6214 20.1888 15.7256L11.3515 18.0223L11.0228 18.1238C11.0161 19.3947 10.1392 20.5555 8.81236 20.9003C7.22189 21.3136 5.58709 20.3982 5.16092 18.8556C4.73476 17.313 5.67861 15.7274 7.26908 15.3141C7.3479 15.2936 7.42682 15.2764 7.5057 15.2623L5.35419 7.47433C5.24592 7.08242 4.92897 6.76092 4.50338 6.64284L2.79968 6.17016C2.40054 6.05942 2.16675 5.64608 2.27749 5.24694Z"/></g>',
width: 24,
height: 24,
};
export const iconUsersGroup: IconifyIcon = {
body: '<g fill="currentColor"><path d="M9.00098 13.001C12.867 13.001 16.001 14.7918 16.001 17.001C16.001 19.2101 12.867 21.001 9.00098 21.001C5.13498 21.001 2.00098 19.2101 2.00098 17.001C2.00098 14.7918 5.13498 13.001 9.00098 13.001Z"/><path d="M16.4766 14.001C18.9615 14.0011 20.9996 15.3434 21 17C21 18.6569 18.9638 20 16.4785 20C17.2107 19.1997 17.7148 18.1955 17.7148 17.002C17.7148 15.8071 17.2099 14.8017 16.4766 14.001Z"/><path d="M9.00098 2C11.2101 2 13.001 3.79086 13.001 6C13.001 8.20914 11.2101 10 9.00098 10C6.79184 10 5.00098 8.20914 5.00098 6C5.00098 3.79086 6.79184 2 9.00098 2Z"/><path d="M15 3.00098C16.6567 3.00118 18 4.34425 18 6.00098C17.9999 7.6576 16.6566 9.00078 15 9.00098C14.6388 9.00098 14.2924 8.93633 13.9717 8.81934C14.4445 7.98748 14.7148 7.02523 14.7148 6C14.7148 4.97534 14.4449 4.0132 13.9727 3.18164C14.2931 3.06489 14.6392 3.00098 15 3.00098Z"/></g>',
width: 24,
height: 24,
};
export const iconLogout: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M3.5 9.56757V14.4324C3.5 16.7258 3.5 17.8724 4.22161 18.5849C4.87719 19.2321 5.89578 19.2913 7.81846 19.2968C7.71686 18.6224 7.69563 17.8168 7.69029 16.8689C7.68802 16.4659 8.01709 16.1374 8.42529 16.1351C8.83348 16.1329 9.16624 16.4578 9.16851 16.8608C9.17451 17.9247 9.20249 18.6789 9.30898 19.2512C9.41158 19.8027 9.57634 20.1219 9.81626 20.3588C10.089 20.6281 10.4719 20.8037 11.1951 20.8996C11.9395 20.9985 12.9261 21 14.3407 21H15.3262C16.7407 21 17.7273 20.9985 18.4717 20.8996C19.1949 20.8037 19.5778 20.6281 19.8505 20.3588C20.1233 20.0895 20.3011 19.7114 20.3983 18.9975C20.4984 18.2626 20.5 17.2885 20.5 15.8919V8.10811C20.5 6.71149 20.4984 5.73743 20.3983 5.0025C20.3011 4.28855 20.1233 3.91048 19.8505 3.6412C19.5778 3.37192 19.1949 3.19635 18.4717 3.10036C17.7273 3.00155 16.7407 3 15.3262 3H14.3407C12.9261 3 11.9395 3.00155 11.1951 3.10036C10.4719 3.19635 10.089 3.37192 9.81626 3.6412C9.57634 3.87807 9.41158 4.19728 9.30898 4.74877C9.20249 5.32112 9.17451 6.07525 9.16851 7.1392C9.16624 7.54221 8.83348 7.8671 8.42529 7.86485C8.01709 7.86261 7.68802 7.53409 7.69029 7.13107C7.69563 6.18322 7.71686 5.37758 7.81846 4.70325C5.89578 4.70867 4.87719 4.76789 4.22161 5.41515C3.5 6.12759 3.5 7.27425 3.5 9.56757ZM5.93385 12.516C5.6452 12.231 5.6452 11.769 5.93385 11.484L7.90484 9.53806C8.19348 9.25308 8.66147 9.25308 8.95011 9.53806C9.23876 9.82304 9.23876 10.2851 8.95011 10.5701L8.24088 11.2703L15.3259 11.2703C15.7341 11.2703 16.0651 11.597 16.0651 12C16.0651 12.403 15.7341 12.7297 15.3259 12.7297L8.24088 12.7297L8.95011 13.4299C9.23876 13.7149 9.23876 14.177 8.95011 14.4619C8.66147 14.7469 8.19348 14.7469 7.90484 14.4619L5.93385 12.516Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconCart: IconifyIcon = {
body: '<g fill="currentColor"><path d="M2.23737 2.28845C1.84442 2.15746 1.41968 2.36983 1.28869 2.76279C1.15771 3.15575 1.37008 3.58049 1.76303 3.71147L2.02794 3.79978C2.70435 4.02524 3.15155 4.17551 3.481 4.32877C3.79296 4.47389 3.92784 4.59069 4.01426 4.71059C4.10068 4.83049 4.16883 4.99538 4.20785 5.33722C4.24907 5.69823 4.2502 6.17 4.2502 6.883L4.2502 9.55484C4.25018 10.9224 4.25017 12.0247 4.36673 12.8917C4.48774 13.7918 4.74664 14.5497 5.34855 15.1516C5.95047 15.7535 6.70834 16.0124 7.60845 16.1334C8.47542 16.25 9.57773 16.25 10.9453 16.25H18.0002C18.4144 16.25 18.7502 15.9142 18.7502 15.5C18.7502 15.0857 18.4144 14.75 18.0002 14.75H11.0002C9.56479 14.75 8.56367 14.7484 7.80832 14.6468C7.07455 14.5482 6.68598 14.3677 6.40921 14.091C6.17403 13.8558 6.00839 13.5398 5.9034 13H16.0222C16.9817 13 17.4614 13 17.8371 12.7522C18.2128 12.5045 18.4017 12.0636 18.7797 11.1817L19.2082 10.1817C20.0177 8.2929 20.4225 7.34849 19.9779 6.67422C19.5333 5.99996 18.5058 5.99996 16.4508 5.99996H5.74526C5.73936 5.69227 5.72644 5.41467 5.69817 5.16708C5.64282 4.68226 5.52222 4.2374 5.23112 3.83352C4.94002 3.42965 4.55613 3.17456 4.1137 2.96873C3.69746 2.7751 3.16814 2.59868 2.54176 2.38991L2.23737 2.28845Z"/><path d="M7.5 18C8.32843 18 9 18.6716 9 19.5C9 20.3284 8.32843 21 7.5 21C6.67157 21 6 20.3284 6 19.5C6 18.6716 6.67157 18 7.5 18Z"/><path d="M16.5 18.0001C17.3284 18.0001 18 18.6716 18 19.5001C18 20.3285 17.3284 21.0001 16.5 21.0001C15.6716 21.0001 15 20.3285 15 19.5001C15 18.6716 15.6716 18.0001 16.5 18.0001Z"/></g>',
width: 24,
height: 24,
};
export const iconClockCircle: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 7.25C11.5858 7.25 11.25 7.58579 11.25 8V12C11.25 12.1989 11.3291 12.3896 11.4697 12.5303L13.9697 15.0303C14.2626 15.3232 14.7374 15.3232 15.0303 15.0303C15.3232 14.7374 15.3232 14.2626 15.0303 13.9697L12.75 11.6895V8C12.75 7.58579 12.4142 7.25 12 7.25Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconHamburgerMenu: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355C22 19.0711 22 16.714 22 12C22 7.28595 22 4.92893 20.5355 3.46447C19.0711 2 16.714 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355ZM18.75 16C18.75 16.4142 18.4142 16.75 18 16.75H6C5.58579 16.75 5.25 16.4142 5.25 16C5.25 15.5858 5.58579 15.25 6 15.25H18C18.4142 15.25 18.75 15.5858 18.75 16ZM18 12.75C18.4142 12.75 18.75 12.4142 18.75 12C18.75 11.5858 18.4142 11.25 18 11.25H6C5.58579 11.25 5.25 11.5858 5.25 12C5.25 12.4142 5.58579 12.75 6 12.75H18ZM18.75 8C18.75 8.41421 18.4142 8.75 18 8.75H6C5.58579 8.75 5.25 8.41421 5.25 8C5.25 7.58579 5.58579 7.25 6 7.25H18C18.4142 7.25 18.75 7.58579 18.75 8Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconCalendar: IconifyIcon = {
body: '<g fill="currentColor"><path d="M7.75 2.5C7.75 2.08579 7.41421 1.75 7 1.75C6.58579 1.75 6.25 2.08579 6.25 2.5V4.07926C4.81067 4.19451 3.86577 4.47737 3.17157 5.17157C2.47737 5.86577 2.19451 6.81067 2.07926 8.25H21.9207C21.8055 6.81067 21.5226 5.86577 20.8284 5.17157C20.1342 4.47737 19.1893 4.19451 17.75 4.07926V2.5C17.75 2.08579 17.4142 1.75 17 1.75C16.5858 1.75 16.25 2.08579 16.25 2.5V4.0129C15.5847 4 14.839 4 14 4H10C9.16097 4 8.41527 4 7.75 4.0129V2.5Z"/><path fill-rule="evenodd" d="M2 12C2 11.161 2 10.4153 2.0129 9.75H21.9871C22 10.4153 22 11.161 22 12V14C22 17.7712 22 19.6569 20.8284 20.8284C19.6569 22 17.7712 22 14 22H10C6.22876 22 4.34315 22 3.17157 20.8284C2 19.6569 2 17.7712 2 14V12ZM17 14C17.5523 14 18 13.5523 18 13C18 12.4477 17.5523 12 17 12C16.4477 12 16 12.4477 16 13C16 13.5523 16.4477 14 17 14ZM17 18C17.5523 18 18 17.5523 18 17C18 16.4477 17.5523 16 17 16C16.4477 16 16 16.4477 16 17C16 17.5523 16.4477 18 17 18ZM13 13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13C11 12.4477 11.4477 12 12 12C12.5523 12 13 12.4477 13 13ZM13 17C13 17.5523 12.5523 18 12 18C11.4477 18 11 17.5523 11 17C11 16.4477 11.4477 16 12 16C12.5523 16 13 16.4477 13 17ZM7 14C7.55228 14 8 13.5523 8 13C8 12.4477 7.55228 12 7 12C6.44772 12 6 12.4477 6 13C6 13.5523 6.44772 14 7 14ZM7 18C7.55228 18 8 17.5523 8 17C8 16.4477 7.55228 16 7 16C6.44772 16 6 16.4477 6 17C6 17.5523 6.44772 18 7 18Z" clip-rule="evenodd"/></g>',
width: 24,
height: 24,
};
export const iconChevronDown: IconifyIcon = {
body: '<path fill="currentColor" d="M12.3704 15.8351L18.8001 9.20467C19.2013 8.79094 18.9581 8 18.4297 8H5.5703C5.04189 8 4.79869 8.79094 5.1999 9.20467L11.6296 15.8351C11.8427 16.0549 12.1573 16.0549 12.3704 15.8351Z"/>',
width: 24,
height: 24,
};
export const iconCloseSquare: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM8.96965 8.96967C9.26254 8.67678 9.73742 8.67678 10.0303 8.96967L12 10.9394L13.9696 8.96969C14.2625 8.6768 14.7374 8.6768 15.0303 8.96969C15.3232 9.26258 15.3232 9.73746 15.0303 10.0303L13.0606 12L15.0303 13.9697C15.3232 14.2625 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2625 15.3232 13.9696 15.0303L12 13.0607L10.0303 15.0303C9.73744 15.3232 9.26256 15.3232 8.96967 15.0303C8.67678 14.7374 8.67678 14.2626 8.96967 13.9697L10.9393 12L8.96965 10.0303C8.67676 9.73744 8.67676 9.26256 8.96965 8.96967Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconSun: IconifyIcon = {
body: '<g fill="currentColor"><path d="M18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6C15.3137 6 18 8.68629 18 12Z"/><path fill-rule="evenodd" d="M12 1.25C12.4142 1.25 12.75 1.58579 12.75 2V3C12.75 3.41421 12.4142 3.75 12 3.75C11.5858 3.75 11.25 3.41421 11.25 3V2C11.25 1.58579 11.5858 1.25 12 1.25ZM4.39861 4.39861C4.6915 4.10572 5.16638 4.10572 5.45927 4.39861L5.85211 4.79145C6.145 5.08434 6.145 5.55921 5.85211 5.85211C5.55921 6.145 5.08434 6.145 4.79145 5.85211L4.39861 5.45927C4.10572 5.16638 4.10572 4.6915 4.39861 4.39861ZM19.6011 4.39887C19.894 4.69176 19.894 5.16664 19.6011 5.45953L19.2083 5.85237C18.9154 6.14526 18.4405 6.14526 18.1476 5.85237C17.8547 5.55947 17.8547 5.0846 18.1476 4.79171L18.5405 4.39887C18.8334 4.10598 19.3082 4.10598 19.6011 4.39887ZM1.25 12C1.25 11.5858 1.58579 11.25 2 11.25H3C3.41421 11.25 3.75 11.5858 3.75 12C3.75 12.4142 3.41421 12.75 3 12.75H2C1.58579 12.75 1.25 12.4142 1.25 12ZM20.25 12C20.25 11.5858 20.5858 11.25 21 11.25H22C22.4142 11.25 22.75 11.5858 22.75 12C22.75 12.4142 22.4142 12.75 22 12.75H21C20.5858 12.75 20.25 12.4142 20.25 12ZM18.1476 18.1476C18.4405 17.8547 18.9154 17.8547 19.2083 18.1476L19.6011 18.5405C19.894 18.8334 19.894 19.3082 19.6011 19.6011C19.3082 19.894 18.8334 19.894 18.5405 19.6011L18.1476 19.2083C17.8547 18.9154 17.8547 18.4405 18.1476 18.1476ZM5.85211 18.1479C6.145 18.4408 6.145 18.9157 5.85211 19.2086L5.45927 19.6014C5.16638 19.8943 4.6915 19.8943 4.39861 19.6014C4.10572 19.3085 4.10572 18.8336 4.39861 18.5407L4.79145 18.1479C5.08434 17.855 5.55921 17.855 5.85211 18.1479ZM12 20.25C12.4142 20.25 12.75 20.5858 12.75 21V22C12.75 22.4142 12.4142 22.75 12 22.75C11.5858 22.75 11.25 22.4142 11.25 22V21C11.25 20.5858 11.5858 20.25 12 20.25Z" clip-rule="evenodd"/></g>',
width: 24,
height: 24,
};
export const iconMoon: IconifyIcon = {
body: '<path fill="currentColor" d="M12 22C17.5228 22 22 17.5228 22 12C22 11.5373 21.3065 11.4608 21.0672 11.8568C19.9289 13.7406 17.8615 15 15.5 15C11.9101 15 9 12.0899 9 8.5C9 6.13845 10.2594 4.07105 12.1432 2.93276C12.5392 2.69347 12.4627 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/>',
width: 24,
height: 24,
};
export const iconMonitor: IconifyIcon = {
body: '<g fill="currentColor"><path d="M8 17C5.17157 17 3.75736 17 2.87868 16.1213C2.30938 15.552 2.10893 14.7579 2.03835 13.5H21.9616C21.8911 14.7579 21.6906 15.552 21.1213 16.1213C20.2426 17 18.8284 17 16 17H12.75V21H16C16.4142 21 16.75 21.3358 16.75 21.75C16.75 22.1642 16.4142 22.5 16 22.5H8C7.58579 22.5 7.25 22.1642 7.25 21.75C7.25 21.3358 7.58579 21 8 21H11.25V17H8Z"/><path d="M10 2H14C17.7712 2 19.6569 2 20.8284 3.17157C22 4.34315 22 6.22876 22 10V11C22 11.5516 22 12.0494 21.9935 12.5H2.00652C2 12.0494 2 11.5516 2 11V10C2 6.22876 2 4.34315 3.17157 3.17157C4.34315 2 6.22876 2 10 2Z"/></g>',
width: 24,
height: 24,
};
export const iconWalletMoney: IconifyIcon = {
body: '<g fill="currentColor"><path fill-rule="evenodd" d="M20.4105 9.86058C20.3559 9.8571 20.2964 9.85712 20.2348 9.85715L20.2194 9.85715H17.8015C15.8086 9.85715 14.1033 11.4382 14.1033 13.5C14.1033 15.5618 15.8086 17.1429 17.8015 17.1429H20.2194L20.2348 17.1429C20.2964 17.1429 20.3559 17.1429 20.4105 17.1394C21.22 17.0879 21.9359 16.4495 21.9961 15.5577C22.0001 15.4992 22 15.4362 22 15.3778L22 15.3619V11.6381L22 11.6222C22 11.5638 22.0001 11.5008 21.9961 11.4423C21.9359 10.5506 21.22 9.91209 20.4105 9.86058ZM17.5872 14.4714C18.1002 14.4714 18.5162 14.0365 18.5162 13.5C18.5162 12.9635 18.1002 12.5286 17.5872 12.5286C17.0741 12.5286 16.6581 12.9635 16.6581 13.5C16.6581 14.0365 17.0741 14.4714 17.5872 14.4714Z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M20.2341 18.6C20.3778 18.5963 20.4866 18.7304 20.4476 18.8699C20.2541 19.562 19.947 20.1518 19.4542 20.6485C18.7329 21.3755 17.8183 21.6981 16.6882 21.8512C15.5902 22 14.1872 22 12.4158 22H10.3794C8.60803 22 7.20501 22 6.10697 21.8512C4.97692 21.6981 4.06227 21.3755 3.34096 20.6485C2.61964 19.9215 2.29953 18.9997 2.1476 17.8608C1.99997 16.7541 1.99999 15.3401 2 13.5548V13.4452C1.99998 11.6599 1.99997 10.2459 2.1476 9.13924C2.29953 8.00031 2.61964 7.07848 3.34096 6.35149C4.06227 5.62451 4.97692 5.30188 6.10697 5.14876C7.205 4.99997 8.60802 4.99999 10.3794 5L12.4158 5C14.1872 4.99998 15.5902 4.99997 16.6882 5.14876C17.8183 5.30188 18.7329 5.62451 19.4542 6.35149C19.947 6.84817 20.2541 7.43804 20.4476 8.13012C20.4866 8.26959 20.3778 8.40376 20.2341 8.4L17.8015 8.40001C15.0673 8.40001 12.6575 10.5769 12.6575 13.5C12.6575 16.4231 15.0673 18.6 17.8015 18.6L20.2341 18.6ZM5.61446 8.88572C5.21522 8.88572 4.89157 9.21191 4.89157 9.61429C4.89157 10.0167 5.21522 10.3429 5.61446 10.3429H9.46988C9.86912 10.3429 10.1928 10.0167 10.1928 9.61429C10.1928 9.21191 9.86912 8.88572 9.46988 8.88572H5.61446Z" clip-rule="evenodd"/><path d="M7.77668 4.02439L9.73549 2.58126C10.7874 1.80625 12.2126 1.80625 13.2645 2.58126L15.2336 4.03197C14.4103 3.99995 13.4909 3.99998 12.4829 4H10.3123C9.39123 3.99998 8.5441 3.99996 7.77668 4.02439Z"/></g>',
width: 24,
height: 24,
};
export const iconChart: IconifyIcon = {
body: '<g fill="currentColor"><path d="M17.2929 2.29289C17 2.58579 17 3.05719 17 4V17C17 17.9428 17 18.4142 17.2929 18.7071C17.5858 19 18.0572 19 19 19C19.9428 19 20.4142 19 20.7071 18.7071C21 18.4142 21 17.9428 21 17V4C21 3.05719 21 2.58579 20.7071 2.29289C20.4142 2 19.9428 2 19 2C18.0572 2 17.5858 2 17.2929 2.29289Z"/><path d="M10 7C10 6.05719 10 5.58579 10.2929 5.29289C10.5858 5 11.0572 5 12 5C12.9428 5 13.4142 5 13.7071 5.29289C14 5.58579 14 6.05719 14 7V17C14 17.9428 14 18.4142 13.7071 18.7071C13.4142 19 12.9428 19 12 19C11.0572 19 10.5858 19 10.2929 18.7071C10 18.4142 10 17.9428 10 17V7Z"/><path d="M3.29289 9.29289C3 9.58579 3 10.0572 3 11V17C3 17.9428 3 18.4142 3.29289 18.7071C3.58579 19 4.05719 19 5 19C5.94281 19 6.41421 19 6.70711 18.7071C7 18.4142 7 17.9428 7 17V11C7 10.0572 7 9.58579 6.70711 9.29289C6.41421 9 5.94281 9 5 9C4.05719 9 3.58579 9 3.29289 9.29289Z"/><path d="M3 21.25C2.58579 21.25 2.25 21.5858 2.25 22C2.25 22.4142 2.58579 22.75 3 22.75H21C21.4142 22.75 21.75 22.4142 21.75 22C21.75 21.5858 21.4142 21.25 21 21.25H3Z"/></g>',
width: 24,
height: 24,
};
export const iconUserPlus: IconifyIcon = {
body: '<g fill="currentColor"><path fill-rule="evenodd" d="M16.5 15C18.1499 15 18.9747 15.0001 19.4873 15.5127C19.9999 16.0253 20 16.8501 20 18.5C20 20.1499 19.9999 20.9747 19.4873 21.4873C18.9747 21.9999 18.1499 22 16.5 22C14.8501 22 14.0253 21.9999 13.5127 21.4873C13.0001 20.9747 13 20.1499 13 18.5C13 16.8501 13.0001 16.0253 13.5127 15.5127C14.0253 15.0001 14.8501 15 16.5 15ZM16.5 16.3613C16.1779 16.3613 15.9171 16.6222 15.917 16.9443V17.917H14.9443C14.6222 17.9171 14.3613 18.1779 14.3613 18.5C14.3613 18.8221 14.6222 19.0829 14.9443 19.083H15.917V20.0557C15.9171 20.3778 16.1779 20.6387 16.5 20.6387C16.8221 20.6387 17.0829 20.3778 17.083 20.0557V19.083H18.0557C18.3778 19.0829 18.6387 18.8221 18.6387 18.5C18.6387 18.1779 18.3778 17.9171 18.0557 17.917H17.083V16.9443C17.0829 16.6222 16.8221 16.3613 16.5 16.3613Z" clip-rule="evenodd"/><path d="M12 13C13.24 13 14.4049 13.1846 15.415 13.5078C15.0415 13.5166 14.6918 13.5352 14.3799 13.5771C13.737 13.6636 13.0336 13.8707 12.4521 14.4521C11.8707 15.0336 11.6636 15.737 11.5771 16.3799C11.4995 16.9576 11.4999 17.6635 11.5 18.4141V18.5859C11.4999 19.3365 11.4995 20.0424 11.5771 20.6201C11.5937 20.7432 11.6154 20.8688 11.6426 20.9951C7.94288 20.8887 5 19.1406 5 17C5 14.7909 8.13401 13 12 13Z"/><path d="M12 2C14.2091 2 16 3.79086 16 6C16 8.20914 14.2091 10 12 10C9.79086 10 8 8.20914 8 6C8 3.79086 9.79086 2 12 2Z"/></g>',
width: 24,
height: 24,
};
export const iconMinusCircle: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM15.75 12C15.75 12.4142 15.4142 12.75 15 12.75H9C8.58579 12.75 8.25 12.4142 8.25 12C8.25 11.5858 8.58579 11.25 9 11.25H15C15.4142 11.25 15.75 11.5858 15.75 12Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconAddCircle: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM12.75 9C12.75 8.58579 12.4142 8.25 12 8.25C11.5858 8.25 11.25 8.58579 11.25 9L11.25 11.25H9C8.58579 11.25 8.25 11.5858 8.25 12C8.25 12.4142 8.58579 12.75 9 12.75H11.25V15C11.25 15.4142 11.5858 15.75 12 15.75C12.4142 15.75 12.75 15.4142 12.75 15L12.75 12.75H15C15.4142 12.75 15.75 12.4142 15.75 12C15.75 11.5858 15.4142 11.25 15 11.25H12.75V9Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconCheckCircle: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM16.0303 8.96967C16.3232 9.26256 16.3232 9.73744 16.0303 10.0303L11.0303 15.0303C10.7374 15.3232 10.2626 15.3232 9.96967 15.0303L7.96967 13.0303C7.67678 12.7374 7.67678 12.2626 7.96967 11.9697C8.26256 11.6768 8.73744 11.6768 9.03033 11.9697L10.5 13.4393L12.7348 11.2045L14.9697 8.96967C15.2626 8.67678 15.7374 8.67678 16.0303 8.96967Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconBarcode: IconifyIcon = {
body: '<path fill="currentColor" d="M1.88379 18.9785V4.97852C1.88379 4.5643 2.21958 4.22852 2.63379 4.22852C3.048 4.22852 3.38379 4.5643 3.38379 4.97852V18.9785C3.38379 19.3927 3.048 19.7285 2.63379 19.7285C2.21958 19.7285 1.88379 19.3927 1.88379 18.9785ZM4.78906 18.9785V4.97852C4.78906 4.5643 5.12485 4.22852 5.53906 4.22852C5.95328 4.22852 6.28906 4.5643 6.28906 4.97852V18.9785C6.28906 19.3927 5.95328 19.7285 5.53906 19.7285C5.12485 19.7285 4.78906 19.3927 4.78906 18.9785ZM11.5674 18.9785V4.97852C11.5674 4.5643 11.9032 4.22852 12.3174 4.22852C12.7314 4.22878 13.0674 4.56446 13.0674 4.97852V18.9785C13.0674 19.3926 12.7314 19.7283 12.3174 19.7285C11.9032 19.7285 11.5674 19.3927 11.5674 18.9785ZM14.4727 18.9785V4.97852C14.4727 4.5643 14.8084 4.22852 15.2227 4.22852C15.6366 4.22878 15.9727 4.56446 15.9727 4.97852V18.9785C15.9727 19.3926 15.6366 19.7283 15.2227 19.7285C14.8084 19.7285 14.4727 19.3927 14.4727 18.9785ZM21.25 18.9785V4.97852C21.25 4.5643 21.5858 4.22754 22 4.22754C22.4142 4.22754 22.75 4.5643 22.75 4.97852V18.9785C22.7497 19.3925 22.4141 19.7285 22 19.7285C21.5859 19.7285 21.2503 19.3925 21.25 18.9785ZM10.1621 18.5156C10.1621 19.1971 9.60922 19.75 8.92773 19.75C8.24627 19.75 7.69434 19.1971 7.69434 18.5156V5.44043C7.69437 4.75898 8.24629 4.20608 8.92773 4.20605L9.05371 4.21289C9.67603 4.27597 10.1621 4.80148 10.1621 5.44043V18.5156ZM19.8447 18.4941C19.8447 19.1755 19.2926 19.7283 18.6113 19.7285C17.9299 19.7285 17.377 19.1756 17.377 18.4941V5.41895C17.377 4.73746 17.9298 4.18457 18.6113 4.18457L18.7373 4.19141C19.3594 4.25473 19.8447 4.78015 19.8447 5.41895V18.4941Z"/>',
width: 24,
height: 24,
};
export const iconPrinter: IconifyIcon = {
body: '<g fill="currentColor"><path fill-rule="evenodd" d="M16 6H8C5.17157 6 3.75736 6 2.87868 6.87868C2 7.75736 2 9.17157 2 12C2 14.8284 2 16.2426 2.87868 17.1213C3.37323 17.6159 4.03743 17.8321 5.02795 17.9266C4.99998 17.2038 4.99999 15.3522 5 14.5C4.72386 14.5 4.5 14.2761 4.5 14C4.5 13.7239 4.72386 13.5 5 13.5H19C19.2761 13.5 19.5 13.7239 19.5 14C19.5 14.2761 19.2761 14.5003 19 14.5003C19 15.3525 19 17.2039 18.9721 17.9266C19.9626 17.8321 20.6268 17.6159 21.1213 17.1213C22 16.2426 22 14.8284 22 12C22 9.17157 22 7.75736 21.1213 6.87868C20.2426 6 18.8284 6 16 6ZM9 10.75C9.41421 10.75 9.75 10.4142 9.75 10C9.75 9.58579 9.41421 9.25 9 9.25H6C5.58579 9.25 5.25 9.58579 5.25 10C5.25 10.4142 5.58579 10.75 6 10.75H9ZM17 11C17.5523 11 18 10.5523 18 10C18 9.44772 17.5523 9 17 9C16.4477 9 16 9.44772 16 10C16 10.5523 16.4477 11 17 11Z" clip-rule="evenodd"/><path d="M17.1211 2.87868C16.2424 2 14.8282 2 11.9998 2C9.17134 2 7.75712 2 6.87844 2.87868C6.38608 3.37105 6.16961 4.03157 6.07444 5.01484C6.63368 4.99996 7.25183 4.99998 7.92943 5H16.0706C16.748 4.99998 17.366 4.99996 17.9251 5.01483C17.8299 4.03156 17.6135 3.37105 17.1211 2.87868Z"/><path fill-rule="evenodd" d="M18 14.5C18 17.3284 18 20.2426 17.1213 21.1213C16.2426 22 14.8284 22 12 22C9.17158 22 7.75736 22 6.87868 21.1213C6 20.2426 6 17.3284 6 14.5H18ZM15.75 16.75C15.75 17.1642 15.4142 17.5 15 17.5H9C8.58579 17.5 8.25 17.1642 8.25 16.75C8.25 16.3358 8.58579 16 9 16H15C15.4142 16 15.75 16.3358 15.75 16.75ZM13.75 19.75C13.75 20.1642 13.4142 20.5 13 20.5H9C8.58579 20.5 8.25 20.1642 8.25 19.75C8.25 19.3358 8.58579 19 9 19H13C13.4142 19 13.75 19.3358 13.75 19.75Z" clip-rule="evenodd"/></g>',
width: 24,
height: 24,
};
export const iconUndo: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M7.53033 3.46967C7.82322 3.76256 7.82322 4.23744 7.53033 4.53033L5.81066 6.25H15C18.1756 6.25 20.75 8.82436 20.75 12C20.75 15.1756 18.1756 17.75 15 17.75H8.00001C7.58579 17.75 7.25001 17.4142 7.25001 17C7.25001 16.5858 7.58579 16.25 8.00001 16.25H15C17.3472 16.25 19.25 14.3472 19.25 12C19.25 9.65279 17.3472 7.75 15 7.75H5.81066L7.53033 9.46967C7.82322 9.76256 7.82322 10.2374 7.53033 10.5303C7.23744 10.8232 6.76256 10.8232 6.46967 10.5303L3.46967 7.53033C3.17678 7.23744 3.17678 6.76256 3.46967 6.46967L6.46967 3.46967C6.76256 3.17678 7.23744 3.17678 7.53033 3.46967Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconScale: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M3 10C3 6.22876 3 4.34315 4.17157 3.17157C5.34315 2 7.22876 2 11 2H13C16.7712 2 18.6569 2 19.8284 3.17157C21 4.34315 21 6.22876 21 10V14C21 17.7712 21 19.6569 19.8284 20.8284C18.6569 22 16.7712 22 13 22H11C7.22876 22 5.34315 22 4.17157 20.8284C3 19.6569 3 17.7712 3 14V10ZM7.25 18C7.25 17.5858 7.58579 17.25 8 17.25H16C16.4142 17.25 16.75 17.5858 16.75 18C16.75 18.4142 16.4142 18.75 16 18.75H8C7.58579 18.75 7.25 18.4142 7.25 18ZM15.3553 6.30984C13.1338 5.89672 10.8663 5.89672 8.64474 6.30984L8.16897 6.39831C7.2887 6.562 6.76942 7.59158 7.10085 8.51607L7.84525 10.5925C7.95314 10.8934 8.2437 11.0592 8.52553 10.9806C8.70281 10.9311 8.88073 10.8855 9.05918 10.8437L8.41984 8.854C8.26351 8.36748 8.49027 7.83168 8.92632 7.65725C9.36237 7.48283 9.84259 7.73584 9.99892 8.22236L10.7514 10.5641C12.3304 10.41 13.9267 10.5488 15.4746 10.9806C15.7564 11.0592 16.047 10.8934 16.1548 10.5925L16.8992 8.51607C17.2307 7.59158 16.7114 6.562 15.8311 6.39831L15.3553 6.30984Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconDangerTriangle: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M5.31171 10.7615C8.23007 5.58716 9.68925 3 12 3C14.3107 3 15.7699 5.58716 18.6883 10.7615L19.0519 11.4063C21.4771 15.7061 22.6897 17.856 21.5937 19.428C20.4978 21 17.7864 21 12.3637 21H11.6363C6.21356 21 3.50217 21 2.40626 19.428C1.31034 17.856 2.52291 15.7061 4.94805 11.4063L5.31171 10.7615ZM12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V13C12.75 13.4142 12.4142 13.75 12 13.75C11.5858 13.75 11.25 13.4142 11.25 13V8C11.25 7.58579 11.5858 7.25 12 7.25ZM12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconDocumentText: IconifyIcon = {
body: '<path fill="currentColor" fill-rule="evenodd" d="M4.17157 3.17157C3 4.34315 3 6.22876 3 10V14C3 17.7712 3 19.6569 4.17157 20.8284C5.34315 22 7.22876 22 11 22H13C16.7712 22 18.6569 22 19.8284 20.8284C21 19.6569 21 17.7712 21 14V10C21 6.22876 21 4.34315 19.8284 3.17157C18.6569 2 16.7712 2 13 2H11C7.22876 2 5.34315 2 4.17157 3.17157ZM7.25 8C7.25 7.58579 7.58579 7.25 8 7.25H16C16.4142 7.25 16.75 7.58579 16.75 8C16.75 8.41421 16.4142 8.75 16 8.75H8C7.58579 8.75 7.25 8.41421 7.25 8ZM7.25 12C7.25 11.5858 7.58579 11.25 8 11.25H16C16.4142 11.25 16.75 11.5858 16.75 12C16.75 12.4142 16.4142 12.75 16 12.75H8C7.58579 12.75 7.25 12.4142 7.25 12ZM8 15.25C7.58579 15.25 7.25 15.5858 7.25 16C7.25 16.4142 7.58579 16.75 8 16.75H13C13.4142 16.75 13.75 16.4142 13.75 16C13.75 15.5858 13.4142 15.25 13 15.25H8Z" clip-rule="evenodd"/>',
width: 24,
height: 24,
};
export const iconBanknote: IconifyIcon = {
body: '<g fill="currentColor"><path d="M9.75 12C9.75 13.2426 10.7574 14.25 12 14.25C13.2426 14.25 14.25 13.2426 14.25 12C14.25 10.7574 13.2426 9.75 12 9.75C10.7574 9.75 9.75 10.7574 9.75 12Z"/><path fill-rule="evenodd" d="M3.77772 18.3259C4.78661 19 6.19108 19 9 19L15 19C17.8089 19 19.2134 19 20.2223 18.3259C20.659 18.034 21.034 17.659 21.3259 17.2223C22 16.2134 22 14.8089 22 12C22 9.19107 22 7.78661 21.3259 6.77772C21.034 6.34096 20.659 5.96595 20.2223 5.67412C19.2134 5 17.8089 5 15 5H9C6.19108 5 4.78661 5 3.77772 5.67412C3.34096 5.96596 2.96596 6.34096 2.67412 6.77772C2 7.78661 2 9.19108 2 12C2 14.8089 2 16.2134 2.67412 17.2223C2.96596 17.659 3.34096 18.034 3.77772 18.3259ZM12 15.75C9.92893 15.75 8.25 14.0711 8.25 12C8.25 9.92893 9.92893 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12C15.75 14.0711 14.0711 15.75 12 15.75ZM5.5 15.75C5.08579 15.75 4.75 15.4142 4.75 15L4.75 9C4.75 8.58579 5.08579 8.25 5.5 8.25C5.91421 8.25 6.25 8.58579 6.25 9L6.25 15C6.25 15.4142 5.91421 15.75 5.5 15.75ZM17.75 15C17.75 15.4142 18.0858 15.75 18.5 15.75C18.9142 15.75 19.25 15.4142 19.25 15V9C19.25 8.58579 18.9142 8.25 18.5 8.25C18.0858 8.25 17.75 8.58579 17.75 9V15Z" clip-rule="evenodd"/></g>',
width: 24,
height: 24,
};
export const iconCard: IconifyIcon = {
body: '<g fill="currentColor"><path fill-rule="evenodd" d="M22 12C22 15.7712 21.9997 17.6566 20.8281 18.8281C19.6566 19.9997 17.7712 20 14 20H10C6.22876 20 4.34345 19.9997 3.17188 18.8281C2.0003 17.6566 2 15.7712 2 12C2 11.5581 2.00007 11.142 2.00195 10.75H21.998C21.9999 11.142 22 11.5581 22 12ZM6 15.25C5.58579 15.25 5.25 15.5858 5.25 16C5.25 16.4142 5.58579 16.75 6 16.75H10C10.4142 16.75 10.75 16.4142 10.75 16C10.75 15.5858 10.4142 15.25 10 15.25H6ZM12.5 15.25C12.0858 15.25 11.75 15.5858 11.75 16C11.75 16.4142 12.0858 16.75 12.5 16.75H14C14.4142 16.75 14.75 16.4142 14.75 16C14.75 15.5858 14.4142 15.25 14 15.25H12.5Z" clip-rule="evenodd"/><path d="M14 4C17.7711 4 19.6565 4.00038 20.8281 5.17188C21.6717 6.01542 21.9085 7.22882 21.9746 9.25H2.02637C2.0925 7.2289 2.3284 6.01541 3.17188 5.17188C4.34343 4.00032 6.22887 4 10 4H14Z"/></g>',
width: 24,
height: 24,
};
@@ -0,0 +1,38 @@
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const body = (await res.json()) as { message?: string | string[] };
if (body.message) message = Array.isArray(body.message) ? body.message.join(", ") : body.message;
} catch {
// el cuerpo no era JSON; se mantiene el mensaje genérico
}
throw new ApiError(res.status, message);
}
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
export const apiClient = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: (path: string) => request<void>(path, { method: "DELETE" }),
};
@@ -0,0 +1,36 @@
import { createResource } from "./resource";
import type {
AppUser,
CartProductRow,
CashMovement,
CashSession,
Category,
Customer,
Inventory,
InventoryMovement,
OperatingDay,
PaymentRow,
Product,
ProductCategoryLink,
ProductHistoryRow,
ShopCart,
Supplier,
Warehouse,
} from "../types/entities";
export const productsResource = createResource<Product>("products");
export const productHistoryResource = createResource<ProductHistoryRow>("product-history");
export const categoriesResource = createResource<Category>("categories");
export const productCategoriesResource = createResource<ProductCategoryLink>("product-categories");
export const suppliersResource = createResource<Supplier>("suppliers");
export const inventoriesResource = createResource<Inventory>("inventories");
export const warehousesResource = createResource<Warehouse>("warehouses");
export const inventoryMovementsResource = createResource<InventoryMovement>("inventory-movements");
export const customersResource = createResource<Customer>("customers");
export const usersResource = createResource<AppUser>("users");
export const shopCartsResource = createResource<ShopCart>("shop-carts");
export const cartProductsResource = createResource<CartProductRow>("cart-products");
export const paymentsResource = createResource<PaymentRow>("payments");
export const cashSessionsResource = createResource<CashSession>("cash-register-sessions");
export const cashMovementsResource = createResource<CashMovement>("cash-movements");
export const operatingDaysResource = createResource<OperatingDay>("operating-days");
@@ -0,0 +1,21 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function AppQueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
@@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
import { apiClient } from "./api-client";
export function createResource<T>(resource: string) {
const key = [resource] as const;
function useList(options?: Partial<UseQueryOptions<T[]>>) {
return useQuery({
queryKey: key,
queryFn: () => apiClient.get<T[]>(`/${resource}`),
...options,
});
}
function useDetail(id: number | string | null | undefined) {
return useQuery({
queryKey: [...key, id],
queryFn: () => apiClient.get<T>(`/${resource}/${id}`),
enabled: id !== null && id !== undefined,
});
}
function useCreate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: unknown) => apiClient.post<T>(`/${resource}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: key }),
});
}
function useUpdate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number | string; data: unknown }) => apiClient.patch<T>(`/${resource}/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: key }),
});
}
function useRemove() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number | string) => apiClient.delete(`/${resource}/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: key }),
});
}
return { key, useList, useDetail, useCreate, useUpdate, useRemove };
}
@@ -0,0 +1,6 @@
export interface AuthUser {
id: number;
name: string;
email: string;
role: string;
}
+131
View File
@@ -0,0 +1,131 @@
export interface Product {
id: number;
name: string;
description: string | null;
sku: string;
barcode: string | null;
minStock: number;
imageUrl: string | null;
supplierId: number | null;
createdAt: string;
}
export interface ProductHistoryRow {
id: number;
productId: number;
unitPrice: string;
costUnit: string;
status: string;
createdAt: string;
}
export interface Category {
id: number;
name: string;
createdAt: string;
}
export interface ProductCategoryLink {
productId: number;
categoryId: number;
}
export interface Supplier {
id: number;
name: string;
lastSupply: string | null;
createdAt: string;
}
export interface Inventory {
id: number;
name: string;
}
export interface Warehouse {
inventoryId: number;
productId: number;
stock: number;
}
export interface InventoryMovement {
id: number;
productId: number;
inventoryId: number;
type: "in" | "out";
quantity: number;
reason: string | null;
createdAt: string;
}
export interface Customer {
id: number;
name: string;
phone: string | null;
email: string | null;
taxId: string | null;
createdAt: string;
}
export interface AppUser {
id: number;
name: string;
email: string;
role: string;
createdAt: string;
}
export interface ShopCart {
id: number;
totalPrice: string;
discount: string;
status: string;
createdAt: string;
userId: number | null;
customerId: number | null;
}
export interface CartProductRow {
cartId: number;
historyId: number;
quantity: number;
}
export interface PaymentRow {
id: number;
cartId: number;
amount: string;
paymentMethod: string;
status: string;
}
export interface CashSession {
id: number;
inventoryId: number;
openingCash: string;
closingCash: string | null;
expectedCash: string | null;
status: string;
openedAt: string;
closedAt: string | null;
}
export interface OperatingDay {
id: number;
date: string;
status: string;
openedById: number;
closedById: number | null;
openedAt: string;
closedAt: string | null;
}
export interface CashMovement {
id: number;
sessionId: number;
type: "in" | "out";
amount: string;
reason: string | null;
userId: number | null;
createdAt: string;
}
@@ -0,0 +1,209 @@
"use client";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
iconArchive,
iconChart,
iconCloseSquare,
iconDashboard,
iconDelivery,
iconHamburgerMenu,
iconStorefrontFill,
iconUserPlus,
iconUsersGroup,
iconWalletMoney,
} from "../../_core/icons";
import { useSessionUser } from "../../_core/hooks/use-session-user";
import { OperatingDayControl } from "./operating-day-control";
import { UserMenu } from "./user-menu";
const NAV_ITEMS = [
{ href: "/admin", label: "Panel", icon: iconDashboard },
{ href: "/admin/inventario", label: "Inventario", icon: iconArchive },
{ href: "/admin/proveedores", label: "Proveedores", icon: iconDelivery },
{ href: "/admin/clientes", label: "Clientes", icon: iconUserPlus },
{ href: "/admin/reportes", label: "Reportes", icon: iconChart },
{ href: "/admin/usuarios", label: "Usuarios", icon: iconUsersGroup },
] as const;
function isActive(pathname: string, href: string) {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
}
export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user, logout } = useSessionUser();
const reduceMotion = useReducedMotion();
const [menuOpen, setMenuOpen] = useState(false);
const [prevPathname, setPrevPathname] = useState(pathname);
if (pathname !== prevPathname) {
setPrevPathname(pathname);
setMenuOpen(false);
}
useEffect(() => {
if (!menuOpen) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setMenuOpen(false);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [menuOpen]);
if (!user) return null;
const activeItem = NAV_ITEMS.find((item) => isActive(pathname, item.href));
const navList = (
<nav aria-label="Principal" className="flex flex-1 flex-col gap-1 overflow-y-auto p-3">
<span className="px-3 pb-1.5 pt-1 text-[11px] font-semibold tracking-wider text-muted uppercase">
Menú principal
</span>
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={`group relative flex items-center gap-3 rounded-lg py-2.5 pl-3.5 pr-3 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ${
active
? "bg-accent-soft text-accent-soft-foreground"
: "text-muted hover:bg-foreground/5 hover:text-foreground"
}`}
>
<span
className={`absolute inset-y-1.5 left-0 w-[3px] rounded-full transition-opacity ${
active ? "bg-accent opacity-100" : "opacity-0"
}`}
aria-hidden="true"
/>
<span
className={`flex size-8 shrink-0 items-center justify-center rounded-md transition ${
active ? "bg-accent text-accent-foreground" : "bg-foreground/5 text-muted group-hover:text-foreground"
}`}
>
<Icon icon={item.icon} width={17} height={17} aria-hidden="true" />
</span>
{item.label}
</Link>
);
})}
</nav>
);
return (
<div className="flex h-dvh w-full flex-col gap-3 overflow-hidden bg-background p-3 text-foreground md:p-4">
{/* Unified header */}
<header className="flex h-16 shrink-0 items-center gap-3 rounded-2xl border border-border bg-surface px-3 md:px-5">
<button
type="button"
onClick={() => setMenuOpen(true)}
aria-label="Abrir menú"
aria-expanded={menuOpen}
className="flex size-10 shrink-0 items-center justify-center rounded-lg text-foreground transition hover:bg-foreground/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus md:hidden"
>
<Icon icon={iconHamburgerMenu} width={22} height={22} aria-hidden="true" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-accent-soft">
<Icon icon={iconStorefrontFill} width={20} height={20} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<div className="hidden min-w-0 leading-tight sm:block">
<p className="truncate text-sm font-semibold tracking-wide text-foreground">PDV</p>
<p className="truncate text-[11px] text-muted">Panel administrativo</p>
</div>
</div>
<div className="mx-2 hidden h-8 w-px shrink-0 bg-border md:block" />
<h1 className="hidden min-w-0 truncate text-base font-semibold text-foreground md:block">
{activeItem?.label ?? "Panel"}
</h1>
<div className="flex-1" />
<Link
href="/pos"
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Icon icon={iconWalletMoney} width={16} height={16} aria-hidden="true" />
<span className="hidden sm:inline">Ir a Caja</span>
</Link>
<OperatingDayControl />
<UserMenu user={user} onLogout={logout} />
</header>
<div className="flex flex-1 gap-3 overflow-hidden">
{/* Sidebar (desktop) */}
<aside className="hidden w-64 shrink-0 flex-col rounded-2xl border border-border bg-surface md:flex">
{navList}
</aside>
<main className="flex-1 overflow-y-auto rounded-2xl border border-border bg-surface p-4 md:p-8">
{children}
</main>
</div>
{/* Mobile drawer */}
<AnimatePresence>
{menuOpen && (
<>
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.15 }}
className="fixed inset-0 z-40 bg-backdrop md:hidden"
onClick={() => setMenuOpen(false)}
aria-hidden="true"
/>
<motion.div
key="drawer"
initial={{ x: reduceMotion ? 0 : "-100%" }}
animate={{ x: 0 }}
exit={{ x: reduceMotion ? 0 : "-100%" }}
transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }}
role="dialog"
aria-modal="true"
aria-label="Menú principal"
className="fixed inset-y-3 left-3 z-50 flex w-72 flex-col rounded-2xl border border-border bg-surface md:hidden"
>
<div className="flex items-center justify-between border-b border-border px-4 py-4">
<div className="flex items-center gap-2">
<div className="flex size-9 items-center justify-center rounded-xl bg-accent-soft">
<Icon
icon={iconStorefrontFill}
width={20}
height={20}
className="text-accent-soft-foreground"
aria-hidden="true"
/>
</div>
<span className="text-sm font-semibold tracking-wide text-foreground">PDV Admin</span>
</div>
<button
type="button"
onClick={() => setMenuOpen(false)}
aria-label="Cerrar menú"
className="flex size-9 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={iconCloseSquare} width={20} height={20} aria-hidden="true" />
</button>
</div>
{navList}
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,154 @@
"use client";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { iconCalendar, iconCheckCircle, iconChevronDown } from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client";
import { cashSessionsResource, operatingDaysResource } from "../../_core/services/entities";
import { useOperatingDay } from "../../_core/hooks/use-operating-day";
import { useSessionUser } from "../../_core/hooks/use-session-user";
import { ConfirmDialog } from "../../_core/components/confirm-dialog";
function formatDay(date: Date) {
const formatted = new Intl.DateTimeFormat("es-AR", { weekday: "long", day: "numeric", month: "long" }).format(date);
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
}
export function OperatingDayControl() {
const { user } = useSessionUser();
const { current } = useOperatingDay();
const { data: sessions = [] } = cashSessionsResource.useList();
const createDay = operatingDaysResource.useCreate();
const closeDay = operatingDaysResource.useUpdate();
const reduceMotion = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [closeOpen, setCloseOpen] = useState(false);
const [openError, setOpenError] = useState<string | null>(null);
const openSessions = sessions.filter((s) => s.status === "open");
useEffect(() => {
if (!menuOpen) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [menuOpen]);
async function handleOpen() {
if (!user) return;
setOpenError(null);
try {
await createDay.mutateAsync({ openedById: user.id });
} catch (err) {
setOpenError(err instanceof ApiError ? err.message : "No se pudo abrir la fecha de operación.");
}
}
async function handleClose() {
if (!current || !user) return;
try {
await closeDay.mutateAsync({ id: current.id, data: { status: "closed", closedById: user.id } });
setMenuOpen(false);
} catch (err) {
throw err instanceof ApiError ? new Error(err.message) : new Error("No se pudo cerrar la fecha de operación.");
}
}
if (current === undefined) return null;
if (current === null) {
return (
<div className="relative">
<button
type="button"
onClick={handleOpen}
disabled={createDay.isPending}
className="flex h-11 items-center gap-2 rounded-lg bg-accent px-3.5 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
<Icon icon={iconCalendar} width={16} height={16} aria-hidden="true" />
<span className="hidden sm:inline">{createDay.isPending ? "Abriendo…" : "Abrir día de operación"}</span>
</button>
{openError && (
<div className="absolute right-0 top-[calc(100%+8px)] z-50 w-64 rounded-xl border border-danger/30 bg-danger-soft px-3 py-2.5 text-sm text-danger">
{openError}
</div>
)}
</div>
);
}
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={menuOpen}
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm font-medium text-foreground transition hover:bg-foreground/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<span className="flex size-2 shrink-0 rounded-full bg-success" aria-hidden="true" />
<Icon icon={iconCalendar} width={16} height={16} aria-hidden="true" />
<span className="hidden leading-tight sm:flex sm:flex-col sm:items-start">
<span className="text-[10px] text-muted">Fecha de operación</span>
<span className="text-xs">{formatDay(new Date(current.openedAt))}</span>
</span>
<Icon
icon={iconChevronDown}
width={14}
height={14}
className={`shrink-0 text-muted transition-transform ${menuOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
<AnimatePresence>
{menuOpen && (
<motion.div
role="menu"
aria-label="Fecha de operación"
initial={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.97 }}
transition={{ duration: reduceMotion ? 0 : 0.15, ease: "easeOut" }}
className="absolute right-0 top-[calc(100%+8px)] z-50 w-64 overflow-hidden rounded-xl border border-border bg-surface shadow-lg"
>
<div className="border-b border-border px-3 py-2.5">
<p className="text-xs text-muted">Abierta el</p>
<p className="text-sm font-medium text-foreground">{formatDay(new Date(current.openedAt))}</p>
</div>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false);
setCloseOpen(true);
}}
disabled={openSessions.length > 0}
title={openSessions.length > 0 ? "Cerrá todas las cajas abiertas antes de cerrar el día" : undefined}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm text-danger transition hover:bg-danger-soft disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
>
<Icon icon={iconCheckCircle} width={16} height={16} aria-hidden="true" />
Cerrar día de operación
</button>
{openSessions.length > 0 && (
<p className="px-3 pb-2.5 text-xs text-muted">Hay {openSessions.length} caja(s) abierta(s).</p>
)}
</motion.div>
)}
</AnimatePresence>
<ConfirmDialog
open={closeOpen}
onClose={() => setCloseOpen(false)}
title="Cerrar día de operación"
description="Se cerrará la fecha de operación actual y se abrirá automáticamente la del día siguiente. Esta acción no se puede deshacer."
confirmLabel="Cerrar día"
onConfirm={handleClose}
/>
</div>
);
}
@@ -0,0 +1,34 @@
import { Icon, type IconifyIcon } from "@iconify/react";
import Link from "next/link";
import { iconArrowLeft } from "../../_core/icons";
interface QuickActionCardProps {
href: string;
icon: IconifyIcon;
label: string;
description: string;
}
export function QuickActionCard({ href, icon, label, description }: QuickActionCardProps) {
return (
<Link
href={href}
className="group flex items-center gap-4 rounded-2xl border border-border bg-surface p-4 transition hover:border-accent/40 hover:bg-accent-soft/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft">
<Icon icon={icon} width={20} height={20} className="text-accent-soft-foreground" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-foreground">{label}</span>
<span className="block truncate text-xs text-muted">{description}</span>
</span>
<Icon
icon={iconArrowLeft}
width={16}
height={16}
className="shrink-0 rotate-180 text-muted transition group-hover:translate-x-0.5 group-hover:text-foreground"
aria-hidden="true"
/>
</Link>
);
}
@@ -0,0 +1,23 @@
import { Icon, type IconifyIcon } from "@iconify/react";
interface StatCardProps {
icon: IconifyIcon;
label: string;
value: string;
hint: string;
}
export function StatCard({ icon, label, value, hint }: StatCardProps) {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-surface p-5">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted">{label}</span>
<span className="flex size-9 items-center justify-center rounded-lg bg-accent-soft">
<Icon icon={icon} width={18} height={18} className="text-accent-soft-foreground" aria-hidden="true" />
</span>
</div>
<p className="text-3xl font-semibold tabular-nums text-foreground">{value}</p>
<p className="text-xs text-muted">{hint}</p>
</div>
);
}
@@ -0,0 +1,133 @@
"use client";
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 {
iconChevronDown,
iconIdentificationBadge,
iconLogout,
iconMonitor,
iconMoon,
iconSun,
} from "../../_core/icons";
import type { AuthUser } from "../../_core/types/auth-user";
interface UserMenuProps {
user: AuthUser;
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();
const rootRef = useRef<HTMLDivElement>(null);
const { theme, setTheme } = useTheme();
const themeMounted = theme !== undefined;
const currentTheme: ThemeMode = themeMounted && THEME_MODES.includes(theme as ThemeMode) ? (theme as ThemeMode) : "system";
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex h-11 items-center gap-2 rounded-lg pl-1.5 pr-2 transition hover:bg-foreground/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Avatar size="sm" className="rounded-full">
<Avatar.Fallback>
<Icon icon={iconIdentificationBadge} width={16} height={16} aria-hidden="true" />
</Avatar.Fallback>
</Avatar>
<span className="hidden min-w-0 max-w-32 truncate text-left text-sm font-medium text-foreground sm:block">
{user.name}
</span>
<Icon
icon={iconChevronDown}
width={14}
height={14}
className={`shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
<AnimatePresence>
{open && (
<motion.div
role="menu"
aria-label="Cuenta"
initial={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.97 }}
transition={{ duration: reduceMotion ? 0 : 0.15, ease: "easeOut" }}
className="absolute right-0 top-[calc(100%+8px)] z-50 w-56 overflow-hidden rounded-xl border border-border bg-surface shadow-lg"
>
<div className="border-b border-border px-3 py-2.5">
<p className="truncate text-sm font-medium text-foreground">{user.name}</p>
<p className="truncate text-xs text-muted">{user.email}</p>
</div>
<div className="border-b border-border p-1.5">
<p className="px-1.5 pb-1 pt-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted">Tema</p>
<div className="grid grid-cols-3 gap-1">
{THEME_MODES.map((mode) => (
<button
key={mode}
type="button"
role="menuitemradio"
aria-checked={currentTheme === mode}
onClick={() => setTheme(mode)}
className={`flex flex-col items-center gap-1 rounded-lg py-2 text-xs transition ${
currentTheme === mode
? "bg-accent-soft text-accent-soft-foreground"
: "text-muted hover:bg-foreground/5 hover:text-foreground"
}`}
>
<Icon icon={THEME_ICONS[mode]} width={16} height={16} aria-hidden="true" />
{THEME_LABELS[mode]}
</button>
))}
</div>
</div>
<button
type="button"
role="menuitem"
onClick={onLogout}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm text-danger transition hover:bg-danger-soft focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus"
>
<Icon icon={iconLogout} width={16} height={16} aria-hidden="true" />
Cerrar sesión
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { Icon } from "@iconify/react";
import { useState } from "react";
import { iconUserPlus } from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client";
import { customersResource } from "../../_core/services/entities";
import { ConfirmDialog } from "../../_core/components/confirm-dialog";
import { Dialog } from "../../_core/components/dialog";
import { Field, TextInput } from "../../_core/components/form-controls";
import { PageHeader } from "../../_core/components/page-header";
import { TableShell, tdClass, thClass } from "../../_core/components/table-shell";
import type { Customer } from "../../_core/types/entities";
export default function AdminCustomersPage() {
const { data: items = [], isLoading } = customersResource.useList();
const createCustomer = customersResource.useCreate();
const updateCustomer = customersResource.useUpdate();
const removeCustomer = customersResource.useRemove();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Customer | null>(null);
const [deleting, setDeleting] = useState<Customer | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const submitting = createCustomer.isPending || updateCustomer.isPending;
function openCreate() {
setEditing(null);
setSubmitError(null);
setDialogOpen(true);
}
function openEdit(item: Customer) {
setEditing(item);
setSubmitError(null);
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
const phone = String(formData.get("phone") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const taxId = String(formData.get("taxId") ?? "").trim();
setSubmitError(null);
try {
const body = { name, phone: phone || null, email: email || null, taxId: taxId || null };
if (editing) {
await updateCustomer.mutateAsync({ id: editing.id, data: body });
} else {
await createCustomer.mutateAsync(body);
}
setDialogOpen(false);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo guardar el cliente.");
}
}
return (
<div>
<PageHeader
title="Clientes"
description="Clientes registrados e historial de compras."
action={
<button
type="button"
onClick={openCreate}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Nuevo cliente
</button>
}
/>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : items.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconUserPlus} width={28} height={28} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<p className="text-sm text-muted">No hay clientes registrados.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Nombre</th>
<th className={thClass}>Teléfono</th>
<th className={thClass}>Email</th>
<th className={`${thClass} text-right`}>Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={item.id}>
<td className={`${tdClass} font-medium`}>
<span className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-lg bg-accent-soft">
<Icon icon={iconUserPlus} width={15} height={15} className="text-accent-soft-foreground" aria-hidden="true" />
</span>
{item.name}
</span>
</td>
<td className={`${tdClass} text-muted`}>{item.phone ?? "—"}</td>
<td className={`${tdClass} text-muted`}>{item.email ?? "—"}</td>
<td className={`${tdClass} text-right`}>
<button
type="button"
onClick={() => openEdit(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Editar
</button>
<button
type="button"
onClick={() => setDeleting(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition hover:bg-danger-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Eliminar
</button>
</td>
</tr>
))}
</tbody>
</TableShell>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} title={editing ? "Editar cliente" : "Nuevo cliente"}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
<Field label="Nombre" htmlFor="customer-name" required>
<TextInput id="customer-name" name="name" required defaultValue={editing?.name ?? ""} autoFocus />
</Field>
<Field label="Teléfono" htmlFor="customer-phone">
<TextInput id="customer-phone" name="phone" type="tel" defaultValue={editing?.phone ?? ""} />
</Field>
<Field label="Email" htmlFor="customer-email">
<TextInput id="customer-email" name="email" type="email" defaultValue={editing?.email ?? ""} />
</Field>
<Field label="RFC / Identificación fiscal" htmlFor="customer-tax-id">
<TextInput id="customer-tax-id" name="taxId" defaultValue={editing?.taxId ?? ""} />
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setDialogOpen(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
{submitting ? "Guardando…" : "Guardar"}
</button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
title="Eliminar cliente"
description={`¿Seguro que querés eliminar "${deleting?.name}"? Esta acción no se puede deshacer.`}
onConfirm={async () => {
if (!deleting) return;
await removeCustomer.mutateAsync(deleting.id);
}}
/>
</div>
);
}
@@ -0,0 +1,391 @@
"use client";
import { Icon } from "@iconify/react";
import { Label, ListBox, Select } from "@heroui/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { iconArchive, iconArrowLeft, iconBox, iconScale } from "../../../_core/icons";
import { apiClient, ApiError } from "../../../_core/services/api-client";
import {
inventoriesResource,
inventoryMovementsResource,
productsResource,
warehousesResource,
} from "../../../_core/services/entities";
import { ConfirmDialog } from "../../../_core/components/confirm-dialog";
import { Dialog } from "../../../_core/components/dialog";
import { Field, TextareaInput, TextInput } from "../../../_core/components/form-controls";
import { TableShell, tdClass, thClass } from "../../../_core/components/table-shell";
import type { Warehouse } from "../../../_core/types/entities";
type MovementType = "in" | "out";
export default function AdminInventoryDetailPage() {
const params = useParams<{ id: string }>();
const inventoryId = Number(params.id);
const queryClient = useQueryClient();
const { data: inventory = null } = inventoriesResource.useDetail(inventoryId);
const { data: products = [] } = productsResource.useList();
const { data: allWarehouses, isLoading: stockLoading } = warehousesResource.useList();
const stock = allWarehouses ? allWarehouses.filter((w) => w.inventoryId === inventoryId) : stockLoading ? null : [];
const { data: allMovements = [] } = inventoryMovementsResource.useList();
const movements = allMovements
.filter((m) => m.inventoryId === inventoryId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 10);
const createMovement = inventoryMovementsResource.useCreate();
const [dialogType, setDialogType] = useState<MovementType | null>(null);
const [removing, setRemoving] = useState<Warehouse | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [countOpen, setCountOpen] = useState(false);
const [counted, setCounted] = useState<Record<number, string>>({});
const [countError, setCountError] = useState<string | null>(null);
const [counting, setCounting] = useState(false);
function refreshStock() {
queryClient.invalidateQueries({ queryKey: warehousesResource.key });
}
function productName(id: number) {
const product = products.find((p) => p.id === id);
return product ? product.name : `Producto #${id}`;
}
const productsWithStock = products.filter((p) => (stock?.find((w) => w.productId === p.id)?.stock ?? 0) > 0);
function openMovementDialog(type: MovementType) {
setSubmitError(null);
setDialogType(type);
}
async function handleSubmitMovement(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!dialogType) return;
const formData = new FormData(e.currentTarget);
const productId = Number(formData.get("productId"));
const quantity = Number(formData.get("quantity"));
const reason = String(formData.get("reason") ?? "").trim();
setSubmitting(true);
setSubmitError(null);
try {
await createMovement.mutateAsync({
inventoryId,
productId,
type: dialogType,
quantity,
reason: reason || null,
});
refreshStock();
setDialogType(null);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo registrar el movimiento.");
} finally {
setSubmitting(false);
}
}
function openCount() {
if (!stock) return;
setCountError(null);
setCounted(Object.fromEntries(stock.map((row) => [row.productId, String(row.stock)])));
setCountOpen(true);
}
async function handleCountSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!stock) return;
setCounting(true);
setCountError(null);
try {
for (const row of stock) {
const value = Number(counted[row.productId] ?? row.stock);
const diff = value - row.stock;
if (diff === 0) continue;
await createMovement.mutateAsync({
productId: row.productId,
inventoryId,
type: diff > 0 ? "in" : "out",
quantity: Math.abs(diff),
reason: "Ajuste por conteo físico",
});
}
refreshStock();
setCountOpen(false);
} catch (err) {
setCountError(err instanceof ApiError ? err.message : "No se pudo aplicar el conteo.");
} finally {
setCounting(false);
}
}
return (
<div>
<Link
href="/admin/inventario"
className="mb-4 inline-flex items-center gap-1.5 text-sm text-muted transition hover:text-foreground"
>
<Icon icon={iconArrowLeft} width={14} height={14} aria-hidden="true" />
Almacenes
</Link>
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground">{inventory?.name ?? "Almacén"}</h1>
<p className="text-sm text-muted">Stock por producto en este almacén.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={openCount}
disabled={!stock || stock.length === 0}
className="flex items-center gap-1.5 rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground transition hover:bg-foreground/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
<Icon icon={iconScale} width={16} height={16} aria-hidden="true" />
Conteo físico
</button>
<button
type="button"
onClick={() => openMovementDialog("out")}
disabled={productsWithStock.length === 0}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground transition hover:bg-foreground/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
Registrar salida
</button>
<button
type="button"
onClick={() => openMovementDialog("in")}
disabled={products.length === 0}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
Registrar entrada
</button>
</div>
</div>
{stock === null ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : stock.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconArchive} width={28} height={28} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<p className="text-sm text-muted">No hay stock. Registrá una entrada para empezar.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Producto</th>
<th className={thClass}>Stock</th>
<th className={`${thClass} text-right`}>Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{stock.map((row) => (
<tr key={row.productId}>
<td className={`${tdClass} font-medium`}>
<span className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-lg bg-accent-soft">
<Icon icon={iconBox} width={15} height={15} className="text-accent-soft-foreground" aria-hidden="true" />
</span>
{productName(row.productId)}
</span>
</td>
<td className={`${tdClass} tabular-nums`}>{row.stock}</td>
<td className={`${tdClass} text-right`}>
<button
type="button"
disabled={row.stock !== 0}
onClick={() => setRemoving(row)}
title={row.stock !== 0 ? "Solo se puede quitar cuando el stock está en 0" : undefined}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition hover:bg-danger-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
>
Quitar
</button>
</td>
</tr>
))}
</tbody>
</TableShell>
)}
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-foreground">Movimientos recientes</h2>
{movements.length === 0 ? (
<p className="text-sm text-muted">No hay movimientos registrados.</p>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Tipo</th>
<th className={thClass}>Producto</th>
<th className={thClass}>Cantidad</th>
<th className={thClass}>Motivo</th>
<th className={thClass}>Fecha</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{movements.map((m) => (
<tr key={m.id}>
<td className={tdClass}>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
m.type === "in" ? "bg-success-soft text-success-soft-foreground" : "bg-danger-soft text-danger"
}`}
>
<Icon
icon={iconArrowLeft}
width={11}
height={11}
className={m.type === "in" ? "-rotate-90" : "rotate-90"}
aria-hidden="true"
/>
{m.type === "in" ? "Entrada" : "Salida"}
</span>
</td>
<td className={`${tdClass} font-medium`}>{productName(m.productId)}</td>
<td className={`${tdClass} tabular-nums`}>{m.quantity}</td>
<td className={`${tdClass} text-muted`}>{m.reason ?? "—"}</td>
<td className={`${tdClass} text-muted`}>
{new Date(m.createdAt).toLocaleString("es-AR", { dateStyle: "short", timeStyle: "short" })}
</td>
</tr>
))}
</tbody>
</TableShell>
)}
</div>
<Dialog
open={dialogType !== null}
onClose={() => setDialogType(null)}
title={dialogType === "in" ? "Registrar entrada" : "Registrar salida"}
>
<form onSubmit={handleSubmitMovement} className="flex flex-col gap-4" noValidate>
<Select className="w-full" name="productId" fullWidth variant="secondary" placeholder="Elegí un producto" isRequired>
<Label className="text-sm font-medium text-foreground">Producto</Label>
<Select.Trigger className="w-full rounded-field border border-border bg-background px-3 py-2 text-sm text-foreground">
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover className="rounded-field border border-border bg-surface p-1.5 shadow-lg">
<ListBox>
{(dialogType === "out" ? productsWithStock : products).map((p) => (
<ListBox.Item
key={p.id}
id={String(p.id)}
textValue={`${p.name} (${p.sku})`}
className="cursor-pointer rounded-lg px-2.5 py-2 text-sm text-foreground data-[focused=true]:bg-foreground/5 data-[selected=true]:bg-accent-soft data-[selected=true]:text-accent-soft-foreground"
>
{p.name} ({p.sku})
{dialogType === "out" ? ` — stock: ${stock?.find((w) => w.productId === p.id)?.stock ?? 0}` : ""}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
<Field label="Cantidad" htmlFor="movement-quantity" required>
<TextInput id="movement-quantity" name="quantity" type="number" min={1} defaultValue={1} required />
</Field>
<Field label="Motivo" htmlFor="movement-reason">
<TextareaInput
id="movement-reason"
name="reason"
rows={2}
placeholder={dialogType === "in" ? "Ej: compra a proveedor" : "Ej: venta, merma, ajuste"}
/>
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setDialogType(null)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
{submitting ? "Guardando…" : "Guardar"}
</button>
</div>
</form>
</Dialog>
<Dialog open={countOpen} onClose={() => setCountOpen(false)} title="Conteo físico">
<form onSubmit={handleCountSubmit} className="flex flex-col gap-4" noValidate>
<p className="text-sm text-muted">
Ingresá la cantidad contada físicamente. Las diferencias se registran automáticamente como
entradas o salidas de ajuste.
</p>
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto">
{stock?.map((row) => (
<div key={row.productId} className="flex items-center gap-3 rounded-lg border border-border p-2.5">
<span className="flex-1 truncate text-sm text-foreground">{productName(row.productId)}</span>
<span className="text-xs text-muted">Sistema: {row.stock}</span>
<input
type="number"
min={0}
value={counted[row.productId] ?? row.stock}
onChange={(e) => setCounted((prev) => ({ ...prev, [row.productId]: e.target.value }))}
className="w-20 rounded-field border border-border bg-background px-2 py-1.5 text-center text-sm tabular-nums text-foreground outline-none focus:border-focus"
/>
</div>
))}
</div>
{countError && (
<p role="alert" className="text-sm text-danger">
{countError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setCountOpen(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground"
>
Cancelar
</button>
<button
type="submit"
disabled={counting}
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"
>
{counting ? "Aplicando…" : "Aplicar ajustes"}
</button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={removing !== null}
onClose={() => setRemoving(null)}
title="Quitar producto del almacén"
description={`¿Seguro que querés quitar "${removing ? productName(removing.productId) : ""}" de este almacén? Su stock ya está en 0.`}
confirmLabel="Quitar"
onConfirm={async () => {
if (!removing) return;
await apiClient.delete(`/warehouses/${removing.inventoryId}/${removing.productId}`);
refreshStock();
}}
/>
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
import { Icon } from "@iconify/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { iconArchive, iconBox, iconTag } from "../../../_core/icons";
const TABS = [
{ href: "/admin/inventario", label: "Almacenes", icon: iconArchive },
{ href: "/admin/inventario/productos", label: "Productos", icon: iconBox },
{ href: "/admin/inventario/categorias", label: "Categorías", icon: iconTag },
] as const;
export function InventorySubNav() {
const pathname = usePathname();
return (
<nav aria-label="Secciones de inventario" className="mb-6 flex gap-1 border-b border-border">
{TABS.map((tab) => {
const active = tab.href === "/admin/inventario" ? pathname === tab.href : pathname.startsWith(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-2 border-b-2 px-3 py-2.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ${
active ? "border-accent text-foreground" : "border-transparent text-muted hover:text-foreground"
}`}
>
<Icon icon={tab.icon} width={16} height={16} aria-hidden="true" />
{tab.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,168 @@
"use client";
import { Icon } from "@iconify/react";
import { useState } from "react";
import { iconTag } from "../../../_core/icons";
import { ApiError } from "../../../_core/services/api-client";
import { categoriesResource } from "../../../_core/services/entities";
import { ConfirmDialog } from "../../../_core/components/confirm-dialog";
import { Dialog } from "../../../_core/components/dialog";
import { Field, TextInput } from "../../../_core/components/form-controls";
import { PageHeader } from "../../../_core/components/page-header";
import { TableShell, tdClass, thClass } from "../../../_core/components/table-shell";
import type { Category } from "../../../_core/types/entities";
import { InventorySubNav } from "../_components/inventory-sub-nav";
export default function AdminCategoriesPage() {
const { data: items = [], isLoading } = categoriesResource.useList();
const createCategory = categoriesResource.useCreate();
const updateCategory = categoriesResource.useUpdate();
const removeCategory = categoriesResource.useRemove();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Category | null>(null);
const [deleting, setDeleting] = useState<Category | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const submitting = createCategory.isPending || updateCategory.isPending;
function openCreate() {
setEditing(null);
setSubmitError(null);
setDialogOpen(true);
}
function openEdit(item: Category) {
setEditing(item);
setSubmitError(null);
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const name = String(new FormData(e.currentTarget).get("name") ?? "").trim();
setSubmitError(null);
try {
if (editing) {
await updateCategory.mutateAsync({ id: editing.id, data: { name } });
} else {
await createCategory.mutateAsync({ name });
}
setDialogOpen(false);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo guardar la categoría.");
}
}
return (
<div>
<InventorySubNav />
<PageHeader
title="Categorías"
description="Organizá el catálogo de productos."
action={
<button
type="button"
onClick={openCreate}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Nueva categoría
</button>
}
/>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : items.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconTag} width={28} height={28} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<p className="text-sm text-muted">No hay categorías.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Nombre</th>
<th className={thClass}>Creada</th>
<th className={`${thClass} text-right`}>Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={item.id}>
<td className={`${tdClass} font-medium`}>
<span className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-lg bg-accent-soft">
<Icon icon={iconTag} width={15} height={15} className="text-accent-soft-foreground" aria-hidden="true" />
</span>
{item.name}
</span>
</td>
<td className={`${tdClass} text-muted`}>{new Date(item.createdAt).toLocaleDateString("es-AR")}</td>
<td className={`${tdClass} text-right`}>
<button
type="button"
onClick={() => openEdit(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Editar
</button>
<button
type="button"
onClick={() => setDeleting(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition hover:bg-danger-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Eliminar
</button>
</td>
</tr>
))}
</tbody>
</TableShell>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} title={editing ? "Editar categoría" : "Nueva categoría"}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
<Field label="Nombre" htmlFor="category-name" required>
<TextInput id="category-name" name="name" required defaultValue={editing?.name ?? ""} autoFocus />
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setDialogOpen(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
{submitting ? "Guardando…" : "Guardar"}
</button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
title="Eliminar categoría"
description={`¿Seguro que querés eliminar "${deleting?.name}"? Esta acción no se puede deshacer.`}
onConfirm={async () => {
if (!deleting) return;
await removeCategory.mutateAsync(deleting.id);
}}
/>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useState } from "react";
import { iconArchive, iconArrowLeft } from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client";
import { inventoriesResource } from "../../_core/services/entities";
import { ConfirmDialog } from "../../_core/components/confirm-dialog";
import { Dialog } from "../../_core/components/dialog";
import { Field, TextInput } from "../../_core/components/form-controls";
import { PageHeader } from "../../_core/components/page-header";
import { TableShell, tdClass, thClass } from "../../_core/components/table-shell";
import type { Inventory } from "../../_core/types/entities";
import { InventorySubNav } from "./_components/inventory-sub-nav";
export default function AdminInventoryPage() {
const { data: items = [], isLoading } = inventoriesResource.useList();
const createInventory = inventoriesResource.useCreate();
const updateInventory = inventoriesResource.useUpdate();
const removeInventory = inventoriesResource.useRemove();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Inventory | null>(null);
const [deleting, setDeleting] = useState<Inventory | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const submitting = createInventory.isPending || updateInventory.isPending;
function openCreate() {
setEditing(null);
setSubmitError(null);
setDialogOpen(true);
}
function openEdit(item: Inventory) {
setEditing(item);
setSubmitError(null);
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const name = String(new FormData(e.currentTarget).get("name") ?? "").trim();
setSubmitError(null);
try {
if (editing) {
await updateInventory.mutateAsync({ id: editing.id, data: { name } });
} else {
await createInventory.mutateAsync({ name });
}
setDialogOpen(false);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo guardar el almacén.");
}
}
return (
<div>
<InventorySubNav />
<PageHeader
title="Almacenes"
description="Ubicaciones físicas donde se guarda el stock."
action={
<button
type="button"
onClick={openCreate}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Nuevo almacén
</button>
}
/>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : items.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconArchive} width={28} height={28} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<p className="text-sm text-muted">No hay almacenes.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Almacén</th>
<th className={`${thClass} text-right`}>Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={item.id}>
<td className={`${tdClass} font-medium`}>
<span className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-lg bg-accent-soft">
<Icon icon={iconArchive} width={15} height={15} className="text-accent-soft-foreground" aria-hidden="true" />
</span>
{item.name}
</span>
</td>
<td className={`${tdClass} text-right`}>
<Link
href={`/admin/inventario/${item.id}`}
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Ver stock
<Icon icon={iconArrowLeft} width={13} height={13} className="rotate-180" aria-hidden="true" />
</Link>
<button
type="button"
onClick={() => openEdit(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Editar
</button>
<button
type="button"
onClick={() => setDeleting(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition hover:bg-danger-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Eliminar
</button>
</td>
</tr>
))}
</tbody>
</TableShell>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} title={editing ? "Editar almacén" : "Nuevo almacén"}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
<Field label="Nombre" htmlFor="inventory-name" required>
<TextInput id="inventory-name" name="name" required defaultValue={editing?.name ?? ""} autoFocus />
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setDialogOpen(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
{submitting ? "Guardando…" : "Guardar"}
</button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
title="Eliminar almacén"
description={`¿Seguro que querés eliminar "${deleting?.name}"? Esta acción no se puede deshacer.`}
onConfirm={async () => {
if (!deleting) return;
await removeInventory.mutateAsync(deleting.id);
}}
/>
</div>
);
}
@@ -0,0 +1,561 @@
"use client";
import { Icon } from "@iconify/react";
import { Label, ListBox, Select } from "@heroui/react";
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { iconArrowLeft, iconBox, iconDangerTriangle, iconDocumentText, iconTag, iconWalletMoney } from "../../../_core/icons";
import { apiClient, ApiError } from "../../../_core/services/api-client";
import {
categoriesResource,
inventoryMovementsResource,
productCategoriesResource,
productHistoryResource,
productsResource,
suppliersResource,
warehousesResource,
} from "../../../_core/services/entities";
import { ConfirmDialog } from "../../../_core/components/confirm-dialog";
import { Dialog } from "../../../_core/components/dialog";
import { Field, TextareaInput, TextInput } from "../../../_core/components/form-controls";
import { MultiSelect } from "../../../_core/components/multi-select";
import { PageHeader } from "../../../_core/components/page-header";
import { TableShell, tdClass, thClass } from "../../../_core/components/table-shell";
import type { Product, ProductHistoryRow } from "../../../_core/types/entities";
import { InventorySubNav } from "../_components/inventory-sub-nav";
function money(value: string | number) {
return Number(value).toLocaleString("es-AR", { style: "currency", currency: "USD" });
}
export default function AdminProductsPage() {
const queryClient = useQueryClient();
const { data: items = [], isLoading } = productsResource.useList();
const { data: suppliers = [] } = suppliersResource.useList();
const { data: categories = [] } = categoriesResource.useList();
const { data: links = [] } = productCategoriesResource.useList();
const { data: warehouses = [] } = warehousesResource.useList();
const { data: history = [] } = productHistoryResource.useList();
const { data: movements = [] } = inventoryMovementsResource.useList();
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);
const [selectedCategoryIds, setSelectedCategoryIds] = useState<number[]>([]);
const [deleting, setDeleting] = useState<Product | null>(null);
const [pricing, setPricing] = useState<Product | null>(null);
const [kardexProduct, setKardexProduct] = useState<Product | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
function kardexFor(productId: number) {
const rows = movements
.filter((m) => m.productId === productId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
let balance = 0;
return rows.map((m) => {
balance += m.type === "in" ? m.quantity : -m.quantity;
return { ...m, balance };
});
}
const activePriceByProduct = useMemo(() => {
const map = new Map<number, ProductHistoryRow>();
for (const row of history) {
if (row.status !== "active") continue;
const existing = map.get(row.productId);
if (!existing || row.createdAt > existing.createdAt) map.set(row.productId, row);
}
return map;
}, [history]);
function stockFor(productId: number) {
return warehouses.filter((w) => w.productId === productId).reduce((sum, w) => sum + w.stock, 0);
}
function supplierName(id: number | null) {
if (!id) return "Sin proveedor";
return suppliers.find((s) => s.id === id)?.name ?? "Sin proveedor";
}
function categoriesFor(productId: number) {
const ids = links.filter((l) => l.productId === productId).map((l) => l.categoryId);
return categories.filter((c) => ids.includes(c.id));
}
function openCreate() {
setEditing(null);
setSelectedCategoryIds([]);
setSubmitError(null);
setDialogOpen(true);
}
function openEdit(item: Product) {
setEditing(item);
setSelectedCategoryIds(links.filter((l) => l.productId === item.id).map((l) => l.categoryId));
setSubmitError(null);
setDialogOpen(true);
}
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}`)),
]);
queryClient.invalidateQueries({ queryKey: productCategoriesResource.key });
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
const sku = String(formData.get("sku") ?? "").trim();
const barcode = String(formData.get("barcode") ?? "").trim();
const minStock = Number(formData.get("minStock") ?? 0);
const imageUrl = String(formData.get("imageUrl") ?? "").trim();
const description = String(formData.get("description") ?? "").trim();
const supplierIdRaw = String(formData.get("supplierId") ?? "");
const supplierId = supplierIdRaw && supplierIdRaw !== "none" ? Number(supplierIdRaw) : null;
setSubmitting(true);
setSubmitError(null);
try {
const body = {
name,
sku,
barcode: barcode || null,
minStock,
imageUrl: imageUrl || null,
description: description || null,
supplierId,
};
const productId = editing ? editing.id : (await createProduct.mutateAsync(body)).id;
if (editing) {
await updateProduct.mutateAsync({ id: editing.id, data: body });
}
await syncCategories(productId);
setDialogOpen(false);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo guardar el producto.");
} finally {
setSubmitting(false);
}
}
async function handlePriceSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!pricing) return;
const formData = new FormData(e.currentTarget);
const unitPrice = String(formData.get("unitPrice") ?? "0");
const costUnit = String(formData.get("costUnit") ?? "0");
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" });
setPricing(null);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo actualizar el precio.");
} finally {
setSubmitting(false);
}
}
return (
<div>
<InventorySubNav />
<PageHeader
title="Productos"
description="Catálogo de productos, proveedores y categorías."
action={
<button
type="button"
onClick={openCreate}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Nuevo producto
</button>
}
/>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : items.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconBox} width={28} height={28} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<p className="text-sm text-muted">No hay productos.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Producto</th>
<th className={thClass}>SKU / Código</th>
<th className={thClass}>Categorías</th>
<th className={thClass}>Proveedor</th>
<th className={thClass}>Compra / Venta</th>
<th className={thClass}>Stock</th>
<th className={`${thClass} text-right`}>Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => {
const stock = stockFor(item.id);
const lowStock = stock <= item.minStock;
const price = activePriceByProduct.get(item.id);
return (
<tr key={item.id}>
<td className={`${tdClass} font-medium`}>
<span className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-lg bg-accent-soft">
<Icon icon={iconBox} width={15} height={15} className="text-accent-soft-foreground" aria-hidden="true" />
</span>
<span>
<span className="block">{item.name}</span>
{item.description && <span className="block text-xs text-muted">{item.description}</span>}
</span>
</span>
</td>
<td className={`${tdClass} text-muted tabular-nums`}>
<span className="block">{item.sku}</span>
{item.barcode && <span className="block text-xs">{item.barcode}</span>}
</td>
<td className={tdClass}>
{categoriesFor(item.id).length === 0 ? (
<span className="text-muted">Sin categoría</span>
) : (
<span className="flex flex-wrap gap-1.5">
{categoriesFor(item.id).map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1 rounded-full bg-accent-soft px-2 py-0.5 text-xs font-medium text-accent-soft-foreground"
>
<Icon icon={iconTag} width={10} height={10} aria-hidden="true" />
{c.name}
</span>
))}
</span>
)}
</td>
<td className={`${tdClass} text-muted`}>{supplierName(item.supplierId)}</td>
<td className={tdClass}>
{price ? (
<button
type="button"
onClick={() => {
setSubmitError(null);
setPricing(item);
}}
className="block text-left tabular-nums transition hover:text-accent"
>
<span className="block text-xs text-muted underline decoration-dotted underline-offset-2">
Compra: {money(price.costUnit)}
</span>
<span className="block font-medium text-foreground underline decoration-dotted underline-offset-2">
Venta: {money(price.unitPrice)}
</span>
{Number(price.unitPrice) > 0 && (
<span className="block text-xs text-success-soft-foreground">
Margen:{" "}
{(((Number(price.unitPrice) - Number(price.costUnit)) / Number(price.unitPrice)) * 100).toFixed(0)}%
</span>
)}
</button>
) : (
<button
type="button"
onClick={() => {
setSubmitError(null);
setPricing(item);
}}
className="flex items-center gap-1 text-xs font-medium text-accent transition hover:text-accent-hover"
>
<Icon icon={iconWalletMoney} width={14} height={14} aria-hidden="true" />
Definir precio
</button>
)}
</td>
<td className={`${tdClass} tabular-nums`}>
<span className={`flex items-center gap-1.5 ${lowStock ? "text-danger" : "text-foreground"}`}>
{lowStock && <Icon icon={iconDangerTriangle} width={14} height={14} aria-hidden="true" />}
{stock}
{item.minStock > 0 && <span className="text-xs text-muted">/ mín. {item.minStock}</span>}
</span>
</td>
<td className={`${tdClass} text-right`}>
<button
type="button"
onClick={() => setKardexProduct(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Kardex
</button>
<button
type="button"
onClick={() => openEdit(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Editar
</button>
<button
type="button"
onClick={() => setDeleting(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition hover:bg-danger-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Eliminar
</button>
</td>
</tr>
);
})}
</tbody>
</TableShell>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} title={editing ? "Editar producto" : "Nuevo producto"}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
<Field label="Nombre" htmlFor="product-name" required>
<TextInput id="product-name" name="name" required defaultValue={editing?.name ?? ""} autoFocus />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="SKU" htmlFor="product-sku" required>
<TextInput id="product-sku" name="sku" required defaultValue={editing?.sku ?? ""} />
</Field>
<Field label="Código de barras" htmlFor="product-barcode">
<TextInput id="product-barcode" name="barcode" defaultValue={editing?.barcode ?? ""} />
</Field>
</div>
<Field label="Descripción" htmlFor="product-description">
<TextareaInput id="product-description" name="description" rows={2} defaultValue={editing?.description ?? ""} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Stock mínimo" htmlFor="product-min-stock">
<TextInput
id="product-min-stock"
name="minStock"
type="number"
min={0}
defaultValue={editing?.minStock ?? 0}
/>
</Field>
<Field label="URL de imagen" htmlFor="product-image">
<TextInput id="product-image" name="imageUrl" type="url" defaultValue={editing?.imageUrl ?? ""} />
</Field>
</div>
<Select
className="w-full"
name="supplierId"
fullWidth
variant="secondary"
placeholder="Sin proveedor"
defaultValue={editing?.supplierId ? String(editing.supplierId) : "none"}
>
<Label className="text-sm font-medium text-foreground">Proveedor</Label>
<Select.Trigger className="w-full rounded-field border border-border bg-background px-3 py-2 text-sm text-foreground">
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover className="rounded-field border border-border bg-surface p-1.5 shadow-lg">
<ListBox>
<ListBox.Item
id="none"
textValue="Sin proveedor"
className="cursor-pointer rounded-lg px-2.5 py-2 text-sm text-muted data-[focused=true]:bg-foreground/5 data-[selected=true]:bg-accent-soft data-[selected=true]:text-accent-soft-foreground"
>
Sin proveedor
</ListBox.Item>
{suppliers.map((s) => (
<ListBox.Item
key={s.id}
id={String(s.id)}
textValue={s.name}
className="cursor-pointer rounded-lg px-2.5 py-2 text-sm text-foreground data-[focused=true]:bg-foreground/5 data-[selected=true]:bg-accent-soft data-[selected=true]:text-accent-soft-foreground"
>
{s.name}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-foreground">Categorías</span>
{categories.length === 0 ? (
<p className="text-sm text-muted">
No hay categorías cargadas todavía. Creá alguna en la pestaña &ldquo;Categorías&rdquo;.
</p>
) : (
<MultiSelect
options={categories.map((c) => ({ id: c.id, label: c.name }))}
selected={selectedCategoryIds}
onChange={setSelectedCategoryIds}
placeholder="Buscar categoría…"
/>
)}
</div>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setDialogOpen(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
{submitting ? "Guardando…" : "Guardar"}
</button>
</div>
</form>
</Dialog>
<Dialog open={pricing !== null} onClose={() => setPricing(null)} title={`Precio de ${pricing?.name ?? ""}`} widthClassName="max-w-sm">
<form onSubmit={handlePriceSubmit} className="flex flex-col gap-4" noValidate>
<Field label="Precio de compra" htmlFor="price-cost" required>
<TextInput
id="price-cost"
name="costUnit"
type="number"
min={0}
step="0.01"
required
autoFocus
defaultValue={pricing ? (activePriceByProduct.get(pricing.id)?.costUnit ?? "") : ""}
/>
</Field>
<Field label="Precio de venta" htmlFor="price-sale" required>
<TextInput
id="price-sale"
name="unitPrice"
type="number"
min={0}
step="0.01"
required
defaultValue={pricing ? (activePriceByProduct.get(pricing.id)?.unitPrice ?? "") : ""}
/>
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setPricing(null)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
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"
>
{submitting ? "Guardando…" : "Guardar precio"}
</button>
</div>
</form>
</Dialog>
<Dialog
open={kardexProduct !== null}
onClose={() => setKardexProduct(null)}
title={`Kardex — ${kardexProduct?.name ?? ""}`}
widthClassName="max-w-2xl"
>
{kardexProduct && (
<div>
{kardexFor(kardexProduct.id).length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Icon icon={iconDocumentText} width={24} height={24} className="text-muted" aria-hidden="true" />
<p className="text-sm text-muted">Sin movimientos registrados para este producto.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Tipo</th>
<th className={thClass}>Cantidad</th>
<th className={thClass}>Saldo</th>
<th className={thClass}>Motivo</th>
<th className={thClass}>Fecha</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{kardexFor(kardexProduct.id)
.slice()
.reverse()
.map((m) => (
<tr key={m.id}>
<td className={tdClass}>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
m.type === "in" ? "bg-success-soft text-success-soft-foreground" : "bg-danger-soft text-danger"
}`}
>
<Icon
icon={iconArrowLeft}
width={10}
height={10}
className={m.type === "in" ? "-rotate-90" : "rotate-90"}
aria-hidden="true"
/>
{m.type === "in" ? "Entrada" : "Salida"}
</span>
</td>
<td className={`${tdClass} tabular-nums`}>{m.quantity}</td>
<td className={`${tdClass} tabular-nums font-medium`}>{m.balance}</td>
<td className={`${tdClass} text-muted`}>{m.reason ?? "—"}</td>
<td className={`${tdClass} text-muted`}>
{new Date(m.createdAt).toLocaleString("es-AR", { dateStyle: "short", timeStyle: "short" })}
</td>
</tr>
))}
</tbody>
</TableShell>
)}
</div>
)}
</Dialog>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
title="Eliminar producto"
description={`¿Seguro que querés eliminar "${deleting?.name}"? Esta acción no se puede deshacer.`}
onConfirm={async () => {
if (!deleting) return;
await removeProduct.mutateAsync(deleting.id);
}}
/>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AdminShell } from "./_components/admin-shell";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <AdminShell>{children}</AdminShell>;
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useMemo } from "react";
import { iconArchive, iconBox, iconCart, iconDelivery, iconTag, iconUsersGroup } from "../_core/icons";
import { useOperatingDay } from "../_core/hooks/use-operating-day";
import { productsResource, shopCartsResource, usersResource, warehousesResource } from "../_core/services/entities";
import { QuickActionCard } from "./_components/quick-action-card";
import { StatCard } from "./_components/stat-card";
const ROLE_LABELS: Record<string, string> = { admin: "administrador", cashier: "cajero" };
function money(value: number) {
return value.toLocaleString("es-AR", { style: "currency", currency: "USD" });
}
export default function AdminDashboardPage() {
const { data: carts, isLoading: loadingCarts } = shopCartsResource.useList();
const { data: products, isLoading: loadingProducts } = productsResource.useList();
const { data: warehouses } = warehousesResource.useList();
const { data: users, isLoading: loadingUsers } = usersResource.useList();
const { start: operatingDayStart } = useOperatingDay();
const salesToday = useMemo(() => {
if (!carts) return null;
return carts
.filter((c) => c.status === "paid" && new Date(c.createdAt) >= operatingDayStart)
.reduce((sum, c) => sum + Number(c.totalPrice), 0);
}, [carts, operatingDayStart]);
const stockByProduct = useMemo(() => {
const map = new Map<number, number>();
for (const w of warehouses ?? []) map.set(w.productId, (map.get(w.productId) ?? 0) + w.stock);
return map;
}, [warehouses]);
const lowStockCount = useMemo(() => {
if (!products) return null;
return products.filter((p) => (stockByProduct.get(p.id) ?? 0) <= p.minStock).length;
}, [products, stockByProduct]);
const usersByRole = useMemo(() => {
const counts: Record<string, number> = {};
for (const u of users ?? []) counts[u.role] = (counts[u.role] ?? 0) + 1;
return counts;
}, [users]);
const usersHint = Object.entries(usersByRole)
.map(([role, count]) => `${count} ${ROLE_LABELS[role] ?? role}${count === 1 ? "" : "s"}`)
.join(", ");
return (
<div className="flex flex-col gap-8">
<div>
<h1 className="text-xl font-semibold text-foreground">Resumen</h1>
<p className="text-sm text-muted">Estado general del negocio.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={iconCart}
label="Ventas de hoy"
value={loadingCarts || salesToday === null ? "—" : money(salesToday)}
hint={salesToday ? "Total cobrado hoy" : "Sin ventas registradas aún"}
/>
<StatCard
icon={iconBox}
label="Productos activos"
value={loadingProducts || !products ? "—" : String(products.length)}
hint={products && products.length > 0 ? "En el catálogo" : "Conectá el catálogo para ver el total"}
/>
<StatCard
icon={iconArchive}
label="Alertas de stock"
value={lowStockCount === null ? "—" : String(lowStockCount)}
hint={lowStockCount ? "Productos bajo el mínimo" : "Sin datos de inventario aún"}
/>
<StatCard
icon={iconUsersGroup}
label="Usuarios activos"
value={loadingUsers || !users ? "—" : String(users.length)}
hint={usersHint || "Sin usuarios"}
/>
</div>
<div>
<h2 className="mb-3 text-sm font-semibold text-foreground">Accesos rápidos</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<QuickActionCard href="/admin/inventario" icon={iconArchive} label="Almacenes" description="Stock, entradas y salidas" />
<QuickActionCard href="/admin/inventario/productos" icon={iconBox} label="Productos" description="Catálogo y precios" />
<QuickActionCard href="/admin/inventario/categorias" icon={iconTag} label="Categorías" description="Organizá el catálogo" />
<QuickActionCard href="/admin/proveedores" icon={iconDelivery} label="Proveedores" description="Contactos y pedidos" />
<QuickActionCard href="/admin/usuarios" icon={iconUsersGroup} label="Usuarios" description="Roles y accesos" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useMemo } from "react";
import { iconArrowLeft, iconBox, iconDelivery } from "../../../_core/icons";
import { inventoryMovementsResource, productsResource, suppliersResource } from "../../../_core/services/entities";
import { TableShell, tdClass, thClass } from "../../../_core/components/table-shell";
export default function AdminSupplierDetailPage() {
const params = useParams<{ id: string }>();
const supplierId = Number(params.id);
const { data: supplier = null } = suppliersResource.useDetail(supplierId);
const { data: products = [] } = productsResource.useList();
const { data: movements = [] } = inventoryMovementsResource.useList();
const supplierProductIds = useMemo(
() => new Set(products.filter((p) => p.supplierId === supplierId).map((p) => p.id)),
[products, supplierId],
);
const purchases = useMemo(
() =>
movements
.filter((m) => m.type === "in" && supplierProductIds.has(m.productId))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
[movements, supplierProductIds],
);
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `Producto #${id}`;
}
return (
<div>
<Link
href="/admin/proveedores"
className="mb-4 inline-flex items-center gap-1.5 text-sm text-muted transition hover:text-foreground"
>
<Icon icon={iconArrowLeft} width={14} height={14} aria-hidden="true" />
Proveedores
</Link>
<div className="mb-6 flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-accent-soft">
<Icon icon={iconDelivery} width={20} height={20} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">{supplier?.name ?? "Proveedor"}</h1>
<p className="text-sm text-muted">
{supplier?.lastSupply
? `Último abastecimiento: ${new Date(supplier.lastSupply).toLocaleDateString("es-AR")}`
: "Sin abastecimientos registrados"}
</p>
</div>
</div>
<h2 className="mb-3 text-sm font-semibold text-foreground">Historial de compras</h2>
{purchases.length === 0 ? (
<div className="flex min-h-[30vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<Icon icon={iconBox} width={26} height={26} className="text-muted" aria-hidden="true" />
<p className="text-sm text-muted">Todavía no hay compras registradas para este proveedor.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Producto</th>
<th className={thClass}>Cantidad</th>
<th className={thClass}>Motivo</th>
<th className={thClass}>Fecha</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{purchases.map((m) => (
<tr key={m.id}>
<td className={`${tdClass} font-medium`}>{productName(m.productId)}</td>
<td className={`${tdClass} tabular-nums`}>{m.quantity}</td>
<td className={`${tdClass} text-muted`}>{m.reason ?? "—"}</td>
<td className={`${tdClass} text-muted`}>{new Date(m.createdAt).toLocaleString("es-AR")}</td>
</tr>
))}
</tbody>
</TableShell>
)}
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useState } from "react";
import { iconArrowLeft, iconDelivery } from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client";
import { suppliersResource } from "../../_core/services/entities";
import { ConfirmDialog } from "../../_core/components/confirm-dialog";
import { Dialog } from "../../_core/components/dialog";
import { Field, TextInput } from "../../_core/components/form-controls";
import { PageHeader } from "../../_core/components/page-header";
import { TableShell, tdClass, thClass } from "../../_core/components/table-shell";
import type { Supplier } from "../../_core/types/entities";
export default function AdminSuppliersPage() {
const { data: items = [], isLoading } = suppliersResource.useList();
const createSupplier = suppliersResource.useCreate();
const updateSupplier = suppliersResource.useUpdate();
const removeSupplier = suppliersResource.useRemove();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Supplier | null>(null);
const [deleting, setDeleting] = useState<Supplier | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const submitting = createSupplier.isPending || updateSupplier.isPending;
function openCreate() {
setEditing(null);
setSubmitError(null);
setDialogOpen(true);
}
function openEdit(item: Supplier) {
setEditing(item);
setSubmitError(null);
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
setSubmitError(null);
try {
if (editing) {
await updateSupplier.mutateAsync({ id: editing.id, data: { name } });
} else {
await createSupplier.mutateAsync({ name });
}
setDialogOpen(false);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo guardar el proveedor.");
}
}
return (
<div>
<PageHeader
title="Proveedores"
description="Contactos y últimos abastecimientos."
action={
<button
type="button"
onClick={openCreate}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Nuevo proveedor
</button>
}
/>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : items.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconDelivery} width={28} height={28} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<p className="text-sm text-muted">No hay proveedores.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Nombre</th>
<th className={thClass}>Último abastecimiento</th>
<th className={`${thClass} text-right`}>Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={item.id}>
<td className={`${tdClass} font-medium`}>
<span className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-lg bg-accent-soft">
<Icon icon={iconDelivery} width={15} height={15} className="text-accent-soft-foreground" aria-hidden="true" />
</span>
{item.name}
</span>
</td>
<td className={`${tdClass} text-muted`}>
{item.lastSupply ? new Date(item.lastSupply).toLocaleDateString("es-AR") : "Sin registro"}
</td>
<td className={`${tdClass} text-right`}>
<Link
href={`/admin/proveedores/${item.id}`}
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Ver historial
<Icon icon={iconArrowLeft} width={13} height={13} className="rotate-180" aria-hidden="true" />
</Link>
<button
type="button"
onClick={() => openEdit(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Editar
</button>
<button
type="button"
onClick={() => setDeleting(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition hover:bg-danger-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Eliminar
</button>
</td>
</tr>
))}
</tbody>
</TableShell>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} title={editing ? "Editar proveedor" : "Nuevo proveedor"}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
<Field label="Nombre" htmlFor="supplier-name" required>
<TextInput id="supplier-name" name="name" required defaultValue={editing?.name ?? ""} autoFocus />
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setDialogOpen(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
{submitting ? "Guardando…" : "Guardar"}
</button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
title="Eliminar proveedor"
description={`¿Seguro que querés eliminar "${deleting?.name}"? Esta acción no se puede deshacer.`}
onConfirm={async () => {
if (!deleting) return;
await removeSupplier.mutateAsync(deleting.id);
}}
/>
</div>
);
}
+420
View File
@@ -0,0 +1,420 @@
"use client";
import { Icon } from "@iconify/react";
import { useMemo, useState } from "react";
import {
iconBanknote,
iconBox,
iconCard,
iconChart,
iconDocumentText,
iconUsersGroup,
iconWalletMoney,
} from "../../_core/icons";
import {
cartProductsResource,
cashSessionsResource,
inventoriesResource,
paymentsResource,
productHistoryResource,
productsResource,
shopCartsResource,
usersResource,
warehousesResource,
} from "../../_core/services/entities";
import { TableShell, tdClass, thClass } from "../../_core/components/table-shell";
const PAYMENT_LABELS: Record<string, string> = { cash: "Efectivo", card: "Tarjeta", transfer: "Transferencia" };
const TABS = [
{ id: "dia", label: "Ventas del día", icon: iconBanknote },
{ id: "producto", label: "Ventas por producto", icon: iconBox },
{ id: "empleado", label: "Ventas por empleado", icon: iconUsersGroup },
{ id: "utilidad", label: "Utilidad", icon: iconChart },
{ id: "inventario", label: "Inventario", icon: iconWalletMoney },
{ id: "top", label: "Más vendidos", icon: iconBox },
{ id: "pagos", label: "Métodos de pago", icon: iconCard },
{ id: "cortes", label: "Cortes de caja", icon: iconDocumentText },
] as const;
type TabId = (typeof TABS)[number]["id"];
function money(value: number | string) {
return Number(value).toLocaleString("es-AR", { style: "currency", currency: "USD" });
}
function isToday(iso: string) {
const d = new Date(iso);
const now = new Date();
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
}
export default function AdminReportsPage() {
const [tab, setTab] = useState<TabId>("dia");
const { data: carts = [] } = shopCartsResource.useList();
const { data: cartProducts = [] } = cartProductsResource.useList();
const { data: history = [] } = productHistoryResource.useList();
const { data: products = [] } = productsResource.useList();
const { data: payments = [] } = paymentsResource.useList();
const { data: users = [] } = usersResource.useList();
const { data: warehouses = [] } = warehousesResource.useList();
const { data: inventories = [] } = inventoriesResource.useList();
const { data: sessions = [] } = cashSessionsResource.useList();
const paidCarts = useMemo(() => carts.filter((c) => c.status === "paid"), [carts]);
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `Producto #${id}`;
}
function userName(id: number | null) {
return users.find((u) => u.id === id)?.name ?? "—";
}
const productAgg = useMemo(() => {
const map = new Map<number, { productId: number; quantity: number; revenue: number; profit: number }>();
for (const cp of cartProducts) {
const cart = paidCarts.find((c) => c.id === cp.cartId);
if (!cart) continue;
const h = history.find((row) => row.id === cp.historyId);
if (!h) continue;
const entry = map.get(h.productId) ?? { productId: h.productId, quantity: 0, revenue: 0, profit: 0 };
entry.quantity += cp.quantity;
entry.revenue += Number(h.unitPrice) * cp.quantity;
entry.profit += (Number(h.unitPrice) - Number(h.costUnit)) * cp.quantity;
map.set(h.productId, entry);
}
return Array.from(map.values());
}, [cartProducts, paidCarts, history]);
const employeeAgg = useMemo(() => {
const map = new Map<number, { userId: number; count: number; total: number }>();
for (const cart of paidCarts) {
const key = cart.userId ?? 0;
const entry = map.get(key) ?? { userId: key, count: 0, total: 0 };
entry.count += 1;
entry.total += Number(cart.totalPrice);
map.set(key, entry);
}
return Array.from(map.values()).sort((a, b) => b.total - a.total);
}, [paidCarts]);
const paymentAgg = useMemo(() => {
const map = new Map<string, { method: string; count: number; total: number }>();
for (const p of payments) {
if (p.status !== "completed" || Number(p.amount) <= 0) continue;
const entry = map.get(p.paymentMethod) ?? { method: p.paymentMethod, count: 0, total: 0 };
entry.count += 1;
entry.total += Number(p.amount);
map.set(p.paymentMethod, entry);
}
return Array.from(map.values()).sort((a, b) => b.total - a.total);
}, [payments]);
const todayCarts = useMemo(() => paidCarts.filter((c) => isToday(c.createdAt)), [paidCarts]);
const todayTotal = todayCarts.reduce((sum, c) => sum + Number(c.totalPrice), 0);
const totalProfit = productAgg.reduce((sum, p) => sum + p.profit, 0);
const totalRevenue = productAgg.reduce((sum, p) => sum + p.revenue, 0);
const topProducts = useMemo(() => [...productAgg].sort((a, b) => b.quantity - a.quantity).slice(0, 10), [productAgg]);
const closedSessions = useMemo(
() => sessions.filter((s) => s.status === "closed").sort((a, b) => (b.closedAt ?? "").localeCompare(a.closedAt ?? "")),
[sessions],
);
return (
<div>
<div className="mb-6">
<h1 className="text-xl font-semibold text-foreground">Reportes</h1>
<p className="text-sm text-muted">Ventas, utilidad, inventario y cortes de caja.</p>
</div>
<nav aria-label="Reportes" className="mb-6 flex flex-wrap gap-1 border-b border-border">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
aria-current={tab === t.id ? "page" : undefined}
className={`flex items-center gap-2 border-b-2 px-3 py-2.5 text-sm font-medium transition ${
tab === t.id ? "border-accent text-foreground" : "border-transparent text-muted hover:text-foreground"
}`}
>
<Icon icon={t.icon} width={15} height={15} aria-hidden="true" />
{t.label}
</button>
))}
</nav>
{tab === "dia" && (
<div>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border bg-surface p-4">
<p className="text-xs text-muted">Ventas de hoy</p>
<p className="text-lg font-semibold tabular-nums text-foreground">{todayCarts.length}</p>
</div>
<div className="rounded-2xl border border-border bg-accent-soft p-4">
<p className="text-xs text-accent-soft-foreground">Total de hoy</p>
<p className="text-lg font-semibold tabular-nums text-accent-soft-foreground">{money(todayTotal)}</p>
</div>
</div>
{todayCarts.length === 0 ? (
<p className="text-sm text-muted">Todavía no hay ventas hoy.</p>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Venta</th>
<th className={thClass}>Cajero</th>
<th className={thClass}>Total</th>
<th className={thClass}>Hora</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{todayCarts.map((c) => (
<tr key={c.id}>
<td className={`${tdClass} font-medium`}>#{c.id}</td>
<td className={`${tdClass} text-muted`}>{userName(c.userId)}</td>
<td className={`${tdClass} tabular-nums`}>{money(c.totalPrice)}</td>
<td className={`${tdClass} text-muted`}>
{new Date(c.createdAt).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" })}
</td>
</tr>
))}
</tbody>
</TableShell>
)}
</div>
)}
{tab === "producto" && (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Producto</th>
<th className={thClass}>Unidades vendidas</th>
<th className={thClass}>Ingresos</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{productAgg.length === 0 ? (
<tr>
<td className={tdClass} colSpan={3}>
<span className="text-muted">Sin ventas registradas.</span>
</td>
</tr>
) : (
productAgg
.sort((a, b) => b.revenue - a.revenue)
.map((p) => (
<tr key={p.productId}>
<td className={`${tdClass} font-medium`}>{productName(p.productId)}</td>
<td className={`${tdClass} tabular-nums`}>{p.quantity}</td>
<td className={`${tdClass} tabular-nums`}>{money(p.revenue)}</td>
</tr>
))
)}
</tbody>
</TableShell>
)}
{tab === "empleado" && (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Empleado</th>
<th className={thClass}>Ventas</th>
<th className={thClass}>Total</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{employeeAgg.length === 0 ? (
<tr>
<td className={tdClass} colSpan={3}>
<span className="text-muted">Sin ventas registradas.</span>
</td>
</tr>
) : (
employeeAgg.map((e) => (
<tr key={e.userId}>
<td className={`${tdClass} font-medium`}>{userName(e.userId)}</td>
<td className={`${tdClass} tabular-nums`}>{e.count}</td>
<td className={`${tdClass} tabular-nums`}>{money(e.total)}</td>
</tr>
))
)}
</tbody>
</TableShell>
)}
{tab === "utilidad" && (
<div>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border bg-surface p-4">
<p className="text-xs text-muted">Ingresos totales</p>
<p className="text-lg font-semibold tabular-nums text-foreground">{money(totalRevenue)}</p>
</div>
<div className="rounded-2xl border border-border bg-accent-soft p-4">
<p className="text-xs text-accent-soft-foreground">Utilidad total</p>
<p className="text-lg font-semibold tabular-nums text-accent-soft-foreground">{money(totalProfit)}</p>
</div>
</div>
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Producto</th>
<th className={thClass}>Ingresos</th>
<th className={thClass}>Utilidad</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{productAgg
.sort((a, b) => b.profit - a.profit)
.map((p) => (
<tr key={p.productId}>
<td className={`${tdClass} font-medium`}>{productName(p.productId)}</td>
<td className={`${tdClass} tabular-nums`}>{money(p.revenue)}</td>
<td className={`${tdClass} tabular-nums text-success-soft-foreground`}>{money(p.profit)}</td>
</tr>
))}
</tbody>
</TableShell>
</div>
)}
{tab === "inventario" && (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Producto</th>
<th className={thClass}>Almacén</th>
<th className={thClass}>Stock</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{warehouses.length === 0 ? (
<tr>
<td className={tdClass} colSpan={3}>
<span className="text-muted">No hay stock cargado.</span>
</td>
</tr>
) : (
warehouses.map((w) => {
const product = products.find((p) => p.id === w.productId);
const low = product && w.stock <= product.minStock;
return (
<tr key={`${w.inventoryId}-${w.productId}`}>
<td className={`${tdClass} font-medium`}>{productName(w.productId)}</td>
<td className={`${tdClass} text-muted`}>
{inventories.find((i) => i.id === w.inventoryId)?.name ?? "—"}
</td>
<td className={`${tdClass} tabular-nums ${low ? "text-danger" : ""}`}>{w.stock}</td>
</tr>
);
})
)}
</tbody>
</TableShell>
)}
{tab === "top" && (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>#</th>
<th className={thClass}>Producto</th>
<th className={thClass}>Unidades vendidas</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{topProducts.length === 0 ? (
<tr>
<td className={tdClass} colSpan={3}>
<span className="text-muted">Sin ventas registradas.</span>
</td>
</tr>
) : (
topProducts.map((p, i) => (
<tr key={p.productId}>
<td className={`${tdClass} text-muted`}>{i + 1}</td>
<td className={`${tdClass} font-medium`}>{productName(p.productId)}</td>
<td className={`${tdClass} tabular-nums`}>{p.quantity}</td>
</tr>
))
)}
</tbody>
</TableShell>
)}
{tab === "pagos" && (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Método</th>
<th className={thClass}>Cobros</th>
<th className={thClass}>Total</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{paymentAgg.length === 0 ? (
<tr>
<td className={tdClass} colSpan={3}>
<span className="text-muted">Sin pagos registrados.</span>
</td>
</tr>
) : (
paymentAgg.map((p) => (
<tr key={p.method}>
<td className={`${tdClass} font-medium`}>{PAYMENT_LABELS[p.method] ?? p.method}</td>
<td className={`${tdClass} tabular-nums`}>{p.count}</td>
<td className={`${tdClass} tabular-nums`}>{money(p.total)}</td>
</tr>
))
)}
</tbody>
</TableShell>
)}
{tab === "cortes" && (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Almacén</th>
<th className={thClass}>Apertura</th>
<th className={thClass}>Cierre</th>
<th className={thClass}>Esperado</th>
<th className={thClass}>Contado</th>
<th className={thClass}>Diferencia</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{closedSessions.length === 0 ? (
<tr>
<td className={tdClass} colSpan={6}>
<span className="text-muted">No hay cortes de caja registrados.</span>
</td>
</tr>
) : (
closedSessions.map((s) => {
const diff = Number(s.closingCash ?? 0) - Number(s.expectedCash ?? 0);
return (
<tr key={s.id}>
<td className={`${tdClass} font-medium`}>
{inventories.find((i) => i.id === s.inventoryId)?.name ?? "—"}
</td>
<td className={`${tdClass} text-muted`}>{new Date(s.openedAt).toLocaleString("es-AR")}</td>
<td className={`${tdClass} text-muted`}>{s.closedAt ? new Date(s.closedAt).toLocaleString("es-AR") : "—"}</td>
<td className={`${tdClass} tabular-nums`}>{money(s.expectedCash ?? 0)}</td>
<td className={`${tdClass} tabular-nums`}>{money(s.closingCash ?? 0)}</td>
<td className={`${tdClass} tabular-nums ${diff === 0 ? "" : diff > 0 ? "text-success-soft-foreground" : "text-danger"}`}>
{diff > 0 ? "+" : ""}
{money(diff)}
</td>
</tr>
);
})
)}
</tbody>
</TableShell>
)}
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { Icon } from "@iconify/react";
import { useState } from "react";
import { iconIdentificationBadge, iconUsersGroup } from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client";
import { usersResource } from "../../_core/services/entities";
import { useSessionUser } from "../../_core/hooks/use-session-user";
import { ConfirmDialog } from "../../_core/components/confirm-dialog";
import { Dialog } from "../../_core/components/dialog";
import { Field, SelectInput, TextInput } from "../../_core/components/form-controls";
import { PageHeader } from "../../_core/components/page-header";
import { TableShell, tdClass, thClass } from "../../_core/components/table-shell";
import type { AppUser } from "../../_core/types/entities";
const ROLE_LABELS: Record<string, string> = {
admin: "Administrador",
cashier: "Cajero",
};
export default function AdminUsersPage() {
const { user: currentUser } = useSessionUser();
const { data: items = [], isLoading } = usersResource.useList();
const createUser = usersResource.useCreate();
const updateUser = usersResource.useUpdate();
const removeUser = usersResource.useRemove();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<AppUser | null>(null);
const [deleting, setDeleting] = useState<AppUser | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const submitting = createUser.isPending || updateUser.isPending;
function openCreate() {
setEditing(null);
setSubmitError(null);
setDialogOpen(true);
}
function openEdit(item: AppUser) {
setEditing(item);
setSubmitError(null);
setDialogOpen(true);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const role = String(formData.get("role") ?? "cashier");
const password = String(formData.get("password") ?? "");
setSubmitError(null);
try {
if (editing) {
const body: Record<string, unknown> = { name, email, role };
if (password) body.password = password;
await updateUser.mutateAsync({ id: editing.id, data: body });
} else {
await createUser.mutateAsync({ name, email, role, password });
}
setDialogOpen(false);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo guardar el usuario.");
}
}
return (
<div>
<PageHeader
title="Usuarios"
description="Cuentas del PDV y sus roles."
action={
<button
type="button"
onClick={openCreate}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Nuevo usuario
</button>
}
/>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : items.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconUsersGroup} width={28} height={28} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<p className="text-sm text-muted">No hay usuarios.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Usuario</th>
<th className={thClass}>Email</th>
<th className={thClass}>Rol</th>
<th className={`${thClass} text-right`}>Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={item.id}>
<td className={`${tdClass} font-medium`}>
<span className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-lg bg-accent-soft">
<Icon
icon={iconIdentificationBadge}
width={15}
height={15}
className="text-accent-soft-foreground"
aria-hidden="true"
/>
</span>
{item.name}
</span>
</td>
<td className={`${tdClass} text-muted`}>{item.email}</td>
<td className={`${tdClass} text-muted`}>{ROLE_LABELS[item.role] ?? item.role}</td>
<td className={`${tdClass} text-right`}>
<button
type="button"
onClick={() => openEdit(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Editar
</button>
<button
type="button"
disabled={item.id === currentUser?.id}
onClick={() => setDeleting(item)}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition hover:bg-danger-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
>
Eliminar
</button>
</td>
</tr>
))}
</tbody>
</TableShell>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} title={editing ? "Editar usuario" : "Nuevo usuario"}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
<Field label="Nombre" htmlFor="user-name" required>
<TextInput id="user-name" name="name" required defaultValue={editing?.name ?? ""} autoFocus />
</Field>
<Field label="Email" htmlFor="user-email" required>
<TextInput id="user-email" name="email" type="email" required defaultValue={editing?.email ?? ""} />
</Field>
<Field label="Rol" htmlFor="user-role" required>
<SelectInput id="user-role" name="role" required defaultValue={editing?.role ?? "cashier"}>
<option value="cashier">Cajero</option>
<option value="admin">Administrador</option>
</SelectInput>
</Field>
<Field label={editing ? "Nueva contraseña" : "Contraseña"} htmlFor="user-password" required={!editing}>
<TextInput
id="user-password"
name="password"
type="password"
required={!editing}
autoComplete="new-password"
placeholder={editing ? "Dejar vacío para no cambiarla" : undefined}
/>
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setDialogOpen(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50"
>
{submitting ? "Guardando…" : "Guardar"}
</button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
title="Eliminar usuario"
description={`¿Seguro que querés eliminar a "${deleting?.name}"? Esta acción no se puede deshacer.`}
onConfirm={async () => {
if (!deleting) return;
await removeUser.mutateAsync(deleting.id);
}}
/>
</div>
);
}
+5 -20
View File
@@ -1,26 +1,11 @@
@import "@heroui/react/styles";
:root {
--background: #ffffff;
--foreground: #171717;
}
/*
* background/foreground/surface/etc. are defined by HeroUI per theme
* (:root and .light for light, .dark/[data-theme=dark] for dark).
* Do not redeclare them here — the theme provider toggles the `dark` class on <html>.
*/
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+5 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -21,9 +22,12 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="flex min-h-full flex-col bg-background text-foreground">
<Providers>{children}</Providers>
</body>
</html>
);
}
@@ -0,0 +1,96 @@
"use client";
import { Avatar, Button } from "@heroui/react";
import { Icon } from "@iconify/react";
import { useEffect, useRef, useState } from "react";
import { iconArrowLeft, iconEye, iconEyeSlash, iconIdentificationBadge } from "../../_core/icons";
import type { AuthUser } from "../../_core/types/auth-user";
interface PasswordFormProps {
user: AuthUser;
error: string | null;
submitting: boolean;
onSubmit: (password: string) => void;
onBack: () => void;
}
export function PasswordForm({ user, error, submitting, onSubmit, onBack }: PasswordFormProps) {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [prevError, setPrevError] = useState(error);
const passwordRef = useRef<HTMLInputElement>(null);
if (error !== prevError) {
setPrevError(error);
if (error) setPassword("");
}
useEffect(() => {
passwordRef.current?.focus();
}, [error]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit(password);
}
return (
<form onSubmit={handleSubmit} className="flex w-full max-w-xs flex-col items-center gap-4" noValidate>
<Avatar size="lg" className="size-24 rounded-full text-2xl">
<Avatar.Fallback>
<Icon icon={iconIdentificationBadge} width={32} height={32} aria-hidden="true" />
</Avatar.Fallback>
</Avatar>
<p className="text-lg font-medium text-foreground">{user.name}</p>
<div className="w-full">
<label htmlFor="password" className="sr-only">
Contraseña
</label>
<div className="relative">
<input
ref={passwordRef}
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Contraseña"
aria-invalid={error ? true : undefined}
aria-describedby={error ? "password-error" : undefined}
className="w-full rounded-field border border-field-border bg-field px-4 py-2.5 pr-11 text-center text-field-foreground placeholder-field-placeholder outline-none transition focus:border-field-border-focus aria-[invalid=true]:border-danger"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Ocultar contraseña" : "Mostrar contraseña"}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center text-muted transition hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Icon icon={showPassword ? iconEyeSlash : iconEye} width={18} height={18} aria-hidden="true" />
</button>
</div>
</div>
{error && (
<p id="password-error" role="alert" className="text-sm text-danger">
{error}
</p>
)}
<Button type="submit" className="rounded-sm" fullWidth isDisabled={submitting || !password}>
{submitting ? "Entrando…" : "Entrar"}
</Button>
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm text-muted transition hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Icon icon={iconArrowLeft} width={14} height={14} aria-hidden="true" />
Cambiar de usuario
</button>
</form>
);
}
@@ -0,0 +1,111 @@
"use client";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { iconStorefrontFill } from "../../_core/icons";
import { fetchUsers, login } from "../_services/auth.service";
import type { AuthUser } from "../../_core/types/auth-user";
import { PasswordForm } from "./password-form";
import { UserPicker } from "./user-picker";
export function UserLoginScreen() {
const router = useRouter();
const reduceMotion = useReducedMotion();
const [users, setUsers] = useState<AuthUser[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [selected, setSelected] = useState<AuthUser | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetchUsers()
.then(setUsers)
.catch(() => setLoadError("No se pudo cargar la lista de usuarios."));
}, []);
function selectUser(user: AuthUser) {
setSelected(user);
setError(null);
}
function backToPicker() {
setSelected(null);
setError(null);
}
async function handleSubmit(password: string) {
if (!selected) return;
setSubmitting(true);
setError(null);
try {
const user = await login(selected.id, password);
sessionStorage.setItem("pdv:user", JSON.stringify(user));
router.push(user.role === "admin" ? "/admin" : "/pos");
} catch {
setError("Contraseña incorrecta. Intenta de nuevo.");
} finally {
setSubmitting(false);
}
}
const step = selected ? "password" : "picker";
const slide = reduceMotion ? 0 : 16;
return (
<div className="flex min-h-dvh w-full items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground">
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
<div className="flex flex-col items-center gap-2">
<div className="flex size-12 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconStorefrontFill} width={24} height={24} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.h1
key={step}
initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="text-2xl font-light tracking-wide text-foreground/80"
>
{step === "password" ? "Ingresa tu contraseña" : "¿Quién va a usar el PDV?"}
</motion.h1>
</AnimatePresence>
</div>
<AnimatePresence mode="wait" initial={false}>
{step === "picker" ? (
<motion.div
key="picker"
initial={{ opacity: 0, x: -slide }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -slide }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="flex w-full justify-center"
>
<UserPicker users={users} loadError={loadError} onSelect={selectUser} />
</motion.div>
) : (
<motion.div
key="password"
initial={{ opacity: 0, x: slide }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: slide }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="flex w-full justify-center"
>
<PasswordForm
user={selected as AuthUser}
error={error}
submitting={submitting}
onSubmit={handleSubmit}
onBack={backToPicker}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import { Avatar } from "@heroui/react";
import { Icon } from "@iconify/react";
import { motion, useReducedMotion } from "framer-motion";
import { iconIdentificationBadge } from "../../_core/icons";
import type { AuthUser } from "../../_core/types/auth-user";
interface UserPickerProps {
users: AuthUser[] | null;
loadError: string | null;
onSelect: (user: AuthUser) => void;
}
export function UserPicker({ users, loadError, onSelect }: UserPickerProps) {
const reduceMotion = useReducedMotion();
return (
<div className="flex w-full flex-col items-center gap-8">
{loadError && (
<div
role="alert"
className="rounded-lg border border-danger/30 bg-danger-soft px-4 py-2 text-sm text-danger"
>
{loadError}
</div>
)}
{!users && !loadError && (
<div
className="flex flex-wrap justify-center gap-8"
aria-live="polite"
aria-label="Cargando usuarios"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex w-28 flex-col items-center gap-3 p-3">
<div className="size-20 animate-pulse rounded-full bg-foreground/10 motion-reduce:animate-none" />
<div className="h-3.5 w-16 animate-pulse rounded bg-foreground/10 motion-reduce:animate-none" />
</div>
))}
</div>
)}
{users && users.length === 0 && !loadError && (
<p className="text-sm text-muted">No hay usuarios disponibles.</p>
)}
<div className="flex flex-wrap justify-center gap-4 sm:gap-8">
{users?.map((user, i) => (
<motion.button
key={user.id}
type="button"
onClick={() => onSelect(user)}
initial={{ opacity: 0, y: reduceMotion ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: reduceMotion ? 0 : i * 0.04, ease: "easeOut" }}
className="group flex w-28 flex-col items-center gap-3 rounded-xl p-3 transition duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Avatar
size="lg"
className="size-20 scale-100 rounded-full text-xl ring-2 ring-transparent transition duration-200 ease-out group-hover:scale-110 group-hover:ring-accent group-focus-visible:scale-110 group-active:scale-95 motion-reduce:transition-none motion-reduce:group-hover:scale-100 motion-reduce:group-focus-visible:scale-100 motion-reduce:group-active:scale-100"
>
<Avatar.Fallback>
<Icon icon={iconIdentificationBadge} width={28} height={28} aria-hidden="true" />
</Avatar.Fallback>
</Avatar>
<span className="text-center text-sm font-medium text-foreground/80 group-hover:text-foreground">
{user.name}
</span>
</motion.button>
))}
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import type { AuthUser } from "../../_core/types/auth-user";
export async function fetchUsers(): Promise<AuthUser[]> {
const res = await fetch("/api/users", { cache: "no-store" });
if (!res.ok) {
throw new Error("No se pudo cargar la lista de usuarios");
}
return res.json();
}
export async function login(id: number, password: string): Promise<AuthUser> {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, password }),
});
if (!res.ok) {
throw new Error("Contraseña incorrecta");
}
return res.json();
}
+5
View File
@@ -0,0 +1,5 @@
import { UserLoginScreen } from "./_components/user-login-screen";
export default function LoginPage() {
return <UserLoginScreen />;
}
+15 -1
View File
@@ -1,3 +1,17 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useSessionUser } from "./_core/hooks/use-session-user";
export default function Home() {
return <></>;
const router = useRouter();
const { user } = useSessionUser();
useEffect(() => {
if (!user) return;
router.replace(user.role === "admin" ? "/admin" : "/pos");
}, [user, router]);
return null;
}
@@ -0,0 +1,343 @@
"use client";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import {
iconAddCircle,
iconCheckCircle,
iconChevronDown,
iconMinusCircle,
iconScale,
iconWalletMoney,
} from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client";
import { cashMovementsResource, cashSessionsResource, inventoriesResource } from "../../_core/services/entities";
import { useSessionUser } from "../../_core/hooks/use-session-user";
import { Dialog } from "../../_core/components/dialog";
import { Field, SelectInput, TextareaInput, TextInput } from "../../_core/components/form-controls";
import { useCashSession } from "./cash-session-context";
function money(value: string | number) {
return Number(value).toLocaleString("es-AR", { style: "currency", currency: "USD" });
}
export function CashRegisterControl() {
const { user } = useSessionUser();
const { session, refresh } = useCashSession();
const reduceMotion = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const { data: inventories = [] } = inventoriesResource.useList();
const { data: movements = [] } = cashMovementsResource.useList();
const createSession = cashSessionsResource.useCreate();
const updateSession = cashSessionsResource.useUpdate();
const createMovement = cashMovementsResource.useCreate();
const [openDialog, setOpenDialog] = useState(false);
const [movementDialog, setMovementDialog] = useState<"in" | "out" | null>(null);
const [closeDialog, setCloseDialog] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const submitting = createSession.isPending || updateSession.isPending || createMovement.isPending;
const sessionMovements = session ? movements.filter((m) => m.sessionId === session.id) : [];
useEffect(() => {
if (!menuOpen) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setMenuOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [menuOpen]);
const totalIn = sessionMovements.filter((m) => m.type === "in").reduce((sum, m) => sum + Number(m.amount), 0);
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>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const inventoryId = Number(formData.get("inventoryId"));
const openingCash = String(formData.get("openingCash") ?? "0");
setSubmitError(null);
try {
await createSession.mutateAsync({ inventoryId, openedById: user?.id, openingCash });
setOpenDialog(false);
refresh();
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo abrir la caja.");
}
}
async function handleMovement(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!session || !movementDialog) return;
const formData = new FormData(e.currentTarget);
const amount = String(formData.get("amount") ?? "0");
const reason = String(formData.get("reason") ?? "").trim();
setSubmitError(null);
try {
await createMovement.mutateAsync({
sessionId: session.id,
type: movementDialog,
amount,
reason: reason || null,
userId: user?.id,
});
setMovementDialog(null);
setMenuOpen(false);
refresh();
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo registrar el movimiento.");
}
}
async function handleClose(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!session) return;
const formData = new FormData(e.currentTarget);
const closingCash = String(formData.get("closingCash") ?? "0");
setSubmitError(null);
try {
await updateSession.mutateAsync({
id: session.id,
data: {
status: "closed",
closingCash,
expectedCash: String(expectedCash),
closedById: user?.id,
},
});
setCloseDialog(false);
setMenuOpen(false);
refresh();
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo cerrar la caja.");
}
}
if (session === undefined) return null;
if (session === null) {
return (
<>
<button
type="button"
onClick={() => {
setSubmitError(null);
setOpenDialog(true);
}}
className="flex h-11 items-center gap-2 rounded-lg bg-accent px-3.5 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Icon icon={iconWalletMoney} width={16} height={16} aria-hidden="true" />
<span className="hidden sm:inline">Abrir caja</span>
</button>
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} title="Abrir caja">
<form onSubmit={handleOpen} className="flex flex-col gap-4" noValidate>
<Field label="Almacén" htmlFor="cash-inventory" required>
<SelectInput id="cash-inventory" name="inventoryId" required autoFocus>
{inventories.map((inv) => (
<option key={inv.id} value={inv.id}>
{inv.name}
</option>
))}
</SelectInput>
</Field>
<Field label="Fondo inicial" htmlFor="cash-opening" required>
<TextInput id="cash-opening" name="openingCash" type="number" min={0} step="0.01" defaultValue={0} required />
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setOpenDialog(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
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"
>
{submitting ? "Abriendo…" : "Abrir caja"}
</button>
</div>
</form>
</Dialog>
</>
);
}
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={menuOpen}
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm font-medium text-foreground transition hover:bg-foreground/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<span className="flex size-2 shrink-0 rounded-full bg-success" aria-hidden="true" />
<Icon icon={iconWalletMoney} width={16} height={16} aria-hidden="true" />
<span className="hidden sm:inline">Caja abierta</span>
<Icon
icon={iconChevronDown}
width={14}
height={14}
className={`shrink-0 text-muted transition-transform ${menuOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
<AnimatePresence>
{menuOpen && (
<motion.div
role="menu"
aria-label="Caja"
initial={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.97 }}
transition={{ duration: reduceMotion ? 0 : 0.15, ease: "easeOut" }}
className="absolute right-0 top-[calc(100%+8px)] z-50 w-64 overflow-hidden rounded-xl border border-border bg-surface shadow-lg"
>
<div className="border-b border-border px-3 py-2.5">
<p className="text-xs text-muted">Esperado en caja</p>
<p className="text-lg font-semibold tabular-nums text-foreground">{money(expectedCash)}</p>
</div>
<button
type="button"
role="menuitem"
onClick={() => {
setSubmitError(null);
setMovementDialog("in");
}}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm text-foreground transition hover:bg-foreground/5"
>
<Icon icon={iconAddCircle} width={16} height={16} aria-hidden="true" />
Entrada de efectivo
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setSubmitError(null);
setMovementDialog("out");
}}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm text-foreground transition hover:bg-foreground/5"
>
<Icon icon={iconMinusCircle} width={16} height={16} aria-hidden="true" />
Salida de efectivo
</button>
<div className="border-t border-border">
<button
type="button"
role="menuitem"
onClick={() => {
setSubmitError(null);
setCloseDialog(true);
}}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm text-danger transition hover:bg-danger-soft"
>
<Icon icon={iconScale} width={16} height={16} aria-hidden="true" />
Cerrar caja
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<Dialog
open={movementDialog !== null}
onClose={() => setMovementDialog(null)}
title={movementDialog === "in" ? "Entrada de efectivo" : "Salida de efectivo"}
>
<form onSubmit={handleMovement} className="flex flex-col gap-4" noValidate>
<Field label="Monto" htmlFor="movement-amount" required>
<TextInput id="movement-amount" name="amount" type="number" min={0.01} step="0.01" required autoFocus />
</Field>
<Field label="Motivo" htmlFor="movement-reason">
<TextareaInput id="movement-reason" name="reason" rows={2} placeholder="Ej: cambio, retiro, depósito" />
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setMovementDialog(null)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
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"
>
{submitting ? "Guardando…" : "Registrar"}
</button>
</div>
</form>
</Dialog>
<Dialog open={closeDialog} onClose={() => setCloseDialog(false)} title="Cerrar caja — Arqueo">
<form onSubmit={handleClose} className="flex flex-col gap-4" noValidate>
<div className="rounded-lg bg-accent-soft p-3 text-sm text-accent-soft-foreground">
<Icon icon={iconScale} width={16} height={16} className="mb-1 inline" aria-hidden="true" /> Esperado
según el sistema: <strong>{money(expectedCash)}</strong>
</div>
<Field label="Efectivo contado" htmlFor="closing-cash" required>
<TextInput id="closing-cash" name="closingCash" type="number" min={0} step="0.01" required autoFocus />
</Field>
{submitError && (
<p role="alert" className="text-sm text-danger">
{submitError}
</p>
)}
<div className="mt-1 flex justify-end gap-2">
<button
type="button"
onClick={() => setCloseDialog(false)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="flex items-center gap-1.5 rounded-lg bg-danger px-3.5 py-2 text-sm font-medium text-danger-foreground transition hover:bg-danger-hover disabled:opacity-50"
>
<Icon icon={iconCheckCircle} width={16} height={16} aria-hidden="true" />
{submitting ? "Cerrando…" : "Confirmar cierre"}
</button>
</div>
</form>
</Dialog>
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { createContext, useContext } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { cashSessionsResource } from "../../_core/services/entities";
import type { CashSession } from "../../_core/types/entities";
export type { CashSession };
interface CashSessionContextValue {
session: CashSession | null | undefined;
refresh: () => void;
}
const CashSessionContext = createContext<CashSessionContextValue | null>(null);
export function CashSessionProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const { data: sessions } = cashSessionsResource.useList();
const session = sessions === undefined ? undefined : (sessions.find((s) => s.status === "open") ?? null);
function refresh() {
queryClient.invalidateQueries({ queryKey: cashSessionsResource.key });
}
return <CashSessionContext.Provider value={{ session, refresh }}>{children}</CashSessionContext.Provider>;
}
export function useCashSession() {
const ctx = useContext(CashSessionContext);
if (!ctx) throw new Error("useCashSession debe usarse dentro de CashSessionProvider");
return ctx;
}
@@ -0,0 +1,92 @@
"use client";
import { Icon } from "@iconify/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { iconArrowLeft, iconCart, iconClockCircle, iconLogout, iconStorefrontFill } from "../../_core/icons";
import { OperatingDayBadge } from "../../_core/components/operating-day-badge";
import { ThemeToggle } from "../../_core/components/theme-toggle";
import { useSessionUser } from "../../_core/hooks/use-session-user";
import { CashRegisterControl } from "./cash-register-control";
import { CashSessionProvider } from "./cash-session-context";
const NAV_ITEMS = [
{ href: "/pos", label: "Venta", icon: iconCart },
{ href: "/pos/historial", label: "Historial", icon: iconClockCircle },
] as const;
function isActive(pathname: string, href: string) {
if (href === "/pos") return pathname === "/pos";
return pathname.startsWith(href);
}
export function PosShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user, logout } = useSessionUser();
if (!user) return null;
return (
<CashSessionProvider>
<div className="flex h-dvh w-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-surface px-3 py-2 sm:px-5 sm:py-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent-soft">
<Icon icon={iconStorefrontFill} width={22} height={22} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
{user.role === "admin" && (
<Link
href="/admin"
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Icon icon={iconArrowLeft} width={16} height={16} aria-hidden="true" />
<span className="hidden sm:inline">Panel de administración</span>
</Link>
)}
<nav aria-label="Principal" className="flex flex-1 items-center gap-1 overflow-x-auto sm:gap-2">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={`flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus sm:px-4 ${
active
? "bg-accent-soft text-accent-soft-foreground"
: "text-muted hover:bg-foreground/5 hover:text-foreground"
}`}
>
<Icon icon={item.icon} width={20} height={20} aria-hidden="true" />
<span className="hidden sm:inline">{item.label}</span>
</Link>
);
})}
</nav>
<OperatingDayBadge />
<div className="hidden flex-col items-end leading-tight sm:flex">
<span className="text-sm font-medium text-foreground">{user.name}</span>
<span className="text-xs text-muted">{user.role === "admin" ? "Administrador" : "Cajero"}</span>
</div>
<CashRegisterControl />
<ThemeToggle />
<button
type="button"
onClick={logout}
aria-label="Cerrar sesión"
className="flex size-11 shrink-0 items-center justify-center rounded-lg text-muted transition hover:bg-danger-soft hover:text-danger focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
>
<Icon icon={iconLogout} width={20} height={20} aria-hidden="true" />
</button>
</header>
<main className="flex-1 overflow-y-auto p-3 sm:p-6">{children}</main>
</div>
</CashSessionProvider>
);
}
+251
View File
@@ -0,0 +1,251 @@
"use client";
import { Icon } from "@iconify/react";
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { iconClockCircle, iconPrinter, iconUndo } from "../../_core/icons";
import { apiClient } from "../../_core/services/api-client";
import {
cartProductsResource,
cashMovementsResource,
cashSessionsResource,
customersResource,
inventoryMovementsResource,
paymentsResource,
productHistoryResource,
productsResource,
shopCartsResource,
usersResource,
} from "../../_core/services/entities";
import { ConfirmDialog } from "../../_core/components/confirm-dialog";
import { Dialog } from "../../_core/components/dialog";
import { TableShell, tdClass, thClass } from "../../_core/components/table-shell";
import type { ShopCart } from "../../_core/types/entities";
const PAYMENT_LABELS: Record<string, string> = { cash: "Efectivo", card: "Tarjeta", transfer: "Transferencia" };
function money(value: string | number) {
return Number(value).toLocaleString("es-AR", { style: "currency", currency: "USD" });
}
export default function PosHistoryPage() {
const queryClient = useQueryClient();
const { data: cartsRaw, isLoading } = shopCartsResource.useList();
const carts = useMemo(
() => (cartsRaw ? [...cartsRaw].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : isLoading ? null : []),
[cartsRaw, isLoading],
);
const { data: cartProducts = [] } = cartProductsResource.useList();
const { data: history = [] } = productHistoryResource.useList();
const { data: products = [] } = productsResource.useList();
const { data: payments = [] } = paymentsResource.useList();
const { data: users = [] } = usersResource.useList();
const { data: customers = [] } = customersResource.useList();
const { data: sessions = [] } = cashSessionsResource.useList();
const [selected, setSelected] = useState<ShopCart | null>(null);
const [returning, setReturning] = useState<ShopCart | null>(null);
function refreshAfterReturn() {
queryClient.invalidateQueries({ queryKey: shopCartsResource.key });
queryClient.invalidateQueries({ queryKey: inventoryMovementsResource.key });
queryClient.invalidateQueries({ queryKey: paymentsResource.key });
queryClient.invalidateQueries({ queryKey: cashMovementsResource.key });
queryClient.invalidateQueries({ queryKey: cartProductsResource.key });
}
function linesFor(cartId: number) {
return cartProducts
.filter((cp) => cp.cartId === cartId)
.map((cp) => {
const h = history.find((row) => row.id === cp.historyId);
const product = h ? products.find((p) => p.id === h.productId) : undefined;
return {
productId: h?.productId ?? 0,
name: product?.name ?? "Producto",
unitPrice: h ? Number(h.unitPrice) : 0,
quantity: cp.quantity,
};
});
}
function cashierName(userId: number | null) {
return users.find((u) => u.id === userId)?.name ?? "—";
}
function customerName(customerId: number | null) {
if (!customerId) return "Cliente mostrador";
return customers.find((c) => c.id === customerId)?.name ?? "Cliente mostrador";
}
function paymentFor(cartId: number) {
return payments.find((p) => p.cartId === cartId) ?? null;
}
const visibleCarts = useMemo(
() => carts?.filter((c) => c.status === "paid" || c.status === "refunded") ?? [],
[carts],
);
async function handleReturn() {
if (!returning) return;
const lines = linesFor(returning.id);
for (const line of lines) {
const warehouses = await apiClient.get<{ inventoryId: number; productId: number }[]>("/warehouses");
const inventoryId = warehouses.find((w) => w.productId === line.productId)?.inventoryId;
if (!inventoryId) continue;
await apiClient.post("/inventory-movements", {
productId: line.productId,
inventoryId,
type: "in",
quantity: line.quantity,
reason: `Devolución venta #${returning.id}`,
});
}
const original = paymentFor(returning.id);
await apiClient.post("/payments", {
cartId: returning.id,
userId: original?.id ?? 0,
amount: `-${returning.totalPrice}`,
status: "refunded",
paid: true,
paymentMethod: original?.paymentMethod ?? "cash",
paidAt: new Date().toISOString(),
});
if ((original?.paymentMethod ?? "cash") === "cash") {
const openSession = sessions.find((s) => s.status === "open");
if (openSession) {
await apiClient.post("/cash-movements", {
sessionId: openSession.id,
type: "out",
amount: returning.totalPrice,
reason: `Devolución venta #${returning.id}`,
});
}
}
await apiClient.patch(`/shop-carts/${returning.id}`, { status: "refunded" });
setReturning(null);
setSelected(null);
refreshAfterReturn();
}
return (
<div>
<h1 className="mb-1 text-xl font-semibold text-foreground">Historial de ventas</h1>
<p className="mb-6 text-sm text-muted">Ventas cobradas, reimpresión de tickets y devoluciones.</p>
{carts === null ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : visibleCarts.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<Icon icon={iconClockCircle} width={28} height={28} className="text-muted" aria-hidden="true" />
<p className="text-sm text-muted">Todavía no hay ventas registradas.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Venta</th>
<th className={thClass}>Cliente</th>
<th className={thClass}>Cajero</th>
<th className={thClass}>Total</th>
<th className={thClass}>Estado</th>
<th className={thClass}>Fecha</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{visibleCarts.map((cart) => (
<tr
key={cart.id}
onClick={() => setSelected(cart)}
className="cursor-pointer transition hover:bg-foreground/5"
>
<td className={`${tdClass} font-medium`}>#{cart.id}</td>
<td className={`${tdClass} text-muted`}>{customerName(cart.customerId)}</td>
<td className={`${tdClass} text-muted`}>{cashierName(cart.userId)}</td>
<td className={`${tdClass} tabular-nums`}>{money(cart.totalPrice)}</td>
<td className={tdClass}>
<span
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${
cart.status === "refunded" ? "bg-danger-soft text-danger" : "bg-success-soft text-success-soft-foreground"
}`}
>
{cart.status === "refunded" ? "Devuelta" : "Pagada"}
</span>
</td>
<td className={`${tdClass} text-muted`}>
{new Date(cart.createdAt).toLocaleString("es-AR", { dateStyle: "short", timeStyle: "short" })}
</td>
</tr>
))}
</tbody>
</TableShell>
)}
<Dialog open={selected !== null} onClose={() => setSelected(null)} title={`Venta #${selected?.id ?? ""}`} widthClassName="max-w-sm">
{selected && (
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-dashed border-border p-4 text-sm">
<p className="text-center text-xs text-muted">
{new Date(selected.createdAt).toLocaleString("es-AR")}
</p>
<p className="text-center text-xs text-muted">{customerName(selected.customerId)}</p>
<div className="my-3 flex flex-col gap-1 border-y border-dashed border-border py-3">
{linesFor(selected.id).map((l, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="text-foreground">
{l.quantity} × {l.name}
</span>
<span className="tabular-nums text-foreground">{money(l.unitPrice * l.quantity)}</span>
</div>
))}
</div>
<div className="flex justify-between text-xs text-muted">
<span>Descuento</span>
<span className="tabular-nums">-{money(selected.discount)}</span>
</div>
<div className="flex justify-between text-sm font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{money(selected.totalPrice)}</span>
</div>
<p className="mt-2 text-center text-xs text-muted">
{paymentFor(selected.id) ? PAYMENT_LABELS[paymentFor(selected.id)!.paymentMethod] : ""}
</p>
</div>
<div className="flex justify-end gap-2">
{selected.status === "paid" && (
<button
type="button"
onClick={() => setReturning(selected)}
className="flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium text-danger transition hover:bg-danger-soft"
>
<Icon icon={iconUndo} width={16} height={16} aria-hidden="true" />
Devolver
</button>
)}
<button
type="button"
onClick={() => window.print()}
className="flex items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover"
>
<Icon icon={iconPrinter} width={16} height={16} aria-hidden="true" />
Reimprimir
</button>
</div>
</div>
)}
</Dialog>
<ConfirmDialog
open={returning !== null}
onClose={() => setReturning(null)}
title="Devolver venta"
description={`Se repondrá el stock de la venta #${returning?.id} y se registrará el reembolso. Esta acción no se puede deshacer.`}
confirmLabel="Confirmar devolución"
onConfirm={handleReturn}
/>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PosShell } from "./_components/pos-shell";
export default function PosLayout({ children }: { children: React.ReactNode }) {
return <PosShell>{children}</PosShell>;
}
+571
View File
@@ -0,0 +1,571 @@
"use client";
import { Icon } from "@iconify/react";
import { Kbd } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
iconBarcode,
iconBox,
iconCart,
iconCloseSquare,
iconMinusCircle,
iconPrinter,
iconWalletMoney,
} from "../_core/icons";
import { apiClient, ApiError } from "../_core/services/api-client";
import {
cartProductsResource,
cashMovementsResource,
customersResource,
inventoryMovementsResource,
paymentsResource,
productHistoryResource,
productsResource,
shopCartsResource,
warehousesResource,
} 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 { useCashSession } from "./_components/cash-session-context";
interface CartLine {
productId: number;
name: string;
sku: string;
historyId: number;
unitPrice: number;
quantity: number;
}
interface CompletedSale {
cartId: number;
lines: CartLine[];
subtotal: number;
discount: number;
total: number;
paymentMethod: string;
customerName: string | null;
createdAt: string;
}
const PAYMENT_METHODS = [
{ value: "cash", label: "Efectivo" },
{ value: "card", label: "Tarjeta" },
{ value: "transfer", label: "Transferencia" },
];
function money(value: number) {
return value.toLocaleString("es-AR", { style: "currency", currency: "USD" });
}
export default function PosSalePage() {
const { user } = useSessionUser();
const { session, refresh: refreshSession } = useCashSession();
const queryClient = useQueryClient();
const { data: products = [] } = productsResource.useList();
const { data: history = [] } = productHistoryResource.useList();
const { data: warehouses = [] } = warehousesResource.useList();
const { data: customers = [] } = customersResource.useList();
const [cart, setCart] = useState<CartLine[]>([]);
const [query, setQuery] = useState("");
const [barcode, setBarcode] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [discount, setDiscount] = useState(0);
const [customerId, setCustomerId] = useState<string>("");
const [paymentMethod, setPaymentMethod] = useState("cash");
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [checkingOut, setCheckingOut] = useState(false);
const [lastSale, setLastSale] = useState<CompletedSale | null>(null);
const barcodeRef = useRef<HTMLInputElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const isShortcut = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (!isShortcut) return;
e.preventDefault();
searchRef.current?.focus();
searchRef.current?.select();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
function invalidateSaleData() {
queryClient.invalidateQueries({ queryKey: shopCartsResource.key });
queryClient.invalidateQueries({ queryKey: cartProductsResource.key });
queryClient.invalidateQueries({ queryKey: inventoryMovementsResource.key });
queryClient.invalidateQueries({ queryKey: paymentsResource.key });
queryClient.invalidateQueries({ queryKey: cashMovementsResource.key });
queryClient.invalidateQueries({ queryKey: warehousesResource.key });
}
const activePriceByProduct = useMemo(() => {
const map = new Map<number, ProductHistoryRow>();
for (const row of history) {
if (row.status !== "active") continue;
const existing = map.get(row.productId);
if (!existing || row.createdAt > existing.createdAt) map.set(row.productId, row);
}
return map;
}, [history]);
const stockByProduct = useMemo(() => {
const map = new Map<number, number>();
if (!session) return map;
for (const w of warehouses) {
if (w.inventoryId === session.inventoryId) map.set(w.productId, w.stock);
}
return map;
}, [warehouses, session]);
const inCartQty = useMemo(() => {
const map = new Map<number, number>();
for (const line of cart) map.set(line.productId, (map.get(line.productId) ?? 0) + line.quantity);
return map;
}, [cart]);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return products
.filter((p) => p.name.toLowerCase().includes(q) || p.sku.toLowerCase().includes(q))
.slice(0, 8);
}, [products, query]);
function addProduct(product: Product, quantity = 1) {
setAddError(null);
const price = activePriceByProduct.get(product.id);
if (!price) {
setAddError(`"${product.name}" no tiene un precio configurado.`);
return;
}
const stock = stockByProduct.get(product.id) ?? 0;
const alreadyInCart = inCartQty.get(product.id) ?? 0;
if (alreadyInCart + quantity > stock) {
setAddError(`Stock insuficiente de "${product.name}" (disponible: ${stock}).`);
return;
}
setCart((prev) => {
const existing = prev.find((l) => l.productId === product.id);
if (existing) {
return prev.map((l) => (l.productId === product.id ? { ...l, quantity: l.quantity + quantity } : l));
}
return [
...prev,
{
productId: product.id,
name: product.name,
sku: product.sku,
historyId: price.id,
unitPrice: Number(price.unitPrice),
quantity,
},
];
});
setQuery("");
}
function handleBarcodeSubmit(e: React.FormEvent) {
e.preventDefault();
const code = barcode.trim();
if (!code) return;
const product = products.find((p) => p.barcode === code);
if (!product) {
setAddError(`No se encontró ningún producto con el código "${code}".`);
} else {
addProduct(product);
}
setBarcode("");
barcodeRef.current?.focus();
}
function updateQuantity(productId: number, quantity: number) {
if (quantity <= 0) {
setCart((prev) => prev.filter((l) => l.productId !== productId));
return;
}
const stock = stockByProduct.get(productId) ?? 0;
if (quantity > stock) {
setAddError(`Stock insuficiente (disponible: ${stock}).`);
return;
}
setAddError(null);
setCart((prev) => prev.map((l) => (l.productId === productId ? { ...l, quantity } : l)));
}
function removeLine(productId: number) {
setCart((prev) => prev.filter((l) => l.productId !== productId));
}
function clearCart() {
setCart([]);
setDiscount(0);
setCustomerId("");
setCheckoutError(null);
}
const subtotal = cart.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0);
const total = Math.max(0, subtotal - discount);
async function handleCheckout() {
if (!session || !user || cart.length === 0) return;
setCheckingOut(true);
setCheckoutError(null);
try {
const cartRecord = await apiClient.post<{ id: number }>("/shop-carts", {
totalPrice: total.toFixed(2),
discount: discount.toFixed(2),
status: "open",
userId: user.id,
customerId: customerId ? Number(customerId) : null,
});
for (const line of cart) {
await apiClient.post("/cart-products", {
cartId: cartRecord.id,
historyId: line.historyId,
quantity: line.quantity,
});
await apiClient.post("/inventory-movements", {
productId: line.productId,
inventoryId: session.inventoryId,
type: "out",
quantity: line.quantity,
reason: `Venta #${cartRecord.id}`,
});
}
await apiClient.post("/payments", {
cartId: cartRecord.id,
userId: user.id,
amount: total.toFixed(2),
status: "completed",
paid: true,
paymentMethod,
paidAt: new Date().toISOString(),
});
if (paymentMethod === "cash") {
await apiClient.post("/cash-movements", {
sessionId: session.id,
type: "in",
amount: total.toFixed(2),
reason: `Venta #${cartRecord.id}`,
userId: user.id,
});
}
await apiClient.patch(`/shop-carts/${cartRecord.id}`, { status: "paid" });
setLastSale({
cartId: cartRecord.id,
lines: cart,
subtotal,
discount,
total,
paymentMethod,
customerName: customerId ? customers.find((c) => String(c.id) === customerId)?.name ?? null : null,
createdAt: new Date().toISOString(),
});
clearCart();
invalidateSaleData();
refreshSession();
} catch (err) {
setCheckoutError(err instanceof ApiError ? err.message : "No se pudo completar la venta.");
} finally {
setCheckingOut(false);
}
}
if (session === undefined) {
return <div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>;
}
if (session === null) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconWalletMoney} width={32} height={32} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">No hay una caja abierta</h1>
<p className="text-sm text-muted">Abrí la caja desde el botón en la parte superior antes de vender.</p>
</div>
</div>
);
}
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_380px]">
<div className="flex flex-col gap-4">
<form onSubmit={handleBarcodeSubmit} className="flex gap-2">
<div className="relative flex-1">
<Icon
icon={iconBarcode}
width={18}
height={18}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
ref={barcodeRef}
type="text"
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
placeholder="Escanear o escribir código de barras…"
autoFocus
className="w-full rounded-field border border-border bg-background py-2.5 pl-10 pr-3 text-sm text-foreground placeholder-muted outline-none transition focus:border-focus"
/>
</div>
<button
type="submit"
className="rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover"
>
Agregar
</button>
</form>
<div className="relative">
<TextInput
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar producto por nombre o SKU…"
className="pr-16"
/>
<div className="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Kbd className="text-[10px]">
<Kbd.Abbr keyValue="command" />
</Kbd>
<Kbd className="text-[10px]">K</Kbd>
</div>
{results.length > 0 && (
<ul className="absolute left-0 top-full z-20 mt-1.5 w-full overflow-hidden rounded-field border border-border bg-surface shadow-lg">
{results.map((p) => {
const stock = stockByProduct.get(p.id) ?? 0;
const price = activePriceByProduct.get(p.id);
return (
<li key={p.id}>
<button
type="button"
onClick={() => addProduct(p)}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left text-sm transition hover:bg-foreground/5"
>
<span className="flex items-center gap-2.5">
<Icon icon={iconBox} width={16} height={16} className="text-muted" aria-hidden="true" />
<span>
<span className="block text-foreground">{p.name}</span>
<span className="block text-xs text-muted">
{p.sku} · stock: {stock}
</span>
</span>
</span>
<span className="tabular-nums text-foreground">{price ? money(Number(price.unitPrice)) : "Sin precio"}</span>
</button>
</li>
);
})}
</ul>
)}
</div>
{addError && (
<div role="alert" className="rounded-lg border border-danger/30 bg-danger-soft px-4 py-2 text-sm text-danger">
{addError}
</div>
)}
{cart.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<Icon icon={iconCart} width={32} height={32} className="text-muted" aria-hidden="true" />
<p className="text-sm text-muted">El carrito está vacío. Buscá o escaneá un producto.</p>
</div>
) : (
<div className="flex flex-col gap-2">
{cart.map((line) => (
<div key={line.productId} className="flex items-center gap-3 rounded-2xl border border-border bg-surface p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{line.name}</p>
<p className="text-xs text-muted">
{money(line.unitPrice)} c/u · SKU {line.sku}
</p>
</div>
<input
type="number"
min={1}
value={line.quantity}
onChange={(e) => updateQuantity(line.productId, Number(e.target.value))}
className="w-16 rounded-field border border-border bg-background px-2 py-1.5 text-center text-sm tabular-nums text-foreground outline-none focus:border-focus"
/>
<p className="w-20 shrink-0 text-right text-sm font-medium tabular-nums text-foreground">
{money(line.unitPrice * line.quantity)}
</p>
<button
type="button"
onClick={() => removeLine(line.productId)}
aria-label={`Quitar ${line.name}`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted transition hover:bg-danger-soft hover:text-danger"
>
<Icon icon={iconCloseSquare} width={16} height={16} aria-hidden="true" />
</button>
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-surface p-4">
<h2 className="text-sm font-semibold text-foreground">Resumen</h2>
<div>
<label htmlFor="pos-customer" className="mb-1.5 block text-sm font-medium text-foreground">
Cliente
</label>
<SelectInput id="pos-customer" value={customerId} onChange={(e) => setCustomerId(e.target.value)}>
<option value="">Cliente mostrador</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</SelectInput>
</div>
<div>
<label htmlFor="pos-payment" className="mb-1.5 block text-sm font-medium text-foreground">
Método de pago
</label>
<SelectInput id="pos-payment" value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}>
{PAYMENT_METHODS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</SelectInput>
</div>
<div>
<label htmlFor="pos-discount" className="mb-1.5 block text-sm font-medium text-foreground">
Descuento
</label>
<TextInput
id="pos-discount"
type="number"
min={0}
step="0.01"
value={discount}
onChange={(e) => setDiscount(Math.max(0, Number(e.target.value)))}
/>
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3 text-sm">
<div className="flex justify-between text-muted">
<span>Subtotal</span>
<span className="tabular-nums">{money(subtotal)}</span>
</div>
<div className="flex justify-between text-muted">
<span>Descuento</span>
<span className="tabular-nums">-{money(discount)}</span>
</div>
<div className="flex justify-between text-base font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{money(total)}</span>
</div>
</div>
{checkoutError && (
<p role="alert" className="text-sm text-danger">
{checkoutError}
</p>
)}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleCheckout}
disabled={cart.length === 0 || checkingOut}
className="rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-accent-foreground transition hover:bg-accent-hover disabled:opacity-50"
>
{checkingOut ? "Cobrando…" : `Cobrar ${money(total)}`}
</button>
<button
type="button"
onClick={clearCart}
disabled={cart.length === 0}
className="flex items-center justify-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground disabled:opacity-50"
>
<Icon icon={iconMinusCircle} width={15} height={15} aria-hidden="true" />
Vaciar carrito
</button>
</div>
</div>
<Dialog
open={lastSale !== null}
onClose={() => setLastSale(null)}
title="Venta completada"
widthClassName="max-w-sm"
>
{lastSale && (
<div className="flex flex-col gap-4">
<div id="pos-ticket" className="rounded-2xl border border-dashed border-border p-4 text-sm">
<p className="text-center font-semibold text-foreground">Ticket de venta #{lastSale.cartId}</p>
<p className="text-center text-xs text-muted">
{new Date(lastSale.createdAt).toLocaleString("es-AR")}
</p>
{lastSale.customerName && (
<p className="mt-2 text-center text-xs text-muted">Cliente: {lastSale.customerName}</p>
)}
<div className="my-3 flex flex-col gap-1 border-y border-dashed border-border py-3">
{lastSale.lines.map((l) => (
<div key={l.productId} className="flex justify-between text-xs">
<span className="text-foreground">
{l.quantity} × {l.name}
</span>
<span className="tabular-nums text-foreground">{money(l.unitPrice * l.quantity)}</span>
</div>
))}
</div>
<div className="flex flex-col gap-1 text-xs text-muted">
<div className="flex justify-between">
<span>Subtotal</span>
<span className="tabular-nums">{money(lastSale.subtotal)}</span>
</div>
<div className="flex justify-between">
<span>Descuento</span>
<span className="tabular-nums">-{money(lastSale.discount)}</span>
</div>
<div className="flex justify-between text-sm font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{money(lastSale.total)}</span>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setLastSale(null)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground"
>
Cerrar
</button>
<button
type="button"
onClick={() => window.print()}
className="flex items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover"
>
<Icon icon={iconPrinter} width={16} height={16} aria-hidden="true" />
Imprimir
</button>
</div>
</div>
)}
</Dialog>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { ThemeProvider } from "./_core/hooks/use-theme";
import { AppQueryProvider } from "./_core/services/query-client";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AppQueryProvider>
<ThemeProvider>{children}</ThemeProvider>
</AppQueryProvider>
);
}
-1
View File
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@