Skip to content

Commit bcfa3d9

Browse files
committed
Add product payload transformer and CRUD support
Introduce ProductFormState model and a product-payload.transformer with helpers (type conversions, transformToCreatePayload, transformToUpdatePayload, validateProductForm, mapProductBackendErrors). Make product request types nullable and add UpdateProductPayload. Add ProductService.create/update methods. Update product form template to surface field errors and product-form.ts to use computed formState, build create/update payloads, populate form from API, call create/update endpoints, and handle validation errors and tab switching.
1 parent 50f9aeb commit bcfa3d9

6 files changed

Lines changed: 473 additions & 102 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export interface ProductFormState {
2+
name: string;
3+
internal_code: string;
4+
category_id: string;
5+
manufacturer_id: string;
6+
unit: string;
7+
status_id: string;
8+
slug: string;
9+
description: string;
10+
11+
weight: number;
12+
height: number;
13+
width: number;
14+
length: number;
15+
16+
price: number;
17+
promotional_price: number | null;
18+
promotion_start_date: string;
19+
promotion_end_date: string;
20+
21+
warehouse_id: string;
22+
quantity: number;
23+
minimum_quantity: number;
24+
maximum_quantity: number | null;
25+
allow_backorder: boolean;
26+
}
Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
export interface CreateProductPayload {
2-
category_id: string;
3-
manufacturer_id: string;
4-
status_id?: string;
2+
category_id: string | null;
3+
manufacturer_id: string | null;
4+
status_id?: string | null;
55

6-
internal_code: string;
7-
name: string;
6+
internal_code: string | null;
7+
name: string | null;
88

9-
slug?: string;
10-
icon?: string;
11-
image?: string;
9+
slug?: string | null;
10+
icon?: string | null;
11+
image?: string | null;
1212

13-
short_description?: string;
14-
description?: string;
13+
short_description?: string | null;
14+
description?: string | null;
1515

1616
weight?: number;
1717
height?: number;
@@ -21,7 +21,7 @@ export interface CreateProductPayload {
2121
active?: boolean;
2222
featured?: boolean;
2323

24-
unit?: string;
24+
unit?: string | null;
2525

2626
price: ProductPricePayload;
2727

@@ -30,18 +30,44 @@ export interface CreateProductPayload {
3030

3131
export interface ProductPricePayload {
3232
price: number;
33-
promotional_price?: number;
34-
promotion_start?: string; // YYYY-MM-DD
35-
promotion_end?: string; // YYYY-MM-DD
33+
promotional_price?: number | null;
34+
promotion_start?: string | null;
35+
promotion_end?: string | null;
3636
}
3737

3838
export interface ProductInventoryPayload {
39-
warehouse_id: string;
39+
warehouse_id: string | null;
4040

4141
quantity: number;
4242

4343
minimum_quantity?: number;
44-
maximum_quantity?: number;
44+
maximum_quantity?: number | null;
4545

4646
allow_backorder?: boolean;
4747
}
48+
49+
export interface UpdateProductPayload {
50+
category_id?: string | null;
51+
manufacturer_id?: string | null;
52+
status_id?: string | null;
53+
54+
internal_code?: string | null;
55+
name?: string | null;
56+
57+
slug?: string | null;
58+
icon?: string | null;
59+
image?: string | null;
60+
61+
short_description?: string | null;
62+
description?: string | null;
63+
64+
weight?: number;
65+
height?: number;
66+
width?: number;
67+
length?: number;
68+
69+
active?: boolean;
70+
featured?: boolean;
71+
72+
unit?: string | null;
73+
}

src/app/core/services/product-service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { HttpClient } from '@angular/common/http';
22
import { inject, Injectable } from '@angular/core';
33
import { PaginatedResponse } from '../models/pagination/pagination.model';
4+
import {
5+
CreateProductPayload,
6+
UpdateProductPayload,
7+
} from '../models/products/product-request.model';
48
import { ProductResponse } from '../models/products/product-response.model';
59
import { environment } from '../../../environments/environment';
610

@@ -19,4 +23,12 @@ export class ProductService {
1923
getById(id: string) {
2024
return this.http.get<ProductResponse>(`${this.api}/${this.flag}/${id}`);
2125
}
26+
27+
create(payload: CreateProductPayload) {
28+
return this.http.post<ProductResponse>(`${this.api}/${this.flag}`, payload);
29+
}
30+
31+
update(id: string, payload: UpdateProductPayload) {
32+
return this.http.put<ProductResponse>(`${this.api}/${this.flag}/${id}`, payload);
33+
}
2234
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { ProductFormState } from '../models/products/product-form-state.model';
2+
import {
3+
CreateProductPayload,
4+
UpdateProductPayload,
5+
} from '../models/products/product-request.model';
6+
7+
export function toBoolean(val: unknown): boolean {
8+
if (val === true || val === 1 || val === 'true' || val === '1') {
9+
return true;
10+
}
11+
if (val === false || val === 0 || val === 'false' || val === '0') {
12+
return false;
13+
}
14+
return Boolean(val);
15+
}
16+
17+
export function toNumber(val: unknown): number {
18+
const parsed = parseFloat(String(val));
19+
return Number.isNaN(parsed) ? 0 : parsed;
20+
}
21+
22+
export function toInteger(val: unknown): number {
23+
const parsed = parseInt(String(val), 10);
24+
return Number.isNaN(parsed) ? 0 : parsed;
25+
}
26+
27+
export function toNullableNumber(val: unknown): number | null {
28+
if (val === null || val === undefined || String(val).trim() === '') {
29+
return null;
30+
}
31+
const parsed = parseFloat(String(val));
32+
return Number.isNaN(parsed) ? null : parsed;
33+
}
34+
35+
export function toNullableString(val: unknown): string | null {
36+
if (val === null || val === undefined || String(val).trim() === '') {
37+
return null;
38+
}
39+
return String(val).trim();
40+
}
41+
42+
export function toNullableDate(val: unknown): string | null {
43+
const normalized = toNullableString(val);
44+
if (!normalized) {
45+
return null;
46+
}
47+
48+
if (/^\d{4}-\d{2}-\d{2}T/.test(normalized)) {
49+
return normalized.replace('T', ' ').slice(0, 19);
50+
}
51+
52+
return normalized;
53+
}
54+
55+
function hasPromotionalPrice(promotionalPrice: number | null): boolean {
56+
return promotionalPrice !== null && promotionalPrice !== undefined && String(promotionalPrice).trim() !== '';
57+
}
58+
59+
function buildPriceBlock(state: ProductFormState) {
60+
const hasPromotion = hasPromotionalPrice(state.promotional_price);
61+
62+
return {
63+
price: toNumber(state.price),
64+
promotional_price: hasPromotion ? toNullableNumber(state.promotional_price) : null,
65+
promotion_start: hasPromotion ? toNullableDate(state.promotion_start_date) : null,
66+
promotion_end: hasPromotion ? toNullableDate(state.promotion_end_date) : null,
67+
};
68+
}
69+
70+
function buildInventoryBlock(state: ProductFormState) {
71+
return {
72+
warehouse_id: toNullableString(state.warehouse_id),
73+
quantity: toInteger(state.quantity),
74+
minimum_quantity: toInteger(state.minimum_quantity),
75+
maximum_quantity: toNullableNumber(state.maximum_quantity),
76+
allow_backorder: toBoolean(state.allow_backorder),
77+
};
78+
}
79+
80+
function buildProductRoot(state: ProductFormState) {
81+
return {
82+
category_id: toNullableString(state.category_id),
83+
manufacturer_id: toNullableString(state.manufacturer_id),
84+
status_id: toNullableString(state.status_id),
85+
internal_code: toNullableString(state.internal_code),
86+
name: toNullableString(state.name),
87+
slug: toNullableString(state.slug),
88+
icon: null,
89+
image: null,
90+
short_description: null,
91+
description: toNullableString(state.description),
92+
weight: toNumber(state.weight),
93+
height: toNumber(state.height),
94+
width: toNumber(state.width),
95+
length: toNumber(state.length),
96+
active: true,
97+
featured: false,
98+
unit: toNullableString(state.unit),
99+
};
100+
}
101+
102+
export function transformToCreatePayload(state: ProductFormState): CreateProductPayload {
103+
return {
104+
...buildProductRoot(state),
105+
price: buildPriceBlock(state),
106+
inventory: buildInventoryBlock(state),
107+
};
108+
}
109+
110+
export function transformToUpdatePayload(state: ProductFormState): UpdateProductPayload {
111+
return buildProductRoot(state);
112+
}
113+
114+
export function validateProductForm(
115+
state: ProductFormState,
116+
mode: 'create' | 'edit' | 'view' | 'duplicate',
117+
): Record<string, string> {
118+
const errs: Record<string, string> = {};
119+
const isCreateFlow = mode === 'create' || mode === 'duplicate';
120+
121+
if (!state.name.trim()) {
122+
errs['name'] = 'O nome do produto é obrigatório.';
123+
}
124+
if (!state.internal_code.trim()) {
125+
errs['internal_code'] = 'O código interno é obrigatório.';
126+
}
127+
if (!state.category_id) {
128+
errs['category_id'] = 'Selecione uma categoria.';
129+
}
130+
if (!state.manufacturer_id) {
131+
errs['manufacturer_id'] = 'Selecione um fabricante.';
132+
}
133+
if (!state.unit) {
134+
errs['unit_id'] = 'Selecione a unidade de medida.';
135+
}
136+
if (!state.status_id) {
137+
errs['status_id'] = 'Selecione o status do produto.';
138+
}
139+
140+
if (isCreateFlow) {
141+
const priceVal = state.price;
142+
if (priceVal <= 0) {
143+
errs['price'] = 'Informe um preço base válido superior a zero.';
144+
}
145+
146+
const hasPromoPrice = hasPromotionalPrice(state.promotional_price);
147+
const hasStartDate = !!state.promotion_start_date?.trim();
148+
const hasEndDate = !!state.promotion_end_date?.trim();
149+
150+
if (hasPromoPrice) {
151+
const parsedPromoPrice = Number(state.promotional_price);
152+
if (Number.isNaN(parsedPromoPrice) || parsedPromoPrice <= 0) {
153+
errs['promotional_price'] = 'Informe um preço promocional válido.';
154+
} else if (parsedPromoPrice >= priceVal) {
155+
errs['promotional_price'] = 'O preço promocional deve ser menor que o preço base.';
156+
}
157+
158+
if (!hasStartDate) {
159+
errs['promotion_start_date'] =
160+
'A data de início da promoção é obrigatória quando há preço promocional.';
161+
}
162+
if (!hasEndDate) {
163+
errs['promotion_end_date'] =
164+
'A data de término da promoção é obrigatória quando há preço promocional.';
165+
}
166+
}
167+
168+
if (hasStartDate || hasEndDate) {
169+
if (!hasPromoPrice) {
170+
errs['promotional_price'] =
171+
'O preço promocional é obrigatório quando as datas da promoção são informadas.';
172+
}
173+
}
174+
175+
if (!state.warehouse_id) {
176+
errs['warehouse_id'] = 'Selecione o depósito.';
177+
}
178+
179+
if (state.quantity === null || state.quantity === undefined || Number.isNaN(state.quantity)) {
180+
errs['quantity'] = 'A quantidade inicial é obrigatória.';
181+
}
182+
183+
const minQty = state.minimum_quantity;
184+
if (minQty < 0) {
185+
errs['min_quantity'] = 'A quantidade mínima deve ser maior ou igual a zero.';
186+
}
187+
188+
const maxQty = state.maximum_quantity;
189+
if (maxQty !== null && maxQty !== undefined && String(maxQty).trim() !== '') {
190+
const parsedMaxQty = Number(maxQty);
191+
if (Number.isNaN(parsedMaxQty)) {
192+
errs['max_quantity'] = 'A quantidade máxima deve ser um número válido.';
193+
} else if (parsedMaxQty < minQty) {
194+
errs['max_quantity'] = 'A quantidade máxima deve ser maior ou igual à quantidade mínima.';
195+
}
196+
}
197+
}
198+
199+
return errs;
200+
}
201+
202+
export function mapProductBackendErrors(
203+
backendErrors: Record<string, string[]>,
204+
): Record<string, string> {
205+
const mapped: Record<string, string> = {};
206+
207+
for (const key of Object.keys(backendErrors)) {
208+
const messages = backendErrors[key];
209+
const message = messages && messages.length > 0 ? messages[0] : '';
210+
if (!message) continue;
211+
212+
if (key === 'price.price') {
213+
mapped['price'] = message;
214+
} else if (key === 'price.promotional_price') {
215+
mapped['promotional_price'] = message;
216+
} else if (key === 'price.promotion_start') {
217+
mapped['promotion_start_date'] = message;
218+
} else if (key === 'price.promotion_end') {
219+
mapped['promotion_end_date'] = message;
220+
} else if (key === 'inventory.warehouse_id') {
221+
mapped['warehouse_id'] = message;
222+
} else if (key === 'inventory.quantity') {
223+
mapped['quantity'] = message;
224+
} else if (key === 'inventory.minimum_quantity') {
225+
mapped['min_quantity'] = message;
226+
} else if (key === 'inventory.maximum_quantity') {
227+
mapped['max_quantity'] = message;
228+
} else if (key === 'inventory.allow_backorder') {
229+
mapped['allow_backorder'] = message;
230+
} else {
231+
mapped[key] = message;
232+
}
233+
}
234+
235+
return mapped;
236+
}

0 commit comments

Comments
 (0)