Compare commits
37 Commits
feature/ng
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cacc04a217 | ||
|
|
f740c11685 | ||
|
|
fa098f4a1b | ||
|
|
a043d64229 | ||
|
|
9d6e5bb7a3 | ||
|
|
aec1fd5ad1 | ||
|
|
02442a162a | ||
|
|
364be9976a | ||
|
|
e1ae0a36d7 | ||
|
|
71a8e267dc | ||
|
|
2934e099b1 | ||
|
|
dad9f4fce1 | ||
|
|
9c1c2f6356 | ||
|
|
bc5b73e080 | ||
|
|
a575200368 | ||
|
|
35a591fcf7 | ||
|
|
6b975dadac | ||
|
|
008b644bb1 | ||
|
|
b047ecc589 | ||
|
|
dfc3afd4a9 | ||
|
|
02cad3dbcd | ||
|
|
085605f85c | ||
|
|
c28431e80c | ||
|
|
4699f16b71 | ||
|
|
86f62b0c4d | ||
|
|
cc483e6ed5 | ||
|
|
e050bf5def | ||
|
|
635975207f | ||
|
|
d635ba0986 | ||
|
|
b08663fb28 | ||
|
|
3e2bdaebc6 | ||
|
|
d7bb559f95 | ||
|
|
a8ef23845c | ||
|
|
ec44849b31 | ||
|
|
1bfa4dec47 | ||
|
|
8ccb75ee4e | ||
|
|
d5644b6044 |
9
.run/all_start_dev.run.xml
Normal file
9
.run/all_start_dev.run.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="all:start:dev" type="CompoundRunConfigurationType">
|
||||||
|
<toRun name="dev:gui" type="ShConfigurationType" />
|
||||||
|
<toRun name="dev:lib" type="ShConfigurationType" />
|
||||||
|
<toRun name="dev:server" type="ShConfigurationType" />
|
||||||
|
<toRun name="docker-env-dev" type="ShConfigurationType" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
17
.run/dev_gui.run.xml
Normal file
17
.run/dev_gui.run.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="dev:gui" type="ShConfigurationType">
|
||||||
|
<option name="SCRIPT_TEXT" value="cd admin && npm run start" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||||
|
<option name="SCRIPT_PATH" value="" />
|
||||||
|
<option name="SCRIPT_OPTIONS" value="" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||||
|
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||||
|
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||||
|
<option name="INTERPRETER_PATH" value="/usr/bin/zsh" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||||
|
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
17
.run/dev_lib.run.xml
Normal file
17
.run/dev_lib.run.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="dev:lib" type="ShConfigurationType">
|
||||||
|
<option name="SCRIPT_TEXT" value="cd admin && ng build @rschneider/ng-daisyui --watch " />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||||
|
<option name="SCRIPT_PATH" value="" />
|
||||||
|
<option name="SCRIPT_OPTIONS" value="" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||||
|
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||||
|
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||||
|
<option name="INTERPRETER_PATH" value="/usr/bin/zsh" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||||
|
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
17
.run/dev_server.run.xml
Normal file
17
.run/dev_server.run.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="dev:server" type="ShConfigurationType">
|
||||||
|
<option name="SCRIPT_TEXT" value="cd server && npm run start" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||||
|
<option name="SCRIPT_PATH" value="" />
|
||||||
|
<option name="SCRIPT_OPTIONS" value="" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||||
|
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||||
|
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||||
|
<option name="INTERPRETER_PATH" value="/usr/bin/zsh" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||||
|
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
17
.run/docker-env-dev.run.xml
Normal file
17
.run/docker-env-dev.run.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="docker-env-dev" type="ShConfigurationType">
|
||||||
|
<option name="SCRIPT_TEXT" value="cd environment/dev && docker compose up -d" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||||
|
<option name="SCRIPT_PATH" value="" />
|
||||||
|
<option name="SCRIPT_OPTIONS" value="" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||||
|
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||||
|
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||||
|
<option name="INTERPRETER_PATH" value="/usr/bin/zsh" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||||
|
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@ -39,12 +39,24 @@
|
|||||||
"maximumError": "8kB"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.production.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.development.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
|
|||||||
99
admin/package-lock.json
generated
99
admin/package-lock.json
generated
@ -14,8 +14,16 @@
|
|||||||
"@angular/forms": "^20.3.0",
|
"@angular/forms": "^20.3.0",
|
||||||
"@angular/platform-browser": "^20.3.0",
|
"@angular/platform-browser": "^20.3.0",
|
||||||
"@angular/router": "^20.3.0",
|
"@angular/router": "^20.3.0",
|
||||||
|
"@fullcalendar/angular": "^6.1.19",
|
||||||
|
"@fullcalendar/core": "^6.1.19",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.19",
|
||||||
|
"@fullcalendar/interaction": "^6.1.19",
|
||||||
|
"@fullcalendar/list": "^6.1.19",
|
||||||
|
"@fullcalendar/timegrid": "^6.1.19",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"daisyui": "^5.4.5",
|
"daisyui": "^5.4.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
@ -1357,6 +1365,68 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fullcalendar/angular": {
|
||||||
|
"version": "6.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/angular/-/angular-6.1.19.tgz",
|
||||||
|
"integrity": "sha512-a3TmjKnF8xprH1aNgFn9zYehEhM4GBAyh+91SJymno2j1cE8D8z0+W1HNwtDekKWwJt/5YoinCvDTHydmF/kKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "12 - 20",
|
||||||
|
"@angular/core": "12 - 20",
|
||||||
|
"@fullcalendar/core": "~6.1.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/core": {
|
||||||
|
"version": "6.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz",
|
||||||
|
"integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "~10.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/daygrid": {
|
||||||
|
"version": "6.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.19.tgz",
|
||||||
|
"integrity": "sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/interaction": {
|
||||||
|
"version": "6.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz",
|
||||||
|
"integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/list": {
|
||||||
|
"version": "6.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz",
|
||||||
|
"integrity": "sha512-knZHpAVF0LbzZpSJSUmLUUzF0XlU/MRGK+Py2s0/mP93bCtno1k2L3XPs/kzh528hSjehwLm89RgKTSfW1P6cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/timegrid": {
|
||||||
|
"version": "6.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.19.tgz",
|
||||||
|
"integrity": "sha512-OuzpUueyO9wB5OZ8rs7TWIoqvu4v3yEqdDxZ2VcsMldCpYJRiOe7yHWKr4ap5Tb0fs7Rjbserc/b6Nt7ol6BRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fullcalendar/daygrid": "~6.1.19"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@inquirer/ansi": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz",
|
||||||
@ -4839,6 +4909,16 @@
|
|||||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/date-format": {
|
"node_modules/date-format": {
|
||||||
"version": "4.0.14",
|
"version": "4.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
|
||||||
@ -6498,6 +6578,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jwt-decode": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/karma": {
|
"node_modules/karma": {
|
||||||
"version": "6.4.4",
|
"version": "6.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz",
|
||||||
@ -8795,6 +8884,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
|
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proc-log": {
|
"node_modules/proc-log": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
|
||||||
|
|||||||
@ -28,8 +28,16 @@
|
|||||||
"@angular/forms": "^20.3.0",
|
"@angular/forms": "^20.3.0",
|
||||||
"@angular/platform-browser": "^20.3.0",
|
"@angular/platform-browser": "^20.3.0",
|
||||||
"@angular/router": "^20.3.0",
|
"@angular/router": "^20.3.0",
|
||||||
|
"@fullcalendar/angular": "^6.1.19",
|
||||||
|
"@fullcalendar/core": "^6.1.19",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.19",
|
||||||
|
"@fullcalendar/interaction": "^6.1.19",
|
||||||
|
"@fullcalendar/list": "^6.1.19",
|
||||||
|
"@fullcalendar/timegrid": "^6.1.19",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"daisyui": "^5.4.5",
|
"daisyui": "^5.4.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
@if (breadcrumbs()?.length) {
|
||||||
|
<div class="breadcrumbs text-sm">
|
||||||
|
<ul>
|
||||||
|
@for (breadcrumb of breadcrumbs(); track breadcrumb; ) {
|
||||||
|
|
||||||
|
<li>
|
||||||
|
@if (breadcrumb.url) {
|
||||||
|
<a [routerLink]="breadcrumb.url">
|
||||||
|
@if (breadcrumb.showIcon !== false) {
|
||||||
|
<span [outerHTML]="(breadcrumb.svgIcon || defaultLinkIcon) | safeHtml"></span>
|
||||||
|
}
|
||||||
|
{{ breadcrumb.text }}
|
||||||
|
</a>
|
||||||
|
} @else {
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
@if (breadcrumb.showIcon !== false) {
|
||||||
|
<span [outerHTML]="(breadcrumb.svgIcon || defaultIcon) | safeHtml"></span>
|
||||||
|
}
|
||||||
|
{{ breadcrumb.text }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Breadcrumbs } from './breadcrumbs';
|
||||||
|
|
||||||
|
describe('Breadcrumbs', () => {
|
||||||
|
let component: Breadcrumbs;
|
||||||
|
let fixture: ComponentFixture<Breadcrumbs>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Breadcrumbs]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Breadcrumbs);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { Breadcrumb } from '../../daisy.types';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { SafeHtmlPipe } from '../../pipes/safe-html-pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'rs-daisy-breadcrumbs',
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
],
|
||||||
|
templateUrl: './breadcrumbs.html',
|
||||||
|
styleUrl: './breadcrumbs.css',
|
||||||
|
})
|
||||||
|
export class Breadcrumbs {
|
||||||
|
|
||||||
|
breadcrumbs = input<Breadcrumb[]>([]);
|
||||||
|
|
||||||
|
defaultLinkIcon: string = `<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-4 w-4 stroke-current">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
defaultIcon: string = `
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-4 w-4 stroke-current">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<div class="card bg-base-100 w-96 shadow-sm" [class]="cardClass()">
|
||||||
|
@if (imageSrc()) {
|
||||||
|
<figure class="px-10 pt-10">
|
||||||
|
<img
|
||||||
|
[src]="imageSrc()"
|
||||||
|
[alt]="imageAlt()"
|
||||||
|
class="rounded-xl"
|
||||||
|
[class]="imageClass()"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
}
|
||||||
|
<div class="card-body items-center text-center">
|
||||||
|
@if (cardTitle()) {
|
||||||
|
<h2 class="card-title">{{ cardTitle() }}</h2>
|
||||||
|
}
|
||||||
|
@if (cardText()) {
|
||||||
|
<p>A card component has a figure, a body part, and inside body there are title and actions parts</p>
|
||||||
|
}
|
||||||
|
<ng-content></ng-content>
|
||||||
|
@if (cardActionText()) {
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-primary">{{ cardActionText() }}</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'rs-daisy-card-with-centered-content-and-paddings',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './card-with-centered-content-and-paddings.html',
|
||||||
|
styleUrl: './card-with-centered-content-and-paddings.css',
|
||||||
|
})
|
||||||
|
export class CardWithCenteredContentAndPaddings {
|
||||||
|
imageSrc = input<string | null>(null);
|
||||||
|
imageAlt = input<string | null>(null);
|
||||||
|
imageClass = input<string | null>(null);
|
||||||
|
cardTitle = input<string | null>(null);
|
||||||
|
cardText = input<string | null>(null);
|
||||||
|
cardActionText = input<string | null>(null);
|
||||||
|
cardActionClick = output<void>();
|
||||||
|
cardClass = signal<string | null>(null);
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{{ isOpen() }}
|
||||||
|
<dialog #modal class="modal" [class]="dialogStyleClass()">
|
||||||
|
<div class="modal-box" [class]="modalBoxStyleClass()">
|
||||||
|
<button (click)="closeClicked()" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
@if (headerText()) {
|
||||||
|
<h3 class="text-lg font-bold">{{ headerText() }}</h3>
|
||||||
|
}
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
|
@if (backdrop()) {
|
||||||
|
<form (click)="closeClicked()" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</dialog>
|
||||||
@ -1,18 +1,18 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { MainMenu } from './main-menu';
|
import { Modal } from './modal';
|
||||||
|
|
||||||
describe('MainMenu', () => {
|
describe('Modal', () => {
|
||||||
let component: MainMenu;
|
let component: Modal;
|
||||||
let fixture: ComponentFixture<MainMenu>;
|
let fixture: ComponentFixture<Modal>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [MainMenu]
|
imports: [Modal]
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(MainMenu);
|
fixture = TestBed.createComponent(Modal);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
input,
|
||||||
|
model, OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'rs-daisy-modal',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './modal.html',
|
||||||
|
styleUrl: './modal.css',
|
||||||
|
})
|
||||||
|
export class Modal implements OnInit , AfterViewInit, OnDestroy{
|
||||||
|
dialogStyleClass = input<string>();
|
||||||
|
backdrop = input<boolean>(false);
|
||||||
|
modalBoxStyleClass = input<string>();
|
||||||
|
closeButton = input<boolean>(true);
|
||||||
|
headerText = input<string>();
|
||||||
|
isOpen = model<boolean>(false);
|
||||||
|
|
||||||
|
onClose = output<boolean>();
|
||||||
|
closeClick = output<boolean>();
|
||||||
|
|
||||||
|
@ViewChild('modal') modalRef!: ElementRef<HTMLDialogElement>;
|
||||||
|
|
||||||
|
private initialized = signal<boolean>(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if ( this.isOpen()){
|
||||||
|
this.doOpen();
|
||||||
|
}else{
|
||||||
|
this.doClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
/* if (open()){
|
||||||
|
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
// console.info("dialog",this.dialog)
|
||||||
|
const modal = this.modalRef.nativeElement;
|
||||||
|
modal.addEventListener('close', () => this.onClose.emit(true) );
|
||||||
|
this.initialized.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitClose(){
|
||||||
|
console.info("emit close", this.onClose);
|
||||||
|
this.onClose?.emit(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
doOpen() {
|
||||||
|
if ( !this.initialized()){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modal = this.modalRef.nativeElement;
|
||||||
|
if ( !modal.open ){
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doClose() {
|
||||||
|
if ( !this.initialized()){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modal = this.modalRef.nativeElement;
|
||||||
|
if ( modal.open ){
|
||||||
|
modal.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ngOnDestroy() {
|
||||||
|
console.info("destroy");
|
||||||
|
// const modal = this.modalRef?.nativeElement;
|
||||||
|
// if ( modal ){
|
||||||
|
// modal.removeEventListener('close', this.emitClose);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
closeClicked() {
|
||||||
|
console.info("close clicked");
|
||||||
|
this.closeClick.emit(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,3 +11,10 @@ export interface FooterNav{
|
|||||||
export interface FooterConfig{
|
export interface FooterConfig{
|
||||||
navs: FooterNav[]
|
navs: FooterNav[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Breadcrumb{
|
||||||
|
text: string;
|
||||||
|
url?: string;
|
||||||
|
showIcon?: boolean;
|
||||||
|
svgIcon?: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
<a class="btn btn-ghost text-xl">daisyUI</a>
|
<a class="btn btn-ghost text-xl">{{headerText()}}</a>
|
||||||
</div>
|
</div>
|
||||||
@if (loggedIn()) {
|
@if (loggedIn()) {
|
||||||
<div class="flex-none">
|
<div class="flex-none">
|
||||||
@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="flex-1 p-6 bg-base-100 overflow-y-auto">
|
<main class="flex-1 p-6 bg-base-100 overflow-y-auto">
|
||||||
<ng-content></ng-content>
|
<ng-content select="[main-content]"></ng-content>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@ -57,27 +57,7 @@
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="drawer-side">
|
<div class="drawer-side">
|
||||||
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||||
<ul class="menu p-4 w-80 min-h-full bg-base-300 text-base-content">
|
<ng-content select="[menu-content]"></ng-content>
|
||||||
<!-- Sidebar content here -->
|
|
||||||
<li class="text-lg font-bold p-4">My App</li>
|
|
||||||
<li><a>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
Dashboard</a></li>
|
|
||||||
<li><a>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Analytics</a></li>
|
|
||||||
<li><a>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
Reports</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { Component, input, output } from '@angular/core';
|
|||||||
})
|
})
|
||||||
export class AdminLayoutRs1 {
|
export class AdminLayoutRs1 {
|
||||||
|
|
||||||
|
headerText = input<string>();
|
||||||
|
|
||||||
readonly loggedIn = input<boolean>(false)
|
readonly loggedIn = input<boolean>(false)
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { inject, Pipe, PipeTransform } from '@angular/core';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'safeHtml'
|
||||||
|
})
|
||||||
|
export class SafeHtmlPipe implements PipeTransform {
|
||||||
|
|
||||||
|
private sanitized = inject(DomSanitizer);
|
||||||
|
|
||||||
|
transform(value: string | undefined): SafeHtml {
|
||||||
|
if (!value) return '';
|
||||||
|
return this.sanitized.bypassSecurityTrustHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -5,5 +5,8 @@
|
|||||||
export * from './lib/ng-daisyui';
|
export * from './lib/ng-daisyui';
|
||||||
export * from './lib/components/button/button';
|
export * from './lib/components/button/button';
|
||||||
export * from './lib/components/footer/footer';
|
export * from './lib/components/footer/footer';
|
||||||
|
export * from './lib/components/breadcrumbs/breadcrumbs';
|
||||||
|
export * from './lib/components/modal/modal';
|
||||||
|
export * from './lib/components/card/card-with-centered-content-and-paddings/card-with-centered-content-and-paddings';
|
||||||
export * from './lib/daisy.types';
|
export * from './lib/daisy.types';
|
||||||
export * from './lib/layout/';
|
export * from './lib/layout/';
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
<rs-daisy-admin-layout-rs1 (clickEvent)="logout()" [loggedIn]="loggedIn()">
|
<rs-daisy-admin-layout-rs1 [headerText]="'Foglalási rendszer'" (clickEvent)="logout()" [loggedIn]="loggedIn()">
|
||||||
<router-outlet />
|
|
||||||
|
<app-menu
|
||||||
|
[title]="menuTitle"
|
||||||
|
[menuItems]="menuConfig"
|
||||||
|
[userRoles]="currentUserRoles"
|
||||||
|
menu-content>
|
||||||
|
</app-menu>
|
||||||
|
<router-outlet main-content />
|
||||||
</rs-daisy-admin-layout-rs1>
|
</rs-daisy-admin-layout-rs1>
|
||||||
|
|||||||
@ -1,10 +1,342 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { LoginComponent } from './components/login/login.component';
|
import { LoginComponent } from './components/login/login.component';
|
||||||
import { AuthGuard } from './auth/auth.guard';
|
import { AuthGuard } from './auth/auth.guard';
|
||||||
import { HomeComponent } from './components/home/home.component'; // Assuming you have a HomeComponent
|
import { HomeComponent } from './components/home/home.component';
|
||||||
|
import { Welcome } from './page/welcome/welcome';
|
||||||
|
import { EventTypeFormComponent } from './features/event-type/components/event-type-form/event-type-form.component';
|
||||||
|
import {
|
||||||
|
EventTypeDetailsComponent,
|
||||||
|
} from './features/event-type/components/event-type-details/event-type-details.component';
|
||||||
|
import { EventTypeTableComponent } from './features/event-type/components/event-type-table/event-type-table.component';
|
||||||
|
import { EventTypeListComponent } from './features/event-type/components/event-type-list/event-type-list.component';
|
||||||
|
import { ProductFormComponent } from './features/products/components/product-form/product-form.component';
|
||||||
|
import { ProductDetailsComponent } from './features/products/components/product-details/product-details.component';
|
||||||
|
import { ProductTableComponent } from './features/products/components/product-table/product-table.component';
|
||||||
|
import { ProductListComponent } from './features/products/components/product-list/product-list.component';
|
||||||
|
import { EventFormComponent } from './features/events/components/event-form/event-form.component';
|
||||||
|
import { EventDetailsComponent } from './features/events/components/event-details/event-details.component';
|
||||||
|
import { EventTableComponent } from './features/events/components/event-table/event-table.component';
|
||||||
|
import { EventListComponent } from './features/events/components/event-list/event-list.component';
|
||||||
|
import { UserFormComponent } from './features/user/components/user-form/user-form.component';
|
||||||
|
import { UserDetailsComponent } from './features/user/components/user-details/user-details.component';
|
||||||
|
import { UserTableComponent } from './features/user/components/user-table/user-table.component';
|
||||||
|
import { UserListComponent } from './features/user/components/user-list/user-list.component';
|
||||||
|
import { UserGroupFormComponent } from './features/user-group/components/user-group-form/user-group-form.component';
|
||||||
|
import {
|
||||||
|
UserGroupDetailsComponent,
|
||||||
|
} from './features/user-group/components/user-group-details/user-group-details.component';
|
||||||
|
import { UserGroupTableComponent } from './features/user-group/components/user-group-table/user-group-table.component';
|
||||||
|
import { UserGroupListComponent } from './features/user-group/components/user-group-list/user-group-list.component';
|
||||||
|
import { UserRoleFormComponent } from './features/user-role/components/user-role-form/user-role-form.component';
|
||||||
|
import {
|
||||||
|
UserRoleDetailsComponent,
|
||||||
|
} from './features/user-role/components/user-role-details/user-role-details.component';
|
||||||
|
import { UserRoleTableComponent } from './features/user-role/components/user-role-table/user-role-table.component';
|
||||||
|
import { UserRoleListComponent } from './features/user-role/components/user-role-list/user-role-list.component';
|
||||||
|
import { BookingFormComponent } from "./features/bookings/components/booking-form/booking-form.component";
|
||||||
|
import { BookingDetailsComponent } from "./features/bookings/components/booking-details/booking-details.component";
|
||||||
|
import { BookingTableComponent } from "./features/bookings/components/booking-table/booking-table.component";
|
||||||
|
import { BookingListComponent } from "./features/bookings/components/booking-list/booking-list.component";
|
||||||
|
import { CalendarView } from './features/calendar/components/calendar-view/calendar-view';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'calendar',
|
||||||
|
component: CalendarView,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'bookings/new',
|
||||||
|
component: BookingFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bookings',
|
||||||
|
component: BookingListComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bookings/table',
|
||||||
|
component: BookingTableComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bookings/:id',
|
||||||
|
component: BookingDetailsComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bookings/:id/edit',
|
||||||
|
component: BookingFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-role/new',
|
||||||
|
component: UserRoleFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-role',
|
||||||
|
component: UserRoleListComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-role/table',
|
||||||
|
component: UserRoleTableComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-role/:id',
|
||||||
|
component: UserRoleDetailsComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-role/:id/edit',
|
||||||
|
component: UserRoleFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-group/new',
|
||||||
|
component: UserGroupFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-group',
|
||||||
|
component: UserGroupListComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-group/table',
|
||||||
|
component: UserGroupTableComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-group/:id',
|
||||||
|
component: UserGroupDetailsComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-group/:id/edit',
|
||||||
|
component: UserGroupFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'user/new',
|
||||||
|
component: UserFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user',
|
||||||
|
component: UserListComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user/table',
|
||||||
|
component: UserTableComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user/:id',
|
||||||
|
component: UserDetailsComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user/:id/edit',
|
||||||
|
component: UserFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'events/new',
|
||||||
|
component: EventFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'events',
|
||||||
|
component: EventListComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'events/table',
|
||||||
|
component: EventTableComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'events/:id',
|
||||||
|
component: EventDetailsComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'events/:id/edit',
|
||||||
|
component: EventFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'products',
|
||||||
|
component: ProductListComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'products/table',
|
||||||
|
component: ProductTableComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'products/:id',
|
||||||
|
component: ProductDetailsComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'products/:id/edit',
|
||||||
|
component: ProductFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'products/new',
|
||||||
|
component: ProductFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'event-type',
|
||||||
|
component: EventTypeListComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'event-type/table',
|
||||||
|
component: EventTypeTableComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'event-type/new',
|
||||||
|
component: EventTypeFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'event-type/:id',
|
||||||
|
component: EventTypeDetailsComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'event-type/:id/edit',
|
||||||
|
component: EventTypeFormComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
{ path: 'login', component: LoginComponent },
|
{ path: 'login', component: LoginComponent },
|
||||||
{ path: '', component: HomeComponent, canActivate: [AuthGuard] },
|
{ path: 'welcome', component: Welcome },
|
||||||
{ path: '**', redirectTo: '' } // Redirect to home for any other route
|
{ path: 'home', component: HomeComponent, canActivate: [AuthGuard] },
|
||||||
|
{ path: '', component: Welcome },
|
||||||
|
{ path: '**', redirectTo: '' }, // Redirect to home for any other route
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,27 +1,80 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { Router, RouterOutlet } from '@angular/router';
|
import { Router, RouterOutlet } from '@angular/router';
|
||||||
import { MainMenu } from './components/main-menu/main-menu';
|
|
||||||
import { AuthService } from './auth/auth.service';
|
import { AuthService } from './auth/auth.service';
|
||||||
import { AdminLayout } from './layout/admin-layout/admin-layout';
|
|
||||||
import { finalize } from 'rxjs/operators';
|
|
||||||
import {Button} from '@rschneider/ng-daisyui';
|
|
||||||
import { AdminLayoutRs1 } from '../../projects/rschneider/ng-daisyui/src/lib/layout';
|
import { AdminLayoutRs1 } from '../../projects/rschneider/ng-daisyui/src/lib/layout';
|
||||||
|
import { Menu, MenuItem } from './components/menu/menu';
|
||||||
|
import { SingleEventDashboardCard } from './features/calendar/components/calendar-view/single-event-dashboard-card/single-event-dashboard-card';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
|
|
||||||
imports: [RouterOutlet, MainMenu, Button, AdminLayoutRs1],
|
imports: [RouterOutlet, AdminLayoutRs1, Menu, SingleEventDashboardCard], // Import it here
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css',
|
styleUrl: './app.css',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly title = signal('admin');
|
protected readonly title = signal('admin');
|
||||||
|
protected menuConfig: MenuItem[] = [];
|
||||||
|
protected currentUserRoles: string[] = ['admin'];
|
||||||
|
protected menuTitle: string | undefined = 'Menü';
|
||||||
|
|
||||||
|
|
||||||
constructor(private authService: AuthService, private router: Router) {}
|
constructor(private authService: AuthService, private router: Router) {
|
||||||
|
this.menuConfig = [
|
||||||
|
{
|
||||||
|
menuText: 'Esemény típusok',
|
||||||
|
targetUrl: '/event-type/table',
|
||||||
|
svgIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuText: 'Események',
|
||||||
|
targetUrl: '/events/table',
|
||||||
|
svgIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuText: 'Naptár',
|
||||||
|
targetUrl: '/calendar',
|
||||||
|
svgIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuText: 'Felhasználók',
|
||||||
|
targetUrl: '/user/table',
|
||||||
|
svgIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuText: 'Felhasználó Csoport',
|
||||||
|
targetUrl: '/user-group/table',
|
||||||
|
svgIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuText: 'Felhasználó Szerepek',
|
||||||
|
targetUrl: '/user-role/table',
|
||||||
|
svgIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
// With the interceptor fixed, this is now the correct and robust way.
|
|
||||||
// The error from a failed server logout will propagate here.
|
|
||||||
this.authService.serverSideLogout().subscribe({
|
this.authService.serverSideLogout().subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
console.log('Server-side logout successful.');
|
console.log('Server-side logout successful.');
|
||||||
@ -35,7 +88,7 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loggedIn() {
|
loggedIn() {
|
||||||
return this.authService.isLoggedIn()
|
return this.authService.isLoggedIn();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,11 +13,32 @@ export class AuthGuard implements CanActivate {
|
|||||||
route: ActivatedRouteSnapshot,
|
route: ActivatedRouteSnapshot,
|
||||||
state: RouterStateSnapshot
|
state: RouterStateSnapshot
|
||||||
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||||
if (this.authService.isLoggedIn()) {
|
const requiredRoles = route.data['roles'] as string[];
|
||||||
|
|
||||||
|
console.info("auth guard started", requiredRoles)
|
||||||
|
|
||||||
|
// 1. Check if the route requires any specific roles
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
|
// If no roles are required, only check if the user is logged in
|
||||||
|
return this.authService.isLoggedIn() ? true : this.router.parseUrl('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 2. Check if the user is logged in
|
||||||
|
if (!this.authService.isLoggedIn()) {
|
||||||
|
// If not logged in, redirect to the login page
|
||||||
|
return this.router.parseUrl('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if the user has the required role
|
||||||
|
if (this.authService.hasRole(requiredRoles)) {
|
||||||
|
// If the user has the role, allow access
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// Redirect to the login page
|
// If the user does not have the role, redirect to an unauthorized page
|
||||||
return this.router.createUrlTree(['/login']);
|
console.warn(`User does not have one of the required roles: ${requiredRoles}`);
|
||||||
}
|
return this.router.parseUrl('/welcome');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,23 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, of, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
name: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodedToken {
|
||||||
|
name: string; // Or 'username', 'given_name', etc.
|
||||||
|
roles: string[]; // The claim can be a single string or an array
|
||||||
|
exp: number; // Expiration time (Unix timestamp)
|
||||||
|
// Add other claims you need, like 'sub' for user ID
|
||||||
|
sub: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@ -12,11 +27,21 @@ export class AuthService {
|
|||||||
private readonly REFRESH_TOKEN_KEY = 'refreshToken';
|
private readonly REFRESH_TOKEN_KEY = 'refreshToken';
|
||||||
private apiUrl = 'http://localhost:4200/api/auth'; // Adjust if your server URL is different
|
private apiUrl = 'http://localhost:4200/api/auth'; // Adjust if your server URL is different
|
||||||
|
|
||||||
constructor(private http: HttpClient, private router: Router) {}
|
currentUser = signal<User | null>(null);
|
||||||
|
|
||||||
|
constructor(private http: HttpClient, private router: Router) {
|
||||||
|
const accessToken = this.getAccessToken();
|
||||||
|
if (accessToken) {
|
||||||
|
this.decodeAndSetUser(accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
login(credentials: { username: string; password: string }): Observable<any> {
|
login(credentials: { username: string; password: string }): Observable<any> {
|
||||||
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/login`, credentials).pipe(
|
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/login`, credentials).pipe(
|
||||||
tap((response) => this.setTokens(response.accessToken, response.refreshToken))
|
tap((response) => {
|
||||||
|
this.setTokens(response.accessToken, response.refreshToken);
|
||||||
|
this.decodeAndSetUser(response.accessToken);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,23 +57,31 @@ export class AuthService {
|
|||||||
* This is the definitive logout action from the user's perspective.
|
* This is the definitive logout action from the user's perspective.
|
||||||
*/
|
*/
|
||||||
clientSideLogout(): void {
|
clientSideLogout(): void {
|
||||||
console.info("clientSideLogout")
|
console.info("clientSideLogout");
|
||||||
this.removeTokens();
|
this.removeTokens();
|
||||||
|
this.currentUser.set(null);
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken(): Observable<any> {
|
refreshToken(): Observable<any> {
|
||||||
|
console.info("getting refresh token");
|
||||||
const refreshToken = this.getRefreshToken();
|
const refreshToken = this.getRefreshToken();
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
|
console.info("no refresh token found", refreshToken);
|
||||||
// If no refresh token is present, logout and return an error.
|
// If no refresh token is present, logout and return an error.
|
||||||
this.clientSideLogout();
|
this.clientSideLogout();
|
||||||
return throwError(() => new Error('No refresh token available'));
|
return throwError(() => new Error('No refresh token available'));
|
||||||
}
|
}
|
||||||
|
console.info("refresh token found", refreshToken);
|
||||||
|
|
||||||
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {}, {
|
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {}, {
|
||||||
headers: { Authorization: `Bearer ${refreshToken}` }
|
headers: { Authorization: `Bearer ${refreshToken}` }
|
||||||
}).pipe(
|
}).pipe(
|
||||||
tap((response) => this.setTokens(response.accessToken, response.refreshToken))
|
tap((response) => {
|
||||||
|
this.setTokens(response.accessToken, response.refreshToken);
|
||||||
|
this.decodeAndSetUser(response.accessToken);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +97,15 @@ export class AuthService {
|
|||||||
return this.getAccessToken() !== null;
|
return this.getAccessToken() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasRole(requiredRoles: string[]): boolean {
|
||||||
|
const user = this.currentUser();
|
||||||
|
if (!user) {
|
||||||
|
return false; // Not logged in, so no roles
|
||||||
|
}
|
||||||
|
// Check if any of the user's roles match any of the required roles
|
||||||
|
return requiredRoles.some(requiredRole => user.roles.includes(requiredRole));
|
||||||
|
}
|
||||||
|
|
||||||
private setTokens(accessToken: string, refreshToken: string): void {
|
private setTokens(accessToken: string, refreshToken: string): void {
|
||||||
localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken);
|
localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken);
|
||||||
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken);
|
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken);
|
||||||
@ -73,4 +115,28 @@ export class AuthService {
|
|||||||
localStorage.removeItem(this.ACCESS_TOKEN_KEY);
|
localStorage.removeItem(this.ACCESS_TOKEN_KEY);
|
||||||
localStorage.removeItem(this.REFRESH_TOKEN_KEY);
|
localStorage.removeItem(this.REFRESH_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private decodeAndSetUser(token: string): void {
|
||||||
|
try {
|
||||||
|
const decodedToken: DecodedToken = jwtDecode(token);
|
||||||
|
|
||||||
|
// Check if the token is expired. exp is in seconds, Date.now() is in ms.
|
||||||
|
// if (decodedToken.exp * 1000 < Date.now()) {
|
||||||
|
// console.warn('Attempted to use an expired token.');
|
||||||
|
// this.logout(); // The token is expired, so log the user out
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
this.currentUser.set({
|
||||||
|
name: decodedToken.name,
|
||||||
|
roles: decodedToken.roles
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decode JWT:', error);
|
||||||
|
this.currentUser.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export class JwtInterceptor implements HttpInterceptor {
|
|||||||
// this.refreshTokenSubject.next(null);
|
// this.refreshTokenSubject.next(null);
|
||||||
this.refreshTokenSubject = new BehaviorSubject<any>(null);
|
this.refreshTokenSubject = new BehaviorSubject<any>(null);
|
||||||
|
|
||||||
|
console.info("Refreshing tokens");
|
||||||
return this.authService.refreshToken().pipe(
|
return this.authService.refreshToken().pipe(
|
||||||
switchMap((token: any) => {
|
switchMap((token: any) => {
|
||||||
this.refreshTokenSubject.next(token.accessToken);
|
this.refreshTokenSubject.next(token.accessToken);
|
||||||
@ -60,6 +60,7 @@ export class JwtInterceptor implements HttpInterceptor {
|
|||||||
return throwError(() => err);
|
return throwError(() => err);
|
||||||
}),
|
}),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
|
console.info("refreshing done")
|
||||||
// When the refresh attempt completes, set isRefreshing to false
|
// When the refresh attempt completes, set isRefreshing to false
|
||||||
this.isRefreshing = false;
|
this.isRefreshing = false;
|
||||||
})
|
})
|
||||||
|
|||||||
0
admin/src/app/components/color-view/color-view.css
Normal file
0
admin/src/app/components/color-view/color-view.css
Normal file
8
admin/src/app/components/color-view/color-view.html
Normal file
8
admin/src/app/components/color-view/color-view.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
@if (color()) {
|
||||||
|
<div class="flex flex-row gap-1 items-center">
|
||||||
|
<div class='min-w-3 min-h-3 w-3 h-3'
|
||||||
|
[style]="'background-color: ' + color()"
|
||||||
|
></div>
|
||||||
|
{{color()}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
23
admin/src/app/components/color-view/color-view.spec.ts
Normal file
23
admin/src/app/components/color-view/color-view.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ColorView } from './color-view';
|
||||||
|
|
||||||
|
describe('ColorView', () => {
|
||||||
|
let component: ColorView;
|
||||||
|
let fixture: ComponentFixture<ColorView>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ColorView]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ColorView);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
admin/src/app/components/color-view/color-view.ts
Normal file
11
admin/src/app/components/color-view/color-view.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-color-view',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './color-view.html',
|
||||||
|
styleUrl: './color-view.css',
|
||||||
|
})
|
||||||
|
export class ColorView {
|
||||||
|
color = input<string>();
|
||||||
|
}
|
||||||
27
admin/src/app/components/detail-view/detail-view.html
Normal file
27
admin/src/app/components/detail-view/detail-view.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
@if (config().data){
|
||||||
|
<div class="overflow-x-auto" [ngClass]="config().styleClass">
|
||||||
|
<table class="table w-full table-zebra">
|
||||||
|
<tbody>
|
||||||
|
@for (row of config().rows; track row.attribute) {
|
||||||
|
<tr [ngClass]="row.getDetailClass ? row.getDetailClass(config().data) : ''">
|
||||||
|
<th [ngClass]="row.getTitleStyleClass ? row.getTitleStyleClass(config().data) : ''">
|
||||||
|
@if (row.titleComponent) {
|
||||||
|
<ng-container *ngComponentOutlet="row.titleComponent; inputs: row.titleComponentInputs ? row.titleComponentInputs(config().data) : {}"></ng-container>
|
||||||
|
} @else {
|
||||||
|
{{ getTitle(row, config().data) }}
|
||||||
|
}
|
||||||
|
</th>
|
||||||
|
<td [ngClass]="row.getValueStyleClass ? row.getValueStyleClass(config().data) : ''">
|
||||||
|
@if (row.component) {
|
||||||
|
<ng-container *ngComponentOutlet="row.component; inputs: row.componentInputs ? row.componentInputs(config().data) : {}"></ng-container>
|
||||||
|
} @else {
|
||||||
|
{{ getFormattedValue(row, config().data) }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
23
admin/src/app/components/detail-view/detail-view.spec.ts
Normal file
23
admin/src/app/components/detail-view/detail-view.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DetailView } from './detail-view';
|
||||||
|
|
||||||
|
describe('DetailView', () => {
|
||||||
|
let component: DetailView;
|
||||||
|
let fixture: ComponentFixture<DetailView>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [DetailView]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DetailView);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
76
admin/src/app/components/detail-view/detail-view.ts
Normal file
76
admin/src/app/components/detail-view/detail-view.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Component, input, Type } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
export interface CellComponent<T> {
|
||||||
|
component?: Type<any>;
|
||||||
|
componentInputs?: (obj: T) => { [key: string]: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailViewRow<T> extends CellComponent<T> {
|
||||||
|
attribute: keyof T;
|
||||||
|
getValue?: (obj: T) => any;
|
||||||
|
format?: 'date' | 'datetime' | 'number' | 'raw' | ((value: any) => string);
|
||||||
|
getTitle?: string | ((obj: T) => string);
|
||||||
|
getDetailClass?: (obj: T) => string;
|
||||||
|
getTitleStyleClass?: (obj: T) => string;
|
||||||
|
getValueStyleClass?: (obj: T) => string;
|
||||||
|
titleComponent?: Type<any>;
|
||||||
|
titleComponentInputs?: (obj: T) => { [key: string]: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailViewConfig<T> {
|
||||||
|
data: T;
|
||||||
|
rows: DetailViewRow<T>[];
|
||||||
|
styleClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-detail-view',
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './detail-view.html',
|
||||||
|
styleUrl: './detail-view.css',
|
||||||
|
})
|
||||||
|
export class DetailView<T> {
|
||||||
|
config = input.required<DetailViewConfig<T>>();
|
||||||
|
|
||||||
|
getFormattedValue(row: DetailViewRow<T>, data: T): string {
|
||||||
|
let value : any;
|
||||||
|
try {
|
||||||
|
value = row.getValue ? row.getValue(data) : data[row.attribute];
|
||||||
|
}catch (e) {
|
||||||
|
value = '';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.component) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof row.format === 'function') {
|
||||||
|
return row.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (row.format) {
|
||||||
|
case 'date':
|
||||||
|
return new Date(value).toLocaleDateString();
|
||||||
|
case 'datetime':
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
case 'number':
|
||||||
|
return Number(value).toString();
|
||||||
|
case 'raw':
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTitle(row: DetailViewRow<T>, data: T): string {
|
||||||
|
if (row.titleComponent) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof row.getTitle === 'function') {
|
||||||
|
return row.getTitle(data);
|
||||||
|
}
|
||||||
|
return row.getTitle || String(row.attribute);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<div class="flex flex-row gap-1 items-center">
|
||||||
|
|
||||||
|
@for (action of actions(); track action){
|
||||||
|
<a class="btn btn-primary" title="{{action.title ?? '' }}" (click)="onClick($event,action)">
|
||||||
|
@if(action.svgIcon){
|
||||||
|
<span [outerHTML]="action.svgIcon | safeHtml"></span>
|
||||||
|
}
|
||||||
|
@if ( action.text !== false){
|
||||||
|
{{action.text || action.action}}
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import { Component, inject, input, OnInit } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { SafeHtmlPipe } from '../../pipes/safe-html-pipe';
|
||||||
|
import { SvgIcons } from '../../svg-icons';
|
||||||
|
import { EventType } from '../../features/event-type/models/event-type.model';
|
||||||
|
import { CellDefinition, StyleClassContext } from '../generic-table/cell-definition.interface';
|
||||||
|
|
||||||
|
export interface ActionDefinition<T> {
|
||||||
|
action: string;
|
||||||
|
text?: string | false;
|
||||||
|
title?: string;
|
||||||
|
generate: (action: string, item?: T) => string;
|
||||||
|
handler?: (action: ActionDefinition<T>, item?: T) => void;
|
||||||
|
svgIcon?: string; //https://heroicons.com/
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-generic-action-column',
|
||||||
|
imports: [
|
||||||
|
SafeHtmlPipe,
|
||||||
|
],
|
||||||
|
templateUrl: './generic-action-column.html',
|
||||||
|
styleUrl: './generic-action-column.css',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class GenericActionColumn<T> implements OnInit {
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
actions = input([] as ActionDefinition<T>[]);
|
||||||
|
item = input(undefined as T);
|
||||||
|
payload = input(undefined as any);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick($event: any, actionDefinition: ActionDefinition<T>) {
|
||||||
|
if (actionDefinition?.handler) {
|
||||||
|
actionDefinition.handler(actionDefinition, this.item());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateActionColumnCellDefinitionContext<T> {
|
||||||
|
actionHandler?: (action: ActionDefinition<T>, item?: T) => void;
|
||||||
|
styleClass?: (definition: StyleClassContext<T>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createActionColumnCellDefinition = <T>(createCtx: CreateActionColumnCellDefinitionContext<T>): CellDefinition<T> => {
|
||||||
|
return {
|
||||||
|
styleClass: createCtx.styleClass ?? (ctx => 'w-[1%]'),
|
||||||
|
component: GenericActionColumn,
|
||||||
|
componentInputs: item => ({
|
||||||
|
item: item,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
title: 'Részletek',
|
||||||
|
text: false,
|
||||||
|
handler: createCtx.actionHandler,
|
||||||
|
svgIcon: SvgIcons.heroDocument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'edit', title: 'Szerkesztés', text: false,
|
||||||
|
svgIcon: SvgIcons.heroCog6Tooth,
|
||||||
|
handler: createCtx.actionHandler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'delete',
|
||||||
|
title: 'Törlés',
|
||||||
|
text: false,
|
||||||
|
handler: createCtx.actionHandler,
|
||||||
|
svgIcon: SvgIcons.heroTrash,
|
||||||
|
},
|
||||||
|
] as ActionDefinition<EventType>[],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { Type } from '@angular/core';
|
||||||
|
import { ColumnDefinition } from './column-definition.interface';
|
||||||
|
|
||||||
|
export interface StyleClassContext<T>{
|
||||||
|
definition: ColumnDefinition<T> ;
|
||||||
|
cellDefinition: CellDefinition<T>;
|
||||||
|
item?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellDefinition<T> {
|
||||||
|
value?: ((item?: T) => any) | string;
|
||||||
|
component?: Type<any>;
|
||||||
|
componentInputs?: (item?: T|null) => { [key: string]: any };
|
||||||
|
styleClass?: (definition: StyleClassContext<T>) => string;
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { CellDefinition } from './cell-definition.interface';
|
||||||
|
|
||||||
|
export interface TypeDefinition{
|
||||||
|
type: 'boolean' | 'number' | 'string' | 'date' | 'time' | 'datetime';
|
||||||
|
params?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnDefinition<T> {
|
||||||
|
attribute: keyof T;
|
||||||
|
type: TypeDefinition;
|
||||||
|
valueCell?: CellDefinition<T> | boolean
|
||||||
|
headerCell?: CellDefinition<T> | boolean
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { PaginatedResponse } from '../../features/products/models/product.model';
|
||||||
|
|
||||||
|
|
||||||
|
export interface QueryParams extends Record<string, any>{
|
||||||
|
sortBy?: string;
|
||||||
|
sortDirection?: 'asc' | 'desc',
|
||||||
|
page?: number,
|
||||||
|
limit?: number,
|
||||||
|
q?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDataOptions extends Record<string, any>{
|
||||||
|
params?: QueryParams
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDataResponse<T> extends Record<string, any>{
|
||||||
|
data: PaginatedResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataProvider<T> {
|
||||||
|
getData( options?: GetDataOptions): Observable<GetDataResponse<T>>;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<form [formGroup]="filterForm" class="p-4 bg-base-200 rounded-lg shadow">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">SearchTerm</span></label>
|
||||||
|
<input type="text" formControlName="term" class="input input-bordered w-full" placeholder="Search term" /></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { GenericTableSearchForm } from './generic-table-search-form';
|
||||||
|
|
||||||
|
describe('GenericTableSearchForm', () => {
|
||||||
|
let component: GenericTableSearchForm;
|
||||||
|
let fixture: ComponentFixture<GenericTableSearchForm>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [GenericTableSearchForm]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GenericTableSearchForm);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { Component, EventEmitter, Output } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-generic-table-search-form',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './generic-table-search-form.html',
|
||||||
|
styleUrl: './generic-table-search-form.css',
|
||||||
|
})
|
||||||
|
export class GenericTableSearchForm {
|
||||||
|
@Output() searchTermChanged = new EventEmitter<any>();
|
||||||
|
filterForm: FormGroup;
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) {
|
||||||
|
this.filterForm = this.fb.group({
|
||||||
|
term: ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
this.filterForm.valueChanges.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
distinctUntilChanged()
|
||||||
|
).subscribe(values => {
|
||||||
|
const cleanFilter = Object.fromEntries(
|
||||||
|
Object.entries(values).filter(([_, v]) => v != null && v !== '')
|
||||||
|
);
|
||||||
|
this.searchTermChanged.emit(cleanFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.filterForm.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { DataProvider } from './data-provider.interface';
|
||||||
|
import { ColumnDefinition } from './column-definition.interface';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
export interface GenericTableConfig<T> {
|
||||||
|
dataProvider: DataProvider<T>;
|
||||||
|
columns: ColumnDefinition<T>[];
|
||||||
|
tableCssClass?: string;
|
||||||
|
rowCssClass?: (item: T) => string;
|
||||||
|
refresh$: Subject<void>;
|
||||||
|
filter$: Subject<any>;
|
||||||
|
page$: Subject<number>;
|
||||||
|
limit$: Subject<number>;
|
||||||
|
}
|
||||||
92
admin/src/app/components/generic-table/generic-table.html
Normal file
92
admin/src/app/components/generic-table/generic-table.html
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<div [ngClass]="config.tableCssClass" [class]="'overflow-x-auto'">
|
||||||
|
<app-generic-table-search-form (searchTermChanged)="searchTermChanged($event)" ></app-generic-table-search-form>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@if (data$ | async; as getDataResponse) {
|
||||||
|
<table class="table w-full table-zebra" [class]="config.tableCssClass ?? ''">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
@for (column of config.columns; track column) {
|
||||||
|
<th [class]="column.attribute">
|
||||||
|
@if (column.headerCell) {
|
||||||
|
@if (typeof column.headerCell === 'boolean') {
|
||||||
|
{{ column.attribute }}
|
||||||
|
} @else {
|
||||||
|
|
||||||
|
@if (!column?.headerCell?.component) {
|
||||||
|
@if (typeof column.headerCell.value === 'string') {
|
||||||
|
<div [innerHTML]="getAsHtml(column.headerCell.value)"></div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<ng-container
|
||||||
|
*ngComponentOutlet="column.headerCell.component!"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</th>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of getDataResponse?.data?.data; track item) {
|
||||||
|
|
||||||
|
<tr [ngClass]="config.rowCssClass ? config.rowCssClass(item) : ''">
|
||||||
|
@for (column of config.columns; track column) {
|
||||||
|
<td [class]="getValueStyleClass(column!,column.valueCell!,item) ">
|
||||||
|
@if (column.valueCell) {
|
||||||
|
@if (typeof column.valueCell === 'boolean') {
|
||||||
|
<div [outerHTML]=" resolveValue(item, column) | safeHtml "></div>
|
||||||
|
} @else {
|
||||||
|
@if (!column.valueCell.component) {
|
||||||
|
<div [outerHTML]=" resolveValue(item, column, column.valueCell) | safeHtml "></div>
|
||||||
|
} @else {
|
||||||
|
<ng-container
|
||||||
|
*ngComponentOutlet="column.valueCell.component; inputs: getComponentInputs(item, column,column.valueCell)"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
@if ((getDataResponse?.data?.meta?.totalPages ?? 1) > 0) {
|
||||||
|
<div class="flex justify-end mt-4 items-center me-3 ">
|
||||||
|
Items: {{getDataResponse.data.data.length}}/{{getDataResponse.data.meta.totalItems}}
|
||||||
|
Page: {{getDataResponse.data.meta.currentPage}}/{{getDataResponse.data.meta.totalPages}}
|
||||||
|
PageSize:
|
||||||
|
<select class="select w-auto" #pageSize (change)="onPageSizeChange(pageSize.value)">
|
||||||
|
<option>10</option>
|
||||||
|
<option>20</option>
|
||||||
|
<option>50</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
@if ((getDataResponse?.data?.meta?.totalPages ?? 1) > 1) {
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<div class="join">
|
||||||
|
<button
|
||||||
|
class="join-item btn"
|
||||||
|
(click)="changePage(getDataResponse.data.meta.currentPage- 1)"
|
||||||
|
[disabled]="getDataResponse.data.meta.currentPage === 1">«
|
||||||
|
</button>
|
||||||
|
<button class="join-item btn">Page {{ getDataResponse.data.meta.currentPage }} of {{ getDataResponse.data.meta.totalPages }}</button>
|
||||||
|
<button
|
||||||
|
class="join-item btn"
|
||||||
|
(click)="changePage(getDataResponse.data.meta.currentPage + 1)"
|
||||||
|
[disabled]="getDataResponse.data.meta.currentPage === getDataResponse.data.meta.totalPages">»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="text-center p-8">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
108
admin/src/app/components/generic-table/generic-table.ts
Normal file
108
admin/src/app/components/generic-table/generic-table.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Component, inject, Input, OnInit } from '@angular/core';
|
||||||
|
import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs';
|
||||||
|
import { ColumnDefinition } from './column-definition.interface';
|
||||||
|
import { AsyncPipe, NgClass, NgComponentOutlet } from '@angular/common';
|
||||||
|
import { CellDefinition } from './cell-definition.interface';
|
||||||
|
import { GetDataResponse } from './data-provider.interface';
|
||||||
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
import { GenericTableConfig } from './generic-table.config';
|
||||||
|
import { startWith, switchMap } from 'rxjs/operators';
|
||||||
|
import { GenericTableSearchForm } from './generic-table-search-form/generic-table-search-form';
|
||||||
|
import { SafeHtmlPipe } from '../../pipes/safe-html-pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-generic-table',
|
||||||
|
templateUrl: './generic-table.html',
|
||||||
|
imports: [NgClass, AsyncPipe, NgComponentOutlet, GenericTableSearchForm, SafeHtmlPipe],
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class GenericTable<T> implements OnInit {
|
||||||
|
|
||||||
|
|
||||||
|
@Input() config!: GenericTableConfig<T>;
|
||||||
|
public data$!: Observable<GetDataResponse<T>>;
|
||||||
|
|
||||||
|
sanitizer = inject(DomSanitizer);
|
||||||
|
parser = new DOMParser();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
|
||||||
|
this.data$ = combineLatest([
|
||||||
|
this.config.refresh$,
|
||||||
|
this.config.filter$.pipe(startWith({})),
|
||||||
|
this.config.page$.pipe(startWith(1)),
|
||||||
|
this.config.limit$.pipe(startWith(10)),
|
||||||
|
]).pipe(
|
||||||
|
switchMap(([_, filter, page, limit]) => {
|
||||||
|
const query = { ...filter, page, limit: 10 };
|
||||||
|
console.info('filter is', filter);
|
||||||
|
return this.config.dataProvider.getData({
|
||||||
|
params: {
|
||||||
|
q: filter.term ?? '',
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveValue(item: T, column: ColumnDefinition<T>, cell?: CellDefinition<T>): any {
|
||||||
|
if (cell) {
|
||||||
|
if (cell.value) {
|
||||||
|
if (typeof cell.value === 'string') {
|
||||||
|
return cell.value;
|
||||||
|
}
|
||||||
|
if (typeof cell.value === 'function') {
|
||||||
|
return cell.value(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (column.attribute) {
|
||||||
|
return item[column.attribute];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getComponentInputs(item: T | null, column: ColumnDefinition<T>, cell: CellDefinition<T>): { [key: string]: any } {
|
||||||
|
if (cell.componentInputs) {
|
||||||
|
return cell.componentInputs(item);
|
||||||
|
}
|
||||||
|
return { item }; // Default input
|
||||||
|
}
|
||||||
|
|
||||||
|
getAsHtml(str: string) {
|
||||||
|
// return this.sanitizer.bypassSecurityTrustHtml(str);
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(this.parser.parseFromString(str, 'text/html').body.innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
changePage(newPage: number): void {
|
||||||
|
if (newPage > 0) {
|
||||||
|
this.config.page$.next(newPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected searchTermChanged(searchTerm: any) {
|
||||||
|
console.info('searchterm', searchTerm);
|
||||||
|
this.config.page$.next(1);
|
||||||
|
this.config.filter$.next(searchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onPageSizeChange(value: any) {
|
||||||
|
this.config.page$.next(1);
|
||||||
|
this.config.limit$.next(+value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getValueStyleClass(columnDefinition: ColumnDefinition<T>, cellDefinition: CellDefinition<T> | boolean, item: T) {
|
||||||
|
console.info("co")
|
||||||
|
if ( (cellDefinition as any).hasOwnProperty("styleClass") ){
|
||||||
|
const def = (cellDefinition as CellDefinition<T>);
|
||||||
|
if (def && def.styleClass) {
|
||||||
|
return (columnDefinition.attribute as string) +" "+def.styleClass({definition: columnDefinition, cellDefinition: def, item});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return columnDefinition.attribute;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
<p>main-menu works!</p>
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-main-menu',
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './main-menu.html',
|
|
||||||
styleUrl: './main-menu.css',
|
|
||||||
})
|
|
||||||
export class MainMenu {
|
|
||||||
|
|
||||||
}
|
|
||||||
0
admin/src/app/components/menu/menu.css
Normal file
0
admin/src/app/components/menu/menu.css
Normal file
52
admin/src/app/components/menu/menu.html
Normal file
52
admin/src/app/components/menu/menu.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<!--
|
||||||
|
<ul class="menu p-4 w-80 min-h-full bg-base-300 text-base-content">
|
||||||
|
<li class="text-lg font-bold p-4">My App</li>
|
||||||
|
<li><a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard</a></li>
|
||||||
|
<li><a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Analytics</a></li>
|
||||||
|
<li><a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
Reports</a></li>
|
||||||
|
</ul>
|
||||||
|
-->
|
||||||
|
<ul class="menu p-4 w-80 min-h-full bg-base-300 text-base-content">
|
||||||
|
@if(title()){
|
||||||
|
<li class="text-lg font-bold p-4">{{ title() }}</li>
|
||||||
|
}
|
||||||
|
@for (item of menuItems(); track item.menuText) {
|
||||||
|
|
||||||
|
@if (isItemVisible(item)) {
|
||||||
|
<li [ngClass]="getItemStyleClass(item)">
|
||||||
|
|
||||||
|
@if (item.children && item.children.length > 0) {
|
||||||
|
<details [open]="isSubMenuActive(item)">
|
||||||
|
<summary>
|
||||||
|
<!-- Using [innerHTML] for SVG. Be cautious with non-trusted sources. -->
|
||||||
|
<span [innerHTML]="item.svgIcon | safeHtml"></span>
|
||||||
|
{{ item.menuText }}
|
||||||
|
</summary>
|
||||||
|
<!-- Recursive call to the menu component for nested items -->
|
||||||
|
<app-menu [menuItems]="item.children" [userRoles]="userRoles()"></app-menu>
|
||||||
|
</details>
|
||||||
|
} @else {
|
||||||
|
<a (click)="handleItemClick($event, item)" [class.menu-active]="isItemActive(item)">
|
||||||
|
<span [innerHTML]="item.svgIcon | safeHtml"></span>
|
||||||
|
{{ item.menuText }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
23
admin/src/app/components/menu/menu.spec.ts
Normal file
23
admin/src/app/components/menu/menu.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Menu } from './menu';
|
||||||
|
|
||||||
|
describe('Menu', () => {
|
||||||
|
let component: Menu;
|
||||||
|
let fixture: ComponentFixture<Menu>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Menu]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Menu);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
82
admin/src/app/components/menu/menu.ts
Normal file
82
admin/src/app/components/menu/menu.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Component, inject, input } from '@angular/core';
|
||||||
|
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { SafeHtmlPipe } from '../../pipes/safe-html-pipe';
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
id?: string;
|
||||||
|
menuText: string;
|
||||||
|
targetUrl?: string;
|
||||||
|
onClick?: (event: MouseEvent, item: MenuItem) => void;
|
||||||
|
roles?: string[];
|
||||||
|
children?: MenuItem[];
|
||||||
|
getStyleClass?: (item: MenuItem) => string;
|
||||||
|
isActive?: (item: MenuItem, router: Router) => boolean;
|
||||||
|
svgIcon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-menu',
|
||||||
|
imports: [CommonModule, RouterModule, SafeHtmlPipe],
|
||||||
|
templateUrl: './menu.html',
|
||||||
|
styleUrl: './menu.css',
|
||||||
|
})
|
||||||
|
export class Menu {
|
||||||
|
title = input<string>();
|
||||||
|
// Use the input() function instead of the @Input() decorator.
|
||||||
|
// input.required makes sure that the menuItems array is always passed.
|
||||||
|
menuItems = input.required<MenuItem[]>();
|
||||||
|
|
||||||
|
// You can also provide a default value with input().
|
||||||
|
// In a real app, this would likely come from an authentication service.
|
||||||
|
userRoles = input<string[]>(['admin', 'user']);
|
||||||
|
|
||||||
|
// Use inject() for dependency injection instead of the constructor.
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
isItemVisible(item: MenuItem): boolean {
|
||||||
|
if (!item.roles || item.roles.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Access the value of the signal by calling it as a function: this.userRoles()
|
||||||
|
return item.roles.some(role => this.userRoles().includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleItemClick(event: MouseEvent, item: MenuItem): void {
|
||||||
|
if (item.onClick) {
|
||||||
|
item.onClick(event, item);
|
||||||
|
} else if (item.targetUrl) {
|
||||||
|
this.router.navigate([item.targetUrl]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemStyleClass(item: MenuItem): string {
|
||||||
|
return item.getStyleClass ? item.getStyleClass(item) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
isItemActive(item: MenuItem): boolean {
|
||||||
|
if (item.isActive) {
|
||||||
|
return item.isActive(item, this.router);
|
||||||
|
}
|
||||||
|
if (item.targetUrl) {
|
||||||
|
// router.isActive is a boolean check, perfect for this use case.
|
||||||
|
return this.router.isActive(item.targetUrl, {
|
||||||
|
paths: 'exact',
|
||||||
|
queryParams: 'exact',
|
||||||
|
fragment: 'ignored',
|
||||||
|
matrixParams: 'ignored'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to determine if a dropdown should be open by default
|
||||||
|
isSubMenuActive(item: MenuItem): boolean {
|
||||||
|
if (!item.children) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Recursively check if any child or grandchild is active
|
||||||
|
return item.children.some(child => this.isItemActive(child) || this.isSubMenuActive(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
<app-generic-table [config]="listConfig"></app-generic-table>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { GenericListExample } from './generic-list-example';
|
||||||
|
|
||||||
|
describe('GenericListExample', () => {
|
||||||
|
let component: GenericListExample;
|
||||||
|
let fixture: ComponentFixture<GenericListExample>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [GenericListExample]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GenericListExample);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { Product } from '../../features/products/models/product.model';
|
||||||
|
import { ColumnDefinition } from '../../components/generic-table/column-definition.interface';
|
||||||
|
import { ProductDataProvider } from '../product-data-provider.service';
|
||||||
|
import { GenericTable } from '../../components/generic-table/generic-table';
|
||||||
|
import { GenericTableConfig } from '../../components/generic-table/generic-table.config';
|
||||||
|
import { ActionDefinition, GenericActionColumn } from '../../components/generic-action-column/generic-action-column';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-generic-list-example',
|
||||||
|
imports: [
|
||||||
|
GenericTable,
|
||||||
|
],
|
||||||
|
templateUrl: './generic-list-example.html',
|
||||||
|
styleUrl: './generic-list-example.css',
|
||||||
|
})
|
||||||
|
export class GenericListExample implements OnInit {
|
||||||
|
router = inject(Router);
|
||||||
|
listConfig!: GenericTableConfig<Product>;
|
||||||
|
|
||||||
|
productDataProvider = inject(ProductDataProvider);
|
||||||
|
|
||||||
|
private refresh$ = new BehaviorSubject<void>(undefined);
|
||||||
|
private filter$ = new BehaviorSubject<any>({});
|
||||||
|
private page$ = new BehaviorSubject<number>(1);
|
||||||
|
private limit$ = new BehaviorSubject<number>(10);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const actionHandler = (action: ActionDefinition<Product>, item: Product) => {
|
||||||
|
switch (action.action) {
|
||||||
|
case 'view':
|
||||||
|
this.router.navigate(['/products/' + item?.id + '']);
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
this.router.navigate(['/products/' + item?.id + '/edit']);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
alert('delete');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.listConfig = {
|
||||||
|
refresh$: this.refresh$,
|
||||||
|
filter$: this.filter$,
|
||||||
|
page$: this.page$,
|
||||||
|
limit$: this.limit$,
|
||||||
|
dataProvider: this.productDataProvider,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
headerCell: true,
|
||||||
|
attribute: 'name',
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'price',
|
||||||
|
headerCell: {
|
||||||
|
value: '<h1>MyPrice</h1>',
|
||||||
|
},
|
||||||
|
valueCell: {
|
||||||
|
value: item => {
|
||||||
|
return item?.price ? Math.floor(item.price) : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'available',
|
||||||
|
headerCell: true ,
|
||||||
|
valueCell: {
|
||||||
|
value: item => {
|
||||||
|
return item?.is_available ? 'yes' : 'no';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'actions',
|
||||||
|
headerCell: { value: 'Actions' },
|
||||||
|
valueCell: {
|
||||||
|
component: GenericActionColumn,
|
||||||
|
componentInputs: item => {
|
||||||
|
return {
|
||||||
|
item: item,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
handler: actionHandler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'edit',
|
||||||
|
handler: actionHandler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'delete',
|
||||||
|
handler: actionHandler,
|
||||||
|
},
|
||||||
|
] as ActionDefinition<Product>[],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as ColumnDefinition<Product>[],
|
||||||
|
tableCssClass: 'product-list-container',
|
||||||
|
rowCssClass: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
admin/src/app/examples/product-data-provider.service.ts
Normal file
18
admin/src/app/examples/product-data-provider.service.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { DataProvider, GetDataOptions, GetDataResponse } from '../components/generic-table/data-provider.interface';
|
||||||
|
import { Product } from '../features/products/models/product.model';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import { ProductService } from '../features/products/services/product.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ProductDataProvider implements DataProvider<Product> {
|
||||||
|
private productService = inject(ProductService);
|
||||||
|
getData(options?: GetDataOptions): Observable<GetDataResponse<Product>> {
|
||||||
|
return this.productService.find({
|
||||||
|
...(options?.params ?? {})
|
||||||
|
}).pipe(map((res) => {return {'data': res}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
<!-- dvbooking-cli/src/templates/angular/details.component.html.tpl -->
|
||||||
|
|
||||||
|
<!-- Generated by the CLI -->
|
||||||
|
<div class="p-4 md:p-8">
|
||||||
|
<ng-container *ngIf="booking$ | async as booking; else loading">
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-3xl">Booking Details</h2>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto mt-4">
|
||||||
|
<table class="table w-full">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>id</th>
|
||||||
|
<td>{{ booking.id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>event_id</th>
|
||||||
|
<td>{{ booking.event_id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>occurrence_start_time</th>
|
||||||
|
<td>{{ booking.occurrence_start_time }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>user_id</th>
|
||||||
|
<td>{{ booking.user_id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>notes</th>
|
||||||
|
<td>{{ booking.notes }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>reserved_seats_count</th>
|
||||||
|
<td>{{ booking.reserved_seats_count }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>created_at</th>
|
||||||
|
<td>{{ booking.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>updated_at</th>
|
||||||
|
<td>{{ booking.updated_at }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>canceled_at</th>
|
||||||
|
<td>{{ booking.canceled_at }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>canceled_reason</th>
|
||||||
|
<td>{{ booking.canceled_reason }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>canceled_by_user_id</th>
|
||||||
|
<td>{{ booking.canceled_by_user_id }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-6">
|
||||||
|
<a routerLink="/bookings" class="btn btn-secondary">Back to List</a>
|
||||||
|
<a routerLink="/bookings/{{ booking.id }}/edit" class="btn btn-primary">Edit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #loading>
|
||||||
|
<div class="text-center p-8">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
// dvbooking-cli/src/templates/angular/details.component.ts.tpl
|
||||||
|
|
||||||
|
// Generated by the CLI
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { switchMap } from 'rxjs/operators';
|
||||||
|
import { Booking } from '../../models/booking.model';
|
||||||
|
import { BookingService } from '../../services/booking.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-booking-details',
|
||||||
|
templateUrl: './booking-details.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterModule],
|
||||||
|
})
|
||||||
|
export class BookingDetailsComponent implements OnInit {
|
||||||
|
booking$!: Observable<Booking>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private bookingService: BookingService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.booking$ = this.route.params.pipe(
|
||||||
|
switchMap(params => {
|
||||||
|
const id = params['id'];
|
||||||
|
return this.bookingService.findOne(id);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<!-- dvbooking-cli/src/templates/angular/filter.component.html.tpl -->
|
||||||
|
<!-- Generated by the CLI -->
|
||||||
|
<form [formGroup]="filterForm" class="p-4 bg-base-200 rounded-lg shadow">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">notes</span></label>
|
||||||
|
<input type="text" formControlName="notes" class="input input-bordered w-full" placeholder="Filter by notes" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">canceled_reason</span></label>
|
||||||
|
<input type="text" formControlName="canceled_reason" class="input input-bordered w-full" placeholder="Filter by canceled_reason" /></div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<button type="button" (click)="reset()" class="btn btn-secondary">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
// dvbooking-cli/src/templates/angular/filter.component.ts.tpl
|
||||||
|
|
||||||
|
// Generated by the CLI
|
||||||
|
import { Component, EventEmitter, Output } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-booking-filter',
|
||||||
|
templateUrl: './booking-filter.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule]
|
||||||
|
})
|
||||||
|
export class BookingFilterComponent {
|
||||||
|
@Output() filterChanged = new EventEmitter<any>();
|
||||||
|
filterForm: FormGroup;
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) {
|
||||||
|
this.filterForm = this.fb.group({
|
||||||
|
notes: [''],
|
||||||
|
canceled_reason: ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
this.filterForm.valueChanges.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
distinctUntilChanged()
|
||||||
|
).subscribe(values => {
|
||||||
|
const cleanFilter = Object.fromEntries(
|
||||||
|
Object.entries(values).filter(([_, v]) => v != null && v !== '')
|
||||||
|
);
|
||||||
|
this.filterChanged.emit(cleanFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.filterForm.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
<!-- dvbooking-cli/src/templates/angular/form.component.html.tpl -->
|
||||||
|
|
||||||
|
<!-- Generated by the CLI -->
|
||||||
|
<div class="p-4 md:p-8">
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-3xl">
|
||||||
|
{{ isEditMode ? 'Edit' : 'Create' }} Booking
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-4 mt-4">
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">event_id</span></label>
|
||||||
|
<input type="number" formControlName="event_id" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">occurrence_start_time</span></label>
|
||||||
|
<input type="text" formControlName="occurrence_start_time" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">user_id</span></label>
|
||||||
|
<input type="number" formControlName="user_id" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">notes</span></label>
|
||||||
|
<input type="text" formControlName="notes" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">reserved_seats_count</span></label>
|
||||||
|
<input type="number" formControlName="reserved_seats_count" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">created_at</span></label>
|
||||||
|
<input type="text" formControlName="created_at" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">updated_at</span></label>
|
||||||
|
<input type="text" formControlName="updated_at" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">canceled_at</span></label>
|
||||||
|
<input type="text" formControlName="canceled_at" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">canceled_reason</span></label>
|
||||||
|
<input type="text" formControlName="canceled_reason" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">canceled_by_user_id</span></label>
|
||||||
|
<input type="number" formControlName="canceled_by_user_id" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-6">
|
||||||
|
<a routerLink="/bookings" class="btn btn-ghost">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary" [disabled]="form.invalid">
|
||||||
|
{{ isEditMode ? 'Update' : 'Create' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
// dvbooking-cli/src/templates/angular/form.component.ts.tpl
|
||||||
|
|
||||||
|
// Generated by the CLI
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { switchMap, tap } from 'rxjs/operators';
|
||||||
|
import { Booking } from '../../models/booking.model';
|
||||||
|
import { BookingService } from '../../services/booking.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-booking-form',
|
||||||
|
templateUrl: './booking-form.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterModule],
|
||||||
|
})
|
||||||
|
export class BookingFormComponent implements OnInit {
|
||||||
|
form: FormGroup;
|
||||||
|
isEditMode = false;
|
||||||
|
id: number | null = null;
|
||||||
|
|
||||||
|
private numericFields = ["event_id","user_id","reserved_seats_count","canceled_by_user_id"];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private bookingService: BookingService
|
||||||
|
) {
|
||||||
|
this.form = this.fb.group({
|
||||||
|
event_id: [null],
|
||||||
|
occurrence_start_time: [null],
|
||||||
|
user_id: [null],
|
||||||
|
notes: [null],
|
||||||
|
reserved_seats_count: [null],
|
||||||
|
created_at: [null],
|
||||||
|
updated_at: [null],
|
||||||
|
canceled_at: [null],
|
||||||
|
canceled_reason: [null],
|
||||||
|
canceled_by_user_id: [null]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.params.pipe(
|
||||||
|
tap(params => {
|
||||||
|
if (params['id']) {
|
||||||
|
this.isEditMode = true;
|
||||||
|
this.id = +params['id'];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
switchMap(() => {
|
||||||
|
if (this.isEditMode && this.id) {
|
||||||
|
return this.bookingService.findOne(this.id);
|
||||||
|
}
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
).subscribe(booking => {
|
||||||
|
if (booking) {
|
||||||
|
this.form.patchValue(booking);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.form.invalid) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { ...this.form.value };
|
||||||
|
|
||||||
|
for (const field of this.numericFields) {
|
||||||
|
if (payload[field] != null && payload[field] !== '') {
|
||||||
|
payload[field] = parseFloat(payload[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let action$: Observable<Booking>;
|
||||||
|
|
||||||
|
if (this.isEditMode && this.id) {
|
||||||
|
action$ = this.bookingService.update(this.id, payload);
|
||||||
|
} else {
|
||||||
|
action$ = this.bookingService.create(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
action$.subscribe({
|
||||||
|
next: () => this.router.navigate(['/bookings']),
|
||||||
|
error: (err) => console.error('Failed to save booking', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
<!-- dvbooking-cli/src/templates/angular/list.component.html.tpl -->
|
||||||
|
|
||||||
|
<!-- Generated by the CLI -->
|
||||||
|
<div class="p-4 md:p-8 space-y-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-3xl font-bold">Bookings</h1>
|
||||||
|
<a routerLink="/bookings/new" class="btn btn-primary">Create New</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-booking-filter (filterChanged)="onFilterChanged($event)"></app-booking-filter>
|
||||||
|
|
||||||
|
<ng-container *ngIf="paginatedResponse$ | async as response; else loading">
|
||||||
|
<div class="overflow-x-auto bg-base-100 rounded-lg shadow">
|
||||||
|
<table class="table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>id</th>
|
||||||
|
<th>event_id</th>
|
||||||
|
<th>occurrence_start_time</th>
|
||||||
|
<th>user_id</th>
|
||||||
|
<th>notes</th>
|
||||||
|
<th>reserved_seats_count</th>
|
||||||
|
<th>created_at</th>
|
||||||
|
<th>updated_at</th>
|
||||||
|
<th>canceled_at</th>
|
||||||
|
<th>canceled_reason</th>
|
||||||
|
<th>canceled_by_user_id</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let item of response.data" class="hover">
|
||||||
|
<td>{{ item.id }}</td>
|
||||||
|
<td>{{ item.event_id }}</td>
|
||||||
|
<td>{{ item.occurrence_start_time }}</td>
|
||||||
|
<td>{{ item.user_id }}</td>
|
||||||
|
<td>{{ item.notes }}</td>
|
||||||
|
<td>{{ item.reserved_seats_count }}</td>
|
||||||
|
<td>{{ item.created_at }}</td>
|
||||||
|
<td>{{ item.updated_at }}</td>
|
||||||
|
<td>{{ item.canceled_at }}</td>
|
||||||
|
<td>{{ item.canceled_reason }}</td>
|
||||||
|
<td>{{ item.canceled_by_user_id }}</td>
|
||||||
|
<td class="text-right space-x-2">
|
||||||
|
<a [routerLink]="['/bookings', item.id]" class="btn btn-sm btn-ghost">View</a>
|
||||||
|
<a [routerLink]="['/bookings', item.id, 'edit']" class="btn btn-sm btn-ghost">Edit</a>
|
||||||
|
<button (click)="deleteItem(item.id)" class="btn btn-sm btn-error btn-ghost">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="response.data.length === 0">
|
||||||
|
<td colspan="12" class="text-center">No bookings found.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Controls -->
|
||||||
|
<div *ngIf="response.meta.totalPages > 1" class="flex justify-center mt-4">
|
||||||
|
<div class="join">
|
||||||
|
<button
|
||||||
|
class="join-item btn"
|
||||||
|
(click)="changePage(response.meta.currentPage - 1)"
|
||||||
|
[disabled]="response.meta.currentPage === 1">«</button>
|
||||||
|
<button class="join-item btn">Page {{ response.meta.currentPage }} of {{ response.meta.totalPages }}</button>
|
||||||
|
<button
|
||||||
|
class="join-item btn"
|
||||||
|
(click)="changePage(response.meta.currentPage + 1)"
|
||||||
|
[disabled]="response.meta.currentPage === response.meta.totalPages">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #loading>
|
||||||
|
<div class="text-center p-8">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
// dvbooking-cli/src/templates/angular/list.component.ts.tpl
|
||||||
|
|
||||||
|
// Generated by the CLI
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { BehaviorSubject, Observable, combineLatest } from 'rxjs';
|
||||||
|
import { switchMap, startWith } from 'rxjs/operators';
|
||||||
|
import { Booking } from '../../models/booking.model';
|
||||||
|
import { BookingService } from '../../services/booking.service';
|
||||||
|
import { BookingFilterComponent } from '../booking-filter/booking-filter.component';
|
||||||
|
import { PaginatedResponse } from '../../../../../types';
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-booking-list',
|
||||||
|
templateUrl: './booking-list.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule,RouterModule, BookingFilterComponent],
|
||||||
|
})
|
||||||
|
export class BookingListComponent implements OnInit {
|
||||||
|
|
||||||
|
private refresh$ = new BehaviorSubject<void>(undefined);
|
||||||
|
private filter$ = new BehaviorSubject<any>({});
|
||||||
|
private page$ = new BehaviorSubject<number>(1);
|
||||||
|
|
||||||
|
paginatedResponse$!: Observable<PaginatedResponse<Booking>>;
|
||||||
|
|
||||||
|
constructor(private bookingService: BookingService) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.paginatedResponse$ = combineLatest([
|
||||||
|
this.refresh$,
|
||||||
|
this.filter$.pipe(startWith({})),
|
||||||
|
this.page$.pipe(startWith(1))
|
||||||
|
]).pipe(
|
||||||
|
switchMap(([_, filter, page]) => {
|
||||||
|
const query = { ...filter, page, limit: 10 };
|
||||||
|
return this.bookingService.find(query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChanged(filter: any): void {
|
||||||
|
this.page$.next(1);
|
||||||
|
this.filter$.next(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
changePage(newPage: number): void {
|
||||||
|
if (newPage > 0) {
|
||||||
|
this.page$.next(newPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteItem(id: number): void {
|
||||||
|
if (confirm('Are you sure you want to delete this item?')) {
|
||||||
|
this.bookingService.remove(id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
console.log(`Item with ID ${id} deleted successfully.`);
|
||||||
|
this.refresh$.next();
|
||||||
|
},
|
||||||
|
// --- THIS IS THE FIX ---
|
||||||
|
// Explicitly type 'err' to satisfy strict TypeScript rules.
|
||||||
|
error: (err: any) => {
|
||||||
|
console.error(`Error deleting item with ID ${id}:`, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
// dvbooking-cli/src/templates/angular-generic/data-provider.service.ts.tpl
|
||||||
|
|
||||||
|
// Generated by the CLI
|
||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { DataProvider, GetDataOptions, GetDataResponse } from '../../../../components/generic-table/data-provider.interface';
|
||||||
|
import { Booking } from '../../models/booking.model';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import { BookingService } from '../../services/booking.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class BookingDataProvider implements DataProvider<Booking> {
|
||||||
|
private bookingService = inject(BookingService);
|
||||||
|
|
||||||
|
getData(options?: GetDataOptions): Observable<GetDataResponse<Booking>> {
|
||||||
|
const {q,page,limit} = options?.params ?? {};
|
||||||
|
// The generic table's params are compatible with our NestJS Query DTO
|
||||||
|
return this.bookingService.search(q ?? '',page,limit, ).pipe(
|
||||||
|
map((res) => {
|
||||||
|
// Adapt the paginated response to the GetDataResponse format
|
||||||
|
return { data: res };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<!-- dvbooking-cli/src/templates/angular-generic/table.component.html.tpl -->
|
||||||
|
|
||||||
|
<!-- Generated by the CLI -->
|
||||||
|
<div class="p-4 md:p-8 space-y-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-3xl font-bold">Bookings (Generic Table)</h1>
|
||||||
|
<a routerLink="/bookings/new" class="btn btn-primary">Create New</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-generic-table [config]="tableConfig"></app-generic-table>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
// dvbooking-cli/src/templates/angular-generic/table.component.ts.tpl
|
||||||
|
|
||||||
|
// Generated by the CLI
|
||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { Booking } from '../../models/booking.model';
|
||||||
|
import { BookingDataProvider } from './booking-data-provider.service';
|
||||||
|
import { ColumnDefinition } from '../../../../components/generic-table/column-definition.interface';
|
||||||
|
import { GenericTable } from '../../../../components/generic-table/generic-table';
|
||||||
|
import { GenericTableConfig } from '../../../../components/generic-table/generic-table.config';
|
||||||
|
import {
|
||||||
|
ActionDefinition,
|
||||||
|
GenericActionColumn,
|
||||||
|
} from '../../../../components/generic-action-column/generic-action-column';
|
||||||
|
import { BookingService } from '../../services/booking.service';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-booking-table',
|
||||||
|
standalone: true,
|
||||||
|
imports: [GenericTable, RouterModule],
|
||||||
|
templateUrl: './booking-table.component.html',
|
||||||
|
})
|
||||||
|
export class BookingTableComponent implements OnInit {
|
||||||
|
|
||||||
|
private refresh$ = new BehaviorSubject<void>(undefined);
|
||||||
|
private filter$ = new BehaviorSubject<any>({});
|
||||||
|
private page$ = new BehaviorSubject<number>(1);
|
||||||
|
private limit$ = new BehaviorSubject<number>(10);
|
||||||
|
|
||||||
|
router = inject(Router);
|
||||||
|
tableConfig!: GenericTableConfig<Booking>;
|
||||||
|
|
||||||
|
bookingDataProvider = inject(BookingDataProvider);
|
||||||
|
bookingService = inject(BookingService);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const actionHandler = (action: ActionDefinition<Booking>, item: Booking) => {
|
||||||
|
switch (action.action) {
|
||||||
|
case 'view':
|
||||||
|
this.router.navigate(['/bookings', item?.id]);
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
this.router.navigate(['/bookings', item?.id, 'edit']);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
this.deleteItem(item.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tableConfig = {
|
||||||
|
refresh$: this.refresh$,
|
||||||
|
filter$: this.filter$,
|
||||||
|
page$: this.page$,
|
||||||
|
limit$: this.limit$,
|
||||||
|
dataProvider: this.bookingDataProvider,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
attribute: 'event_id',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'occurrence_start_time',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'user_id',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'notes',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'reserved_seats_count',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'created_at',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'updated_at',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'canceled_at',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'canceled_reason',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'canceled_by_user_id',
|
||||||
|
headerCell: true,
|
||||||
|
valueCell: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'actions',
|
||||||
|
headerCell: { value: 'Actions' },
|
||||||
|
valueCell: {
|
||||||
|
component: GenericActionColumn,
|
||||||
|
componentInputs: item => ({
|
||||||
|
item: item,
|
||||||
|
actions: [
|
||||||
|
{ action: 'view', handler: actionHandler },
|
||||||
|
{ action: 'edit', handler: actionHandler },
|
||||||
|
{ action: 'delete', handler: actionHandler },
|
||||||
|
] as ActionDefinition<Booking>[],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as ColumnDefinition<Booking>[],
|
||||||
|
tableCssClass: 'booking-table-container',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteItem(id: number): void {
|
||||||
|
if (confirm('Are you sure you want to delete this item?')) {
|
||||||
|
this.bookingService.remove(id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
console.log(`Item with ID ${id} deleted successfully.`);
|
||||||
|
this.refresh$.next();
|
||||||
|
},
|
||||||
|
// --- THIS IS THE FIX ---
|
||||||
|
// Explicitly type 'err' to satisfy strict TypeScript rules.
|
||||||
|
error: (err: any) => {
|
||||||
|
console.error(`Error deleting item with ID ${id}:`, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
16
admin/src/app/features/bookings/models/booking.model.ts
Normal file
16
admin/src/app/features/bookings/models/booking.model.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// dvbooking-cli/src/templates/angular/model.ts.tpl
|
||||||
|
|
||||||
|
// Generated by the CLI
|
||||||
|
export interface Booking {
|
||||||
|
id: number;
|
||||||
|
event_id: number;
|
||||||
|
occurrence_start_time: Date;
|
||||||
|
user_id: number;
|
||||||
|
notes: string;
|
||||||
|
reserved_seats_count: number;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
canceled_at: Date;
|
||||||
|
canceled_reason: string;
|
||||||
|
canceled_by_user_id: number;
|
||||||
|
}
|
||||||
85
admin/src/app/features/bookings/services/booking.service.ts
Normal file
85
admin/src/app/features/bookings/services/booking.service.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// dvbooking-cli/src/templates/angular/service.ts.tpl
|
||||||
|
|
||||||
|
// Generated by the CLI
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Booking } from '../models/booking.model';
|
||||||
|
import { ConfigurationService } from '../../../services/configuration.service';
|
||||||
|
import { PaginatedResponse } from '../../../../types';
|
||||||
|
|
||||||
|
|
||||||
|
export interface SearchResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class BookingService {
|
||||||
|
private readonly apiUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private http: HttpClient,
|
||||||
|
private configService: ConfigurationService
|
||||||
|
) {
|
||||||
|
this.apiUrl = `${this.configService.getApiUrl()}/bookings`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find records with pagination and filtering.
|
||||||
|
*/
|
||||||
|
public find(filter: Record<string, any>): Observable<PaginatedResponse<Booking>> {
|
||||||
|
// --- THIS IS THE FIX ---
|
||||||
|
// The incorrect line: .filter(([_, v]) for v != null)
|
||||||
|
// is now correctly written with an arrow function.
|
||||||
|
const cleanFilter = Object.fromEntries(
|
||||||
|
Object.entries(filter).filter(([_, v]) => v != null)
|
||||||
|
);
|
||||||
|
// --- END OF FIX ---
|
||||||
|
|
||||||
|
const params = new HttpParams({ fromObject: cleanFilter });
|
||||||
|
return this.http.get<PaginatedResponse<Booking>>(this.apiUrl, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search across multiple fields with a single term.
|
||||||
|
* @param term The search term (q).
|
||||||
|
*/
|
||||||
|
public search(term: string, page: number = 1, limit: number = 10): Observable<PaginatedResponse<Booking>> {
|
||||||
|
const params = new HttpParams()
|
||||||
|
.set('q', term)
|
||||||
|
.set('page', page.toString())
|
||||||
|
.set('limit', limit.toString());
|
||||||
|
return this.http.get<PaginatedResponse<Booking>>(`${this.apiUrl}/search`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a single record by its ID.
|
||||||
|
*/
|
||||||
|
public findOne(id: number): Observable<Booking> {
|
||||||
|
return this.http.get<Booking>(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new record.
|
||||||
|
*/
|
||||||
|
public create(data: Omit<Booking, 'id'>): Observable<Booking> {
|
||||||
|
return this.http.post<Booking>(this.apiUrl, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing record.
|
||||||
|
*/
|
||||||
|
public update(id: number, data: Partial<Omit<Booking, 'id'>>): Observable<Booking> {
|
||||||
|
return this.http.patch<Booking>(`${this.apiUrl}/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a record by its ID.
|
||||||
|
*/
|
||||||
|
public remove(id: number): Observable<any> {
|
||||||
|
return this.http.delete(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<div>
|
||||||
|
<h1>Naptár</h1>
|
||||||
|
<!-- <div>-->
|
||||||
|
<!-- <input type="text" name="startHour" id="starthour" #startHour>-->
|
||||||
|
<!-- <a class="btn" (click)="addEvent($event)">add</a>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<full-calendar #calendar [options]="calendarOptions"></full-calendar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (workflow() == 'event-create') {
|
||||||
|
<rs-daisy-modal [isOpen]="true" (closeClick)="closeDialog()" [modalBoxStyleClass]="'max-w-none w-2xl'">
|
||||||
|
<app-create-event-form (ready)="closeDialog()" [date]="selectedDate()"
|
||||||
|
[id]="undefined"></app-create-event-form>
|
||||||
|
</rs-daisy-modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (workflow() == 'event-dashboard' && selectedEvent()) {
|
||||||
|
<rs-daisy-modal [isOpen]="true" (closeClick)="closeDialog()" [modalBoxStyleClass]="'max-w-none w-2xl'">
|
||||||
|
<app-single-event-dashboard [event]="selectedEvent()"
|
||||||
|
(action)="onDashboardAction($event)"
|
||||||
|
></app-single-event-dashboard>
|
||||||
|
</rs-daisy-modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (dialogDefinition of dialogs; track dialogDefinition) {
|
||||||
|
|
||||||
|
@if (dialogDefinition.isRendered()) {
|
||||||
|
<rs-daisy-modal [isOpen]="true" (closeClick)="closeDialog()" [modalBoxStyleClass]="'max-w-none w-2xl'">
|
||||||
|
<ng-container
|
||||||
|
*ngComponentOutlet="dialogDefinition.component; inputs: dialogDefinition.componentInputs ? dialogDefinition.componentInputs() : {}; "
|
||||||
|
></ng-container>
|
||||||
|
</rs-daisy-modal>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,224 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
Type,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FullCalendarComponent, FullCalendarModule } from '@fullcalendar/angular';
|
||||||
|
import { CalendarOptions, EventInput } from '@fullcalendar/core';
|
||||||
|
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
|
import listPlugin from '@fullcalendar/list';
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
|
import { Modal } from '@rschneider/ng-daisyui';
|
||||||
|
import { CreateEventForm } from '../create-event-form/create-event-form';
|
||||||
|
import { CalendarService } from '../../services/calendar.service';
|
||||||
|
import { CalendarEventDto } from '../../models/events-in-range-dto.model';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { SingleEventDashboard } from './single-event-dashboard/single-event-dashboard';
|
||||||
|
import { CommonModule, NgComponentOutlet } from '@angular/common';
|
||||||
|
import {
|
||||||
|
SingleEventDashboardEventDelete,
|
||||||
|
} from './single-event-dashboard-event-delete/single-event-dashboard-event-delete';
|
||||||
|
import {
|
||||||
|
SingleEventDashboardEventCancel,
|
||||||
|
} from './single-event-dashboard-event-cancel/single-event-dashboard-event-cancel';
|
||||||
|
import { SingleEventDashboardEventEdit } from './single-event-dashboard-event-edit/single-event-dashboard-event-edit';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-calendar-view',
|
||||||
|
imports: [FullCalendarModule, CommonModule, Modal, NgComponentOutlet, CreateEventForm, SingleEventDashboard],
|
||||||
|
templateUrl: './calendar-view.html',
|
||||||
|
styleUrl: './calendar-view.css',
|
||||||
|
})
|
||||||
|
export class CalendarView {
|
||||||
|
|
||||||
|
@ViewChild('startHour') startHour!: ElementRef;
|
||||||
|
@ViewChild('calendar') calendarComponent: FullCalendarComponent | undefined;
|
||||||
|
|
||||||
|
calendarService = inject(CalendarService);
|
||||||
|
|
||||||
|
workflow = signal<string>('');
|
||||||
|
selectedDate = signal<Date | undefined>(undefined);
|
||||||
|
selectedEvent = signal<CalendarEventDto | undefined>(undefined);
|
||||||
|
|
||||||
|
calendarOptions: CalendarOptions;
|
||||||
|
dialogs: DialogConfig[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
|
||||||
|
this.calendarOptions = {
|
||||||
|
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
|
||||||
|
initialView: 'dayGridMonth',
|
||||||
|
weekends: true,
|
||||||
|
firstDay: 1,
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next,today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,dayGridWeek,dayGridDay,timeGridWeek,timeGridDay,listWeek',
|
||||||
|
|
||||||
|
},
|
||||||
|
events: this.fetchEvents.bind(this), // Bind the context
|
||||||
|
|
||||||
|
eventClick: (info) => {
|
||||||
|
this.selectedEvent.set(info.event.extendedProps['event']);
|
||||||
|
this.workflow.set('event-dashboard');
|
||||||
|
this.selectedDate.set(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
dateClick: (info) => {
|
||||||
|
this.workflow.set('event-create');
|
||||||
|
this.selectedDate.set(info.date);
|
||||||
|
this.selectedEvent.set(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dialogs = [
|
||||||
|
{
|
||||||
|
component: SingleEventDashboardEventDelete,
|
||||||
|
isRendered: () => this.workflow() == 'event-delete',
|
||||||
|
// isRendered: () => true,
|
||||||
|
closeClick: () => this.closeDialog(),
|
||||||
|
modalBoxStyleClass: 'max-w-none w-2xl',
|
||||||
|
componentInputs: () => {
|
||||||
|
return {
|
||||||
|
'event': this.selectedEvent(),
|
||||||
|
'onAction': this.handleAction,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
component: SingleEventDashboardEventCancel,
|
||||||
|
isRendered: () => this.workflow() == 'event-cancel',
|
||||||
|
// isRendered: () => true,
|
||||||
|
closeClick: () => this.closeDialog(),
|
||||||
|
modalBoxStyleClass: 'max-w-none w-2xl',
|
||||||
|
componentInputs: () => {
|
||||||
|
return {
|
||||||
|
'event': this.selectedEvent(),
|
||||||
|
'onAction': this.handleAction,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: SingleEventDashboardEventEdit,
|
||||||
|
isRendered: () => this.workflow() == 'event-edit',
|
||||||
|
// isRendered: () => true,
|
||||||
|
closeClick: () => this.closeDialog(),
|
||||||
|
modalBoxStyleClass: 'max-w-none w-2xl',
|
||||||
|
componentInputs: () => {
|
||||||
|
return {
|
||||||
|
'event': this.selectedEvent(),
|
||||||
|
'onAction': this.handleAction,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchEvents(fetchInfo: any, successCallback: (events: EventInput[]) => void, failureCallback: (error: any) => void): void {
|
||||||
|
|
||||||
|
console.info('fetching events', fetchInfo);
|
||||||
|
const start = fetchInfo.start;
|
||||||
|
const end = fetchInfo.end;
|
||||||
|
this.calendarService.getEventsInRange({
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map(value => {
|
||||||
|
const events: EventInput[] = value.map((model) => {
|
||||||
|
const calendarEvent: EventInput = {
|
||||||
|
start: new Date(model.startTime),
|
||||||
|
// end: model.end_time,
|
||||||
|
title: model.title,
|
||||||
|
extendedProps: {
|
||||||
|
event: model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (model.eventType) {
|
||||||
|
calendarEvent.borderColor = model.eventType.color;
|
||||||
|
}
|
||||||
|
// calendarEvent.backgroundColor = "#00ff00"
|
||||||
|
return calendarEvent;
|
||||||
|
});
|
||||||
|
return events;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (events) => {
|
||||||
|
console.info('calendar events', events);
|
||||||
|
successCallback(events);
|
||||||
|
}
|
||||||
|
,
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Error fetching events', error);
|
||||||
|
failureCallback(error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// protected addEvent($event: PointerEvent) {
|
||||||
|
// let hourStr = this.startHour.nativeElement.value;
|
||||||
|
// const hour = parseInt(hourStr, 10);
|
||||||
|
// const date = new Date();
|
||||||
|
// const start = new Date(date.getTime());
|
||||||
|
// start.setHours(hour, 0, 0);
|
||||||
|
// const end = new Date(date.getTime());
|
||||||
|
// // end.setHours(3,0,0)
|
||||||
|
//
|
||||||
|
// this.calendarComponent?.getApi().addEvent({
|
||||||
|
// title: 'Event at ' + hour,
|
||||||
|
// start,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
this.workflow.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
onDashboardAction(action: string) {
|
||||||
|
console.info('dashboard event', action);
|
||||||
|
switch (action) {
|
||||||
|
case 'event_delete':
|
||||||
|
this.workflow.set('event-delete');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'event_cancel':
|
||||||
|
this.workflow.set('event-cancel');
|
||||||
|
break;
|
||||||
|
case 'event_edit':
|
||||||
|
this.workflow.set('event-edit');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// This function is passed into the child
|
||||||
|
handleAction = (msg: string) => {
|
||||||
|
console.log('Parent received:', msg);
|
||||||
|
if (msg == 'close') {
|
||||||
|
this.closeDialog();
|
||||||
|
} else if ( msg == 'save-event-success'){
|
||||||
|
this.closeDialog();
|
||||||
|
this.calendarComponent?.getApi().refetchEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogConfig {
|
||||||
|
component: Type<any>;
|
||||||
|
componentInputs?: () => { [key: string]: any };
|
||||||
|
closeClick: () => void,
|
||||||
|
modalBoxStyleClass: string
|
||||||
|
isRendered: () => boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<div class="card bg-base-100 hover:bg-base-300 shadow-sm h-full" [class]="">
|
||||||
|
<div class="card-body items-center ">
|
||||||
|
<h2 class="card-title text-center">{{ title() }}</h2>
|
||||||
|
<p class="text-center">{{ description() }}</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button class="btn btn-primary" (click)="onClick();">
|
||||||
|
<!-- Using the new @if control flow -->
|
||||||
|
@if (svgIcon()) {
|
||||||
|
<span [innerHTML]="svgIcon() | safeHtml"></span>
|
||||||
|
}
|
||||||
|
{{ buttonTitle() }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { SingleEventDashboardCard } from './single-event-dashboard-card';
|
||||||
|
|
||||||
|
describe('SingleEventDashboardCard', () => {
|
||||||
|
let component: SingleEventDashboardCard;
|
||||||
|
let fixture: ComponentFixture<SingleEventDashboardCard>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SingleEventDashboardCard]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SingleEventDashboardCard);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { Component, computed, HostBinding, input, output } from '@angular/core';
|
||||||
|
import { SafeHtmlPipe } from '../../../../../pipes/safe-html-pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-single-event-dashboard-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
SafeHtmlPipe,
|
||||||
|
], // CommonModule is no longer needed
|
||||||
|
templateUrl: './single-event-dashboard-card.html',
|
||||||
|
styleUrl: './single-event-dashboard-card.css',
|
||||||
|
})
|
||||||
|
export class SingleEventDashboardCard {
|
||||||
|
title = input<string>();
|
||||||
|
description = input<string>();
|
||||||
|
buttonTitle = input<string>();
|
||||||
|
svgIcon = input<string>();
|
||||||
|
onAction = output<void>();
|
||||||
|
|
||||||
|
|
||||||
|
// 1. Define the Input Signal to receive class names as a string or array
|
||||||
|
// Example: 'grow-2 align-center'
|
||||||
|
public styleClass = input<string | null>(null);
|
||||||
|
|
||||||
|
// 2. Create a computed signal that filters out any null/undefined and returns the class string
|
||||||
|
private hostClasses = computed(() => {
|
||||||
|
// We append a base class that should always be present (e.g., 'flex-item')
|
||||||
|
const classes = ['flex-item'];
|
||||||
|
|
||||||
|
const custom = this.styleClass();
|
||||||
|
if (custom) {
|
||||||
|
// Split the input string and add the class names
|
||||||
|
classes.push(...custom.trim().split(/\s+/));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a single space-separated string
|
||||||
|
return classes.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Use @HostBinding to bind the computed string to the host element's 'class' attribute
|
||||||
|
@HostBinding('class')
|
||||||
|
get classAttribute(): string {
|
||||||
|
return this.hostClasses();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onClick() {
|
||||||
|
this.onAction.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<h2 class="text-2xl">Esemény lemondása</h2>
|
||||||
|
<app-single-event-dashboard-event-details-view [event]="event()"></app-single-event-dashboard-event-details-view>
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<rs-daisy-button variant="error">
|
||||||
|
<span [outerHTML]="SvgIcons.heroTrash | safeHtml"></span>
|
||||||
|
Törlés
|
||||||
|
</rs-daisy-button>
|
||||||
|
<rs-daisy-button variant="primary" (click)="triggerAction()">
|
||||||
|
<span [outerHTML]="SvgIcons.heroXcircle | safeHtml"></span>
|
||||||
|
Mégsem
|
||||||
|
</rs-daisy-button>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { SingleEventDashboardEventCancel } from './single-event-dashboard-event-cancel';
|
||||||
|
|
||||||
|
describe('SingleEventDashboardEventCancel', () => {
|
||||||
|
let component: SingleEventDashboardEventCancel;
|
||||||
|
let fixture: ComponentFixture<SingleEventDashboardEventCancel>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SingleEventDashboardEventCancel]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SingleEventDashboardEventCancel);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { CalendarEventDto } from '../../../models/events-in-range-dto.model';
|
||||||
|
import {
|
||||||
|
SingleEventDashboardEventDetailsView
|
||||||
|
} from '../single-event-dashboard-event-details-view/single-event-dashboard-event-details-view';
|
||||||
|
import { Button } from '@rschneider/ng-daisyui';
|
||||||
|
import { SvgIcons } from '../../../../../svg-icons';
|
||||||
|
import { SafeHtmlPipe } from '../../../../../pipes/safe-html-pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-single-event-dashboard-event-cancel',
|
||||||
|
imports: [
|
||||||
|
SingleEventDashboardEventDetailsView,
|
||||||
|
Button,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
],
|
||||||
|
templateUrl: './single-event-dashboard-event-cancel.html',
|
||||||
|
styleUrl: './single-event-dashboard-event-cancel.css',
|
||||||
|
})
|
||||||
|
export class SingleEventDashboardEventCancel {
|
||||||
|
|
||||||
|
event = input<CalendarEventDto>();
|
||||||
|
onAction = input.required<(msg: string) => void>();
|
||||||
|
|
||||||
|
protected readonly SvgIcons = SvgIcons;
|
||||||
|
|
||||||
|
protected triggerAction() {
|
||||||
|
|
||||||
|
this.onAction()('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<h2 class="text-2xl">Törlés</h2>
|
||||||
|
<p>Esemény és az egész széria törlése</p>
|
||||||
|
@if (config) {
|
||||||
|
<app-detail-view
|
||||||
|
[config]="config"
|
||||||
|
></app-detail-view>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<rs-daisy-button variant="error">
|
||||||
|
<span [outerHTML]="SvgIcons.heroTrash | safeHtml"></span>
|
||||||
|
Törlés
|
||||||
|
</rs-daisy-button>
|
||||||
|
<rs-daisy-button variant="primary" (click)="triggerAction()">
|
||||||
|
<span [outerHTML]="SvgIcons.heroXcircle | safeHtml"></span>
|
||||||
|
Mégsem
|
||||||
|
</rs-daisy-button>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import { Component, effect, input, output } from '@angular/core';
|
||||||
|
import { CalendarEventDto } from '../../../models/events-in-range-dto.model';
|
||||||
|
import { DetailView, DetailViewConfig } from '../../../../../components/detail-view/detail-view';
|
||||||
|
import { SvgIcons } from '../../../../../svg-icons';
|
||||||
|
import { SafeHtmlPipe } from '../../../../../pipes/safe-html-pipe';
|
||||||
|
import { Button } from '@rschneider/ng-daisyui';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-single-event-dashboard-event-delete',
|
||||||
|
imports: [
|
||||||
|
|
||||||
|
DetailView,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
Button,
|
||||||
|
],
|
||||||
|
templateUrl: './single-event-dashboard-event-delete.html',
|
||||||
|
styleUrl: './single-event-dashboard-event-delete.css',
|
||||||
|
})
|
||||||
|
export class SingleEventDashboardEventDelete {
|
||||||
|
|
||||||
|
event = input<CalendarEventDto>();
|
||||||
|
// Define an input that expects a function
|
||||||
|
onAction = input.required<(msg: string) => void>();
|
||||||
|
config: DetailViewConfig<CalendarEventDto> | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
data: this.event()!,
|
||||||
|
|
||||||
|
rows: [{
|
||||||
|
attribute: 'id',
|
||||||
|
getTitle: 'Esemény azonosító',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'title',
|
||||||
|
getTitle: 'Esemény neve',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'startTime',
|
||||||
|
getTitle: 'Kezdési időpont',
|
||||||
|
format: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'eventType',
|
||||||
|
getTitle: 'Esemény típusa',
|
||||||
|
getValue: obj => obj.eventType.name,
|
||||||
|
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
triggerAction() {
|
||||||
|
// Call the function passed from the parent
|
||||||
|
this.onAction()('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected readonly SvgIcons = SvgIcons;
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
@if (config) {
|
||||||
|
<app-detail-view
|
||||||
|
[config]="config"
|
||||||
|
></app-detail-view>
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { Component, effect, input } from '@angular/core';
|
||||||
|
import { CalendarEventDto } from '../../../models/events-in-range-dto.model';
|
||||||
|
import { DetailView, DetailViewConfig } from '../../../../../components/detail-view/detail-view';
|
||||||
|
import { SafeHtmlPipe } from '../../../../../pipes/safe-html-pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-single-event-dashboard-event-details-view',
|
||||||
|
imports: [
|
||||||
|
DetailView,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
],
|
||||||
|
templateUrl: './single-event-dashboard-event-details-view.html',
|
||||||
|
styleUrl: './single-event-dashboard-event-details-view.css',
|
||||||
|
})
|
||||||
|
export class SingleEventDashboardEventDetailsView {
|
||||||
|
|
||||||
|
event = input<CalendarEventDto>();
|
||||||
|
config: DetailViewConfig<CalendarEventDto> | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
data: this.event()!,
|
||||||
|
|
||||||
|
rows: [{
|
||||||
|
attribute: 'id',
|
||||||
|
getTitle: 'Esemény azonosító',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'title',
|
||||||
|
getTitle: 'Esemény neve',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'startTime',
|
||||||
|
getTitle: 'Kezdési időpont',
|
||||||
|
format: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'eventType',
|
||||||
|
getTitle: 'Esemény típusa',
|
||||||
|
getValue: obj => obj.eventType.name,
|
||||||
|
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
<app-create-event-form (ready)="triggerAction($event)" [date]="selectedDate()"
|
||||||
|
|
||||||
|
[id]="event()?.id"></app-create-event-form>
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Component, input, signal } from '@angular/core';
|
||||||
|
import { CalendarEventDto } from '../../../models/events-in-range-dto.model';
|
||||||
|
import { SvgIcons } from '../../../../../svg-icons';
|
||||||
|
import { CreateEventForm } from '../../create-event-form/create-event-form';
|
||||||
|
import { Modal } from '@rschneider/ng-daisyui';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-single-event-dashboard-event-edit',
|
||||||
|
imports: [
|
||||||
|
CreateEventForm,
|
||||||
|
Modal,
|
||||||
|
],
|
||||||
|
templateUrl: './single-event-dashboard-event-edit.html',
|
||||||
|
styleUrl: './single-event-dashboard-event-edit.css',
|
||||||
|
})
|
||||||
|
export class SingleEventDashboardEventEdit {
|
||||||
|
|
||||||
|
selectedDate = signal<Date | undefined>(undefined);
|
||||||
|
event = input<CalendarEventDto>();
|
||||||
|
onAction = input.required<(msg: string) => void>();
|
||||||
|
|
||||||
|
protected readonly SvgIcons = SvgIcons;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* proxy to ready event from form to parent
|
||||||
|
*/
|
||||||
|
protected triggerAction(action: string) {
|
||||||
|
this.onAction()(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<h2 class="text-xl">Esemény</h2>
|
||||||
|
|
||||||
|
@if (config) {
|
||||||
|
<app-detail-view
|
||||||
|
[config]="config"
|
||||||
|
></app-detail-view>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex mt-3 gap-2 flex-wrap content-stretch ">
|
||||||
|
@for (card of cards; let i = $index; track i) {
|
||||||
|
|
||||||
|
<app-single-event-dashboard-card
|
||||||
|
[title]="card.title"
|
||||||
|
[svgIcon]="card.svgIcon"
|
||||||
|
[description]="card.description"
|
||||||
|
[buttonTitle]="card.buttonTitle"
|
||||||
|
[styleClass]="'flex-1'"
|
||||||
|
(onAction)="onCardAction(card.action)"
|
||||||
|
|
||||||
|
></app-single-event-dashboard-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
import { Component, effect, input, output } from '@angular/core';
|
||||||
|
import { CalendarEventDto } from '../../../models/events-in-range-dto.model';
|
||||||
|
import { DetailView, DetailViewConfig } from '../../../../../components/detail-view/detail-view';
|
||||||
|
import { SvgIcons } from '../../../../../svg-icons';
|
||||||
|
import { SingleEventDashboardCard } from '../single-event-dashboard-card/single-event-dashboard-card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-single-event-dashboard',
|
||||||
|
imports: [
|
||||||
|
DetailView,
|
||||||
|
SingleEventDashboardCard,
|
||||||
|
],
|
||||||
|
templateUrl: './single-event-dashboard.html',
|
||||||
|
styleUrl: './single-event-dashboard.css',
|
||||||
|
})
|
||||||
|
export class SingleEventDashboard {
|
||||||
|
|
||||||
|
event = input<CalendarEventDto>();
|
||||||
|
action = output<string>();
|
||||||
|
config: DetailViewConfig<CalendarEventDto> | undefined;
|
||||||
|
|
||||||
|
cards: CardConfig[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
this.config = {
|
||||||
|
data: this.event()!,
|
||||||
|
|
||||||
|
rows: [{
|
||||||
|
attribute: 'id',
|
||||||
|
getTitle: 'Esemény azonosító',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'title',
|
||||||
|
getTitle: 'Esemény neve',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'startTime',
|
||||||
|
getTitle: 'Kezdési időpont',
|
||||||
|
format: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: 'eventType',
|
||||||
|
getTitle: 'Esemény típusa',
|
||||||
|
getValue: obj => obj.eventType.name,
|
||||||
|
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.cards = [
|
||||||
|
{
|
||||||
|
buttonTitle: 'Szerkesztés',
|
||||||
|
title: 'Szerkesztés',
|
||||||
|
svgIcon: SvgIcons.heorPencilSquare,
|
||||||
|
description: 'Az esemény módosítása',
|
||||||
|
action: 'event_edit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buttonTitle: 'Lemondás',
|
||||||
|
title: 'Esemény lemondása',
|
||||||
|
svgIcon: SvgIcons.heroXcircle,
|
||||||
|
description: 'Az esemény lemondása',
|
||||||
|
action: 'event_cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buttonTitle: 'Törlés',
|
||||||
|
title: 'Esemény törlése',
|
||||||
|
svgIcon: SvgIcons.heroTrash,
|
||||||
|
description: 'Az esemény törlése',
|
||||||
|
action: 'event_delete',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buttonTitle: 'Megnézem',
|
||||||
|
title: 'Foglalások',
|
||||||
|
svgIcon: SvgIcons.heroUserGroup,
|
||||||
|
description: 'Foglalások megtekintése',
|
||||||
|
action: 'event_bookings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buttonTitle: 'Bejelentkezés',
|
||||||
|
title: 'Időpont foglalás',
|
||||||
|
svgIcon: SvgIcons.heroUserPlus,
|
||||||
|
description: 'Időpont foglalása eseményre',
|
||||||
|
action: 'user_booking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buttonTitle: 'Lemondás',
|
||||||
|
title: 'Lemondás',
|
||||||
|
svgIcon: SvgIcons.heroUserMinus,
|
||||||
|
description: 'Az időpont lemondása',
|
||||||
|
action: 'user_cancel',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
onCardAction (action: string|undefined) {
|
||||||
|
if ( action ){
|
||||||
|
this.action.emit(action);
|
||||||
|
}
|
||||||
|
console.info("card action", action);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected readonly SvgIcons = SvgIcons;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardConfig {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
buttonTitle?: string;
|
||||||
|
svgIcon?: string;
|
||||||
|
action?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
<!-- Generated by the CLI -->
|
||||||
|
<div class="">
|
||||||
|
<h2 class="card-title text-3xl">
|
||||||
|
Esemény {{ isEditMode ? 'szerkesztése' : 'létrehozása' }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-4 mt-4">
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">Megnevezés</span></label>
|
||||||
|
<input [class.input-error]="title?.invalid && title?.touched" type="text" formControlName="title" class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">Esemény típus</span></label>
|
||||||
|
<select class="select w-full"
|
||||||
|
formControlName="eventTypeId"
|
||||||
|
[class.input-error]="eventType?.invalid && eventType?.touched"
|
||||||
|
>
|
||||||
|
<option disabled selected>Pick a color</option>
|
||||||
|
@for (eventType of eventTypes(); track eventType.id) {
|
||||||
|
<option [value]="eventType.id">{{ eventType.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">Leírás</span></label>
|
||||||
|
<input [class.input-error]="description?.invalid && description?.touched" type="text" formControlName="description" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">Kezdő időpont</span></label>
|
||||||
|
<input [class.input-error]="startTime?.invalid && startTime?.touched" type="datetime-local" formControlName="startTime" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label"><span class="label-text">Befejezési időpont</span></label>
|
||||||
|
<input [class.input-error]="endTime?.invalid && endTime?.touched" type="datetime-local" formControlName="endTime" class="input input-bordered w-full" /></div>
|
||||||
|
|
||||||
|
<div class="form-control"><label class="label cursor-pointer justify-start gap-4">
|
||||||
|
<span class="label-text">Ismétlődő</span>
|
||||||
|
<input type="checkbox" formControlName="isRecurring" class="checkbox" />
|
||||||
|
</label></div>
|
||||||
|
|
||||||
|
@if (isRecurring?.value === true) {
|
||||||
|
<div formGroupName="recurrenceRule" class="space-y-4 p-4 border border-base-300 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold">Ismétlődés</h3>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Gyakoriság</span></label>
|
||||||
|
<select class="select w-full"
|
||||||
|
formControlName="frequency"
|
||||||
|
[class.select-error]="frequency?.invalid && frequency?.touched">
|
||||||
|
<option disabled selected>Válassz gyakoriságot</option>
|
||||||
|
@for (frequencyOption of frequencyOptions; track frequencyOption) {
|
||||||
|
<option [value]="frequencyOption.frequency">{{ frequencyOption.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Intervallum</span></label>
|
||||||
|
<input type="number"
|
||||||
|
formControlName="interval"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
[class.input-error]="interval?.invalid && interval?.touched" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Napok</span></label>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
@for (day of weekDayOptions; track day.value) {
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input type="checkbox"
|
||||||
|
[value]="day.value"
|
||||||
|
[checked]="isDayChecked(day.value)"
|
||||||
|
(change)="onByDayChange($event)"
|
||||||
|
class="checkbox" />
|
||||||
|
<span class="label-text">{{ day.label }}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Hónap napja</span></label>
|
||||||
|
<input type="text"
|
||||||
|
formControlName="byMonthDay"
|
||||||
|
class="input input-bordered w-full" />
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Vesszővel elválasztott lista (pl. 1,15,31)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Hónap</span></label>
|
||||||
|
<input type="text"
|
||||||
|
formControlName="byMonth"
|
||||||
|
class="input input-bordered w-full" />
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Vesszővel elválasztott lista (pl. 1,2,3)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Ismétlődés vége</span></label>
|
||||||
|
<input type="date"
|
||||||
|
formControlName="endDate"
|
||||||
|
class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-6">
|
||||||
|
<a class="btn btn-ghost" (click)="doReady()">Mégse</a>
|
||||||
|
<button type="submit" class="btn btn-primary" [disabled]="form.invalid">
|
||||||
|
{{ isEditMode ? 'Mentés' : 'Létrezhozás' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user