feat(catalog): replace stock_tecnico with maximum_addable_quantity across components and services

This commit is contained in:
2026-08-19 17:00:03 -03:00
parent cc446433f6
commit 5c6a03b10d
15 changed files with 231 additions and 74 deletions

View File

@@ -12,6 +12,7 @@
[title]="item.nombre"
[description]="item.descripcion ?? ''"
[price]="price(item)"
[maximumAddableQuantity]="item.maximum_addable_quantity ?? null"
[variants]="item.variants ?? []"
[saving]="savingProductIds().has(item.id)"
(buy)="emitRowBuy(item, $event)"
@@ -23,6 +24,7 @@
[title]="item.nombre"
[description]="item.descripcion ?? ''"
[price]="price(item)"
[maximumAddableQuantity]="item.maximum_addable_quantity ?? null"
[variants]="item.variants ?? []"
[saving]="savingProductIds().has(item.id)"
(buy)="emitColumnBuy(item, $event)"

View File

@@ -193,8 +193,8 @@ describe('ProductListComponent', () => {
const itemWithVariants: ProductListItem = {
...items[0],
variants: [
{ id: 91, stock_tecnico: 3, values: { fecha: '10 de octubre' } },
{ id: 92, stock_tecnico: 4, values: { fecha: '11 de octubre' } },
{ id: 91, maximum_addable_quantity: 3, values: { fecha: '10 de octubre' } },
{ id: 92, maximum_addable_quantity: 4, values: { fecha: '11 de octubre' } },
],
};
const fixture = await render('column_with_cart', [itemWithVariants]);
@@ -214,6 +214,58 @@ describe('ProductListComponent', () => {
});
});
it('caps the quantity at the selected variant maximum', async () => {
const itemWithVariants: ProductListItem = {
...items[0],
variants: [
{
id: 91,
maximum_addable_quantity: 2,
values: { fecha: '10 de octubre' },
},
],
};
const fixture = await render('column_with_cart', [itemWithVariants]);
await fixture.whenStable();
fixture.detectChanges();
const increase = fixture.nativeElement.querySelector(
'.quantity-selector__button:last-child',
) as HTMLButtonElement;
increase.click();
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.quantity-selector__value')
?.textContent,
).toContain('2');
expect(increase.disabled).toBe(true);
});
it('disables purchase actions when the selected variant maximum is zero', async () => {
const unavailableItem: ProductListItem = {
...items[0],
variants: [
{
id: 91,
maximum_addable_quantity: 0,
values: { fecha: '10 de octubre' },
},
],
};
const fixture = await render('row', [unavailableItem]);
await fixture.whenStable();
fixture.detectChanges();
const buttons = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>(
'.product-row-card__buttons button',
),
);
expect(buttons).toHaveLength(2);
expect(buttons.every((button) => button.disabled)).toBe(true);
});
it('renders pagination and emits the requested page', async () => {
const fixture = await render('row');
const pageChangeSpy = vi.fn();
@@ -252,7 +304,7 @@ describe('ProductListComponent', () => {
{
id: 401,
precio: '10000.00',
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: { value: 'vip', label: 'VIP' },
sector: { value: 'a', label: 'Sector A' },
@@ -263,7 +315,7 @@ describe('ProductListComponent', () => {
{
id: 402,
precio: '12000.00',
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: { value: 'vip', label: 'VIP' },
sector: { value: 'a', label: 'Sector A' },

View File

@@ -21,7 +21,11 @@
[(selectedVariant)]="selectedVariant"
/>
<app-quantity-selector [(quantity)]="quantity"></app-quantity-selector>
<app-quantity-selector
[(quantity)]="quantity"
[max]="effectiveMaximum()"
[disabled]="unavailable()"
/>
</div>
<span class="product-row-card__price text-primary">
@@ -31,10 +35,16 @@
<div class="product-row-card__buttons">
<div class="product-row-card__btn-wrapper">
<app-button variant="primary" (click)="onBuy()"> Comprar </app-button>
<app-button variant="primary" [disabled]="unavailable()" (click)="onBuy()">
Comprar
</app-button>
</div>
<div class="product-row-card__btn-wrapper">
<app-button variant="secondary" [disabled]="saving()" (click)="onAddToCart()">
<app-button
variant="secondary"
[disabled]="saving() || unavailable()"
(click)="onAddToCart()"
>
{{ saving() ? 'Guardando' : 'Agregar al carrito' }}
</app-button>
</div>

View File

@@ -1,4 +1,12 @@
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
model,
output,
} from '@angular/core';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
import {
@@ -10,7 +18,7 @@ export interface Variant extends VariantSelectorVariant {
label?: string;
descripcion?: string | null;
precio?: string | number;
stock_tecnico?: number | null;
maximum_addable_quantity?: number | null;
}
@Component({
@@ -26,6 +34,7 @@ export class ProductRowCardComponent {
readonly title = input<string>('');
readonly description = input<string>('');
readonly price = input<number>(0);
readonly maximumAddableQuantity = input<number | null>(null);
readonly variants = input<Variant[]>([]);
readonly saving = input(false);
@@ -48,11 +57,25 @@ export class ProductRowCardComponent {
return Number.isFinite(variantPrice) ? variantPrice : this.price();
});
protected readonly effectiveMaximum = computed(
() => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(),
);
protected readonly unavailable = computed(() => this.effectiveMaximum() === 0);
readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
constructor() {
effect(() => {
const maximum = this.effectiveMaximum();
if (maximum !== null && maximum > 0 && this.quantity() > maximum) {
this.quantity.set(maximum);
}
});
}
protected onAddToCart(): void {
if (this.saving()) {
if (this.saving() || this.unavailable()) {
return;
}
@@ -63,6 +86,8 @@ export class ProductRowCardComponent {
}
protected onBuy(): void {
if (this.unavailable()) return;
this.buy.emit({
quantity: this.quantity(),
variant: this.selectedVariant(),

View File

@@ -361,7 +361,7 @@ export class ProductTicketSelectorComponent {
return {
id: item.variant.id,
precio: item.variant.precio,
stock_tecnico: item.variant.stock_tecnico,
maximum_addable_quantity: item.variant.stock_tecnico,
values: item.variant.values,
};
}

View File

@@ -11,13 +11,21 @@
@if (!hasVariants()) {
<div class="product-vertical-with-cart-card__summary">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
<app-quantity-selector
[(quantity)]="quantity"
[max]="effectiveMaximum()"
[disabled]="unavailable()"
/>
</div>
} @else {
<div class="product-vertical-with-cart-card__variants">
<div class="product-vertical-with-cart-card__summary">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
<app-quantity-selector
[(quantity)]="quantity"
[max]="effectiveMaximum()"
[disabled]="unavailable()"
/>
</div>
<div class="product-vertical-with-cart-card__variant-selectors">
@@ -29,14 +37,14 @@
<div class="product-vertical-with-cart-card__actions">
<app-button
variant="primary"
[disabled]="hasVariants() && selectedVariant() === null"
[disabled]="unavailable() || (hasVariants() && selectedVariant() === null)"
(click)="onBuy()"
>
Comprar
</app-button>
<app-button
variant="secondary"
[disabled]="saving() || (hasVariants() && selectedVariant() === null)"
[disabled]="saving() || unavailable() || (hasVariants() && selectedVariant() === null)"
(click)="onAddToCart()"
>
{{ saving() ? 'Guardando' : 'Agregar al carrito' }}

View File

@@ -1,4 +1,12 @@
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
model,
output,
} from '@angular/core';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
@@ -10,6 +18,7 @@ import {
export interface VerticalCartVariant extends VariantSelectorVariant {
descripcion?: string | null;
precio?: string | number;
maximum_addable_quantity?: number | null;
}
@Component({
@@ -23,6 +32,7 @@ export class ProductVerticalWithCartCardComponent {
readonly title = input<string>('');
readonly description = input<string>('');
readonly price = input<number>(0);
readonly maximumAddableQuantity = input<number | null>(null);
readonly variants = input<VerticalCartVariant[]>([]);
readonly saving = input(false);
@@ -45,9 +55,23 @@ export class ProductVerticalWithCartCardComponent {
});
protected readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
protected readonly hasVariants = computed(() => this.variants().length > 0);
protected readonly effectiveMaximum = computed(
() => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(),
);
protected readonly unavailable = computed(() => this.effectiveMaximum() === 0);
constructor() {
effect(() => {
const maximum = this.effectiveMaximum();
if (maximum !== null && maximum > 0 && this.quantity() > maximum) {
this.quantity.set(maximum);
}
});
}
protected onAddToCart(): void {
if (this.saving()) {
if (this.saving() || this.unavailable()) {
return;
}
@@ -55,6 +79,8 @@ export class ProductVerticalWithCartCardComponent {
}
protected onBuy(): void {
if (this.unavailable()) return;
this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
}