Compare commits
5 Commits
main
...
b9f2626f40
| Author | SHA1 | Date | |
|---|---|---|---|
| b9f2626f40 | |||
| 978411c9ec | |||
| 6dfac52c27 | |||
| 9072477e65 | |||
| ba2e449c88 |
34
.env
Normal file
34
.env
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# AccManager Backend Configuration
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Host port exposed by docker compose
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
|
# Image used for server pull deployment
|
||||||
|
DOCKER_IMAGE=toiiiiday/accmanager:1.2.1
|
||||||
|
|
||||||
|
# Container app port
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# SQL Server Configuration
|
||||||
|
DB_SERVER=172.20.235.176
|
||||||
|
DB_USER=sa
|
||||||
|
DB_PASSWORD=robotics@2022
|
||||||
|
DB_NAME=AccManager
|
||||||
|
DB_ENCRYPT=false
|
||||||
|
DB_TRUST_CERTIFICATE=true
|
||||||
|
DB_CONNECT_TIMEOUT=30000
|
||||||
|
|
||||||
|
# Security
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
# Email Verification (SMTP)
|
||||||
|
APP_BASE_URL=https://accrobot.pnkr.asia/
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=robotics.gitlab.2fa@gmail.com
|
||||||
|
SMTP_PASS=jaljalhreomqruqo
|
||||||
|
SMTP_FROM=robotics.gitlab.2fa@gmail.com
|
||||||
48
.env.example
48
.env.example
@@ -1,48 +0,0 @@
|
|||||||
NODE_ENV=production
|
|
||||||
APP_PORT=3000
|
|
||||||
PORT=3000
|
|
||||||
TZ=Asia/Ho_Chi_Minh
|
|
||||||
APP_TIME_ZONE=Asia/Ho_Chi_Minh
|
|
||||||
|
|
||||||
# Database: never commit real credentials.
|
|
||||||
DB_SERVER=sql-server.internal
|
|
||||||
DB_USER=accmanager_app
|
|
||||||
DB_PASSWORD=replace-with-a-strong-database-password
|
|
||||||
DB_NAME=AccManager
|
|
||||||
DB_ENCRYPT=true
|
|
||||||
DB_TRUST_CERTIFICATE=false
|
|
||||||
DB_CONNECT_TIMEOUT=30000
|
|
||||||
|
|
||||||
# Generate a separate random value of at least 32 bytes and keep it stable.
|
|
||||||
# Changing this value without a key-rotation migration will make stored
|
|
||||||
# application credentials unreadable.
|
|
||||||
DATA_ENCRYPTION_SECRET=replace-with-at-least-32-random-bytes
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
|
|
||||||
# Public HTTPS origin used by email links, CORS and secure cookies.
|
|
||||||
APP_BASE_URL=https://accmanager.example.internal
|
|
||||||
CORS_ALLOWED_ORIGINS=https://accmanager.example.internal
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
TRUST_PROXY_HOPS=1
|
|
||||||
SESSION_TTL_HOURS=12
|
|
||||||
REMEMBER_SESSION_TTL_DAYS=14
|
|
||||||
ALLOW_SELF_REGISTRATION=true
|
|
||||||
|
|
||||||
# Only needed once when bootstrapping an empty database. Remove the password
|
|
||||||
# from the environment after the first administrator has been created.
|
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
|
||||||
INITIAL_ADMIN_PASSWORD=
|
|
||||||
INITIAL_ADMIN_EMAIL=admin@example.internal
|
|
||||||
|
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_SECURE=false
|
|
||||||
SMTP_REQUIRE_TLS=true
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
SMTP_FROM=
|
|
||||||
EMAIL_VERIFY_TOKEN_TTL_MINUTES=30
|
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES=30
|
|
||||||
|
|
||||||
# Maximum size for each uploaded PDF document (1-100 MB).
|
|
||||||
DOCUMENT_MAX_FILE_SIZE_MB=25
|
|
||||||
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
296
DEPLOYMENT_GUIDE.md
Normal file
296
DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# 📘 Hướng dẫn Triển khai AccManager
|
||||||
|
|
||||||
|
Tài liệu này hướng dẫn chi tiết cách build Docker image, push lên registry, và triển khai trên server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Mục lục
|
||||||
|
|
||||||
|
1. [Chuẩn bị](#chuẩn-bị)
|
||||||
|
2. [Máy DEV: Build & Push Image](#máy-dev-build--push-image)
|
||||||
|
3. [Máy Server: Pull & Deploy](#máy-server-pull--deploy)
|
||||||
|
4. [Kiểm tra & Troubleshoot](#kiểm-tra--troubleshoot)
|
||||||
|
5. [Cập nhật bản mới](#cập-nhật-bản-mới)
|
||||||
|
6. [Public Domain qua Nginx Proxy Manager](#public-domain-qua-nginx-proxy-manager)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Chuẩn bị
|
||||||
|
|
||||||
|
### Tài khoản & Biến môi trường
|
||||||
|
|
||||||
|
1. **Docker Hub Account**: Tạo account tại https://hub.docker.com
|
||||||
|
- Username: `toiiiiday` (dùng username của bạn)
|
||||||
|
- Repository: `accmanager`
|
||||||
|
|
||||||
|
2. **File .env trên máy dev** - Kiểm tra nội dung:
|
||||||
|
```env
|
||||||
|
NODE_ENV=production
|
||||||
|
APP_PORT=3000
|
||||||
|
DOCKER_IMAGE=toiiiiday/accmanager:1.0.1
|
||||||
|
PORT=3000
|
||||||
|
DB_SERVER=172.20.235.176
|
||||||
|
DB_USER=sa
|
||||||
|
DB_PASSWORD=robotics@2022
|
||||||
|
DB_NAME=AccManager
|
||||||
|
DB_ENCRYPT=false
|
||||||
|
DB_TRUST_CERTIFICATE=true
|
||||||
|
DB_CONNECT_TIMEOUT=30000
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Thư mục trên server** - SSH vào server tạo:
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/accmanager
|
||||||
|
cd ~/accmanager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Máy DEV: Build & Push Image
|
||||||
|
|
||||||
|
### Bước 1: Chọn version mới
|
||||||
|
|
||||||
|
Mỗi lần sửa code và muốn deploy, chọn một tag mới theo thứ tự tăng dần.
|
||||||
|
|
||||||
|
Ví dụ:
|
||||||
|
- Bản cũ đang chạy: `1.0.2`
|
||||||
|
- Bản mới sau khi sửa code: `1.0.3`
|
||||||
|
|
||||||
|
### Bước 2: Build image mới (trên máy DEV)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\RoboticsSource\AccManager
|
||||||
|
docker build -t toiiiiday/accmanager:"version" .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bước 3: Push image mới lên Docker Hub
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker push toiiiiday/accmanager:"version"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bước 4: Kiểm tra image đã có trên registry
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker image ls | findstr toiiiiday/accmanager
|
||||||
|
```
|
||||||
|
|
||||||
|
Hoặc kiểm tra trên Docker Hub:
|
||||||
|
https://hub.docker.com/r/toiiiiday/accmanager/tags
|
||||||
|
|
||||||
|
### Bước 5: Cập nhật .env
|
||||||
|
|
||||||
|
Sửa dòng `DOCKER_IMAGE`:
|
||||||
|
```
|
||||||
|
DOCKER_IMAGE=toiiiiday/accmanager:"version"
|
||||||
|
```
|
||||||
|
|
||||||
|
Mẹo PowerShell (cập nhật nhanh):
|
||||||
|
```powershell
|
||||||
|
(Get-Content .env) -replace '^DOCKER_IMAGE=.*', 'DOCKER_IMAGE=toiiiiday/accmanager:1.0.3' | Set-Content .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bước 6: Copy confi lên server
|
||||||
|
|
||||||
|
Từ máy dev:
|
||||||
|
```powershell
|
||||||
|
scp .env robotics@172.20.235.176:~/accmanager/.env
|
||||||
|
scp docker-compose.yml robotics@172.20.235.176:~/accmanager/docker-compose.yml
|
||||||
|
scp docker-compose.image.yml robotics@172.20.235.176:~/accmanager/docker-compose.image.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐧 Máy Server: Pull & Deploy
|
||||||
|
|
||||||
|
### Bước 1: SSH vào server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh robotics@172.20.235.176
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bước 2: Vào thư mục deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/accmanager
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bước 3: Pull image mới và chạy lại container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env -f docker-compose.image.yml pull accmanager
|
||||||
|
docker compose --env-file .env -f docker-compose.image.yml up -d accmanager
|
||||||
|
```
|
||||||
|
|
||||||
|
Kiểm tra trạng thái:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.image.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Xem log:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.image.yml logs -f accmanager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Kiểm tra & Troubleshoot
|
||||||
|
|
||||||
|
### Kiểm tra app chạy OK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kiểm tra container đang running
|
||||||
|
docker compose -f docker-compose.image.yml ps
|
||||||
|
|
||||||
|
# Xem log (tìm "Server running")
|
||||||
|
docker compose -f docker-compose.image.yml logs --tail=50 accmanager
|
||||||
|
|
||||||
|
|
||||||
|
### Nếu gặp lỗi
|
||||||
|
|
||||||
|
**Lỗi: "image not found"**
|
||||||
|
- Kiểm tra: `cat .env | grep DOCKER_IMAGE`
|
||||||
|
- Đảm bảo image đã push lên Docker Hub, kiểm tra: https://hub.docker.com/r/toiiiiday/accmanager
|
||||||
|
|
||||||
|
**Lỗi: "connection refused"**
|
||||||
|
- Kiểm tra DB Server có chạy: `ssh robotics@172.20.235.176`
|
||||||
|
- Kiểm tra DB credentials trong .env
|
||||||
|
|
||||||
|
**Lỗi: Container restart liên tục**
|
||||||
|
- Xem log: `docker compose -f docker-compose.image.yml logs --tail=100 accmanager`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Cập nhật bản mới
|
||||||
|
|
||||||
|
Áp dụng đúng 8 bước sau cho mỗi lần sửa code:
|
||||||
|
|
||||||
|
### Trên máy DEV
|
||||||
|
|
||||||
|
1. Chọn version mới (ví dụ `1.0.3`)
|
||||||
|
2. Build:
|
||||||
|
```powershell
|
||||||
|
docker build -t toiiiiday/accmanager:"version" .
|
||||||
|
```
|
||||||
|
3. Push:
|
||||||
|
```powershell
|
||||||
|
docker push toiiiiday/accmanager:"version"
|
||||||
|
```
|
||||||
|
4. Cập nhật `.env`:
|
||||||
|
```env
|
||||||
|
DOCKER_IMAGE=toiiiiday/accmanager:"version"
|
||||||
|
```
|
||||||
|
5. Copy `.env` lên server:
|
||||||
|
```powershell
|
||||||
|
scp .env robotics@172.20.235.176:~/accmanager/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trên máy SERVER
|
||||||
|
|
||||||
|
6. SSH và vào thư mục deploy:
|
||||||
|
```bash
|
||||||
|
ssh robotics@172.20.235.176
|
||||||
|
cd ~/accmanager
|
||||||
|
```
|
||||||
|
7. Pull + Up:
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env -f docker-compose.image.yml pull accmanager
|
||||||
|
docker compose --env-file .env -f docker-compose.image.yml up -d accmanager
|
||||||
|
```
|
||||||
|
8. Kiểm tra bản mới đã chạy:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.image.yml ps
|
||||||
|
docker compose -f docker-compose.image.yml logs --tail=50 accmanager
|
||||||
|
```
|
||||||
|
|
||||||
|
### Có cần gắn tag/version cho mỗi phiên bản mới không?
|
||||||
|
|
||||||
|
**Có, nên làm bắt buộc cho production.**
|
||||||
|
|
||||||
|
Lý do:
|
||||||
|
1. Tránh đè image cũ và tránh nhầm lẫn khi deploy.
|
||||||
|
2. Rollback nhanh về bản ổn định trước đó.
|
||||||
|
3. Truy vết được bản code nào đang chạy trên server.
|
||||||
|
4. Tránh rủi ro do dùng `latest` (khó kiểm soát).
|
||||||
|
|
||||||
|
Quy ước khuyến nghị:
|
||||||
|
- `1.0.2` -> fix nhỏ
|
||||||
|
- `1.1.0` -> thêm tính năng
|
||||||
|
- `2.0.0` -> thay đổi lớn/breaking
|
||||||
|
|
||||||
|
Ví dụ rollback về bản cũ `1.0.2`:
|
||||||
|
```env
|
||||||
|
DOCKER_IMAGE=toiiiiday/accmanager:1.0.2
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env -f docker-compose.image.yml pull accmanager
|
||||||
|
docker compose --env-file .env -f docker-compose.image.yml up -d accmanager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Public Domain qua Nginx Proxy Manager
|
||||||
|
|
||||||
|
### Chuẩn bị
|
||||||
|
|
||||||
|
1. **Domain**
|
||||||
|
- Trỏ DNS A record về IP public nơi đặt Nginx Proxy Manager
|
||||||
|
|
||||||
|
2. **Firewall/Router**
|
||||||
|
- Mở inbound port 80, 443 từ Internet
|
||||||
|
- Port 3000 chỉ nội bộ (không public)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Các file liên quan
|
||||||
|
|
||||||
|
- `docker-compose.yml` - Build local
|
||||||
|
- `docker-compose.image.yml` - Pull & run từ registry
|
||||||
|
- `.env` - Biến môi trường
|
||||||
|
- `.dockerignore` - Ignore file khi build
|
||||||
|
- `Dockerfile` - Config image
|
||||||
|
- `deploy-dev.ps1` - Script build & push (Windows)
|
||||||
|
- `deploy-server.sh` - Script pull & deploy (Linux)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Tóm tắt quy trình
|
||||||
|
|
||||||
|
```
|
||||||
|
Máy DEV
|
||||||
|
├─ Chỉnh sửa code
|
||||||
|
├─ docker build -t toiiiiday/accmanager:X .
|
||||||
|
├─ docker push toiiiiday/accmanager:X
|
||||||
|
├─ sửa DOCKER_IMAGE trong .env
|
||||||
|
└─ scp .env server (copy env)
|
||||||
|
|
||||||
|
Máy Server
|
||||||
|
├─ ssh vào server
|
||||||
|
├─ cd ~/accmanager
|
||||||
|
├─ docker compose pull accmanager
|
||||||
|
└─ docker compose up -d accmanager
|
||||||
|
|
||||||
|
Nginx Proxy Manager
|
||||||
|
└─ Forward từ domain → http://172.20.235.176:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Mẹo
|
||||||
|
|
||||||
|
1. **Luôn tăng version tag**: 1.0.1 → 1.0.2 → 1.0.3
|
||||||
|
2. **Rollback nhanh**: Chỉ cần đổi DOCKER_IMAGE trong .env sang tag cũ rồi deploy lại
|
||||||
|
3. **Giữ log**: `docker compose logs --tail=1000 > backup.log`
|
||||||
|
4. **Restart container**: `docker compose -f docker-compose.image.yml restart accmanager`
|
||||||
|
5. **Xóa container cũ**: `docker compose -f docker-compose.image.yml down`
|
||||||
|
6. **Không dùng `latest` cho production**: luôn deploy bằng tag cụ thể
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Cần giúp? Xem log chi tiết:**
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.image.yml logs --tail=200 accmanager
|
||||||
|
```
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
FROM node:22-bookworm-slim
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV TZ=Asia/Ho_Chi_Minh
|
|
||||||
ENV APP_TIME_ZONE=Asia/Ho_Chi_Minh
|
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
@@ -14,4 +12,4 @@ COPY public ./public
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "backend/server.js"]
|
CMD ["node", "backend/server.js"]
|
||||||
@@ -10,7 +10,6 @@ Thay vì lưu rải rác tài khoản ứng dụng ở nhiều nơi, AccManager
|
|||||||
- Quản lý danh sách ứng dụng đang sử dụng trong công ty.
|
- Quản lý danh sách ứng dụng đang sử dụng trong công ty.
|
||||||
- Gán tài khoản truy cập ứng dụng cho từng người dùng.
|
- Gán tài khoản truy cập ứng dụng cho từng người dùng.
|
||||||
- Theo dõi thông tin tài khoản rõ ràng, tránh thất lạc.
|
- Theo dõi thông tin tài khoản rõ ràng, tránh thất lạc.
|
||||||
- Lưu trữ, tìm kiếm, xem và tải xuống tài liệu PDF dùng chung.
|
|
||||||
|
|
||||||
## Ai sẽ sử dụng
|
## Ai sẽ sử dụng
|
||||||
|
|
||||||
@@ -23,8 +22,7 @@ Thay vì lưu rải rác tài khoản ứng dụng ở nhiều nơi, AccManager
|
|||||||
2. Vào mục Người dùng để tạo mới hoặc cập nhật thông tin nhân sự.
|
2. Vào mục Người dùng để tạo mới hoặc cập nhật thông tin nhân sự.
|
||||||
3. Vào mục Ứng dụng để thêm các hệ thống cần quản lý.
|
3. Vào mục Ứng dụng để thêm các hệ thống cần quản lý.
|
||||||
4. Vào mục Tài khoản để gán tài khoản ứng dụng cho đúng người.
|
4. Vào mục Tài khoản để gán tài khoản ứng dụng cho đúng người.
|
||||||
5. Vào mục Tài liệu PDF để tải lên hoặc xem tài liệu dùng chung.
|
5. Dùng nút Xem chi tiết trong danh sách người dùng để kiểm tra đầy đủ thông tin trước khi chỉnh sửa.
|
||||||
6. Dùng nút Xem chi tiết trong danh sách người dùng để kiểm tra đầy đủ thông tin trước khi chỉnh sửa.
|
|
||||||
|
|
||||||
## Quy trình vận hành gợi ý
|
## Quy trình vận hành gợi ý
|
||||||
|
|
||||||
@@ -60,5 +58,5 @@ He thong da ho tro xac thuc email cho:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Phiên bản tài liệu: 4.0.0
|
Phiên bản tài liệu: 3.0.0
|
||||||
Cập nhật: Tháng 4 năm 2026
|
Cập nhật: Tháng 4 năm 2026
|
||||||
|
|||||||
765
UI_DESIGN.md
765
UI_DESIGN.md
@@ -1,765 +0,0 @@
|
|||||||
# Portable UI Style Guide - AccManager Inspired
|
|
||||||
|
|
||||||
Tài liệu này dùng để mang phong cách UI hiện tại của AccManager sang một dự án khác. Nội dung tập trung vào ngôn ngữ thiết kế, token, layout, component pattern và checklist triển khai, không phụ thuộc chặt vào nghiệp vụ tài khoản/tài sản của AccManager.
|
|
||||||
|
|
||||||
## 1. Design DNA
|
|
||||||
|
|
||||||
Phong cách UI gốc là một **admin console nội bộ**: gọn, sáng, nhiều dữ liệu, dễ quét bảng, thao tác nhanh và ít trang trí thừa.
|
|
||||||
|
|
||||||
Tinh thần chính:
|
|
||||||
|
|
||||||
- Ưu tiên hiệu quả vận hành hơn cảm giác marketing.
|
|
||||||
- Nền sáng, bề mặt trắng/xám nhạt, màu nhấn xanh primary.
|
|
||||||
- Layout chắc, dày thông tin nhưng vẫn thoáng.
|
|
||||||
- Component nhỏ gọn: table, filter, modal, form, badge, icon action.
|
|
||||||
- Button và icon rõ chức năng, phản hồi hover/active nhẹ.
|
|
||||||
- Typography dùng heading đậm, label nhỏ uppercase, nội dung bảng vừa đủ đọc.
|
|
||||||
- Phù hợp cho dashboard, CRM, ERP mini, tool nội bộ, quản lý dữ liệu, asset/account/user management.
|
|
||||||
|
|
||||||
Không nên biến phong cách này thành landing page, hero lớn, nhiều gradient trang trí, card marketing hoặc layout quá thoáng kiểu portfolio.
|
|
||||||
|
|
||||||
## 2. Stack Khuyến Nghị
|
|
||||||
|
|
||||||
Để tái tạo đúng style, dự án mới nên dùng:
|
|
||||||
|
|
||||||
- Tailwind CSS.
|
|
||||||
- Font heading: `Manrope`.
|
|
||||||
- Font body: `Inter`.
|
|
||||||
- Icon: Google Material Symbols Outlined.
|
|
||||||
- Toast/notification: Notiflix hoặc thư viện tương đương.
|
|
||||||
- Table data với sticky header, horizontal scroll trên mobile.
|
|
||||||
|
|
||||||
Google Fonts:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
|
||||||
```
|
|
||||||
|
|
||||||
Material Symbols base CSS:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.material-symbols-outlined {
|
|
||||||
font-family: 'Material Symbols Outlined';
|
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: normal;
|
|
||||||
text-transform: none;
|
|
||||||
display: inline-flex;
|
|
||||||
white-space: nowrap;
|
|
||||||
direction: ltr;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Tailwind Design Tokens
|
|
||||||
|
|
||||||
Có thể copy phần `theme.extend` này sang `tailwind.config.js` của dự án mới.
|
|
||||||
|
|
||||||
```js
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
"primary": "#3755c3",
|
|
||||||
"primary-dim": "#2848b7",
|
|
||||||
"primary-container": "#dde1ff",
|
|
||||||
"on-primary": "#f8f7ff",
|
|
||||||
"background": "#f7f9fb",
|
|
||||||
"surface": "#f7f9fb",
|
|
||||||
"surface-container-lowest": "#ffffff",
|
|
||||||
"surface-container-low": "#f0f4f7",
|
|
||||||
"surface-container": "#e8eff3",
|
|
||||||
"surface-container-high": "#e1e9ee",
|
|
||||||
"surface-container-highest": "#d9e4ea",
|
|
||||||
"on-surface": "#2a3439",
|
|
||||||
"on-surface-variant": "#566166",
|
|
||||||
"outline": "#717c82",
|
|
||||||
"outline-variant": "#a9b4b9",
|
|
||||||
"error": "#9f403d",
|
|
||||||
"error-container": "#fe8983"
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
headline: ["Manrope", "sans-serif"],
|
|
||||||
body: ["Inter", "sans-serif"],
|
|
||||||
label: ["Inter", "sans-serif"]
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
DEFAULT: "0.125rem",
|
|
||||||
lg: "0.25rem",
|
|
||||||
xl: "0.5rem",
|
|
||||||
full: "0.75rem"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Base body:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<body class="bg-background text-on-surface antialiased">
|
|
||||||
```
|
|
||||||
|
|
||||||
Font rule:
|
|
||||||
|
|
||||||
```css
|
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
h1, h2, h3, .brand-logo { font-family: 'Manrope', sans-serif; }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Color Usage
|
|
||||||
|
|
||||||
Màu nền:
|
|
||||||
|
|
||||||
- App background: `bg-background`.
|
|
||||||
- Main card/table: `bg-white` hoặc `bg-surface-container-lowest`.
|
|
||||||
- Filter bar/input subtle: `bg-surface-container-low`.
|
|
||||||
- Header/table head: `bg-slate-50`.
|
|
||||||
|
|
||||||
Màu chữ:
|
|
||||||
|
|
||||||
- Text chính: `text-on-surface`, `text-slate-900`.
|
|
||||||
- Text phụ: `text-on-surface-variant`, `text-slate-500`, `text-slate-600`.
|
|
||||||
- Link/action chính: `text-primary`.
|
|
||||||
|
|
||||||
Màu hành động:
|
|
||||||
|
|
||||||
- Primary: `bg-primary hover:bg-primary-dim text-on-primary`.
|
|
||||||
- Secondary: border slate, text slate.
|
|
||||||
- Danger: `bg-red-600 hover:bg-red-700 text-white` hoặc `text-error`.
|
|
||||||
- Success: emerald/green.
|
|
||||||
- Pending/warning: amber/yellow.
|
|
||||||
- Info: blue.
|
|
||||||
|
|
||||||
Tránh:
|
|
||||||
|
|
||||||
- Dùng quá nhiều gradient.
|
|
||||||
- Dùng một palette đơn sắc toàn xanh.
|
|
||||||
- Nền tối toàn trang nếu chưa thiết kế dark mode đầy đủ.
|
|
||||||
- Màu quá rực trên bảng dữ liệu.
|
|
||||||
|
|
||||||
## 5. Typography Scale
|
|
||||||
|
|
||||||
Page title:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Page Title</h1>
|
|
||||||
```
|
|
||||||
|
|
||||||
Subtitle:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<p class="text-sm text-on-surface-variant">Short page description.</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
Table header / form label:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<span class="text-[10px] font-bold uppercase tracking-wider text-on-surface-variant">Label</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
Table body:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<td class="px-4 py-3 text-sm text-slate-600">Value</td>
|
|
||||||
```
|
|
||||||
|
|
||||||
Badge text:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<span class="px-2 py-1 rounded text-xs font-semibold">Status</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
Nguyên tắc:
|
|
||||||
|
|
||||||
- Heading đậm, ngắn, dễ scan.
|
|
||||||
- Label nhỏ, uppercase, tracking rộng.
|
|
||||||
- Nội dung bảng không quá lớn để giữ mật độ dữ liệu.
|
|
||||||
- Không dùng hero-scale type trong dashboard/tool view.
|
|
||||||
|
|
||||||
## 6. App Shell Pattern
|
|
||||||
|
|
||||||
Dùng shell này cho các app quản trị/dữ liệu.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<body class="app-shell bg-background text-on-surface antialiased flex h-screen w-screen overflow-hidden">
|
|
||||||
<aside id="appSidebar" class="h-screen w-56 flex flex-col bg-slate-100 border-r border-outline-variant/10 shrink-0">
|
|
||||||
<!-- Sidebar content -->
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<button id="sidebarBackdrop" type="button" aria-label="Close navigation"></button>
|
|
||||||
|
|
||||||
<main id="appMain" class="flex-1 flex flex-col h-screen min-w-0">
|
|
||||||
<header class="h-14 flex items-center justify-between px-6 bg-slate-50/80 backdrop-blur-xl border-b border-outline-variant/10 shrink-0">
|
|
||||||
<!-- Topbar actions -->
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div id="mainContent" class="flex-1 overflow-hidden">
|
|
||||||
<!-- Current page -->
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
```
|
|
||||||
|
|
||||||
Sidebar style:
|
|
||||||
|
|
||||||
- Width desktop: `w-56`.
|
|
||||||
- Background: `bg-slate-100`.
|
|
||||||
- Border right: `border-outline-variant/10`.
|
|
||||||
- Group label: uppercase, `text-[10px]`, tracking rộng.
|
|
||||||
- Active nav: left border xanh, nền slate nhạt, text đậm.
|
|
||||||
- Nav item: icon + label, height vừa phải, hover nền slate.
|
|
||||||
|
|
||||||
Active nav example:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 border-l-4 border-blue-600 bg-slate-200/80 text-slate-900 font-bold rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">dashboard</span>
|
|
||||||
<span>Tổng quan</span>
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
Inactive nav:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-slate-600 hover:text-slate-900 hover:bg-slate-200/50 transition-all rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">apps</span>
|
|
||||||
<span>Ứng dụng</span>
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Page Layout Pattern
|
|
||||||
|
|
||||||
Mỗi page nên theo cấu trúc:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="page-name flex flex-col p-4 md:p-6 overflow-hidden h-full">
|
|
||||||
<div class="page-header flex items-center justify-between gap-4 mb-5 shrink-0">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Page Title</h1>
|
|
||||||
<p class="text-sm text-on-surface-variant">Short explanation.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<!-- Action buttons -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-filters flex items-center gap-3 mb-4 shrink-0">
|
|
||||||
<!-- Search/filter controls -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden min-h-0">
|
|
||||||
<!-- Table/list/content -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Nguyên tắc:
|
|
||||||
|
|
||||||
- Page padding luôn `p-4 md:p-6`.
|
|
||||||
- Header không quá cao.
|
|
||||||
- Action chính nằm bên phải.
|
|
||||||
- Filter nằm ngay trên bảng.
|
|
||||||
- Content panel chiếm hết phần còn lại.
|
|
||||||
- Không bọc section bằng quá nhiều card lồng nhau.
|
|
||||||
|
|
||||||
## 8. Dashboard Pattern
|
|
||||||
|
|
||||||
Dashboard nên có:
|
|
||||||
|
|
||||||
- Header ngắn.
|
|
||||||
- CTA chính.
|
|
||||||
- Metric grid.
|
|
||||||
- Recent activity hoặc bảng tóm tắt.
|
|
||||||
|
|
||||||
Metric card:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="bg-surface-container-lowest p-4 rounded-xl border border-outline-variant/15 flex flex-col">
|
|
||||||
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider mb-2">Applications</span>
|
|
||||||
<div class="flex items-baseline justify-between">
|
|
||||||
<span class="text-2xl font-black text-on-surface">24</span>
|
|
||||||
<span class="text-[10px] font-bold text-on-surface-variant">18 Active</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Grid:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="dashboard-stats grid grid-cols-4 gap-4 shrink-0">
|
|
||||||
```
|
|
||||||
|
|
||||||
Responsive:
|
|
||||||
|
|
||||||
- Desktop: 4 cột.
|
|
||||||
- Tablet/mobile: 2 cột.
|
|
||||||
- Mobile nhỏ: 1 cột.
|
|
||||||
|
|
||||||
## 9. Table Pattern
|
|
||||||
|
|
||||||
Đây là component quan trọng nhất của style này.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="flex-1 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden min-h-0">
|
|
||||||
<div class="table-wrap overflow-y-auto overflow-x-auto flex-1">
|
|
||||||
<table class="w-full text-left border-collapse" style="min-width: 900px;">
|
|
||||||
<thead class="sticky top-0 z-10 bg-slate-50 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Name</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-100">
|
|
||||||
<tr class="hover:bg-slate-50/80 transition-colors">
|
|
||||||
<td class="px-4 py-3 text-sm text-slate-700">Example</td>
|
|
||||||
<td class="px-4 py-3 text-right">...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-pager flex items-center justify-between px-4 py-2 border-t border-slate-200 bg-slate-50 text-xs text-slate-600">
|
|
||||||
<span>Showing 1-10 of 50</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button class="px-2 py-1 border border-slate-200 rounded text-xs">Prev</button>
|
|
||||||
<span class="text-[11px]">Page 1 / 5</span>
|
|
||||||
<button class="px-2 py-1 border border-slate-200 rounded text-xs">Next</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Quy ước table:
|
|
||||||
|
|
||||||
- Header sticky.
|
|
||||||
- Row hover nhẹ.
|
|
||||||
- Cell padding đều.
|
|
||||||
- Text trong bảng ưu tiên `text-sm`.
|
|
||||||
- Header dùng `text-[10px] uppercase`.
|
|
||||||
- Nếu nhiều cột, đặt `min-width` và cho horizontal scroll.
|
|
||||||
- Nếu có action column quan trọng, có thể sticky bên phải.
|
|
||||||
|
|
||||||
Sticky action column:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<td class="sticky right-0 z-10 bg-white group-hover:bg-slate-50/80 px-4 py-3 text-right shadow-[-10px_0_12px_-12px_rgba(15,23,42,0.45)]">
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. Filter/Search Pattern
|
|
||||||
|
|
||||||
Filter bar:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="page-filters flex items-center gap-3 mb-4">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Status</span>
|
|
||||||
<select class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
|
|
||||||
<option>All</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5 flex-1">
|
|
||||||
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Search</span>
|
|
||||||
<input class="flex-1 bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Search...">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Nguyên tắc:
|
|
||||||
|
|
||||||
- Filter ngắn, đặt sát bảng.
|
|
||||||
- Search chiếm phần còn lại.
|
|
||||||
- Label filter nhỏ và uppercase.
|
|
||||||
- Trên mobile, filter chuyển thành column/full width.
|
|
||||||
|
|
||||||
## 11. Button Pattern
|
|
||||||
|
|
||||||
Primary:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button class="bg-primary hover:bg-primary-dim text-on-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95">
|
|
||||||
<span class="material-symbols-outlined text-base">add</span>
|
|
||||||
Add New
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
Secondary:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button class="border border-slate-300 hover:bg-slate-100 text-slate-700 px-3 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95">
|
|
||||||
<span class="material-symbols-outlined text-base">download</span>
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
Danger:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-xs font-bold">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
Icon action:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button class="p-1.5 text-slate-400 transition-colors hover:text-primary" title="Edit">
|
|
||||||
<span class="material-symbols-outlined text-lg">edit</span>
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
Disabled:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button class="opacity-50 cursor-not-allowed" disabled>Disabled</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 12. Form Pattern
|
|
||||||
|
|
||||||
Form field:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Name</label>
|
|
||||||
<input class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Enter name">
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Readonly:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<input readonly class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50 text-slate-600">
|
|
||||||
```
|
|
||||||
|
|
||||||
Textarea:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<textarea class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none"></textarea>
|
|
||||||
```
|
|
||||||
|
|
||||||
Field with icon:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
|
|
||||||
<span class="material-symbols-outlined text-base">person</span>
|
|
||||||
</span>
|
|
||||||
<input class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-sm font-medium">
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 13. Badge/Status Pattern
|
|
||||||
|
|
||||||
Online/offline:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<div class="w-1.5 h-1.5 rounded-full bg-primary ring-2 ring-primary/20"></div>
|
|
||||||
<span class="text-xs font-medium text-on-primary-fixed-variant">Online</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Status badges:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<span class="inline-block px-2 py-1 rounded text-xs font-semibold bg-emerald-100 text-emerald-700">Trong kho</span>
|
|
||||||
<span class="inline-block px-2 py-1 rounded text-xs font-semibold bg-blue-100 text-blue-700">Đang sử dụng</span>
|
|
||||||
<span class="inline-block px-2 py-1 rounded text-xs font-semibold bg-yellow-100 text-yellow-700">Đang chờ</span>
|
|
||||||
<span class="inline-block px-2 py-1 rounded text-xs font-semibold bg-red-100 text-red-700">Từ chối</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
Role chip:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700">Admin</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 14. Modal Pattern
|
|
||||||
|
|
||||||
Backdrop + content:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="exampleModal">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Modal Title</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="p-6 space-y-4">
|
|
||||||
<!-- Fields -->
|
|
||||||
<div class="flex gap-3 pt-4">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg">Cancel</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Modal CSS:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.modal-backdrop {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop.open {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
transform: scale(0.95);
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop.open .modal-content {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Danger confirm modal:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 bg-red-50 flex items-center gap-3">
|
|
||||||
<span class="material-symbols-outlined text-red-600 text-2xl">warning</span>
|
|
||||||
<h3 class="text-base font-extrabold text-red-700">Xác nhận xóa</h3>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 15. Login/Auth Screen Pattern
|
|
||||||
|
|
||||||
Auth screen dùng centered card:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<body class="bg-gradient-to-br from-blue-50 via-background to-purple-50 antialiased">
|
|
||||||
<div class="min-h-screen flex items-center justify-center px-4">
|
|
||||||
<div class="w-full max-w-md">
|
|
||||||
<div class="bg-white rounded-2xl shadow-lg border border-outline-variant/10 p-8">
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
|
|
||||||
<span class="material-symbols-outlined text-primary text-4xl">security</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-black text-slate-900 tracking-tight">Product Name</h1>
|
|
||||||
<p class="text-xs uppercase tracking-widest text-on-surface-variant font-bold mt-2">Admin Console</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
```
|
|
||||||
|
|
||||||
Auth tabs:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="flex gap-2 mb-6" role="tablist">
|
|
||||||
<button class="flex-1 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider border border-outline-variant/30 bg-primary text-on-primary shadow-sm">Đăng nhập</button>
|
|
||||||
<button class="flex-1 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider border border-outline-variant/30 bg-surface-container-low text-on-surface-variant">Đăng ký</button>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Alert states:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="bg-error-container/20 text-error/80 border border-error/30 rounded-lg px-4 py-3 text-xs font-medium">Error message</div>
|
|
||||||
<div class="bg-green-50 text-green-800 border border-green-200 rounded-lg px-4 py-3 text-xs font-medium">Success message</div>
|
|
||||||
<div class="bg-amber-50 text-amber-800 border border-amber-200 rounded-lg px-4 py-3 text-xs font-medium">Warning message</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 16. Responsive Rules
|
|
||||||
|
|
||||||
Recommended CSS:
|
|
||||||
|
|
||||||
```css
|
|
||||||
#mobileMenuBtn,
|
|
||||||
#sidebarBackdrop {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
body.app-shell {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100dvh;
|
|
||||||
height: 100dvh;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobileMenuBtn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebarBackdrop {
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 70;
|
|
||||||
background: rgba(15, 23, 42, 0.45);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
#appSidebar {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
height: 100dvh;
|
|
||||||
width: min(82vw, 16rem);
|
|
||||||
z-index: 80;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.mobile-nav-open #sidebarBackdrop {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.mobile-nav-open #appSidebar {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header,
|
|
||||||
.page-filters,
|
|
||||||
.users-controls {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap table {
|
|
||||||
min-width: 700px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-pager {
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop .modal-content {
|
|
||||||
width: calc(100% - 1rem);
|
|
||||||
max-height: min(88dvh, 700px);
|
|
||||||
margin: 0.5rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.dashboard-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Mobile behavior:
|
|
||||||
|
|
||||||
- Sidebar thành off-canvas.
|
|
||||||
- Topbar gọn lại.
|
|
||||||
- Profile metadata nên ẩn.
|
|
||||||
- Header/filter chuyển column.
|
|
||||||
- Table luôn scroll ngang nếu nhiều cột.
|
|
||||||
- Modal gần bottom và giới hạn chiều cao.
|
|
||||||
|
|
||||||
## 17. UX Rules
|
|
||||||
|
|
||||||
Giữ những nguyên tắc này khi áp dụng sang dự án khác:
|
|
||||||
|
|
||||||
1. Mỗi màn dữ liệu cần có search hoặc filter nếu danh sách có thể dài.
|
|
||||||
2. Action chính luôn rõ ràng, nằm ở page header.
|
|
||||||
3. Action nguy hiểm luôn cần confirm.
|
|
||||||
4. Action theo quyền nên disabled trực quan, không chỉ báo lỗi sau khi bấm.
|
|
||||||
5. Table phải có empty state.
|
|
||||||
6. Table dài cần pagination.
|
|
||||||
7. Form dài nên chia grid 2 cột trên desktop, 1 cột mobile.
|
|
||||||
8. Modal dài phải scroll trong content, không làm tràn viewport.
|
|
||||||
9. Badge trạng thái phải thống nhất màu theo ý nghĩa.
|
|
||||||
10. Icon-only button phải có `title` hoặc accessible name.
|
|
||||||
11. Không dùng nhiều card trang trí trong dashboard vận hành.
|
|
||||||
12. Text không được overlap hoặc tràn button/card.
|
|
||||||
|
|
||||||
## 18. Accessibility Checklist
|
|
||||||
|
|
||||||
Khi port sang dự án mới, nên bổ sung:
|
|
||||||
|
|
||||||
- `aria-label` cho icon-only buttons.
|
|
||||||
- `role="dialog"` và `aria-modal="true"` cho modal.
|
|
||||||
- Focus trap trong modal.
|
|
||||||
- Đóng modal bằng phím `Escape`.
|
|
||||||
- `aria-expanded` cho mobile menu.
|
|
||||||
- `aria-selected` cho tabs.
|
|
||||||
- Label đầy đủ cho form input.
|
|
||||||
- Không chỉ dùng màu để truyền đạt trạng thái; nên có text status.
|
|
||||||
- Contrast đủ tốt cho text nhỏ.
|
|
||||||
|
|
||||||
## 19. Implementation Checklist
|
|
||||||
|
|
||||||
Khi bắt đầu dự án mới:
|
|
||||||
|
|
||||||
1. Cài Tailwind và plugin forms nếu cần.
|
|
||||||
2. Copy design tokens vào `tailwind.config.js`.
|
|
||||||
3. Thêm Google Fonts và Material Symbols.
|
|
||||||
4. Tạo app shell: sidebar, topbar, content area.
|
|
||||||
5. Tạo component base cho button, table, filter, modal, badge.
|
|
||||||
6. Tạo responsive sidebar theo breakpoint `900px`.
|
|
||||||
7. Áp dụng page layout pattern cho từng màn hình.
|
|
||||||
8. Kiểm tra table trên mobile với horizontal scroll.
|
|
||||||
9. Chuẩn hóa status color map.
|
|
||||||
10. Thêm empty/loading/error states.
|
|
||||||
|
|
||||||
## 20. Mapping Sang Dự Án Khác
|
|
||||||
|
|
||||||
Nếu dự án mới không phải quản lý tài khoản/tài sản, vẫn có thể dùng style này bằng cách đổi tên module:
|
|
||||||
|
|
||||||
| AccManager concept | Dự án mới có thể thay bằng |
|
|
||||||
| --- | --- |
|
|
||||||
| Applications | Products, Services, Modules, Integrations |
|
|
||||||
| Accounts | Records, Credentials, Customers, Contracts |
|
|
||||||
| Assets | Inventory, Devices, Documents, Items |
|
|
||||||
| Users | Members, Staff, Operators |
|
|
||||||
| Departments | Teams, Groups, Categories |
|
|
||||||
| Projects | Campaigns, Workspaces, Sites |
|
|
||||||
| Borrow/Return | Requests, Tickets, Approvals, Workflows |
|
|
||||||
|
|
||||||
Điều quan trọng là giữ cùng cấu trúc:
|
|
||||||
|
|
||||||
- Sidebar nhóm chức năng.
|
|
||||||
- Page header + actions.
|
|
||||||
- Filter/search.
|
|
||||||
- Table/list.
|
|
||||||
- Modal CRUD.
|
|
||||||
- Badge trạng thái.
|
|
||||||
- Toast feedback.
|
|
||||||
|
|
||||||
## 21. Những Gì Nên Tránh Khi Port
|
|
||||||
|
|
||||||
- Copy nguyên text nghiệp vụ AccManager nếu dự án mới khác domain.
|
|
||||||
- Copy table quá nhiều cột khi domain không cần.
|
|
||||||
- Lạm dụng modal cho mọi thứ nếu workflow mới cần full page editor.
|
|
||||||
- Dùng gradient nền cho app shell chính.
|
|
||||||
- Tăng border radius quá lớn làm mất chất admin console.
|
|
||||||
- Dùng font khác quá mềm hoặc decorative.
|
|
||||||
- Thêm quá nhiều shadow, glassmorphism, decorative blobs.
|
|
||||||
- Trộn nhiều style icon khác nhau.
|
|
||||||
|
|
||||||
8033
backend/server.js
8033
backend/server.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,34 +8,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
TZ: ${TZ:-Asia/Ho_Chi_Minh}
|
DB_SERVER: ${DB_SERVER:-172.20.235.176}
|
||||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh}
|
DB_USER: ${DB_USER:-sa}
|
||||||
DB_SERVER: ${DB_SERVER:?DB_SERVER is required}
|
DB_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||||
DB_USER: ${DB_USER:?DB_USER is required}
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
|
||||||
DB_NAME: ${DB_NAME:-AccManager}
|
DB_NAME: ${DB_NAME:-AccManager}
|
||||||
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
||||||
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
||||||
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
|
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
|
||||||
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
|
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
|
||||||
DATA_ENCRYPTION_SECRET: ${DATA_ENCRYPTION_SECRET:?DATA_ENCRYPTION_SECRET is required}
|
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
|
||||||
APP_BASE_URL: ${APP_BASE_URL:?APP_BASE_URL is required}
|
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
|
||||||
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
|
||||||
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12}
|
|
||||||
REMEMBER_SESSION_TTL_DAYS: ${REMEMBER_SESSION_TTL_DAYS:-14}
|
|
||||||
ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-true}
|
|
||||||
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-1}
|
|
||||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
|
||||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
|
||||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@accmanager.local}
|
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||||
SMTP_REQUIRE_TLS: ${SMTP_REQUIRE_TLS:-true}
|
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
SMTP_PASS: ${SMTP_PASS:-}
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
|
|
||||||
DOCUMENT_MAX_FILE_SIZE_MB: ${DOCUMENT_MAX_FILE_SIZE_MB:-25}
|
|
||||||
@@ -10,34 +10,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
TZ: ${TZ:-Asia/Ho_Chi_Minh}
|
DB_SERVER: ${DB_SERVER:-172.20.235.176}
|
||||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh}
|
DB_USER: ${DB_USER:-sa}
|
||||||
DB_SERVER: ${DB_SERVER:?DB_SERVER is required}
|
DB_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||||
DB_USER: ${DB_USER:?DB_USER is required}
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
|
||||||
DB_NAME: ${DB_NAME:-AccManager}
|
DB_NAME: ${DB_NAME:-AccManager}
|
||||||
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
||||||
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
||||||
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
|
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
|
||||||
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
|
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
|
||||||
DATA_ENCRYPTION_SECRET: ${DATA_ENCRYPTION_SECRET:?DATA_ENCRYPTION_SECRET is required}
|
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
|
||||||
APP_BASE_URL: ${APP_BASE_URL:?APP_BASE_URL is required}
|
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
|
||||||
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
|
||||||
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12}
|
|
||||||
REMEMBER_SESSION_TTL_DAYS: ${REMEMBER_SESSION_TTL_DAYS:-14}
|
|
||||||
ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-true}
|
|
||||||
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-1}
|
|
||||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
|
||||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
|
||||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@accmanager.local}
|
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||||
SMTP_REQUIRE_TLS: ${SMTP_REQUIRE_TLS:-true}
|
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
SMTP_PASS: ${SMTP_PASS:-}
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
|
|
||||||
DOCUMENT_MAX_FILE_SIZE_MB: ${DOCUMENT_MAX_FILE_SIZE_MB:-25}
|
|
||||||
2685
package-lock.json
generated
2685
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -6,8 +6,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node backend/server.js",
|
"start": "node backend/server.js",
|
||||||
"dev": "nodemon backend/server.js",
|
"dev": "nodemon backend/server.js",
|
||||||
"test": "node --test",
|
|
||||||
"check": "node --check backend/server.js && node --check public/js/app.js && npm audit",
|
|
||||||
"build:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --minify",
|
"build:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --minify",
|
||||||
"watch:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --watch"
|
"watch:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --watch"
|
||||||
},
|
},
|
||||||
@@ -19,20 +17,15 @@
|
|||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.6.0",
|
"mssql": "^9.1.1",
|
||||||
"helmet": "^8.3.0",
|
"multer": "^2.1.1",
|
||||||
"mssql": "^12.7.0",
|
"nodemailer": "^8.0.4",
|
||||||
"multer": "^2.2.0",
|
"xlsx": "^0.18.5"
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,630 +0,0 @@
|
|||||||
/* Mobile-first usability layer for the AccManager application. */
|
|
||||||
|
|
||||||
html {
|
|
||||||
min-width: 320px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
a,
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap,
|
|
||||||
.overflow-x-auto {
|
|
||||||
overscroll-behavior-x: contain;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
body.app-shell,
|
|
||||||
#appMain,
|
|
||||||
#mainContent {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.mobile-nav-open {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#appSidebar {
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
#appSidebar nav {
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
#appMain > header {
|
|
||||||
min-height: 3.5rem;
|
|
||||||
height: auto;
|
|
||||||
padding-top: max(0.35rem, env(safe-area-inset-top));
|
|
||||||
padding-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobileMenuBtn,
|
|
||||||
#profileBtn,
|
|
||||||
#logoutBtn,
|
|
||||||
#pendingAssetRequestsBtn {
|
|
||||||
min-width: 2.75rem;
|
|
||||||
min-height: 2.75rem;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pendingAssetRequestsBtn {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent > [class*="-page"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.875rem;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-page {
|
|
||||||
padding-bottom: calc(0.875rem + env(safe-area-inset-bottom)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-header,
|
|
||||||
.page-header {
|
|
||||||
margin-bottom: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-header > div,
|
|
||||||
.page-header > div {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-header h1,
|
|
||||||
.page-header h1,
|
|
||||||
.users-page h1 {
|
|
||||||
font-size: 1.35rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-header p,
|
|
||||||
.page-header p,
|
|
||||||
.users-page h1 + p {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-actions,
|
|
||||||
.asset-header-actions,
|
|
||||||
.consumable-header-actions {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header > button,
|
|
||||||
.dashboard-actions > button,
|
|
||||||
.page-header .asset-header-actions > button,
|
|
||||||
.page-header .consumable-header-actions > button {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
justify-content: center;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-filters,
|
|
||||||
.users-controls {
|
|
||||||
margin-bottom: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-filters > *,
|
|
||||||
.users-controls > * {
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-filters > div,
|
|
||||||
.users-controls > div {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-filters input,
|
|
||||||
.page-filters select,
|
|
||||||
.users-controls input,
|
|
||||||
.users-controls select,
|
|
||||||
.modal-content input,
|
|
||||||
.modal-content select,
|
|
||||||
.modal-content textarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 2.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep the asset and consumable toolbars compact so the data remains
|
|
||||||
visible on tablets and phones. */
|
|
||||||
.asset-borrows-page .compact-page-actions,
|
|
||||||
.consumable-exports-page .compact-page-actions,
|
|
||||||
.assets-page .asset-header-actions,
|
|
||||||
.consumables-page .consumable-header-actions {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
flex-flow: row nowrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
overscroll-behavior-x: contain;
|
|
||||||
padding: 0.125rem 0.125rem 0.375rem;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.asset-borrows-page .compact-page-actions > button,
|
|
||||||
.consumable-exports-page .compact-page-actions > button,
|
|
||||||
.assets-page .asset-header-actions > button,
|
|
||||||
.consumables-page .consumable-header-actions > button {
|
|
||||||
width: auto;
|
|
||||||
min-width: max-content;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters {
|
|
||||||
display: grid !important;
|
|
||||||
align-items: end !important;
|
|
||||||
gap: 0.625rem !important;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgb(248 250 252 / 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.asset-borrow-filter-bar {
|
|
||||||
grid-template-columns: minmax(10rem, 0.35fr) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.asset-filter-bar {
|
|
||||||
grid-template-columns: minmax(9rem, 0.3fr) minmax(14rem, 1fr) max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.consumable-filter-bar {
|
|
||||||
grid-template-columns: minmax(8rem, 0.25fr) minmax(8rem, 0.25fr) minmax(14rem, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.consumable-export-filter-bar {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters > div {
|
|
||||||
width: auto;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters input,
|
|
||||||
.compact-page-filters select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters .compact-filter-action {
|
|
||||||
width: auto;
|
|
||||||
min-width: max-content;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
align-self: end;
|
|
||||||
justify-content: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-stats,
|
|
||||||
.apps-stats {
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-stats > *,
|
|
||||||
.apps-stats > * {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
min-width: 0;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tables are progressively enhanced with data labels by app.js. */
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0 !important;
|
|
||||||
border-collapse: separate;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > thead {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: rgb(248 250 252 / 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr {
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr:hover {
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td {
|
|
||||||
position: static !important;
|
|
||||||
z-index: auto !important;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(5.75rem, 36%) minmax(0, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: auto !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
max-width: none !important;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px solid #f1f5f9;
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
color: #334155;
|
|
||||||
text-align: left !important;
|
|
||||||
white-space: normal !important;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td::before {
|
|
||||||
content: attr(data-label);
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.35;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td[colspan] {
|
|
||||||
display: block;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
text-align: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td[colspan]::before {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td > * {
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td .truncate {
|
|
||||||
white-space: normal;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td .inline-flex {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) button,
|
|
||||||
#mainContent .asset-mobile-card button,
|
|
||||||
#mainContent .consumable-mobile-card button {
|
|
||||||
min-width: 2.5rem;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-pager {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem !important;
|
|
||||||
padding-bottom: max(0.75rem, env(safe-area-inset-bottom)) !important;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-pager > div,
|
|
||||||
.page-pager > span {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-pager button {
|
|
||||||
min-width: 2.75rem;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop .modal-content {
|
|
||||||
max-width: none !important;
|
|
||||||
max-height: calc(100dvh - 0.75rem - env(safe-area-inset-top)) !important;
|
|
||||||
margin: 0.375rem !important;
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content > .p-6,
|
|
||||||
.modal-content > form.p-6,
|
|
||||||
.modal-content > form > .p-6 {
|
|
||||||
padding: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content > .px-6,
|
|
||||||
.modal-content > form > .px-6 {
|
|
||||||
padding-right: 1rem !important;
|
|
||||||
padding-left: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content .grid-cols-2,
|
|
||||||
.modal-content .grid-cols-4 {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content h2,
|
|
||||||
.modal-content h3 {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content > .flex:last-child,
|
|
||||||
.modal-content > form > .flex:last-child,
|
|
||||||
.modal-content > form > div > .flex:last-child {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content > .flex:last-child > button,
|
|
||||||
.modal-content > form > .flex:last-child > button,
|
|
||||||
.modal-content > form > div > .flex:last-child > button {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
flex: 1 1 8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content .overflow-x-auto table {
|
|
||||||
min-width: 42rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#NotiflixNotifyWrap {
|
|
||||||
right: 0.5rem !important;
|
|
||||||
left: 0.5rem !important;
|
|
||||||
width: auto !important;
|
|
||||||
max-width: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
#appMain > header .topbar-actions {
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pendingAssetRequestsBtn > span:not(.material-symbols-outlined):not(#pendingAssetRequestsBadge) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pendingAssetRequestsBtn,
|
|
||||||
#profileBtn,
|
|
||||||
#logoutBtn,
|
|
||||||
#mobileMenuBtn {
|
|
||||||
padding-right: 0.625rem;
|
|
||||||
padding-left: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent > [class*="-page"] {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-stats,
|
|
||||||
.apps-stats {
|
|
||||||
grid-template-columns: minmax(0, 1fr) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.asset-borrow-filter-bar {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.asset-filter-bar {
|
|
||||||
grid-template-columns: minmax(0, 1fr) max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.asset-filter-bar .compact-filter-search {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.consumable-filter-bar,
|
|
||||||
.compact-page-filters.consumable-export-filter-bar {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-page-filters.consumable-filter-bar .compact-filter-search,
|
|
||||||
.compact-page-filters.consumable-export-filter-bar .compact-filter-search {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody {
|
|
||||||
padding: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody > tr > td {
|
|
||||||
grid-template-columns: minmax(5rem, 34%) minmax(0, 1fr);
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop .modal-content {
|
|
||||||
width: 100% !important;
|
|
||||||
margin-right: 0 !important;
|
|
||||||
margin-left: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Login, registration, password recovery and email verification. */
|
|
||||||
body.auth-page,
|
|
||||||
body.verify-page {
|
|
||||||
min-height: 100dvh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-shell,
|
|
||||||
.verify-shell {
|
|
||||||
min-height: 100dvh;
|
|
||||||
padding-top: max(1rem, env(safe-area-inset-top));
|
|
||||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.auth-shell,
|
|
||||||
.verify-shell {
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-right: 0.75rem;
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card,
|
|
||||||
.verify-card {
|
|
||||||
padding: 1.25rem !important;
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card .auth-heading {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card .auth-icon {
|
|
||||||
width: 3.25rem;
|
|
||||||
height: 3.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card h1,
|
|
||||||
.verify-card h1 {
|
|
||||||
font-size: 1.35rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card input:not([type="checkbox"]),
|
|
||||||
.auth-card select,
|
|
||||||
.auth-card textarea {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card button,
|
|
||||||
.verify-card a {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card .remember-row,
|
|
||||||
.verify-card .verify-actions {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card .remember-row > div {
|
|
||||||
min-height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card .remember-row > button {
|
|
||||||
align-self: flex-start;
|
|
||||||
min-height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verify-card .verify-actions > a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Shared brand artwork */
|
|
||||||
.auth-brand-image,
|
|
||||||
.sidebar-brand-image,
|
|
||||||
.verify-brand-image {
|
|
||||||
display: block;
|
|
||||||
width: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-brand-image {
|
|
||||||
height: 5rem;
|
|
||||||
max-width: 9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-brand-link {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 3.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-brand-image {
|
|
||||||
height: 3.25rem;
|
|
||||||
max-width: 7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verify-brand-image {
|
|
||||||
height: 4rem;
|
|
||||||
max-width: 7rem;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.auth-card .auth-brand-image {
|
|
||||||
height: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verify-card .verify-brand-image {
|
|
||||||
height: 3.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
@@ -1,10 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Redirecting...</title>
|
<title>Redirecting...</title>
|
||||||
<link rel="icon" type="image/png" href="./image/logo_PNKX.png">
|
|
||||||
<script>
|
<script>
|
||||||
window.location.href = './pages/login.html';
|
window.location.href = './pages/login.html';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
7417
public/js/app.js
7417
public/js/app.js
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Password</label>
|
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Password</label>
|
||||||
<input type="password" id="accountPassword" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="********">
|
<input type="password" id="accountPassword" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="••••••••">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 pt-4">
|
<div class="flex gap-3 pt-4">
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAccountModal()">Cancel</button>
|
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAccountModal()">Cancel</button>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Password</label>
|
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Password</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div id="viewAccountPassword" class="flex-1 border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50 text-slate-600">********</div>
|
<div id="viewAccountPassword" class="flex-1 border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50 text-slate-600">••••••••</div>
|
||||||
<button type="button" class="p-2 rounded-lg hover:bg-slate-100 text-slate-400 transition-colors toggle-password">
|
<button type="button" class="p-2 rounded-lg hover:bg-slate-100 text-slate-400 transition-colors toggle-password">
|
||||||
<span class="material-symbols-outlined text-lg" id="toggleIcon">visibility</span>
|
<span class="material-symbols-outlined text-lg" id="toggleIcon">visibility</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">URL</label>
|
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">URL</label>
|
||||||
<input type="url" id="appUrl" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="https://app.example.internal">
|
<input type="text" id="appUrl" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="https://example.com or 172.20.235.176">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Status</label>
|
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Status</label>
|
||||||
@@ -197,954 +197,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Asset Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetModal">
|
|
||||||
<div class="modal-content w-full max-w-2xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Biểu mẫu tài sản</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="assetForm" class="p-6 space-y-4 overflow-y-auto">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label id="assetCodeLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Mã tài sản</label>
|
|
||||||
<input type="text" id="assetCodeInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="De trong de he thong tu tao">
|
|
||||||
<p id="assetCodeHint" class="mt-1 text-xs text-slate-500">Để trống khi thêm mới, hệ thống sẽ tự tạo mã.</p>
|
|
||||||
<p id="assetCodeError" class="mt-1 text-xs font-semibold text-red-600 hidden"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên tài sản</label>
|
|
||||||
<input type="text" id="assetNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="Laptop Dell Latitude 5440">
|
|
||||||
<p id="assetNameError" class="mt-1 text-xs font-semibold text-red-600 hidden"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Trạng thái</label>
|
|
||||||
<input type="text" id="assetStatusInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly value="Trong kho">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Model <span class="text-red-600">*</span></label>
|
|
||||||
<input type="text" id="assetModelInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="Latitude 5440">
|
|
||||||
<p id="assetModelError" class="mt-1 text-xs font-semibold text-red-600 hidden"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số serial</label>
|
|
||||||
<input type="text" id="assetSerialInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="SN123...">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng (Tồn đầu kỳ)</label>
|
|
||||||
<input type="number" id="assetQuantityInput" min="0" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Nhập trong kỳ</label>
|
|
||||||
<input type="number" id="assetImportInPeriodInput" min="0" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Xuất trong kỳ</label>
|
|
||||||
<input type="number" id="assetExportInPeriodInput" min="0" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" value="0" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tồn cuối kỳ</label>
|
|
||||||
<input type="number" id="assetEndingBalanceInput" min="0" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" value="0" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Đơn vị</label>
|
|
||||||
<input type="text" id="assetUnitInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="cái">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Phòng ban</label>
|
|
||||||
<select id="assetDepartmentInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
|
|
||||||
<option value="">-- Chọn phòng ban --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Dự án</label>
|
|
||||||
<select id="assetProjectInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
|
|
||||||
<option value="">-- Chọn dự án --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Vị trí</label>
|
|
||||||
<input type="text" id="assetLocationInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="Kho A">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người phụ trách</label>
|
|
||||||
<select id="assetCustodianInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
|
|
||||||
<option value="">-- Chon nguoi phu trach --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người mượn hiện tại</label>
|
|
||||||
<textarea id="assetBorrowerSummaryInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-24 resize-none bg-slate-50" readonly></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ngày mua</label>
|
|
||||||
<input type="date" id="assetPurchaseDateInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Giá mua</label>
|
|
||||||
<input type="number" id="assetPriceInput" min="0" step="0.01" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="0">
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú</label>
|
|
||||||
<textarea id="assetNotesInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Thông tin bổ sung"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 pt-2 sticky bottom-0 bg-white pb-1">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetModal()">Hủy</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Lưu tài sản</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Consumable Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="consumableModal">
|
|
||||||
<div class="modal-content bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900" id="consumableModalTitle">Thêm vật tư tiêu hao</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeConsumableModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="consumableForm" class="p-6 space-y-4 overflow-y-auto">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tháng đề xuất</label>
|
|
||||||
<select id="consumableRequestMonthInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
|
|
||||||
<option value="">-- Chọn tháng --</option>
|
|
||||||
<option value="Tháng 1">Tháng 1</option>
|
|
||||||
<option value="Tháng 2">Tháng 2</option>
|
|
||||||
<option value="Tháng 3">Tháng 3</option>
|
|
||||||
<option value="Tháng 4">Tháng 4</option>
|
|
||||||
<option value="Tháng 5">Tháng 5</option>
|
|
||||||
<option value="Tháng 6">Tháng 6</option>
|
|
||||||
<option value="Tháng 7">Tháng 7</option>
|
|
||||||
<option value="Tháng 8">Tháng 8</option>
|
|
||||||
<option value="Tháng 9">Tháng 9</option>
|
|
||||||
<option value="Tháng 10">Tháng 10</option>
|
|
||||||
<option value="Tháng 11">Tháng 11</option>
|
|
||||||
<option value="Tháng 12">Tháng 12</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Mã vật tư</label>
|
|
||||||
<input type="text" id="consumableCodeInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="Để trống để hệ thống tự tạo">
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên linh kiện/sp</label>
|
|
||||||
<input type="text" id="consumableNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="Hub type C">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Model</label>
|
|
||||||
<input type="text" id="consumableModelInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="Hub Type C">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">ĐVT</label>
|
|
||||||
<input type="text" id="consumableUnitInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="Cái">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tồn đầu kỳ</label>
|
|
||||||
<input type="number" id="consumableOpeningBalanceInput" min="0" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Nhập trong kì</label>
|
|
||||||
<input type="number" id="consumableImportInPeriodInput" min="0" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Xuất trong kì</label>
|
|
||||||
<input type="number" id="consumableExportInPeriodInput" min="0" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tồn cuối kì</label>
|
|
||||||
<input type="number" id="consumableEndingBalanceInput" min="0" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" value="0" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Lý do xuất</label>
|
|
||||||
<textarea id="consumableExportReasonInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Xuất cho bộ phận/dự án/người nhận..."></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeConsumableModal()">Hủy</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Lưu vật tư</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Consumable Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="deleteConsumableModal">
|
|
||||||
<div class="modal-content bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
|
|
||||||
<div class="flex items-center gap-3 mb-4">
|
|
||||||
<div class="w-10 h-10 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
|
|
||||||
<span class="material-symbols-outlined">delete</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-extrabold text-slate-900">Xóa vật tư tiêu hao</h3>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-slate-600 mb-6">Bạn có chắc muốn xóa vật tư <strong id="deleteConsumableName">-</strong>? Hành động này không thể hoàn tác.</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeDeleteConsumableModal()">Hủy</button>
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold confirm-delete-consumable">Xóa</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Borrow / Export Consumable Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="consumableExportModal">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 id="consumableExportModalTitle" class="text-base font-extrabold text-slate-900">Mượn / xuất vật tư tiêu hao</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeConsumableExportModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="consumableExportForm" class="p-6 space-y-4">
|
|
||||||
<input type="hidden" id="consumableExportConsumableIdInput">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Vật tư</label>
|
|
||||||
<input type="text" id="consumableExportConsumableNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tồn cuối kỳ hiện tại</label>
|
|
||||||
<input type="number" id="consumableExportCurrentEndingInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Hình thức</label>
|
|
||||||
<select id="consumableExportTargetTypeInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
|
|
||||||
<option value="user">Mượn cho người dùng</option>
|
|
||||||
<option value="project">Xuất cho dự án</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label id="consumableExportQuantityLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng mượn</label>
|
|
||||||
<input type="number" id="consumableExportQuantityInput" min="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="1" required>
|
|
||||||
</div>
|
|
||||||
<div id="consumableExportProjectField" class="hidden">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Dự án nhận</label>
|
|
||||||
<select id="consumableExportProjectInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
|
|
||||||
<option value="">-- Chọn dự án --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label id="consumableExportRecipientLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người nhận</label>
|
|
||||||
<select id="consumableExportUserInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
|
|
||||||
<option value="">-- Chọn người nhận --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người xuất</label>
|
|
||||||
<input type="text" id="consumableExportActorInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Role người thao tác</label>
|
|
||||||
<input type="text" id="consumableExportRoleInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label id="consumableExportNoteLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú mượn</label>
|
|
||||||
<textarea id="consumableExportNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập mục đích sử dụng hoặc ghi chú bàn giao"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeConsumableExportModal()">Hủy</button>
|
|
||||||
<button id="consumableExportSubmitBtn" type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Xác nhận mượn</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Consumable Borrow Request Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[125] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="consumableBorrowRequestModal">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-visible m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Tạo đơn mượn vật tư tiêu hao</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeConsumableBorrowRequestModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="consumableBorrowRequestForm" class="p-6 space-y-4 relative">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người tạo đơn</label>
|
|
||||||
<input type="text" id="consumableBorrowRequesterInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Vật tư cần mượn</label>
|
|
||||||
<div id="consumableBorrowProductPicker" class="relative z-[135]">
|
|
||||||
<input type="hidden" id="consumableBorrowProductInput">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="consumableBorrowProductDisplayBtn"
|
|
||||||
class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 text-left flex items-center justify-between gap-2 bg-white"
|
|
||||||
>
|
|
||||||
<span id="consumableBorrowProductDisplayText" class="flex-1 min-w-0 truncate text-slate-600">-- Chọn vật tư --</span>
|
|
||||||
<span class="material-symbols-outlined text-base text-slate-400">expand_more</span>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
id="consumableBorrowProductDropdown"
|
|
||||||
class="hidden absolute left-0 right-0 w-full max-w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-xl z-[145] overflow-hidden"
|
|
||||||
>
|
|
||||||
<div class="p-2 border-b border-slate-100">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="consumableBorrowProductSearchInput"
|
|
||||||
class="w-full border border-slate-200 rounded-md text-sm py-2 px-2.5"
|
|
||||||
placeholder="Tìm theo mã, tên hoặc model vật tư..."
|
|
||||||
autocomplete="off"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div id="consumableBorrowProductList" class="overflow-auto" style="max-height: 240px; overscroll-behavior: contain;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tồn hiện tại</label>
|
|
||||||
<input type="number" id="consumableBorrowCurrentStockInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Đơn vị</label>
|
|
||||||
<input type="text" id="consumableBorrowUnitInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng mượn</label>
|
|
||||||
<input type="number" id="consumableBorrowQuantityInput" min="1" value="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ngày mượn</label>
|
|
||||||
<input type="date" id="consumableBorrowDateInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú</label>
|
|
||||||
<textarea id="consumableBorrowNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập mục đích sử dụng hoặc ghi chú"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-slate-500">Tồn kho chỉ được trừ sau khi Asset/Admin chấp nhận đơn.</p>
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeConsumableBorrowRequestModal()">Hủy</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Gửi đơn mượn</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Consumable Borrow Requests List Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[125] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="consumableBorrowRequestsModal">
|
|
||||||
<div class="modal-content w-full max-w-7xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<div>
|
|
||||||
<h3 id="consumableBorrowRequestsModalTitle" class="text-base font-extrabold text-slate-900">Đơn mượn vật tư của tôi</h3>
|
|
||||||
<p id="consumableBorrowRequestsModalSubtitle" class="text-xs text-slate-500 mt-0.5">Theo dõi trạng thái các đơn đã gửi.</p>
|
|
||||||
</div>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeConsumableBorrowRequestsModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 pt-4 overflow-auto">
|
|
||||||
<div class="rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-left border-collapse" style="min-width: 1450px;">
|
|
||||||
<thead class="bg-slate-50 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Mã đơn</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người tạo</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Vật tư</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Loại đơn</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Số lượng</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Trạng thái</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ghi chú</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người xử lý</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Lý do từ chối</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Hành động</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="consumableBorrowRequestsTableBody" class="divide-y divide-slate-100">
|
|
||||||
<tr><td colspan="11" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có đơn mượn/trả vật tư.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reject Consumable Borrow/Return Request Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[150] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="consumableRequestRejectModal" style="z-index: 150;">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 id="consumableRequestRejectModalTitle" class="text-base font-extrabold text-slate-900">Từ chối đơn vật tư</h3>
|
|
||||||
<button type="button" class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeConsumableRequestRejectModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="consumableRequestRejectForm" class="p-6 space-y-4">
|
|
||||||
<input type="hidden" id="consumableRequestRejectIdInput">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Lý do từ chối</label>
|
|
||||||
<textarea id="consumableRequestRejectReasonInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-24 resize-none" placeholder="Nhập lý do để người tạo đơn biết..." required></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50" onclick="closeConsumableRequestRejectModal()">Đóng</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold">Xác nhận từ chối</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Return Consumable Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[130] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="consumableReturnModal">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 id="consumableReturnModalTitle" class="text-base font-extrabold text-slate-900">Hoàn trả vật tư về kho</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeConsumableReturnModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="consumableReturnForm" class="p-6 space-y-4">
|
|
||||||
<input type="hidden" id="consumableReturnExportHistoryIdInput">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Vật tư</label>
|
|
||||||
<input type="text" id="consumableReturnConsumableInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Nơi đang nhận</label>
|
|
||||||
<input type="text" id="consumableReturnDestinationInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng chưa trả</label>
|
|
||||||
<input type="number" id="consumableReturnRemainingInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label id="consumableReturnQuantityLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng trả kho</label>
|
|
||||||
<input type="number" id="consumableReturnQuantityInput" min="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="1" required>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label id="consumableReturnActorLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người nhận lại kho</label>
|
|
||||||
<input type="text" id="consumableReturnActorInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú trả</label>
|
|
||||||
<textarea id="consumableReturnNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập tình trạng hoặc ghi chú khi nhận lại"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p id="consumableReturnHelpText" class="text-xs text-slate-500">Khi xác nhận, số lượng này sẽ được cộng lại vào tồn kho ngay.</p>
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeConsumableReturnModal()">Hủy</button>
|
|
||||||
<button id="consumableReturnSubmitBtn" type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Xác nhận trả kho</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Consumable Export History Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[110] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="consumableExportHistoryModal" style="z-index: 124;">
|
|
||||||
<div class="modal-content w-full max-w-6xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Lịch sử mượn / xuất / trả vật tư</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeConsumableExportHistoryModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 pt-4 overflow-auto">
|
|
||||||
<div class="rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-left border-collapse" style="min-width: 1500px;">
|
|
||||||
<thead class="bg-slate-50 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày giờ</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Vật tư</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Nơi nhận</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Đã xuất</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Đã trả</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Còn lại</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Trạng thái</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người xuất</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tồn trước/sau</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ghi chú</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Thao tác</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="consumableExportHistoryTableBody" class="divide-y divide-slate-100">
|
|
||||||
<tr>
|
|
||||||
<td colspan="11" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dữ liệu lịch sử mượn / xuất vật tư.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Export Asset Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="borrowAssetModal">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Xuất tài sản</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeBorrowAssetModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="borrowAssetForm" class="p-6 space-y-4">
|
|
||||||
<input type="hidden" id="borrowAssetIdInput">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tài sản</label>
|
|
||||||
<input type="text" id="borrowAssetNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tồn cuối kỳ hiện tại</label>
|
|
||||||
<input type="number" id="borrowCurrentEndingInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng xuất</label>
|
|
||||||
<input type="number" id="borrowQuantityInput" min="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="1" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người mượn</label>
|
|
||||||
<select id="borrowAssetUserInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
|
|
||||||
<option value="">-- Chọn người mượn --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Xuất cho dự án</label>
|
|
||||||
<select id="borrowAssetProjectInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
|
|
||||||
<option value="">-- Chọn dự án --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người xuất</label>
|
|
||||||
<input type="text" id="borrowByInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Role người thao tác</label>
|
|
||||||
<input type="text" id="borrowRoleInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú xuất</label>
|
|
||||||
<textarea id="borrowAssetNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập ghi chú (nếu có)"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeBorrowAssetModal()">Hủy</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Xác nhận xuất</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Asset Damage/Disposal Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetDamageModal">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Hỏng / Thanh lý tài sản</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetDamageModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="assetDamageForm" class="p-6 space-y-4">
|
|
||||||
<input type="hidden" id="assetDamageAssetIdInput">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tài sản</label>
|
|
||||||
<input type="text" id="assetDamageAssetNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Lý do</label>
|
|
||||||
<select id="assetDamageTypeInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
|
|
||||||
<option value="damaged">Hỏng</option>
|
|
||||||
<option value="disposed">Thanh lý</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng</label>
|
|
||||||
<input type="number" id="assetDamageQuantityInput" min="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="1" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng hiện tại</label>
|
|
||||||
<input type="number" id="assetDamageCurrentQuantityInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tồn cuối kỳ hiện tại</label>
|
|
||||||
<input type="number" id="assetDamageCurrentEndingInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">SL hàng mới</label>
|
|
||||||
<input type="number" id="assetDamageCurrentNewInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">SL đã qua sử dụng</label>
|
|
||||||
<input type="number" id="assetDamageCurrentUsedInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người thao tác</label>
|
|
||||||
<input type="text" id="assetDamageActorInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú</label>
|
|
||||||
<textarea id="assetDamageNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập tình trạng, biên bản hoặc lý do chi tiết"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetDamageModal()">Hủy</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold">Xác nhận</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Asset Export History Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[110] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetExportHistoryModal" style="z-index: 125;">
|
|
||||||
<div class="modal-content w-full max-w-6xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Lịch sử xuất tài sản</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetExportHistoryModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 pt-4 overflow-auto">
|
|
||||||
<div class="rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-left border-collapse" style="min-width: 1100px;">
|
|
||||||
<thead class="bg-slate-50 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày giờ</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tài sản</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Số lượng</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Dự án</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người phụ trách</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người xuất</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ghi chú</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="assetExportHistoryTableBody" class="divide-y divide-slate-100">
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dữ liệu lịch sử xuất.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Asset Damage/Disposal History Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[110] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetDamageHistoryModal" style="z-index: 126;">
|
|
||||||
<div class="modal-content w-full max-w-6xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Tài sản hỏng / thanh lý</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetDamageHistoryModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 pt-4 overflow-auto">
|
|
||||||
<div class="rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-left border-collapse" style="min-width: 1280px;">
|
|
||||||
<thead class="bg-slate-50 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày giờ</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Lý do</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tài sản</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Số lượng</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tồn đầu kỳ</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tồn cuối kỳ</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">SL hàng mới</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">SL đã sử dụng</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người thao tác</th>
|
|
||||||
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ghi chú</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="assetDamageHistoryTableBody" class="divide-y divide-slate-100">
|
|
||||||
<tr>
|
|
||||||
<td colspan="10" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dữ liệu tài sản hỏng/thanh lý.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Asset Borrow Request Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetBorrowRequestModal">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-visible m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 id="assetBorrowRequestModalTitle" class="text-base font-extrabold text-slate-900">Tạo đơn mượn tài sản</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetBorrowRequestModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="assetBorrowRequestForm" class="p-6 space-y-4 relative">
|
|
||||||
<input type="hidden" id="assetBorrowRequestTypeInput" value="borrow">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên đầy đủ</label>
|
|
||||||
<input type="text" id="assetBorrowRequesterInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên tài sản</label>
|
|
||||||
<div id="assetBorrowProductPicker" class="relative z-[130]">
|
|
||||||
<input type="hidden" id="assetBorrowProductInput">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="assetBorrowProductDisplayBtn"
|
|
||||||
class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 text-left flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<span id="assetBorrowProductDisplayText" class="flex-1 min-w-0 truncate text-slate-600">-- Chọn tài sản --</span>
|
|
||||||
<span class="material-symbols-outlined text-base text-slate-400">expand_more</span>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
id="assetBorrowProductDropdown"
|
|
||||||
class="hidden absolute left-0 right-0 w-full max-w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-xl z-[140] overflow-hidden"
|
|
||||||
>
|
|
||||||
<div class="p-2 border-b border-slate-100">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="assetBorrowProductSearchInput"
|
|
||||||
class="w-full border border-slate-200 rounded-md text-sm py-2 px-2.5"
|
|
||||||
placeholder="Tìm theo mã hoặc tên tài sản..."
|
|
||||||
autocomplete="off"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="assetBorrowProductList"
|
|
||||||
class="overflow-auto"
|
|
||||||
style="max-height: 224px; overflow: auto; overscroll-behavior: contain;"
|
|
||||||
></div>
|
|
||||||
<div id="assetBorrowProductLoading" class="hidden px-3 py-2 text-xs text-slate-500 bg-slate-50 border-t border-slate-100">
|
|
||||||
Đang tải thêm tài sản...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng</label>
|
|
||||||
<input type="number" id="assetBorrowQuantityInput" min="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="1" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Đơn vị</label>
|
|
||||||
<input type="text" id="assetBorrowUnitInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label id="assetBorrowDateLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ngày mượn</label>
|
|
||||||
<input type="date" id="assetBorrowDateInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú</label>
|
|
||||||
<textarea id="assetBorrowNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập ghi chú (nếu có)"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetBorrowRequestModal()">Hủy</button>
|
|
||||||
<button type="submit" id="assetBorrowRequestSubmitBtn" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Tạo đơn mượn</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pending Asset Requests Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetPendingRequestsModal" style="z-index: 120;">
|
|
||||||
<div class="modal-content w-full max-w-6xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Đơn chờ xử lý</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetPendingRequestsModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4 overflow-y-auto">
|
|
||||||
<div class="rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div class="px-4 py-3 bg-blue-50 border-b border-blue-100 flex items-center justify-between">
|
|
||||||
<h4 class="text-sm font-extrabold text-blue-700">Đơn mượn</h4>
|
|
||||||
<span id="pendingBorrowCountBadge" class="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-blue-600 text-white text-xs font-extrabold">0</span>
|
|
||||||
</div>
|
|
||||||
<div id="pendingBorrowRequestsList" class="p-3 space-y-3 max-h-[60vh] overflow-y-auto"></div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div class="px-4 py-3 bg-emerald-50 border-b border-emerald-100 flex items-center justify-between">
|
|
||||||
<h4 class="text-sm font-extrabold text-emerald-700">Đơn trả</h4>
|
|
||||||
<span id="pendingReturnCountBadge" class="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-emerald-600 text-white text-xs font-extrabold">0</span>
|
|
||||||
</div>
|
|
||||||
<div id="pendingReturnRequestsList" class="p-3 space-y-3 max-h-[60vh] overflow-y-auto"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirm Delete/Cancel Asset Request Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[120] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetRequestDeleteConfirmModal" style="z-index: 140;">
|
|
||||||
<div class="modal-content w-full max-w-md bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 bg-red-50 flex items-center gap-3">
|
|
||||||
<span class="material-symbols-outlined text-red-600 text-2xl">warning</span>
|
|
||||||
<h3 class="text-base font-extrabold text-red-700">Xác nhận thao tác</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<p id="assetRequestDeleteConfirmMessage" class="text-sm text-slate-600 mb-6">Bạn có chắc muốn thực hiện thao tác này?</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="cancel-asset-request-delete-confirm flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg">Đóng</button>
|
|
||||||
<button type="button" id="confirmAssetRequestDeleteBtn" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold">Xóa đơn</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reject Asset Request Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[110] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetRequestRejectModal" style="z-index: 130;">
|
|
||||||
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Từ chối đơn</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetRequestRejectModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="assetRequestRejectForm" class="p-6 space-y-4">
|
|
||||||
<input type="hidden" id="assetRequestRejectIdInput">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Lý do từ chối</label>
|
|
||||||
<textarea id="assetRequestRejectReasonInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-24 resize-none" placeholder="Nhập lý do từ chối..." required></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetRequestRejectModal()">Hủy</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold">Xác nhận từ chối</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View Asset Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="viewAssetModal">
|
|
||||||
<div class="modal-content w-full max-w-2xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900">Chi tiết tài sản</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeViewAssetModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="assetDetailsContent" class="p-6 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-slate-700 overflow-y-auto"></div>
|
|
||||||
<div class="p-6 pt-3 flex gap-3 border-t border-slate-100 bg-white">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeViewAssetModal()">Đóng</button>
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold edit-asset-from-view">Sửa</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Asset Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="deleteAssetModal">
|
|
||||||
<div class="modal-content w-full max-w-md bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 bg-red-50 flex items-center gap-3">
|
|
||||||
<span class="material-symbols-outlined text-red-600 text-2xl">warning</span>
|
|
||||||
<h3 class="text-base font-extrabold text-red-700">Xóa tài sản</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<p class="text-sm text-slate-600 mb-6">Bạn có chắc muốn xóa tài sản <strong id="deleteAssetName">-</strong>? Hành động này không thể hoàn tác.</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeDeleteAssetModal()">Hủy</button>
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold confirm-delete-asset">Xóa</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bulk Delete Assets Confirm Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[110] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="bulkDeleteAssetsConfirmModal" style="z-index: 130;">
|
|
||||||
<div class="modal-content w-full max-w-md bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 bg-red-50 flex items-center gap-3">
|
|
||||||
<span class="material-symbols-outlined text-red-600 text-2xl">warning</span>
|
|
||||||
<h3 class="text-base font-extrabold text-red-700">Xóa tài sản đã chọn</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<p id="bulkDeleteAssetsConfirmMessage" class="text-sm text-slate-600 mb-6">Bạn có chắc muốn xóa các tài sản đã chọn?</p>
|
|
||||||
<div class="mb-4 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-xs text-red-700">
|
|
||||||
Số lượng: <strong id="bulkDeleteAssetsConfirmCount">0</strong> tài sản
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="cancel-bulk-asset-delete-confirm flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeBulkDeleteAssetsConfirmModal()">Hủy</button>
|
|
||||||
<button type="button" id="confirmBulkAssetDeleteBtn" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold">Xóa</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Asset Department Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetDepartmentModal">
|
|
||||||
<div class="modal-content w-full max-w-md bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900" id="assetDepartmentModalTitle">Thêm phòng ban</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetDepartmentModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="assetDepartmentForm" class="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên phòng ban</label>
|
|
||||||
<input type="text" id="assetDepartmentNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="Ví dụ: Kỹ thuật">
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetDepartmentModal()">Hủy</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Lưu</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Asset Department Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="deleteAssetDepartmentModal">
|
|
||||||
<div class="modal-content w-full max-w-md bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 bg-red-50 flex items-center gap-3">
|
|
||||||
<span class="material-symbols-outlined text-red-600 text-2xl">warning</span>
|
|
||||||
<h3 class="text-base font-extrabold text-red-700">Xóa phòng ban</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<p class="text-sm text-slate-600 mb-6">Bạn có chắc muốn xóa phòng ban <strong id="deleteAssetDepartmentName">-</strong>? Các tài sản đang gắn phòng ban này sẽ được để trống phòng ban.</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeDeleteAssetDepartmentModal()">Hủy</button>
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold confirm-delete-asset-department">Xóa</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Asset Project Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetProjectModal">
|
|
||||||
<div class="modal-content w-full max-w-md bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
|
||||||
<h3 class="text-base font-extrabold text-slate-900" id="assetProjectModalTitle">Thêm dự án</h3>
|
|
||||||
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetProjectModal()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="assetProjectForm" class="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên dự án</label>
|
|
||||||
<input type="text" id="assetProjectNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="Ví dụ: AGV">
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetProjectModal()">Hủy</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Lưu</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Asset Project Modal -->
|
|
||||||
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="deleteAssetProjectModal">
|
|
||||||
<div class="modal-content w-full max-w-md bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
|
|
||||||
<div class="px-6 py-4 border-b border-slate-100 bg-red-50 flex items-center gap-3">
|
|
||||||
<span class="material-symbols-outlined text-red-600 text-2xl">warning</span>
|
|
||||||
<h3 class="text-base font-extrabold text-red-700">Xóa dự án</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<p class="text-sm text-slate-600 mb-6">Bạn có chắc muốn xóa dự án <strong id="deleteAssetProjectName">-</strong>? Các tài sản đang gắn dự án này sẽ được để trống dự án.</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeDeleteAssetProjectModal()">Hủy</button>
|
|
||||||
<button type="button" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold confirm-delete-asset-project">Xóa</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title>Robot Manager Account - Accounts Management</title>
|
<title>Robot Manager Account - Accounts Management</title>
|
||||||
<link rel="icon" type="image/png" href="../image/logo_PNKX.png"/>
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
||||||
<!-- Material Symbols -->
|
<!-- Material Symbols -->
|
||||||
@@ -28,4 +27,4 @@
|
|||||||
window.location.href = './index.html#accounts';
|
window.location.href = './index.html#accounts';
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title>Robot Manager Account - Applications Management</title>
|
<title>Robot Manager Account - Applications Management</title>
|
||||||
<link rel="icon" type="image/png" href="../image/logo_PNKX.png"/>
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||||||
<!-- Material Symbols -->
|
<!-- Material Symbols -->
|
||||||
@@ -28,4 +27,4 @@
|
|||||||
window.location.href = './index.html#applications';
|
window.location.href = './index.html#applications';
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,16 +4,15 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title>Robot Manager Account - Hệ thống quản lý</title>
|
<title>Robot Manager Account - Hệ thống quản lý</title>
|
||||||
<link rel="icon" type="image/png" href="../image/logo_PNKX.png"/>
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||||||
<!-- Material Symbols -->
|
<!-- Material Symbols -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
|
||||||
<link rel="stylesheet" href="../css/main.css?v=20260806-4" />
|
<link rel="stylesheet" href="../css/main.css" />
|
||||||
<!-- Notiflix Notify -->
|
<!-- Notiflix Notify -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
|
||||||
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-family: 'Material Symbols Outlined';
|
font-family: 'Material Symbols Outlined';
|
||||||
@@ -74,11 +73,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assets-mobile-list,
|
|
||||||
.consumables-mobile-list {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
body.app-shell {
|
body.app-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -183,54 +177,6 @@
|
|||||||
min-width: 700px;
|
min-width: 700px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assets-page,
|
|
||||||
.consumables-page {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-page .page-header,
|
|
||||||
.consumables-page .page-header {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-page .asset-header-actions,
|
|
||||||
.consumables-page .consumable-header-actions {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row nowrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
overscroll-behavior-x: contain;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
padding-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-page .asset-header-actions > button,
|
|
||||||
.consumables-page .consumable-header-actions > button {
|
|
||||||
width: auto;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-page .page-filters,
|
|
||||||
.consumables-page .page-filters {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-page .assets-desktop-table,
|
|
||||||
.consumables-page .consumables-desktop-table {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-page .assets-mobile-list,
|
|
||||||
.consumables-page .consumables-mobile-list {
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-page .asset-mobile-card,
|
|
||||||
.consumables-page .consumable-mobile-card {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-pager {
|
.page-pager {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -255,20 +201,8 @@
|
|||||||
.apps-stats {
|
.apps-stats {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assets-page .page-header h1,
|
|
||||||
.consumables-page .page-header h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assets-page .page-header p,
|
|
||||||
.consumables-page .page-header p {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="../css/responsive.css?v=20260717-2" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="app-shell bg-background text-on-surface antialiased flex h-screen w-screen">
|
<body class="app-shell bg-background text-on-surface antialiased flex h-screen w-screen">
|
||||||
<!-- SideNavBar -->
|
<!-- SideNavBar -->
|
||||||
@@ -276,12 +210,11 @@
|
|||||||
<div class="flex flex-col h-full py-6">
|
<div class="flex flex-col h-full py-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="px-6 mb-8">
|
<div class="px-6 mb-8">
|
||||||
<a href="#dashboard" class="sidebar-brand-link" aria-label="PNKX - Về trang tổng quan">
|
<div class="text-lg font-black text-slate-900 dark:text-slate-50 tracking-tight leading-none">Robot Account</div>
|
||||||
<img src="../image/logo_PNKX.png" alt="PNKX" class="sidebar-brand-image"/>
|
<div class="text-[10px] uppercase tracking-widest text-on-surface-variant mt-1.5 font-bold">Admin Console</div>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Primary Nav -->
|
<!-- Primary Nav -->
|
||||||
<nav class="flex-1 px-3 space-y-4 overflow-y-auto">
|
<nav class="flex-1 px-3 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="tree-label">Tài khoản</div>
|
<div class="tree-label">Tài khoản</div>
|
||||||
<div class="tree-branch">
|
<div class="tree-branch">
|
||||||
@@ -307,46 +240,6 @@
|
|||||||
<span class="material-symbols-outlined">inventory_2</span>
|
<span class="material-symbols-outlined">inventory_2</span>
|
||||||
<span>Tài sản</span>
|
<span>Tài sản</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#asset-borrows" data-nav="asset-borrows" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">assignment_returned</span>
|
|
||||||
<span>Mượn/Trả tài sản</span>
|
|
||||||
</a>
|
|
||||||
<a href="#my-borrowed-assets" data-nav="my-borrowed-assets" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">inventory</span>
|
|
||||||
<span>Tài sản đang mượn</span>
|
|
||||||
</a>
|
|
||||||
<a href="#asset-departments" data-nav="asset-departments" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">apartment</span>
|
|
||||||
<span> Phòng Ban</span>
|
|
||||||
</a>
|
|
||||||
<a href="#asset-projects" data-nav="asset-projects" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">workspaces</span>
|
|
||||||
<span>Dự án</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="tree-label">Vật tư tiêu hao</div>
|
|
||||||
<div class="tree-branch">
|
|
||||||
<a href="#consumables" data-nav="consumables" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">category</span>
|
|
||||||
<span>Danh sách VTTH</span>
|
|
||||||
</a>
|
|
||||||
<a href="#consumable-exports" data-nav="consumable-exports" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">outbox</span>
|
|
||||||
<span>Lịch sử xuất</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="tree-label">Tài liệu</div>
|
|
||||||
<div class="tree-branch">
|
|
||||||
<a href="#documents" data-nav="documents" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
|
|
||||||
<span class="material-symbols-outlined">picture_as_pdf</span>
|
|
||||||
<span>Tài liệu PDF</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,11 +269,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions flex items-center gap-4">
|
<div class="topbar-actions flex items-center gap-4">
|
||||||
<button id="pendingAssetRequestsBtn" type="button" class="hidden relative flex items-center gap-2 px-3 py-2 rounded-lg border border-amber-200 bg-amber-50 hover:bg-amber-100 text-amber-800 transition-colors" title="Đơn chờ xử lý">
|
|
||||||
<span class="material-symbols-outlined text-base">notifications_active</span>
|
|
||||||
<span class="text-xs font-bold">Đơn chờ</span>
|
|
||||||
<span id="pendingAssetRequestsBadge" class="hidden absolute -top-2.5 -right-2.5 min-w-[22px] h-[22px] px-1.5 rounded-[999px] bg-red-600 text-white text-xs font-extrabold leading-[22px] text-center ring-2 ring-white">0</span>
|
|
||||||
</button>
|
|
||||||
<button id="profileBtn" type="button" class="profile-btn flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" title="Sửa hồ sơ">
|
<button id="profileBtn" type="button" class="profile-btn flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" title="Sửa hồ sơ">
|
||||||
<span class="material-symbols-outlined text-slate-600 dark:text-slate-400">account_circle</span>
|
<span class="material-symbols-outlined text-slate-600 dark:text-slate-400">account_circle</span>
|
||||||
<div class="profile-meta flex flex-col">
|
<div class="profile-meta flex flex-col">
|
||||||
@@ -400,6 +288,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="../js/app.js?v=20260806-4"></script>
|
<script src="../js/app.js?v=20260421-4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title>Robotics Account - Login</title>
|
<title>Robotics Account - Login</title>
|
||||||
<link rel="icon" type="image/png" href="../image/logo_PNKX.png"/>
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||||||
<!-- Material Symbols -->
|
<!-- Material Symbols -->
|
||||||
@@ -26,23 +25,24 @@
|
|||||||
body { font-family: 'Inter', sans-serif; min-height: 100vh; }
|
body { font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||||
h1, h2, h3 { font-family: 'Manrope', sans-serif; }
|
h1, h2, h3 { font-family: 'Manrope', sans-serif; }
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="../css/responsive.css?v=20260717-1" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-page bg-gradient-to-br from-blue-50 via-background to-purple-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 antialiased">
|
<body class="bg-gradient-to-br from-blue-50 via-background to-purple-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 antialiased">
|
||||||
<div class="auth-shell min-h-screen flex items-center justify-center px-4">
|
<div class="min-h-screen flex items-center justify-center px-4">
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<!-- Login Card -->
|
<!-- Login Card -->
|
||||||
<div class="auth-card bg-white dark:bg-slate-900 rounded-2xl shadow-lg border border-outline-variant/10 p-8">
|
<div class="bg-white dark:bg-slate-900 rounded-2xl shadow-lg border border-outline-variant/10 p-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="auth-heading text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="flex justify-center mb-4">
|
<div class="flex justify-center mb-4">
|
||||||
<img src="../image/logo_PNKX.png" alt="PNKX" class="auth-brand-image"/>
|
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
|
<span class="material-symbols-outlined text-primary text-4xl">security</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-black text-slate-900 dark:text-slate-50 tracking-tight">Robotics Account Manager</h1>
|
<h1 class="text-2xl font-black text-slate-900 dark:text-slate-50 tracking-tight">Robotics Account Manager</h1>
|
||||||
<p class="text-xs uppercase tracking-widest text-on-surface-variant font-bold mt-2">Account Management System</p>
|
<p class="text-xs uppercase tracking-widest text-on-surface-variant font-bold mt-2">Account Management System</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="authTabs" class="flex gap-2 mb-6" role="tablist" aria-label="Auth switcher">
|
<div class="flex gap-2 mb-6" role="tablist" aria-label="Auth switcher">
|
||||||
<button id="loginTab" type="button" class="flex-1 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider border border-outline-variant/30 bg-primary text-on-primary shadow-sm">Đăng nhập</button>
|
<button id="loginTab" type="button" class="flex-1 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider border border-outline-variant/30 bg-primary text-on-primary shadow-sm">Đăng nhập</button>
|
||||||
<button id="registerTab" type="button" class="flex-1 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider border border-outline-variant/30 bg-surface-container-low text-on-surface-variant">Đăng ký</button>
|
<button id="registerTab" type="button" class="flex-1 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider border border-outline-variant/30 bg-surface-container-low text-on-surface-variant">Đăng ký</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,19 +86,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remember Me Checkbox -->
|
<!-- Remember Me Checkbox -->
|
||||||
<div class="remember-row flex items-center justify-between gap-2">
|
<div class="flex items-center">
|
||||||
<div class="flex items-center">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
id="remember"
|
||||||
id="remember"
|
name="remember"
|
||||||
name="remember"
|
class="w-4 h-4 rounded border-outline-variant/30 text-primary focus:ring-2 focus:ring-primary/50 cursor-pointer"
|
||||||
class="w-4 h-4 rounded border-outline-variant/30 text-primary focus:ring-2 focus:ring-primary/50 cursor-pointer"
|
/>
|
||||||
/>
|
<label for="remember" class="ml-2.5 text-xs font-medium text-on-surface-variant cursor-pointer">Remember me</label>
|
||||||
<label for="remember" class="ml-2.5 text-xs font-medium text-on-surface-variant cursor-pointer">Remember me</label>
|
|
||||||
</div>
|
|
||||||
<button id="forgotPasswordLink" type="button" class="text-xs font-semibold text-primary hover:text-primary-dim transition-colors bg-transparent border-0 p-0">
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Button -->
|
<!-- Login Button -->
|
||||||
@@ -122,119 +117,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form id="forgotPasswordForm" class="space-y-5 hidden">
|
|
||||||
<div class="bg-blue-50 border border-blue-100 rounded-lg px-4 py-3 text-xs text-blue-700">
|
|
||||||
Enter your username and registered email to receive a password reset link.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="forgotUsername" class="block text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-2">Username</label>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
|
|
||||||
<span class="material-symbols-outlined text-base">person</span>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="forgotUsername"
|
|
||||||
name="forgotUsername"
|
|
||||||
placeholder="Enter your username"
|
|
||||||
required
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="forgotEmail" class="block text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-2">Registered Email</label>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
|
|
||||||
<span class="material-symbols-outlined text-base">mail</span>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="forgotEmail"
|
|
||||||
name="forgotEmail"
|
|
||||||
placeholder="Enter your registered email"
|
|
||||||
required
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full bg-primary hover:bg-primary-dim text-on-primary font-bold py-2.5 px-4 rounded-lg transition-all active:scale-95 duration-100 flex items-center justify-center gap-2 mt-4"
|
|
||||||
>
|
|
||||||
<span class="material-symbols-outlined text-sm">mark_email_unread</span>
|
|
||||||
<span>Send reset email</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="forgotBackToLoginBtn" type="button" class="w-full bg-surface-container-low hover:bg-slate-200 text-on-surface-variant font-semibold py-2.5 px-4 rounded-lg transition-colors">
|
|
||||||
Back to sign in
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="forgotPasswordErrorMessage" class="hidden bg-error-container/20 text-error/80 border border-error/30 rounded-lg px-4 py-3 text-xs font-medium"></div>
|
|
||||||
<div id="forgotPasswordSuccessMessage" class="hidden bg-green-50 text-green-800 border border-green-200 rounded-lg px-4 py-3 text-xs font-medium"></div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form id="resetPasswordForm" class="space-y-5 hidden">
|
|
||||||
<div class="bg-blue-50 border border-blue-100 rounded-lg px-4 py-3 text-xs text-blue-700">
|
|
||||||
Set your new password below.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="resetPassword" class="block text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-2">New Password</label>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
|
|
||||||
<span class="material-symbols-outlined text-base">lock_reset</span>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="resetPassword"
|
|
||||||
name="resetPassword"
|
|
||||||
placeholder="Enter a new password"
|
|
||||||
minlength="12"
|
|
||||||
maxlength="256"
|
|
||||||
required
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="resetConfirmPassword" class="block text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-2">Confirm New Password</label>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
|
|
||||||
<span class="material-symbols-outlined text-base">verified_user</span>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="resetConfirmPassword"
|
|
||||||
name="resetConfirmPassword"
|
|
||||||
placeholder="Re-enter new password"
|
|
||||||
minlength="12"
|
|
||||||
maxlength="256"
|
|
||||||
required
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full bg-primary hover:bg-primary-dim text-on-primary font-bold py-2.5 px-4 rounded-lg transition-all active:scale-95 duration-100 flex items-center justify-center gap-2 mt-4"
|
|
||||||
>
|
|
||||||
<span class="material-symbols-outlined text-sm">password</span>
|
|
||||||
<span>Reset password</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="resetBackToLoginBtn" type="button" class="w-full bg-surface-container-low hover:bg-slate-200 text-on-surface-variant font-semibold py-2.5 px-4 rounded-lg transition-colors">
|
|
||||||
Back to sign in
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="resetPasswordErrorMessage" class="hidden bg-error-container/20 text-error/80 border border-error/30 rounded-lg px-4 py-3 text-xs font-medium"></div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Register Form -->
|
<!-- Register Form -->
|
||||||
<form id="registerForm" class="space-y-5 hidden">
|
<form id="registerForm" class="space-y-5 hidden">
|
||||||
<div>
|
<div>
|
||||||
@@ -297,8 +179,6 @@
|
|||||||
id="regPassword"
|
id="regPassword"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Create a password"
|
placeholder="Create a password"
|
||||||
minlength="12"
|
|
||||||
maxlength="256"
|
|
||||||
required
|
required
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
|
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
|
||||||
/>
|
/>
|
||||||
@@ -319,7 +199,7 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<!-- <div class="mt-8 pt-6 border-t border-outline-variant/10 text-center">
|
<!-- <div class="mt-8 pt-6 border-t border-outline-variant/10 text-center">
|
||||||
<p class="text-[10px] text-on-surface-variant/60">Use the account provided by your administrator.</p>
|
<p class="text-[10px] text-on-surface-variant/60">Default credentials for demo: admin / admin</p>
|
||||||
</div> -->
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -331,27 +211,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const authTabs = document.getElementById('authTabs');
|
// Simple login functionality
|
||||||
const loginForm = document.getElementById('loginForm');
|
const loginForm = document.getElementById('loginForm');
|
||||||
const errorMessage = document.getElementById('errorMessage');
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
const usernameInput = document.getElementById('username');
|
const usernameInput = document.getElementById('username');
|
||||||
const passwordInput = document.getElementById('password');
|
const passwordInput = document.getElementById('password');
|
||||||
const rememberCheckbox = document.getElementById('remember');
|
const rememberCheckbox = document.getElementById('remember');
|
||||||
const forgotPasswordLink = document.getElementById('forgotPasswordLink');
|
|
||||||
|
|
||||||
const forgotPasswordForm = document.getElementById('forgotPasswordForm');
|
|
||||||
const forgotUsernameInput = document.getElementById('forgotUsername');
|
|
||||||
const forgotEmailInput = document.getElementById('forgotEmail');
|
|
||||||
const forgotBackToLoginBtn = document.getElementById('forgotBackToLoginBtn');
|
|
||||||
const forgotPasswordErrorMessage = document.getElementById('forgotPasswordErrorMessage');
|
|
||||||
const forgotPasswordSuccessMessage = document.getElementById('forgotPasswordSuccessMessage');
|
|
||||||
|
|
||||||
const resetPasswordForm = document.getElementById('resetPasswordForm');
|
|
||||||
const resetPasswordInput = document.getElementById('resetPassword');
|
|
||||||
const resetConfirmPasswordInput = document.getElementById('resetConfirmPassword');
|
|
||||||
const resetBackToLoginBtn = document.getElementById('resetBackToLoginBtn');
|
|
||||||
const resetPasswordErrorMessage = document.getElementById('resetPasswordErrorMessage');
|
|
||||||
|
|
||||||
const registerForm = document.getElementById('registerForm');
|
const registerForm = document.getElementById('registerForm');
|
||||||
const registerErrorMessage = document.getElementById('registerErrorMessage');
|
const registerErrorMessage = document.getElementById('registerErrorMessage');
|
||||||
const registerSuccessMessage = document.getElementById('registerSuccessMessage');
|
const registerSuccessMessage = document.getElementById('registerSuccessMessage');
|
||||||
@@ -364,31 +229,19 @@
|
|||||||
const regPasswordInput = document.getElementById('regPassword');
|
const regPasswordInput = document.getElementById('regPassword');
|
||||||
const loginTab = document.getElementById('loginTab');
|
const loginTab = document.getElementById('loginTab');
|
||||||
const registerTab = document.getElementById('registerTab');
|
const registerTab = document.getElementById('registerTab');
|
||||||
|
|
||||||
let pendingVerificationIdentifier = '';
|
let pendingVerificationIdentifier = '';
|
||||||
let currentMode = 'login';
|
let currentMode = 'login';
|
||||||
let resetToken = '';
|
|
||||||
|
|
||||||
const setMode = (mode) => {
|
const setMode = (mode) => {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
const isLogin = mode === 'login';
|
const isLogin = mode === 'login';
|
||||||
const isRegister = mode === 'register';
|
|
||||||
const isForgot = mode === 'forgot';
|
|
||||||
const isReset = mode === 'reset';
|
|
||||||
|
|
||||||
loginForm.classList.toggle('hidden', !isLogin);
|
loginForm.classList.toggle('hidden', !isLogin);
|
||||||
registerForm.classList.toggle('hidden', !isRegister);
|
registerForm.classList.toggle('hidden', isLogin);
|
||||||
forgotPasswordForm.classList.toggle('hidden', !isForgot);
|
|
||||||
resetPasswordForm.classList.toggle('hidden', !isReset);
|
|
||||||
authTabs.classList.toggle('hidden', isReset);
|
|
||||||
|
|
||||||
errorMessage.classList.add('hidden');
|
errorMessage.classList.add('hidden');
|
||||||
registerErrorMessage.classList.add('hidden');
|
registerErrorMessage.classList.add('hidden');
|
||||||
registerSuccessMessage.classList.add('hidden');
|
registerSuccessMessage.classList.add('hidden');
|
||||||
verifyNotice.classList.add('hidden');
|
verifyNotice.classList.add('hidden');
|
||||||
forgotPasswordErrorMessage.classList.add('hidden');
|
|
||||||
forgotPasswordSuccessMessage.classList.add('hidden');
|
|
||||||
resetPasswordErrorMessage.classList.add('hidden');
|
|
||||||
|
|
||||||
const activate = (btn, active) => {
|
const activate = (btn, active) => {
|
||||||
btn.classList.toggle('bg-primary', active);
|
btn.classList.toggle('bg-primary', active);
|
||||||
@@ -398,112 +251,29 @@
|
|||||||
btn.classList.toggle('text-on-surface-variant', !active);
|
btn.classList.toggle('text-on-surface-variant', !active);
|
||||||
};
|
};
|
||||||
|
|
||||||
activate(loginTab, !isRegister);
|
activate(loginTab, isLogin);
|
||||||
activate(registerTab, isRegister);
|
activate(registerTab, !isLogin);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearResetQuery = () => {
|
// Check if already logged in
|
||||||
const cleanUrl = `${window.location.origin}${window.location.pathname}`;
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
window.history.replaceState({}, document.title, cleanUrl);
|
const currentUser = localStorage.getItem('currentUser');
|
||||||
};
|
if (currentUser) {
|
||||||
|
window.location.href = './index.html';
|
||||||
const getInitialMode = () => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const mode = String(params.get('mode') || '').trim().toLowerCase();
|
|
||||||
const token = String(params.get('token') || '').trim();
|
|
||||||
|
|
||||||
if (mode === 'reset-password') {
|
|
||||||
resetToken = token;
|
|
||||||
return token ? 'reset' : 'forgot';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'forgot-password') {
|
setMode('login');
|
||||||
return 'forgot';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'login';
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
let initialMode = getInitialMode();
|
|
||||||
try {
|
|
||||||
const configResponse = await fetch('/api/auth/config', { cache: 'no-store' });
|
|
||||||
const config = await configResponse.json();
|
|
||||||
if (configResponse.ok && config.allowSelfRegistration === false) {
|
|
||||||
registerTab.classList.add('hidden');
|
|
||||||
if (initialMode === 'register') {
|
|
||||||
initialMode = 'login';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
registerTab.classList.add('hidden');
|
|
||||||
if (initialMode === 'register') {
|
|
||||||
initialMode = 'login';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (initialMode !== 'reset') {
|
|
||||||
try {
|
|
||||||
const sessionResponse = await fetch('/api/auth/session', { cache: 'no-store' });
|
|
||||||
const sessionData = await sessionResponse.json();
|
|
||||||
if (sessionResponse.ok && sessionData.success && sessionData.user) {
|
|
||||||
localStorage.setItem('currentUser', JSON.stringify(sessionData.user));
|
|
||||||
window.location.replace('./index.html');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.debug('No active session');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localStorage.removeItem('currentUser');
|
|
||||||
|
|
||||||
setMode(initialMode);
|
|
||||||
|
|
||||||
if (initialMode === 'forgot') {
|
|
||||||
forgotUsernameInput.value = usernameInput.value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialMode === 'reset' && !resetToken) {
|
|
||||||
setMode('forgot');
|
|
||||||
forgotPasswordErrorMessage.textContent = 'Reset link is invalid. Please request a new password reset email.';
|
|
||||||
forgotPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Restore remembered username
|
||||||
const rememberedUsername = localStorage.getItem('rememberedUsername');
|
const rememberedUsername = localStorage.getItem('rememberedUsername');
|
||||||
if (rememberedUsername) {
|
if (rememberedUsername) {
|
||||||
usernameInput.value = rememberedUsername;
|
usernameInput.value = rememberedUsername;
|
||||||
rememberCheckbox.checked = true;
|
rememberCheckbox.checked = true;
|
||||||
if (!forgotUsernameInput.value) {
|
|
||||||
forgotUsernameInput.value = rememberedUsername;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
loginTab.addEventListener('click', () => {
|
loginTab.addEventListener('click', () => setMode('login'));
|
||||||
clearResetQuery();
|
registerTab.addEventListener('click', () => setMode('register'));
|
||||||
setMode('login');
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTab.addEventListener('click', () => {
|
|
||||||
clearResetQuery();
|
|
||||||
setMode('register');
|
|
||||||
});
|
|
||||||
|
|
||||||
forgotPasswordLink.addEventListener('click', () => {
|
|
||||||
setMode('forgot');
|
|
||||||
forgotUsernameInput.value = usernameInput.value.trim();
|
|
||||||
forgotEmailInput.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
forgotBackToLoginBtn.addEventListener('click', () => {
|
|
||||||
clearResetQuery();
|
|
||||||
setMode('login');
|
|
||||||
});
|
|
||||||
|
|
||||||
resetBackToLoginBtn.addEventListener('click', () => {
|
|
||||||
resetToken = '';
|
|
||||||
clearResetQuery();
|
|
||||||
setMode('login');
|
|
||||||
});
|
|
||||||
|
|
||||||
loginForm.addEventListener('submit', async (e) => {
|
loginForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -514,31 +284,36 @@
|
|||||||
const password = passwordInput.value;
|
const password = passwordInput.value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Call backend login API
|
||||||
const response = await fetch('/api/auth/login', {
|
const response = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password, remember: rememberCheckbox.checked })
|
body: JSON.stringify({ username, password })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success && data.user) {
|
if (data.success && data.user) {
|
||||||
|
// Store user info from backend
|
||||||
localStorage.setItem('currentUser', JSON.stringify(data.user));
|
localStorage.setItem('currentUser', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
// Handle remember me
|
||||||
if (rememberCheckbox.checked) {
|
if (rememberCheckbox.checked) {
|
||||||
localStorage.setItem('rememberedUsername', username);
|
localStorage.setItem('rememberedUsername', username);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('rememberedUsername');
|
localStorage.removeItem('rememberedUsername');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
window.location.href = './index.html';
|
window.location.href = './index.html';
|
||||||
} else if (data.requiresEmailVerification) {
|
} else if (data.requiresEmailVerification) {
|
||||||
pendingVerificationIdentifier = data.username || data.email || username;
|
pendingVerificationIdentifier = data.username || data.email || username;
|
||||||
verifyNoticeText.textContent = data.message || 'Please confirm your email before signing in';
|
verifyNoticeText.textContent = data.message || 'Please confirm your email before signing in';
|
||||||
verifyNotice.classList.remove('hidden');
|
verifyNotice.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
|
// Show error
|
||||||
errorMessage.textContent = data.message || 'Invalid username or password';
|
errorMessage.textContent = data.message || 'Invalid username or password';
|
||||||
errorMessage.classList.remove('hidden');
|
errorMessage.classList.remove('hidden');
|
||||||
passwordInput.value = '';
|
passwordInput.value = '';
|
||||||
@@ -551,108 +326,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
forgotPasswordForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
forgotPasswordErrorMessage.classList.add('hidden');
|
|
||||||
forgotPasswordSuccessMessage.classList.add('hidden');
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
username: forgotUsernameInput.value.trim(),
|
|
||||||
email: forgotEmailInput.value.trim()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!payload.username || !payload.email) {
|
|
||||||
forgotPasswordErrorMessage.textContent = 'Username and email are required.';
|
|
||||||
forgotPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/forgot-password', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data?.success) {
|
|
||||||
const lines = [data.message || 'If the account exists, a password reset email has been sent.'];
|
|
||||||
if (data.resetPreviewUrl) {
|
|
||||||
lines.push(`Development reset link: ${data.resetPreviewUrl}`);
|
|
||||||
}
|
|
||||||
forgotPasswordSuccessMessage.textContent = lines.join(' ');
|
|
||||||
forgotPasswordSuccessMessage.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
forgotPasswordErrorMessage.textContent = data?.message || 'Forgot password request failed.';
|
|
||||||
forgotPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
forgotPasswordErrorMessage.textContent = 'Connection error. Please try again.';
|
|
||||||
forgotPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
console.error('Forgot password error:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resetPasswordForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
resetPasswordErrorMessage.classList.add('hidden');
|
|
||||||
|
|
||||||
const newPassword = resetPasswordInput.value;
|
|
||||||
const confirmPassword = resetConfirmPasswordInput.value;
|
|
||||||
|
|
||||||
if (!resetToken) {
|
|
||||||
resetPasswordErrorMessage.textContent = 'Reset link is invalid. Please request a new one.';
|
|
||||||
resetPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newPassword || newPassword.length < 12 || newPassword.length > 256) {
|
|
||||||
resetPasswordErrorMessage.textContent = 'New password must be between 12 and 256 characters.';
|
|
||||||
resetPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
resetPasswordErrorMessage.textContent = 'Confirm password does not match.';
|
|
||||||
resetPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/reset-password', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
token: resetToken,
|
|
||||||
newPassword
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data?.success) {
|
|
||||||
resetToken = '';
|
|
||||||
resetPasswordInput.value = '';
|
|
||||||
resetConfirmPasswordInput.value = '';
|
|
||||||
clearResetQuery();
|
|
||||||
setMode('login');
|
|
||||||
verifyNoticeText.textContent = data.message || 'Password reset successful. Please sign in.';
|
|
||||||
verifyNotice.classList.remove('hidden');
|
|
||||||
passwordInput.focus();
|
|
||||||
} else {
|
|
||||||
resetPasswordErrorMessage.textContent = data?.message || 'Password reset failed.';
|
|
||||||
resetPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
resetPasswordErrorMessage.textContent = 'Connection error. Please try again.';
|
|
||||||
resetPasswordErrorMessage.classList.remove('hidden');
|
|
||||||
console.error('Reset password error:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerForm.addEventListener('submit', async (e) => {
|
registerForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
registerErrorMessage.classList.add('hidden');
|
registerErrorMessage.classList.add('hidden');
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title>Robot Manager Account - Users Management</title>
|
<title>Robot Manager Account - Users Management</title>
|
||||||
<link rel="icon" type="image/png" href="../image/logo_PNKX.png"/>
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||||||
<!-- Material Symbols -->
|
<!-- Material Symbols -->
|
||||||
|
|||||||
@@ -4,17 +4,14 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Confirm Email - AccManager</title>
|
<title>Confirm Email - AccManager</title>
|
||||||
<link rel="icon" type="image/png" href="../image/logo_PNKX.png" />
|
|
||||||
<link rel="stylesheet" href="../css/main.css" />
|
<link rel="stylesheet" href="../css/main.css" />
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Inter', sans-serif; min-height: 100vh; }
|
body { font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="../css/responsive.css?v=20260717-1" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="verify-page bg-gradient-to-br from-slate-100 via-white to-blue-100 text-slate-900 antialiased">
|
<body class="bg-gradient-to-br from-slate-100 via-white to-blue-100 text-slate-900 antialiased">
|
||||||
<main class="verify-shell min-h-screen flex items-center justify-center px-4">
|
<main class="min-h-screen flex items-center justify-center px-4">
|
||||||
<section class="verify-card w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-lg p-8">
|
<section class="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-lg p-8">
|
||||||
<img src="../image/logo_PNKX.png" alt="PNKX" class="verify-brand-image mb-6" />
|
|
||||||
<h1 class="text-2xl font-black tracking-tight mb-2">Email Confirmation</h1>
|
<h1 class="text-2xl font-black tracking-tight mb-2">Email Confirmation</h1>
|
||||||
<p class="text-sm text-slate-600 mb-6">We are confirming your account.</p>
|
<p class="text-sm text-slate-600 mb-6">We are confirming your account.</p>
|
||||||
|
|
||||||
@@ -22,7 +19,7 @@
|
|||||||
Confirming your email, please wait...
|
Confirming your email, please wait...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="verify-actions mt-6 flex gap-3">
|
<div class="mt-6 flex gap-3">
|
||||||
<a href="./login.html" class="flex-1 text-center px-4 py-2 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold transition-colors">Back to Login</a>
|
<a href="./login.html" class="flex-1 text-center px-4 py-2 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold transition-colors">Back to Login</a>
|
||||||
<a id="signInBtn" href="./login.html" class="hidden flex-1 text-center px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors">Sign In</a>
|
<a id="signInBtn" href="./login.html" class="hidden flex-1 text-center px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors">Sign In</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
process.env.DATA_ENCRYPTION_SECRET = 'test-only-encryption-secret-with-more-than-32-characters';
|
|
||||||
|
|
||||||
const {
|
|
||||||
app,
|
|
||||||
encryptSensitiveValue,
|
|
||||||
decryptSensitiveValue,
|
|
||||||
hashSessionToken,
|
|
||||||
normalizeOptionalHttpUrl,
|
|
||||||
isPdfBuffer,
|
|
||||||
sanitizeDocumentFileName,
|
|
||||||
getDocumentContentDisposition
|
|
||||||
} = require('../backend/server');
|
|
||||||
|
|
||||||
async function withTestServer(run) {
|
|
||||||
const server = app.listen(0, '127.0.0.1');
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
server.once('listening', resolve);
|
|
||||||
server.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const address = server.address();
|
|
||||||
await run(`http://127.0.0.1:${address.port}`);
|
|
||||||
} finally {
|
|
||||||
await new Promise(resolve => server.close(resolve));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('stored credentials use authenticated encryption and round-trip safely', () => {
|
|
||||||
const plainText = 'example-password-value';
|
|
||||||
const first = encryptSensitiveValue(plainText);
|
|
||||||
const second = encryptSensitiveValue(plainText);
|
|
||||||
|
|
||||||
assert.match(first, /^enc:v2:/);
|
|
||||||
assert.notEqual(first, second);
|
|
||||||
assert.equal(first.includes(plainText), false);
|
|
||||||
assert.equal(decryptSensitiveValue(first), plainText);
|
|
||||||
assert.equal(decryptSensitiveValue(`${first}tampered`), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('session tokens are stored as deterministic SHA-256 hashes', () => {
|
|
||||||
const token = 'a-private-random-session-token';
|
|
||||||
const hash = hashSessionToken(token);
|
|
||||||
|
|
||||||
assert.match(hash, /^[a-f0-9]{64}$/);
|
|
||||||
assert.equal(hash, hashSessionToken(token));
|
|
||||||
assert.equal(hash.includes(token), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('application URLs accept only HTTP and HTTPS protocols', () => {
|
|
||||||
assert.equal(normalizeOptionalHttpUrl(''), '');
|
|
||||||
assert.equal(normalizeOptionalHttpUrl('javascript:alert(1)'), null);
|
|
||||||
assert.equal(normalizeOptionalHttpUrl('file:///etc/passwd'), null);
|
|
||||||
assert.match(normalizeOptionalHttpUrl('https://example.com/path'), /^https:\/\/example\.com\/path/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PDF uploads require a PDF header and end-of-file marker', () => {
|
|
||||||
assert.equal(isPdfBuffer(Buffer.from('%PDF-1.7\nbody\n%%EOF')), true);
|
|
||||||
assert.equal(isPdfBuffer(Buffer.from('%PDF-1.7\nbody without trailer')), false);
|
|
||||||
assert.equal(isPdfBuffer(Buffer.from('<html>not a pdf</html>')), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('document filenames and response headers cannot inject control characters', () => {
|
|
||||||
const safeName = sanitizeDocumentFileName('bao/cao\r\nInjected: value');
|
|
||||||
const longName = sanitizeDocumentFileName('a'.repeat(300));
|
|
||||||
const disposition = getDocumentContentDisposition('Báo cáo tháng 8.pdf');
|
|
||||||
|
|
||||||
assert.equal(safeName, 'bao_caoInjected_ value.pdf');
|
|
||||||
assert.equal(longName.length, 255);
|
|
||||||
assert.match(longName, /\.pdf$/);
|
|
||||||
assert.match(disposition, /^inline; filename="/);
|
|
||||||
assert.match(disposition, /filename\*=UTF-8''/);
|
|
||||||
assert.equal(disposition.includes('\r'), false);
|
|
||||||
assert.equal(disposition.includes('\n'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('forged legacy identity headers cannot bypass protected APIs', async () => {
|
|
||||||
await withTestServer(async baseUrl => {
|
|
||||||
const response = await fetch(`${baseUrl}/api/users`, {
|
|
||||||
headers: {
|
|
||||||
'x-user-id': '1',
|
|
||||||
'x-user-role': 'admin'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const body = await response.json();
|
|
||||||
|
|
||||||
assert.equal(response.status, 401);
|
|
||||||
assert.equal(body.success, false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('security headers and no-store API caching are enabled', async () => {
|
|
||||||
await withTestServer(async baseUrl => {
|
|
||||||
const response = await fetch(`${baseUrl}/api/health`);
|
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
assert.match(response.headers.get('content-security-policy') || '', /default-src 'self'/);
|
|
||||||
assert.equal(response.headers.get('x-powered-by'), null);
|
|
||||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state-changing requests from untrusted origins are rejected before route handling', async () => {
|
|
||||||
await withTestServer(async baseUrl => {
|
|
||||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Origin: 'https://attacker.example',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username: 'test', password: 'not-a-real-password' })
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(response.status, 403);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user