diff --git a/client-management/.editorconfig b/client-management/.editorconfig new file mode 100644 index 0000000..59d9a3a --- /dev/null +++ b/client-management/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/client-management/.gitignore b/client-management/.gitignore new file mode 100644 index 0000000..0711527 --- /dev/null +++ b/client-management/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/client-management/.vscode/extensions.json b/client-management/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/client-management/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/client-management/.vscode/launch.json b/client-management/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/client-management/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, + { + "name": "ng test", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: test", + "url": "http://localhost:9876/debug.html" + } + ] +} diff --git a/client-management/.vscode/tasks.json b/client-management/.vscode/tasks.json new file mode 100644 index 0000000..a298b5b --- /dev/null +++ b/client-management/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + } + ] +} diff --git a/client-management/README.md b/client-management/README.md new file mode 100644 index 0000000..6eac262 --- /dev/null +++ b/client-management/README.md @@ -0,0 +1,27 @@ +# ClientManagement + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.2.11. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. + +## Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Running end-to-end tests + +Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. diff --git a/client-management/angular.json b/client-management/angular.json new file mode 100644 index 0000000..1d73eda --- /dev/null +++ b/client-management/angular.json @@ -0,0 +1,106 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "client-management": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/client-management", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss", + "node_modules/ag-grid-community/styles/ag-grid.css", + "node_modules/ag-grid-community/styles/ag-theme-alpine.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "browserTarget": "client-management:build:production" + }, + "development": { + "browserTarget": "client-management:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "client-management:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/client-management/package.json b/client-management/package.json new file mode 100644 index 0000000..0d323ec --- /dev/null +++ b/client-management/package.json @@ -0,0 +1,43 @@ +{ + "name": "client-management", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^15.2.0", + "@angular/cdk": "^15.2.9", + "@angular/common": "^15.2.0", + "@angular/compiler": "^15.2.0", + "@angular/core": "^15.2.0", + "@angular/forms": "^15.2.0", + "@angular/material": "^15.2.9", + "@angular/platform-browser": "^15.2.0", + "@angular/platform-browser-dynamic": "^15.2.0", + "@angular/router": "^15.2.0", + "ag-grid-angular": "^28.2.1", + "ag-grid-community": "^28.2.1", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.12.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^15.2.11", + "@angular/cli": "~15.2.11", + "@angular/compiler-cli": "^15.2.0", + "@types/jasmine": "~4.3.0", + "jasmine-core": "~4.5.0", + "json-server": "^1.0.0-beta.3", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.1.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.0.0", + "typescript": "~4.9.4" + } +} \ No newline at end of file diff --git a/client-management/src/app/action-button-renderer/action-button-renderer.component.scss b/client-management/src/app/action-button-renderer/action-button-renderer.component.scss new file mode 100644 index 0000000..61780e6 --- /dev/null +++ b/client-management/src/app/action-button-renderer/action-button-renderer.component.scss @@ -0,0 +1,10 @@ +.action-btn { + + mat-icon { + font-size: 18px; + } + + button { + margin: 0 2px; + } +} \ No newline at end of file diff --git a/client-management/src/app/action-button-renderer/action-button-renderer.component.spec.ts b/client-management/src/app/action-button-renderer/action-button-renderer.component.spec.ts new file mode 100644 index 0000000..5b09930 --- /dev/null +++ b/client-management/src/app/action-button-renderer/action-button-renderer.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ActionButtonRendererComponent } from './action-button-renderer.component'; + +describe('ActionButtonRendererComponent', () => { + let component: ActionButtonRendererComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ActionButtonRendererComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ActionButtonRendererComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client-management/src/app/action-button-renderer/action-button-renderer.component.ts b/client-management/src/app/action-button-renderer/action-button-renderer.component.ts new file mode 100644 index 0000000..cb39e6c --- /dev/null +++ b/client-management/src/app/action-button-renderer/action-button-renderer.component.ts @@ -0,0 +1,56 @@ +import { Component } from '@angular/core'; +import { ICellRendererAngularComp } from 'ag-grid-angular'; +import { ClientService } from '../services/client-service.service'; +import { MatDialog } from '@angular/material/dialog'; +import { ClientFormComponent } from '../client-form/client-form.component'; + +@Component({ + selector: 'app-action-buttons-renderer', + template: ` + + + + `, + styleUrls: ['./action-button-renderer.component.scss'] +}) +export class ActionButtonRendererComponent implements ICellRendererAngularComp { + constructor(private clientService: ClientService, private dialog: MatDialog){} + params: any; + + agInit(params: any): void { + this.params = params; + } + + refresh(): boolean { + return false; + } + + onEdit(): void { + this.dialog.open(ClientFormComponent, { + width: '400px', + data: { + mode: 'edit', + client: { ...this.params.data } + } + }).afterClosed().subscribe(updatedClient => { + if (updatedClient) { + this.clientService.updateClient(updatedClient).subscribe(() => { + this.params.api.applyTransaction({ update: [updatedClient] }); + }); + } + }); + } + + onDelete(): void { + const confirmed = confirm(`Delete client "${this.params.data.name}"?`); + if (confirmed) { + this.clientService.deleteClient(this.params.data.id).subscribe(() => { + this.params.api.applyTransaction({ remove: [this.params.data] }); + }); + } + } +} diff --git a/client-management/src/app/app-routing.module.ts b/client-management/src/app/app-routing.module.ts new file mode 100644 index 0000000..62d58cb --- /dev/null +++ b/client-management/src/app/app-routing.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ClientFormComponent } from './client-form/client-form.component'; +import { ClientListComponent } from './client-list/client-list.component'; +import { LoginComponent } from './login/login.component'; + +const routes: Routes = [ + { path: '', redirectTo: 'login', pathMatch: 'full' }, + { path: 'login', component: LoginComponent }, + { path: 'clients', component: ClientListComponent }, + { path: 'clients/add', component: ClientFormComponent }, + { path: 'clients/edit/:id', component: ClientFormComponent } +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] +}) +export class AppRoutingModule { } diff --git a/client-management/src/app/app.component.html b/client-management/src/app/app.component.html new file mode 100644 index 0000000..0680b43 --- /dev/null +++ b/client-management/src/app/app.component.html @@ -0,0 +1 @@ + diff --git a/client-management/src/app/app.component.scss b/client-management/src/app/app.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/client-management/src/app/app.component.spec.ts b/client-management/src/app/app.component.spec.ts new file mode 100644 index 0000000..788ffc6 --- /dev/null +++ b/client-management/src/app/app.component.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule + ], + declarations: [ + AppComponent + ], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have as title 'client-management'`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('client-management'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.content span')?.textContent).toContain('client-management app is running!'); + }); +}); diff --git a/client-management/src/app/app.component.ts b/client-management/src/app/app.component.ts new file mode 100644 index 0000000..52ce56b --- /dev/null +++ b/client-management/src/app/app.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent { + title = 'client-management'; +} diff --git a/client-management/src/app/app.module.ts b/client-management/src/app/app.module.ts new file mode 100644 index 0000000..02b648e --- /dev/null +++ b/client-management/src/app/app.module.ts @@ -0,0 +1,50 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; + +import { AppRoutingModule } from './app-routing.module'; +import { AppComponent } from './app.component'; +import { ClientListComponent } from './client-list/client-list.component'; +import { LoginComponent } from './login/login.component'; +import { ClientFormComponent } from './client-form/client-form.component'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { HttpClientModule } from '@angular/common/http'; +import { AgGridModule } from 'ag-grid-angular'; +import { MatDialogModule } from '@angular/material/dialog'; +import { ActionButtonRendererComponent } from './action-button-renderer/action-button-renderer.component'; + + +@NgModule({ + declarations: [ + AppComponent, + LoginComponent, + ClientListComponent, + ClientFormComponent, + ActionButtonRendererComponent + ], + imports: [ + BrowserModule, + AppRoutingModule, + BrowserAnimationsModule, + HttpClientModule, + MatDividerModule, + MatInputModule, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatIconModule, + ReactiveFormsModule, + AgGridModule, + FormsModule, + MatDialogModule + ], + providers: [], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/client-management/src/app/client-form/client-form.component.html b/client-management/src/app/client-form/client-form.component.html new file mode 100644 index 0000000..af09cf2 --- /dev/null +++ b/client-management/src/app/client-form/client-form.component.html @@ -0,0 +1,31 @@ +

+ {{ mode === 'edit' ? 'Edit Client' : mode === 'view' ? 'View Client' : 'Add New Client' }} +

+ +
+
+ + {{ field.label }} + + + {{ field.label }} is required + + +
+
+ + + + + + + + + diff --git a/client-management/src/app/client-form/client-form.component.scss b/client-management/src/app/client-form/client-form.component.scss new file mode 100644 index 0000000..cc1858d --- /dev/null +++ b/client-management/src/app/client-form/client-form.component.scss @@ -0,0 +1,83 @@ +:host { + display: block; + width: 100%; + max-width: 500px; + margin: auto; +} + +.form-title { + text-align: center; + font-size: 1.5rem; + font-weight: 600; + color: #4a2e29; + background-color: #ffe1d6; + padding: 16px; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + margin: -24px 0px 16px 0px; +} + +.form-wrapper { + display: flex; + flex-direction: column; + gap: 16px; + padding: 8px 24px; + background-color: #fffaf8; + max-height: 60vh; + overflow-y: auto; + + // scroll handling + scrollbar-width: thin; + scrollbar-color: #ffd2bd transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background-color: #ffd2bd; + border-radius: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } +} + +.form-group { + display: flex; + flex-direction: column; +} + +.full-width { + width: 100%; +} + +mat-form-field { + --mat-form-field-outline-color: #ff815f; + --mat-form-field-hover-outline-color: #feaa8c; + --mat-form-field-disabled-color: #ccc; + --mat-form-field-label-color: #4a2e29; +} + +.dialog-actions { + margin-top: 20px; + padding: 0 24px 12px 24px; + background-color: #fffaf8; + border-top: 1px solid #ffe1d6; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; +} + +:host-context(.view-mode) mat-form-field { + opacity: 1; // override disabled opacity + pointer-events: none; // prevents editing +} + +:host-context(.view-mode) input { + color: #333 !important; + -webkit-text-fill-color: #333 !important; + font-weight: 500; + cursor: default; + border-radius: 4px; +} diff --git a/client-management/src/app/client-form/client-form.component.spec.ts b/client-management/src/app/client-form/client-form.component.spec.ts new file mode 100644 index 0000000..f52a030 --- /dev/null +++ b/client-management/src/app/client-form/client-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ClientFormComponent } from './client-form.component'; + +describe('ClientFormComponent', () => { + let component: ClientFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ClientFormComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ClientFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client-management/src/app/client-form/client-form.component.ts b/client-management/src/app/client-form/client-form.component.ts new file mode 100644 index 0000000..0db18c2 --- /dev/null +++ b/client-management/src/app/client-form/client-form.component.ts @@ -0,0 +1,83 @@ +import { Component, ElementRef, Inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-client-form', + templateUrl: './client-form.component.html', + styleUrls: ['./client-form.component.scss'] +}) +export class ClientFormComponent implements OnInit { + clientForm: FormGroup; + mode: 'add' | 'edit' | 'view'; + + readonly: boolean = false; + + fieldSchema = [ + { key: 'name', label: 'Name', type: 'text', required: true }, + { key: 'company', label: 'Company', type: 'text', required: true }, + { key: 'age', label: 'Age', type: 'number', required: true }, + { key: 'gender', label: 'Gender', type: 'text' }, + { key: 'picture', label: 'Picture URL', type: 'text' }, + { key: 'registered', label: 'Registered Date', type: 'date' }, + { key: 'currency', label: 'Currency', type: 'text' }, + { key: 'subscriptionCost', label: 'Subscription Cost', type: 'number', required: true } + ]; + + constructor( + private fb: FormBuilder, + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any, + private elementRef: ElementRef + ) { + this.mode = data.mode; + + this.readonly = data.readonly || false; + + const client = data.client || {}; + const today = new Date().toISOString().split('T')[0]; + + this.clientForm = this.fb.group({}); + + this.fieldSchema.forEach(field => { + let value = client[field.key] ?? (field.key === 'registered' ? today : field.type === 'number' ? 0 : ''); + + if (field.type === 'date' && typeof value === 'string') { + value = value.replace(/\s(?=[+-]\d{2}:\d{2})/, ''); + const parsed = new Date(value); + if (!isNaN(parsed.getTime())) { + value = parsed.toISOString().split('T')[0]; + } + } + + const controlConfig = { + value, + disabled: this.readonly || (field.key === 'registered' && this.mode === 'edit') + }; + + this.clientForm.addControl( + field.key, + this.fb.control(controlConfig, field.required ? Validators.required : undefined) + ); + }); + + this.clientForm.addControl('id', this.fb.control(client.id || null)); + } + + ngOnInit() { + if (this.mode === 'view') { + this.elementRef.nativeElement.classList.add('view-mode'); + } +} + + submit(): void { + if (this.clientForm.valid) { + const formValue = this.clientForm.getRawValue(); + this.dialogRef.close(formValue); + } + } + + cancel(): void { + this.dialogRef.close(); + } +} diff --git a/client-management/src/app/client-list/client-list.component.html b/client-management/src/app/client-list/client-list.component.html new file mode 100644 index 0000000..1132b08 --- /dev/null +++ b/client-management/src/app/client-list/client-list.component.html @@ -0,0 +1,23 @@ +
+

Welcome, {{ userName }}

+ +
+ + + +
+
+ +
+ +
+ + +
+
diff --git a/client-management/src/app/client-list/client-list.component.scss b/client-management/src/app/client-list/client-list.component.scss new file mode 100644 index 0000000..1488d07 --- /dev/null +++ b/client-management/src/app/client-list/client-list.component.scss @@ -0,0 +1,75 @@ +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + h2 { + margin: 0; + font-weight: 500; + color: #333; + } +} + +.logout { + background: black !important; + color: white !important; +} + +.divider { + margin-bottom: 10px !important; + background-color: black !important; +} + + + +.grid-wrapper { + display: flex; + flex-direction: column; + padding: 20px; + height: calc(100vh - 160px); + max-width: 800px; + margin: 20px auto; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + border-radius: 12px; + background-color: #fffaf8; + overflow: hidden; +} + +.client-actions { + margin-bottom: 2px; + display: flex; + justify-content: flex-end; +} + +.grid-container { + flex: 1; + overflow: auto; + min-height: 0; +} + +:host ::ng-deep .ag-theme-alpine { + height: 100%; + width: 100%; +} + + +::ng-deep .ag-theme-alpine { + --ag-background-color: #fffaf8; + --ag-header-background-color: #ffe1d6; + --ag-header-foreground-color: #4a2e29; + --ag-row-hover-color: #ffd2bd; + --ag-selected-row-background-color: #ffc3a4; + --ag-row-border-color: #ffd2bd; + --ag-odd-row-background-color: #fff2ee; + --ag-even-row-background-color: #ffffff; + --ag-font-family: 'Segoe UI', sans-serif; + --ag-font-size: 14px; + + --ag-input-border-color: #feaa8c; + --ag-input-focus-border-color: #ff815f; + --ag-checkbox-checked-color: #feaa8c; + --ag-range-selection-border-color: #feaa8c; + + --ag-chip-background-color: #ffd2bd; + --ag-chip-color: #4a2e29; +} \ No newline at end of file diff --git a/client-management/src/app/client-list/client-list.component.spec.ts b/client-management/src/app/client-list/client-list.component.spec.ts new file mode 100644 index 0000000..61b6969 --- /dev/null +++ b/client-management/src/app/client-list/client-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ClientListComponent } from './client-list.component'; + +describe('ClientListComponent', () => { + let component: ClientListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ClientListComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ClientListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client-management/src/app/client-list/client-list.component.ts b/client-management/src/app/client-list/client-list.component.ts new file mode 100644 index 0000000..7593b99 --- /dev/null +++ b/client-management/src/app/client-list/client-list.component.ts @@ -0,0 +1,156 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { AuthService } from '../services/auth.service'; +import { ClientService } from '../services/client-service.service'; +import { GridOptions } from 'ag-grid-community'; +import { MatDialog } from '@angular/material/dialog'; +import { ClientFormComponent } from '../client-form/client-form.component'; +import { ActionButtonRendererComponent } from '../action-button-renderer/action-button-renderer.component'; +import { AgGridAngular } from 'ag-grid-angular'; + +@Component({ + selector: 'app-client-list', + templateUrl: './client-list.component.html', + styleUrls: ['./client-list.component.scss'] +}) +export class ClientListComponent implements OnInit { + @ViewChild(AgGridAngular) agGrid!: AgGridAngular; + + userName: string = 'User'; + + clients: any[] = []; + + columnDefs = [ + { + headerName: 'Actions', + field: 'actions', + cellRenderer: 'actionButtonsRenderer', + width: 150 + }, + { + headerName: 'Profile', + field: 'picture', + cellRenderer: (params: any) => + `profile`, + width: 80, + hide: true + }, + { field: 'name', headerName: 'Client Name' }, + { field: 'gender', headerName: 'Gender', width: 100, hide: true }, + { field: 'company', headerName: 'Company', width: 150 }, + { field: 'currency', headerName: 'Currency', width: 100, hide: true }, + { + field: 'subscriptionCost', + headerName: 'Subscription Cost', + valueFormatter: (params: any) => + Number(params.value).toLocaleString(undefined, { + style: 'currency', + currency: params.data.currency || 'USD', + maximumFractionDigits: 2 + }) + }, + { field: 'age', headerName: 'Age', width: 110 }, + { + headerName: 'Registered', + field: 'registered', + hide: true, + valueFormatter: (params: { value: string }) => { + const cleaned = params.value.replace(' -', '-'); + const date = new Date(cleaned); + return isNaN(date.getTime()) + ? 'Invalid Date' + : date.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric' + }); + } + } + ]; + + gridOptions: GridOptions = { + defaultColDef: { + resizable: true, + sortable: true, + filter: true + }, + sideBar: { + toolPanels: [ + { + id: 'columns', + labelDefault: 'Columns', + labelKey: 'columns', + iconKey: 'columns', + toolPanel: 'agColumnsToolPanel' + } + ], + defaultToolPanel: 'columns' + }, + animateRows: true, + rowSelection: 'single', + getRowId: params => params.data.id, + frameworkComponents: { + actionButtonsRenderer: ActionButtonRendererComponent + }, + context: { + componentParent: this + }, + onRowDoubleClicked: event => this.onRowDoubleClicked(event) + }; + + constructor( + private clientService: ClientService, + private authService: AuthService, + private dialog: MatDialog + ) { + this.userName = this.authService.getUserName(); + } + + ngOnInit(): void { + this.clientService.getClients().subscribe(data => { + this.clients = data; + }); + } + + logout(): void { + this.authService.logout(); + } + + onRowDoubleClicked(event: any): void { + const dialogRef = this.dialog.open(ClientFormComponent, { + width: '400px', + data: { mode: 'view', client: event.data, readonly: true } + }); + + dialogRef.afterClosed().subscribe(); +} + + openClientDialog(mode: 'add'): void { + const maxId = Math.max(...this.clients.map(c => +c.id), 0); + const today = new Date().toISOString().split('T')[0]; + + const newClientPayload = { + id: (maxId + 1).toString(), + name: '', + company: '', + age: null, + gender: '', + picture: 'https://via.placeholder.com/32', + registered: today, + currency: 'USD', + subscriptionCost: '' + }; + + const dialogRef = this.dialog.open(ClientFormComponent, { + width: '400px', + data: { mode, client: newClientPayload } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.clientService.addClient(result).subscribe(client => { + this.agGrid.api.applyTransaction({ add: [client] }); + }); + } + }); + } +} diff --git a/client-management/src/app/login/login.component.html b/client-management/src/app/login/login.component.html new file mode 100644 index 0000000..be537aa --- /dev/null +++ b/client-management/src/app/login/login.component.html @@ -0,0 +1,33 @@ + diff --git a/client-management/src/app/login/login.component.scss b/client-management/src/app/login/login.component.scss new file mode 100644 index 0000000..dc9c5ff --- /dev/null +++ b/client-management/src/app/login/login.component.scss @@ -0,0 +1,42 @@ +.login-wrapper { + background-color: #ffffff; + padding: 32px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + width: 100%; + max-width: 400px; + margin: auto; +} + +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; /* Full screen centering */ +} + +.form-container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.help-icon { + position: absolute; + top: 8px; + right: 8px; +} + +.full-width { + width: 100%; +} + +.submit-btn { + margin-top: 16px; +} + +.error-message { + color: #c62828; + margin-top: 10px; + font-weight: 500; +} diff --git a/client-management/src/app/login/login.component.spec.ts b/client-management/src/app/login/login.component.spec.ts new file mode 100644 index 0000000..10eca24 --- /dev/null +++ b/client-management/src/app/login/login.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ LoginComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client-management/src/app/login/login.component.ts b/client-management/src/app/login/login.component.ts new file mode 100644 index 0000000..7778a28 --- /dev/null +++ b/client-management/src/app/login/login.component.ts @@ -0,0 +1,56 @@ +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { AuthService } from '../services/auth.service'; +import { Router } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; + + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'], +}) +export class LoginComponent { + loginForm: FormGroup; + loginError: boolean = false; + + constructor(private fb: FormBuilder, private http: HttpClient, private authService: AuthService, private router: Router) { + this.loginForm = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + password: [ + '', + [ + Validators.required, + Validators.minLength(8), + Validators.pattern( + '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$' + ), + ], + ], + }); + } + + get email() { + return this.loginForm.get('email'); + } + + get password() { + return this.loginForm.get('password'); + } + + onSubmit() { + if (this.loginForm.valid) { + const { email, password } = this.loginForm.value; + this.authService.login(email, password).subscribe({ + next: user => { + this.router.navigate(['/clients'], { + state: { name: user.name }, + }); + }, + error: () => { + alert('You are not a registered admin or your credentials are incorrect.'); + }, + }); + } + } +} diff --git a/client-management/src/app/model/client-model.ts b/client-management/src/app/model/client-model.ts new file mode 100644 index 0000000..3e2099a --- /dev/null +++ b/client-management/src/app/model/client-model.ts @@ -0,0 +1,11 @@ +export interface Client { + id: string; + gender: string; + name: string; + company: string; + age: number; + picture: string; + registered: string; + currency: string; + subscriptionCost: string; +} \ No newline at end of file diff --git a/client-management/src/app/services/auth.service.spec.ts b/client-management/src/app/services/auth.service.spec.ts new file mode 100644 index 0000000..f1251ca --- /dev/null +++ b/client-management/src/app/services/auth.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AuthService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client-management/src/app/services/auth.service.ts b/client-management/src/app/services/auth.service.ts new file mode 100644 index 0000000..a09e4c5 --- /dev/null +++ b/client-management/src/app/services/auth.service.ts @@ -0,0 +1,41 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, map, Observable, throwError } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private userName: string = ''; + + constructor(private router: Router, private http: HttpClient) {} + + setUserName(name: string) { + this.userName = name; + } + getUserName(): string { + return this.userName; + } + + login(email: string, password: string): Observable { + return this.http + .get(`https://glowing-fishstick-g6vjrxjwjwpfvxpp-4090.app.github.dev/users?email=${email}`) + .pipe( + map(users => { + const user = users[0]; + if (user && user.password === password) { + this.userName = user.name; + return user; + } else { + throw new Error('Invalid credentials'); + } + }), + catchError(err => throwError(() => new Error('Login failed'))) + ); + } + + logout(): void { + this.router.navigate(['/login']); + } +} diff --git a/client-management/src/app/services/client-service.service.spec.ts b/client-management/src/app/services/client-service.service.spec.ts new file mode 100644 index 0000000..ece22ff --- /dev/null +++ b/client-management/src/app/services/client-service.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ClientServiceService } from './client-service.service'; + +describe('ClientServiceService', () => { + let service: ClientServiceService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ClientServiceService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client-management/src/app/services/client-service.service.ts b/client-management/src/app/services/client-service.service.ts new file mode 100644 index 0000000..49c97b6 --- /dev/null +++ b/client-management/src/app/services/client-service.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Client } from '../model/client-model'; + +@Injectable({ providedIn: 'root' }) +export class ClientService { + private baseUrl = 'https://glowing-fishstick-g6vjrxjwjwpfvxpp-4090.app.github.dev/clients'; + + constructor(private http: HttpClient) {} + + getClients(): Observable { + return this.http.get(this.baseUrl); + } + + addClient(client: Client): Observable { + return this.http.post(this.baseUrl, client); + } + + updateClient(client: Client): Observable { + const url = `${this.baseUrl}/${client.id}`; + return this.http.put(url, client); + } + + deleteClient(id: string): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} diff --git a/client-management/src/assets/.gitkeep b/client-management/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/client-management/src/favicon.ico b/client-management/src/favicon.ico new file mode 100644 index 0000000..997406a Binary files /dev/null and b/client-management/src/favicon.ico differ diff --git a/client-management/src/index.html b/client-management/src/index.html new file mode 100644 index 0000000..759ca3c --- /dev/null +++ b/client-management/src/index.html @@ -0,0 +1,16 @@ + + + + + ClientManagement + + + + + + + + + + + diff --git a/client-management/src/main.ts b/client-management/src/main.ts new file mode 100644 index 0000000..c58dc05 --- /dev/null +++ b/client-management/src/main.ts @@ -0,0 +1,7 @@ +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; + + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); diff --git a/client-management/src/styles.scss b/client-management/src/styles.scss new file mode 100644 index 0000000..d659e2f --- /dev/null +++ b/client-management/src/styles.scss @@ -0,0 +1,42 @@ + +// Custom Theming for Angular Material +// For more information: https://material.angular.io/guide/theming +@use '@angular/material' as mat; +// Plus imports for other components in your app. + +// Include the common styles for Angular Material. We include this here so that you only +// have to load a single css file for Angular Material in your app. +// Be sure that you only ever include this mixin once! +@include mat.core(); + +// Define the palettes for your theme using the Material Design palettes available in palette.scss +// (imported above). For each palette, you can optionally specify a default, lighter, and darker +// hue. Available color palettes: https://material.io/design/color/ +$client-management-primary: mat.define-palette(mat.$indigo-palette); +$client-management-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400); + +// The warn palette is optional (defaults to red). +$client-management-warn: mat.define-palette(mat.$red-palette); + +// Create the theme object. A theme consists of configurations for individual +// theming systems such as "color" or "typography". +$client-management-theme: mat.define-light-theme(( + color: ( + primary: $client-management-primary, + accent: $client-management-accent, + warn: $client-management-warn, + ) +)); + +// Include theme styles for core and each component used in your app. +// Alternatively, you can import and @include the theme mixins for each component +// that you are using. +@include mat.all-component-themes($client-management-theme); + +/* You can add global styles to this file, and also import other style files */ + +@import 'ag-grid-community/styles/ag-grid.css'; +@import 'ag-grid-community/styles/ag-theme-alpine.css'; + +html, body { height: 100%; } +body { margin: 0; font-family: Gilroy, sans-serif; background-color: #feaa8c; } diff --git a/client-management/tsconfig.app.json b/client-management/tsconfig.app.json new file mode 100644 index 0000000..374cc9d --- /dev/null +++ b/client-management/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/client-management/tsconfig.json b/client-management/tsconfig.json new file mode 100644 index 0000000..ed966d4 --- /dev/null +++ b/client-management/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/client-management/tsconfig.spec.json b/client-management/tsconfig.spec.json new file mode 100644 index 0000000..be7e9da --- /dev/null +++ b/client-management/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/clients.json b/clients.json index 15fcbb7..738c680 100644 --- a/clients.json +++ b/clients.json @@ -1,4 +1,12 @@ { + "users": [ + { + "id": "1", + "email": "admin@virtusize.com", + "password": "Admin@123", + "name": "Virtusize Admin" + } + ], "clients": [ { "id": "0", @@ -76,6 +84,50 @@ "registered": "2019-10-18T07:26:00 -09:00", "currency": "SGD", "subscriptionCost": "500.00" + }, + { + "name": "RaahimTest", + "company": "GAP", + "age": "45", + "gender": "Male", + "picture": "https://via.placeholder.com/32", + "registered": "2025-08-03", + "currency": "USD", + "subscriptionCost": "110", + "id": "7" + }, + { + "id": "11", + "name": "Raahim", + "company": "Adidas", + "age": "321", + "gender": "Male", + "picture": "https://via.placeholder.com/32", + "registered": "2025-08-30", + "currency": "GBP", + "subscriptionCost": "20000" + }, + { + "id": "12", + "name": "RaahimTest3", + "company": "Ralph Lauren", + "age": "14", + "gender": "male", + "picture": "https://via.placeholder.com/32", + "registered": "2025-08-10", + "currency": "USD", + "subscriptionCost": "20000" + }, + { + "id": "12", + "name": "Test5", + "company": "Onitsuka", + "age": "5", + "gender": "female", + "picture": "https://via.placeholder.com/32", + "registered": "2025-11-03", + "currency": "YEN", + "subscriptionCost": "14000000" } ] -} +} \ No newline at end of file diff --git a/devNotes.md b/devNotes.md index ef9d6b5..6e94070 100644 --- a/devNotes.md +++ b/devNotes.md @@ -1 +1,6 @@ # Developer Notes +- Login with basic authentication (email - admin@virtusize.com, pw - Admin@123) +- Displays paginated client list (10 rows per page) +- Supports View/Edit/Delete actions via action buttons +- Opens Client Details in view mode by double clicking row +- Used AG Grid for its powerful, customizable, and high-performance data table features, including pagination, sorting, and flexible column rendering \ No newline at end of file diff --git a/package.json b/package.json index bf9fa05..a878eec 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "author": "virtusize", "license": "UNLICENSED", - "dependencies": { - "json-server": "^1.0.0-beta.2" + "devDependencies": { + "json-server": "^1.0.0-beta.3" } }