Séparation du frontend et du backend de Color Recipes Explorer en deux projets

This commit is contained in:
FyloZ 2021-02-12 10:28:34 -05:00
commit 1907c71980
205 changed files with 21393 additions and 0 deletions

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# 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
[*.md]
max_line_length = off
trim_trailing_whitespace = false

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db

27
README.md Normal file
View File

@ -0,0 +1,27 @@
# ColorRecipesExplorerFrontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 9.0.5.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app 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. Use the `--prod` flag for a production build.
## 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 [Protractor](http://www.protractortest.org/).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

132
angular.json Normal file
View File

@ -0,0 +1,132 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"color-recipes-explorer-frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "sass"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "cre",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/color-recipes-explorer-frontend",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [
"src/favicon.ico",
"src/assets",
{ "glob": "mdi.svg", "input": "./node_modules/@mdi/angular-material", "output": "./assets"}
],
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/custom-theme.scss",
"src/styles.sass"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "100kb",
"maximumError": "200kb"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "color-recipes-explorer-frontend:build"
},
"configurations": {
"production": {
"browserTarget": "color-recipes-explorer-frontend:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "color-recipes-explorer-frontend:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.sass"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "color-recipes-explorer-frontend:serve"
},
"configurations": {
"production": {
"devServerTarget": "color-recipes-explorer-frontend:serve:production"
}
}
}
}
}
},
"defaultProject": "color-recipes-explorer-frontend"
}

12
browserslist Normal file
View File

@ -0,0 +1,12 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# You can see what browsers were selected by your queries by running:
# npx browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

32
e2e/protractor.conf.js Normal file
View File

@ -0,0 +1,32 @@
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
browserName: 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

23
e2e/src/app.e2e-spec.ts Normal file
View File

@ -0,0 +1,23 @@
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('color-recipes-explorer-frontend app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});

11
e2e/src/app.po.ts Normal file
View File

@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo(): Promise<unknown> {
return browser.get(browser.baseUrl) as Promise<unknown>;
}
getTitleText(): Promise<string> {
return element(by.css('cre-root .content span')).getText() as Promise<string>;
}
}

13
e2e/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

32
karma.conf.js Normal file
View File

@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/color-recipes-explorer-frontend'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

12896
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "color-recipes-explorer-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/animations": "~9.0.5",
"@angular/cdk": "^9.2.4",
"@angular/common": "~9.0.5",
"@angular/compiler": "~9.0.5",
"@angular/core": "~9.0.5",
"@angular/forms": "~9.0.5",
"@angular/material": "^9.2.4",
"@angular/platform-browser": "~9.0.5",
"@angular/platform-browser-dynamic": "~9.0.5",
"@angular/router": "~9.0.5",
"@mdi/angular-material": "^5.7.55",
"bootstrap": "^4.5.2",
"copy-webpack-plugin": "^6.2.1",
"js-joda": "^1.11.0",
"material-design-icons": "^3.0.1",
"ngx-material-file-input": "^2.1.1",
"rxjs": "~6.5.4",
"tslib": "^1.10.0",
"zone.js": "~0.10.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.900.5",
"@angular/cli": "~9.0.5",
"@angular/compiler-cli": "~9.0.5",
"@angular/language-service": "~9.0.5",
"@types/node": "^12.11.1",
"@types/jasmine": "~3.5.0",
"@types/jasminewd2": "~2.0.3",
"codelyzer": "^5.1.2",
"jasmine-core": "~3.5.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~4.3.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~2.1.0",
"karma-jasmine": "~2.0.1",
"karma-jasmine-html-reporter": "^1.4.2",
"protractor": "~5.4.3",
"ts-node": "~8.3.0",
"tslint": "~5.18.0",
"typescript": "~3.7.5"
}
}

6
proxy.conf.json Normal file
View File

@ -0,0 +1,6 @@
{
"/api": {
"target": "http://localhost:9090/api",
"secure": false
}
}

View File

@ -0,0 +1,48 @@
import {NgModule} from '@angular/core';
import {Routes, RouterModule} from '@angular/router';
import {CatalogComponent} from "./pages/catalog/catalog.component";
const routes: Routes = [{
path: 'color',
loadChildren: () => import('./modules/colors/colors.module').then(m => m.ColorsModule)
}, {
path: 'account',
loadChildren: () => import('./modules/accounts/accounts.module').then(m => m.AccountsModule)
}, {
path: 'employee',
loadChildren: () => import('./modules/employees/employees.module').then(m => m.EmployeesModule)
}, {
path: 'group',
loadChildren: () => import('./modules/groups/groups.module').then(m => m.GroupsModule)
}, {
path: 'catalog',
component: CatalogComponent,
children: [
{
path: 'materialtype',
loadChildren: () => import('./modules/material-type/material-type.module').then(m => m.MaterialTypeModule),
},
{
path: 'material',
loadChildren: () => import('./modules/material/material.module').then(m => m.MaterialModule)
},
{
path: 'company',
loadChildren: () => import('./modules/company/company.module').then(m => m.CompanyModule)
},
{
path: '',
pathMatch: 'full',
redirectTo: 'materialtype'
}
]
},
{path: 'material', loadChildren: () => import('./modules/material/material.module').then(m => m.MaterialModule)}];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {
}

View File

@ -0,0 +1,20 @@
<cre-header></cre-header>
<div>
<div class="dark-background"></div>
<router-outlet></router-outlet>
<div class="offline-server-card-wrapper" [hidden]="isServerOnline">
<div class="dark-background"></div>
<mat-card class="x-centered y-centered">
<mat-card-header>
<mat-card-title>Erreur de connexion</mat-card-title>
</mat-card-header>
<mat-card-content>
<p>Le serveur est présentement hors ligne. Réessayez plus tard.</p>
</mat-card-content>
<mat-card-actions>
<button mat-raised-button color="accent" (click)="reload()">Réessayer</button>
</mat-card-actions>
</mat-card>
</div>
</div>

View File

@ -0,0 +1,13 @@
.offline-server-card-wrapper
position: fixed
top: 0
z-index: 100
.dark-background
position: fixed
top: 0
opacity: .5
mat-card
left: 50vw
transform: translate(-50%, -50%)

41
src/app/app.component.ts Normal file
View File

@ -0,0 +1,41 @@
import {Component, Inject, PLATFORM_ID} from '@angular/core';
import {isPlatformBrowser} from "@angular/common";
import {AppState} from "./modules/shared/app-state";
import {Observable} from "rxjs";
import {SubscribingComponent} from "./modules/shared/components/subscribing.component";
import {ActivatedRoute, Router} from "@angular/router";
@Component({
selector: 'cre-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.sass']
})
export class AppComponent extends SubscribingComponent {
isOnline: boolean
isServerOnline = true
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private appState: AppState,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit() {
this.isOnline = isPlatformBrowser(this.platformId)
super.ngOnInit();
this.subscribe(
this.appState.serverOnline$,
{
next: online => this.isServerOnline = online
}
)
}
reload() {
window.location.reload()
}
}

32
src/app/app.module.ts Normal file
View File

@ -0,0 +1,32 @@
import {DomSanitizer} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {MatIconRegistry} from "@angular/material/icon";
import {SharedModule} from "./modules/shared/shared.module";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {CatalogComponent} from './pages/catalog/catalog.component';
import {CompanyModule} from './modules/company/company.module';
@NgModule({
declarations: [
AppComponent,
CatalogComponent
],
imports: [
AppRoutingModule,
SharedModule,
BrowserAnimationsModule,
CompanyModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
constructor(matIconRegistry: MatIconRegistry, domSanitizer: DomSanitizer) {
matIconRegistry.addSvgIconSet(
domSanitizer.bypassSecurityTrustResourceUrl('./assets/mdi.svg')
)
}
}

View File

@ -0,0 +1,14 @@
import {NgModule} from '@angular/core';
import {Routes, RouterModule} from '@angular/router';
import {LoginComponent} from './pages/login/login.component';
import {LogoutComponent} from "./pages/logout/logout.component";
const routes: Routes = [{path: 'login', component: LoginComponent}, {path: 'logout', component: LogoutComponent}, {path: '', redirectTo: 'login'}];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AccountsRoutingModule {
}

View File

@ -0,0 +1,19 @@
import {NgModule} from '@angular/core';
import {AccountsRoutingModule} from './accounts-routing.module';
import {LoginComponent} from './pages/login/login.component';
import {SharedModule} from "../shared/shared.module";
import {LogoutComponent} from './pages/logout/logout.component';
import {CommonModule} from "@angular/common";
import {BrowserModule} from "@angular/platform-browser";
@NgModule({
declarations: [LoginComponent, LogoutComponent],
imports: [
SharedModule,
AccountsRoutingModule,
]
})
export class AccountsModule {
}

View File

@ -0,0 +1,39 @@
<form [formGroup]="form">
<mat-card class="x-centered y-centered">
<mat-card-header>
<mat-card-title>Connexion au système</mat-card-title>
</mat-card-header>
<mat-card-content>
<div *ngIf="invalidCredentials" class="alert alert-danger">
<p>Les identifiants entrés sont invalides.</p>
</div>
<mat-form-field>
<mat-label>Numéro d'employé</mat-label>
<input matInput [formControl]="idFormControl" type="text"/>
<mat-icon matSuffix>person</mat-icon>
<mat-error *ngIf="idFormControl.invalid">
<span *ngIf="idFormControl.errors.required">Un numéro d'employé est requis</span>
<span *ngIf="idFormControl.errors.pattern">Le numéro d'employé doit être un nombre</span>
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Mot de passe</mat-label>
<input matInput [formControl]="passwordFormControl" type="password"/>
<mat-icon matSuffix>lock</mat-icon>
<mat-error *ngIf="passwordFormControl.invalid">
<span *ngIf="passwordFormControl.errors.required">Un mot de passe est requis</span>
</mat-error>
</mat-form-field>
</mat-card-content>
<mat-card-actions class="justify-content-end">
<button
mat-raised-button
type="submit"
color="accent"
[disabled]="form.invalid"
(click)="submit()">
Connexion
</button>
</mat-card-actions>
</mat-card>
</form>

View File

@ -0,0 +1,8 @@
mat-card
width: 25rem
.alert p
margin: 0
mat-form-field
width: 100%

View File

@ -0,0 +1,46 @@
import {Component, OnInit} from '@angular/core';
import {FormBuilder, FormControl, FormGroup, Validators} from "@angular/forms";
import {AccountService} from "../../services/account.service";
import {Router} from "@angular/router";
@Component({
selector: 'cre-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.sass']
})
export class LoginComponent implements OnInit {
form: FormGroup
idFormControl: FormControl
passwordFormControl: FormControl
invalidCredentials = false
constructor(
private formBuilder: FormBuilder,
private accountService: AccountService,
private router: Router
) {
}
ngOnInit(): void {
if (this.accountService.isLoggedIn()) {
this.router.navigate(['/color'])
}
this.idFormControl = this.formBuilder.control(null, Validators.compose([Validators.required, Validators.pattern(new RegExp('^[0-9]+$'))]))
this.passwordFormControl = this.formBuilder.control(null, Validators.required)
this.form = this.formBuilder.group({
id: this.idFormControl,
password: this.passwordFormControl
})
}
submit() {
this.accountService.login(
this.idFormControl.value,
this.passwordFormControl.value,
() => this.router.navigate(["/color"]),
err => this.invalidCredentials = err.status === 401
)
}
}

View File

@ -0,0 +1,28 @@
import {Component, OnInit} from '@angular/core';
import {AccountService} from "../../services/account.service";
import {Router} from "@angular/router";
@Component({
selector: 'cre-logout',
templateUrl: './logout.component.html',
styleUrls: ['./logout.component.sass']
})
export class LogoutComponent implements OnInit {
constructor(
private accountService: AccountService,
private router: Router
) {
}
ngOnInit(): void {
if (!this.accountService.isLoggedIn()) {
this.router.navigate(['/account/login'])
}
this.accountService.logout(() => {
this.router.navigate(['/account/login'])
})
}
}

View File

@ -0,0 +1,121 @@
import {Injectable, OnDestroy} from '@angular/core';
import {Subject} from "rxjs";
import {take, takeUntil} from "rxjs/operators";
import {AppState} from "../../shared/app-state";
import {HttpClient, HttpResponse} from "@angular/common/http";
import {environment} from "../../../../environments/environment";
import {ApiService} from "../../shared/service/api.service";
import {Employee, EmployeePermission} from "../../shared/model/employee";
@Injectable({
providedIn: 'root'
})
export class AccountService implements OnDestroy {
private destroy$ = new Subject<boolean>()
constructor(
private http: HttpClient,
private api: ApiService,
private appState: AppState
) {
}
ngOnDestroy(): void {
this.destroy$.next(true)
this.destroy$.complete()
}
isLoggedIn(): boolean {
return this.appState.isAuthenticated
}
checkAuthenticationStatus() {
if (!this.appState.authenticatedEmployee) {
// Try to get current default group user
this.http.get<Employee>(`${environment.apiUrl}/employee/current`, {withCredentials: true})
.pipe(
take(1),
takeUntil(this.destroy$),
).subscribe({
next: employee => this.appState.authenticatedEmployee = employee,
error: err => {
if (err.status === 0 && err.statusText === "Unknown Error") {
this.appState.isServerOnline = false
} else {
this.appState.isServerOnline = true
if (err.status === 404 || err.status === 403) {
console.error('No default user is defined on this computer')
} else {
console.error('An error occurred while authenticating the default user')
console.error(err)
}
}
}
})
}
}
login(id: number, password: string, success: () => void, error: (err) => void) {
const loginForm = {id, password}
this.http.post<any>(`${environment.apiUrl}/login`, loginForm, {
withCredentials: true,
observe: 'response' as 'body'
})
.pipe(
take(1),
takeUntil(this.destroy$)
)
.subscribe({
next: (response: HttpResponse<any>) => {
this.appState.authenticationExpiration = parseInt(response.headers.get("X-Authentication-Expiration"))
this.appState.isAuthenticated = true
this.setLoggedInEmployeeFromApi()
success()
},
error: err => {
if (err.status === 0 && err.statusText === "Unknown Error") {
this.appState.isServerOnline = false
} else {
this.appState.isServerOnline = true
error(err)
}
}
})
}
logout(success: () => void) {
this.api.get<void>('/employee/logout', true).pipe(
take(1),
takeUntil(this.destroy$)
)
.subscribe({
next: () => {
this.appState.isAuthenticated = false
this.appState.authenticationExpiration = -1
this.appState.authenticatedEmployee = null
this.checkAuthenticationStatus()
success()
},
error: err => console.error(err)
})
}
hasPermission(permission: EmployeePermission): boolean {
return this.appState.authenticatedEmployee && this.appState.authenticatedEmployee.permissions.indexOf(permission) >= 0
}
private setLoggedInEmployeeFromApi() {
this.api.get<Employee>("/employee/current", true)
.pipe(
take(1),
takeUntil(this.destroy$)
)
.subscribe({
next: employee => this.appState.authenticatedEmployee = employee,
error: err => {
console.error("Could not get the logged in employee from the API: ")
console.error(err)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {ListComponent} from "./pages/list/list.component";
import {AddComponent} from "./pages/add/add.component";
import {EditComponent} from "./pages/edit/edit.component";
import {ExploreComponent} from "./pages/explore/explore.component";
import {MixEditComponent} from "./pages/mix/mix-edit/mix-edit.component";
import {MixAddComponent} from "./pages/mix/mix-add/mix-add.component";
const routes: Routes = [{
path: 'list',
component: ListComponent
}, {
path: 'add',
component: AddComponent
}, {
path: 'edit/:id',
component: EditComponent
}, {
path: 'add/mix/:recipeId',
component: MixAddComponent
}, {
path: 'edit/mix/:recipeId/:id',
component: MixEditComponent
}, {
path: 'explore/:id',
component: ExploreComponent
}, {
path: '',
pathMatch: 'full',
redirectTo: 'list'
}]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ColorsRoutingModule {
}

View File

@ -0,0 +1,33 @@
import {NgModule} from '@angular/core';
import {ColorsRoutingModule} from './colors-routing.module';
import {SharedModule} from "../shared/shared.module";
import {ListComponent} from './pages/list/list.component';
import {AddComponent} from './pages/add/add.component';
import {EditComponent} from './pages/edit/edit.component';
import {MatExpansionModule} from "@angular/material/expansion";
import {FormsModule} from "@angular/forms";
import {ExploreComponent} from './pages/explore/explore.component';
import {RecipeInfoComponent} from './components/recipe-info/recipe-info.component';
import {MixTableComponent} from './components/mix-table/mix-table.component';
import {StepListComponent} from './components/step-list/step-list.component';
import {StepTableComponent} from './components/step-table/step-table.component';
import {MixEditorComponent} from './components/mix-editor/mix-editor.component';
import {UnitSelectorComponent} from './components/unit-selector/unit-selector.component';
import {MixAddComponent} from './pages/mix/mix-add/mix-add.component';
import {MixEditComponent} from './pages/mix/mix-edit/mix-edit.component';
import { ImagesEditorComponent } from './components/images-editor/images-editor.component';
import { MixesCardComponent } from './components/mixes-card/mixes-card.component';
@NgModule({
declarations: [ListComponent, AddComponent, EditComponent, ExploreComponent, RecipeInfoComponent, MixTableComponent, StepListComponent, StepTableComponent, MixEditorComponent, UnitSelectorComponent, MixAddComponent, MixEditComponent, ImagesEditorComponent, MixesCardComponent],
imports: [
ColorsRoutingModule,
SharedModule,
MatExpansionModule,
FormsModule
]
})
export class ColorsModule {
}

View File

@ -0,0 +1,26 @@
<mat-card>
<mat-card-header>
<mat-card-title>Images</mat-card-title>
</mat-card-header>
<mat-card-content [class.no-action]="!editionMode">
<div class="d-flex flex-row justify-content-around flex-wrap">
<div *ngFor="let imageId of (imageIds$ | async)" class="d-flex flex-column align-self-center m-3">
<div class="image-wrapper">
<img src="{{backendUrl}}/recipe/{{recipe.id}}/image/{{imageId}}" width="300px"/>
<div class="d-flex flex-row justify-content-end mt-2" [class.justify-content-between]="editionMode">
<button mat-raised-button color="primary" (click)="openImage(imageId)">Afficher</button>
<button *ngIf="editionMode" mat-raised-button color="warn" (click)="delete(imageId)">Retirer</button>
</div>
</div>
</div>
</div>
</mat-card-content>
<mat-card-actions *ngIf="editionMode">
<cre-file-button
color="accent"
label="Ajouter"
accept="image/jpeg,image/png"
(change)="submit($event)">
</cre-file-button>
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1,9 @@
mat-card
background-color: rgba(255, 255, 255, 0.5)
max-width: 90vw !important
.image-wrapper
padding: 16px
border-radius: 4px
box-shadow: 0 2px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.12)
background-color: white

View File

@ -0,0 +1,57 @@
import {Component, Input} from '@angular/core';
import {Recipe} from "../../../shared/model/recipe.model";
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {ActivatedRoute, Router} from "@angular/router";
import {Observable} from "rxjs";
import {RecipeImageService} from "../../services/recipe-image.service";
import {environment} from "../../../../../environments/environment";
@Component({
selector: 'cre-images-editor',
templateUrl: './images-editor.component.html',
styleUrls: ['./images-editor.component.sass']
})
export class ImagesEditorComponent extends SubscribingComponent {
@Input() recipe: Recipe
@Input() editionMode = false
imageIds$: Observable<number[]>
backendUrl = environment.apiUrl
constructor(
private recipeImageService: RecipeImageService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit() {
super.ngOnInit()
this.loadImagesIds()
}
submit(event) {
const image = event.target.files[0]
this.subscribe(
this.recipeImageService.save(image, this.recipe.id),
{next: () => this.loadImagesIds()}
)
}
openImage(imageId: number) {
window.open(`${environment.apiUrl}/recipe/${this.recipe.id}/image/${imageId}`, '_blank')
}
delete(imageId: number) {
this.subscribe(
this.recipeImageService.delete(imageId, this.recipe.id),
{next: () => this.loadImagesIds()}
)
}
private loadImagesIds() {
this.imageIds$ = this.recipeImageService.getAllIdsForRecipe(this.recipe.id)
}
}

View File

@ -0,0 +1,105 @@
<mat-card *ngIf="recipe && (!editionMode || mix)" class="x-centered y-centered">
<mat-card-header>
<mat-card-title *ngIf="!editionMode">Création d'un mélange pour la recette {{recipe.company.name}}
- {{recipe.name}}</mat-card-title>
<mat-card-title *ngIf="editionMode">Modification du mélange {{mix.mixType.name}} de la
recette {{recipe.company.name}} - {{recipe.name}}</mat-card-title>
</mat-card-header>
<mat-card-content>
<mat-form-field>
<mat-label>Nom</mat-label>
<input matInput type="text" [formControl]="nameControl"/>
<mat-icon svgIcon="form-textbox" matSuffix></mat-icon>
</mat-form-field>
<mat-form-field>
<mat-label>Type de produit</mat-label>
<mat-select [formControl]="materialTypeControl">
<mat-option
*ngFor="let materialType of (materialTypes$ | async)"
[value]="materialType.id">
{{materialType.name}}
</mat-option>
</mat-select>
</mat-form-field>
<div class="mix-materials-wrapper">
<ng-container *ngTemplateOutlet="mixEditor"></ng-container>
</div>
</mat-card-content>
<mat-card-actions>
<button mat-raised-button color="primary" routerLink="/color/edit/{{recipeId}}">Retour</button>
<button *ngIf="editionMode && canDeleteMix" mat-raised-button color="warn" (click)="deleteConfirmBox.show()">Supprimer</button>
<button mat-raised-button color="accent" [disabled]="!form.valid" (click)="submit()">Enregistrer</button>
</mat-card-actions>
</mat-card>
<ng-template #mixEditor>
<table #mixTable mat-table [dataSource]="mixMaterials">
<ng-container matColumnDef="position">
<th mat-header-cell *matHeaderCellDef>Position</th>
<td mat-cell *matCellDef="let mixMaterial; let i = index">
{{i + 1}}
</td>
</ng-container>
<ng-container matColumnDef="material">
<th mat-header-cell *matHeaderCellDef>Produit</th>
<td mat-cell *matCellDef="let mixMaterial">
<mat-form-field *ngIf="materials">
<mat-select [(ngModel)]="mixMaterial.materialId">
<mat-option *ngFor="let material of materials"
[value]="material.id">{{material.name}}</mat-option>
</mat-select>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef>Quantité</th>
<td mat-cell *matCellDef="let mixMaterial">
<mat-form-field>
<input matInput type="number" step="0.001" [(ngModel)]="mixMaterial.quantity"/>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="units">
<th mat-header-cell *matHeaderCellDef>Unités</th>
<td mat-cell *matCellDef="let mixMaterial" class="units-wrapper">
<ng-container *ngIf="materials">
<ng-container *ngIf="materialUsePercentages(mixMaterial)">
<p>%</p>
</ng-container>
<ng-container *ngIf="!materialUsePercentages(mixMaterial)">
<ng-container *ngIf="!hoveredMixMaterial || hoveredMixMaterial != mixMaterial">
<span>{{units}}</span>
</ng-container>
<ng-container *ngIf="hoveredMixMaterial && hoveredMixMaterial == mixMaterial">
<cre-unit-selector [(unit)]="units" [showLabel]="false" [short]="true"></cre-unit-selector>
</ng-container>
</ng-container>
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="buttonRemove">
<th mat-header-cell *matHeaderCellDef>
<button mat-raised-button color="accent" (click)="addRow()">Ajouter</button>
</th>
<td mat-cell *matCellDef="let mixMaterial; let i = index">
<ng-container *ngIf="hoveredMixMaterial && hoveredMixMaterial == mixMaterial">
<button mat-raised-button color="warn" (click)="removeRow(i)">Retirer</button>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let mixMaterial; columns: columns" (mouseover)="hoveredMixMaterial = mixMaterial"></tr>
</table>
</ng-template>
<cre-confirm-box
#deleteConfirmBox
message="Voulez-vous vraiment supprimer le mélange {{mix.mixType.name}} de la recette {{recipe.company.name}} - {{recipe.name}}"
(confirm)="delete()">
</cre-confirm-box>

View File

@ -0,0 +1,3 @@
td.units-wrapper p
width: 3rem
margin-bottom: 0

View File

@ -0,0 +1,143 @@
import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {Mix, MixMaterial, Recipe} from "../../../shared/model/recipe.model";
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {MixService} from "../../services/mix.service";
import {Observable} from "rxjs";
import {RecipeService} from "../../services/recipe.service";
import {Material} from "../../../shared/model/material.model";
import {MaterialService} from "../../../material/service/material.service";
import {MaterialType} from "../../../shared/model/materialtype.model";
import {MaterialTypeService} from "../../../material-type/service/material-type.service";
import {FormBuilder, FormControl, FormGroup, Validators} from "@angular/forms";
import {UNIT_MILLILITER} from "../../../shared/units";
import {MatTable} from "@angular/material/table";
import {ActivatedRoute, Router} from "@angular/router";
import {ConfirmBoxComponent} from "../../../shared/components/confirm-box/confirm-box.component";
import {AccountService} from "../../../accounts/services/account.service";
import {EmployeePermission} from "../../../shared/model/employee";
@Component({
selector: 'cre-mix-editor',
templateUrl: './mix-editor.component.html',
styleUrls: ['./mix-editor.component.sass']
})
export class MixEditorComponent extends SubscribingComponent {
@ViewChild('mixTable') mixTable: MatTable<MixMaterial>
@ViewChild('deleteConfirmBox') deleteConfirmBox: ConfirmBoxComponent
@Input() mixId: number | null
@Input() recipeId: number | null
@Input() materials: Material[]
@Output() save = new EventEmitter<any>();
mix: Mix | null
recipe: Recipe | null
materialTypes$: Observable<MaterialType[]>
form: FormGroup
nameControl: FormControl
materialTypeControl: FormControl
mixMaterials = []
editionMode = false
units = UNIT_MILLILITER
hoveredMixMaterial: MixMaterial | null
columns = ['position', 'material', 'quantity', 'units', 'buttonRemove']
constructor(
private mixService: MixService,
private recipeService: RecipeService,
private materialService: MaterialService,
private materialTypeService: MaterialTypeService,
private accountService: AccountService,
private formBuilder: FormBuilder,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit() {
super.ngOnInit();
this.mixId = this.urlUtils.parseIntUrlParam('id')
if (this.mixId) {
this.editionMode = true
}
this.subscribe(
this.recipeService.getById(this.recipeId),
{
next: r => {
this.recipe = r
if (this.editionMode) {
this.subscribe(
this.mixService.getById(this.mixId),
{
next: m => {
this.mix = m
this.mixMaterials = this.mixService.extractMixMaterials(this.mix)
this.generateForm()
}, error: err => this.handleNotFoundError(err, '/color/list')
}
)
} else {
this.mixMaterials.push({})
this.generateForm()
}
},
error: err => this.handleNotFoundError(err, '/color/list')
}
)
this.materialTypes$ = this.materialTypeService.all
}
addRow() {
this.mixMaterials.push({materialId: null, quantity: null, percents: false})
this.mixTable.renderRows()
}
removeRow(position: number) {
this.mixMaterials.splice(position, 1)
this.mixTable.renderRows()
}
submit() {
this.save.emit({
name: this.nameControl.value,
recipeId: this.recipeId,
materialTypeId: this.materialTypeControl.value,
mixMaterials: this.mixMaterials,
units: this.units
})
}
delete() {
this.subscribeAndNavigate(this.mixService.delete(this.mixId), `/color/edit/${this.recipeId}`)
}
materialUsePercentages(mixMaterial: any) {
if (!mixMaterial.materialId) return null
const material = this.getMaterialFromId(mixMaterial.materialId);
mixMaterial.percents = material && material.materialType.usePercentages
return mixMaterial.percents
}
getMaterialFromId(id: number): Material {
return id ? this.materials.filter(m => m.id === id)[0] : null
}
get canDeleteMix() {
return this.accountService.hasPermission(EmployeePermission.REMOVE_RECIPE)
}
private generateForm() {
this.nameControl = new FormControl(this.mix ? this.mix.mixType.name : null, Validators.required)
this.materialTypeControl = new FormControl(this.mix ? this.mix.mixType.material.materialType.id : null, Validators.required)
this.form = this.formBuilder.group({
name: this.nameControl,
materialType: this.materialTypeControl
})
}
}

View File

@ -0,0 +1,106 @@
<mat-expansion-panel class="table-title mt-5">
<mat-expansion-panel-header>
<mat-panel-title>{{mix.mixType.name}}</mat-panel-title>
</mat-expansion-panel-header>
<div class="mix-actions d-flex flex-row justify-content-between">
<ng-container *ngIf="!editionMode">
<div class="flex-grow-1">
<mat-form-field class="dark">
<mat-label>Casier</mat-label>
<input matInput type="text" [(ngModel)]="mix.location" (change)="changeLocation($event)"/>
</mat-form-field>
</div>
<div>
<button mat-raised-button color="accent" (click)="printingConfirmBox.show()">Imprimer</button>
</div>
<div>
<button mat-raised-button color="accent" (click)="deduct.emit()">Déduire</button>
</div>
</ng-container>
<ng-container *ngIf="editionMode">
<div class="flex-grow-1"></div>
<button mat-raised-button color="accent" routerLink="/color/edit/mix/{{recipe.id}}/{{mix.id}}">Modifier</button>
</ng-container>
</div>
<ng-container [ngTemplateOutlet]="mixTable"></ng-container>
</mat-expansion-panel>
<ng-template #mixTable>
<table mat-table [dataSource]="mix.mixMaterials">
<ng-container matColumnDef="material">
<th mat-header-cell *matHeaderCellDef>Produit</th>
<td mat-cell *matCellDef="let mixMaterial">{{mixMaterial.material.name}}</td>
<td mat-footer-cell *matFooterCellDef></td>
</ng-container>
<ng-container matColumnDef="materialType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let mixMaterial">{{mixMaterial.material.materialType.name}}</td>
<td mat-footer-cell *matFooterCellDef></td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef>Quantité</th>
<td mat-cell *matCellDef="let mixMaterial">
<mat-form-field>
<input
matInput
type="number"
min="0.001"
step="0.001"
[value]="getComputedQuantityRounded(mixMaterial)"
[disabled]="mixMaterial.material.materialType.usePercentages"
(change)="changeQuantity($event, mixMaterial)"/>
</mat-form-field>
</td>
<td mat-footer-cell *matFooterCellDef>
<mat-form-field>
<mat-label>Total</mat-label>
<input
matInput
type="number"
min="0.001"
step="0.001"
[value]="round(getTotalQuantity())"
(change)="changeQuantity($event, null, true)"/>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="quantityStatic">
<th mat-header-cell *matHeaderCellDef>Quantité</th>
<td mat-cell *matCellDef="let mixMaterial">{{getComputedQuantityRounded(mixMaterial)}}</td>
</ng-container>
<ng-container matColumnDef="quantityCalculated">
<th mat-header-cell *matHeaderCellDef>Calcul</th>
<td mat-cell *matCellDef="let mixMaterial; let i = index" [innerHTML]="getCalculatedQuantity(mixMaterial, i)"
class="mix-calculation"></td>
<td mat-footer-cell *matFooterCellDef></td>
</ng-container>
<ng-container matColumnDef="quantityUnits">
<th mat-header-cell *matHeaderCellDef>Unités</th>
<td mat-cell *matCellDef="let mixMaterial">
<ng-container *ngIf="mixMaterial.material.materialType.usePercentages">%</ng-container>
<ng-container *ngIf="!mixMaterial.material.materialType.usePercentages">{{units}}</ng-container>
</td>
<td mat-footer-cell *matFooterCellDef>{{units}}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="mixColumns"></tr>
<tr mat-row *matRowDef="let mixMaterial; columns: mixColumns"
[class.low-quantity]="!editionMode && isInLowQuantity(mixMaterial.id)"></tr>
<ng-container *ngIf="!editionMode">
<tr mat-footer-row *matFooterRowDef="mixColumns"></tr>
</ng-container>
</table>
</ng-template>
<cre-confirm-box
#printingConfirmBox
message="Voulez-vous vraiment imprimer ce mélange?"
(confirm)="print()">
</cre-confirm-box>

View File

@ -0,0 +1,22 @@
@import '../../../../../custom-theme'
mat-expansion-panel
width: 40rem
margin: 2rem 0
.mix-actions
background-color: $color-primary
padding: 0 1rem
div:last-child
margin-left: 1rem
.low-quantity
background-color: #ffb3b3
::ng-deep span.mix-calculated-quantity
&:first-child
color: green
&:last-child
color: dimgrey

View File

@ -0,0 +1,160 @@
import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {Mix, MixMaterial, Recipe} from "../../../shared/model/recipe.model";
import {Subject} from "rxjs";
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {convertMixMaterialQuantity, UNIT_MILLILITER} from "../../../shared/units";
import {ActivatedRoute, Router} from "@angular/router";
import {PtouchPrinter} from "../../ptouchPrint"
import {ConfirmBoxComponent} from "../../../shared/components/confirm-box/confirm-box.component";
@Component({
selector: 'cre-mix-table',
templateUrl: './mix-table.component.html',
styleUrls: ['./mix-table.component.sass']
})
export class MixTableComponent extends SubscribingComponent {
private readonly COLUMNS = ['material', 'materialType', 'quantity', 'quantityCalculated', 'quantityUnits']
private readonly COLUMNS_STATIC = ['material', 'materialType', 'quantityStatic', 'quantityUnits']
@ViewChild('printingConfirmBox') printingConfirmBox: ConfirmBoxComponent
@Input() mix: Mix
@Input() recipe: Recipe
@Input() units$: Subject<string>
@Input() deductErrorBody
@Input() editionMode: boolean
@Input() printingError = 2
@Output() locationChange = new EventEmitter<{ id: number, location: string }>()
@Output() quantityChange = new EventEmitter<{ id: number, materialId: number, quantity: number }>()
@Output() deduct = new EventEmitter<void>()
@Output() printingErrorChange = new EventEmitter<number>()
mixColumns = this.COLUMNS
units = UNIT_MILLILITER
computedQuantities: { id: number, percents: boolean, quantity: number }[] = []
// BPac printer
printer: PtouchPrinter | null
constructor(
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit() {
super.ngOnInit();
if (this.editionMode) {
this.mixColumns = this.COLUMNS_STATIC
}
this.mix.mixMaterials.forEach(m => this.computedQuantities.push({
id: m.id,
percents: m.material.materialType.usePercentages,
quantity: m.quantity
}))
this.subscribe(
this.units$,
{
next: u => this.convertQuantities(u)
}
)
}
changeLocation(event: any) {
this.locationChange.emit({id: this.mix.id, location: event.target.value})
}
changeQuantity(event: any, mixMaterial: MixMaterial, isTotal = false) {
const newQuantity = parseInt(event.target.value)
let ratio = 1
if (!isTotal) {
const originalQuantity = this.getComputedQuantity(mixMaterial.id)
ratio = newQuantity / originalQuantity.quantity
} else {
ratio = newQuantity / this.getTotalQuantity()
}
this.computedQuantities.forEach((q, i) => {
if (!q.percents) {
q.quantity *= ratio
}
this.emitQuantityChangeEvent(i)
})
}
getComputedQuantityRounded(mixMaterial: MixMaterial): number {
return this.round(this.getComputedQuantity(mixMaterial.id).quantity)
}
getTotalQuantity(index: number = -1): number {
if (index === -1) index = this.computedQuantities.length - 1
let totalQuantity = 0
for (let i = 0; i <= index; i++) {
totalQuantity += this.calculateQuantity(i)
}
return totalQuantity
}
getCalculatedQuantity(mixMaterial: MixMaterial, index: number): string {
const totalQuantity = this.round(this.getTotalQuantity(index))
const addedQuantity = this.round(this.calculateQuantity(index))
return `<span class="mix-calculated-quantity">+${addedQuantity}</span> <span class="mix-calculated-quantity">(${totalQuantity})</span>`
}
isInLowQuantity(materialId: number): boolean {
return this.deductErrorBody[this.mix.id] && this.deductErrorBody[this.mix.id].indexOf(materialId) >= 0
}
round(quantity: number): number {
return Math.round(quantity * 1000) / 1000
}
async print() {
const base = this.mix.mixMaterials
.map(ma => ma.material)
.filter(m => m.materialType.name === 'Base')[0]
if (!base) {
this.printingErrorChange.emit(98)
return
}
this.printer = new PtouchPrinter({
template: "Couleur",
lines: [
{name: "color", value: this.recipe.name},
{name: "banner", value: this.recipe.company.name},
{name: "base", value: base.name},
{name: "description", value: this.recipe.description}
]
})
const errorCode = await this.printer.print()
this.printingErrorChange.emit(errorCode)
}
private emitQuantityChangeEvent(index: number) {
this.quantityChange.emit({
id: this.mix.id,
materialId: this.computedQuantities[index].id,
quantity: this.calculateQuantity(index)
})
}
private convertQuantities(newUnit: string) {
this.computedQuantities.forEach(q => q.quantity = convertMixMaterialQuantity(q, this.units, newUnit))
this.units = newUnit
}
private getComputedQuantity(id: number): any {
return this.computedQuantities.filter(q => q.id == id)[0]
}
private calculateQuantity(index: number): number {
const computedQuantity = this.computedQuantities[index]
if (!computedQuantity.percents) {
return computedQuantity.quantity
}
return this.computedQuantities[0].quantity * (computedQuantity.quantity / 100)
}
}

View File

@ -0,0 +1,29 @@
<mat-card>
<mat-card-header>
<mat-card-title>Mélanges</mat-card-title>
</mat-card-header>
<mat-card-content [class.no-action]="!editionMode">
<ng-container *ngFor="let mix of recipe.mixes; let i = index">
<cre-mix-table
[class.no-top-margin]="i == 0"
[recipe]="recipe"
[mix]="mix"
[units$]="units$"
[deductErrorBody]="deductErrorBody"
[editionMode]="editionMode"
(quantityChange)="quantityChange.emit($event)"
(locationChange)="locationChange.emit($event)"
(deduct)="deduct.emit(mix.id)"
[(printingError)]="printingError">>
</cre-mix-table>
</ng-container>
</mat-card-content>
<mat-card-actions *ngIf="editionMode">
<button
mat-raised-button
color="accent"
routerLink="/color/add/mix/{{recipe.id}}">
Ajouter
</button>
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1,3 @@
mat-card
background-color: rgba(255, 255, 255, 0.5)
min-width: 20rem

View File

@ -0,0 +1,21 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {Recipe} from "../../../shared/model/recipe.model";
import {Subject} from "rxjs";
@Component({
selector: 'cre-mixes-card',
templateUrl: './mixes-card.component.html',
styleUrls: ['./mixes-card.component.sass']
})
export class MixesCardComponent {
@Input() recipe: Recipe
@Input() units$: Subject<string>
@Input() deductErrorBody: any
@Input() printingError = 2
@Input() editionMode = false
@Output() locationChange = new EventEmitter<{ id: number, location: string }>()
@Output() quantityChange = new EventEmitter<{ id: number, materialId: number, quantity: number }>()
@Output() deduct = new EventEmitter<number>()
@Output() printingErrorChange = new EventEmitter<number>()
}

View File

@ -0,0 +1,42 @@
<div class="recipe-info-wrapper d-flex flex-column">
<div class="d-flex flex-column">
<h3>{{recipe.company.name}} - {{recipe.name}}</h3>
</div>
<div class="d-flex flex-row">
<div class="d-flex flex-column">
<p>Échantillon #{{recipe.sample}}</p>
<p *ngIf="recipe.approbationDate">Approuvée le {{recipe.approbationDate}}</p>
<div *ngIf="!recipe.approbationDate" class="recipe-not-approved-wrapper d-flex flex-row">
<p>Non approuvée</p>
<mat-icon svgIcon="alert" class="color-warning"></mat-icon>
</div>
<p>{{recipe.remark}}</p>
</div>
<div class="recipe-description">
<p>{{recipe.description}}</p>
</div>
<div class="flex-grow-1"></div>
<mat-icon
*ngIf="hasModifications"
class="color-warning has-modification-icon mr-4"
svgIcon="pencil"
title="Les modifications apportées n'ont pas été enregistrées"
[inline]="true">
</mat-icon>
<mat-icon
*ngIf="!isBPacExtensionInstalled"
color="warn"
svgIcon="printer-alert"
title="L'extension b-Pac n'est pas installée"
[inline]="true">
</mat-icon>
<mat-icon
*ngIf="isBPacExtensionInstalled"
color="accent"
svgIcon="printer"
[inline]="true">
</mat-icon>
</div>
</div>

View File

@ -0,0 +1,46 @@
.recipe-info-wrapper
background-color: black
color: white
padding: 1rem
div
margin-right: 3rem
p
margin-bottom: 0
h3
font-weight: bold
text-decoration: underline
text-transform: uppercase
&.recipe-not-approved-wrapper
p
margin-top: 1px
margin-right: .4em
&.recipe-description
max-width: 30rem
&.recipe-note
margin-right: 0
mat-form-field
width: 100%
::ng-deep .mat-form-field-wrapper
padding-bottom: 0
::ng-deep .mat-form-field-underline, ::ng-deep .mat-form-field-ripple
opacity: 0
mat-label
color: white
textarea:focus
background-color: white
color: black
mat-icon
width: 2em !important

View File

@ -0,0 +1,18 @@
import {AfterViewInit, Component, Input} from '@angular/core';
import {Recipe} from "../../../shared/model/recipe.model";
@Component({
selector: 'cre-recipe-info',
templateUrl: './recipe-info.component.html',
styleUrls: ['./recipe-info.component.sass']
})
export class RecipeInfoComponent implements AfterViewInit {
@Input() recipe: Recipe
@Input() hasModifications: boolean
isBPacExtensionInstalled = false
ngAfterViewInit(): void {
this.isBPacExtensionInstalled = document.querySelectorAll(".bpac-extension-installed").length > 0
}
}

View File

@ -0,0 +1,12 @@
<mat-card>
<mat-card-header>
<mat-card-title>Étapes</mat-card-title>
</mat-card-header>
<mat-card-content class="no-action">
<mat-list>
<mat-list-item *ngFor="let step of steps;let i = index">
{{i + 1}}.<span class="space"></span>{{step.message}}
</mat-list-item>
</mat-list>
</mat-card-content>
</mat-card>

View File

@ -0,0 +1,2 @@
.space
width: 1em

View File

@ -0,0 +1,11 @@
import {Component, Input} from '@angular/core';
import {RecipeStep} from "../../../shared/model/recipe.model";
@Component({
selector: 'cre-step-list',
templateUrl: './step-list.component.html',
styleUrls: ['./step-list.component.sass']
})
export class StepListComponent {
@Input() steps: RecipeStep[]
}

View File

@ -0,0 +1,33 @@
<mat-expansion-panel class="table-title" [expanded]="true" [disabled]="true">
<mat-expansion-panel-header>
<mat-panel-title>Étapes</mat-panel-title>
</mat-expansion-panel-header>
<table #stepTable mat-table [dataSource]="steps">
<ng-container matColumnDef="position">
<th mat-header-cell *matHeaderCellDef>Position</th>
<td mat-cell *matCellDef="let step; let i = index">{{i + 1}}</td>
</ng-container>
<ng-container matColumnDef="message">
<th mat-header-cell *matHeaderCellDef>Message</th>
<td mat-cell *matCellDef="let step">
<mat-form-field>
<input matInput type="text" [(ngModel)]="step.message"/>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="buttonRemove">
<th mat-header-cell *matHeaderCellDef>
<button mat-raised-button color="accent" (click)="addStep()">Ajouter</button>
</th>
<td mat-cell *matCellDef="let step; let i = index">
<button mat-raised-button color="warn" (click)="removeStep(i)">Retirer</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let step; columns: columns"></tr>
</table>
</mat-expansion-panel>

View File

@ -0,0 +1,5 @@
mat-expansion-panel
min-width: 560px
mat-form-field
width: 20rem

View File

@ -0,0 +1,25 @@
import {Component, Input, ViewChild} from '@angular/core';
import {RecipeStep} from "../../../shared/model/recipe.model";
import {MatTable} from "@angular/material/table";
@Component({
selector: 'cre-step-table',
templateUrl: './step-table.component.html',
styleUrls: ['./step-table.component.sass']
})
export class StepTableComponent {
@ViewChild('stepTable', {static: true}) stepTable: MatTable<RecipeStep>
readonly columns = ['position', 'message', 'buttonRemove']
@Input() steps: RecipeStep[]
addStep() {
this.steps.push({id: null, message: ""})
this.stepTable.renderRows()
}
removeStep(position: number) {
this.steps.splice(position, 1)
this.stepTable.renderRows()
}
}

View File

@ -0,0 +1,15 @@
<mat-form-field [class.short]="short">
<mat-label *ngIf="showLabel">Unités</mat-label>
<mat-select [value]="unit" (selectionChange)="unitChange.emit($event.value)">
<ng-container *ngIf="!short">
<mat-option [value]="unitConstants.UNIT_MILLILITER">Millilitres</mat-option>
<mat-option [value]="unitConstants.UNIT_LITER">Litres</mat-option>
<mat-option [value]="unitConstants.UNIT_GALLON">Gallons</mat-option>
</ng-container>
<ng-container *ngIf="short">
<mat-option [value]="unitConstants.UNIT_MILLILITER">mL</mat-option>
<mat-option [value]="unitConstants.UNIT_LITER">L</mat-option>
<mat-option [value]="unitConstants.UNIT_GALLON">gal</mat-option>
</ng-container>
</mat-select>
</mat-form-field>

View File

@ -0,0 +1,2 @@
mat-form-field.short
width: 3rem

View File

@ -0,0 +1,17 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {UNIT_GALLON, UNIT_LITER, UNIT_MILLILITER} from "../../../shared/units";
@Component({
selector: 'cre-unit-selector',
templateUrl: './unit-selector.component.html',
styleUrls: ['./unit-selector.component.sass']
})
export class UnitSelectorComponent {
readonly unitConstants = {UNIT_MILLILITER, UNIT_LITER, UNIT_GALLON}
@Input() unit = UNIT_MILLILITER
@Input() showLabel = true
@Input() short = false
@Output() unitChange = new EventEmitter<string>()
}

View File

@ -0,0 +1,8 @@
<cre-entity-add
title="Création d'une recette"
backButtonLink="/color/list"
[unknownError]="unknownError"
[customError]="errorMessage"
[formFields]="formFields"
(submit)="submit($event)">
</cre-entity-add>

View File

@ -0,0 +1,98 @@
import {Component} from '@angular/core';
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {RecipeService} from "../../services/recipe.service";
import {FormField} from "../../../shared/components/entity-add/entity-add.component";
import {FormBuilder, Validators} from "@angular/forms";
import {CompanyService} from "../../../company/service/company.service";
import {map} from "rxjs/operators";
import {ActivatedRoute, Router} from "@angular/router";
@Component({
selector: 'cre-add',
templateUrl: './add.component.html',
styleUrls: ['./add.component.sass']
})
export class AddComponent extends SubscribingComponent {
formFields: FormField[] = [
{
name: 'name',
label: 'Nom',
icon: 'form-textbox',
type: 'text',
validator: Validators.required,
errorMessages: [
{conditionFn: errors => errors.required, message: 'Un nom est requis'}
]
},
{
name: 'description',
label: 'Description',
icon: 'text',
type: 'text',
validator: Validators.required,
errorMessages: [
{conditionFn: errors => errors.required, message: 'Une description est requise'}
]
},
{
name: 'sample',
label: 'Échantillon',
icon: 'pound',
type: 'number',
validator: Validators.compose([Validators.required, Validators.min(0)]),
errorMessages: [
{conditionFn: errors => errors.required, message: 'Un numéro d\'échantillon est requis'},
{conditionFn: errors => errors.min, message: 'Le numéro d\'échantillon doit être supérieur ou égal à 0'}
]
},
{
name: 'approbationDate',
label: 'Date d\'approbation',
icon: 'calendar',
type: 'date'
},
{
name: 'remark',
label: 'Remarque',
icon: 'text',
type: 'text'
},
{
name: 'company',
label: 'Bannière',
icon: 'domain',
type: 'select',
validator: Validators.required,
errorMessages: [
{conditionFn: errors => errors.required, message: 'Une bannière est requise'}
],
options$: this.companyService.all.pipe(map(companies => companies.map(c => {
return {value: c.id, label: c.name}
})))
}
]
unknownError = false
errorMessage: string | null
constructor(
private recipeService: RecipeService,
private companyService: CompanyService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
submit(values) {
this.subscribe(
this.recipeService.save(values.name, values.description, values.sample, values.approbationDate, values.remark, values.company),
{
next: recipe => this.urlUtils.navigateTo(`/color/edit/${recipe.id}`),
error: err => {
this.unknownError = true
console.error(err)
}
}
)
}
}

View File

@ -0,0 +1,55 @@
<div *ngIf="recipe">
<div *ngIf="!recipe.mixes" class="alert alert-warning m-3">
<p>Il n'y a aucun mélange dans cette recette</p>
</div>
<div *ngIf="recipe.steps.length <= 0" class="alert alert-warning m-3">
<p>Il n'y a aucune étape dans cette recette</p>
</div>
<div class="action-bar backward">
<div class="d-flex flex-column">
<div class="mt-1 pb-2">
<button mat-raised-button color="primary" routerLink="/color/list">Retour</button>
<button mat-raised-button color="accent" (click)="submit(editComponent)">Enregistrer</button>
<button mat-raised-button color="warn" *ngIf="hasDeletePermission" (click)="delete()">Supprimer</button>
</div>
<mat-form-field>
<mat-label>Unités</mat-label>
<mat-select [value]="unitConstants.UNIT_MILLILITER" (selectionChange)="changeUnits($event.value)">
<mat-option [value]="unitConstants.UNIT_MILLILITER">Millilitres</mat-option>
<mat-option [value]="unitConstants.UNIT_LITER">Litres</mat-option>
<mat-option [value]="unitConstants.UNIT_GALLON">Gallons</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="flex-grow-1"></div>
</div>
<div class="recipe-wrapper d-flex flex-row justify-content-around align-items-start flex-wrap">
<div>
<cre-entity-edit
#editComponent
title="Modifier la couleur {{recipe.name}}"
deleteConfirmMessage="Voulez-vous vraiment supprimer la couleur {{recipe.name}}?"
[entity]="recipe"
[formFields]="formFields"
[unknownError]="unknownError"
[customError]="errorMessage"
[disableButtons]="true"
[noTopMargin]="true">
</cre-entity-edit>
</div>
<div class="recipe-mixes-wrapper">
<cre-mixes-card [recipe]="recipe" [units$]="units$" [editionMode]="true"></cre-mixes-card>
</div>
<div>
<cre-step-table [steps]="recipe.steps"></cre-step-table>
</div>
<div>
<cre-images-editor #imagesEditor [recipe]="recipe" [editionMode]="true"></cre-images-editor>
</div>
</div>
</div>

View File

@ -0,0 +1,2 @@
.recipe-wrapper > div
margin: 0 3rem 3rem

View File

@ -0,0 +1,145 @@
import {Component, ViewChild} from '@angular/core';
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {Recipe} from "../../../shared/model/recipe.model";
import {RecipeService} from "../../services/recipe.service";
import {ActivatedRoute, Router} from "@angular/router";
import {Validators} from "@angular/forms";
import {Subject} from "rxjs";
import {UNIT_GALLON, UNIT_LITER, UNIT_MILLILITER} from "../../../shared/units";
import {AccountService} from "../../../accounts/services/account.service";
import {EmployeePermission} from "../../../shared/model/employee";
import {EntityEditComponent} from "../../../shared/components/entity-edit/entity-edit.component";
import {ImagesEditorComponent} from "../../components/images-editor/images-editor.component";
@Component({
selector: 'cre-edit',
templateUrl: './edit.component.html',
styleUrls: ['./edit.component.sass']
})
export class EditComponent extends SubscribingComponent {
readonly unitConstants = {UNIT_MILLILITER, UNIT_LITER, UNIT_GALLON}
@ViewChild('imagesEditor') imagesEditor: ImagesEditorComponent
recipe: Recipe | null
formFields = [
{
name: 'name',
label: 'Nom',
icon: 'form-textbox',
type: 'text',
validator: Validators.required,
errorMessages: [
{conditionFn: errors => errors.required, message: 'Un nom est requis'}
]
},
{
name: 'description',
label: 'Description',
icon: 'text',
type: 'text',
validator: Validators.required,
errorMessages: [
{conditionFn: errors => errors.required, message: 'Une description est requise'}
]
},
{
name: 'sample',
label: 'Échantillon',
icon: 'pound',
type: 'number',
validator: Validators.compose([Validators.required, Validators.min(0)]),
errorMessages: [
{conditionFn: errors => errors.required, message: 'Un numéro d\'échantillon est requis'},
{conditionFn: errors => errors.min, message: 'Le numéro d\'échantillon doit être supérieur ou égal à 0'}
]
},
{
name: 'approbationDate',
label: 'Date d\'approbation',
icon: 'calendar',
type: 'date'
},
{
name: 'remark',
label: 'Remarque',
icon: 'text',
type: 'text'
},
{
name: 'company',
label: 'Bannière',
icon: 'domain',
type: 'text',
readonly: true,
valueFn: recipe => recipe.company.name,
}
]
unknownError = false
errorMessage: string | null
units$ = new Subject<string>()
constructor(
private recipeService: RecipeService,
private accountService: AccountService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit() {
super.ngOnInit();
const id = parseInt(this.activatedRoute.snapshot.paramMap.get('id'))
this.subscribe(
this.recipeService.getById(id),
{
next: recipe => this.recipe = recipe,
error: err => {
if (err.status === 404) {
this.router.navigate(['/color/list'])
} else {
this.unknownError = true
}
}
},
1
)
}
changeUnits(unit: string) {
this.units$.next(unit)
}
submit(editComponent: EntityEditComponent) {
const values = editComponent.values
this.subscribe(
this.recipeService.update(this.recipe.id, values.name, values.description, values.sample, values.approbationDate, values.remark, this.recipe.steps),
{
next: () => this.router.navigate(['/color/list']),
error: err => {
if (err.status === 409) {
this.errorMessage = `Une couleur avec le nom '${values.name}' et la bannière '${this.recipe.company.name}' existe déjà`
} else {
this.unknownError = true
console.error(err)
}
}
}
)
}
delete() {
this.subscribe(
this.recipeService.delete(this.recipe.id),
{
next: () => this.router.navigate(['/color/list'])
}
)
}
get hasDeletePermission(): boolean {
return this.accountService.hasPermission(EmployeePermission.REMOVE_RECIPE)
}
}

View File

@ -0,0 +1,69 @@
<div *ngIf="recipe">
<cre-recipe-info [recipe]="recipe" [hasModifications]="hasModifications"></cre-recipe-info>
<div *ngIf="error" class="alert alert-danger m-3">
<p *ngIf="error === ERROR_UNKNOWN">Une erreur est survenue</p>
<p *ngIf="error === ERROR_DEDUCT">Certains produit ne sont pas en quantité suffisante dans l'inventaire</p>
</div>
<div *ngIf="printingError != 2 && printingError != 1" class="alert alert-danger m-3">
<p *ngIf="printingError === -1">L'extension b-Pac n'est pas installée</p>
<p *ngIf="printingError === 98">Il n'y a pas de base dans ce mélange</p>
<p *ngIf="printingError === 99">Une erreur est survenue pendant l'impression</p>
</div>
<div *ngIf="success" class="alert alert-success m-3">
<p *ngIf="success === SUCCESS_SAVE">Les modifications ont été enregistrées</p>
<p *ngIf="success === SUCCESS_DEDUCT">Les quantités des produits utilisés ont été déduites de l'inventaire</p>
</div>
<div *ngIf="printingError === 1" class="alert alert-success m-3">
<p>Impression en cours. Cette opération peut prendre quelques secondes.</p>
</div>
<div class="action-bar backward d-flex flex-row">
<div class="d-flex flex-column">
<div class="mt-1 pb-2">
<button mat-raised-button color="primary" routerLink="/color/list">Retour</button>
<button mat-raised-button color="primary">Version Excel</button>
<button mat-raised-button color="accent" (click)="saveModifications()" [disabled]="!hasModifications">
Enregistrer
</button>
<button mat-raised-button color="accent" (click)="deductQuantities()">Déduire</button>
</div>
<cre-unit-selector (unitChange)="changeUnits($event)"></cre-unit-selector>
</div>
<div class="flex-grow-1"></div>
<mat-form-field class="w-auto">
<mat-label>Note</mat-label>
<textarea matInput cols="40" rows="3" (change)="changeNote($event)">{{note}}</textarea>
</mat-form-field>
</div>
<div class="recipe-content d-flex flex-row justify-content-around align-items-start flex-wrap mt-5">
<!-- Mixes -->
<div>
<cre-mixes-card
[recipe]="recipe"
[deductErrorBody]="deductErrorBody"
[units$]="units$"
(quantityChange)="changeQuantity($event)"
(locationChange)="changeMixLocation($event)"
(deduct)="deductMixQuantities($event)"
[(printingError)]="printingError">
</cre-mixes-card>
</div>
<!-- Steps -->
<div>
<cre-step-list [steps]="recipe.steps"></cre-step-list>
</div>
<!-- Images -->
<div>
<cre-images-editor [recipe]="recipe" [editionMode]="false"></cre-images-editor>
</div>
</div>
</div>

View File

@ -0,0 +1,2 @@
.recipe-content > div
margin: 0 3rem 3rem

View File

@ -0,0 +1,129 @@
import {Component} from '@angular/core';
import {RecipeService} from "../../services/recipe.service";
import {ActivatedRoute, Router} from "@angular/router";
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {Recipe} from "../../../shared/model/recipe.model";
import {Observable, Subject} from "rxjs";
import {UNIT_GALLON, UNIT_LITER, UNIT_MILLILITER} from "../../../shared/units";
@Component({
selector: 'cre-explore',
templateUrl: './explore.component.html',
styleUrls: ['./explore.component.sass']
})
export class ExploreComponent extends SubscribingComponent {
readonly unitConstants = {UNIT_MILLILITER, UNIT_LITER, UNIT_GALLON}
recipe: Recipe | null
error: string
deductErrorBody = {}
success: string
units$ = new Subject<string>()
hasModifications = false
note: string | null
quantitiesChanges = new Map<number, Map<number, number>>()
mixesLocationChanges = new Map<number, string>()
printingError = 2
// Errors
readonly ERROR_UNKNOWN = 'unknown'
readonly ERROR_DEDUCT = 'deduct'
// Success
readonly SUCCESS_SAVE = 'save'
readonly SUCCESS_DEDUCT = 'deduct'
constructor(
private recipeService: RecipeService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit(): void {
super.ngOnInit()
const id = parseInt(this.activatedRoute.snapshot.paramMap.get('id'))
this.subscribe(
this.recipeService.getById(id),
{
next: r => {
this.recipe = r
this.note = r.note
},
error: err => this.handleNotFoundError(err, '/colors/list')
},
1
)
}
changeUnits(unit: string) {
this.units$.next(unit)
}
changeNote(event: any) {
this.hasModifications = true
this.note = event.target.value
}
changeQuantity(event: { id: number, materialId: number, quantity: number }) {
if (!this.quantitiesChanges.has(event.id)) this.quantitiesChanges.set(event.id, new Map<number, number>())
this.quantitiesChanges.get(event.id).set(event.materialId, event.quantity)
}
changeMixLocation(event: { id: number, location: string }) {
this.hasModifications = true
this.mixesLocationChanges.set(event.id, event.location)
}
saveModifications() {
this.subscribe(
this.recipeService.saveExplorerModifications(this.recipe.id, this.note, this.mixesLocationChanges),
{
next: () => {
this.hasModifications = false
this.error = null
this.success = this.SUCCESS_SAVE
},
error: err => {
this.success = null
this.error = this.ERROR_UNKNOWN
console.error(err)
}
},
1
)
}
deductQuantities() {
this.performDeductQuantities(this.recipeService.deductQuantities(this.recipe, this.quantitiesChanges))
}
deductMixQuantities(mixId: number) {
this.performDeductQuantities(this.recipeService.deductMixQuantities(this.recipe, mixId, this.quantitiesChanges.get(mixId)))
}
performDeductQuantities(observable: Observable<void>) {
this.subscribe(
observable,
{
next: () => {
this.error = null
this.success = this.SUCCESS_DEDUCT
},
error: err => {
this.success = null
if (err.status === 409) { // There is not enough of one or more materials in the inventory
this.error = this.ERROR_DEDUCT
this.deductErrorBody = err.error
} else {
this.error = this.ERROR_UNKNOWN
console.error(err)
}
}
},
1
)
}
}

View File

@ -0,0 +1,71 @@
<div class="action-bar">
<mat-form-field>
<mat-label>Recherche</mat-label>
<input matInput type="text" [(ngModel)]="searchQuery"/>
<button mat-button *ngIf="searchQuery" matSuffix mat-icon-button (click)="searchQuery=''">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<div class="button-add">
<button *ngIf="hasEditPermission" mat-raised-button color="accent" routerLink="/color/add">Ajouter</button>
</div>
</div>
<mat-expansion-panel class="table-title" *ngFor="let companyRecipes of (recipes$ | async)"
[hidden]="isCompanyHidden(companyRecipes.recipes)" [expanded]="panelForcedExpanded">
<mat-expansion-panel-header>
<mat-panel-title>
{{companyRecipes.company}}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container *ngTemplateOutlet="recipeTableTemplate; context: {recipes: companyRecipes.recipes}"></ng-container>
</mat-expansion-panel>
<ng-template
#recipeTableTemplate
let-recipes="recipes">
<table class="mx-auto" mat-table [dataSource]="recipes">
<!-- Recipe's info -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Nom</th>
<td mat-cell *matCellDef="let recipe">{{recipe.name}}</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>Description</th>
<td mat-cell *matCellDef="let recipe">{{recipe.description}}</td>
</ng-container>
<ng-container matColumnDef="sample">
<th mat-header-cell *matHeaderCellDef>Échantillon</th>
<td mat-cell *matCellDef="let recipe">#{{recipe.sample}}</td>
</ng-container>
<!-- Icons -->
<ng-container matColumnDef="iconNotApproved">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let recipe" [class.disabled]="recipe.approbationDate">
<mat-icon svgIcon="alert" class="color-warning" title="Cette recette n'est pas approuvée"></mat-icon>
</td>
</ng-container>
<!-- Buttons -->
<ng-container matColumnDef="buttonView">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let recipe">
<button mat-flat-button color="accent" routerLink="/color/explore/{{recipe.id}}">Voir</button>
</td>
</ng-container>
<ng-container matColumnDef="buttonEdit">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let recipe" [class.disabled]="!hasEditPermission">
<button mat-flat-button color="accent" routerLink="/color/edit/{{recipe.id}}">Modifier</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="tableCols"></tr>
<tr mat-row *matRowDef="let row; columns: tableCols" [hidden]="!searchRecipe(row)"></tr>
</table>
</ng-template>

View File

@ -0,0 +1,6 @@
mat-expansion-panel
width: 60rem
margin: 20px auto
.button-add
margin-top: .8rem

View File

@ -0,0 +1,56 @@
import {Component} from '@angular/core';
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {RecipeService} from "../../services/recipe.service";
import {EmployeePermission} from "../../../shared/model/employee";
import {AccountService} from "../../../accounts/services/account.service";
import {Recipe} from "../../../shared/model/recipe.model";
import {ActivatedRoute, Router} from "@angular/router";
@Component({
selector: 'cre-list',
templateUrl: './list.component.html',
styleUrls: ['./list.component.sass']
})
export class ListComponent extends SubscribingComponent {
recipes$ = this.recipeService.allSortedByCompany
tableCols = ['name', 'description', 'sample', 'iconNotApproved', 'buttonView', 'buttonEdit']
searchQuery = ""
panelForcedExpanded = false
recipesHidden = []
constructor(
private recipeService: RecipeService,
private accountService: AccountService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit() {
super.ngOnInit();
}
searchRecipe(recipe: Recipe) {
if (this.searchQuery.length > 0) {
this.panelForcedExpanded = true
}
const positive = this.searchString(recipe.name) ||
this.searchString(recipe.description) ||
this.searchString(recipe.sample.toString())
this.recipesHidden[recipe.id] = !positive
return positive
}
isCompanyHidden(companyRecipes: Recipe[]): boolean {
return (this.searchQuery && this.searchQuery.length > 0) && companyRecipes.map(r => this.recipesHidden[r.id]).filter(r => !r).length <= 0
}
get hasEditPermission(): boolean {
return this.accountService.hasPermission(EmployeePermission.EDIT_RECIPE)
}
private searchString(value: string): boolean {
return value.toLowerCase().indexOf(this.searchQuery.toLowerCase()) >= 0
}
}

View File

@ -0,0 +1,5 @@
<cre-mix-editor
[recipeId]="recipeId"
[materials]="materials"
(save)="submit($event)">
</cre-mix-editor>

View File

@ -0,0 +1,43 @@
import {Component} from '@angular/core';
import {Material} from "../../../../shared/model/material.model";
import {MaterialService} from "../../../../material/service/material.service";
import {ActivatedRoute, Router} from "@angular/router";
import {SubscribingComponent} from "../../../../shared/components/subscribing.component";
import {MixService} from "../../../services/mix.service";
@Component({
selector: 'cre-mix-add',
templateUrl: './mix-add.component.html',
styleUrls: ['./mix-add.component.sass']
})
export class MixAddComponent extends SubscribingComponent {
recipeId: number | null
materials: Material[] | null
constructor(
private materialService: MaterialService,
private mixService: MixService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit(): void {
super.ngOnInit()
this.recipeId = this.urlUtils.parseIntUrlParam('recipeId')
this.subscribe(
this.materialService.getAllForMixCreation(this.recipeId),
{next: m => this.materials = m}
)
}
submit(values) {
this.subscribe(
this.mixService.saveWithUnits(values.name, values.recipeId, values.materialTypeId, values.mixMaterials, values.units),
{next: () => this.urlUtils.navigateTo(`/color/edit/${this.recipeId}`)}
)
}
}

View File

@ -0,0 +1,6 @@
<cre-mix-editor
[mixId]="mixId"
[recipeId]="recipeId"
[materials]="materials"
(save)="submit($event)">
</cre-mix-editor>

View File

@ -0,0 +1,45 @@
import {Component} from '@angular/core';
import {ActivatedRoute, Router} from "@angular/router";
import {SubscribingComponent} from "../../../../shared/components/subscribing.component";
import {Material} from "../../../../shared/model/material.model";
import {MaterialService} from "../../../../material/service/material.service";
import {MixService} from "../../../services/mix.service";
@Component({
selector: 'cre-mix-edit',
templateUrl: './mix-edit.component.html',
styleUrls: ['./mix-edit.component.sass']
})
export class MixEditComponent extends SubscribingComponent {
mixId: number | null
recipeId: number | null
materials: Material[] | null
constructor(
private materialService: MaterialService,
private mixService: MixService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit(): void {
super.ngOnInit()
this.mixId = this.urlUtils.parseIntUrlParam('id')
this.recipeId = this.urlUtils.parseIntUrlParam('recipeId')
this.subscribe(
this.materialService.getAllForMixUpdate(this.mixId),
{next: m => this.materials = m}
)
}
submit(values) {
this.subscribe(
this.mixService.updateWithUnits(this.mixId, values.name, values.materialTypeId, values.mixMaterials, values.units),
{next: () => this.urlUtils.navigateTo(`/color/edit/${this.recipeId}`)}
)
}
}

View File

@ -0,0 +1,51 @@
import * as bpac from "./bpac.js";
export class PtouchPrinter {
constructor(object) {
this.object = object;
this.pdocument = bpac.IDocument;
}
async print() {
if (!this.isBPacExtensionInstalled()) {
console.error("L'extension b-Pac n'est pas installée");
return -1;
}
try {
await this.openDoc();
await this.fillDoc();
this.printDoc();
this.pdocument.Close();
return 1;
} catch (e) {
console.log(e);
return 99;
}
};
async openDoc() {
const docUrl = `${baseUrl}/lbx/${this.object.template}.lbx`;
console.log("Ouverture du modèle: " + docUrl);
await this.pdocument.Open(docUrl);
}
async fillDoc() {
for (let i = 0; i < this.object.lines.length; i++) {
const line = this.object.lines[i];
const label = await this.pdocument.GetObject(line.name);
label.Text = line.value;
}
}
printDoc() {
this.pdocument.StartPrint("", 0);
this.pdocument.PrintOut(1, 0);
this.pdocument.EndPrint();
}
isBPacExtensionInstalled() {
return bpac.IsExtensionInstalled();
}
}

View File

@ -0,0 +1,79 @@
import {Injectable} from '@angular/core';
import {ApiService} from "../../shared/service/api.service";
import {convertMixMaterialQuantity, UNIT_MILLILITER} from "../../shared/units";
import {Observable} from "rxjs";
import {Mix} from "../../shared/model/recipe.model";
@Injectable({
providedIn: 'root'
})
export class MixService {
constructor(
private api: ApiService
) {
}
get all(): Observable<Mix[]> {
return this.api.get<Mix[]>('/recipe/mix')
}
getById(id: number): Observable<Mix> {
return this.api.get<Mix>(`/recipe/mix/${id}`)
}
saveWithUnits(name: string, recipeId: number, materialTypeId: number, mixMaterials: { materialId: number, quantity: number, percents: boolean }[], units: string): Observable<void> {
return this.save(name, recipeId, materialTypeId, this.convertMixMaterialsToMl(mixMaterials, units))
}
save(name: string, recipeId: number, materialTypeId: number, mixMaterials: { materialId: number, quantity: number }[]): Observable<void> {
const body = {
name,
recipeId,
materialTypeId,
mixMaterials: {}
}
this.appendMixMaterialsToBody(mixMaterials, body)
return this.api.post('/recipe/mix', body)
}
updateWithUnits(id: number, name: string, materialTypeId: number, mixMaterials: { materialId: number, quantity: number, percents: boolean }[], units: string): Observable<void> {
return this.update(id, name, materialTypeId, this.convertMixMaterialsToMl(mixMaterials, units))
}
update(id: number, name: string, materialTypeId: number, mixMaterials: { materialId: number, quantity: number }[]): Observable<void> {
const body = {
id,
name,
materialTypeId,
mixMaterials: {}
}
this.appendMixMaterialsToBody(mixMaterials, body)
return this.api.put('/recipe/mix', body)
}
delete(id: number): Observable<void> {
return this.api.delete(`/recipe/mix/${id}`)
}
extractMixMaterials(mix: Mix): { materialId: number, quantity: number, percents: boolean }[] {
return mix.mixMaterials.map(m => {
return {materialId: m.material.id, quantity: m.quantity, percents: m.material.materialType.usePercentages}
})
}
private convertMixMaterialsToMl(mixMaterials: { materialId: number, quantity: number, percents: boolean }[], units: string): { materialId: number, quantity: number }[] {
return mixMaterials.map(m => {
return {
materialId: m.materialId,
quantity: convertMixMaterialQuantity(m, units, UNIT_MILLILITER)
}
})
}
private appendMixMaterialsToBody(mixMaterials: { materialId: number, quantity: number }[], body: any) {
mixMaterials
.filter(m => m.materialId != null && m.quantity != null)
.forEach(m => body.mixMaterials[m.materialId] = m.quantity)
}
}

View File

@ -0,0 +1,32 @@
import {Injectable} from '@angular/core';
import {ApiService} from "../../shared/service/api.service";
import {Observable} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class RecipeImageService {
constructor(
private api: ApiService
) {
}
getAllIdsForRecipe(recipeId: number): Observable<number[]> {
return this.api.get(`/recipe/${recipeId}/image`)
}
save(image: File, recipeId: number): Observable<void> {
const body = new FormData()
body.append('image', image)
return this.api.post<void>(`/recipe/${recipeId}/image`, body, true)
}
deleteAll(imageIds: number[], recipeId: number) {
imageIds.forEach(id => this.delete(id, recipeId))
}
delete(imageId: number, recipeId: number): Observable<void> {
return this.api.delete<void>(`/recipe/${recipeId}/image/${imageId}`)
}
}

View File

@ -0,0 +1,106 @@
import {Injectable} from '@angular/core';
import {ApiService} from "../../shared/service/api.service";
import {Observable} from "rxjs";
import {Recipe, RecipeStep} from "../../shared/model/recipe.model";
import {map} from "rxjs/operators";
@Injectable({
providedIn: 'root'
})
export class RecipeService {
constructor(
private api: ApiService
) {
}
get all(): Observable<Recipe[]> {
return this.api.get<Recipe[]>('/recipe')
}
get allSortedByCompany(): Observable<{ company: string, recipes: Recipe[] }[]> {
return this.all.pipe(map(recipes => {
const mapped = []
recipes.forEach(r => {
if (!mapped[r.company.id]) {
mapped[r.company.id] = {company: r.company.name, recipes: []}
}
mapped[r.company.id].recipes.push(r)
})
return mapped.filter(e => e != null) // Filter to remove empty elements in the array that appears for some reason
}))
}
getById(id: number): Observable<Recipe> {
return this.api.get<Recipe>(`/recipe/${id}`)
}
save(name: string, description: string, sample: number, approbationDate: string, remark: string, companyId: number): Observable<Recipe> {
const body = {name, description, sample, remark, companyId}
if (approbationDate) {
// @ts-ignore
body.approbationDate = approbationDate
}
return this.api.post<Recipe>('/recipe', body)
}
update(id: number, name: string, description: string, sample: number, approbationDate: string, remark: string, steps: RecipeStep[] = null) {
const body = {id, name, description, sample, remark, steps}
if (approbationDate) {
// @ts-ignore
body.approbationDate = approbationDate
}
return this.api.put<Recipe>('/recipe', body)
}
saveExplorerModifications(id: number, note: string, mixesLocationChange: Map<number, string>): Observable<void> {
const body = {
id,
note,
mixesLocation: {}
}
mixesLocationChange.forEach((l, i) => body.mixesLocation[i] = l)
return this.api.put<void>('/recipe/public', body)
}
deductMixQuantities(recipe: Recipe, mixId: number, quantities: Map<number, number>): Observable<void> {
return this.sendDeductBody(this.buildDeductMixBody(recipe, mixId, quantities))
}
deductQuantities(recipe: Recipe, quantities: Map<number, Map<number, number>>): Observable<void> {
return this.sendDeductBody(this.buildDeductBody(recipe, quantities))
}
delete(id: number): Observable<void> {
return this.api.delete<void>(`/recipe/${id}`)
}
private buildDeductMixBody(recipe: Recipe, mixId: number, quantities: Map<number, number>): any {
const mix = recipe.mixes.filter(m => m.id === mixId)[0]
const body = {id: recipe.id, quantities: {}}
body.quantities[mixId] = {}
const firstMaterial = mix.mixMaterials[0].material.id
mix.mixMaterials.forEach(m => {
if (quantities && quantities.has(m.material.id)) {
body.quantities[mix.id][m.material.id] = quantities.get(m.material.id)
} else {
let quantity = m.quantity
if (m.material.materialType.usePercentages) quantity = body.quantities[mix.id][firstMaterial] * (quantity / 100)
body.quantities[mix.id][m.material.id] = quantity
}
})
return body
}
private buildDeductBody(recipe: Recipe, quantities: Map<number, Map<number, number>>): any {
const body = {id: recipe.id, quantities: {}}
recipe.mixes.forEach(mix => {
body.quantities[mix.id] = this.buildDeductMixBody(recipe, mix.id, quantities.get(mix.id)).quantities[mix.id]
})
return body
}
private sendDeductBody(body: any): Observable<void> {
return this.api.put('/recipe/deduct', body)
}
}

View File

@ -0,0 +1,27 @@
import {RouterModule, Routes} from "@angular/router";
import {ListComponent} from "./pages/list/list.component";
import {NgModule} from "@angular/core";
import {AddComponent} from "./pages/add/add.component";
import {EditComponent} from "./pages/edit/edit.component";
const routes: Routes = [{
path: 'list',
component: ListComponent
}, {
path: 'add',
component: AddComponent
}, {
path: 'edit/:id',
component: EditComponent
}, {
path: '',
pathMatch: 'full',
redirectTo: 'list'
}]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class CompanyRoutingModule {
}

View File

@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ListComponent } from './pages/list/list.component';
import { AddComponent } from './pages/add/add.component';
import { EditComponent } from './pages/edit/edit.component';
import {CompanyRoutingModule} from "./company-routing.module";
import {SharedModule} from "../shared/shared.module";
@NgModule({
declarations: [ListComponent, AddComponent, EditComponent],
imports: [
CommonModule,
CompanyRoutingModule,
SharedModule
]
})
export class CompanyModule { }

View File

@ -0,0 +1,8 @@
<cre-entity-add
title="Création d'une bannière"
backButtonLink="/catalog/company/list"
[unknownError]="unknownError"
[customError]="errorMessage"
[formFields]="formFields"
(submit)="submit($event)">
</cre-entity-add>

View File

@ -0,0 +1,53 @@
import {Component} from '@angular/core';
import {CompanyService} from "../../service/company.service";
import {FormBuilder, Validators} from "@angular/forms";
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {FormField} from "../../../shared/components/entity-add/entity-add.component";
import {ActivatedRoute, Router} from "@angular/router";
@Component({
selector: 'cre-add',
templateUrl: './add.component.html',
styleUrls: ['./add.component.sass']
})
export class AddComponent extends SubscribingComponent {
formFields: FormField[] = [
{
name: 'name',
label: 'Nom',
icon: 'form-textbox',
type: 'text',
validator: Validators.required,
errorMessages: [
{conditionFn: errors => errors.required, message: 'Un nom est requis'}
]
}
]
unknownError = false
errorMessage: string | null
constructor(
private companyService: CompanyService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
submit(values) {
this.subscribe(
this.companyService.save(values.name),
{
next: () => this.router.navigate(['/catalog/company/list']),
error: err => {
if (err.status === 409) {
this.errorMessage = `Une bannière avec le nom '${values.name}' existe déjà`
} else {
this.unknownError = true
console.log(err)
}
}
}
)
}
}

View File

@ -0,0 +1,13 @@
<cre-entity-edit
*ngIf="company"
title="Modifier la bannière {{company.name}}"
deleteConfirmMessage="Voulez-vous vraiment supprimer la bannière {{company.name}}?"
backButtonLink="/catalog/company/list"
deletePermission="REMOVE_COMPANY"
[entity]="company"
[formFields]="formFields"
[unknownError]="unknownError"
[customError]="errorMessage"
(submit)="submit($event)"
(delete)="delete()">
</cre-entity-edit>

View File

@ -0,0 +1,88 @@
import {Component} from '@angular/core';
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {Company} from "../../../shared/model/company.model";
import {FormField} from "../../../shared/components/entity-add/entity-add.component";
import {FormBuilder, Validators} from "@angular/forms";
import {CompanyService} from "../../service/company.service";
import {ActivatedRoute, Router} from "@angular/router";
@Component({
selector: 'cre-edit',
templateUrl: './edit.component.html',
styleUrls: ['./edit.component.sass']
})
export class EditComponent extends SubscribingComponent {
company: Company | null
formFields: FormField[] = [
{
name: 'name',
label: 'Nom',
icon: 'form-textbox',
type: 'text',
validator: Validators.required,
errorMessages: [
{conditionFn: errors => errors.required, message: 'Un nom est requis'}
]
}
]
unknownError = false
errorMessage: string | null
constructor(
private companyService: CompanyService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit(): void {
super.ngOnInit()
const id = parseInt(this.activatedRoute.snapshot.paramMap.get('id'))
this.subscribe(
this.companyService.getById(id),
{
next: company => this.company = company,
error: err => {
if (err.status == 404) {
this.router.navigate(['/catalog/company/list'])
} else {
this.unknownError = true
}
}
},
1
)
}
submit(values) {
this.subscribe(
this.companyService.update(this.company.id, values.name),
{
next: () => this.router.navigate(['/catalog/company/list']),
error: err => {
if (err.status == 409) {
this.errorMessage = `Une bannière avec le nom '${values.name}' existe déjà`
} else {
this.unknownError = true
}
console.log(err)
}
}
)
}
delete() {
this.subscribe(
this.companyService.delete(this.company.id),
{
next: () => this.router.navigate(['/catalog/company/list']),
error: err => {
this.unknownError = true
console.log(err)
}
}
)
}
}

View File

@ -0,0 +1,7 @@
<cre-entity-list
[entities$]="companies$"
[columns]="columns"
[buttons]="buttons"
addLink="/catalog/company/add"
addPermission="EDIT_COMPANY">
</cre-entity-list>

View File

@ -0,0 +1,31 @@
import {Component} from '@angular/core';
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {CompanyService} from "../../service/company.service";
import {EmployeePermission} from "../../../shared/model/employee";
import {FormBuilder} from "@angular/forms";
import {ActivatedRoute, Router} from "@angular/router";
@Component({
selector: 'cre-list',
templateUrl: './list.component.html',
styleUrls: ['./list.component.sass']
})
export class ListComponent extends SubscribingComponent {
companies$ = this.companyService.all
columns = [
{def: 'name', title: 'Nom', valueFn: c => c.name}
]
buttons = [{
text: 'Modifier',
linkFn: t => `/catalog/company/edit/${t.id}`,
permission: EmployeePermission.EDIT_COMPANY
}]
constructor(
private companyService: CompanyService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
}

View File

@ -0,0 +1,34 @@
import {Injectable} from '@angular/core';
import {ApiService} from "../../shared/service/api.service";
import {Observable} from "rxjs";
import {Company} from "../../shared/model/company.model";
@Injectable({
providedIn: 'root'
})
export class CompanyService {
constructor(
private api: ApiService
) {
}
get all(): Observable<Company[]> {
return this.api.get<Company[]>('/company')
}
getById(id: number): Observable<Company> {
return this.api.get<Company>(`/company/${id}`)
}
save(name: string): Observable<void> {
return this.api.post<void>('/company', {name})
}
update(id: number, name: string): Observable<void> {
return this.api.put<void>('/company', {id, name})
}
delete(id: number): Observable<void> {
return this.api.delete<void>(`/company/${id}`)
}
}

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ListComponent } from './pages/list/list.component';
import {AddComponent} from "./pages/add/add.component";
import {EditComponent} from "./pages/edit/edit.component";
import {PasswordEditComponent} from "./pages/password-edit/password-edit.component";
const routes: Routes = [{ path: 'list', component: ListComponent }, {path: 'add', component: AddComponent}, {path: 'edit/:id', component: EditComponent}, {path: 'password/edit/:id', component: PasswordEditComponent}, {path: '', redirectTo: 'list'}];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class EmployeesRoutingModule { }

View File

@ -0,0 +1,20 @@
import {NgModule} from '@angular/core';
import {EmployeesRoutingModule} from './employees-routing.module';
import {ListComponent} from './pages/list/list.component';
import {SharedModule} from "../shared/shared.module";
import { AddComponent } from './pages/add/add.component';
import {MatSelectModule} from "@angular/material/select";
import { EditComponent } from './pages/edit/edit.component';
import { PasswordEditComponent } from './pages/password-edit/password-edit.component';
@NgModule({
declarations: [ListComponent, AddComponent, EditComponent, PasswordEditComponent],
imports: [
EmployeesRoutingModule,
SharedModule,
MatSelectModule
]
})
export class EmployeesModule { }

View File

@ -0,0 +1,60 @@
<mat-card class="x-centered mt-5">
<mat-card-header>
<mat-card-title>Création d'un employé</mat-card-title>
</mat-card-header>
<mat-card-content>
<div *ngIf="unknownError" class="alert alert-danger">
<p>Une erreur est survenue</p>
</div>
<form [formGroup]="form">
<mat-form-field>
<mat-label>Numéro d'employé</mat-label>
<input matInput type="text" [formControl]="idControl"/>
<mat-icon svgIcon="pound" matSuffix></mat-icon>
<mat-error *ngIf="idControl.invalid">
<span *ngIf="idControl.errors.required">Un numéro d'employé est requis</span>
<span *ngIf="idControl.errors.pattern">Le numéro d'employé doit être un nombre</span>
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Prénom</mat-label>
<input matInput type="text" [formControl]="firstNameControl"/>
<mat-icon svgIcon="account" matSuffix></mat-icon>
<mat-error *ngIf="firstNameControl.invalid">
<span *ngIf="firstNameControl.errors.required">Un prénom est requis</span>
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Nom</mat-label>
<input matInput type="text" [formControl]="lastNameControl"/>
<mat-icon svgIcon="account" matSuffix></mat-icon>
<mat-error *ngIf="lastNameControl.invalid">
<span *ngIf="lastNameControl.errors.required">Un nom est requis</span>
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Mot de passe</mat-label>
<input matInput type="password" [formControl]="passwordControl"/>
<mat-icon svgIcon="lock" matSuffix></mat-icon>
<mat-error *ngIf="passwordControl.invalid">
<span *ngIf="passwordControl.errors.required">Un mot de passe est requis</span>
<span *ngIf="passwordControl.errors.minlength">Le mot de passe doit comprendre au moins 8 caractères</span>
</mat-error>
</mat-form-field>
<mat-form-field *ngIf="group$ | async as groups">
<mat-label>Groupe</mat-label>
<mat-select [formControl]="groupControl">
<mat-option [value]="null">Aucun</mat-option>
<mat-option *ngFor="let group of groups" [value]="group.id">{{group.name}}</mat-option>
</mat-select>
<mat-icon svgIcon="account-multiple" matSuffix></mat-icon>
</mat-form-field>
<cre-permissions-field #permissionsField></cre-permissions-field>
</form>
</mat-card-content>
<mat-card-actions>
<button mat-raised-button color="primary" routerLink="/employee/list">Retour</button>
<button mat-raised-button color="accent" (click)="submit()" [disabled]="form.invalid">Créer</button>
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1,2 @@
mat-card
max-width: 90rem

View File

@ -0,0 +1,79 @@
import {Component, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {FormBuilder, FormControl, FormGroup, Validators} from "@angular/forms";
import {PermissionsFieldComponent} from "../../../shared/components/permissions-field/permissions-field.component";
import {EmployeeGroup} from "../../../shared/model/employee";
import {Observable, Subject} from "rxjs";
import {GroupService} from "../../../groups/services/group.service";
import {take, takeUntil} from "rxjs/operators";
import {EmployeeService} from "../../services/employee.service";
import {ActivatedRoute, Router} from "@angular/router";
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
@Component({
selector: 'cre-add',
templateUrl: './add.component.html',
styleUrls: ['./add.component.sass']
})
export class AddComponent extends SubscribingComponent {
@ViewChild('permissionsField', {static: true}) permissionsField: PermissionsFieldComponent
form: FormGroup
idControl: FormControl
firstNameControl: FormControl
lastNameControl: FormControl
passwordControl: FormControl
groupControl: FormControl
unknownError = false
group$: Observable<EmployeeGroup[]> | null
constructor(
private employeeService: EmployeeService,
private groupService: GroupService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit(): void {
super.ngOnInit()
this.group$ = this.groupService.all
this.idControl = new FormControl(null, Validators.compose([Validators.required, Validators.pattern(new RegExp('^[0-9]+$')), Validators.min(0)]))
this.firstNameControl = new FormControl(null, Validators.required)
this.lastNameControl = new FormControl(null, Validators.required)
this.passwordControl = new FormControl(null, Validators.compose([Validators.required, Validators.minLength(8)]))
this.groupControl = new FormControl(null, Validators.min(0))
this.form = new FormGroup({
id: this.idControl,
firstName: this.firstNameControl,
lastName: this.lastNameControl,
password: this.passwordControl,
group: this.groupControl
})
}
submit() {
if (this.permissionsField.valid() && this.form.valid) {
this.subscribe(
this.employeeService.save(
parseInt(this.idControl.value),
this.firstNameControl.value,
this.lastNameControl.value,
this.passwordControl.value,
this.groupControl.value,
this.permissionsField.allEnabledPermissions
),
{
next: () => this.router.navigate(['/employee/list']),
error: err => {
console.error(err)
this.unknownError = true
}
}
)
}
}
}

View File

@ -0,0 +1,54 @@
<mat-card *ngIf="employee" class="x-centered mt-5">
<mat-card-header>
<mat-card-title>Modification de l'employé #{{employee.id}}</mat-card-title>
</mat-card-header>
<mat-card-content>
<div *ngIf="unknownError" class="alert alert-danger">
<p>Une erreur est survenue</p>
</div>
<form [formGroup]="form">
<mat-form-field>
<mat-label>Numéro d'employé</mat-label>
<input matInput type="text" [formControl]="idControl"/>
<mat-icon svgIcon="pound" matSuffix></mat-icon>
<mat-error *ngIf="idControl.invalid">
<span *ngIf="idControl.errors.required">Un numéro d'employé est requis</span>
<span *ngIf="idControl.errors.pattern">Le numéro d'employé doit être un nombre</span>
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Prénom</mat-label>
<input matInput type="text" [formControl]="firstNameControl"/>
<mat-icon svgIcon="account" matSuffix></mat-icon>
<mat-error *ngIf="firstNameControl.invalid">
<span *ngIf="firstNameControl.errors.required">Un prénom est requis</span>
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Nom</mat-label>
<input matInput type="text" [formControl]="lastNameControl"/>
<mat-icon svgIcon="account" matSuffix></mat-icon>
<mat-error *ngIf="lastNameControl.invalid">
<span *ngIf="lastNameControl.errors.required">Un nom est requis</span>
</mat-error>
</mat-form-field>
<mat-form-field *ngIf="group$ | async as groups">
<mat-label>Groupe</mat-label>
<mat-select [formControl]="groupControl">
<mat-option [value]="null">Aucun</mat-option>
<mat-option *ngFor="let group of groups" [value]="group.id">{{group.name}}</mat-option>
</mat-select>
<mat-icon svgIcon="account-multiple" matSuffix></mat-icon>
</mat-form-field>
<cre-permissions-field #permissionsField [enabledPermissions]="employee.permissions"></cre-permissions-field>
</form>
</mat-card-content>
<mat-card-actions>
<button mat-raised-button color="primary" routerLink="/employee/list">Retour</button>
<button mat-raised-button color="warn" *ngIf="canRemoveEmployee" (click)="confirmBoxComponent.show()">Supprimer
</button>
<button mat-raised-button color="accent" (click)="submit(permissionsField)" [disabled]="form.invalid">Enregistrer</button>
</mat-card-actions>
<cre-confirm-box #confirmBoxComponent message="Voulez-vous vraiment supprimer l'employé {{employee.id}}?" (confirm)="delete()"></cre-confirm-box>
</mat-card>

View File

@ -0,0 +1,2 @@
mat-card
max-width: 90rem

View File

@ -0,0 +1,161 @@
import {Component} from '@angular/core';
import {PermissionsFieldComponent} from "../../../shared/components/permissions-field/permissions-field.component";
import {FormBuilder, FormControl, FormGroup, Validators} from "@angular/forms";
import {EmployeeService} from "../../services/employee.service";
import {GroupService} from "../../../groups/services/group.service";
import {ActivatedRoute, Router} from "@angular/router";
import {Observable} from "rxjs";
import {Employee, EmployeeGroup, EmployeePermission} from "../../../shared/model/employee";
import {AccountService} from "../../../accounts/services/account.service";
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
@Component({
selector: 'cre-edit',
templateUrl: './edit.component.html',
styleUrls: ['./edit.component.sass']
})
export class EditComponent extends SubscribingComponent {
employee: Employee | null
unknownError = false
group$: Observable<EmployeeGroup[]> | null
private _idControl: FormControl
private _firstNameControl: FormControl
private _lastNameControl: FormControl
private _groupControl: FormControl
constructor(
private accountService: AccountService,
private employeeService: EmployeeService,
private groupService: GroupService,
private formBuilder: FormBuilder,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit(): void {
const employeeId = this.activatedRoute.snapshot.paramMap.get("id")
this.subscribe(
this.employeeService.get(parseInt(employeeId)),
{
next: employee => this.employee = employee,
error: err => {
if (err.status === 404) {
this.router.navigate(['/employee/list'])
} else {
this.unknownError = true
}
}
},
1
)
this.group$ = this.groupService.all
}
submit(permissionsField: PermissionsFieldComponent) {
if (permissionsField.valid() && this.form.valid) {
this.subscribe(
this.employeeService.update(
parseInt(this.idControl.value),
this.firstNameControl.value,
this.lastNameControl.value,
permissionsField.allEnabledPermissions
),
{
next: () => {
const group = parseInt(this._groupControl.value)
if (!isNaN(group)) {
this.subscribe(
this.groupService.addEmployeeToGroup(group, this.employee),
{
next: () => this.router.navigate(['/employee/list']),
error: err => {
console.error(err)
this.unknownError = true
}
}
)
} else {
if (this.employee.group) {
this.subscribe(
this.groupService.removeEmployeeFromGroup(this.employee),
{
next: () => this.router.navigate(['/employee/list']),
error: err => {
console.error(err)
this.unknownError = true
}
}
)
} else {
this.router.navigate(['/employee/list'])
}
}
},
error: err => {
console.error(err)
this.unknownError = true
}
}
)
}
}
delete() {
this.subscribe(
this.employeeService.delete(this.employee.id),
{
next: () => this.router.navigate(['/employee/list']),
error: err => {
this.unknownError = true
console.error(err)
}
}
)
}
get form(): FormGroup {
return this.formBuilder.group({
id: this._idControl,
firstName: this._firstNameControl,
lastName: this._lastNameControl,
group: this._groupControl
})
}
get idControl(): FormControl {
this._idControl = this.lazyControl(this._idControl, () => new FormControl(this.employee.id, Validators.compose([Validators.required, Validators.pattern(new RegExp('^[0-9]+$')), Validators.min(0)])))
return this._idControl
}
get firstNameControl(): FormControl {
this._firstNameControl = this.lazyControl(this._firstNameControl, () => new FormControl(this.employee.firstName, Validators.required))
return this._firstNameControl
}
get lastNameControl(): FormControl {
this._lastNameControl = this.lazyControl(this._lastNameControl, () => new FormControl(this.employee.lastName, Validators.required))
return this._lastNameControl
}
get groupControl(): FormControl {
this._groupControl = this.lazyControl(this._groupControl, () => new FormControl(this.employee.group?.id))
return this._groupControl
}
private lazyControl(control: FormControl, provider: () => FormControl): FormControl {
if (control) return control
if (this.employee) {
return provider()
}
return null
}
get canRemoveEmployee(): boolean {
return this.accountService.hasPermission(EmployeePermission.REMOVE_EMPLOYEE)
}
}

View File

@ -0,0 +1,66 @@
<div class="action-bar">
<button *ngIf="canEditEmployee" mat-raised-button color="accent" routerLink="/employee/add">Ajouter</button>
</div>
<table class="mx-auto" *ngIf="employees$ | async as employees" mat-table multiTemplateDataRows [dataSource]="employees">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Numéro d'employé</th>
<td mat-cell *matCellDef="let employee">{{employee.id}}</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Nom</th>
<td mat-cell *matCellDef="let employee">{{employee.firstName}} {{employee.lastName}}</td>
</ng-container>
<ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef>Groupe</th>
<td mat-cell *matCellDef="let employee">
<ng-container *ngIf="employee.group">{{employee.group.name}}</ng-container>
<ng-container *ngIf="!employee.group">Aucun</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="lastLogin">
<th mat-header-cell *matHeaderCellDef>Dernière connexion</th>
<td mat-cell *matCellDef="let employee">
<ng-container *ngIf="employee.lastLoginTime">{{getDate(employee.lastLoginTime).toLocaleString()}}</ng-container>
<ng-container *ngIf="!employee.lastLoginTime">Jamais</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="permissionCount">
<th mat-header-cell *matHeaderCellDef>Permissions</th>
<td mat-cell *matCellDef="let employee">
<ng-container *ngIf="employee.permissions">{{employee.permissions.length}}</ng-container>
<ng-container *ngIf="!employee.permissions">0</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="editButton">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell [class.disabled]="!canEditEmployee" *matCellDef="let employee">
<button mat-raised-button color="accent" routerLink="/employee/edit/{{employee.id}}">Modifier</button>
</td>
</ng-container>
<ng-container matColumnDef="editPasswordButton">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell [class.disabled]="!canEditEmployeePassword" *matCellDef="let employee">
<button mat-raised-button color="accent" routerLink="/employee/password/edit/{{employee.id}}">Modifier mot de passe</button>
</td>
</ng-container>
<ng-container matColumnDef="expandedDetail">
<td mat-cell *matCellDef="let employee" [attr.colspan]="columns.length">
<div class="entity-detail"
[@detailExpand]="employee == expandedElement ? 'expanded' : 'collapsed'">
<cre-permissions-list [employee]="employee" class="w-100"></cre-permissions-list>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr
mat-row
*matRowDef="let employee; columns: columns"
class="entity-row can-expand"
[class.expanded-row]="expandedElement === employee"
(click)="expandedElement = expandedElement === employee ? null : employee">
</tr>
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
</table>

View File

@ -0,0 +1,2 @@
th, td
padding: 0 .7rem !important

View File

@ -0,0 +1,54 @@
import {Component} from '@angular/core';
import {Observable} from "rxjs";
import {EmployeeService} from "../../services/employee.service";
import {Employee, EmployeePermission} from "../../../shared/model/employee";
import {takeUntil} from "rxjs/operators";
import {AccountService} from "../../../accounts/services/account.service";
import {animate, state, style, transition, trigger} from "@angular/animations";
import {SubscribingComponent} from "../../../shared/components/subscribing.component";
import {FormBuilder} from "@angular/forms";
import {ActivatedRoute, Router} from "@angular/router";
@Component({
selector: 'cre-employees',
templateUrl: './list.component.html',
styleUrls: ['./list.component.sass'],
animations: [
trigger('detailExpand', [
state('collapsed', style({height: '0px', minHeight: '0'})),
state('expanded', style({height: '*'})),
transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)'))
])
]
})
export class ListComponent extends SubscribingComponent {
employees$: Observable<Employee[]>
columns = ['id', 'name', 'group', 'permissionCount', 'lastLogin', 'editButton', 'editPasswordButton']
expandedElement: Employee | null
constructor(
private employeeService: EmployeeService,
private accountService: AccountService,
router: Router,
activatedRoute: ActivatedRoute
) {
super(activatedRoute, router)
}
ngOnInit(): void {
this.employees$ = this.employeeService.all.pipe(takeUntil(this.destroy$))
}
getDate(dateString: string) {
return new Date(dateString)
}
get canEditEmployee(): boolean {
return this.accountService.hasPermission(EmployeePermission.EDIT_EMPLOYEE)
}
get canEditEmployeePassword(): boolean {
return this.accountService.hasPermission(EmployeePermission.EDIT_EMPLOYEE_PASSWORD)
}
}

View File

@ -0,0 +1,22 @@
<mat-card *ngIf="employee" class="x-centered mt-5">
<form [formGroup]="form">
<mat-card-header>
<mat-card-title>Modification du mot de passe de l'employé #{{employee.id}}</mat-card-title>
</mat-card-header>
<mat-card-content>
<mat-form-field>
<mat-label>Mot de passe</mat-label>
<input type="password" matInput [formControl]="passwordControl"/>
<mat-icon matSuffix svgIcon="lock"></mat-icon>
<mat-error *ngIf="passwordControl.invalid">
<span *ngIf="passwordControl.errors.required">Un mot de passe est requis</span>
<span *ngIf="passwordControl.errors.minlength">Le mot de passe doit comprendre au moins 8 caractères</span>
</mat-error>
</mat-form-field>
</mat-card-content>
<mat-card-actions>
<button mat-raised-button color="primary" routerLink="/employee/list">Retour</button>
<button mat-raised-button color="accent" [disabled]="form.invalid" (click)="submit()">Enregistrer</button>
</mat-card-actions>
</form>
</mat-card>

Some files were not shown because too many files have changed in this diff Show More