Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23abee72d4 |
No files matched your search
@@ -5,7 +5,7 @@ import tseslint from 'typescript-eslint';
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['dist/**', 'src/prisma/contract.d.ts'],
|
ignores: ['dist/**'],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@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,12 +1,4 @@
|
|||||||
import {
|
import { Body, Controller, Get, Inject, Param, ParseIntPipe, Post } from "@nestjs/common";
|
||||||
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";
|
||||||
@@ -14,8 +6,7 @@ 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)
|
@Inject(CASH_MOVEMENT_SERVICE) private readonly cashMovementService: ICashMovementService,
|
||||||
private readonly cashMovementService: ICashMovementService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
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 {
|
import type { CashMovement, CashMovementKey } from "../../entities/cash-movement.entity.js";
|
||||||
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 = [
|
const COLUMNS = ["id", "sessionId", "type", "amount", "reason", "userId", "createdAt"] as const;
|
||||||
"id",
|
|
||||||
"sessionId",
|
|
||||||
"type",
|
|
||||||
"amount",
|
|
||||||
"reason",
|
|
||||||
"userId",
|
|
||||||
"createdAt",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CashMovementRepository implements ICashMovementRepository {
|
export class CashMovementRepository implements ICashMovementRepository {
|
||||||
@@ -27,17 +16,11 @@ export class CashMovementRepository implements ICashMovementRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findAll(): Promise<CashMovement[]> {
|
findAll(): Promise<CashMovement[]> {
|
||||||
return this.model
|
return this.model.select(...COLUMNS).all().toArray();
|
||||||
.select(...COLUMNS)
|
|
||||||
.all()
|
|
||||||
.toArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
findById(where: CashMovementKey): Promise<CashMovement | null> {
|
findById(where: CashMovementKey): Promise<CashMovement | null> {
|
||||||
return this.model
|
return this.model.select(...COLUMNS).where(where).first();
|
||||||
.select(...COLUMNS)
|
|
||||||
.where(where)
|
|
||||||
.first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
create(data: CreateCashMovementInput): Promise<CashMovement> {
|
create(data: CreateCashMovementInput): Promise<CashMovement> {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import type {
|
import type { CashMovement, CashMovementKey } from "../../entities/cash-movement.entity.js";
|
||||||
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,
|
||||||
@@ -13,8 +10,7 @@ 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)
|
@Inject(CASH_MOVEMENT_REPOSITORY) private readonly repository: ICashMovementRepository,
|
||||||
private readonly repository: ICashMovementRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(): Promise<CashMovement[]> {
|
findAll(): Promise<CashMovement[]> {
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import type {
|
import type { CashMovement, CashMovementKey } from "../../../entities/cash-movement.entity.js";
|
||||||
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,7 +1,4 @@
|
|||||||
import type {
|
import type { CashMovement, CashMovementKey } from "../../../entities/cash-movement.entity.js";
|
||||||
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,14 +1,4 @@
|
|||||||
import {
|
import { Body, Controller, Delete, Get, Inject, Param, ParseIntPipe, Patch, Post } from "@nestjs/common";
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
ParseIntPipe,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import type {
|
import type {
|
||||||
CreateCashRegisterSessionInput,
|
CreateCashRegisterSessionInput,
|
||||||
UpdateCashRegisterSessionInput,
|
UpdateCashRegisterSessionInput,
|
||||||
@@ -39,10 +29,7 @@ export class CashRegisterSessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
update(
|
update(@Param("id", ParseIntPipe) id: number, @Body() body: UpdateCashRegisterSessionInput) {
|
||||||
@Param("id", ParseIntPipe) id: number,
|
|
||||||
@Body() body: UpdateCashRegisterSessionInput,
|
|
||||||
) {
|
|
||||||
return this.sessionService.update({ id }, body);
|
return this.sessionService.update({ id }, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,8 @@ 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_REPOSITORY,
|
{ provide: CASH_REGISTER_SESSION_SERVICE, useClass: CashRegisterSessionService },
|
||||||
useClass: CashRegisterSessionRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: CASH_REGISTER_SESSION_SERVICE,
|
|
||||||
useClass: CashRegisterSessionService,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
exports: [CASH_REGISTER_SESSION_SERVICE],
|
exports: [CASH_REGISTER_SESSION_SERVICE],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,13 +31,9 @@ export class CashRegisterSessionService
|
|||||||
super(sessionRepository);
|
super(sessionRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
override async create(
|
override async create(data: CreateCashRegisterSessionInput): Promise<CashRegisterSession> {
|
||||||
data: CreateCashRegisterSessionInput,
|
|
||||||
): Promise<CashRegisterSession> {
|
|
||||||
const all = await this.sessionRepository.findAll();
|
const all = await this.sessionRepository.findAll();
|
||||||
const alreadyOpen = all.some(
|
const alreadyOpen = all.some((s) => s.inventoryId === data.inventoryId && s.status === "open");
|
||||||
(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");
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -8,9 +8,7 @@ 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(
|
export const CASH_REGISTER_SESSION_REPOSITORY = Symbol("CASH_REGISTER_SESSION_REPOSITORY");
|
||||||
"CASH_REGISTER_SESSION_REPOSITORY",
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ICashRegisterSessionRepository = IRepository<
|
export type ICashRegisterSessionRepository = IRepository<
|
||||||
CashRegisterSession,
|
CashRegisterSession,
|
||||||
|
|||||||
+1
-3
@@ -8,9 +8,7 @@ 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(
|
export const CASH_REGISTER_SESSION_SERVICE = Symbol("CASH_REGISTER_SESSION_SERVICE");
|
||||||
"CASH_REGISTER_SESSION_SERVICE",
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ICashRegisterSessionService = IService<
|
export type ICashRegisterSessionService = IService<
|
||||||
CashRegisterSession,
|
CashRegisterSession,
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
import {
|
import { Body, Controller, Delete, Get, Inject, Param, ParseIntPipe, Patch, Post } from "@nestjs/common";
|
||||||
Body,
|
import type { CreateCustomerInput, UpdateCustomerInput } from "../../dtos/customer.dto.js";
|
||||||
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(
|
constructor(@Inject(CUSTOMER_SERVICE) private readonly customerService: ICustomerService) {}
|
||||||
@Inject(CUSTOMER_SERVICE)
|
|
||||||
private readonly customerService: ICustomerService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll() {
|
findAll() {
|
||||||
@@ -39,10 +23,7 @@ export class CustomerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
update(
|
update(@Param("id", ParseIntPipe) id: number, @Body() body: UpdateCustomerInput) {
|
||||||
@Param("id", ParseIntPipe) id: number,
|
|
||||||
@Body() body: UpdateCustomerInput,
|
|
||||||
) {
|
|
||||||
return this.customerService.update({ id }, body);
|
return this.customerService.update({ id }, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
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 { CreateCustomerInput, UpdateCustomerInput } from "../../dtos/customer.dto.js";
|
||||||
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<
|
extends BaseRepository<Customer, CreateCustomerInput, UpdateCustomerInput, CustomerKey>
|
||||||
Customer,
|
|
||||||
CreateCustomerInput,
|
|
||||||
UpdateCustomerInput,
|
|
||||||
CustomerKey
|
|
||||||
>
|
|
||||||
implements ICustomerRepository
|
implements ICustomerRepository
|
||||||
{
|
{
|
||||||
protected readonly columns = [
|
protected readonly columns = ["id", "name", "phone", "email", "taxId", "createdAt"] as const;
|
||||||
"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,9 +1,6 @@
|
|||||||
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 {
|
import type { CreateCustomerInput, UpdateCustomerInput } from "../../dtos/customer.dto.js";
|
||||||
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,
|
||||||
@@ -13,12 +10,7 @@ import type { ICustomerService } from "./interfaces/customer-service.interface.j
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerService
|
export class CustomerService
|
||||||
extends BaseService<
|
extends BaseService<Customer, CreateCustomerInput, UpdateCustomerInput, CustomerKey>
|
||||||
Customer,
|
|
||||||
CreateCustomerInput,
|
|
||||||
UpdateCustomerInput,
|
|
||||||
CustomerKey
|
|
||||||
>
|
|
||||||
implements ICustomerService
|
implements ICustomerService
|
||||||
{
|
{
|
||||||
constructor(@Inject(CUSTOMER_REPOSITORY) repository: ICustomerRepository) {
|
constructor(@Inject(CUSTOMER_REPOSITORY) repository: ICustomerRepository) {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { IRepository } from "../../../common/repository.interface.js";
|
import type { IRepository } from "../../../common/repository.interface.js";
|
||||||
import type {
|
import type { Customer, CustomerKey } from "../../../entities/customer.entity.js";
|
||||||
Customer,
|
import type { CreateCustomerInput, UpdateCustomerInput } from "../../../dtos/customer.dto.js";
|
||||||
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,12 +1,6 @@
|
|||||||
import type { IService } from "../../../common/service.interface.js";
|
import type { IService } from "../../../common/service.interface.js";
|
||||||
import type {
|
import type { Customer, CustomerKey } from "../../../entities/customer.entity.js";
|
||||||
Customer,
|
import type { CreateCustomerInput, UpdateCustomerInput } from "../../../dtos/customer.dto.js";
|
||||||
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,12 +1,4 @@
|
|||||||
import {
|
import { Body, Controller, Get, Inject, Param, ParseIntPipe, Post } from "@nestjs/common";
|
||||||
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,10 +12,7 @@ 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,7 +20,9 @@ const COLUMNS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InventoryMovementRepository implements IInventoryMovementRepository {
|
export class InventoryMovementRepository
|
||||||
|
implements IInventoryMovementRepository
|
||||||
|
{
|
||||||
constructor(@Inject(DB) private readonly db: Database) {}
|
constructor(@Inject(DB) private readonly db: Database) {}
|
||||||
|
|
||||||
private get model() {
|
private get model() {
|
||||||
@@ -28,17 +30,11 @@ export class InventoryMovementRepository implements IInventoryMovementRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
findAll(): Promise<InventoryMovement[]> {
|
findAll(): Promise<InventoryMovement[]> {
|
||||||
return this.model
|
return this.model.select(...COLUMNS).all().toArray();
|
||||||
.select(...COLUMNS)
|
|
||||||
.all()
|
|
||||||
.toArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
findById(where: InventoryMovementKey): Promise<InventoryMovement | null> {
|
findById(where: InventoryMovementKey): Promise<InventoryMovement | null> {
|
||||||
return this.model
|
return this.model.select(...COLUMNS).where(where).first();
|
||||||
.select(...COLUMNS)
|
|
||||||
.where(where)
|
|
||||||
.first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
create(data: CreateInventoryMovementInput): Promise<InventoryMovement> {
|
create(data: CreateInventoryMovementInput): Promise<InventoryMovement> {
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
BadRequestException,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import {
|
import {
|
||||||
PRODUCT_SERVICE,
|
PRODUCT_SERVICE,
|
||||||
type IProductService,
|
type IProductService,
|
||||||
@@ -38,11 +33,9 @@ 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)
|
@Inject(WAREHOUSE_SERVICE) private readonly warehouseService: IWarehouseService,
|
||||||
private readonly warehouseService: IWarehouseService,
|
|
||||||
@Inject(PRODUCT_SERVICE) private readonly productService: IProductService,
|
@Inject(PRODUCT_SERVICE) private readonly productService: IProductService,
|
||||||
@Inject(SUPPLIER_SERVICE)
|
@Inject(SUPPLIER_SERVICE) private readonly supplierService: ISupplierService,
|
||||||
private readonly supplierService: ISupplierService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(): Promise<InventoryMovement[]> {
|
findAll(): Promise<InventoryMovement[]> {
|
||||||
@@ -73,25 +66,17 @@ 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(
|
throw new BadRequestException("Stock insuficiente para registrar la salida");
|
||||||
"Stock insuficiente para registrar la salida",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await this.warehouseService.update(key, {
|
await this.warehouseService.update(key, { stock: currentStock - data.quantity });
|
||||||
stock: currentStock - data.quantity,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.warehouseService.update(key, {
|
await this.warehouseService.update(key, { stock: existing.stock + data.quantity });
|
||||||
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
|
const product = await this.productService.findById({ id: data.productId }).catch(() => null);
|
||||||
.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,12 +1,6 @@
|
|||||||
import type { IRepository } from "../../../common/repository.interface.js";
|
import type { IRepository } from "../../../common/repository.interface.js";
|
||||||
import type {
|
import type { OperatingDay, OperatingDayKey } from "../../../entities/operating-day.entity.js";
|
||||||
OperatingDay,
|
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../../dtos/operating-day.dto.js";
|
||||||
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,12 +1,6 @@
|
|||||||
import type { IService } from "../../../common/service.interface.js";
|
import type { IService } from "../../../common/service.interface.js";
|
||||||
import type {
|
import type { OperatingDay, OperatingDayKey } from "../../../entities/operating-day.entity.js";
|
||||||
OperatingDay,
|
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../../dtos/operating-day.dto.js";
|
||||||
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,18 +1,5 @@
|
|||||||
import {
|
import { Body, Controller, Delete, Get, Inject, Param, ParseIntPipe, Patch, Post } from "@nestjs/common";
|
||||||
Body,
|
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../dtos/operating-day.dto.js";
|
||||||
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";
|
||||||
|
|
||||||
@@ -39,10 +26,7 @@ export class OperatingDayController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
update(
|
update(@Param("id", ParseIntPipe) id: number, @Body() body: UpdateOperatingDayInput) {
|
||||||
@Param("id", ParseIntPipe) id: number,
|
|
||||||
@Body() body: UpdateOperatingDayInput,
|
|
||||||
) {
|
|
||||||
return this.operatingDayService.update({ id }, body);
|
return this.operatingDayService.update({ id }, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,15 @@
|
|||||||
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 { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../dtos/operating-day.dto.js";
|
||||||
CreateOperatingDayInput,
|
import type { OperatingDay, OperatingDayKey } from "../../entities/operating-day.entity.js";
|
||||||
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<
|
extends BaseRepository<OperatingDay, CreateOperatingDayInput, UpdateOperatingDayInput, OperatingDayKey>
|
||||||
OperatingDay,
|
|
||||||
CreateOperatingDayInput,
|
|
||||||
UpdateOperatingDayInput,
|
|
||||||
OperatingDayKey
|
|
||||||
>
|
|
||||||
implements IOperatingDayRepository
|
implements IOperatingDayRepository
|
||||||
{
|
{
|
||||||
protected readonly columns = [
|
protected readonly columns = ["id", "date", "status", "openedById", "closedById", "openedAt", "closedAt"] as const;
|
||||||
"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,31 +1,17 @@
|
|||||||
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 {
|
import type { CreateOperatingDayInput, UpdateOperatingDayInput } from "../../dtos/operating-day.dto.js";
|
||||||
CreateOperatingDayInput,
|
import type { OperatingDay, OperatingDayKey } from "../../entities/operating-day.entity.js";
|
||||||
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 {
|
import { OPERATING_DAY_REPOSITORY, type IOperatingDayRepository } from "./interfaces/operating-day-repository.interface.js";
|
||||||
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<
|
extends BaseService<OperatingDay, CreateOperatingDayInput, UpdateOperatingDayInput, OperatingDayKey>
|
||||||
OperatingDay,
|
|
||||||
CreateOperatingDayInput,
|
|
||||||
UpdateOperatingDayInput,
|
|
||||||
OperatingDayKey
|
|
||||||
>
|
|
||||||
implements IOperatingDayService
|
implements IOperatingDayService
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
@@ -46,29 +32,20 @@ export class OperatingDayService
|
|||||||
return this.operatingDayRepository.create(data);
|
return this.operatingDayRepository.create(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
override async update(
|
override async update(where: OperatingDayKey, data: UpdateOperatingDayInput): Promise<OperatingDay> {
|
||||||
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(
|
throw new BadRequestException("Esta fecha de operación ya está cerrada");
|
||||||
"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(
|
throw new BadRequestException("No se puede cerrar el día de operación con cajas abiertas");
|
||||||
"No se puede cerrar el día de operación con cajas abiertas",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!data.closedById) {
|
if (!data.closedById) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("Falta indicar quién cierra la fecha de operación");
|
||||||
"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,26 +7,9 @@ import type {
|
|||||||
|
|
||||||
export const PRODUCT_REPOSITORY = Symbol("PRODUCT_REPOSITORY");
|
export const PRODUCT_REPOSITORY = Symbol("PRODUCT_REPOSITORY");
|
||||||
|
|
||||||
export interface SetPriceInput {
|
export type IProductRepository = IRepository<
|
||||||
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,26 +5,11 @@ 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 interface IProductService extends IService<
|
export type IProductService = 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,22 +37,6 @@ 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,4 +1,3 @@
|
|||||||
/* 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 {
|
||||||
@@ -33,36 +32,4 @@ 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,22 +25,6 @@ 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 { BadRequestException, 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 {
|
import type {
|
||||||
CreateShopCartInput,
|
CreateShopCartInput,
|
||||||
@@ -25,34 +25,6 @@ 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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
"framer-motion": "^13.4.0",
|
"motion": "^12.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,6 +10,7 @@ interface ConfirmDialogProps {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
|
pendingLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmDialog({
|
export function ConfirmDialog({
|
||||||
@@ -19,10 +20,14 @@ 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);
|
||||||
@@ -61,7 +66,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 ? "Eliminando…" : confirmLabel}
|
{submitting ? activePendingLabel : 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, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import { iconCloseSquare } from "../icons";
|
import { iconCloseSquare } from "../icons";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
@@ -26,21 +26,38 @@ 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) return;
|
if (!open) {
|
||||||
|
setActiveIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
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("pointerdown", onPointerDown);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onGlobalKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("pointerdown", onPointerDown);
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onGlobalKeyDown);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -54,6 +71,17 @@ 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]);
|
||||||
}
|
}
|
||||||
@@ -62,10 +90,49 @@ 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={() => 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" : ""}`}
|
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) => (
|
||||||
@@ -89,14 +156,22 @@ 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="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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -104,25 +179,35 @@ export function MultiSelect({
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<ul
|
<ul
|
||||||
id="multi-select-listbox"
|
ref={listboxRef}
|
||||||
|
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) => {
|
results.map((o, idx) => {
|
||||||
const isSelected = selected.includes(o.id);
|
const isSelected = selected.includes(o.id);
|
||||||
|
const isHighlighted = activeIndex === idx;
|
||||||
return (
|
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
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { THEME_ICONS, THEME_LABELS, THEME_MODES, type ThemeMode, useTheme } from "../hooks/use-theme";
|
import { useTheme } from "../hooks/use-theme";
|
||||||
import { iconMonitor } from "../icons";
|
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() {
|
export function ThemeToggle() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const mounted = theme !== undefined;
|
const mounted = theme !== undefined;
|
||||||
const current: ThemeMode = mounted && THEME_MODES.includes(theme as ThemeMode) ? (theme as ThemeMode) : "system";
|
const current: Mode = mounted && MODES.includes(theme as Mode) ? (theme as Mode) : "system";
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -28,7 +34,7 @@ export function ThemeToggle() {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
function select(mode: ThemeMode) {
|
function select(mode: Mode) {
|
||||||
setTheme(mode);
|
setTheme(mode);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
@@ -40,11 +46,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: ${THEME_LABELS[current]}. Cambiar tema`}
|
aria-label={`Tema: ${LABELS[current]}. Cambiar tema`}
|
||||||
title={`Tema: ${THEME_LABELS[current]}`}
|
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"
|
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 ? THEME_ICONS[current] : iconMonitor} width={18} height={18} aria-hidden="true" />
|
<Icon icon={mounted ? ICONS[current] : iconMonitor} width={18} height={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
@@ -53,7 +59,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"
|
||||||
>
|
>
|
||||||
{THEME_MODES.map((mode) => (
|
{MODES.map((mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -66,8 +72,8 @@ export function ThemeToggle() {
|
|||||||
: "text-foreground hover:bg-foreground/5"
|
: "text-foreground hover:bg-foreground/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon icon={THEME_ICONS[mode]} width={16} height={16} aria-hidden="true" />
|
<Icon icon={ICONS[mode]} width={16} height={16} aria-hidden="true" />
|
||||||
{THEME_LABELS[mode]}
|
{LABELS[mode]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
"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 const THEME_MODES = ["light", "dark", "system"] as const;
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
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";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { AppUser } from "./entities";
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
export type AuthUser = AppUser;
|
name: string;
|
||||||
|
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 "framer-motion";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
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-[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"
|
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 "framer-motion";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
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,6 +147,7 @@ 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,19 +2,16 @@
|
|||||||
|
|
||||||
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 "framer-motion";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { useTheme } from "../../_core/hooks/use-theme";
|
||||||
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";
|
||||||
|
|
||||||
@@ -23,6 +20,12 @@ 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();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function AdminCustomersPage() {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<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 { notFound, useParams } from "next/navigation";
|
import { 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,11 +25,6 @@ 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);
|
||||||
@@ -68,7 +63,7 @@ export default function AdminInventoryDetailPage() {
|
|||||||
setDialogType(type);
|
setDialogType(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmitMovement(e: React.SubmitEvent<HTMLFormElement>) {
|
async function handleSubmitMovement(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!dialogType) return;
|
if (!dialogType) return;
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
@@ -102,7 +97,7 @@ export default function AdminInventoryDetailPage() {
|
|||||||
setCountOpen(true);
|
setCountOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCountSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
async function handleCountSubmit(e: React.FormEvent<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.SubmitEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<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);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function AdminInventoryPage() {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<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,6 +41,8 @@ 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);
|
||||||
@@ -101,13 +103,18 @@ export default function AdminProductsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function syncCategories(productId: number) {
|
async function syncCategories(productId: number) {
|
||||||
await apiClient.patch(`/products/${productId}/categories`, {
|
const current = links.filter((l) => l.productId === productId).map((l) => l.categoryId);
|
||||||
categoryIds: selectedCategoryIds,
|
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 });
|
queryClient.invalidateQueries({ queryKey: productCategoriesResource.key });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<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();
|
||||||
@@ -144,7 +151,7 @@ export default function AdminProductsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePriceSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
async function handlePriceSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!pricing) return;
|
if (!pricing) return;
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
@@ -154,11 +161,11 @@ export default function AdminProductsPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/products/${pricing.id}/price`, {
|
const current = activePriceByProduct.get(pricing.id);
|
||||||
unitPrice,
|
if (current) {
|
||||||
costUnit,
|
await updateHistory.mutateAsync({ id: current.id, data: { status: "archived" } });
|
||||||
});
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: productHistoryResource.key });
|
await createHistory.mutateAsync({ productId: pricing.id, unitPrice, costUnit, status: "active" });
|
||||||
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 { notFound, useParams } from "next/navigation";
|
import { 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,10 +12,6 @@ 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();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function AdminSuppliersPage() {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<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();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function AdminUsersPage() {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<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();
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "PDV — Punto de Venta",
|
||||||
description: "Generated by create next app",
|
description: "Sistema de Punto de Venta y Gestión de Inventario",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="es"
|
||||||
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.SubmitEvent<HTMLFormElement>) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
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 "framer-motion";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
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 "framer-motion";
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
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 "framer-motion";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
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.SubmitEvent<HTMLFormElement>) {
|
async function handleOpen(e: React.FormEvent<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.SubmitEvent<HTMLFormElement>) {
|
async function handleMovement(e: React.FormEvent<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.SubmitEvent<HTMLFormElement>) {
|
async function handleClose(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ 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>
|
||||||
|
|||||||
+12
-152
@@ -27,9 +27,8 @@ 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 { Field, SelectInput, TextInput } from "../_core/components/form-controls";
|
import { SelectInput, TextInput } from "../_core/components/form-controls";
|
||||||
import type { AppUser, Product, ProductHistoryRow } from "../_core/types/entities";
|
import type { 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 {
|
||||||
@@ -75,14 +74,6 @@ 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);
|
||||||
@@ -91,16 +82,6 @@ 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";
|
||||||
@@ -188,7 +169,7 @@ export default function PosSalePage() {
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBarcodeSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
function handleBarcodeSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const code = barcode.trim();
|
const code = barcode.trim();
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
@@ -223,53 +204,12 @@ 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 maxUnauthDiscount = Number((subtotal * 0.15).toFixed(2));
|
const total = Math.max(0, subtotal - discount);
|
||||||
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;
|
||||||
@@ -278,7 +218,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: effectiveDiscount.toFixed(2),
|
discount: discount.toFixed(2),
|
||||||
status: "open",
|
status: "open",
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
customerId: customerId ? Number(customerId) : null,
|
customerId: customerId ? Number(customerId) : null,
|
||||||
@@ -325,7 +265,7 @@ export default function PosSalePage() {
|
|||||||
cartId: cartRecord.id,
|
cartId: cartRecord.id,
|
||||||
lines: cart,
|
lines: cart,
|
||||||
subtotal,
|
subtotal,
|
||||||
discount: effectiveDiscount,
|
discount,
|
||||||
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,
|
||||||
@@ -509,28 +449,17 @@ export default function PosSalePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
<label htmlFor="pos-discount" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
<label htmlFor="pos-discount" className="text-sm font-medium text-foreground">
|
Descuento
|
||||||
Descuento
|
</label>
|
||||||
</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={effectiveDiscount}
|
value={discount}
|
||||||
onChange={(e) => handleDiscountChange(Number(e.target.value))}
|
onChange={(e) => setDiscount(Math.max(0, 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">
|
||||||
@@ -540,7 +469,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(effectiveDiscount)}</span>
|
<span className="tabular-nums">-{money(discount)}</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>
|
||||||
@@ -637,75 +566,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Generated
+63
-33
@@ -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",
|
||||||
"framer-motion": "^13.4.0",
|
"motion": "^12.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,29 +11780,6 @@
|
|||||||
"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",
|
||||||
@@ -13798,19 +13775,72 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion": {
|
||||||
"version": "13.3.0",
|
"version": "12.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz",
|
||||||
"integrity": "sha512-AmAnB6pHdZ1vHGzWzuzteG6Q3j1lHKeX/lbST6jGlgm0cjvLUgaCk1xhOuPMOy5SgovN6wnnUdwFrkcsus7Ftg==",
|
"integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-utils": "^13.3.0"
|
"framer-motion": "^12.43.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-utils": {
|
"node_modules/motion/node_modules/framer-motion": {
|
||||||
"version": "13.3.0",
|
"version": "12.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
|
||||||
"integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==",
|
"integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==",
|
||||||
|
"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": {
|
||||||
|
|||||||
@@ -10,11 +10,5 @@
|
|||||||
"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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user