This commit is contained in:
2025-11-11 10:52:30 +07:00
commit c838b2a979
56 changed files with 4014 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
n nSTEP 1 — สร้างโปรเจ็กต์ใหม่
ng new accounting-ng-nuttakit --no-standalone
STEP 2
npm install bootstrap
npm install @angular/material @angular/cdk
และเพิ่มใน angular.json
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/styles.scss"
]
STEP 3 สร้าง Module แรก
ng generate module main --route main --module ../app-routing.module.ts
ng g module controls/login-control --routing
STEP 4
ng generate component modules/goods/goods-content
ng generate component modules/goods/goods-page
ng generate service modules/goods/goods-state
STEP 5
shared/interfaces/product.interface.ts
export interface Product {
id: number;
name: string;
price: number;
category: string;
imgUrl: string;
}
goods-state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { Product } from 'src/app/shared/interfaces/product.interface';
@Injectable({ providedIn: 'root' })
export class GoodsStateService {
private goodsState = new BehaviorSubject<Product[]>([]);
goodsState$ = this.goodsState.asObservable();
setState(data: Product[]) {
this.goodsState.next(data);
}
getStateResult() {
return this.goodsState$;
}
}
STEP 10 — สร้าง Content Component (ตัวควบคุมหลัก)
import { Component, OnInit } from '@angular/core';
import { GoodsStateService } from './goods-state.service';
import { Product } from 'src/app/shared/interfaces/product.interface';
@Component({
selector: 'app-goods-content',
templateUrl: './goods-content.component.html',
})
export class GoodsContentComponent implements OnInit {
products: Product[] = [];
constructor(private goodsState: GoodsStateService) {}
ngOnInit(): void {
this.goodsState.getStateResult().subscribe(data => {
this.products = data;
});
this.mockData(); // ตัวอย่าง data
}
private mockData() {
const mock: Product[] = [
{ id: 1, name: 'Phone', price: 9000, category: 'Electronics', imgUrl: 'phone.jpg' },
{ id: 2, name: 'Laptop', price: 25000, category: 'Electronics', imgUrl: 'laptop.jpg' }
];
this.goodsState.setState(mock);
}
}
goods-page.component.html
<div class="row">
<div *ngFor="let item of products" class="col-md-3">
<div class="card mb-3">
<img [src]="item.imgUrl" class="card-img-top">
<div class="card-body">
<h5>{{ item.name }}</h5>
<p>{{ item.price | currency:'THB' }}</p>
</div>
</div>
</div>
</div>
app-routing.module.ts
const routes: Routes = [
{
path: 'goods',
canActivate: [AuthControl],
loadChildren: () => import('./modules/goods/goods.module').then(m => m.GoodsModule)
},
{ path: '**', redirectTo: '/goods' }
];
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
MODULE
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
# สร้างโฟลเดอร์ control
mkdir src/app/controls/login-control
cd src/app/controls/login-control
# สร้าง module พร้อม routing
ng g module controls/login-control --routing
# (optional) guard
ng g guard controls/login-control/login

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

46
accounting-ng-nuttakit/.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/ios
/dist
/android
/node_modules
npm-debug.log
package-lock.json
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -0,0 +1,33 @@
// {
// // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
// "version": "0.2.0",
// "configurations": [
// {
// "name": "ng serve",
// "type": "chrome",
// "request": "launch",
// "preLaunchTask": "npm: start",
// "url": "http://localhost:4200/"
// },
// {
// "name": "ng test",
// "type": "chrome",
// "request": "launch",
// "preLaunchTask": "npm: test",
// "url": "http://localhost:9876/debug.html"
// }
// ]
// }
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Chrome against localhost",
"type": "chrome",
"request": "launch",
"url": "http://localhost:4200",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View File

@@ -0,0 +1,59 @@
# AccountingNgNuttakit
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.18.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,160 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"accounting-ng-nuttakit": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"standalone": false
},
"@schematics/angular:directive": {
"standalone": false
},
"@schematics/angular:pipe": {
"standalone": false
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/accounting-ng-nuttakit",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"node_modules/@fortawesome/fontawesome-free/css/all.min.css",
"src/styles.css"
],
"scripts": [
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.ts"
}
],
"optimization": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kB",
"maximumError": "10kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"host": "0.0.0.0",
"allowedHosts": ["accounting.nuttakit.work", "localhost"]
},
"configurations": {
"production": {
"buildTarget": "accounting-ng-nuttakit:build:production"
},
"development": {
"buildTarget": "accounting-ng-nuttakit:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"node_modules/@fortawesome/fontawesome-free/css/all.min.css",
"src/styles.css"
],
"scripts": [
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
]
}
}
}
}
},
"cli": {
"analytics": false
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

View File

@@ -0,0 +1,10 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'accounting.nuttakit.work',
appName: 'accounting-ng-nuttakit',
webDir: "dist/accounting-ng-nuttakit/browser",
// bundledWebRuntime: false
};
export default config;

View File

@@ -0,0 +1,90 @@
{
"name": "accounting-ng-nuttakit",
"version": "1.0.0",
"main": "electron/main.js",
"author": "Nuttakit",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"electron": "ng build --base-href ./ && electron .",
"dist": "ng build --configuration production && electron-builder"
},
"build": {
"appId": "accounting.nuttakit.work",
"productName": "accounting-nuttakit",
"asar": false,
"directories": {
"output": "dist_electron"
},
"files": [
"dist/accounting-ng-nuttakit/browser/**/*",
"electron/**/*",
"!node_modules/@capacitor/android/**/*"
],
"win": {
"target": [
"nsis",
"msi"
],
"icon": "public/favicon.ico"
},
"nsis": {
"oneClick": false,
"perMachine": true,
"allowElevation": true,
"runAfterFinish": false
}
},
"private": true,
"dependencies": {
"@angular/animations": "^20.3.10",
"@angular/common": "^20.3.10",
"@angular/compiler": "^20.3.10",
"@angular/core": "^20.3.10",
"@angular/forms": "^20.3.10",
"@angular/platform-browser": "^20.3.10",
"@angular/platform-browser-dynamic": "^20.3.10",
"@angular/router": "^20.3.10",
"@capacitor/android": "^7.4.4",
"@capacitor/angular": "^2.0.3",
"@capacitor/core": "latest",
"@fortawesome/angular-fontawesome": "^3.0.0",
"@fortawesome/fontawesome-free": "^7.1.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@tailwindcss/postcss": "^4.1.16",
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"dotenv": "^17.2.3",
"ng2-charts": "^6.0.1",
"postcss": "^8.5.6",
"rxjs": "~7.8.0",
"tailwindcss": "^4.1.16",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.9",
"@angular/cli": "^20.3.9",
"@angular/compiler-cli": "^20.3.10",
"@capacitor/cli": "latest",
"cross-env": "^10.1.0",
"electron": "^39.0.0",
"electron-builder": "^26.0.12",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"ngx-toastr": "^19.1.0",
"postcss": "^8.5.3",
"typescript": "~5.9.3",
"@types/jasmine": "~5.1.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -0,0 +1,37 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SidebarContentComponent } from './content/sidebar-content/sidebar-content.component';
import { LicensePrivacyTermsComponent } from './component/license-privacy-terms/license-privacy-terms.component';
import { authGuard } from './services/auth.guard';
const routes: Routes = [
{ path: 'login', loadChildren: () => import('./controls/login-control/login-control.module').then(m => m.LoginControlModule) },
{ path: 'c', component: LicensePrivacyTermsComponent},
{
path: 'main',
component: SidebarContentComponent,
canActivate: [authGuard],
children: [
{
path: '',
loadChildren: () =>
import('./controls/main-control/main-control.module').then(
(m) => m.MainControlModule
),
},
],
},
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: '**', redirectTo: 'login' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
standalone: false,
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'accounting-ng-nuttakit';
}

View File

@@ -0,0 +1,57 @@
import { NgModule, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ToastrModule } from 'ngx-toastr';
import { AppRoutingModule } from './app-routing.module';
// import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
// import { LayoutComponent } from './content/content/layout/layout.component';
import { SidebarContentComponent } from './content/sidebar-content/sidebar-content.component';
import { SidebarComponent } from './component/sidebar/sidebar.component';
// import { ReactiveFormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { HttpClientModule } from '@angular/common/http';
import { LicensePrivacyTermsComponent } from './component/license-privacy-terms/license-privacy-terms.component';
// import { MainDashboardContentComponent } from './content/main-dashboard-content/main-dashboard-content.component';
// import { MainDashboardComponent } from './component/main-dashboard/main-dashboard.component';
// import { LoginForgotComponent } from './component/login-forgot/login-forgot.component';
// import { LoginPageComponent } from './component/login-page/login-page.component';
// import { LoginContentComponent } from './content/login-content/login-content.component';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
@NgModule({
declarations: [
AppComponent,
// LayoutComponent,
SidebarContentComponent,
SidebarComponent,
LicensePrivacyTermsComponent,
// MainDashboardContentComponent,
// MainDashboardComponent,
// LoginForgotComponent,
// LoginPageComponent,
// LoginPageComponentComponent,
],
imports: [
BrowserModule,
CommonModule,
ToastrModule.forRoot({
positionClass:'toast-top-right',
preventDuplicates: true,
maxOpened: 3,
autoDismiss: true
}),
// ReactiveFormsModule,
BrowserAnimationsModule,
AppRoutingModule,
HttpClientModule,
FontAwesomeModule
],
providers: [provideCharts(withDefaultRegisterables())],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

@@ -0,0 +1,68 @@
.policy-container {
display: flex;
justify-content: center;
align-items: flex-start;
background: linear-gradient(135deg, #f3f6f9 0%, #e9eff5 100%);
min-height: 100vh;
padding: 40px 20px;
color: #1a1f36;
font-family: "Sarabun", "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
}
.card {
background: white;
max-width: 800px;
width: 100%;
border-radius: 16px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
padding: 40px;
line-height: 1.7;
}
.page-title {
font-size: 26px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
font-size: 13px;
color: #6b737a;
margin-bottom: 32px;
}
section {
margin-bottom: 40px;
}
h2 {
font-size: 20px;
margin-bottom: 10px;
color: #0b1a2b;
}
p {
font-size: 15px;
color: #333;
margin-bottom: 12px;
text-align: justify;
}
a {
color: #0078d4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
footer {
text-align: center;
margin-top: 40px;
}
.footer-text {
color: #6b737a;
font-size: 13px;
}

View File

@@ -0,0 +1,82 @@
<div class="policy-container">
<div class="card">
<h1 class="page-title">ข้อตกลงสิทธิ์การใช้งาน นโยบายความเป็นส่วนตัว และเงื่อนไขการให้บริการ</h1>
<p class="subtitle">ปรับปรุงล่าสุด: 27 ตุลาคม 2025</p>
<section>
<h2>1. ข้อตกลงสิทธิ์การใช้งาน (License Agreement)</h2>
<p>
ซอร์สโค้ด ส่วนประกอบ และทรัพย์สินการออกแบบทั้งหมดภายใต้โครงการ Nuttakit Software
อยู่ภายใต้สัญญาอนุญาตแบบ <strong>MIT License</strong> เว้นแต่จะมีการระบุเป็นอย่างอื่นโดยเฉพาะ
</p>
<p>
ท่านได้รับสิทธิ์ในการใช้งาน คัดลอก แก้ไข รวม รวมเข้ากับซอฟต์แวร์อื่น เผยแพร่ หรือแจกจ่ายซอฟต์แวร์นี้
เพื่อวัตถุประสงค์ส่วนตัวหรือเชิงพาณิชย์ได้
โดยต้องคงไว้ซึ่งข้อความลิขสิทธิ์และข้อความอนุญาตนี้ในสำเนาทั้งหมดของซอฟต์แวร์
</p>
<p>
ซอฟต์แวร์นี้ถูกจัดให้ “ตามสภาพ” (AS IS)
โดยไม่มีการรับประกันใด ๆ ไม่ว่าจะโดยชัดแจ้งหรือโดยนัย
รวมถึงแต่ไม่จำกัดเฉพาะการรับประกันความเหมาะสมในการใช้งานหรือความถูกต้องของข้อมูล
</p>
</section>
<section>
<h2>2. นโยบายความเป็นส่วนตัว (Privacy Policy)</h2>
<p>
NuttakitSoftwareให้ความสำคัญกับความเป็นส่วนตัวของผู้ใช้งาน
แอปพลิเคชันของเราจะเก็บข้อมูลเพียงบางส่วนเท่านั้น เช่น รหัสอุปกรณ์
บันทึกการทำงาน หรือสถิติการใช้งาน
เพื่อใช้ในการวิเคราะห์ ปรับปรุง และพัฒนาประสิทธิภาพของระบบ
</p>
<p>
ข้อมูลส่วนบุคคล (Personal Identifiable Information — PII)
จะไม่ถูกขาย แบ่งปัน หรือโอนไปยังบุคคลที่สามโดยไม่ได้รับความยินยอมจากท่านอย่างชัดเจน
</p>
<p>
เราใช้มาตรฐานการเข้ารหัสระดับอุตสาหกรรม (AES-CBC)
เพื่อรักษาความปลอดภัยในการส่งข้อมูลระหว่างแอปพลิเคชัน เซิร์ฟเวอร์ และ API
</p>
<p>
ผู้ใช้สามารถร้องขอให้ลบหรือขอรับสำเนาข้อมูลของตนเองได้ตลอดเวลา
โดยติดต่อทีมสนับสนุนของเรา
</p>
</section>
<section>
<h2>3. เงื่อนไขการให้บริการ (Terms of Service)</h2>
<p>
เมื่อท่านใช้งานซอฟต์แวร์หรือบริการของเรา ถือว่าท่านยอมรับและปฏิบัติตามกฎหมายและข้อบังคับที่เกี่ยวข้องทั้งหมด
</p>
<p>
ท่านจะต้องไม่ใช้ซอฟต์แวร์ของเราในทางที่ผิด
ไม่พยายามถอดรหัส แก้ไข ดัดแปลง หรือแสวงหาประโยชน์จากช่องโหว่ในระบบโดยไม่ได้รับอนุญาต
</p>
<p>
Nuttakit ขอสงวนสิทธิ์ในการแก้ไขหรือยุติการให้บริการโดยไม่ต้องแจ้งให้ทราบล่วงหน้า
หากตรวจพบการละเมิดความปลอดภัยหรือการใช้งานที่ไม่เหมาะสม
</p>
<p>
ทีมพัฒนา Nuttakit และผู้ร่วมพัฒนาไม่รับผิดชอบต่อความเสียหายใด ๆ
ที่อาจเกิดจากการใช้งานหรือไม่สามารถใช้งานซอฟต์แวร์นี้ได้
</p>
</section>
<section>
<h2>4. ช่องทางการติดต่อ (Contact)</h2>
<p>
หากท่านมีข้อสงสัยหรือข้อกังวลเกี่ยวกับข้อมูลส่วนบุคคลหรือเงื่อนไขการให้บริการ
สามารถติดต่อเราได้ที่:
<br />
<strong>อีเมล:</strong> support&#64;nuttakit.work<br />
<strong>เว็บไซต์:</strong>
<a href="https://nuttakit.work" target="_blank">https://nuttakit.work</a>
</p>
</section>
<footer>
<hr />
<p class="footer-text">© 2025 Nuttakit Software. สงวนลิขสิทธิ์ทั้งหมด</p>
</footer>
</div>
</div>

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-license-privacy-terms',
standalone: false,
templateUrl: './license-privacy-terms.component.html',
styleUrl: './license-privacy-terms.component.css'
})
export class LicensePrivacyTermsComponent {
}

View File

@@ -0,0 +1,290 @@
:root {
--bg-1: #f3f6f9;
--card-bg: #ffffff;
--muted: #6b737a;
--text: #0b1a2b;
--primary: #0078d4;
--primary-600: #0065b8;
--radius: 8px;
--shadow: 0 10px 30px rgba(11,26,43,0.08);
--glass: rgba(255,255,255,0.6);
}
/* Page layout */
.login-widget {
/* Fill the viewport and center the card. Do not let the page itself
scroll; the card gets an internal max-height instead. */
min-height: 100vh;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 28px 18px;
background: linear-gradient(180deg, #f7f9fb 0%, var(--bg-1) 100%);
color: var(--text);
}
/* Card */
.login-widget .card{
width: 380px;
max-width: calc(100% - 40px);
background: var(--card-bg);
border-radius: calc(var(--radius) + 2px);
box-shadow: var(--shadow);
padding: 22px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 14px;
/* Constrain the card so it never forces the page to scroll. If content
grows, the card will scroll internally. */
max-height: calc(100vh - 56px);
overflow: auto;
}
/* Modal/backdrop styles */
.login-backdrop{
position: fixed;
inset: 0; /* top:0; right:0; bottom:0; left:0; */
background: rgba(0,0,0,0.38);
display: flex;
align-items: center;
justify-content: center;
z-index: 1040;
padding: 24px;
}
.login-modal{ width: 480px; max-width: 480px; }
.modal-card{
border-radius: 12px;
padding: 0; /* card children control internal padding */
overflow: hidden;
box-shadow: 0 20px 50px rgba(2,6,23,0.4);
}
/* Slightly larger brand area inside modal */
.modal-card .brand{ padding: 18px; }
/* Make the primary button pill-shaped and slightly larger */
button.primary{
color: #000;
border-radius: 999px;
padding: 10px 18px;
font-size: 15px;
}
/* Make biometric and other action buttons visually lighter */
.biometric{
border-radius: 999px;
padding: 8px 12px;
}
/* On small screens reduce modal padding and width to avoid overflow */
@media (max-width: 420px){
.login-backdrop{ padding: 12px; }
.login-modal{ max-width: 100%; }
.modal-card .brand{ padding: 12px; }
}
/* Brand area */
.brand{
text-align: center;
padding-bottom: 4px;
border-bottom: 1px solid #eef2f5;
}
.brand .logo{
height: 44px;
width: 44px;
object-fit: contain;
display: inline-block;
margin-bottom: 10px;
}
.brand h1{
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.2px;
color: var(--text);
}
.brand .subtitle{
margin: 6px 0 12px;
color: var(--muted);
font-size: 13px;
}
/* Form area */
.form{
/* keep compact spacing inside the card */
/* width: 410px; */
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 6px 0 2px;
}
/* Field label wrapper */
.field{
display: flex;
flex-direction: column;
gap: 6px;
}
.field .label-text{
font-size: 13px;
color: var(--muted);
}
/* Inputs */
input[type="email"],
input[type="password"],
input[type="text"]{
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
font-size: 15px;
color: var(--text);
background: #fff;
border: 1px solid #d8dee6;
border-radius: 6px;
outline: none;
transition: box-shadow .14s ease, border-color .14s ease, transform .06s ease;
-webkit-appearance: none;
appearance: none;
}
input::placeholder{
color: #9aa3ad;
}
input:focus{
border-color: var(--primary);
box-shadow: 0 6px 20px rgba(0,120,212,0.10);
transform: translateZ(0);
}
/* Checkbox / stay signed */
.stay-signed{
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
color: var(--muted);
}
.stay-signed input[type="checkbox"]{
width: 16px;
height: 16px;
accent-color: var(--primary);
}
/* Actions row */
.actions{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
}
button.primary{
background: linear-gradient(180deg, var(--primary) 0%, var(--primary-600) 100%);
color: #000000;
border: none;
padding: 10px 14px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
box-shadow: 0 6px 18px rgba(0,120,212,0.12);
transition: transform .06s ease, box-shadow .12s ease, opacity .12s ease;
}
button.primary:hover:not(:disabled){
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(0,120,212,0.14);
}
button.primary:active{
transform: translateY(0);
}
button.primary:disabled{
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
/* Alternative options */
.alt-options{
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
flex-wrap: wrap;
}
.biometric{
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
color: var(--primary);
border: 1px solid rgba(0,120,212,0.14);
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
}
.biometric svg{ display: block; opacity: .95; }
.biometric:hover{
background: rgba(0,120,212,0.04);
}
/* Help link */
.help-link{
margin-left: auto;
font-size: 13px;
color: var(--primary);
text-decoration: none;
}
.help-link:hover{ text-decoration: underline; }
/* Footer */
.footer{
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 6px;
padding-top: 10px;
border-top: 1px solid #eef2f5;
font-size: 13px;
color: var(--muted);
}
.footer a{
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
.footer a:hover{ text-decoration: underline; }
.divider{ color: #d0d6db; }
/* Focus styles for keyboard users */
:focus{
outline: none;
}
:focus-visible{
outline: 3px solid rgba(0,120,212,0.12);
outline-offset: 2px;
border-radius: 6px;
}
/* Small screens */
@media (max-width:420px){
.login-widget .card{
padding: 18px;
width: 100%;
}
.brand h1{ font-size: 18px; }
.brand .subtitle{
font-family: "Kanit";
font-weight: 1000;
font-style: normal; }
.biometric span, .primary{ font-size: 13px; }
}

View File

@@ -0,0 +1,105 @@
<div class="login-backdrop">
<div class="login-modal d-flex align-items-center justify-content-center ">
<div class="card modal-card" role="dialog" aria-labelledby="signin-title" aria-modal="true">
<div class="brand">
<!-- <img src="assets/logo.png" alt="Company logo" class="logo" /> -->
<img src="logo.png" alt="Company logo" class="logo mb-2"/>
<h1 id="signin-title" class='kanit-bold'>ลืมรหัสผ่าน</h1>
<p class="subtitle">โปรดกรอก Email ของท่าน</p>
</div>
<form [formGroup]="forgotFrm" class="form px-3 pb-3 login-mobile">
<label class="field">
<span class="label-text ">อีเมล์</span>
<input type="email" formControlName="email" class="px-2 " id="englishInput" autocomplete="username" placeholder="nuttakit@gmail.com" aria-label="Email address" required />
</label>
@if (isSendOtp === true) {
<label class="field">
<span class="label-text">รหัสยืนยัน OTP</span>
<input type="email" formControlName="otp" autocomplete="otp" placeholder="123456" alt required/>
</label>
}
<!-- <div class="justify-end flex"> -->
<!-- <label class="stay-signed">
<input type="checkbox" formControlName="remember" />
<span>จดจำรหัสผ่าน</span>
</label> -->
<div class="flex flex-row gap-2 mt-4 justify-end">
<div class="flex-row hover:-translate-y-2.5 transform transition-all">
<button class="bg-[linear-gradient(180deg,var(--primary)_0%,var(--primary-600)_100%)]
text-black
border-0
px-3.5 py-2.5
rounded-md
font-semibold
cursor-pointer
text-[14px]
shadow-[0_6px_18px_var(--color-blue-500)]
transition
ease-in-out
duration-100
hover:scale-[1.02]
active:opacity-90" (click)="isModalOpen = true">
เปิด Modal
</button>
</div>
@if (isSendOtp === false) {
<div class="flex justify-end">
@if (isLoading === true) {
<button type="submit" class="primary cursor-progress!" disabled>
กำลังส่ง...
</button>
} @else {
<button type="submit" class="primary" (click)="onSubmin()">
{{ 'ยืนยันส่ง OTP รีเซ็ตรหัสผ่าน' }}
</button>
}
</div>
} @else if(isSendOtp === true) {
<div class="flex justify-end gap-2">
<button type="button" class="primary" (click)="onSubmin()">
{{ 'ส่งอีกครั้ง' }}
</button>
<button type="submit" class="primary" (click)="onVerifySubmit()">
{{ 'รีเซ็ตรหัสผ่าน' }}
</button>
</div>
}
</div>
<!-- <button mat-raised-button color="primary" [disabled]="isLoading">
{{ isLoading ? 'กำลังส่ง...' : 'ส่งรหัส OTP' }}
</button> -->
<!-- } -->
<!-- </div> -->
</form>
@if(isModalOpen){
<div class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50" [formGroup]="forgotFrm">
<div class="bg-white p-6 rounded-lg shadow-lg max-w-sm w-fit">
<h2 class="text-xl font-bold mb-4">เปลี่ยนรหัสผ่าน</h2>
<hr class="w-full h-1 bg-gray-300 rounded-sm shadow-neutral-400 md:my-1">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">รหัสผ่านใหม่</label>
<input type="password" id="newPassword" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" formControlName="newPassword" placeholder="กรอกรหัสผ่านใหม่">
</div>
<div class="mb-4">
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-1">ยืนยันรหัสผ่านใหม่</label>
<input type="password" id="confirmPassword" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" formControlName="confirmPassword" placeholder="กรอกยืนยันรหัสผ่านใหม่">
@if ( this.forgotFrm.get('confirmPassword')!.touched && this.forgotFrm.get('newPassword')?.value !== this.forgotFrm.get('confirmPassword')?.value ){
<span class="text-red-600 md">รหัสผ่านไม่ตรงกัน</span>
}
</div>
<!-- <hr class="w-full h-[] bg-gray-100 border-0 rounded-sm md:my-1 dark:bg-gray-700"> -->
<div class="flex justify-end gap-2">
<button class="bg-red-500 text-white px-4 py-2 rounded" (click)="isModalOpen = false">
ปิด
</button>
<button class="bg-green-500 text-white px-4 py-2 rounded shadow-[0_1px_18px_var(--color-green-300)] hover:-translate-y-1.5 hover:shadow-[0_6px_18px_var(--color-green-500)] transition-all duration-500 ease-in-out" (click)="onSetNewPassword()">
ยืนยัน
</button>
</div>
</div>
</div>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,91 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms';
import { GeneralService } from '../../services/generalservice';
@Component({
selector: 'app-login-forgot',
standalone: false,
templateUrl: './login-forgot.component.html',
styleUrl: './login-forgot.component.css'
})
export class LoginForgotComponent implements OnInit {
// @Input() brandName = 'Contoso';
// @Input() subtitle = 'to your account';
// @Input() mode = '';
@Output() otpEventSubmit = new EventEmitter<any>();
@Output() otpVerifyEventSubmit = new EventEmitter<any>();
forgotFrm!: FormGroup;
isLoading: boolean = false;
isSendOtp: boolean = false;
isModalOpen: boolean = false;
// busy = false;
// message = '';
constructor(
// private generalService: GeneralService
// private fb: FormBuilder
) {}
ngOnInit(): void {
this.setupFormControl();
}
setupFormControl(){
this.forgotFrm = new FormGroup({
email: new FormControl('',[Validators.required, Validators.email, Validators.maxLength(100)]),
otp: new FormControl('',[Validators.required, Validators.maxLength(6)]),
newPassword: new FormControl('',[Validators.required, Validators.maxLength(50)]),
confirmPassword: new FormControl('',[Validators.required, Validators.maxLength(50)])
});
}
EventSubmit(event: any){
this.otpEventSubmit.emit(event);
}
VerifyEventSubmit(event: any){
this.otpVerifyEventSubmit.emit(event);
}
onSubmin(){
let data = {
email: this.forgotFrm.get('email')?.value
}
this.EventSubmit(data);
}
onVerifySubmit(){
let data = {
email: this.forgotFrm.get('email')?.value,
otp: this.forgotFrm.get('otp')?.value
}
this.VerifyEventSubmit(data);
}
onSetNewPassword(){
let newpassword = this.forgotFrm.get('newPassword')?.value;
let confirmPassword = this.forgotFrm.get('confirmPassword')?.value;
let data = {
email: this.forgotFrm.get('email')?.value,
otp: this.forgotFrm.get('otp')?.value,
newPassword: newpassword
}
if (newpassword.trim() === confirmPassword.trim()) {
// this.VerifyEventSubmit(data);
console.log("Password matched! (รหัสผ่านตรงกัน)");
} else {
console.error("Password mismatched! (รหัสผ่านไม่ตรงกัน)");
}
// console.log(newpassword.value);
}
// otp: }
}

View File

@@ -0,0 +1,289 @@
:root {
--bg-1: #f3f6f9;
--card-bg: #ffffff;
--muted: #6b737a;
--text: #0b1a2b;
--primary: #0078d4;
--primary-600: #0065b8;
--radius: 8px;
--shadow: 0 10px 30px rgba(11,26,43,0.08);
--glass: rgba(255,255,255,0.6);
}
/* Page layout */
.login-widget {
/* Fill the viewport and center the card. Do not let the page itself
scroll; the card gets an internal max-height instead. */
min-height: 100vh;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 28px 18px;
background: linear-gradient(180deg, #f7f9fb 0%, var(--bg-1) 100%);
color: var(--text);
}
/* Card */
.login-widget .card{
width: 380px;
max-width: calc(100% - 40px);
background: var(--card-bg);
border-radius: calc(var(--radius) + 2px);
box-shadow: var(--shadow);
padding: 22px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 14px;
/* Constrain the card so it never forces the page to scroll. If content
grows, the card will scroll internally. */
max-height: calc(100vh - 56px);
overflow: auto;
}
/* Modal/backdrop styles */
.login-backdrop{
position: fixed;
inset: 0; /* top:0; right:0; bottom:0; left:0; */
background: rgba(0,0,0,0.38);
display: flex;
align-items: center;
justify-content: center;
z-index: 1040;
padding: 24px;
}
.login-modal{ width: 480px; max-width: 480px; }
.modal-card{
border-radius: 12px;
padding: 0; /* card children control internal padding */
overflow: hidden;
box-shadow: 0 20px 50px rgba(2,6,23,0.4);
}
/* Slightly larger brand area inside modal */
.modal-card .brand{ padding: 18px; }
/* Make the primary button pill-shaped and slightly larger */
button.primary{
color: #000;
border-radius: 999px;
padding: 10px 18px;
font-size: 15px;
}
/* Make biometric and other action buttons visually lighter */
.biometric{
border-radius: 999px;
padding: 8px 12px;
}
/* On small screens reduce modal padding and width to avoid overflow */
@media (max-width: 420px){
.login-backdrop{ padding: 12px; }
.login-modal{ max-width: 100%; }
.modal-card .brand{ padding: 12px; }
}
/* Brand area */
.brand{
text-align: center;
padding-bottom: 4px;
border-bottom: 1px solid #eef2f5;
}
.brand .logo{
height: 44px;
width: 44px;
object-fit: contain;
display: inline-block;
margin-bottom: 10px;
}
.brand h1{
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.2px;
color: var(--text);
}
.brand .subtitle{
margin: 6px 0 12px;
color: var(--muted);
font-size: 13px;
}
/* Form area */
.form{
/* keep compact spacing inside the card */
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 6px 0 2px;
}
/* Field label wrapper */
.field{
display: flex;
flex-direction: column;
gap: 6px;
}
.field .label-text{
font-size: 13px;
color: var(--muted);
}
/* Inputs */
input[type="email"],
input[type="password"],
input[type="text"]{
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
font-size: 15px;
color: var(--text);
background: #fff;
border: 1px solid #d8dee6;
border-radius: 6px;
outline: none;
transition: box-shadow .14s ease, border-color .14s ease, transform .06s ease;
-webkit-appearance: none;
appearance: none;
}
input::placeholder{
color: #9aa3ad;
}
input:focus{
border-color: var(--primary);
box-shadow: 0 6px 20px rgba(0,120,212,0.10);
transform: translateZ(0);
}
/* Checkbox / stay signed */
.stay-signed{
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
color: var(--muted);
}
.stay-signed input[type="checkbox"]{
width: 16px;
height: 16px;
accent-color: var(--primary);
}
/* Actions row */
.actions{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
}
button.primary{
background: linear-gradient(180deg, var(--primary) 0%, var(--primary-600) 100%);
color: #000000;
border: none;
padding: 10px 14px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
box-shadow: 0 6px 18px rgba(0,120,212,0.12);
transition: transform .06s ease, box-shadow .12s ease, opacity .12s ease;
}
button.primary:hover:not(:disabled){
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(0,120,212,0.14);
}
button.primary:active{
transform: translateY(0);
}
button.primary:disabled{
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
/* Alternative options */
.alt-options{
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
flex-wrap: wrap;
}
.biometric{
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
color: var(--primary);
border: 1px solid rgba(0,120,212,0.14);
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
}
.biometric svg{ display: block; opacity: .95; }
.biometric:hover{
background: rgba(0,120,212,0.04);
}
/* Help link */
.help-link{
margin-left: auto;
font-size: 13px;
color: var(--primary);
text-decoration: none;
}
.help-link:hover{ text-decoration: underline; }
/* Footer */
.footer{
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 6px;
padding-top: 10px;
border-top: 1px solid #eef2f5;
font-size: 13px;
color: var(--muted);
}
.footer a{
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
.footer a:hover{ text-decoration: underline; }
.divider{ color: #d0d6db; }
/* Focus styles for keyboard users */
:focus{
outline: none;
}
:focus-visible{
outline: 3px solid rgba(0,120,212,0.12);
outline-offset: 2px;
border-radius: 6px;
}
/* Small screens */
@media (max-width:420px){
.login-widget .card{
padding: 18px;
width: 100%;
}
.brand h1{ font-size: 18px; }
.brand .subtitle{
font-family: "Kanit";
font-weight: 1000;
font-style: normal; }
.biometric span, .primary{ font-size: 13px; }
}

View File

@@ -0,0 +1,55 @@
<!-- Modal-like backdrop that covers the viewport -->
<div class="login-backdrop">
<div class="login-modal d-flex align-items-center justify-content-center">
<div class="card modal-card" role="dialog" aria-labelledby="signin-title" aria-modal="true">
<div class="brand">
<!-- <img src="assets/logo.png" alt="Company logo" class="logo" /> -->
<img src="logo.png" alt="Company logo" class="logo mb-2"/>
<h1 id="signin-title" class='kanit-bold'>เข้าสู่ระบบ</h1>
<p class="subtitle">บัญชีโปรแกรมจัดการบัญชีของคุณ</p>
</div>
<form [formGroup]="loginForm" (ngSubmit)="signIn()" class="form px-3 pb-3">
<label class="field">
<span class="label-text">อีเมล์</span>
<input type="email" formControlName="username" autocomplete="username" placeholder="nuttakit@gmail.com" required />
</label>
<label class="field">
<span class="label-text">รหัสผ่าน</span>
<input type="password" formControlName="password" autocomplete="current-password" required />
</label>
<div class="actions">
<label class="stay-signed">
<input type="checkbox" formControlName="remember" />
<span>จดจำรหัสผ่าน</span>
</label>
<!-- <fa-icon [icon]="faCoffee" /> -->
<button type="submit" class="primary" [disabled]="!(loginForm.get('username')?.valid && loginForm.get('password')?.value)">
เข้าสู่ระบบ
</button>
</div>
<div class="alt-options">
<button type="button" class="biometric" (click)="useBiometric()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2z" fill="currentColor" opacity=".9"/>
<path d="M6.2 10.9A6 6 0 0 1 12 6a6 6 0 0 1 5.8 4.9M12 22v-2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12a8 8 0 0 1 16 0v1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>เข้าสู่ระบบด้วย Windows Hello / Touch ID</span>
</button>
<a class="help-link" href="#" (click)="$event.preventDefault(); forgotPassword()">ลืมรหัส ใช่ หรือ ไม่?</a>
</div>
<div class="footer">
<a href="#" (click)="$event.preventDefault(); createAccount()">สร้างบัญชี</a>
<span class="divider"></span>
<a href="#" (click)="$event.preventDefault(); privacy()">Privacy & terms</a>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,90 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms';
import { Router } from '@angular/router';
import { faCoffee } from '@fortawesome/free-solid-svg-icons';
@Component({
selector: 'app-login-page',
standalone: false,
templateUrl: './login-page.component.html',
styleUrls: ['./login-page.component.css'],
})
export class LoginPageComponent implements OnInit {
@Input() brandName = 'Contoso';
@Input() subtitle = 'to your account';
@Input() mode = '';
@Output() signedIn = new EventEmitter<any>();
faCoffee = faCoffee;
loginForm!: FormGroup;
busy = false;
message = '';
constructor(
private router: Router
) {}
ngOnInit(): void {
this.setupFormControl();
}
setupFormControl(): void {
this.loginForm = new FormGroup({
username: new FormControl('',[Validators.required, Validators.maxLength(100)]),
password: new FormControl( '', [Validators.required, Validators.maxLength(50)]),
remember: new FormControl(false)
});
}
signIn(): void {
if (this.loginForm.invalid) return;
this.signedIn.emit(this.loginForm.value);
}
async useBiometric(): Promise<void> {
this.message = '';
if (!('credentials' in navigator) || !('get' in (navigator as any).credentials)) {
this.message = 'Biometric authentication is not available on this device/browser.';
return;
}
try {
this.busy = true;
// Example WebAuthn / PublicKeyCredential call. In a real application,
// you must obtain the challenge and allowedCredentials from your server.
const publicKeyCredentialRequestOptions = {
// challenge must be provided by server as ArrayBuffer
challenge: Uint8Array.from('server-provided-challenge', c => c.charCodeAt(0)).buffer,
timeout: 60000,
userVerification: 'preferred',
} as any;
const credential: any = await (navigator as any).credentials.get({
publicKey: publicKeyCredentialRequestOptions,
});
// Send credential to server for verification (not implemented here).
// Example: await this.authService.verifyWebAuthn(credential);
// On success:
this.signedIn.emit({ email: '', remember: true });
} catch (err: any) {
this.message = err?.message || 'Biometric sign-in cancelled or failed.';
} finally {
this.busy = false;
}
}
forgotPassword(): void {
// emit or navigate
this.router.navigate(['/login/forgot-password']);
}
createAccount(): void {
this.message = 'Create account flow not implemented.';
}
privacy(): void {
this.router.navigate(['/license']);
}
}

View File

@@ -0,0 +1,629 @@
:host {
display: block;
padding: 2rem clamp(1.25rem, 4vw, 3rem) 3rem;
background: radial-gradient(120% 120% at 0% 0%, #f6f8ff 0%, #eef5ff 55%, #ffffff 100%);
min-height: 100%;
}
.dashboard {
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 1280px;
margin: 0 auto;
}
.dashboard__hero {
background: #0f172a;
color: #f8fafc;
padding: 2rem;
border-radius: 28px;
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: space-between;
align-items: center;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.3);
}
.hero__text h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.8rem, 3vw, 2.5rem);
}
.hero__subtitle {
margin: 0;
color: rgba(248, 250, 252, 0.7);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.8rem;
color: rgba(248, 250, 252, 0.8);
margin: 0 0 0.5rem;
}
.hero__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn {
border: none;
border-radius: 999px;
padding: 0.65rem 1.5rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.btn--primary {
background: linear-gradient(135deg, #22d3ee, #0ea5e9);
color: #0f172a;
box-shadow: 0 15px 30px rgba(14, 165, 233, 0.35);
}
.btn--ghost {
background: rgba(248, 250, 252, 0.12);
color: #f8fafc;
border: 1px solid rgba(248, 250, 252, 0.2);
}
.btn--compact {
padding: 0.45rem 1.15rem;
font-size: 0.9rem;
}
.btn:focus-visible {
outline: 3px solid rgba(14, 165, 233, 0.4);
outline-offset: 3px;
}
.btn:hover {
transform: translateY(-1px);
}
.dashboard__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 1rem;
}
.dashboard__periods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.period-card {
background: rgba(15, 23, 42, 0.85);
color: #f8fafc;
border-radius: 22px;
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.4);
}
.period-card__header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
color: rgba(248, 250, 252, 0.75);
}
.period-card__badge {
padding: 0.2rem 0.75rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.period-card__badge--year { background: rgba(248, 250, 252, 0.14); }
.period-card__badge--month { background: rgba(125, 211, 252, 0.25); }
.period-card__badge--week { background: rgba(110, 231, 183, 0.2); }
.period-card__values {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.muted {
margin: 0;
font-size: 0.8rem;
color: rgba(248, 250, 252, 0.65);
}
.income,
.expense,
.net {
margin: 0.1rem 0 0;
font-size: 1.2rem;
font-weight: 600;
}
.income { color: #34d399; }
.expense { color: #fbbf24; }
.net { color: #38bdf8; }
.trend-chip {
background: rgba(248, 250, 252, 0.12);
padding: 0.35rem 0.9rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
align-self: flex-start;
}
.stat-card {
background: #ffffff;
border-radius: 20px;
padding: 1.25rem;
display: flex;
gap: 1rem;
align-items: center;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.08);
}
.stat-card__icon {
width: 52px;
height: 52px;
border-radius: 16px;
flex-shrink: 0;
background: #e2e8f0;
}
.accent-mint { background: linear-gradient(135deg, #a7f3d0, #34d399); }
.accent-lavender { background: linear-gradient(135deg, #ddd6fe, #a78bfa); }
.accent-amber { background: linear-gradient(135deg, #fde68a, #fbbf24); }
.accent-teal { background: linear-gradient(135deg, #99f6e4, #14b8a6); }
.stat-card__label {
margin: 0;
color: #475569;
font-size: 0.9rem;
}
.stat-card__value {
font-size: 1.5rem;
font-weight: 700;
color: #0f172a;
}
.stat-card__trend {
margin: 0;
color: #64748b;
font-size: 0.85rem;
}
.dashboard__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
.panel {
background: #ffffff;
border-radius: 24px;
padding: 1.5rem;
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.panel--main {
grid-column: span 2;
min-height: 280px;
}
.panel--side {
grid-column: span 1;
}
@media (max-width: 900px) {
.panel--main {
grid-column: span 1;
}
}
.panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.panel__header h2 {
margin: 0;
font-size: 1.2rem;
}
.panel__header p {
margin: 0;
color: #94a3b8;
font-size: 0.9rem;
}
.ledger-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
.quick-log__form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.quick-log__form label {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.9rem;
color: #475569;
}
.quick-log__form input,
.quick-log__form textarea {
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 0.75rem 1rem;
font-family: inherit;
font-size: 0.95rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.quick-log__form input:focus,
.quick-log__form textarea:focus {
border-color: #0ea5e9;
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.15);
outline: none;
}
.quick-log__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.quick-log__toggle {
display: inline-flex;
gap: 0.4rem;
background: #f1f5f9;
border-radius: 999px;
padding: 0.25rem;
}
.toggle-btn {
border: none;
background: transparent;
border-radius: 999px;
padding: 0.4rem 1.1rem;
font-weight: 600;
color: #475569;
cursor: pointer;
}
.toggle-btn.is-active {
background: #0ea5e9;
color: #f8fafc;
}
.ledger-table {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.pie-panel__content {
display: grid;
grid-template-columns: minmax(220px, 1fr) 1fr;
gap: 2rem;
align-items: center;
}
.pie-chart {
width: 220px;
height: 220px;
border-radius: 50%;
position: relative;
margin: 0 auto;
box-shadow: inset 0 0 20px rgba(15, 23, 42, 0.08);
}
.pie-chart__center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120px;
height: 120px;
border-radius: 50%;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.1);
}
.pie-chart__center p {
margin: 0;
font-size: 0.85rem;
color: #94a3b8;
}
.pie-chart__center strong {
font-size: 1.2rem;
color: #0f172a;
}
.pie-legend {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pie-legend__item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0;
}
.swatch {
width: 14px;
height: 14px;
border-radius: 4px;
}
.pie-legend__label {
margin: 0;
font-weight: 600;
color: #0f172a;
}
.pie-legend__value {
margin: 0;
color: #94a3b8;
font-size: 0.85rem;
}
.ledger-row {
display: grid;
grid-template-columns: 2fr 1fr 0.8fr 1.2fr;
gap: 1rem;
align-items: center;
padding: 0.75rem 0.4rem;
border-bottom: 1px solid #e2e8f0;
}
.ledger-head {
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.08em;
color: #94a3b8;
}
.ledger-main {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pill {
padding: 0.2rem 0.7rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.pill--income {
background: rgba(16, 185, 129, 0.12);
color: #059669;
}
.pill--expense {
background: rgba(248, 113, 113, 0.15);
color: #dc2626;
}
.ledger-title {
margin: 0;
font-weight: 600;
}
.ledger-date {
margin: 0;
font-size: 0.85rem;
color: #94a3b8;
}
.ledger-category {
font-weight: 500;
color: #475569;
}
.ledger-amount {
font-weight: 700;
}
.ledger-note {
color: #64748b;
font-size: 0.9rem;
}
.trend-chart {
display: flex;
gap: 1rem;
align-items: flex-end;
min-height: 180px;
}
.trend-chart__bar {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.trend-chart__value {
width: 100%;
border-radius: 16px 16px 6px 6px;
background: linear-gradient(180deg, rgba(14, 165, 233, 0.8) 0%, rgba(56, 189, 248, 0.4) 100%);
}
.trend-chart__label {
font-size: 0.85rem;
color: #475569;
}
.ratio-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.ratio {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 18px;
padding: 0.85rem 1.2rem;
font-weight: 600;
}
.ratio--positive {
background: rgba(16, 185, 129, 0.12);
color: #047857;
}
.ratio--neutral {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.ratio--warning {
background: rgba(251, 191, 36, 0.15);
color: #b45309;
}
.alerts-panel .alert {
display: flex;
justify-content: space-between;
align-items: center;
background: #f8fafc;
border-radius: 18px;
padding: 1rem 1.2rem;
gap: 1rem;
}
.alert__title {
margin: 0 0 0.3rem;
font-weight: 600;
color: #0f172a;
}
.alert__detail {
margin: 0;
color: #64748b;
font-size: 0.9rem;
}
.alert__tag {
padding: 0.4rem 0.9rem;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
font-size: 0.85rem;
font-weight: 600;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.task {
background: #f8fafc;
border-radius: 18px;
padding: 1rem 1.2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.task__title {
margin: 0 0 0.25rem;
font-weight: 600;
}
.task__due {
margin: 0;
color: #94a3b8;
font-size: 0.9rem;
}
.task__badge {
padding: 0.35rem 0.8rem;
border-radius: 12px;
background: #e2e8f0;
font-weight: 600;
}
.is-credit {
color: #10b981;
font-weight: 600;
}
.is-debit {
color: #ef4444;
font-weight: 600;
}
@media (max-width: 640px) {
.dashboard__hero,
.panel {
padding: 1.25rem;
}
.quick-log__grid {
grid-template-columns: 1fr;
}
.pie-panel__content {
grid-template-columns: 1fr;
}
.pie-chart {
width: 180px;
height: 180px;
}
.ledger-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ledger-row span:nth-child(3),
.ledger-row span:nth-child(4) {
text-align: left;
}
}

View File

@@ -0,0 +1,332 @@
<section class="dashboard">
<header class="dashboard__hero">
<div class="hero__text">
<p class="eyebrow">ภาพรวมบัญชี</p>
<h1>ยินดีต้อนรับกลับ, {{ ownerName }}</h1>
<p class="hero__subtitle">
จดบันทึกรายรับรายจ่าย และดูสรุปต่อปี เดือน สัปดาห์ ได้ในหน้าเดียว
</p>
</div>
<div class="hero__actions">
<button class="btn btn--primary">สร้างรายงานด่วน</button>
<!-- <button class="btn btn--ghost">อัปโหลดใบเสร็จ</button> -->
</div>
</header>
<section class="dashboard__periods">
<article class="period-card" *ngFor="let summary of periodSummaries">
<header class="period-card__header">
<span class="period-card__badge" [ngClass]="'period-card__badge--' + summary.badge">
{{ summary.label }}
</span>
<p>{{ summary.note }}</p>
</header>
<div class="period-card__values">
<div>
<p class="muted">รายรับ</p>
<p class="income">{{ summary.income }}</p>
</div>
<div>
<p class="muted">รายจ่าย</p>
<p class="expense">{{ summary.expense }}</p>
</div>
<div>
<p class="muted">คงเหลือสุทธิ</p>
<p class="net">{{ summary.net }}</p>
</div>
</div>
<footer>
<span class="trend-chip">แนวโน้ม {{ summary.trend }}</span>
</footer>
</article>
</section>
<section class="dashboard__stats">
<article class="stat-card" *ngFor="let card of kpiCards">
<div class="stat-card__icon" [ngClass]="'accent-' + card.accent"></div>
<div class="stat-card__body">
<p class="stat-card__label">{{ card.label }}</p>
<div class="stat-card__value">{{ card.value }}</div>
<p class="stat-card__trend">{{ card.trend }} · {{ card.context }}</p>
</div>
</article>
</section>
<section class="ledger-grid">
<article class="panel quick-log">
<div class="panel__header">
<div>
<h2>บันทึกรายการแบบรวดเร็ว</h2>
<p>จดรายรับรายจ่ายภายในไม่กี่คลิก</p>
</div>
</div>
<form class="quick-log__form">
<label>
<span>ประเภท</span>
<div class="quick-log__toggle">
<button type="button" class="toggle-btn" [ngClass]="{ 'is-active': mode == 'i' }" (click)="mode = 'i'">รายรับ</button>
<button type="button" class="toggle-btn" [ngClass]="{ 'is-active': mode == 'e' }" (click)="mode = 'e'">รายจ่าย</button>
</div>
</label>
<label>
<span>วันที่</span>
<!-- <input type="text" disabled placeholder="10/04/2025 เวลา 12:00"/> -->
<input type="datetime-local"/>
</label>
<div class="quick-log__grid">
<label>
<span>หมวดหมู่</span>
<input type="text" placeholder="เลือกหมวดหมู่" />
</label>
<label>
<span>ยอดเงิน (฿)</span>
<input type="number" placeholder="0.00" />
</label>
</div>
<label>
<span>บันทึกเพิ่มเติม</span>
<textarea rows="3" placeholder="รายละเอียดการรับ/จ่าย"></textarea>
</label>
<button type="button" class="btn btn--primary">บันทึกรายการ</button>
</form>
</article>
<article class="panel ledger-panel">
<div class="panel__header">
<div>
<h2>สมุดบันทึกล่าสุด</h2>
<p>แยกสีระหว่างรายรับและรายจ่าย</p>
</div>
<button class="btn btn--ghost btn--compact">ดูทั้งหมด</button>
</div>
<div class="ledger-table">
<div class="ledger-row ledger-head">
<span>รายการ</span>
<span>หมวดหมู่</span>
<span>ยอดเงิน</span>
<span>บันทึก</span>
</div>
<div class="ledger-row" *ngFor="let idx of ledgerEntries; let i = index;">
<div class="ledger-main">
<span class="pill" [ngClass]="idx.type == 'i' ? 'pill--income' : 'pill--expense'">
{{ idx.type == 'i' ? 'รับ' : 'จ่าย' }}
</span>
<div>
<p class="ledger-title">{{ idx.title }}</p>
<p class="ledger-date">{{ idx.date }}</p>
</div>
</div>
<span class="ledger-category">{{ idx.category }}</span>
<span class="ledger-amount" [ngClass]="idx.type == 'i' ? 'is-credit' : 'is-debit'">
{{ idx.amount }}
</span>
<span class="ledger-note">{{ idx.note }}</span>
</div>
</div>
</article>
</section>
<section class="dashboard__grid">
<!-- <article class="panel panel--main">
<div class="panel__header">
<div>
<h2>แนวโน้มรายรับ</h2>
<p>สรุป 6 เดือนล่าสุด</p>
</div>
<button class="btn btn--ghost btn--compact">ดาวน์โหลดข้อมูล</button>
</div>
<div class="trend-chart">
<div class="trend-chart__bar" *ngFor="let point of revenueTrend">
<span class="trend-chart__value" [style.height.%]="point.value"></span>
<span class="trend-chart__label">{{ point.label }}</span>
</div>
</div>
</article> -->
<article class="panel panel--main pie-panel">
<div class="panel__header">
<div>
<h2>สัดส่วนค่าใช้จ่าย</h2>
<p>ดูหมวดไหนใช้เงินมากที่สุด</p>
</div>
<button class="btn btn--ghost btn--compact">จัดการหมวดหมู่</button>
</div>
<div class="pie-panel__content">
<div class="pie-chart" [style.background]="expenseGradient">
<div class="pie-chart__center">
<p>รวมเดือนนี้</p>
<strong>฿732K</strong>
</div>
</div>
<ul class="pie-legend">
<li class="pie-legend__item" *ngFor="let part of expenseBreakdown">
<span class="swatch" [style.background]="part.color"></span>
<div>
<p class="pie-legend__label">{{ part.label }}</p>
<p class="pie-legend__value">{{ part.value }}%</p>
</div>
</li>
</ul>
</div>
</article>
<!-- ตัวเลขซ้อนทับกัน -->
<article class="panel panel--side">
<div class="panel__header">
<div>
<h2>สรุปสภาพคล่อง</h2>
<p>อัปเดตล่าสุด 5 นาทีที่แล้ว</p>
</div>
</div>
<div class="ratio-list">
<div class="ratio" *ngFor="let ratio of quickRatios" [ngClass]="'ratio--' + ratio.status">
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;">
<p style="margin:0;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ ratio.label }}
</p>
<span style="margin-left:0.5rem;flex:0 0 auto">{{ ratio.value }}</span>
</div>
</div>
</div>
</article>
<article class="panel alerts-panel">
<div class="panel__header">
<div>
<h2>การแจ้งเตือนสำคัญ</h2>
<p>จัดลำดับงานค้างก่อนครบกำหนด</p>
</div>
</div>
<div class="alert" *ngFor="let alert of alerts">
<div>
<p class="alert__title">{{ alert.title }}</p>
<p class="alert__detail">{{ alert.detail }}</p>
</div>
<span class="alert__tag">{{ alert.tag }}</span>
</div>
</article>
<article class="panel tasks-panel">
<div class="panel__header">
<div>
<h2>รายการยอดค้างจ่าย</h2>
<p>ช่วยเตือนความจำให้</p>
</div>
<button class="btn btn--primary btn--compact" (click)="isModalOpen = true">เพิ่มงาน</button>
</div>
<ul class="task-list">
<li class="task" *ngFor="let task of tasks">
<div>
<p class="task__title">{{ task.title }}</p>
<p class="task__due">{{ task.due }}</p>
</div>
<span class="task__badge">{{ task.priority }}</span>
</li>
</ul>
</article>
</section>
</section>
@if(isModalOpen == true){
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 backdrop-blur-sm transition-all duration-300 ease-in-out" role="dialog" aria-modal="true" [formGroup]="arrearsForm">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-auto overflow-hidden transform scale-100 transition-all duration-300 ease-out">
<!-- Header -->
<header class="flex items-center justify-between gap-4 px-6 py-5 border-b bg-linear-to-r from-rose-50 to-white">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 text-rose-600" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M12 2v6M6 12h12M4 20h16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div>
<h2 class="text-lg font-semibold text-gray-900 m-0">เพิ่มยอดค้างชำระ</h2>
<p class="text-sm text-gray-500 m-0">บันทึกยอดที่ยังค้างชำระเพื่อการติดตาม</p>
</div>
</div>
<button type="button" (click)="isModalOpen = false" class="text-gray-400 hover:text-rose-600 p-2 rounded-md transition-colors duration-200" aria-label="ปิด">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
<path d="M6 6l12 12M6 18L18 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</header>
<!-- Form -->
<form class="px-6 py-6 bg-white" (ngSubmit)="onArrearsSubmit()" autocomplete="off" novalidate>
<div class="grid grid-cols-1 gap-2">
<!-- จำนวนเงิน -->
<label class="block">
<span class="text-sm font-medium text-gray-700">จำนวนเงิน (฿)</span>
<div class="mt-1 relative">
<input
type="text"
inputmode="decimal"
id="amount"
formControlName="amount"
placeholder="0.00"
class="w-full px-4 py-2 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-rose-400 focus:border-rose-500 transition-all"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">THB</span>
</div>
@if(arrearsForm.get('amount')?.touched && arrearsForm.get('amount')?.invalid) {
<p class="mt-1 text-xs text-red-600">
กรุณากรอกจำนวนเงินที่ถูกต้อง
</p>
}
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700">วันครบกำหนกจ่าย</span>
<div class="mt-1 relative">
<input type="datetime-local" formControlName="expdtm" class=" w-full px-4 py-2 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-rose-400 focus:border-rose-500 transition-all"/>
</div>
@if(arrearsForm.get('expdtm')?.touched && arrearsForm.get('expdtm')?.invalid) {
<p class="mt-1 text-xs text-red-600">
กรุณาระบุวันครบกำหนดชำระ
</p>
}
</label>
<!-- เหตุผล -->
<label class="block">
<span class="text-sm font-medium text-gray-700">เหตุผล</span>
<input
type="text"
id="reason"
formControlName="reason"
placeholder="เช่น บิลค้างชำระจากผู้ขาย"
class="mt-1 w-full px-4 py-2 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-rose-400 focus:border-rose-500 transition-all"
/>
@if(arrearsForm.get('reason')?.touched && arrearsForm.get('reason')?.invalid) {
<p class="mt-1 text-xs text-red-600">
กรุณากรอกเหตุผล
</p>
}
</label>
<!-- บันทึกเพิ่มเติม -->
<label class="block">
<span class="text-sm font-medium text-gray-700">บันทึกเพิ่มเติม (ไม่บังคับ)</span>
<textarea
rows="3"
formControlName="note"
placeholder="รายละเอียดเพิ่มเติม (เช่น เลขใบแจ้งหนี้ หรือ ผู้ติดต่อ)"
class="mt-1 w-full px-4 py-2 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-rose-400 focus:border-rose-500 resize-none transition-all"
></textarea>
</label>
</div>
<!-- Footer -->
<footer class="flex items-center justify-end gap-3 pt-4 border-t mt-4">
<button type="button" (click)="isModalOpen = false" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-xl hover:bg-gray-300 transition-colors duration-200">
ยกเลิก
</button>
<button type="submit" class="rounded-2xl">
บันทึก
</button>
</footer>
</form>
</div>
</div>
}

View File

@@ -0,0 +1,220 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms';
import { GeneralService } from '../../services/generalservice';
@Component({
selector: 'app-main-dashboard',
standalone: false,
templateUrl: './main-dashboard.component.html',
styleUrl: './main-dashboard.component.css'
})
export class MainDashboardComponent implements OnInit {
mode: string = 'i';
isModalOpen: boolean = false;
isSubmitting: boolean = false;
arrearsForm!: FormGroup;
saveFrm!: FormGroup;
readonly ownerName = 'Nuttakit';
readonly kpiCards = [
{
label: 'รายรับรวม',
value: '฿1.28M',
trend: '+12.4%',
context: 'เทียบกับเดือนก่อน',
accent: 'mint'
},
{
label: 'รายจ่ายรวม',
value: '฿732K',
trend: '-4.1%',
context: 'จัดการได้ดีขึ้น',
accent: 'lavender'
},
{
label: 'ยอดค้างชำระ',
value: '฿184K',
trend: '-2 ใบแจ้งหนี้',
context: 'รอติดตาม',
accent: 'amber'
},
{
label: 'อัตรากำไร',
value: '37.8%',
trend: '+1.9 จุด',
context: 'ระยะ 30 วัน',
accent: 'teal'
}
];
readonly revenueTrend = [
{ label: 'ม.ค.', value: 52 },
{ label: 'ก.พ.', value: 61 },
{ label: 'มี.ค.', value: 73 },
{ label: 'เม.ย.', value: 68 },
{ label: 'พ.ค.', value: 82 },
{ label: 'มิ.ย.', value: 77 }
];
readonly quickRatios = [
{ label: 'กระแสเงินสด', value: '+฿312K', status: 'positive' },
{ label: 'วงเงินคงเหลือ', value: '฿890K', status: 'neutral' },
{ label: 'ค่าใช้จ่ายเดือนนี้', value: '฿412K', status: 'warning' }
];
readonly periodSummaries = [
{
label: 'รายปี',
note: 'ปี 2567',
income: '฿9.6M',
expense: '฿5.1M',
net: '+฿4.5M',
trend: '+18%',
badge: 'year'
},
{
label: 'รายเดือน',
note: 'มิถุนายน 2567',
income: '฿1.28M',
expense: '฿732K',
net: '+฿548K',
trend: '+6%',
badge: 'month'
},
{
label: 'รายสัปดาห์',
note: 'สัปดาห์ที่ 24',
income: '฿312K',
expense: '฿188K',
net: '+฿124K',
trend: '+2%',
badge: 'week'
}
];
readonly alerts = [
{
title: 'ใบแจ้งหนี้ #INV-083 จะครบกำหนด',
detail: 'ลูกค้า Metro Engineering',
tag: 'ภายใน 3 วัน'
},
{
title: 'มีเอกสารที่ต้องอนุมัติ 2 รายการ',
detail: 'เบิกค่าใช้จ่ายฝ่ายการตลาด',
tag: 'รออนุมัติ'
},
{
title: 'พบรายการใช้จ่ายผิดปกติ',
detail: 'ค่าใช้จ่ายเดินทางสูงกว่าค่าเฉลี่ย 28%',
tag: 'ตรวจสอบ'
}
];
readonly tasks = [
{
title: 'กระทบยอดธนาคาร เดือน มิ.ย.',
due: 'วันนี้ 16:00',
priority: 'สูง'
},
{
title: 'เตรียมรายงาน VAT',
due: 'พรุ่งนี้ 10:30',
priority: 'กลาง'
},
{
title: 'ออกใบเสนอราคา โครงการใหม่',
due: 'ศุกร์ 14:00',
priority: 'ต่ำ'
}
];
readonly ledgerEntries = [
{
type: 'i',
title: 'ค่าบริการที่ปรึกษา',
category: 'บริการ',
amount: '+฿85,000',
date: 'วันนี้ · 10:15',
note: 'โครงการ Warehouse Automation'
},
{
type: 'e',
title: 'ค่าเช่าออฟฟิศ',
category: 'ค่าใช้จ่ายคงที่',
amount: '-฿48,000',
date: 'วันนี้ · 09:00',
note: 'สำนักงานพระราม 9'
},
{
type: 'i',
title: 'รับเงินมัดจำ',
category: 'สัญญาใหม่',
amount: '+฿120,000',
date: 'เมื่อวาน',
note: 'ลูกค้า Urbane CoWorking'
},
{
type: 'e',
title: 'ค่าวัตถุดิบ',
category: 'ต้นทุนโครงการ',
amount: '-฿27,500',
date: '12 มิ.ย.',
note: 'สั่งผ่าน Blue Supply'
}
];
readonly expenseBreakdown = [
{ label: 'ฝ่ายบริหาร', value: 32, color: '#0ea5e9' },
{ label: 'การตลาด', value: 18, color: '#f97316' },
{ label: 'ต้นทุนโครงการ', value: 27, color: '#10b981' },
{ label: 'บุคลากร', value: 15, color: '#a855f7' },
{ label: 'อื่นๆ', value: 8, color: '#e11d48' }
];
readonly expenseGradient = this.buildExpenseGradient();
ngOnInit(): void {
this.setupFormControl();
}
setupFormControl(){
this.arrearsForm = new FormGroup({
// email: new FormControl('',[Validators.required, Validators.email, Validators.maxLength(100)]),
amount: new FormControl('',[Validators.required, Validators.maxLength(20)]),
expdtm: new FormControl('',[Validators.required, Validators.maxLength(12)]),
note: new FormControl('',[Validators.maxLength(200)]),
reason: new FormControl('',[Validators.required, Validators.maxLength(200)])
});
this.saveFrm = new FormGroup({
actacpdtm: new FormControl('',[Validators.required, Validators.maxLength(12)]),
actqty: new FormControl('',[Validators.required]),
actcat: new FormControl('',[Validators.required, Validators.maxLength(1)]),
actcmt: new FormControl('',[Validators.maxLength(200)])
});
}
onSaveSubmit(){
}
onArrearsSubmit(){
}
private buildExpenseGradient(): string {
let current = 0;
const segments = this.expenseBreakdown
.map(part => {
const start = current;
const end = current + part.value;
current = end;
return `${part.color} ${start}% ${end}%`;
})
.join(', ');
return `conic-gradient(${segments})`;
}
}

View File

@@ -0,0 +1,39 @@
/* .sidebar {
width: 220px;
background: #222;
color: white;
height: 100vh;
padding: 20px;
} */
/* .sidebar ul {
list-style: none;
padding: 0;
}
.sidebar li {
margin: 10px 0;
cursor: pointer;
transition: 0.2s;
}
.sidebar li:hover {
color: #00bcd4;
} */
@keyframes spin-slow {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.animate-spin-slow {
animation: spin-slow 8s linear infinite;
}
@media (max-width: 768px) {
.sidebar {
position: absolute;
z-index: 50;
transform: translateX(-100%);
}
.sidebar.expanded {
transform: translateX(0);
}
}

View File

@@ -0,0 +1,65 @@
<div
class="h-screen bg-linear-to-b from-amber-950 to-amber-900 text-gray-100 shadow-2xl flex flex-col relative transition-all duration-300 ease-in-out"
[@sidebarState]="isOpen ? 'expanded' : 'collapsed'">
<button
(click)="toggleSidebar()"
class="absolute -right-3 top-6 bg-amber-700 hover:bg-amber-600 text-white rounded-full p-2 shadow-md transition-all duration-300">
<i class="fas" [ngClass]="isOpen ? 'fa-angle-left' : 'fa-angle-right'"></i>
</button>
<div class="flex items-center gap-3 p-5">
<h3 *ngIf="isOpen" class="text-2xl font-bold transition-all duration-300">
Global Sidebar
</h3>
</div>
<hr class="border-t border-amber-700 mx-4 my-4 opacity-70">
<ul class="flex flex-col gap-2 px-2 grow">
<li class="group flex items-center gap-3 p-3 rounded-lg cursor-pointer
hover:bg-amber-800 hover:shadow-lg transition-all duration-300 ease-in-out"
(click)="navigate('/main/dashboard')">
<i class="fas fa-tachometer-alt text-xl group-hover:scale-110 transition-transform"></i>
<span *ngIf="isOpen" class="text-lg font-medium">Dashboard</span>
</li>
<li class="group flex items-center gap-3 p-3 rounded-lg cursor-pointer
hover:bg-amber-800 hover:shadow-lg transition-all duration-300 ease-in-out"
(click)="navigate('/main/profile')">
<i class="fas fa-user-circle text-xl group-hover:scale-110 transition-transform"></i>
<span *ngIf="isOpen" class="text-lg font-medium">Profile</span>
</li>
<li class="group flex items-center gap-3 p-3 rounded-lg cursor-pointer
hover:bg-amber-800 hover:shadow-lg transition-all duration-300 ease-in-out"
(click)="navigate('/main/report')">
<i class="fas fa-chart-bar text-xl group-hover:scale-110 transition-transform"></i>
<span *ngIf="isOpen" class="text-lg font-medium">Report</span>
</li>
<li class="group flex items-center gap-3 p-3 rounded-lg cursor-pointer mt-auto
hover:bg-red-700 hover:shadow-lg transition-all duration-300 ease-in-out"
(click)="logout()">
<i class="fas fa-sign-out-alt text-xl group-hover:scale-110 transition-transform"></i>
<span *ngIf="isOpen" class="text-lg font-medium text-red-200">Logout</span>
</li>
</ul>
</div>
<div
*ngIf="isMobile && showOverlay"
class="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
(click)="toggleSidebar()">
</div>
<!--
<div class="flex-1 bg-gray-100 text-gray-900 overflow-y-auto">
<router-outlet></router-outlet>
</div> -->

View File

@@ -0,0 +1,66 @@
import { Component, HostListener, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { trigger, state, style, transition, animate } from '@angular/animations';
@Component({
selector: 'app-sidebar',
standalone: false,
templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.css'],
animations: [
trigger('sidebarState', [
state('expanded', style({
width: '220px',
opacity: 1
})),
state('collapsed', style({
width: '70px',
opacity: 0.95
})),
transition('expanded <=> collapsed', [
animate('300ms ease-in-out')
])
])
]
})
export class SidebarComponent implements OnInit {
isOpen = true; // ขยายไหม
isMobile = false; // ตรวจอุปกรณ์
showOverlay = false; // สำหรับ mobile overlay
constructor(private router: Router) {}
ngOnInit() {
this.checkDevice();
window.addEventListener('resize', () => this.checkDevice());
}
@HostListener('window:resize')
checkDevice() {
this.isMobile = window.innerWidth <= 768;
if (this.isMobile) {
this.isOpen = false; // ซ่อน sidebar ตอนเข้า mobile
} else {
this.isOpen = true; // เปิดไว้ตลอดใน desktop
this.showOverlay = false;
}
}
toggleSidebar() {
if (this.isMobile) {
this.showOverlay = !this.showOverlay;
this.isOpen = this.showOverlay;
} else {
this.isOpen = !this.isOpen;
}
}
navigate(path: string) {
this.router.navigate([path]);
}
logout() {
localStorage.removeItem('access_token');
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,19 @@
:host {
display: block;
margin: 0;
padding: 0;
width: 100%;
height: 100vh; /* ครอบเต็มหน้าจอ */
overflow: hidden; /* ปิด scroll bar */
box-sizing: border-box;
}
.login-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column; /* ✅ แก้ตรงนี้ จาก row → column */
align-items: center; /* ✅ จัดให้อยู่กลางแนวนอน */
justify-content: center; /* ✅ จัดให้อยู่กลางแนวตั้ง */
text-align: center; /* ✅ ให้ข้อความตรงกลาง */
}

View File

@@ -0,0 +1,11 @@
<div class="justify-content-center flex-column">
<!-- <h2>Login | เข้าสู่ระบบ ({{ mode }})</h2> -->
@if (mode == "default") {
<app-login-page [mode]="mode" (signedIn)="onSignInSubmit($event)"></app-login-page>
} @else if(mode == "forgot-password"){
<app-login-forgot (otpEventSubmit)="onOtpSendSubmit($event)" (otpVerifyEventSubmit)="onVerifySubmit($event)"></app-login-forgot>
}
<!-- @else {
} -->
</div>

View File

@@ -0,0 +1,140 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { GeneralService } from '../../services/generalservice';
import { LoginForgotComponent } from '../../../app/component/login-forgot/login-forgot.component';
import { LoginPageComponent } from '../../../app/component/login-page/login-page.component';
import { finalize } from 'rxjs/operators';
@Component({
selector: 'app-login-content',
standalone: false,
templateUrl: './login-content.component.html',
styleUrl: './login-content.component.css'
})
export class LoginContentComponent implements OnInit {
@ViewChild(LoginForgotComponent) loginForgotComponent!: LoginForgotComponent;
@ViewChild(LoginPageComponent) loginPageComponent!: LoginPageComponent;
mode: 'forgot-password' | 'default' = 'default';
constructor(
private generalService: GeneralService,
private route: ActivatedRoute,
private router: Router
) {}
ngOnInit(): void {
let param = this.route.snapshot.paramMap.get('mode');
if (param === 'forgot-password') {
this.mode = 'forgot-password';
} else {
// this.router.navigate(['/login']); // This can cause navigation loops
this.mode = 'default';
}
}
onSignInSubmit(value: any){
const uri = '/api/login/login';
const request = {
username: value.username,
password: value.password
};
if (this.loginPageComponent) {
this.loginPageComponent.busy = true;
this.loginPageComponent.message = '';
}
this.generalService.postRequest(uri, request)
.pipe(
finalize(() => {
if (this.loginPageComponent) {
this.loginPageComponent.busy = false;
}
})
).subscribe({
next: (result: any) => {
if (result.code === '200' && result.data?.token) {
this.generalService.trowApi(result);
localStorage.setItem('access_token', result.data.token);
this.router.navigate(['main/dashboard']);
} else {
const errorMessage = result.message_th || result.message || 'Sign-in failed.';
if (this.loginPageComponent) {
this.loginPageComponent.message = errorMessage;
}
this.generalService.trowApi(result);
}
},
error: (error: any) => {
const errorMessage = error?.error?.message_th || error?.error?.message || 'An error occurred.';
if (this.loginPageComponent) {
this.loginPageComponent.message = errorMessage;
}
this.generalService.trowApi(error.error || { message_th: 'เกิดข้อผิดพลาดไม่ทราบสาเหตุ' });
}
});
}
onOtpSendSubmit(value: any){
let uri = '/api/login/otp/send';
let request = {
email: value.email
// otp: value.otp
}
this.loginForgotComponent.isLoading = true;
this.generalService.postRequest(uri, request)
.pipe(
// ✅ finalize จะทำงานทุกกรณี (ทั้ง success/error)
finalize(() => {
this.loginForgotComponent.isLoading = false;
})
).subscribe({
next: (result: any) => {
if (result.code === '200') {
this.generalService.trowApi(result);
console.log(`✅ OTP ส่งไปที่ ${value.email}`);
} else {
console.warn('⚠️ ไม่สามารถส่ง OTP ได้:', result.message_th);
}
},
error: (error: any) => {
this.loginForgotComponent.isSendOtp = false;
this.loginForgotComponent.isLoading = false;
this.generalService.trowApi(error);
console.error('❌ Error sending OTP:', error);
},
complete: () => {
this.loginForgotComponent.isLoading = false;
this.loginForgotComponent.isSendOtp = true;
console.log('📨 OTP send request completed');
}
});
}
onVerifySubmit(value: any){
let uri = '/api/login/otp/verify';
let request = {
email: value.email,
otp: value.otp
}
this.generalService.postRequest(uri, request).subscribe({
next: (result: any) => {
if (result.code === '200') {
console.log(`OTP ส่งไปยืนยันสำเร็จ`);
} else {
console.warn('⚠️ ไม่สามารถส่ง OTP ได้:', result.message_th);
}
},
error: (error: any) => {
console.error('❌ Error sending OTP:', error);
},
complete: () => {
this.router.navigate(['/login']);
console.log('📨 OTP send request completed');
}
});
}
}

View File

@@ -0,0 +1 @@
<app-main-dashboard></app-main-dashboard>

View File

@@ -0,0 +1,95 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ChartConfiguration, ChartOptions } from 'chart.js';
import { BaseChartDirective } from 'ng2-charts';
import { GeneralService } from '../../services/generalservice';
@Component({
selector: 'app-main-dashboard-content',
standalone: false,
templateUrl: './main-dashboard-content.component.html',
styleUrls: ['./main-dashboard-content.component.css']
})
export class MainDashboardContentComponent implements OnInit {
@ViewChild(BaseChartDirective) chart?: BaseChartDirective;
public lineChartData: ChartConfiguration<'line'>['data'] = {
labels: [],
datasets: [
{
data: [],
label: 'Revenue',
fill: true,
tension: 0.5,
borderColor: 'rgba(75,192,192,1)',
backgroundColor: 'rgba(75,192,192,0.2)'
}
]
};
public lineChartOptions: ChartOptions<'line'> = {
responsive: true,
scales: {
y: {
beginAtZero: true
}
},
plugins: {
legend: {
display: true,
},
title: {
display: true,
text: 'Revenue Summary - Last 6 Months'
}
}
};
constructor(private generalService: GeneralService) {}
ngOnInit(): void {
this.fetchChartData();
}
fetchChartData(): void {
// NOTE: Using a placeholder endpoint as the actual one was not provided.
const uri = '/api/dashboard/summary-last-6-months';
this.generalService.getRequest(uri).subscribe({
next: (result: any) => {
if (result.code === '200' && result.data) {
this.processChartData(result.data);
} else {
console.warn('Could not fetch chart data:', result.message_th);
// Optionally, display placeholder data or an error message
this.setupPlaceholderData();
}
},
error: (error: any) => {
console.error('Error fetching chart data:', error);
// Display placeholder data on error to show the graph structure
this.setupPlaceholderData();
}
});
}
processChartData(data: any[]): void {
const labels = data.map(item => item.month);
const revenues = data.map(item => item.revenue);
this.lineChartData.labels = labels;
this.lineChartData.datasets[0].data = revenues;
this.chart?.update();
}
setupPlaceholderData(): void {
// This function is called if the API fails, to show a sample graph.
const labels = ['January', 'February', 'March', 'April', 'May', 'June'];
const revenues = [1200, 1900, 3000, 5000, 2300, 3200]; // Sample data
this.lineChartData.labels = labels;
this.lineChartData.datasets[0].data = revenues;
this.chart?.update();
}
}

View File

@@ -0,0 +1,11 @@
.layout {
display: flex;
height: 100vh;
width: 100%;
}
.main-container {
flex: 1;
background: #f5f5f5;
overflow-y: auto;
padding: 20px;
}

View File

@@ -0,0 +1,9 @@
<div class="flex h-screen overflow-hidden">
<!-- Sidebar (เฉพาะ main) -->
<app-sidebar></app-sidebar>
<!-- Content -->
<div class="flex-1 overflow-y-auto bg-gray-50 text-gray-900">
<router-outlet></router-outlet>
</div>
</div>

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-sidebar-content',
standalone: false,
templateUrl: './sidebar-content.component.html',
styleUrl: './sidebar-content.component.css'
})
export class SidebarContentComponent {
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoginContentComponent } from '../../content/login-content/login-content.component';
const routes: Routes = [
{ path: '', component: LoginContentComponent },
{ path: ':mode', component: LoginContentComponent } // ตัวอย่าง param /login/reset
// { path: 'forgot-password', component: LoginContentComponent }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoginControlRoutingModule { }

View File

@@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LoginContentComponent } from '../../content/login-content/login-content.component';
import { LoginControlRoutingModule } from './login-control-routing.module';
import { LoginPageComponent } from '../../component/login-page/login-page.component';
import { ReactiveFormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { LoginForgotComponent } from '../../component/login-forgot/login-forgot.component';
// import { AppModule } from '../../app.module';
@NgModule({
declarations: [
LoginContentComponent,
LoginPageComponent,
LoginForgotComponent
],
imports: [
CommonModule,
ReactiveFormsModule,
FontAwesomeModule,
// AppModule,
LoginControlRoutingModule
]
})
export class LoginControlModule { }

View File

@@ -0,0 +1,27 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { MainDashboardContentComponent } from '../../content/main-dashboard-content/main-dashboard-content.component';
// import { MainReportComponent } from '../../component/main-report/main-report.component';
const routes: Routes = [
{ path: 'dashboard', component: MainDashboardContentComponent },
// children: [
// {
// path: 'dashboard',
// // loadChildren: () => import('./controls/profile-control/profile-control.module').then(m => m.ProfileControlModule)
// },
// { path: 'report', component: MainReportComponent },
// { path: '', redirectTo: 'profile', pathMatch: 'full' }
// ]
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: '**', redirectTo: 'dashboard' }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class MainControlRoutingModule { }

View File

@@ -0,0 +1,29 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
// import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MainControlRoutingModule } from './main-control-routing.module';
import { ReactiveFormsModule } from '@angular/forms';
import { MainDashboardComponent } from '../../component/main-dashboard/main-dashboard.component';
import { MainDashboardContentComponent } from '../../content/main-dashboard-content/main-dashboard-content.component';
// import { MainReportComponent } from '../../component/main-report/main-report.component';
@NgModule({
declarations: [
MainDashboardComponent,
MainDashboardContentComponent
// MainReportComponent
],
imports: [
CommonModule,
MainControlRoutingModule,
ReactiveFormsModule
// BrowserAnimationsModule
]
})
export class MainControlModule { }

View File

@@ -0,0 +1,14 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
export const authGuard: CanActivateFn = (route, state) => {
const router = inject(Router);
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
return true;
} else {
router.navigate(['/login']);
return false;
}
};

View File

@@ -0,0 +1,138 @@
import { ToastrService } from 'ngx-toastr';
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class GeneralService {
private baseUrl = environment.apiBaseUrl;
private mode = environment.production;
constructor(
private http: HttpClient,
private toastr: ToastrService
) {}
// Default header
private getHttpOptions() {
const token = localStorage.getItem('access_token');
const headers = new HttpHeaders({
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
});
return { headers };
}
// Log ต้นแบบ
// private logRequest(method: string, url: string, body?: any) {
// if (this.mode === 'development') {
// console.log(`📡 [${method}] ${url}`, body || '');
// }
// }
// Helper: wrap body ให้มี request ครอบเสมอ
private wrapRequestBody(body: any): any {
// ถ้ามี request อยู่แล้ว จะไม่ซ้ำ
if (body && body.request) {
return body;
}
return { request: body ?? {} };
}
// POST Request
postRequest(uri: string, body: any): Observable<any> {
const payload = this.wrapRequestBody(body);
const fullUrl = `${this.baseUrl}${uri}`;
return this.http.post(fullUrl, payload, this.getHttpOptions()).pipe(
map((res: any) => res),
catchError((error: any) => {
console.error('❌ [POST Request Error]:', error);
return throwError(() => error);
})
);
}
// GET Request
getRequest(uri: string): Observable<any> {
const fullUrl = `${this.baseUrl}${uri}`;
return this.http.get(fullUrl, this.getHttpOptions()).pipe(
map((res: any) => res),
catchError((error: any) => {
console.error('❌ [GET Request Error]:', error);
return throwError(() => error);
})
);
}
// PUT Request
putRequest(uri: string, body: any): Observable<any> {
const payload = this.wrapRequestBody(body);
const fullUrl = `${this.baseUrl}${uri}`;
return this.http.put(fullUrl, payload, this.getHttpOptions()).pipe(
map((res: any) => res),
catchError((error: any) => {
console.error('❌ [PUT Request Error]:', error);
return throwError(() => error);
})
);
}
// DELETE Request
deleteRequest(uri: string, body?: any): Observable<any> {
const payload = this.wrapRequestBody(body);
const fullUrl = `${this.baseUrl}${uri}`;
return this.http.delete(fullUrl, { ...this.getHttpOptions(), body: payload }).pipe(
map((res: any) => res),
catchError((error: any) => {
console.error('❌ [DELETE Request Error]:', error);
return throwError(() => error);
})
);
}
// showToast(type: 'success' | 'error' | 'warning' | 'info', message: string, title?: string) {
// const options = {
// positionClass: 'toast-top-right',
// timeOut: 3000,
// progressBar: true,
// progressAnimation: 'decreasing',
// toastClass: 'ngx-toastr bg-white bg-opacity-90 shadow-lg border'
// };
// this.toastr[type](message, title || type.toUpperCase(), options);
// }
trowApi(result: any){
const code = result?.code ?? 500;
const msg = result?.message ?? 'unknow';
const msgTh = result?.message_th ?? 'unknow';
if(code == 200){
this.toastr.success(`${msgTh || msg}`,'success',{
positionClass: 'toast-top-right',
timeOut: 2500,
progressBar: true,
progressAnimation: 'decreasing',
toastClass:
'ngx-toastr success-toast bg-white bg-opacity-90 text-green-700 shadow-lg'
});
} else {
this.toastr.error(`${msgTh || msg}`,'error',{
positionClass: 'toast-top-right',
timeOut: 3500,
progressBar: true,
progressAnimation: 'decreasing',
toastClass:
'ngx-toastr error-toast bg-white bg-opacity-90 text-red-700 shadow-lg'
});
}
}
}

View File

@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8000'
};

View File

@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiBaseUrl: 'https://api.nuttakit.work'
};

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>AccountingNgNuttakit</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
<!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> -->
<!-- <link rel="stylesheet" href="node_modules/font-awesome/css/all.min.css"> -->
<!-- <link rel="stylesheet" href="styles.css"> -->
<!-- <link rel="stylesheet" href="styles.css"> -->
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { platformBrowser } from '@angular/platform-browser';
import { AppModule } from './app/app.module';
platformBrowser().bootstrapModule(AppModule, {
ngZoneEventCoalescing: true,
})
.catch(err => console.error(err));

View File

@@ -0,0 +1,221 @@
@import "tailwindcss";
/* Global base styles for the app. Keep lightweight and self-contained so
the login component can reliably fill the viewport without producing
an outer page scrollbar. */
/* Force ngx-toastr container to be fixed to the viewport and positioned correctly */
.toast-container.toast-top-right {
position: fixed !important;
top: 12px !important;
right: 12px !important;
z-index: 999999 !important;
}
/* Make sure the page and app root occupy full height so 100vh aligns */
html, body, app-root {
height: 100%;
min-height: 100%;
}
/* เริ่มต้น: สำหรับ Desktop */
.login-mobile {
width: 415px;
}
/* ถ้าเป็น Mobile (<=768px) ให้ลบ width ออก */
@media (max-width: 768px) {
.login-mobile {
width: auto !important;
}
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100%;
z-index: 50;
transition: transform 0.3s ease-in-out;
}
}
/* ✅ Toast Custom Style */
.ngx-toastr {
border-radius: 8px !important;
backdrop-filter: blur(6px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
font-family: "Segoe UI", Roboto, sans-serif;
padding: 12px 16px !important;
min-width: 260px;
transition: all 0.3s ease-in-out;
}
.success-toast {
background: rgba(255, 255, 255, 0.8) !important;
color: #15803d !important;
border-left: 5px solid #16a34a;
}
.error-toast {
background: rgba(239, 68, 68, 0.8) !important;
color: #fff !important;
border-left: 5px solid #dc2626;
}
.toast-title {
font-weight: 600 !important;
margin-bottom: 2px;
}
.toast-message {
font-size: 14px;
}
/* sensible default box model */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: "Kanit", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Prevent the browser from showing a scroll bar for the page itself;
the login card will scroll internally if needed. */
}
/* Simple utilities used by nested components in this workspace */
.content-box {
border: 2px solid black;
padding: 10px;
margin: 20px;
}
.comp-box {
border: 1px solid #555;
border-radius: 8px;
padding: 10px;
margin: 10px;
/* Use flex centering so nested components (like the login widget)
are centered without forcing the document to scroll. */
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
/* If the project uses Bootstrap, the Bootstrap utilities will still apply.
These local utility rules only ensure a consistent appearance if Bootstrap
isn't available. */
.kanit-thin {
font-family: "Kanit", sans-serif;
font-weight: 100;
font-style: normal;
}
.kanit-extralight {
font-family: "Kanit", sans-serif;
font-weight: 200;
font-style: normal;
}
.kanit-light {
font-family: "Kanit", sans-serif;
font-weight: 300;
font-style: normal;
}
.kanit-regular {
font-family: "Kanit", sans-serif;
font-weight: 400;
font-style: normal;
}
.kanit-medium {
font-family: "Kanit", sans-serif;
font-weight: 500;
font-style: normal;
}
.kanit-semibold {
font-family: "Kanit", sans-serif;
font-weight: 600;
font-style: normal;
}
.kanit-bold {
font-family: "Kanit", sans-serif;
font-weight: 700;
font-style: normal;
}
.kanit-extrabold {
font-family: "Kanit", sans-serif;
font-weight: 800;
font-style: normal;
}
.kanit-black {
font-family: "Kanit", sans-serif;
font-weight: 900;
font-style: normal;
}
.kanit-thin-italic {
font-family: "Kanit", sans-serif;
font-weight: 100;
font-style: italic;
}
.kanit-extralight-italic {
font-family: "Kanit", sans-serif;
font-weight: 200;
font-style: italic;
}
.kanit-light-italic {
font-family: "Kanit", sans-serif;
font-weight: 300;
font-style: italic;
}
.kanit-regular-italic {
font-family: "Kanit", sans-serif;
font-weight: 400;
font-style: italic;
}
.kanit-medium-italic {
font-family: "Kanit", sans-serif;
font-weight: 500;
font-style: italic;
}
.kanit-semibold-italic {
font-family: "Kanit", sans-serif;
font-weight: 600;
font-style: italic;
}
.kanit-bold-italic {
font-family: "Kanit", sans-serif;
font-weight: 700;
font-style: italic;
}
.kanit-extrabold-italic {
font-family: "Kanit", sans-serif;
font-weight: 800;
font-style: italic;
}
.kanit-black-italic {
font-family: "Kanit", sans-serif;
font-weight: 900;
font-style: italic;
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import { angular } from '@analogjs/vite-plugin-angular';
export default defineConfig({
plugins: [angular()],
server: {
host: '0.0.0.0',
allowedHosts: ['accounting.nuttakit.work'], // 👈 เพิ่มโดเมนของคุณ
},
});