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
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Name |
+
+ Actions |
+
+
+
+
+ | {{ item.id }} |
+ {{ item.name }} |
+
+
+
+
+
+ |
+
+
+ | No products found. |
+
+
+
+
+
+
+ 1" class="flex justify-center mt-4">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 };
+ }
+}