59 changed files with 696 additions and 352 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
{ {
ignores: ['dist/**'], ignores: ['dist/**', 'src/prisma/contract.d.ts'],
}, },
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,
-1
View File
@@ -15,7 +15,6 @@
"@nestjs/common": "^12.0.3", "@nestjs/common": "^12.0.3",
"@nestjs/core": "^12.0.3", "@nestjs/core": "^12.0.3",
"@nestjs/platform-express": "^12.0.3", "@nestjs/platform-express": "^12.0.3",
"@prisma/client": "^7.10.0",
"@prisma/orm-postgres": "^8.0.0-rc.13", "@prisma/orm-postgres": "^8.0.0-rc.13",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"next": "^16.3.5", "next": "^16.3.5",
@@ -1,4 +1,12 @@
import { Body, Controller, Get, Inject, Param, ParseIntPipe, Post } from "@nestjs/common"; import {
Body,
Controller,
Get,
Inject,
Param,
ParseIntPipe,
Post,
} from "@nestjs/common";
import type { CreateCashMovementInput } from "../../dtos/cash-movement.dto.js"; import type { CreateCashMovementInput } from "../../dtos/cash-movement.dto.js";
import { CASH_MOVEMENT_SERVICE } from "./interfaces/cash-movement-service.interface.js"; import { CASH_MOVEMENT_SERVICE } from "./interfaces/cash-movement-service.interface.js";
import type { ICashMovementService } from "./interfaces/cash-movement-service.interface.js"; import type { ICashMovementService } from "./interfaces/cash-movement-service.interface.js";
@@ -6,7 +14,8 @@ import type { ICashMovementService } from "./interfaces/cash-movement-service.in
@Controller("cash-movements") @Controller("cash-movements")
export class CashMovementController { export class CashMovementController {
constructor( constructor(
@Inject(CASH_MOVEMENT_SERVICE) private readonly cashMovementService: ICashMovementService, @Inject(CASH_MOVEMENT_SERVICE)
private readonly cashMovementService: ICashMovementService,
) {} ) {}
@Get() @Get()
@@ -1,11 +1,22 @@
import { Inject, Injectable } from "@nestjs/common"; import { Inject, Injectable } from "@nestjs/common";
import { DB } from "../../database/database.tokens.js"; import { DB } from "../../database/database.tokens.js";
import type { Database } from "../../prisma/db.js"; import type { Database } from "../../prisma/db.js";
import type { CashMovement, CashMovementKey } from "../../entities/cash-movement.entity.js"; import type {
CashMovement,
CashMovementKey,
} from "../../entities/cash-movement.entity.js";
import type { CreateCashMovementInput } from "../../dtos/cash-movement.dto.js"; import type { CreateCashMovementInput } from "../../dtos/cash-movement.dto.js";
import type { ICashMovementRepository } from "./interfaces/cash-movement-repository.interface.js"; import type { ICashMovementRepository } from "./interfaces/cash-movement-repository.interface.js";
const COLUMNS = ["id", "sessionId", "type", "amount", "reason", "userId", "createdAt"] as const; const COLUMNS = [
"id",
"sessionId",
"type",
"amount",
"reason",
"userId",
"createdAt",
] as const;
@Injectable() @Injectable()
export class CashMovementRepository implements ICashMovementRepository { export class CashMovementRepository implements ICashMovementRepository {
@@ -16,11 +27,17 @@ export class CashMovementRepository implements ICashMovementRepository {
} }
findAll(): Promise<CashMovement[]> { findAll(): Promise<CashMovement[]> {
return this.model.select(...COLUMNS).all().toArray(); return this.model
.select(...COLUMNS)
.all()
.toArray();
} }
findById(where: CashMovementKey): Promise<CashMovement | null> { findById(where: CashMovementKey): Promise<CashMovement | null> {
return this.model.select(...COLUMNS).where(where).first(); return this.model
.select(...COLUMNS)
.where(where)
.first();
} }
create(data: CreateCashMovementInput): Promise<CashMovement> { create(data: CreateCashMovementInput): Promise<CashMovement> {
@@ -1,5 +1,8 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import type { CashMovement, CashMovementKey } from "../../entities/cash-movement.entity.js"; import type {
CashMovement,
CashMovementKey,
} from "../../entities/cash-movement.entity.js";
import type { CreateCashMovementInput } from "../../dtos/cash-movement.dto.js"; import type { CreateCashMovementInput } from "../../dtos/cash-movement.dto.js";
import { import {
CASH_MOVEMENT_REPOSITORY, CASH_MOVEMENT_REPOSITORY,
@@ -10,7 +13,8 @@ import type { ICashMovementService } from "./interfaces/cash-movement-service.in
@Injectable() @Injectable()
export class CashMovementService implements ICashMovementService { export class CashMovementService implements ICashMovementService {
constructor( constructor(
@Inject(CASH_MOVEMENT_REPOSITORY) private readonly repository: ICashMovementRepository, @Inject(CASH_MOVEMENT_REPOSITORY)
private readonly repository: ICashMovementRepository,
) {} ) {}
findAll(): Promise<CashMovement[]> { findAll(): Promise<CashMovement[]> {
@@ -1,4 +1,7 @@
import type { CashMovement, CashMovementKey } from "../../../entities/cash-movement.entity.js"; import type {
CashMovement,
CashMovementKey,
} from "../../../entities/cash-movement.entity.js";
import type { CreateCashMovementInput } from "../../../dtos/cash-movement.dto.js"; import type { CreateCashMovementInput } from "../../../dtos/cash-movement.dto.js";
export const CASH_MOVEMENT_REPOSITORY = Symbol("CASH_MOVEMENT_REPOSITORY"); export const CASH_MOVEMENT_REPOSITORY = Symbol("CASH_MOVEMENT_REPOSITORY");
@@ -1,4 +1,7 @@
import type { CashMovement, CashMovementKey } from "../../../entities/cash-movement.entity.js"; import type {
CashMovement,
CashMovementKey,
} from "../../../entities/cash-movement.entity.js";
import type { CreateCashMovementInput } from "../../../dtos/cash-movement.dto.js"; import type { CreateCashMovementInput } from "../../../dtos/cash-movement.dto.js";
export const CASH_MOVEMENT_SERVICE = Symbol("CASH_MOVEMENT_SERVICE"); export const CASH_MOVEMENT_SERVICE = Symbol("CASH_MOVEMENT_SERVICE");
@@ -1,4 +1,14 @@
import { Body, Controller, Delete, Get, Inject, Param, ParseIntPipe, Patch, Post } from "@nestjs/common"; import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
ParseIntPipe,
Patch,
Post,
} from "@nestjs/common";
import type { import type {
CreateCashRegisterSessionInput, CreateCashRegisterSessionInput,
UpdateCashRegisterSessionInput, UpdateCashRegisterSessionInput,
@@ -29,7 +39,10 @@ export class CashRegisterSessionController {
} }
@Patch(":id") @Patch(":id")
update(@Param("id", ParseIntPipe) id: number, @Body() body: UpdateCashRegisterSessionInput) { update(
@Param("id", ParseIntPipe) id: number,
@Body() body: UpdateCashRegisterSessionInput,
) {
return this.sessionService.update({ id }, body); return this.sessionService.update({ id }, body);
} }
@@ -8,8 +8,14 @@ import { CashRegisterSessionService } from "./cash-register-session.service.js";
@Module({ @Module({
controllers: [CashRegisterSessionController], controllers: [CashRegisterSessionController],
providers: [ providers: [
{ provide: CASH_REGISTER_SESSION_REPOSITORY, useClass: CashRegisterSessionRepository }, {
{ provide: CASH_REGISTER_SESSION_SERVICE, useClass: CashRegisterSessionService }, provide: CASH_REGISTER_SESSION_REPOSITORY,
useClass: CashRegisterSessionRepository,
},
{
provide: CASH_REGISTER_SESSION_SERVICE,
useClass: CashRegisterSessionService,
},
], ],
exports: [CASH_REGISTER_SESSION_SERVICE], exports: [CASH_REGISTER_SESSION_SERVICE],
}) })
@@ -31,9 +31,13 @@ export class CashRegisterSessionService
super(sessionRepository); super(sessionRepository);
} }
override async create(data: CreateCashRegisterSessionInput): Promise<CashRegisterSession> { override async create(
data: CreateCashRegisterSessionInput,
): Promise<CashRegisterSession> {
const all = await this.sessionRepository.findAll(); const all = await this.sessionRepository.findAll();
const alreadyOpen = all.some((s) => s.inventoryId === data.inventoryId && s.status === "open"); const alreadyOpen = all.some(
(s) => s.inventoryId === data.inventoryId && s.status === "open",
);
if (alreadyOpen) { if (alreadyOpen) {
throw new BadRequestException("Ya hay una caja abierta en este almacén"); throw new BadRequestException("Ya hay una caja abierta en este almacén");
} }
@@ -8,7 +8,9 @@ import type {
UpdateCashRegisterSessionInput, UpdateCashRegisterSessionInput,
} from "../../../dtos/cash-register-session.dto.js"; } from "../../../dtos/cash-register-session.dto.js";
export const CASH_REGISTER_SESSION_REPOSITORY = Symbol("CASH_REGISTER_SESSION_REPOSITORY"); export const CASH_REGISTER_SESSION_REPOSITORY = Symbol(
"CASH_REGISTER_SESSION_REPOSITORY",
);
export type ICashRegisterSessionRepository = IRepository< export type ICashRegisterSessionRepository = IRepository<
CashRegisterSession, CashRegisterSession,
@@ -8,7 +8,9 @@ import type {
UpdateCashRegisterSessionInput, UpdateCashRegisterSessionInput,
} from "../../../dtos/cash-register-session.dto.js"; } from "../../../dtos/cash-register-session.dto.js";
export const CASH_REGISTER_SESSION_SERVICE = Symbol("CASH_REGISTER_SESSION_SERVICE"); export const CASH_REGISTER_SESSION_SERVICE = Symbol(
"CASH_REGISTER_SESSION_SERVICE",
);
export type ICashRegisterSessionService = IService< export type ICashRegisterSessionService = IService<
CashRegisterSession, CashRegisterSession,
@@ -1,11 +1,27 @@
import { Body, Controller, Delete, Get, Inject, Param, ParseIntPipe, Patch, Post } from "@nestjs/common"; import {
import type { CreateCustomerInput, UpdateCustomerInput } from "../../dtos/customer.dto.js"; Body,
Controller,
Delete,
Get,
Inject,
Param,
ParseIntPipe,
Patch,
Post,
} from "@nestjs/common";
import type {
CreateCustomerInput,
UpdateCustomerInput,
} from "../../dtos/customer.dto.js";
import { CUSTOMER_SERVICE } from "./interfaces/customer-service.interface.js"; import { CUSTOMER_SERVICE } from "./interfaces/customer-service.interface.js";
import type { ICustomerService } from "./interfaces/customer-service.interface.js"; import type { ICustomerService } from "./interfaces/customer-service.interface.js";
@Controller("customers") @Controller("customers")
export class CustomerController { export class CustomerController {
constructor(@Inject(CUSTOMER_SERVICE) private readonly customerService: ICustomerService) {} constructor(
@Inject(CUSTOMER_SERVICE)
private readonly customerService: ICustomerService,
) {}
@Get() @Get()
findAll() { findAll() {
@@ -23,7 +39,10 @@ export class CustomerController {
} }
@Patch(":id") @Patch(":id")
update(@Param("id", ParseIntPipe) id: number, @Body() body: UpdateCustomerInput) { update(
@Param("id", ParseIntPipe) id: number,
@Body() body: UpdateCustomerInput,
) {
return this.customerService.update({ id }, body); return this.customerService.update({ id }, body);
} }
@@ -1,15 +1,30 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { BaseRepository } from "../../common/base.repository.js"; import { BaseRepository } from "../../common/base.repository.js";
import type { CreateCustomerInput, UpdateCustomerInput } from "../../dtos/customer.dto.js"; import type {
CreateCustomerInput,
UpdateCustomerInput,
} from "../../dtos/customer.dto.js";
import type { Customer, CustomerKey } from "../../entities/customer.entity.js"; import type { Customer, CustomerKey } from "../../entities/customer.entity.js";
import type { ICustomerRepository } from "./interfaces/customer-repository.interface.js"; import type { ICustomerRepository } from "./interfaces/customer-repository.interface.js";
@Injectable() @Injectable()
export class CustomerRepository export class CustomerRepository
extends BaseRepository<Customer, CreateCustomerInput, UpdateCustomerInput, CustomerKey> extends BaseRepository<
Customer,
CreateCustomerInput,
UpdateCustomerInput,
CustomerKey
>
implements ICustomerRepository implements ICustomerRepository
{ {
protected readonly columns = ["id", "name", "phone", "email", "taxId", "createdAt"] as const; protected readonly columns = [
"id",
"name",
"phone",
"email",
"taxId",
"createdAt",
] as const;
protected get model() { protected get model() {
return this.db.orm.public.Customer; return this.db.orm.public.Customer;
@@ -1,6 +1,9 @@
import { Inject, Injectable } from "@nestjs/common"; import { Inject, Injectable } from "@nestjs/common";
import { BaseService } from "../../common/base.service.js"; import { BaseService } from "../../common/base.service.js";
import type { CreateCustomerInput, UpdateCustomerInput } from "../../dtos/customer.dto.js"; import type {
CreateCustomerInput,
UpdateCustomerInput,
} from "../../dtos/customer.dto.js";
import type { Customer, CustomerKey } from "../../entities/customer.entity.js"; import type { Customer, CustomerKey } from "../../entities/customer.entity.js";
import { import {
CUSTOMER_REPOSITORY, CUSTOMER_REPOSITORY,
@@ -10,7 +13,12 @@ import type { ICustomerService } from "./interfaces/customer-service.interface.j
@Injectable() @Injectable()
export class CustomerService export class CustomerService
extends BaseService<Customer, CreateCustomerInput, UpdateCustomerInput, CustomerKey> extends BaseService<
Customer,
CreateCustomerInput,
UpdateCustomerInput,
CustomerKey
>
implements ICustomerService implements ICustomerService
{ {
constructor(@Inject(CUSTOMER_REPOSITORY) repository: ICustomerRepository) { constructor(@Inject(CUSTOMER_REPOSITORY) repository: ICustomerRepository) {
@@ -1,6 +1,12 @@
import type { IRepository } from "../../../common/repository.interface.js"; import type { IRepository } from "../../../common/repository.interface.js";
import type { Customer, CustomerKey } from "../../../entities/customer.entity.js"; import type {
import type { CreateCustomerInput, UpdateCustomerInput } from "../../../dtos/customer.dto.js"; Customer,
CustomerKey,
} from "../../../entities/customer.entity.js";
import type {
CreateCustomerInput,
UpdateCustomerInput,
} from "../../../dtos/customer.dto.js";
export const CUSTOMER_REPOSITORY = Symbol("CUSTOMER_REPOSITORY"); export const CUSTOMER_REPOSITORY = Symbol("CUSTOMER_REPOSITORY");
@@ -1,6 +1,12 @@
import type { IService } from "../../../common/service.interface.js"; import type { IService } from "../../../common/service.interface.js";
import type { Customer, CustomerKey } from "../../../entities/customer.entity.js"; import type {
import type { CreateCustomerInput, UpdateCustomerInput } from "../../../dtos/customer.dto.js"; Customer,
CustomerKey,
} from "../../../entities/customer.entity.js";
import type {
CreateCustomerInput,
UpdateCustomerInput,
} from "../../../dtos/customer.dto.js";
export const CUSTOMER_SERVICE = Symbol("CUSTOMER_SERVICE"); export const CUSTOMER_SERVICE = Symbol("CUSTOMER_SERVICE");
@@ -1,4 +1,12 @@
import { Body, Controller, Get, Inject, Param, ParseIntPipe, Post } from "@nestjs/common"; import {
Body,
Controller,
Get,
Inject,
Param,
ParseIntPipe,
Post,
} from "@nestjs/common";
import type { CreateInventoryMovementInput } from "../../dtos/inventory-movement.dto.js"; import type { CreateInventoryMovementInput } from "../../dtos/inventory-movement.dto.js";
import { import {
INVENTORY_MOVEMENT_SERVICE, INVENTORY_MOVEMENT_SERVICE,
@@ -12,7 +12,10 @@ import { InventoryMovementService } from "./inventory-movement.service.js";
imports: [WarehouseModule, ProductModule, SupplierModule], imports: [WarehouseModule, ProductModule, SupplierModule],
controllers: [InventoryMovementController], controllers: [InventoryMovementController],
providers: [ providers: [
{ provide: INVENTORY_MOVEMENT_REPOSITORY, useClass: InventoryMovementRepository }, {
provide: INVENTORY_MOVEMENT_REPOSITORY,
useClass: InventoryMovementRepository,
},
{ provide: INVENTORY_MOVEMENT_SERVICE, useClass: InventoryMovementService }, { provide: INVENTORY_MOVEMENT_SERVICE, useClass: InventoryMovementService },
], ],
exports: [INVENTORY_MOVEMENT_SERVICE], exports: [INVENTORY_MOVEMENT_SERVICE],
@@ -20,9 +20,7 @@ const COLUMNS = [
] as const; ] as const;
@Injectable() @Injectable()
export class InventoryMovementRepository export class InventoryMovementRepository implements IInventoryMovementRepository {
implements IInventoryMovementRepository
{
constructor(@Inject(DB) private readonly db: Database) {} constructor(@Inject(DB) private readonly db: Database) {}
private get model() { private get model() {
@@ -30,11 +28,17 @@ export class InventoryMovementRepository
} }
findAll(): Promise<InventoryMovement[]> { findAll(): Promise<InventoryMovement[]> {
return this.model.select(...COLUMNS).all().toArray(); return this.model
.select(...COLUMNS)
.all()
.toArray();
} }
findById(where: InventoryMovementKey): Promise<InventoryMovement | null> { findById(where: InventoryMovementKey): Promise<InventoryMovement | null> {
return this.model.select(...COLUMNS).where(where).first(); return this.model
.select(...COLUMNS)
.where(where)
.first();
} }
create(data: CreateInventoryMovementInput): Promise<InventoryMovement> { create(data: CreateInventoryMovementInput): Promise<InventoryMovement> {
@@ -1,4 +1,9 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common"; import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { import {
PRODUCT_SERVICE, PRODUCT_SERVICE,
type IProductService, type IProductService,
@@ -33,9 +38,11 @@ export class InventoryMovementService implements IInventoryMovementService {
constructor( constructor(
@Inject(INVENTORY_MOVEMENT_REPOSITORY) @Inject(INVENTORY_MOVEMENT_REPOSITORY)
private readonly repository: IInventoryMovementRepository, private readonly repository: IInventoryMovementRepository,
@Inject(WAREHOUSE_SERVICE) private readonly warehouseService: IWarehouseService, @Inject(WAREHOUSE_SERVICE)
private readonly warehouseService: IWarehouseService,
@Inject(PRODUCT_SERVICE) private readonly productService: IProductService, @Inject(PRODUCT_SERVICE) private readonly productService: IProductService,
@Inject(SUPPLIER_SERVICE) private readonly supplierService: ISupplierService, @Inject(SUPPLIER_SERVICE)
private readonly supplierService: ISupplierService,
) {} ) {}
findAll(): Promise<InventoryMovement[]> { findAll(): Promise<InventoryMovement[]> {
@@ -66,17 +73,25 @@ export class InventoryMovementService implements IInventoryMovementService {
if (data.type === "out") { if (data.type === "out") {
const currentStock = existing?.stock ?? 0; const currentStock = existing?.stock ?? 0;
if (currentStock < data.quantity) { if (currentStock < data.quantity) {
throw new BadRequestException("Stock insuficiente para registrar la salida"); throw new BadRequestException(
"Stock insuficiente para registrar la salida",
);
} }
await this.warehouseService.update(key, { stock: currentStock - data.quantity }); await this.warehouseService.update(key, {
stock: currentStock - data.quantity,
});
} else { } else {
if (existing) { if (existing) {
await this.warehouseService.update(key, { stock: existing.stock + data.quantity }); await this.warehouseService.update(key, {
stock: existing.stock + data.quantity,
});
} else { } else {
await this.warehouseService.create({ ...key, stock: data.quantity }); await this.warehouseService.create({ ...key, stock: data.quantity });
} }
const product = await this.productService.findById({ id: data.productId }).catch(() => null); const product = await this.productService
.findById({ id: data.productId })
.catch(() => null);
if (product?.supplierId) { if (product?.supplierId) {
await this.supplierService.update( await this.supplierService.update(
{ id: product.supplierId }, { id: product.supplierId },
@@ -1,6 +1,12 @@
import type { IRepository } from "../../../common/repository.interface.js"; import type { IRepository } from "../../../common/repository.interface.js";
import type { OperatingDay, OperatingDayKey } from "../../../entities/operating-day.entity.js"; import type {
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../../dtos/operating-day.dto.js"; OperatingDay,
OperatingDayKey,
} from "../../../entities/operating-day.entity.js";
import type {
CreateOperatingDayInput,
UpdateOperatingDayInput,
} from "../../../dtos/operating-day.dto.js";
export const OPERATING_DAY_REPOSITORY = Symbol("OPERATING_DAY_REPOSITORY"); export const OPERATING_DAY_REPOSITORY = Symbol("OPERATING_DAY_REPOSITORY");
@@ -1,6 +1,12 @@
import type { IService } from "../../../common/service.interface.js"; import type { IService } from "../../../common/service.interface.js";
import type { OperatingDay, OperatingDayKey } from "../../../entities/operating-day.entity.js"; import type {
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../../dtos/operating-day.dto.js"; OperatingDay,
OperatingDayKey,
} from "../../../entities/operating-day.entity.js";
import type {
CreateOperatingDayInput,
UpdateOperatingDayInput,
} from "../../../dtos/operating-day.dto.js";
export const OPERATING_DAY_SERVICE = Symbol("OPERATING_DAY_SERVICE"); export const OPERATING_DAY_SERVICE = Symbol("OPERATING_DAY_SERVICE");
@@ -1,5 +1,18 @@
import { Body, Controller, Delete, Get, Inject, Param, ParseIntPipe, Patch, Post } from "@nestjs/common"; import {
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../dtos/operating-day.dto.js"; Body,
Controller,
Delete,
Get,
Inject,
Param,
ParseIntPipe,
Patch,
Post,
} from "@nestjs/common";
import type {
CreateOperatingDayInput,
UpdateOperatingDayInput,
} from "../../dtos/operating-day.dto.js";
import { OPERATING_DAY_SERVICE } from "./interfaces/operating-day-service.interface.js"; import { OPERATING_DAY_SERVICE } from "./interfaces/operating-day-service.interface.js";
import type { IOperatingDayService } from "./interfaces/operating-day-service.interface.js"; import type { IOperatingDayService } from "./interfaces/operating-day-service.interface.js";
@@ -26,7 +39,10 @@ export class OperatingDayController {
} }
@Patch(":id") @Patch(":id")
update(@Param("id", ParseIntPipe) id: number, @Body() body: UpdateOperatingDayInput) { update(
@Param("id", ParseIntPipe) id: number,
@Body() body: UpdateOperatingDayInput,
) {
return this.operatingDayService.update({ id }, body); return this.operatingDayService.update({ id }, body);
} }
@@ -1,15 +1,34 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { BaseRepository } from "../../common/base.repository.js"; import { BaseRepository } from "../../common/base.repository.js";
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../dtos/operating-day.dto.js"; import type {
import type { OperatingDay, OperatingDayKey } from "../../entities/operating-day.entity.js"; CreateOperatingDayInput,
UpdateOperatingDayInput,
} from "../../dtos/operating-day.dto.js";
import type {
OperatingDay,
OperatingDayKey,
} from "../../entities/operating-day.entity.js";
import type { IOperatingDayRepository } from "./interfaces/operating-day-repository.interface.js"; import type { IOperatingDayRepository } from "./interfaces/operating-day-repository.interface.js";
@Injectable() @Injectable()
export class OperatingDayRepository export class OperatingDayRepository
extends BaseRepository<OperatingDay, CreateOperatingDayInput, UpdateOperatingDayInput, OperatingDayKey> extends BaseRepository<
OperatingDay,
CreateOperatingDayInput,
UpdateOperatingDayInput,
OperatingDayKey
>
implements IOperatingDayRepository implements IOperatingDayRepository
{ {
protected readonly columns = ["id", "date", "status", "openedById", "closedById", "openedAt", "closedAt"] as const; protected readonly columns = [
"id",
"date",
"status",
"openedById",
"closedById",
"openedAt",
"closedAt",
] as const;
protected get model() { protected get model() {
return this.db.orm.public.OperatingDay; return this.db.orm.public.OperatingDay;
@@ -1,17 +1,31 @@
import { BadRequestException, Inject, Injectable } from "@nestjs/common"; import { BadRequestException, Inject, Injectable } from "@nestjs/common";
import { BaseService } from "../../common/base.service.js"; import { BaseService } from "../../common/base.service.js";
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../dtos/operating-day.dto.js"; import type {
import type { OperatingDay, OperatingDayKey } from "../../entities/operating-day.entity.js"; CreateOperatingDayInput,
UpdateOperatingDayInput,
} from "../../dtos/operating-day.dto.js";
import type {
OperatingDay,
OperatingDayKey,
} from "../../entities/operating-day.entity.js";
import { import {
CASH_REGISTER_SESSION_SERVICE, CASH_REGISTER_SESSION_SERVICE,
type ICashRegisterSessionService, type ICashRegisterSessionService,
} from "../cash-register-session/interfaces/cash-register-session-service.interface.js"; } from "../cash-register-session/interfaces/cash-register-session-service.interface.js";
import { OPERATING_DAY_REPOSITORY, type IOperatingDayRepository } from "./interfaces/operating-day-repository.interface.js"; import {
OPERATING_DAY_REPOSITORY,
type IOperatingDayRepository,
} from "./interfaces/operating-day-repository.interface.js";
import type { IOperatingDayService } from "./interfaces/operating-day-service.interface.js"; import type { IOperatingDayService } from "./interfaces/operating-day-service.interface.js";
@Injectable() @Injectable()
export class OperatingDayService export class OperatingDayService
extends BaseService<OperatingDay, CreateOperatingDayInput, UpdateOperatingDayInput, OperatingDayKey> extends BaseService<
OperatingDay,
CreateOperatingDayInput,
UpdateOperatingDayInput,
OperatingDayKey
>
implements IOperatingDayService implements IOperatingDayService
{ {
constructor( constructor(
@@ -32,20 +46,29 @@ export class OperatingDayService
return this.operatingDayRepository.create(data); return this.operatingDayRepository.create(data);
} }
override async update(where: OperatingDayKey, data: UpdateOperatingDayInput): Promise<OperatingDay> { override async update(
where: OperatingDayKey,
data: UpdateOperatingDayInput,
): Promise<OperatingDay> {
const current = await this.findById(where); const current = await this.findById(where);
if (data.status === "closed") { if (data.status === "closed") {
if (current.status === "closed") { if (current.status === "closed") {
throw new BadRequestException("Esta fecha de operación ya está cerrada"); throw new BadRequestException(
"Esta fecha de operación ya está cerrada",
);
} }
const sessions = await this.sessionService.findAll(); const sessions = await this.sessionService.findAll();
const hasOpenSession = sessions.some((s) => s.status === "open"); const hasOpenSession = sessions.some((s) => s.status === "open");
if (hasOpenSession) { if (hasOpenSession) {
throw new BadRequestException("No se puede cerrar el día de operación con cajas abiertas"); throw new BadRequestException(
"No se puede cerrar el día de operación con cajas abiertas",
);
} }
if (!data.closedById) { if (!data.closedById) {
throw new BadRequestException("Falta indicar quién cierra la fecha de operación"); throw new BadRequestException(
"Falta indicar quién cierra la fecha de operación",
);
} }
const closed = await this.operatingDayRepository.update(where, { const closed = await this.operatingDayRepository.update(where, {
@@ -7,9 +7,26 @@ import type {
export const PRODUCT_REPOSITORY = Symbol("PRODUCT_REPOSITORY"); export const PRODUCT_REPOSITORY = Symbol("PRODUCT_REPOSITORY");
export type IProductRepository = IRepository< export interface SetPriceInput {
unitPrice: string;
costUnit?: string;
}
export interface IProductRepository extends IRepository<
Product, Product,
CreateProductInput, CreateProductInput,
UpdateProductInput, UpdateProductInput,
ProductKey ProductKey
>; > {
setPrice(
productId: number,
data: SetPriceInput,
): Promise<{
id: number;
productId: number;
unitPrice: string;
costUnit: string;
status: string;
}>;
setCategories(productId: number, categoryIds: number[]): Promise<void>;
}
@@ -5,11 +5,26 @@ import type {
UpdateProductInput, UpdateProductInput,
} from "../../../dtos/product.dto.js"; } from "../../../dtos/product.dto.js";
import type { SetPriceInput } from "./product-repository.interface.js";
export type { SetPriceInput };
export const PRODUCT_SERVICE = Symbol("PRODUCT_SERVICE"); export const PRODUCT_SERVICE = Symbol("PRODUCT_SERVICE");
export type IProductService = IService< export interface IProductService extends IService<
Product, Product,
CreateProductInput, CreateProductInput,
UpdateProductInput, UpdateProductInput,
ProductKey ProductKey
>; > {
setPrice(
productId: number,
data: SetPriceInput,
): Promise<{
id: number;
productId: number;
unitPrice: string;
costUnit: string;
status: string;
}>;
setCategories(productId: number, categoryIds: number[]): Promise<void>;
}
@@ -37,6 +37,22 @@ export class ProductController {
return this.productService.create(body); return this.productService.create(body);
} }
@Post(":id/price")
setPrice(
@Param("id", ParseIntPipe) id: number,
@Body() body: { unitPrice: string; costUnit?: string },
) {
return this.productService.setPrice(id, body);
}
@Patch(":id/categories")
syncCategoriesPatch(
@Param("id", ParseIntPipe) id: number,
@Body() body: { categoryIds: number[] },
) {
return this.productService.setCategories(id, body.categoryIds ?? []);
}
@Patch(":id") @Patch(":id")
update( update(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseIntPipe) id: number,
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { BaseRepository } from "../../common/base.repository.js"; import { BaseRepository } from "../../common/base.repository.js";
import type { import type {
@@ -32,4 +33,36 @@ export class ProductRepository
protected get model() { protected get model() {
return this.db.orm.public.Product; return this.db.orm.public.Product;
} }
async setPrice(
productId: number,
data: { unitPrice: string; costUnit?: string },
) {
// 1. Archivar los precios activos anteriores para este producto
await (this.db.orm.public.ProductHistory as any)
.where({ productId, status: "active" })
.update({ status: "archived" });
// 2. Crear el nuevo precio con status active
const newHistory = await (this.db.orm.public.ProductHistory as any).create({
productId,
unitPrice: data.unitPrice,
costUnit: data.costUnit ?? "0",
status: "active",
});
return newHistory;
}
async setCategories(productId: number, categoryIds: number[]): Promise<void> {
const categoryModel = this.db.orm.public.ProductCategory as any;
// 1. Eliminar las categorías actuales del producto
await categoryModel.where({ productId }).delete();
// 2. Insertar las nuevas categorías
const uniqueIds = Array.from(new Set(categoryIds));
for (const categoryId of uniqueIds) {
await categoryModel.create({ productId, categoryId });
}
}
} }
@@ -25,6 +25,22 @@ export class ProductService
super(repository); super(repository);
} }
async setPrice(
productId: number,
data: { unitPrice: string; costUnit?: string },
) {
await this.findById({ id: productId });
return (this.repository as IProductRepository).setPrice(productId, data);
}
async setCategories(productId: number, categoryIds: number[]): Promise<void> {
await this.findById({ id: productId });
return (this.repository as IProductRepository).setCategories(
productId,
categoryIds,
);
}
protected override notFoundMessage(where: ProductKey): string { protected override notFoundMessage(where: ProductKey): string {
return `Product ${where.id} not found`; return `Product ${where.id} not found`;
} }
@@ -1,4 +1,4 @@
import { Inject, Injectable } from "@nestjs/common"; import { BadRequestException, Inject, Injectable } from "@nestjs/common";
import { BaseService } from "../../common/base.service.js"; import { BaseService } from "../../common/base.service.js";
import type { import type {
CreateShopCartInput, CreateShopCartInput,
@@ -25,6 +25,34 @@ export class ShopCartService
super(repository); super(repository);
} }
private validateDiscount(totalPriceStr?: string, discountStr?: string) {
if (discountStr !== undefined) {
const discount = Number(discountStr);
if (Number.isNaN(discount) || discount < 0) {
throw new BadRequestException("El descuento no puede ser negativo");
}
if (totalPriceStr !== undefined) {
const total = Number(totalPriceStr);
if (Number.isNaN(total) || total < 0) {
throw new BadRequestException("El total no puede ser negativo");
}
}
}
}
override create(data: CreateShopCartInput): Promise<ShopCart> {
this.validateDiscount(data.totalPrice, data.discount);
return super.create(data);
}
override update(
where: ShopCartKey,
data: UpdateShopCartInput,
): Promise<ShopCart> {
this.validateDiscount(data.totalPrice, data.discount);
return super.update(where, data);
}
protected override notFoundMessage(where: ShopCartKey): string { protected override notFoundMessage(where: ShopCartKey): string {
return `ShopCart ${where.id} not found`; return `ShopCart ${where.id} not found`;
} }
+1 -1
View File
@@ -12,7 +12,7 @@
"@heroui/react": "^3.2.6", "@heroui/react": "^3.2.6",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.104.0", "@tanstack/react-query": "^5.104.0",
"motion": "^12.4.0", "framer-motion": "^13.4.0",
"next": "16.3.5", "next": "16.3.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
@@ -10,7 +10,6 @@ interface ConfirmDialogProps {
title: string; title: string;
description: string; description: string;
confirmLabel?: string; confirmLabel?: string;
pendingLabel?: string;
} }
export function ConfirmDialog({ export function ConfirmDialog({
@@ -20,14 +19,10 @@ export function ConfirmDialog({
title, title,
description, description,
confirmLabel = "Eliminar", confirmLabel = "Eliminar",
pendingLabel,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const activePendingLabel =
pendingLabel ?? (confirmLabel === "Eliminar" ? "Eliminando…" : "Procesando…");
async function handleConfirm() { async function handleConfirm() {
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
@@ -66,7 +61,7 @@ export function ConfirmDialog({
disabled={submitting} 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" 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 ? activePendingLabel : confirmLabel} {submitting ? "Eliminando…" : confirmLabel}
</button> </button>
</div> </div>
</Dialog> </Dialog>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { iconCloseSquare } from "../icons"; import { iconCloseSquare } from "../icons";
interface Option { interface Option {
@@ -26,38 +26,21 @@ export function MultiSelect({
}: MultiSelectProps) { }: MultiSelectProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const listboxId = useId();
useEffect(() => { useEffect(() => {
if (!open) { if (!open) return;
setActiveIndex(-1);
return;
}
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
setOpen(false);
setActiveIndex(-1);
}
} }
function onKeyDown(e: KeyboardEvent) {
function onGlobalKeyDown(e: KeyboardEvent) { if (e.key === "Escape") setOpen(false);
if (e.key === "Escape") {
setOpen(false);
setActiveIndex(-1);
}
} }
document.addEventListener("pointerdown", onPointerDown); document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onGlobalKeyDown); document.addEventListener("keydown", onKeyDown);
return () => { return () => {
document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onGlobalKeyDown); document.removeEventListener("keydown", onKeyDown);
}; };
}, [open]); }, [open]);
@@ -71,17 +54,6 @@ export function MultiSelect({
return options.filter((o) => !q || o.label.toLowerCase().includes(q)).slice(0, 50); return options.filter((o) => !q || o.label.toLowerCase().includes(q)).slice(0, 50);
}, [options, query]); }, [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) { function toggle(id: number) {
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]); onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
} }
@@ -90,49 +62,10 @@ export function MultiSelect({
onChange(selected.filter((s) => s !== id)); 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 ( return (
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<div <div
onClick={() => { onClick={() => setOpen(true)}
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" : ""}`} 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) => ( {selectedOptions.map((o) => (
@@ -156,22 +89,14 @@ export function MultiSelect({
))} ))}
<input <input
ref={inputRef}
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onKeyDown={handleInputKeyDown}
placeholder={selectedOptions.length === 0 ? placeholder : ""} placeholder={selectedOptions.length === 0 ? placeholder : ""}
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
aria-controls={open ? listboxId : undefined} aria-controls="multi-select-listbox"
aria-autocomplete="list"
aria-activedescendant={
open && activeIndex >= 0 && results[activeIndex]
? `${listboxId}-option-${results[activeIndex].id}`
: undefined
}
autoComplete="off" autoComplete="off"
className="min-w-24 flex-1 bg-transparent px-1.5 py-1 text-sm text-foreground placeholder-muted outline-none" className="min-w-24 flex-1 bg-transparent px-1.5 py-1 text-sm text-foreground placeholder-muted outline-none"
/> />
@@ -179,35 +104,25 @@ export function MultiSelect({
{open && ( {open && (
<ul <ul
ref={listboxRef} id="multi-select-listbox"
id={listboxId}
role="listbox" 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" 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 ? ( {results.length === 0 ? (
<li className="px-2.5 py-2 text-sm text-muted">{emptyMessage}</li> <li className="px-2.5 py-2 text-sm text-muted">{emptyMessage}</li>
) : ( ) : (
results.map((o, idx) => { results.map((o) => {
const isSelected = selected.includes(o.id); const isSelected = selected.includes(o.id);
const isHighlighted = activeIndex === idx;
return ( return (
<li <li key={o.id} role="option" aria-selected={isSelected}>
key={o.id}
id={`${listboxId}-option-${o.id}`}
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActiveIndex(idx)}
>
<button <button
type="button" type="button"
tabIndex={-1}
onClick={() => toggle(o.id)} onClick={() => toggle(o.id)}
className={`w-full rounded-lg px-2.5 py-2 text-left text-sm transition ${ className={`w-full rounded-lg px-2.5 py-2 text-left text-sm transition ${
isSelected isSelected
? "bg-accent-soft text-accent-soft-foreground" ? "bg-accent-soft text-accent-soft-foreground"
: "text-foreground hover:bg-foreground/5" : "text-foreground hover:bg-foreground/5"
} ${isHighlighted ? "ring-1 ring-focus bg-foreground/5" : ""}`} }`}
> >
{o.label} {o.label}
</button> </button>
@@ -1,26 +0,0 @@
import { Icon, type IconifyIcon } from "@iconify/react";
interface SectionPlaceholderProps {
icon: IconifyIcon;
title: string;
description: string;
}
export function SectionPlaceholder({ icon, title, description }: SectionPlaceholderProps) {
return (
<div>
<h1 className="text-xl font-semibold text-foreground">{title}</h1>
<div className="mt-4 flex min-h-[50vh] flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-border bg-surface/50 p-8 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={icon} width={32} height={32} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<div className="flex flex-col items-center gap-2">
<span className="rounded-full bg-warning-soft px-2.5 py-1 text-xs font-medium text-warning-soft-foreground">
Próximamente
</span>
<p className="max-w-sm text-sm text-muted">{description}</p>
</div>
</div>
</div>
);
}
@@ -2,19 +2,13 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTheme } from "../hooks/use-theme"; import { THEME_ICONS, THEME_LABELS, THEME_MODES, type ThemeMode, useTheme } from "../hooks/use-theme";
import { iconMonitor, iconMoon, iconSun } from "../icons"; import { iconMonitor } 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() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const mounted = theme !== undefined; const mounted = theme !== undefined;
const current: Mode = mounted && MODES.includes(theme as Mode) ? (theme as Mode) : "system"; const current: ThemeMode = mounted && THEME_MODES.includes(theme as ThemeMode) ? (theme as ThemeMode) : "system";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
@@ -34,7 +28,7 @@ export function ThemeToggle() {
}; };
}, [open]); }, [open]);
function select(mode: Mode) { function select(mode: ThemeMode) {
setTheme(mode); setTheme(mode);
setOpen(false); setOpen(false);
} }
@@ -46,11 +40,11 @@ export function ThemeToggle() {
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
aria-label={`Tema: ${LABELS[current]}. Cambiar tema`} aria-label={`Tema: ${THEME_LABELS[current]}. Cambiar tema`}
title={`Tema: ${LABELS[current]}`} title={`Tema: ${THEME_LABELS[current]}`}
className="flex size-11 shrink-0 items-center justify-center rounded-lg text-muted transition hover:bg-foreground/10 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus" className="flex size-11 shrink-0 items-center justify-center rounded-lg text-muted transition hover:bg-foreground/10 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
> >
<Icon icon={mounted ? ICONS[current] : iconMonitor} width={18} height={18} aria-hidden="true" /> <Icon icon={mounted ? THEME_ICONS[current] : iconMonitor} width={18} height={18} aria-hidden="true" />
</button> </button>
{open && ( {open && (
@@ -59,7 +53,7 @@ export function ThemeToggle() {
aria-label="Tema" 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" className="absolute right-0 top-[calc(100%+8px)] z-50 w-40 overflow-hidden rounded-xl border border-border bg-surface p-1.5 shadow-lg"
> >
{MODES.map((mode) => ( {THEME_MODES.map((mode) => (
<button <button
key={mode} key={mode}
type="button" type="button"
@@ -72,8 +66,8 @@ export function ThemeToggle() {
: "text-foreground hover:bg-foreground/5" : "text-foreground hover:bg-foreground/5"
}`} }`}
> >
<Icon icon={ICONS[mode]} width={16} height={16} aria-hidden="true" /> <Icon icon={THEME_ICONS[mode]} width={16} height={16} aria-hidden="true" />
{LABELS[mode]} {THEME_LABELS[mode]}
</button> </button>
))} ))}
</div> </div>
+15 -1
View File
@@ -1,8 +1,22 @@
"use client"; "use client";
import { createContext, useCallback, useContext, useLayoutEffect, useState } from "react"; import { createContext, useCallback, useContext, useLayoutEffect, useState } from "react";
import { iconMonitor, iconMoon, iconSun } from "../icons";
export type ThemeMode = "light" | "dark" | "system"; export const THEME_MODES = ["light", "dark", "system"] as const;
export type ThemeMode = (typeof THEME_MODES)[number];
export const THEME_ICONS: Record<ThemeMode, typeof iconSun> = {
light: iconSun,
dark: iconMoon,
system: iconMonitor,
};
export const THEME_LABELS: Record<ThemeMode, string> = {
light: "Claro",
dark: "Oscuro",
system: "Automático",
};
const STORAGE_KEY = "theme"; const STORAGE_KEY = "theme";
+4 -6
View File
@@ -1,6 +1,4 @@
export interface AuthUser { import type { AppUser } from "./entities";
id: number;
name: string; export type AuthUser = AppUser;
email: string;
role: string;
}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -79,7 +79,7 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
}`} }`}
> >
<span <span
className={`absolute inset-y-1.5 left-0 w-0.75 rounded-full transition-opacity ${ className={`absolute inset-y-1.5 left-0 w-[3px] rounded-full transition-opacity ${
active ? "bg-accent opacity-100" : "opacity-0" active ? "bg-accent opacity-100" : "opacity-0"
}`} }`}
aria-hidden="true" aria-hidden="true"
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { iconCalendar, iconCheckCircle, iconChevronDown } from "../../_core/icons"; import { iconCalendar, iconCheckCircle, iconChevronDown } from "../../_core/icons";
import { ApiError } from "../../_core/services/api-client"; import { ApiError } from "../../_core/services/api-client";
@@ -147,7 +147,6 @@ export function OperatingDayControl() {
title="Cerrar día de operación" 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." 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" confirmLabel="Cerrar día"
pendingLabel="Cerrando día…"
onConfirm={handleClose} onConfirm={handleClose}
/> />
</div> </div>
@@ -2,16 +2,19 @@
import { Avatar } from "@heroui/react"; import { Avatar } from "@heroui/react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTheme } from "../../_core/hooks/use-theme"; import {
THEME_ICONS,
THEME_LABELS,
THEME_MODES,
type ThemeMode,
useTheme,
} from "../../_core/hooks/use-theme";
import { import {
iconChevronDown, iconChevronDown,
iconIdentificationBadge, iconIdentificationBadge,
iconLogout, iconLogout,
iconMonitor,
iconMoon,
iconSun,
} from "../../_core/icons"; } from "../../_core/icons";
import type { AuthUser } from "../../_core/types/auth-user"; import type { AuthUser } from "../../_core/types/auth-user";
@@ -20,12 +23,6 @@ interface UserMenuProps {
onLogout: () => void; 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) { export function UserMenu({ user, onLogout }: UserMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
+1 -1
View File
@@ -36,7 +36,7 @@ export default function AdminCustomersPage() {
setDialogOpen(true); setDialogOpen(true);
} }
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim(); const name = String(formData.get("name") ?? "").trim();
@@ -3,7 +3,7 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Label, ListBox, Select } from "@heroui/react"; import { Label, ListBox, Select } from "@heroui/react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { notFound, useParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { iconArchive, iconArrowLeft, iconBox, iconScale } from "../../../_core/icons"; import { iconArchive, iconArrowLeft, iconBox, iconScale } from "../../../_core/icons";
@@ -25,6 +25,11 @@ type MovementType = "in" | "out";
export default function AdminInventoryDetailPage() { export default function AdminInventoryDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const inventoryId = Number(params.id); const inventoryId = Number(params.id);
if (Number.isNaN(inventoryId)) {
notFound();
}
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: inventory = null } = inventoriesResource.useDetail(inventoryId); const { data: inventory = null } = inventoriesResource.useDetail(inventoryId);
@@ -63,7 +68,7 @@ export default function AdminInventoryDetailPage() {
setDialogType(type); setDialogType(type);
} }
async function handleSubmitMovement(e: React.FormEvent<HTMLFormElement>) { async function handleSubmitMovement(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (!dialogType) return; if (!dialogType) return;
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
@@ -97,7 +102,7 @@ export default function AdminInventoryDetailPage() {
setCountOpen(true); setCountOpen(true);
} }
async function handleCountSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleCountSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (!stock) return; if (!stock) return;
setCounting(true); setCounting(true);
@@ -37,7 +37,7 @@ export default function AdminCategoriesPage() {
setDialogOpen(true); setDialogOpen(true);
} }
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const name = String(new FormData(e.currentTarget).get("name") ?? "").trim(); const name = String(new FormData(e.currentTarget).get("name") ?? "").trim();
setSubmitError(null); setSubmitError(null);
+1 -1
View File
@@ -38,7 +38,7 @@ export default function AdminInventoryPage() {
setDialogOpen(true); setDialogOpen(true);
} }
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const name = String(new FormData(e.currentTarget).get("name") ?? "").trim(); const name = String(new FormData(e.currentTarget).get("name") ?? "").trim();
setSubmitError(null); setSubmitError(null);
@@ -41,8 +41,6 @@ export default function AdminProductsPage() {
const createProduct = productsResource.useCreate(); const createProduct = productsResource.useCreate();
const updateProduct = productsResource.useUpdate(); const updateProduct = productsResource.useUpdate();
const removeProduct = productsResource.useRemove(); const removeProduct = productsResource.useRemove();
const createHistory = productHistoryResource.useCreate();
const updateHistory = productHistoryResource.useUpdate();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Product | null>(null); const [editing, setEditing] = useState<Product | null>(null);
@@ -103,18 +101,13 @@ export default function AdminProductsPage() {
} }
async function syncCategories(productId: number) { async function syncCategories(productId: number) {
const current = links.filter((l) => l.productId === productId).map((l) => l.categoryId); await apiClient.patch(`/products/${productId}/categories`, {
const toAdd = selectedCategoryIds.filter((id) => !current.includes(id)); categoryIds: selectedCategoryIds,
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 }); queryClient.invalidateQueries({ queryKey: productCategoriesResource.key });
} }
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim(); const name = String(formData.get("name") ?? "").trim();
@@ -151,7 +144,7 @@ export default function AdminProductsPage() {
} }
} }
async function handlePriceSubmit(e: React.FormEvent<HTMLFormElement>) { async function handlePriceSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (!pricing) return; if (!pricing) return;
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
@@ -161,11 +154,11 @@ export default function AdminProductsPage() {
setSubmitting(true); setSubmitting(true);
setSubmitError(null); setSubmitError(null);
try { try {
const current = activePriceByProduct.get(pricing.id); await apiClient.post(`/products/${pricing.id}/price`, {
if (current) { unitPrice,
await updateHistory.mutateAsync({ id: current.id, data: { status: "archived" } }); costUnit,
} });
await createHistory.mutateAsync({ productId: pricing.id, unitPrice, costUnit, status: "active" }); queryClient.invalidateQueries({ queryKey: productHistoryResource.key });
setPricing(null); setPricing(null);
} catch (err) { } catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "No se pudo actualizar el precio."); setSubmitError(err instanceof ApiError ? err.message : "No se pudo actualizar el precio.");
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { notFound, useParams } from "next/navigation";
import { useMemo } from "react"; import { useMemo } from "react";
import { iconArrowLeft, iconBox, iconDelivery } from "../../../_core/icons"; import { iconArrowLeft, iconBox, iconDelivery } from "../../../_core/icons";
import { inventoryMovementsResource, productsResource, suppliersResource } from "../../../_core/services/entities"; import { inventoryMovementsResource, productsResource, suppliersResource } from "../../../_core/services/entities";
@@ -12,6 +12,10 @@ export default function AdminSupplierDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const supplierId = Number(params.id); const supplierId = Number(params.id);
if (Number.isNaN(supplierId)) {
notFound();
}
const { data: supplier = null } = suppliersResource.useDetail(supplierId); const { data: supplier = null } = suppliersResource.useDetail(supplierId);
const { data: products = [] } = productsResource.useList(); const { data: products = [] } = productsResource.useList();
const { data: movements = [] } = inventoryMovementsResource.useList(); const { data: movements = [] } = inventoryMovementsResource.useList();
+1 -1
View File
@@ -37,7 +37,7 @@ export default function AdminSuppliersPage() {
setDialogOpen(true); setDialogOpen(true);
} }
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim(); const name = String(formData.get("name") ?? "").trim();
+1 -1
View File
@@ -43,7 +43,7 @@ export default function AdminUsersPage() {
setDialogOpen(true); setDialogOpen(true);
} }
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim(); const name = String(formData.get("name") ?? "").trim();
+3 -3
View File
@@ -14,14 +14,14 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PDV — Punto de Venta", title: "Create Next App",
description: "Sistema de Punto de Venta y Gestión de Inventario", description: "Generated by create next app",
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html <html
lang="es" lang="en"
suppressHydrationWarning suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
@@ -29,7 +29,7 @@ export function PasswordForm({ user, error, submitting, onSubmit, onBack }: Pass
passwordRef.current?.focus(); passwordRef.current?.focus();
}, [error]); }, [error]);
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
onSubmit(password); onSubmit(password);
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { iconStorefrontFill } from "../../_core/icons"; import { iconStorefrontFill } from "../../_core/icons";
@@ -2,7 +2,7 @@
import { Avatar } from "@heroui/react"; import { Avatar } from "@heroui/react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { motion, useReducedMotion } from "motion/react"; import { motion, useReducedMotion } from "framer-motion";
import { iconIdentificationBadge } from "../../_core/icons"; import { iconIdentificationBadge } from "../../_core/icons";
import type { AuthUser } from "../../_core/types/auth-user"; import type { AuthUser } from "../../_core/types/auth-user";
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
iconAddCircle, iconAddCircle,
@@ -62,7 +62,7 @@ export function CashRegisterControl() {
const totalOut = sessionMovements.filter((m) => m.type === "out").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; const expectedCash = session ? Number(session.openingCash) + totalIn - totalOut : 0;
async function handleOpen(e: React.FormEvent<HTMLFormElement>) { async function handleOpen(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const inventoryId = Number(formData.get("inventoryId")); const inventoryId = Number(formData.get("inventoryId"));
@@ -78,7 +78,7 @@ export function CashRegisterControl() {
} }
} }
async function handleMovement(e: React.FormEvent<HTMLFormElement>) { async function handleMovement(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (!session || !movementDialog) return; if (!session || !movementDialog) return;
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
@@ -102,7 +102,7 @@ export function CashRegisterControl() {
} }
} }
async function handleClose(e: React.FormEvent<HTMLFormElement>) { async function handleClose(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (!session) return; if (!session) return;
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
-1
View File
@@ -244,7 +244,6 @@ export default function PosHistoryPage() {
title="Devolver venta" 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.`} 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" confirmLabel="Confirmar devolución"
pendingLabel="Devolviendo…"
onConfirm={handleReturn} onConfirm={handleReturn}
/> />
</div> </div>
+152 -12
View File
@@ -27,8 +27,9 @@ import {
} from "../_core/services/entities"; } from "../_core/services/entities";
import { useSessionUser } from "../_core/hooks/use-session-user"; import { useSessionUser } from "../_core/hooks/use-session-user";
import { Dialog } from "../_core/components/dialog"; import { Dialog } from "../_core/components/dialog";
import { SelectInput, TextInput } from "../_core/components/form-controls"; import { Field, SelectInput, TextInput } from "../_core/components/form-controls";
import type { Product, ProductHistoryRow } from "../_core/types/entities"; import type { AppUser, Product, ProductHistoryRow } from "../_core/types/entities";
import { fetchUsers, login } from "../login/_services/auth.service";
import { useCashSession } from "./_components/cash-session-context"; import { useCashSession } from "./_components/cash-session-context";
interface CartLine { interface CartLine {
@@ -74,6 +75,14 @@ export default function PosSalePage() {
const [barcode, setBarcode] = useState(""); const [barcode, setBarcode] = useState("");
const [addError, setAddError] = useState<string | null>(null); const [addError, setAddError] = useState<string | null>(null);
const [discount, setDiscount] = useState(0); const [discount, setDiscount] = useState(0);
const [discountAuthorizedBy, setDiscountAuthorizedBy] = useState<number | null>(null);
const [authDialogOpen, setAuthDialogOpen] = useState(false);
const [pendingDiscount, setPendingDiscount] = useState<number | null>(null);
const [adminUsers, setAdminUsers] = useState<AppUser[]>([]);
const [authAdminId, setAuthAdminId] = useState<number | null>(null);
const [authPassword, setAuthPassword] = useState("");
const [authError, setAuthError] = useState<string | null>(null);
const [authorizing, setAuthorizing] = useState(false);
const [customerId, setCustomerId] = useState<string>(""); const [customerId, setCustomerId] = useState<string>("");
const [paymentMethod, setPaymentMethod] = useState("cash"); const [paymentMethod, setPaymentMethod] = useState("cash");
const [checkoutError, setCheckoutError] = useState<string | null>(null); const [checkoutError, setCheckoutError] = useState<string | null>(null);
@@ -82,6 +91,16 @@ export default function PosSalePage() {
const barcodeRef = useRef<HTMLInputElement>(null); const barcodeRef = useRef<HTMLInputElement>(null);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchUsers()
.then((users) => {
const admins = users.filter((u) => u.role === "admin");
setAdminUsers(admins);
if (admins.length > 0) setAuthAdminId(admins[0].id);
})
.catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
const isShortcut = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; const isShortcut = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
@@ -169,7 +188,7 @@ export default function PosSalePage() {
setQuery(""); setQuery("");
} }
function handleBarcodeSubmit(e: React.FormEvent) { function handleBarcodeSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const code = barcode.trim(); const code = barcode.trim();
if (!code) return; if (!code) return;
@@ -204,12 +223,53 @@ export default function PosSalePage() {
function clearCart() { function clearCart() {
setCart([]); setCart([]);
setDiscount(0); setDiscount(0);
setDiscountAuthorizedBy(null);
setCustomerId(""); setCustomerId("");
setCheckoutError(null); setCheckoutError(null);
} }
const subtotal = cart.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0); const subtotal = cart.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0);
const total = Math.max(0, subtotal - discount); const maxUnauthDiscount = Number((subtotal * 0.15).toFixed(2));
const effectiveDiscount = Math.min(discount, subtotal);
const total = Math.max(0, subtotal - effectiveDiscount);
function handleDiscountChange(rawValue: number) {
const clamped = Math.min(subtotal, Math.max(0, rawValue));
if (clamped <= maxUnauthDiscount || user?.role === "admin" || discountAuthorizedBy !== null) {
setDiscount(clamped);
if (clamped <= maxUnauthDiscount && discountAuthorizedBy !== null) {
setDiscountAuthorizedBy(null);
}
} else {
setPendingDiscount(clamped);
setAuthPassword("");
setAuthError(null);
setAuthDialogOpen(true);
}
}
async function handleAuthorizeDiscount(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (!authAdminId || pendingDiscount === null) return;
setAuthorizing(true);
setAuthError(null);
try {
const authorizedUser = await login(authAdminId, authPassword);
if (authorizedUser.role !== "admin") {
setAuthError("El usuario seleccionado no tiene permisos de administrador.");
return;
}
setDiscount(pendingDiscount);
setDiscountAuthorizedBy(authorizedUser.id);
setAuthDialogOpen(false);
setPendingDiscount(null);
setAuthPassword("");
} catch {
setAuthError("Contraseña incorrecta de administrador.");
} finally {
setAuthorizing(false);
}
}
async function handleCheckout() { async function handleCheckout() {
if (!session || !user || cart.length === 0) return; if (!session || !user || cart.length === 0) return;
@@ -218,7 +278,7 @@ export default function PosSalePage() {
try { try {
const cartRecord = await apiClient.post<{ id: number }>("/shop-carts", { const cartRecord = await apiClient.post<{ id: number }>("/shop-carts", {
totalPrice: total.toFixed(2), totalPrice: total.toFixed(2),
discount: discount.toFixed(2), discount: effectiveDiscount.toFixed(2),
status: "open", status: "open",
userId: user.id, userId: user.id,
customerId: customerId ? Number(customerId) : null, customerId: customerId ? Number(customerId) : null,
@@ -265,7 +325,7 @@ export default function PosSalePage() {
cartId: cartRecord.id, cartId: cartRecord.id,
lines: cart, lines: cart,
subtotal, subtotal,
discount, discount: effectiveDiscount,
total, total,
paymentMethod, paymentMethod,
customerName: customerId ? customers.find((c) => String(c.id) === customerId)?.name ?? null : null, customerName: customerId ? customers.find((c) => String(c.id) === customerId)?.name ?? null : null,
@@ -449,17 +509,28 @@ export default function PosSalePage() {
</div> </div>
<div> <div>
<label htmlFor="pos-discount" className="mb-1.5 block text-sm font-medium text-foreground"> <div className="mb-1.5 flex items-center justify-between">
Descuento <label htmlFor="pos-discount" className="text-sm font-medium text-foreground">
</label> Descuento
</label>
<span className="text-xs text-muted">
Máx. sin autorizar: {money(maxUnauthDiscount)} (15%)
</span>
</div>
<TextInput <TextInput
id="pos-discount" id="pos-discount"
type="number" type="number"
min={0} min={0}
max={subtotal}
step="0.01" step="0.01"
value={discount} value={effectiveDiscount}
onChange={(e) => setDiscount(Math.max(0, Number(e.target.value)))} onChange={(e) => handleDiscountChange(Number(e.target.value))}
/> />
{effectiveDiscount > maxUnauthDiscount && (
<p className="mt-1 text-xs text-warning">
Descuento superior al 15% (autorizado por administrador)
</p>
)}
</div> </div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3 text-sm"> <div className="flex flex-col gap-1.5 border-t border-border pt-3 text-sm">
@@ -469,7 +540,7 @@ export default function PosSalePage() {
</div> </div>
<div className="flex justify-between text-muted"> <div className="flex justify-between text-muted">
<span>Descuento</span> <span>Descuento</span>
<span className="tabular-nums">-{money(discount)}</span> <span className="tabular-nums">-{money(effectiveDiscount)}</span>
</div> </div>
<div className="flex justify-between text-base font-semibold text-foreground"> <div className="flex justify-between text-base font-semibold text-foreground">
<span>Total</span> <span>Total</span>
@@ -566,6 +637,75 @@ export default function PosSalePage() {
</div> </div>
)} )}
</Dialog> </Dialog>
<Dialog
open={authDialogOpen}
onClose={() => {
setAuthDialogOpen(false);
setPendingDiscount(null);
}}
title="Autorización de descuento"
widthClassName="max-w-sm"
>
<form onSubmit={handleAuthorizeDiscount} className="flex flex-col gap-4" noValidate>
<p className="text-sm text-muted">
El descuento solicitado ({money(pendingDiscount ?? 0)}) supera el 15% permitido sin autorización ({money(maxUnauthDiscount)}). Se requiere la clave de un administrador.
</p>
<Field label="Administrador" htmlFor="auth-admin" required>
<SelectInput
id="auth-admin"
value={authAdminId ?? ""}
onChange={(e) => setAuthAdminId(Number(e.target.value))}
>
{adminUsers.map((a) => (
<option key={a.id} value={a.id}>
{a.name} ({a.email})
</option>
))}
</SelectInput>
</Field>
<Field label="Contraseña de administrador" htmlFor="auth-password" required>
<TextInput
id="auth-password"
type="password"
required
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder="••••••••"
autoFocus
/>
</Field>
{authError && (
<p role="alert" className="text-sm text-danger">
{authError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => {
setAuthDialogOpen(false);
setPendingDiscount(null);
}}
disabled={authorizing}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={authorizing || !authPassword}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover disabled:opacity-50"
>
{authorizing ? "Verificando…" : "Autorizar descuento"}
</button>
</div>
</form>
</Dialog>
</div> </div>
); );
} }
+33 -63
View File
@@ -50,7 +50,7 @@
"@heroui/react": "^3.2.6", "@heroui/react": "^3.2.6",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.104.0", "@tanstack/react-query": "^5.104.0",
"motion": "^12.4.0", "framer-motion": "^13.4.0",
"next": "16.3.5", "next": "16.3.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
@@ -11780,6 +11780,29 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/framer-motion": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.0.tgz",
"integrity": "sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "^13.3.0",
"motion-utils": "^13.3.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -13775,72 +13798,19 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/motion": { "node_modules/motion-dom": {
"version": "12.43.0", "version": "13.3.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.3.0.tgz",
"integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==", "integrity": "sha512-AmAnB6pHdZ1vHGzWzuzteG6Q3j1lHKeX/lbST6jGlgm0cjvLUgaCk1xhOuPMOy5SgovN6wnnUdwFrkcsus7Ftg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"framer-motion": "^12.43.0", "motion-utils": "^13.3.0"
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
} }
}, },
"node_modules/motion/node_modules/framer-motion": { "node_modules/motion-utils": {
"version": "12.43.0", "version": "13.3.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz",
"integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.43.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion/node_modules/motion-dom": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
"integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion/node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ms": { "node_modules/ms": {
+6
View File
@@ -10,5 +10,11 @@
"build": "npm run build --workspace=@pdv/web && npm run build --workspace=@pdv/api", "build": "npm run build --workspace=@pdv/web && npm run build --workspace=@pdv/api",
"start": "npm run start:prod --workspace=@pdv/api", "start": "npm run start:prod --workspace=@pdv/api",
"lint": "npm run lint --workspace=@pdv/web && npm run lint --workspace=@pdv/api" "lint": "npm run lint --workspace=@pdv/web && npm run lint --workspace=@pdv/api"
},
"allowScripts": {
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true
} }
} }