From 997dd917fea47522f561b8742f4092bc9bd60d2d Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Wed, 19 Nov 2025 09:52:15 +0100 Subject: [PATCH] add angular list view generations --- admin/src/app/app.routes.ts | 6 +- .../product-filter.component.html | 20 ++++ .../product-filter.component.ts | 37 ++++++ .../product-list/product-list.component.html | 59 ++++++++++ .../product-list/product-list.component.ts | 67 +++++++++++ .../features/products/models/product.model.ts | 17 +++ .../products/services/product.service.ts | 66 +++++++++++ server/api.http | 2 +- server/src/app.module.ts | 7 +- server/src/entity/product.entity.ts | 23 ++++ server/src/product/dto/create-product.dto.ts | 5 + server/src/product/dto/query-product.dto.ts | 32 ++++++ server/src/product/dto/update-product.dto.ts | 4 + server/src/product/products.controller.ts | 35 ++++++ server/src/product/products.module.ts | 12 ++ server/src/product/products.service.ts | 108 ++++++++++++++++++ 16 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 admin/src/app/features/products/components/product-filter/product-filter.component.html create mode 100644 admin/src/app/features/products/components/product-filter/product-filter.component.ts create mode 100644 admin/src/app/features/products/components/product-list/product-list.component.html create mode 100644 admin/src/app/features/products/components/product-list/product-list.component.ts create mode 100644 admin/src/app/features/products/models/product.model.ts create mode 100644 admin/src/app/features/products/services/product.service.ts create mode 100644 server/src/entity/product.entity.ts create mode 100644 server/src/product/dto/create-product.dto.ts create mode 100644 server/src/product/dto/query-product.dto.ts create mode 100644 server/src/product/dto/update-product.dto.ts create mode 100644 server/src/product/products.controller.ts create mode 100644 server/src/product/products.module.ts create mode 100644 server/src/product/products.service.ts diff --git a/admin/src/app/app.routes.ts b/admin/src/app/app.routes.ts index b4405e1..6a6ab22 100644 --- a/admin/src/app/app.routes.ts +++ b/admin/src/app/app.routes.ts @@ -1,10 +1,12 @@ import { Routes } from '@angular/router'; import { LoginComponent } from './components/login/login.component'; import { AuthGuard } from './auth/auth.guard'; -import { HomeComponent } from './components/home/home.component'; // Assuming you have a HomeComponent +import { HomeComponent } from './components/home/home.component'; +import { ProductListComponent } from "./features/products/components/product-list/product-list.component"; export const routes: Routes = [ - { path: 'login', component: LoginComponent }, + { path: 'products', component: ProductListComponent }, + { path: 'login', component: LoginComponent }, { path: '', component: HomeComponent, canActivate: [AuthGuard] }, { path: '**', redirectTo: '' } // Redirect to home for any other route ]; diff --git a/admin/src/app/features/products/components/product-filter/product-filter.component.html b/admin/src/app/features/products/components/product-filter/product-filter.component.html new file mode 100644 index 0000000..7041668 --- /dev/null +++ b/admin/src/app/features/products/components/product-filter/product-filter.component.html @@ -0,0 +1,20 @@ + +
+
+ + +
+ + +
+ + + + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/products/components/product-filter/product-filter.component.ts b/admin/src/app/features/products/components/product-filter/product-filter.component.ts new file mode 100644 index 0000000..7054946 --- /dev/null +++ b/admin/src/app/features/products/components/product-filter/product-filter.component.ts @@ -0,0 +1,37 @@ +// Generated by the CLI +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +@Component({ + selector: 'app-product-filter', + templateUrl: './product-filter.component.html', + standalone: true, + imports: [ReactiveFormsModule] +}) +export class ProductFilterComponent { + @Output() filterChanged = new EventEmitter(); + filterForm: FormGroup; + + constructor(private fb: FormBuilder) { + this.filterForm = this.fb.group({ + name: [''], + // Add other filterable form controls here + }); + + this.filterForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(values => { + // Remove empty properties before emitting + const cleanFilter = Object.fromEntries( + Object.entries(values).filter(([_, v]) => v != null && v !== '') + ); + this.filterChanged.emit(cleanFilter); + }); + } + + reset() { + this.filterForm.reset(); + } +} \ No newline at end of file diff --git a/admin/src/app/features/products/components/product-list/product-list.component.html b/admin/src/app/features/products/components/product-list/product-list.component.html new file mode 100644 index 0000000..ea89f14 --- /dev/null +++ b/admin/src/app/features/products/components/product-list/product-list.component.html @@ -0,0 +1,59 @@ + +
+

Products

+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
IDNameActions
{{ item.id }}{{ item.name }} + + + +
No products found.
+
+ + +
+
+ + + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/products/components/product-list/product-list.component.ts b/admin/src/app/features/products/components/product-list/product-list.component.ts new file mode 100644 index 0000000..e0f4d3f --- /dev/null +++ b/admin/src/app/features/products/components/product-list/product-list.component.ts @@ -0,0 +1,67 @@ +// dvbooking-cli/src/templates/angular/list.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { switchMap, startWith } from 'rxjs/operators'; +import { Product, PaginatedResponse } from '../../models/product.model'; +import { ProductService } from '../../services/product.service'; +import { ProductFilterComponent } from '../product-filter/product-filter.component'; + +@Component({ + selector: 'app-product-list', + templateUrl: './product-list.component.html', + standalone: true, + imports: [CommonModule, ProductFilterComponent], +}) +export class ProductListComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + + paginatedResponse$!: Observable>; + + constructor(private productService: ProductService) { } + + ngOnInit(): void { + this.paginatedResponse$ = combineLatest([ + this.refresh$, + this.filter$.pipe(startWith({})), + this.page$.pipe(startWith(1)) + ]).pipe( + switchMap(([_, filter, page]) => { + const query = { ...filter, page, limit: 10 }; + return this.productService.find(query); + }) + ); + } + + onFilterChanged(filter: any): void { + this.page$.next(1); + this.filter$.next(filter); + } + + changePage(newPage: number): void { + if (newPage > 0) { + this.page$.next(newPage); + } + } + + deleteItem(id: number): void { + if (confirm('Are you sure you want to delete this item?')) { + this.productService.remove(id).subscribe({ + next: () => { + console.log(`Item with ID ${id} deleted successfully.`); + this.refresh$.next(); + }, + // --- THIS IS THE FIX --- + // Explicitly type 'err' to satisfy strict TypeScript rules. + error: (err: any) => { + console.error(`Error deleting item with ID ${id}:`, err); + } + }); + } + } +} \ No newline at end of file diff --git a/admin/src/app/features/products/models/product.model.ts b/admin/src/app/features/products/models/product.model.ts new file mode 100644 index 0000000..9c7609d --- /dev/null +++ b/admin/src/app/features/products/models/product.model.ts @@ -0,0 +1,17 @@ +// Generated by the CLI +export interface Product { + id: number; + name: string; + // Add other properties from your NestJS entity here +} + +export interface PaginatedResponse { + data: T[]; + meta: { + totalItems: number; + itemCount: number; + itemsPerPage: number; + totalPages: number; + currentPage: number; + }; +} \ No newline at end of file diff --git a/admin/src/app/features/products/services/product.service.ts b/admin/src/app/features/products/services/product.service.ts new file mode 100644 index 0000000..7b6ded7 --- /dev/null +++ b/admin/src/app/features/products/services/product.service.ts @@ -0,0 +1,66 @@ +// dvbooking-cli/src/templates/angular/service.ts.tpl + +// Generated by the CLI +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Product, PaginatedResponse } from '../models/product.model'; +import { ConfigurationService } from '../../../services/configuration.service'; + +@Injectable({ + providedIn: 'root' +}) +export class ProductService { + private readonly apiUrl: string; + + constructor( + private http: HttpClient, + private configService: ConfigurationService + ) { + this.apiUrl = `${this.configService.getApiUrl()}/products`; + } + + /** + * Find records with pagination and filtering. + */ + public find(filter: Record): Observable> { + // --- THIS IS THE FIX --- + // The incorrect line: .filter(([_, v]) for v != null) + // is now correctly written with an arrow function. + const cleanFilter = Object.fromEntries( + Object.entries(filter).filter(([_, v]) => v != null) + ); + // --- END OF FIX --- + + const params = new HttpParams({ fromObject: cleanFilter }); + return this.http.get>(this.apiUrl, { params }); + } + + /** + * Find a single record by its ID. + */ + public findOne(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + /** + * Create a new record. + */ + public create(data: Omit): Observable { + return this.http.post(this.apiUrl, data); + } + + /** + * Update an existing record. + */ + public update(id: number, data: Partial>): Observable { + return this.http.patch(`${this.apiUrl}/${id}`, data); + } + + /** + * Remove a record by its ID. + */ + public remove(id: number): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } +} \ No newline at end of file diff --git a/server/api.http b/server/api.http index 6a60eb1..665dc2d 100644 --- a/server/api.http +++ b/server/api.http @@ -33,7 +33,7 @@ Accept: application/json Content-Type: application/json { - "name": "p3", + "name": "p22", "price": 3, "is_available": true } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 85aee95..ada175f 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -9,6 +9,8 @@ import { User } from './entity/user'; import { UserGroup } from './entity/user-group'; import { UserRole } from './entity/user-role'; import { LoggerModule } from './logger/logger.module'; +import { Product } from "./entity/product.entity"; +import { ProductsModule } from "./product/products.module"; const moduleTypeOrm = TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -21,7 +23,7 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ username: configService.get('DATABASE_USER'), password: configService.get('DATABASE_PASS'), database: configService.get('DATABASE_NAME'), - entities: [User, UserGroup, UserRole], + entities: [User, UserGroup, UserRole, Product], logging: true, // synchronize: true, }; @@ -35,7 +37,8 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ UserModule, AuthModule, LoggerModule, - ], + ProductsModule + ], controllers: [AppController], providers: [AppService], }) diff --git a/server/src/entity/product.entity.ts b/server/src/entity/product.entity.ts new file mode 100644 index 0000000..6037c72 --- /dev/null +++ b/server/src/entity/product.entity.ts @@ -0,0 +1,23 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { IsString, IsNumber, IsBoolean, IsDate, IsOptional } from 'class-validator'; + +@Entity({ name: 'products' }) +export class Product { + @PrimaryGeneratedColumn() + id: number; + + @Column() + @IsString() + name: string; + + @Column({ type: 'numeric', nullable: true }) + @IsOptional() + @IsNumber() + price: number | null; + + @Column({ type: 'boolean', nullable: true, default: true }) + @IsOptional() + @IsBoolean() + is_available: boolean | null = true; + +} diff --git a/server/src/product/dto/create-product.dto.ts b/server/src/product/dto/create-product.dto.ts new file mode 100644 index 0000000..23db617 --- /dev/null +++ b/server/src/product/dto/create-product.dto.ts @@ -0,0 +1,5 @@ +import { OmitType } from '@nestjs/mapped-types'; +import { Product } from '../../entity/product.entity'; + +// NOTE: Use class-validator decorators here for production-grade validation. +export class CreateProductDto extends OmitType(Product, ['id']) {} diff --git a/server/src/product/dto/query-product.dto.ts b/server/src/product/dto/query-product.dto.ts new file mode 100644 index 0000000..b289278 --- /dev/null +++ b/server/src/product/dto/query-product.dto.ts @@ -0,0 +1,32 @@ +import { IsOptional, IsString, IsNumber, IsIn, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryProductDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + page?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; // Should be a property of the Product entity + + @IsOptional() + @IsIn(['ASC', 'DESC']) + order?: 'ASC' | 'DESC'; + + // --- Add other filterable properties below --- + // @IsOptional() + // @IsString() + // name?: string; + + // @IsOptional() + // @Type(() => Boolean) + // @IsBoolean() + // is_available?: boolean; +} diff --git a/server/src/product/dto/update-product.dto.ts b/server/src/product/dto/update-product.dto.ts new file mode 100644 index 0000000..e2d43fc --- /dev/null +++ b/server/src/product/dto/update-product.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateProductDto } from './create-product.dto'; + +export class UpdateProductDto extends PartialType(CreateProductDto) {} diff --git a/server/src/product/products.controller.ts b/server/src/product/products.controller.ts new file mode 100644 index 0000000..4a8758a --- /dev/null +++ b/server/src/product/products.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe } from '@nestjs/common'; +import { ProductsService } from './products.service'; +import { CreateProductDto } from './dto/create-product.dto'; +import { UpdateProductDto } from './dto/update-product.dto'; +import { QueryProductDto } from './dto/query-product.dto'; + +@Controller('products') +export class ProductsController { + constructor(private readonly productsService: ProductsService) {} + + @Post() + create(@Body() createProductDto: CreateProductDto) { + return this.productsService.create(createProductDto); + } + + @Get() + findAll(@Query() queryParams: QueryProductDto) { + return this.productsService.findAll(queryParams); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.productsService.findOne(id); + } + + @Patch(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() updateProductDto: UpdateProductDto) { + return this.productsService.update(id, updateProductDto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.productsService.remove(id); + } +} diff --git a/server/src/product/products.module.ts b/server/src/product/products.module.ts new file mode 100644 index 0000000..7a8e8e6 --- /dev/null +++ b/server/src/product/products.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProductsService } from './products.service'; +import { ProductsController } from './products.controller'; +import { Product } from '../entity/product.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Product])], + controllers: [ProductsController], + providers: [ProductsService], +}) +export class ProductsModule {} diff --git a/server/src/product/products.service.ts b/server/src/product/products.service.ts new file mode 100644 index 0000000..70abce2 --- /dev/null +++ b/server/src/product/products.service.ts @@ -0,0 +1,108 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindManyOptions, FindOptionsWhere, ILike } from 'typeorm'; +import { CreateProductDto } from './dto/create-product.dto'; +import { UpdateProductDto } from './dto/update-product.dto'; +import { QueryProductDto } from './dto/query-product.dto'; +import { Product } from '../entity/product.entity'; + +type QueryConfigItem = { + param: keyof Omit; + dbField: keyof Product; + operator: 'equals' | 'like'; +}; + +@Injectable() +export class ProductsService { + constructor( + @InjectRepository(Product) + private readonly productRepository: Repository, + ) {} + + create(createProductDto: CreateProductDto) { + const newRecord = this.productRepository.create(createProductDto); + return this.productRepository.save(newRecord); + } + + async findAll(queryParams: QueryProductDto) { + const { page = 1, limit = 0, sortBy, order, ...filters } = queryParams; + + const queryConfig: QueryConfigItem[] = [ + // Example: { param: 'name', dbField: 'name', operator: 'like' }, + // Example: { param: 'is_available', dbField: 'is_available', operator: 'equals' }, + ]; + + // --- START OF THE FIX --- + + // 1. Create a loosely typed object to build the where clause. + const whereClause: { [key: string]: any } = {}; + + // 2. Populate it dynamically. This avoids the TypeScript error. + for (const config of queryConfig) { + if (filters[config.param] !== undefined) { + if (config.operator === 'like') { + whereClause[config.dbField] = ILike(`%${filters[config.param]}%`); + } else { + whereClause[config.dbField] = filters[config.param]; + } + } + } + + // 3. Assign the complete, built clause to the strongly-typed options. + const findOptions: FindManyOptions = { + where: whereClause as FindOptionsWhere, + }; + + // --- END OF THE FIX --- + + const paginated = limit > 0; + + if (paginated) { + findOptions.skip = (page - 1) * limit; + findOptions.take = limit; + } + + if (sortBy && order) { + findOptions.order = { [sortBy]: order }; + } + + const [data, totalItems] = await this.productRepository.findAndCount(findOptions); + + if (!paginated) { + return { data, total: data.length }; + } + + return { + data, + meta: { + totalItems, + itemCount: data.length, + itemsPerPage: limit, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }, + }; + } + + async findOne(id: number) { + const record = await this.productRepository.findOneBy({ id: id as any }); + if (!record) { + throw new NotFoundException(`Product with ID ${id} not found`); + } + return record; + } + + async update(id: number, updateProductDto: UpdateProductDto) { + const record = await this.findOne(id); + Object.assign(record, updateProductDto); + return this.productRepository.save(record); + } + + async remove(id: number) { + const result = await this.productRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`Product with ID ${id} not found`); + } + return { deleted: true, id }; + } +}