diff --git a/admin/src/app/app.routes.ts b/admin/src/app/app.routes.ts index b4405e1..8f93e04 100644 --- a/admin/src/app/app.routes.ts +++ b/admin/src/app/app.routes.ts @@ -1,10 +1,28 @@ 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 { EventTypeFormComponent } from "./features/event-type/components/event-type-form/event-type-form.component"; +import { EventTypeDetailsComponent } from "./features/event-type/components/event-type-details/event-type-details.component"; +import { EventTypeTableComponent } from "./features/event-type/components/event-type-table/event-type-table.component"; +import { EventTypeListComponent } from "./features/event-type/components/event-type-list/event-type-list.component"; +import { ProductFormComponent } from "./features/products/components/product-form/product-form.component"; +import { ProductDetailsComponent } from "./features/products/components/product-details/product-details.component"; +import { ProductTableComponent } from "./features/products/components/product-table/product-table.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: 'products/table', component: ProductTableComponent }, + { path: 'products/:id', component: ProductDetailsComponent }, + { path: 'products/:id/edit', component: ProductFormComponent }, + { path: 'products/new', component: ProductFormComponent }, + { path: 'event-type', component: EventTypeListComponent }, + { path: 'event-type/table', component: EventTypeTableComponent }, + { path: 'event-type/new', component: EventTypeFormComponent }, + { path: 'event-type/:id', component: EventTypeDetailsComponent }, + { path: 'event-type/:id/edit', component: EventTypeFormComponent }, + { path: 'login', component: LoginComponent }, { path: '', component: HomeComponent, canActivate: [AuthGuard] }, { path: '**', redirectTo: '' } // Redirect to home for any other route ]; diff --git a/admin/src/app/examples/generic-list-example/generic-list-example.css b/admin/src/app/examples/generic-list-example/generic-list-example.css new file mode 100644 index 0000000..e69de29 diff --git a/admin/src/app/examples/generic-list-example/generic-list-example.html b/admin/src/app/examples/generic-list-example/generic-list-example.html new file mode 100644 index 0000000..944617a --- /dev/null +++ b/admin/src/app/examples/generic-list-example/generic-list-example.html @@ -0,0 +1 @@ + diff --git a/admin/src/app/examples/generic-list-example/generic-list-example.spec.ts b/admin/src/app/examples/generic-list-example/generic-list-example.spec.ts new file mode 100644 index 0000000..c429653 --- /dev/null +++ b/admin/src/app/examples/generic-list-example/generic-list-example.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GenericListExample } from './generic-list-example'; + +describe('GenericListExample', () => { + let component: GenericListExample; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GenericListExample] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GenericListExample); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/admin/src/app/examples/generic-list-example/generic-list-example.ts b/admin/src/app/examples/generic-list-example/generic-list-example.ts new file mode 100644 index 0000000..3cec16d --- /dev/null +++ b/admin/src/app/examples/generic-list-example/generic-list-example.ts @@ -0,0 +1,107 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { Product } from '../../features/products/models/product.model'; +import { ColumnDefinition } from '../../components/generic-table/column-definition.interface'; +import { ProductDataProvider } from '../product-data-provider.service'; +import { GenericTable } from '../../components/generic-table/generic-table'; +import { GenericTableConfig } from '../../components/generic-table/generic-table.config'; +import { ActionDefinition, GenericActionColumn } from '../../components/generic-action-column/generic-action-column'; +import { Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-generic-list-example', + imports: [ + GenericTable, + ], + templateUrl: './generic-list-example.html', + styleUrl: './generic-list-example.css', +}) +export class GenericListExample implements OnInit { + router = inject(Router); + listConfig!: GenericTableConfig; + + productDataProvider = inject(ProductDataProvider); + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + private limit$ = new BehaviorSubject(10); + + ngOnInit(): void { + const actionHandler = (action: ActionDefinition, item: Product) => { + switch (action.action) { + case 'view': + this.router.navigate(['/products/' + item?.id + '']); + break; + case 'edit': + this.router.navigate(['/products/' + item?.id + '/edit']); + break; + case 'delete': + alert('delete'); + break; + } + }; + this.listConfig = { + refresh$: this.refresh$, + filter$: this.filter$, + page$: this.page$, + limit$: this.limit$, + dataProvider: this.productDataProvider, + columns: [ + { + headerCell: true, + attribute: 'name', + valueCell: true, + }, + { + attribute: 'price', + headerCell: { + value: '

MyPrice

', + }, + valueCell: { + value: item => { + return item?.price ? Math.floor(item.price) : '-'; + }, + }, + }, + { + attribute: 'available', + headerCell: true , + valueCell: { + value: item => { + return item?.is_available ? 'yes' : 'no'; + }, + }, + }, + { + attribute: 'actions', + headerCell: { value: 'Actions' }, + valueCell: { + component: GenericActionColumn, + componentInputs: item => { + return { + item: item, + actions: [ + { + action: 'view', + handler: actionHandler, + }, + { + action: 'edit', + handler: actionHandler, + }, + { + action: 'delete', + handler: actionHandler, + }, + ] as ActionDefinition[], + }; + }, + }, + }, + ] as ColumnDefinition[], + tableCssClass: 'product-list-container', + rowCssClass: undefined, + }; + } +} diff --git a/admin/src/app/examples/product-data-provider.service.ts b/admin/src/app/examples/product-data-provider.service.ts new file mode 100644 index 0000000..345a736 --- /dev/null +++ b/admin/src/app/examples/product-data-provider.service.ts @@ -0,0 +1,18 @@ +import { inject, Injectable } from '@angular/core'; +import { DataProvider, GetDataOptions, GetDataResponse } from '../components/generic-table/data-provider.interface'; +import { Product } from '../features/products/models/product.model'; +import { map, Observable } from 'rxjs'; +import { ProductService } from '../features/products/services/product.service'; + +@Injectable({ + providedIn: 'root', +}) +export class ProductDataProvider implements DataProvider { + private productService = inject(ProductService); + getData(options?: GetDataOptions): Observable> { + return this.productService.find({ + ...(options?.params ?? {}) + }).pipe(map((res) => {return {'data': res}})); + } + +} diff --git a/admin/src/app/features/event-type/components/event-type-details/event-type-details.component.html b/admin/src/app/features/event-type/components/event-type-details/event-type-details.component.html new file mode 100644 index 0000000..f819f0c --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-details/event-type-details.component.html @@ -0,0 +1,46 @@ + + + +
+ +
+
+

EventType Details

+ +
+ + + + + + + + + + + + + + + + + + + +
id{{ eventType.id }}
name{{ eventType.name }}
description{{ eventType.description }}
color{{ eventType.color }}
+
+ + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/event-type/components/event-type-details/event-type-details.component.ts b/admin/src/app/features/event-type/components/event-type-details/event-type-details.component.ts new file mode 100644 index 0000000..8a40867 --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-details/event-type-details.component.ts @@ -0,0 +1,34 @@ +// dvbooking-cli/src/templates/angular/details.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { EventType } from '../../models/event-type.model'; +import { EventTypeService } from '../../services/event-type.service'; + +@Component({ + selector: 'app-event-type-details', + templateUrl: './event-type-details.component.html', + standalone: true, + imports: [CommonModule, RouterModule], +}) +export class EventTypeDetailsComponent implements OnInit { + eventType$!: Observable; + + constructor( + private route: ActivatedRoute, + private eventTypeService: EventTypeService + ) {} + + ngOnInit(): void { + this.eventType$ = this.route.params.pipe( + switchMap(params => { + const id = params['id']; + return this.eventTypeService.findOne(id); + }) + ); + } +} \ No newline at end of file diff --git a/admin/src/app/features/event-type/components/event-type-filter/event-type-filter.component.html b/admin/src/app/features/event-type/components/event-type-filter/event-type-filter.component.html new file mode 100644 index 0000000..45aadea --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-filter/event-type-filter.component.html @@ -0,0 +1,18 @@ + + +
+
+
+
+ +
+
+ +
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/event-type/components/event-type-filter/event-type-filter.component.ts b/admin/src/app/features/event-type/components/event-type-filter/event-type-filter.component.ts new file mode 100644 index 0000000..6f0934c --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-filter/event-type-filter.component.ts @@ -0,0 +1,39 @@ +// dvbooking-cli/src/templates/angular/filter.component.ts.tpl + +// 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-event-type-filter', + templateUrl: './event-type-filter.component.html', + standalone: true, + imports: [ReactiveFormsModule] +}) +export class EventTypeFilterComponent { + @Output() filterChanged = new EventEmitter(); + filterForm: FormGroup; + + constructor(private fb: FormBuilder) { + this.filterForm = this.fb.group({ + name: [''], + description: [''], + color: [''] + }); + + this.filterForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(values => { + 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/event-type/components/event-type-form/event-type-form.component.html b/admin/src/app/features/event-type/components/event-type-form/event-type-form.component.html new file mode 100644 index 0000000..fe8ad03 --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-form/event-type-form.component.html @@ -0,0 +1,31 @@ + + + +
+
+
+

+ {{ isEditMode ? 'Edit' : 'Create' }} EventType +

+ +
+ +
+
+ +
+
+ +
+
+ +
+ Cancel + +
+
+
+
+
\ No newline at end of file diff --git a/admin/src/app/features/event-type/components/event-type-form/event-type-form.component.ts b/admin/src/app/features/event-type/components/event-type-form/event-type-form.component.ts new file mode 100644 index 0000000..dc007cb --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-form/event-type-form.component.ts @@ -0,0 +1,87 @@ +// dvbooking-cli/src/templates/angular/form.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; +import { EventType } from '../../models/event-type.model'; +import { EventTypeService } from '../../services/event-type.service'; + +@Component({ + selector: 'app-event-type-form', + templateUrl: './event-type-form.component.html', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterModule], +}) +export class EventTypeFormComponent implements OnInit { + form: FormGroup; + isEditMode = false; + id: number | null = null; + + private numericFields = []; + + constructor( + private fb: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private eventTypeService: EventTypeService + ) { + this.form = this.fb.group({ + name: [null], + description: [null], + color: [null] + }); + } + + ngOnInit(): void { + this.route.params.pipe( + tap(params => { + if (params['id']) { + this.isEditMode = true; + this.id = +params['id']; + } + }), + switchMap(() => { + if (this.isEditMode && this.id) { + return this.eventTypeService.findOne(this.id); + } + return of(null); + }) + ).subscribe(eventType => { + if (eventType) { + this.form.patchValue(eventType); + } + }); + } + + onSubmit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const payload = { ...this.form.value }; + + for (const field of this.numericFields) { + if (payload[field] != null && payload[field] !== '') { + payload[field] = parseFloat(payload[field]); + } + } + + let action$: Observable; + + if (this.isEditMode && this.id) { + action$ = this.eventTypeService.update(this.id, payload); + } else { + action$ = this.eventTypeService.create(payload); + } + + action$.subscribe({ + next: () => this.router.navigate(['/event-type']), + error: (err) => console.error('Failed to save event-type', err) + }); + } +} \ No newline at end of file diff --git a/admin/src/app/features/event-type/components/event-type-list/event-type-list.component.html b/admin/src/app/features/event-type/components/event-type-list/event-type-list.component.html new file mode 100644 index 0000000..028b4cf --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-list/event-type-list.component.html @@ -0,0 +1,64 @@ + + + +
+
+

EventTypes

+ Create New +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
idnamedescriptioncolorActions
{{ item.id }}{{ item.name }}{{ item.description }}{{ item.color }} + View + Edit + +
No event-type found.
+
+ + +
+
+ + + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/event-type/components/event-type-list/event-type-list.component.ts b/admin/src/app/features/event-type/components/event-type-list/event-type-list.component.ts new file mode 100644 index 0000000..802e8a9 --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-list/event-type-list.component.ts @@ -0,0 +1,68 @@ +// 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 { RouterModule } from '@angular/router'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { switchMap, startWith } from 'rxjs/operators'; +import { EventType, PaginatedResponse } from '../../models/event-type.model'; +import { EventTypeService } from '../../services/event-type.service'; +import { EventTypeFilterComponent } from '../event-type-filter/event-type-filter.component'; + +@Component({ + selector: 'app-event-type-list', + templateUrl: './event-type-list.component.html', + standalone: true, + imports: [CommonModule,RouterModule, EventTypeFilterComponent], +}) +export class EventTypeListComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + + paginatedResponse$!: Observable>; + + constructor(private eventTypeService: EventTypeService) { } + + 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.eventTypeService.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.eventTypeService.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/event-type/components/event-type-table/event-type-data-provider.service.ts b/admin/src/app/features/event-type/components/event-type-table/event-type-data-provider.service.ts new file mode 100644 index 0000000..28e0a0a --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-table/event-type-data-provider.service.ts @@ -0,0 +1,26 @@ +// dvbooking-cli/src/templates/angular-generic/data-provider.service.ts.tpl + +// Generated by the CLI +import { inject, Injectable } from '@angular/core'; +import { DataProvider, GetDataOptions, GetDataResponse } from '../../../../components/generic-table/data-provider.interface'; +import { EventType } from '../../models/event-type.model'; +import { map, Observable } from 'rxjs'; +import { EventTypeService } from '../../services/event-type.service'; + +@Injectable({ + providedIn: 'root', +}) +export class EventTypeDataProvider implements DataProvider { + private eventTypeService = inject(EventTypeService); + + getData(options?: GetDataOptions): Observable> { + const {q,page,limit} = options?.params ?? {}; + // The generic table's params are compatible with our NestJS Query DTO + return this.eventTypeService.search(q ?? '',page,limit, ).pipe( + map((res) => { + // Adapt the paginated response to the GetDataResponse format + return { data: res }; + }) + ); + } +} \ No newline at end of file diff --git a/admin/src/app/features/event-type/components/event-type-table/event-type-table.component.html b/admin/src/app/features/event-type/components/event-type-table/event-type-table.component.html new file mode 100644 index 0000000..ddd007c --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-table/event-type-table.component.html @@ -0,0 +1,11 @@ + + + +
+
+

EventTypes (Generic Table)

+ Create New +
+ + +
\ No newline at end of file diff --git a/admin/src/app/features/event-type/components/event-type-table/event-type-table.component.ts b/admin/src/app/features/event-type/components/event-type-table/event-type-table.component.ts new file mode 100644 index 0000000..54fc01e --- /dev/null +++ b/admin/src/app/features/event-type/components/event-type-table/event-type-table.component.ts @@ -0,0 +1,110 @@ +// dvbooking-cli/src/templates/angular-generic/table.component.ts.tpl + +// Generated by the CLI +import { Component, inject, OnInit } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { EventType } from '../../models/event-type.model'; +import { EventTypeDataProvider } from './event-type-data-provider.service'; +import { ColumnDefinition } from '../../../../components/generic-table/column-definition.interface'; +import { GenericTable } from '../../../../components/generic-table/generic-table'; +import { GenericTableConfig } from '../../../../components/generic-table/generic-table.config'; +import { + ActionDefinition, + GenericActionColumn, +} from '../../../../components/generic-action-column/generic-action-column'; +import { EventTypeService } from '../../services/event-type.service'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-event-type-table', + standalone: true, + imports: [GenericTable, RouterModule], + templateUrl: './event-type-table.component.html', +}) +export class EventTypeTableComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + private limit$ = new BehaviorSubject(10); + + router = inject(Router); + tableConfig!: GenericTableConfig; + + eventTypeDataProvider = inject(EventTypeDataProvider); + eventTypeService = inject(EventTypeService); + + ngOnInit(): void { + const actionHandler = (action: ActionDefinition, item: EventType) => { + switch (action.action) { + case 'view': + this.router.navigate(['/event-type', item?.id]); + break; + case 'edit': + this.router.navigate(['/event-type', item?.id, 'edit']); + break; + case 'delete': + this.deleteItem(item.id); + break; + } + }; + + this.tableConfig = { + refresh$: this.refresh$, + filter$: this.filter$, + page$: this.page$, + limit$: this.limit$, + dataProvider: this.eventTypeDataProvider, + columns: [ + { + attribute: 'name', + headerCell: true, + valueCell: true, + }, + { + attribute: 'description', + headerCell: true, + valueCell: true, + }, + { + attribute: 'color', + headerCell: true, + valueCell: true, + }, + { + attribute: 'actions', + headerCell: { value: 'Actions' }, + valueCell: { + component: GenericActionColumn, + componentInputs: item => ({ + item: item, + actions: [ + { action: 'view', handler: actionHandler }, + { action: 'edit', handler: actionHandler }, + { action: 'delete', handler: actionHandler }, + ] as ActionDefinition[], + }), + }, + }, + ] as ColumnDefinition[], + tableCssClass: 'event-type-table-container', + }; + } + + deleteItem(id: number): void { + if (confirm('Are you sure you want to delete this item?')) { + this.eventTypeService.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/event-type/models/event-type.model.ts b/admin/src/app/features/event-type/models/event-type.model.ts new file mode 100644 index 0000000..8fd02bb --- /dev/null +++ b/admin/src/app/features/event-type/models/event-type.model.ts @@ -0,0 +1,20 @@ +// dvbooking-cli/src/templates/angular/model.ts.tpl + +// Generated by the CLI +export interface EventType { + id: number; + name: string; + description: string; + color: string; +} + +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/event-type/services/event-type.service.ts b/admin/src/app/features/event-type/services/event-type.service.ts new file mode 100644 index 0000000..b3f324f --- /dev/null +++ b/admin/src/app/features/event-type/services/event-type.service.ts @@ -0,0 +1,83 @@ +// 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 { EventType, PaginatedResponse } from '../models/event-type.model'; +import { ConfigurationService } from '../../../services/configuration.service'; + +export interface SearchResponse { + data: T[]; + total: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class EventTypeService { + private readonly apiUrl: string; + + constructor( + private http: HttpClient, + private configService: ConfigurationService + ) { + this.apiUrl = `${this.configService.getApiUrl()}/event-type`; + } + + /** + * 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 }); + } + + /** + * Search across multiple fields with a single term. + * @param term The search term (q). + */ + public search(term: string, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('q', term) + .set('page', page.toString()) + .set('limit', limit.toString()); + return this.http.get>(`${this.apiUrl}/search`, { 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/admin/src/app/features/products/components/product-details/product-details.component.html b/admin/src/app/features/products/components/product-details/product-details.component.html new file mode 100644 index 0000000..0e39edd --- /dev/null +++ b/admin/src/app/features/products/components/product-details/product-details.component.html @@ -0,0 +1,46 @@ + + + +
+ +
+
+

Product Details

+ +
+ + + + + + + + + + + + + + + + + + + +
id{{ product.id }}
name{{ product.name }}
price{{ product.price }}
is_available{{ product.is_available }}
+
+ + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/products/components/product-details/product-details.component.ts b/admin/src/app/features/products/components/product-details/product-details.component.ts new file mode 100644 index 0000000..970746a --- /dev/null +++ b/admin/src/app/features/products/components/product-details/product-details.component.ts @@ -0,0 +1,34 @@ +// dvbooking-cli/src/templates/angular/details.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { Product } from '../../models/product.model'; +import { ProductService } from '../../services/product.service'; + +@Component({ + selector: 'app-product-details', + templateUrl: './product-details.component.html', + standalone: true, + imports: [CommonModule, RouterModule], +}) +export class ProductDetailsComponent implements OnInit { + product$!: Observable; + + constructor( + private route: ActivatedRoute, + private productService: ProductService + ) {} + + ngOnInit(): void { + this.product$ = this.route.params.pipe( + switchMap(params => { + const id = params['id']; + return this.productService.findOne(id); + }) + ); + } +} \ No newline at end of file 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..5e3ee19 --- /dev/null +++ b/admin/src/app/features/products/components/product-filter/product-filter.component.html @@ -0,0 +1,12 @@ + + +
+
+
+
+ +
+ +
+
+
\ 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..8062717 --- /dev/null +++ b/admin/src/app/features/products/components/product-filter/product-filter.component.ts @@ -0,0 +1,37 @@ +// dvbooking-cli/src/templates/angular/filter.component.ts.tpl + +// 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: [''] + }); + + this.filterForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(values => { + 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-form/product-form.component.html b/admin/src/app/features/products/components/product-form/product-form.component.html new file mode 100644 index 0000000..14b8614 --- /dev/null +++ b/admin/src/app/features/products/components/product-form/product-form.component.html @@ -0,0 +1,33 @@ + + + +
+
+
+

+ {{ isEditMode ? 'Edit' : 'Create' }} Product +

+ +
+ +
+
+ +
+
+ +
+ +
+ Cancel + +
+
+
+
+
\ No newline at end of file diff --git a/admin/src/app/features/products/components/product-form/product-form.component.ts b/admin/src/app/features/products/components/product-form/product-form.component.ts new file mode 100644 index 0000000..95ea795 --- /dev/null +++ b/admin/src/app/features/products/components/product-form/product-form.component.ts @@ -0,0 +1,87 @@ +// dvbooking-cli/src/templates/angular/form.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; +import { Product } from '../../models/product.model'; +import { ProductService } from '../../services/product.service'; + +@Component({ + selector: 'app-product-form', + templateUrl: './product-form.component.html', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterModule], +}) +export class ProductFormComponent implements OnInit { + form: FormGroup; + isEditMode = false; + id: number | null = null; + + private numericFields = ["price"]; + + constructor( + private fb: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private productService: ProductService + ) { + this.form = this.fb.group({ + name: [null], + price: [null], + is_available: [null] + }); + } + + ngOnInit(): void { + this.route.params.pipe( + tap(params => { + if (params['id']) { + this.isEditMode = true; + this.id = +params['id']; + } + }), + switchMap(() => { + if (this.isEditMode && this.id) { + return this.productService.findOne(this.id); + } + return of(null); + }) + ).subscribe(product => { + if (product) { + this.form.patchValue(product); + } + }); + } + + onSubmit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const payload = { ...this.form.value }; + + for (const field of this.numericFields) { + if (payload[field] != null && payload[field] !== '') { + payload[field] = parseFloat(payload[field]); + } + } + + let action$: Observable; + + if (this.isEditMode && this.id) { + action$ = this.productService.update(this.id, payload); + } else { + action$ = this.productService.create(payload); + } + + action$.subscribe({ + next: () => this.router.navigate(['/products']), + error: (err) => console.error('Failed to save product', err) + }); + } +} \ 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..6845e81 --- /dev/null +++ b/admin/src/app/features/products/components/product-list/product-list.component.html @@ -0,0 +1,64 @@ + + + +
+
+

Products

+ Create New +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
idnamepriceis_availableActions
{{ item.id }}{{ item.name }}{{ item.price }}{{ item.is_available }} + View + Edit + +
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..2f86be5 --- /dev/null +++ b/admin/src/app/features/products/components/product-list/product-list.component.ts @@ -0,0 +1,68 @@ +// 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 { RouterModule } from '@angular/router'; +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,RouterModule, 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..6110fc7 --- /dev/null +++ b/admin/src/app/features/products/models/product.model.ts @@ -0,0 +1,20 @@ +// dvbooking-cli/src/templates/angular/model.ts.tpl + +// Generated by the CLI +export interface Product { + id: number; + name: string; + price: number; + is_available: boolean; +} + +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..15d4d7c --- /dev/null +++ b/admin/src/app/features/products/services/product.service.ts @@ -0,0 +1,83 @@ +// 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'; + +export interface SearchResponse { + data: T[]; + total: number; +} + +@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 }); + } + + /** + * Search across multiple fields with a single term. + * @param term The search term (q). + */ + public search(term: string, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('q', term) + .set('page', page.toString()) + .set('limit', limit.toString()); + return this.http.get>(`${this.apiUrl}/search`, { 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..ae1f07e 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -9,6 +9,10 @@ import { User } from './entity/user'; import { UserGroup } from './entity/user-group'; import { UserRole } from './entity/user-role'; import { LoggerModule } from './logger/logger.module'; +import { EventType } from './entity/event-type.entity'; +import { EventTypesModule } from './event-type/event-type.module'; +import { Product } from './entity/product.entity'; +import { ProductsModule } from './product/products.module'; const moduleTypeOrm = TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -21,7 +25,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, EventType, Product], logging: true, // synchronize: true, }; @@ -35,6 +39,8 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ UserModule, AuthModule, LoggerModule, + EventTypesModule, + ProductsModule, ], controllers: [AppController], providers: [AppService], diff --git a/server/src/data-source.ts b/server/src/data-source.ts index b2fa416..aa87ee0 100644 --- a/server/src/data-source.ts +++ b/server/src/data-source.ts @@ -5,6 +5,7 @@ import { User } from './entity/user'; import * as dotenv from 'dotenv'; import { UserGroup } from './entity/user-group'; import { UserRole } from './entity/user-role'; +import { EventType } from './entity/event-type.entity'; dotenv.config(); @@ -17,7 +18,7 @@ export const AppDataSource = new DataSource({ database: process.env.DATABASE_NAME, synchronize: false, logging: false, - entities: [User, UserGroup, UserRole], + entities: [User, UserGroup, UserRole, EventType], migrations: [ 'src/migration/**/*.ts' ], diff --git a/server/src/entity/event-type.entity.ts b/server/src/entity/event-type.entity.ts new file mode 100644 index 0000000..87c4545 --- /dev/null +++ b/server/src/entity/event-type.entity.ts @@ -0,0 +1,23 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { IsString, IsNumber, IsBoolean, IsDate, IsOptional } from 'class-validator'; + +@Entity({ name: 'event_type' }) +export class EventType { + @PrimaryGeneratedColumn() + id: number; + + @Column() + @IsString() + name: string; + + @Column({ type: 'character varying', nullable: true }) + @IsOptional() + @IsString() + description: string | null; + + @Column({ type: 'character varying', nullable: true }) + @IsOptional() + @IsString() + color: string | null; + +} 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/event-type/dto/create-event-type.dto.ts b/server/src/event-type/dto/create-event-type.dto.ts new file mode 100644 index 0000000..4c2a661 --- /dev/null +++ b/server/src/event-type/dto/create-event-type.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/mapped-types'; +import { EventType } from '../../entity/event-type.entity'; + +export class CreateEventTypeDto extends OmitType(EventType, ['id']) {} \ No newline at end of file diff --git a/server/src/event-type/dto/query-event-type.dto.ts b/server/src/event-type/dto/query-event-type.dto.ts new file mode 100644 index 0000000..ef0c192 --- /dev/null +++ b/server/src/event-type/dto/query-event-type.dto.ts @@ -0,0 +1,9 @@ +import { IsOptional, IsString, IsNumber, IsIn, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryEventTypeDto { + @IsOptional() @Type(() => Number) @IsNumber() page?: number; + @IsOptional() @Type(() => Number) @IsNumber() limit?: number; + @IsOptional() @IsString() sortBy?: string; + @IsOptional() @IsIn(['ASC', 'DESC']) order?: 'ASC' | 'DESC'; +} \ No newline at end of file diff --git a/server/src/event-type/dto/update-event-type.dto.ts b/server/src/event-type/dto/update-event-type.dto.ts new file mode 100644 index 0000000..b631dfc --- /dev/null +++ b/server/src/event-type/dto/update-event-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateEventTypeDto } from './create-event-type.dto'; + +export class UpdateEventTypeDto extends PartialType(CreateEventTypeDto) {} \ No newline at end of file diff --git a/server/src/event-type/event-type.controller.ts b/server/src/event-type/event-type.controller.ts new file mode 100644 index 0000000..7637023 --- /dev/null +++ b/server/src/event-type/event-type.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common'; +import { EventTypesService } from './event-type.service'; +import { CreateEventTypeDto } from './dto/create-event-type.dto'; +import { UpdateEventTypeDto } from './dto/update-event-type.dto'; +import { QueryEventTypeDto } from './dto/query-event-type.dto'; + +@Controller('event-type') +export class EventTypesController { + constructor(private readonly eventTypesService: EventTypesService) {} + + @Post() + create(@Body() createEventTypeDto: CreateEventTypeDto) { + return this.eventTypesService.create(createEventTypeDto); + } + + @Get() + findAll(@Query() queryParams: QueryEventTypeDto) { + return this.eventTypesService.findAll(queryParams); + } + + @Get('search') + search( + @Query('q') term: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + return this.eventTypesService.search(term, { page, limit }); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.eventTypesService.findOne(id); + } + + @Patch(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() updateEventTypeDto: UpdateEventTypeDto) { + return this.eventTypesService.update(id, updateEventTypeDto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.eventTypesService.remove(id); + } +} \ No newline at end of file diff --git a/server/src/event-type/event-type.module.ts b/server/src/event-type/event-type.module.ts new file mode 100644 index 0000000..9cc728b --- /dev/null +++ b/server/src/event-type/event-type.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EventTypesService } from './event-type.service'; +import { EventTypesController } from './event-type.controller'; +import { EventType } from '../entity/event-type.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([EventType])], + controllers: [EventTypesController], + providers: [EventTypesService], +}) +export class EventTypesModule {} \ No newline at end of file diff --git a/server/src/event-type/event-type.service.ts b/server/src/event-type/event-type.service.ts new file mode 100644 index 0000000..afe2170 --- /dev/null +++ b/server/src/event-type/event-type.service.ts @@ -0,0 +1,103 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindManyOptions, FindOptionsWhere, ILike } from 'typeorm'; +import { CreateEventTypeDto } from './dto/create-event-type.dto'; +import { UpdateEventTypeDto } from './dto/update-event-type.dto'; +import { QueryEventTypeDto } from './dto/query-event-type.dto'; +import { EventType } from '../entity/event-type.entity'; + +type QueryConfigItem = { + param: keyof Omit; + dbField: keyof EventType; + operator: 'equals' | 'like'; +}; + +@Injectable() +export class EventTypesService { + constructor( + @InjectRepository(EventType) + private readonly eventTypeRepository: Repository, + ) {} + + private readonly searchableFields: (keyof EventType)[] = [ + 'name', + 'description', + 'color' + ]; + + create(createEventTypeDto: CreateEventTypeDto) { + const newRecord = this.eventTypeRepository.create(createEventTypeDto); + return this.eventTypeRepository.save(newRecord); + } + + async findAll(queryParams: QueryEventTypeDto) { + const { page = 1, limit = 0, sortBy, order, ...filters } = queryParams; + const queryConfig: QueryConfigItem[] = []; + const whereClause: { [key: string]: any } = {}; + 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]; + } + } + } + const findOptions: FindManyOptions = { where: whereClause as FindOptionsWhere }; + 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.eventTypeRepository.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 search(term: string, options: { page: number; limit: number }) { + if (this.searchableFields.length === 0) { + console.warn('Search is not configured for this entity.'); + return { data: [], meta: { totalItems: 0, itemCount: 0, itemsPerPage: options.limit, totalPages: 0, currentPage: options.page } }; + } + const whereConditions = this.searchableFields.map(field => ({ [field]: ILike(`%${term}%`) })); + const [data, totalItems] = await this.eventTypeRepository.findAndCount({ + where: whereConditions, + skip: (options.page - 1) * options.limit, + take: options.limit, + }); + return { + data, + meta: { totalItems, itemCount: data.length, itemsPerPage: options.limit, totalPages: Math.ceil(totalItems / options.limit), currentPage: options.page }, + }; + } + + async findOne(id: number) { + const record = await this.eventTypeRepository.findOneBy({ id: id as any }); + if (!record) { + throw new NotFoundException(`EventType with ID ${id} not found`); + } + return record; + } + + async update(id: number, updateEventTypeDto: UpdateEventTypeDto) { + const record = await this.findOne(id); + Object.assign(record, updateEventTypeDto); + return this.eventTypeRepository.save(record); + } + + async remove(id: number) { + const result = await this.eventTypeRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`EventType with ID ${id} not found`); + } + return { deleted: true, id }; + } +} \ No newline at end of file diff --git a/server/src/migration/1763106308121-add_event_type_table.ts b/server/src/migration/1763106308121-add_event_type_table.ts new file mode 100644 index 0000000..980987d --- /dev/null +++ b/server/src/migration/1763106308121-add_event_type_table.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddEventTypeTable1763106308121 implements MigrationInterface { + name = 'AddEventTypeTable1763106308121'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "event_type" + ( + "id" SERIAL NOT NULL, + "name" character varying NOT NULL, + "description" character varying NULL, + "color" character varying NULL, + CONSTRAINT "PK_event_type_id" PRIMARY KEY ("id"), + CONSTRAINT "U_event_type_name" UNIQUE ("name"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "event_type"`); + } +} 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..ba3f0b0 --- /dev/null +++ b/server/src/product/dto/create-product.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/mapped-types'; +import { Product } from '../../entity/product.entity'; + +export class CreateProductDto extends OmitType(Product, ['id']) {} \ No newline at end of file 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..e770b74 --- /dev/null +++ b/server/src/product/dto/query-product.dto.ts @@ -0,0 +1,9 @@ +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; + @IsOptional() @IsIn(['ASC', 'DESC']) order?: 'ASC' | 'DESC'; +} \ No newline at end of file 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..68a1e7c --- /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) {} \ No newline at end of file diff --git a/server/src/product/products.controller.ts b/server/src/product/products.controller.ts new file mode 100644 index 0000000..f9a06b9 --- /dev/null +++ b/server/src/product/products.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, DefaultValuePipe } 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('search') + search( + @Query('q') term: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + return this.productsService.search(term, { page, limit }); + } + + @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); + } +} \ No newline at end of file diff --git a/server/src/product/products.module.ts b/server/src/product/products.module.ts new file mode 100644 index 0000000..48311e4 --- /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 {} \ No newline at end of file diff --git a/server/src/product/products.service.ts b/server/src/product/products.service.ts new file mode 100644 index 0000000..48684a6 --- /dev/null +++ b/server/src/product/products.service.ts @@ -0,0 +1,104 @@ +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, + ) {} + + private readonly searchableFields: (keyof Product)[] = [ + 'name' + ]; + + 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[] = []; + const whereClause: { [key: string]: any } = {}; + 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]; + } + } + } + const findOptions: FindManyOptions = { where: whereClause as FindOptionsWhere }; + 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 search(term: string, options: { page: number; limit: number }) { + if (!term || term.trim() === '') { + return { data: [], meta: { totalItems: 0, itemCount: 0, itemsPerPage: options.limit, totalPages: 0, currentPage: options.page } }; + } + if (this.searchableFields.length === 0) { + console.warn('Search is not configured for this entity.'); + return { data: [], meta: { totalItems: 0, itemCount: 0, itemsPerPage: options.limit, totalPages: 0, currentPage: options.page } }; + } + const whereConditions = this.searchableFields.map(field => ({ [field]: ILike(`%${term}%`) })); + const [data, totalItems] = await this.productRepository.findAndCount({ + where: whereConditions, + skip: (options.page - 1) * options.limit, + take: options.limit, + }); + return { + data, + meta: { totalItems, itemCount: data.length, itemsPerPage: options.limit, totalPages: Math.ceil(totalItems / options.limit), currentPage: options.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 }; + } +} \ No newline at end of file