12 changed files with 268 additions and 189 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
"@heroui/react": "^3.2.6",
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.104.0",
"framer-motion": "^13.4.0",
"motion": "^12.4.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8"
@@ -10,6 +10,7 @@ interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel?: string;
pendingLabel?: string;
}
export function ConfirmDialog({
@@ -19,10 +20,14 @@ export function ConfirmDialog({
title,
description,
confirmLabel = "Eliminar",
pendingLabel,
}: ConfirmDialogProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const activePendingLabel =
pendingLabel ?? (confirmLabel === "Eliminar" ? "Eliminando…" : "Procesando…");
async function handleConfirm() {
setSubmitting(true);
setError(null);
@@ -61,7 +66,7 @@ export function ConfirmDialog({
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}
{submitting ? activePendingLabel : confirmLabel}
</button>
</div>
</Dialog>
@@ -1,7 +1,7 @@
"use client";
import { Icon } from "@iconify/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { iconCloseSquare } from "../icons";
interface Option {
@@ -26,21 +26,38 @@ export function MultiSelect({
}: MultiSelectProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const listboxId = useId();
useEffect(() => {
if (!open) return;
if (!open) {
setActiveIndex(-1);
return;
}
function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setActiveIndex(-1);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
function onGlobalKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
setActiveIndex(-1);
}
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keydown", onGlobalKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keydown", onGlobalKeyDown);
};
}, [open]);
@@ -54,6 +71,17 @@ export function MultiSelect({
return options.filter((o) => !q || o.label.toLowerCase().includes(q)).slice(0, 50);
}, [options, query]);
useEffect(() => {
setActiveIndex(-1);
}, [query]);
useEffect(() => {
if (activeIndex >= 0 && listboxRef.current) {
const activeElement = listboxRef.current.children[activeIndex] as HTMLElement | undefined;
activeElement?.scrollIntoView({ block: "nearest" });
}
}, [activeIndex]);
function toggle(id: number) {
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
}
@@ -62,10 +90,49 @@ export function MultiSelect({
onChange(selected.filter((s) => s !== id));
}
function handleInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
setActiveIndex(results.length > 0 ? 0 : -1);
} else if (results.length > 0) {
setActiveIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
setActiveIndex(results.length > 0 ? results.length - 1 : -1);
} else if (results.length > 0) {
setActiveIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
}
} else if (e.key === "Enter") {
if (open && activeIndex >= 0 && activeIndex < results.length) {
e.preventDefault();
toggle(results[activeIndex].id);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
setActiveIndex(-1);
}
} else if (e.key === "Backspace" && query === "" && selected.length > 0) {
remove(selected[selected.length - 1]);
} else if (e.key === "Tab") {
setOpen(false);
setActiveIndex(-1);
}
}
return (
<div ref={rootRef} className="relative">
<div
onClick={() => setOpen(true)}
onClick={() => {
inputRef.current?.focus();
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) => (
@@ -89,14 +156,22 @@ export function MultiSelect({
))}
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setOpen(true)}
onKeyDown={handleInputKeyDown}
placeholder={selectedOptions.length === 0 ? placeholder : ""}
role="combobox"
aria-expanded={open}
aria-controls="multi-select-listbox"
aria-controls={open ? listboxId : undefined}
aria-autocomplete="list"
aria-activedescendant={
open && activeIndex >= 0 && results[activeIndex]
? `${listboxId}-option-${results[activeIndex].id}`
: undefined
}
autoComplete="off"
className="min-w-24 flex-1 bg-transparent px-1.5 py-1 text-sm text-foreground placeholder-muted outline-none"
/>
@@ -104,25 +179,35 @@ export function MultiSelect({
{open && (
<ul
id="multi-select-listbox"
ref={listboxRef}
id={listboxId}
role="listbox"
aria-multiselectable="true"
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) => {
results.map((o, idx) => {
const isSelected = selected.includes(o.id);
const isHighlighted = activeIndex === idx;
return (
<li key={o.id} role="option" aria-selected={isSelected}>
<li
key={o.id}
id={`${listboxId}-option-${o.id}`}
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActiveIndex(idx)}
>
<button
type="button"
tabIndex={-1}
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"
}`}
} ${isHighlighted ? "ring-1 ring-focus bg-foreground/5" : ""}`}
>
{o.label}
</button>
@@ -1,7 +1,7 @@
"use client";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
@@ -79,7 +79,7 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
}`}
>
<span
className={`absolute inset-y-1.5 left-0 w-[3px] rounded-full transition-opacity ${
className={`absolute inset-y-1.5 left-0 w-0.75 rounded-full transition-opacity ${
active ? "bg-accent opacity-100" : "opacity-0"
}`}
aria-hidden="true"
@@ -1,7 +1,7 @@
"use client";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { iconCalendar, iconCheckCircle, iconChevronDown } from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client";
@@ -147,6 +147,7 @@ export function OperatingDayControl() {
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"
pendingLabel="Cerrando día…"
onConfirm={handleClose}
/>
</div>
@@ -2,7 +2,7 @@
import { Avatar } from "@heroui/react";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useTheme } from "../../_core/hooks/use-theme";
import {
+3 -3
View File
@@ -14,14 +14,14 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "PDV — Punto de Venta",
description: "Sistema de Punto de Venta y Gestión de Inventario",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
lang="es"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
@@ -1,7 +1,7 @@
"use client";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { iconStorefrontFill } from "../../_core/icons";
@@ -2,7 +2,7 @@
import { Avatar } from "@heroui/react";
import { Icon } from "@iconify/react";
import { motion, useReducedMotion } from "framer-motion";
import { motion, useReducedMotion } from "motion/react";
import { iconIdentificationBadge } from "../../_core/icons";
import type { AuthUser } from "../../_core/types/auth-user";
@@ -1,7 +1,7 @@
"use client";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import {
iconAddCircle,
+1
View File
@@ -244,6 +244,7 @@ export default function PosHistoryPage() {
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"
pendingLabel="Devolviendo…"
onConfirm={handleReturn}
/>
</div>
+151 -164
View File
File diff suppressed because it is too large. Load diff