feat: implement product creation and update features with form handling and routing

This commit is contained in:
2025-12-06 17:48:16 +03:30
parent 70f39de9d8
commit abf53bac03
22 changed files with 606 additions and 48 deletions
@@ -2,7 +2,6 @@ import { ProductBrandsSelectComponent } from '@/modules/productBrands/components
import { ProductCategoriesSelectComponent } from '@/modules/productCategories/components';
import { InputComponent } from '@/shared/components';
import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component';
import { UikitFieldComponent } from '@/uikit';
import { Component, effect, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Dialog } from 'primeng/dialog';
@@ -17,7 +16,6 @@ import { ProductsService } from '../services/main.service';
Dialog,
InputComponent,
FormFooterActionsComponent,
UikitFieldComponent,
ProductBrandsSelectComponent,
ProductCategoriesSelectComponent,
],
@@ -49,8 +47,8 @@ export class ProductFormComponent {
name: [this.initialValues?.name || null, [Validators.required]],
description: [this.initialValues?.description || null],
// productType: [this.initialValues?.productType || null, [Validators.required]],
brandId: [this.initialValues?.brandId || null, [Validators.required]],
categoryId: [this.initialValues?.categoryId || null],
brandId: [this.initialValues?.brand?.id || null, [Validators.required]],
categoryId: [this.initialValues?.category?.id || null],
});
submitLoading = signal(false);
@@ -0,0 +1,55 @@
<div class="flex flex-col gap-4">
<div class="flex items-center gap-5 justify-between">
<div class="flex items-center gap-2">
<p-button icon="pi pi-arrow-right" aria-label="بازگشت" outlined [routerLink]="backRoute" />
<h3 class="m-0">{{ initialData ? "ویرایش محصول" : "ایجاد محصول جدید" }}</h3>
</div>
<div class="flex items-center gap-2">
<button
pButton
label="انصراف"
type="button"
outlined
icon="pi pi-times"
routerLink="/products"
[disabled]="form.disabled || loading"
></button>
<button pButton label="ذخیره" type="submit" icon="pi pi-check" [loading]="loading" (click)="submit()"></button>
</div>
</div>
<div class="flex gap-3">
<div class="flex flex-col gap-3 grow">
<p-card header="جزئیات محصول" class="w-full">
<form [formGroup]="form" (submit)="submit()" class="grid grid-cols-2 gap-4">
<app-input label="نام" [control]="form.controls.name" name="name" class="col-span-2" />
<app-input label="SKU" [control]="form.controls.sku" name="sku" />
<barcode-input [control]="form.controls.barcode" name="barcode" />
<product-brands-select-field [control]="form.controls.brandId" [canInsert]="true" />
<uikit-field label="توضیحات" [control]="form.controls.description" name="description" class="col-span-2">
<textarea
pTextarea
[formControl]="form.controls.description"
name="description"
rows="4"
class="w-full"
></textarea>
</uikit-field>
</form>
</p-card>
</div>
<div class="shrink-0 w-md">
<div class="flex flex-col gap-3 grow">
<p-card header="قیمت">
<form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4">
<app-input label="قیمت پایه‌ی فروش" [control]="form.controls.basePrice" name="basePrice" />
</form>
</p-card>
<p-card header="اطلاعات تکمیلی">
<form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4">
<product-categories-select-field [control]="form.controls.categoryId" [canInsert]="true" />
</form>
</p-card>
</div>
</div>
</div>
</div>
@@ -0,0 +1,72 @@
import { ProductBrandsSelectComponent } from '@/modules/productBrands/components';
import { ProductCategoriesSelectComponent } from '@/modules/productCategories/components';
import { InputComponent } from '@/shared/components';
import { BarcodeInputComponent } from '@/shared/components/barcodeInput/barcode-input.component';
import { UikitFieldComponent } from '@/uikit';
import { Component, effect, EventEmitter, inject, Input, Output } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { Button, ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { TextareaModule } from 'primeng/textarea';
import { IProductRequest, IProductResponse } from '../../models';
@Component({
selector: 'product-full-form',
templateUrl: './full-form.component.html',
imports: [
Button,
RouterLink,
ButtonDirective,
Card,
ReactiveFormsModule,
InputComponent,
UikitFieldComponent,
TextareaModule,
BarcodeInputComponent,
ProductBrandsSelectComponent,
ProductCategoriesSelectComponent,
],
})
export class ProductFullFormComponent {
@Input() initialData?: IProductResponse;
@Input() loading: boolean = false;
@Output() onSubmit = new EventEmitter<IProductRequest>();
private fb = inject(FormBuilder);
constructor() {
effect(() => {
if (this.initialData) {
this.form.patchValue({
...this.initialData,
brandId: this.initialData.brand?.id || null,
categoryId: this.initialData.category?.id || null,
});
} else {
this.form.reset();
}
});
}
form = this.fb.group({
name: [this.initialData?.name || '', [Validators.required]],
brandId: [this.initialData?.brand?.id || null, [Validators.required]],
sku: [this.initialData?.sku || null],
barcode: [this.initialData?.barcode || null],
description: [this.initialData?.description || null],
categoryId: [this.initialData?.category?.id || null],
imageUrl: [null],
basePrice: [this.initialData?.basePrice || 0, [Validators.required, Validators.min(0)]],
});
get backRoute() {
return this.initialData ? ['/products', this.initialData.id] : ['/products'];
}
submit() {
this.form.markAsTouched();
if (this.form.valid) {
this.onSubmit.emit(this.form.value as unknown as IProductRequest);
}
}
}
@@ -1,7 +1,7 @@
import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router';
export type TProductsRouteNames = 'products' | 'product';
export type TProductsRouteNames = 'products' | 'product' | 'create' | 'update';
export const productsNamedRoutes: NamedRoutes<TProductsRouteNames> = {
products: {
@@ -12,6 +12,15 @@ export const productsNamedRoutes: NamedRoutes<TProductsRouteNames> = {
pagePath: () => '/products',
},
},
create: {
path: 'products/create',
loadComponent: () =>
import('../../views/create.component').then((m) => m.CreateProductComponent),
meta: {
title: 'ایجاد محصول جدید',
pagePath: () => '/products/create',
},
},
product: {
path: 'products/:productId',
loadComponent: () => import('../../views/single.component').then((m) => m.ProductComponent),
@@ -20,6 +29,15 @@ export const productsNamedRoutes: NamedRoutes<TProductsRouteNames> = {
pagePath: () => '/products/:productId',
},
},
update: {
path: 'products/:productId/update',
loadComponent: () =>
import('../../views/update.component').then((m) => m.UpdateProductComponent),
meta: {
title: 'محصول',
pagePath: () => '/products/:productId/update',
},
},
};
export const PRODUCTS_ROUTES: Routes = Object.values(productsNamedRoutes);
+18 -10
View File
@@ -1,23 +1,31 @@
import { Maybe } from '@/core';
import { IProductBrand, IProductCategory } from './others';
export interface IProductRawResponse {
id: number;
name: string;
description: string;
productType: string;
description?: string;
barcode?: Maybe<string>;
sku?: Maybe<string>;
// productType: string;
brand?: IProductBrand;
category?: IProductCategory;
// supplierId: number;
basePrice?: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
brandId: number;
categoryId: number;
supplierId: number;
deletedAt?: Maybe<Date>;
}
export interface IProductResponse extends IProductRawResponse {}
export interface IProductRequest {
name: string;
description: string;
productType: string;
// productType: string;
brandId: number;
categoryId: number;
supplierId: number;
description: Maybe<string>;
barcode: Maybe<string>;
sku: Maybe<string>;
categoryId: Maybe<number>;
// supplierId: number;
}
@@ -0,0 +1,8 @@
export interface IProductCategory {
id: number;
name: string;
}
export interface IProductBrand {
id: number;
name: string;
}
@@ -22,4 +22,7 @@ export class ProductsService {
create(data: IProductRequest): Observable<IProductResponse> {
return this.http.post<IProductRawResponse>(this.apiRoutes.list(), data);
}
update(productId: string, data: Partial<IProductRequest>): Observable<IProductResponse> {
return this.http.patch<IProductRawResponse>(this.apiRoutes.single(productId), data);
}
}
@@ -0,0 +1 @@
<product-full-form [loading]="submitLoading()" (onSubmit)="submit($event)" />
@@ -0,0 +1,37 @@
import { ToastService } from '@/core/services/toast.service';
import { Component, signal } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { TextareaModule } from 'primeng/textarea';
import { ProductFullFormComponent } from '../components/fullForm/full-form.component';
import { IProductRequest } from '../models';
import { ProductsService } from '../services/main.service';
@Component({
selector: 'app-product-create',
templateUrl: './create.component.html',
imports: [TextareaModule, ReactiveFormsModule, ProductFullFormComponent],
})
export class CreateProductComponent {
constructor(
private service: ProductsService,
private toastService: ToastService,
private router: Router,
) {}
submitLoading = signal(false);
submit(payload: IProductRequest) {
this.submitLoading.set(true);
this.service.create(payload).subscribe({
next: (res) => {
this.toastService.success({ text: `محصول ${payload.name} با موفقیت ایجاد شد` });
this.router.navigate(['products', res.id]);
this.submitLoading.set(false);
},
error: (err) => {
this.submitLoading.set(false);
},
});
}
}
+2
View File
@@ -1,2 +1,4 @@
export * from './create.component';
export * from './list.component';
export * from './single.component';
export * from './update.component';
@@ -9,6 +9,7 @@
[loading]="loading()"
[showDetails]="true"
(onAdd)="openAddForm()"
(onDetails)="toDetails($event)"
/>
<product-form [(visible)]="visibleForm" (onSubmit)="refresh()" />
@@ -3,7 +3,9 @@ import {
PageDataListComponent,
} from '@/shared/components/pageDataList/page-data-list.component';
import { Component, signal } from '@angular/core';
import { Router } from '@angular/router';
import { ProductFormComponent } from '../components/form.component';
import { productsNamedRoutes } from '../constants';
import { IProductResponse } from '../models';
import { ProductsService } from '../services/main.service';
@@ -13,20 +15,32 @@ import { ProductsService } from '../services/main.service';
imports: [PageDataListComponent, ProductFormComponent],
})
export class ProductsComponent {
constructor(private productService: ProductsService) {
constructor(
private productService: ProductsService,
private router: Router,
) {
this.getData();
}
columns = [
{ field: 'id', header: 'شناسه' },
{ field: 'name', header: 'نام' },
{ field: 'description', header: 'توضیحات' },
{ field: 'productType', header: 'نوع محصول' },
{ field: 'brandId', header: 'برند' },
{ field: 'categoryId', header: 'دسته‌بندی' },
{ field: 'supplierId', header: 'تامین‌کننده' },
{ field: 'createdAt', header: 'تاریخ ایجاد' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی' },
{
field: 'brand',
header: 'برند',
customDataModel(item) {
return item.brand?.name || '-';
},
},
{
field: 'category',
header: 'دسته‌بندی',
customDataModel(item) {
return item.category?.name || '-';
},
},
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' },
] as IColumn[];
loading = signal(false);
@@ -46,6 +60,14 @@ export class ProductsComponent {
}
openAddForm() {
this.visibleForm.set(true);
const pagePathFn = productsNamedRoutes.create.meta.pagePath;
if (pagePathFn) {
this.router.navigateByUrl(pagePathFn({}));
}
// this.visibleForm.set(true);
}
toDetails(product: IProductResponse) {
this.router.navigate(['/products', product.id]);
}
}
@@ -1 +1,84 @@
<div class=""></div>
<div class="flex flex-col gap-4">
<div class="flex items-center">
<div class="grow">
<div class="flex items-center gap-2">
<p-button icon="pi pi-arrow-right" aria-label="بازگشت" outlined [routerLink]="['/products']" />
<h3 class="m-0">{{ product()?.name }}</h3>
</div>
</div>
<div class="flex items-center gap-2">
<button
pButton
type="button"
label="ویرایش"
icon="pi pi-pencil"
[routerLink]="['/products', productId, 'update']"
></button>
<button pButton icon="pi pi-trash" severity="danger" (click)="openDeleteConfirm()"></button>
</div>
</div>
<div class="inline-flex items-center gap-5">
<div class="">
<app-key-value label="SKU" [value]="product()?.sku || '-'" />
</div>
<div class="">
<app-key-value label="بارکد" [value]="product()?.barcode || '-'" />
</div>
</div>
<div class="flex gap-4">
<div class="flex grow flex-col gap-4">
<div class="grid grid-cols-4 gap-4">
@for (item of topBarCardDetails; track $index) {
<p-card class="w-full border border-primary-700">
<div class="flex gap-5 text-primary-400">
<div class="shrink-0 pt-1">
<i [class]="'pi ' + item.icon + ' text-2xl'"></i>
</div>
<div class="grow flex flex-col gap-1">
<span class="text-sm">{{ item.label }}</span>
<span class="text-xl font-bold text-primary">{{ item.value }}</span>
</div>
</div>
</p-card>
}
</div>
<p-card class="border border-primary-700">
<div class="flex gap-6">
<div class="flex flex-col gap-2 grow">
<span class="font-bold text-lg">توضیحات</span>
<p class="m-0">{{ product()?.description || "بدون توضیحات" }}</p>
</div>
<div class="shrink-0 w-xs">
<p-card class="border border-primary-700">
<div class="flex justify-between p-2 gap-2">
<span class="font-bold text-lg">دسته‌بندی</span>
<p class="m-0">{{ product()?.category?.name || "بدون دسته‌بندی" }}</p>
</div>
<hr class="m-0" />
<div class="flex justify-between p-2 gap-2">
<span class="font-bold text-lg">برند</span>
<p class="m-0">{{ product()?.brand?.name || "بدون برند" }}</p>
</div>
</p-card>
</div>
</div>
</p-card>
</div>
<p-galleria
[value]="images()"
[showItemNavigators]="true"
[circular]="true"
[numVisible]="4"
class="shrink-0 w-md"
containerClass="max-w-md"
>
<ng-template #item let-item>
<img [src]="item.itemImageSrc" style="width: 100%; display: block" />
</ng-template>
<ng-template #thumbnail let-item>
<img [src]="item.itemImageSrc" style="display: block" />
</ng-template>
</p-galleria>
</div>
</div>
@@ -1,9 +1,95 @@
import { Component } from '@angular/core';
import { Maybe } from '@/core';
import { KeyValueComponent } from '@/shared/components';
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Button, ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { GalleriaModule } from 'primeng/galleria';
import { IProductResponse } from '../models';
import { ProductsService } from '../services/main.service';
@Component({
selector: 'app-product',
templateUrl: './single.component.html',
imports: [ButtonDirective, RouterLink, GalleriaModule, KeyValueComponent, Card, Button],
})
export class ProductComponent {
constructor() {}
private route = inject(ActivatedRoute);
constructor(private service: ProductsService) {
this.getData();
}
images = signal([
{
itemImageSrc: 'https://primefaces.org/cdn/primeng/images/galleria/galleria1.jpg',
alt: 'Description for Image 1',
title: 'Title 1',
},
{
itemImageSrc: 'https://primefaces.org/cdn/primeng/images/galleria/galleria2.jpg',
alt: 'Description for Image 2',
title: 'Title 2',
},
{
itemImageSrc: 'https://primefaces.org/cdn/primeng/images/galleria/galleria3.jpg',
alt: 'Description for Image 3',
title: 'Title 3',
},
{
itemImageSrc: 'https://primefaces.org/cdn/primeng/images/galleria/galleria4.jpg',
alt: 'Description for Image 4',
title: 'Title 4',
},
{
itemImageSrc: 'https://primefaces.org/cdn/primeng/images/galleria/galleria5.jpg',
alt: 'Description for Image 5',
title: 'Title 5',
},
]);
productId = this.route.snapshot.params['productId'];
loading = signal(false);
product = signal<Maybe<IProductResponse>>(null);
getData() {
this.loading.set(true);
this.service.getSingle(this.productId).subscribe({
next: (res) => {
this.product.set(res);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
get topBarCardDetails() {
return [
{
icon: 'pi pi-box',
label: 'موجودی',
value: 0,
},
{
icon: 'pi pi-dollar',
label: 'قیمت پایه',
value: this.product()?.basePrice?.toString() || 'تعیین نشده',
},
{
icon: 'pi pi-calendar',
label: 'تاریخ ایجاد',
value: this.product()?.createdAt
? new Date(this.product()!.createdAt!).toLocaleDateString()
: '-',
},
{
icon: 'pi pi-clock',
label: 'تعداد فروش',
value: 1200,
},
];
}
openDeleteConfirm() {}
}
@@ -0,0 +1,9 @@
@if (loading()) {
<p-progress-spinner />
} @else {
<product-full-form
[initialData]="product() || undefined"
[loading]="submitLoading() || loading()"
(onSubmit)="submit($event)"
/>
}
@@ -0,0 +1,57 @@
import { Maybe } from '@/core';
import { ToastService } from '@/core/services/toast.service';
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ProgressSpinner } from 'primeng/progressspinner';
import { ProductFullFormComponent } from '../components/fullForm/full-form.component';
import { IProductRequest, IProductResponse } from '../models';
import { ProductsService } from '../services/main.service';
@Component({
selector: 'app-product-update',
templateUrl: './update.component.html',
imports: [ProductFullFormComponent, ProgressSpinner],
})
export class UpdateProductComponent {
private route = inject(ActivatedRoute);
constructor(
private service: ProductsService,
private toastService: ToastService,
private router: Router,
) {
this.getData();
}
submitLoading = signal(false);
productId = this.route.snapshot.params['productId'];
loading = signal(false);
product = signal<Maybe<IProductResponse>>(null);
getData() {
this.loading.set(true);
this.service.getSingle(this.productId).subscribe({
next: (res) => {
this.product.set(res);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
submit(payload: IProductRequest) {
this.submitLoading.set(true);
this.service.update(this.productId, payload).subscribe({
next: (res) => {
this.toastService.success({ text: `محصول ${payload.name} با موفقیت ویرایش شد` });
this.router.navigate(['products', this.productId]);
this.submitLoading.set(false);
},
error: (err) => {
this.submitLoading.set(false);
},
});
}
}