diff --git a/.gitignore b/.gitignore index 6eeaedf9..012d12ac 100755 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,13 @@ # ยกเว้นโฟลเดอร์ .devcontainer/ +.qsync/ @Recently-Snapshot/ Documents/ mariadb/data/ +n8n*/ +npm/ phpmyadmin/ pgadmin/ -npm/ -n8n/ -n8n-cache/ -n8n-migrate/git -n8n-postgres/ -pgadmin/ -.tmp*/ # ===================================================== # IDE/Editor settings # ===================================================== @@ -94,4 +90,5 @@ docker-compose.override.*.yml /backend/.cache/ /frontend/.cache/ .tmp/ +.tmp*.*/ .cache/ \ No newline at end of file diff --git a/README.md b/README.md index 2005c1b6..49a18501 100755 --- a/README.md +++ b/README.md @@ -113,3 +113,24 @@ - ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว - Code ของ backend ทั้งหมด - การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend + +# กรณี 2: มี Git อยู่แล้ว (มี main อยู่) + +2.1 อัปเดต main ให้ตรงล่าสุดก่อนแตกบร้านช์ + +cd /share/Container/dms +git checkout main +git pull --ff-only # ถ้าเชื่อม remote อยู่ +git tag -f stable-$(date +%F) # tag จุดเสถียรปัจจุบัน + +2.2 แตก branch งาน Dashboard +git checkout -b feature/dashboard-update-$(date +%y%m%d) +git checkout -b feature/dashboard-update-251004 + +2.3 ทำงาน/คอมมิตตามปกติ + +# แก้ไฟล์ frontend/app/dashboard/\* และที่เกี่ยวข้อง + +git add frontend/app/dashboard +git commit -m "feat(dashboard): เพิ่ม KPI tiles + แก้ layout grid" +git push -u origin feature/dashboard-update-251004 diff --git a/backend/src/index.js b/backend/src/index.js index 8ed31cbb..c73efc08 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -10,6 +10,9 @@ import { authJwt } from "./middleware/authJwt.js"; import { loadPrincipalMw } from "./middleware/loadPrincipal.js"; // ROUTES +import usersRoutes from "./routes/users.js"; +import rbacAdminRoutes from "./routes/rbac_admin.js"; +import dashboardRoutes from "./routes/dashboard.js"; import authRoutes from "./routes/auth.js"; import lookupRoutes from "./routes/lookup.js"; import organizationsRoutes from "./routes/organizations.js"; @@ -148,6 +151,8 @@ app.use("/api/volumes", volumesRoutes); app.use("/api/uploads", uploadsRoutes); app.use("/api/users", usersRoutes); app.use("/api/permissions", permissionsRoutes); +app.use("/api/rbac", rbacAdminRoutes); +app.use("/api/dashboard", dashboardRoutes); // 404 / error app.use((req, res) => diff --git a/backend/src/routes/dashboard copy.js b/backend/src/routes/dashboard copy.js new file mode 100644 index 00000000..5e1fa197 --- /dev/null +++ b/backend/src/routes/dashboard copy.js @@ -0,0 +1,56 @@ +// backend/src/routes/dashboard.js +import { Router } from "express"; +import { Op } from "sequelize"; +import { Correspondence, Document, RFA, User } from "../db/index.js"; // import models +import { authJwt } from "../middleware/index.js"; + +const router = Router(); + +// Middleware: ตรวจสอบสิทธิ์สำหรับทุก route ในไฟล์นี้ +router.use(authJwt.verifyToken); + +// === API สำหรับ User Management Widget === +router.get("/users/summary", async (req, res, next) => { + try { + const totalUsers = await User.count(); + const activeUsers = await User.count({ where: { is_active: true } }); + // ดึง user ที่สร้างล่าสุด 5 คน + const recentUsers = await User.findAll({ + limit: 5, + order: [["createdAt", "DESC"]], + attributes: ["id", "username", "email", "createdAt"], + }); + + res.json({ + total: totalUsers, + active: activeUsers, + inactive: totalUsers - activeUsers, + recent: recentUsers, + }); + } catch (error) { + next(error); + } +}); + +// === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า === +router.get("/stats", async (req, res, next) => { + try { + const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7)); + + const totalDocuments = await Document.count(); + const newThisWeek = await Document.count({ + where: { createdAt: { [Op.gte]: sevenDaysAgo } }, + }); + const pendingRfas = await RFA.count({ where: { status: "pending" } }); // สมมติตาม status + + res.json({ + totalDocuments, + newThisWeek, + pendingRfas, + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js new file mode 100644 index 00000000..7489e1d3 --- /dev/null +++ b/backend/src/routes/dashboard.js @@ -0,0 +1,23 @@ +import { Router } from "express"; +import { User } from "../db/index.js"; +import { authJwt } from "../middleware/index.js"; + +const router = Router(); + +router.use(authJwt.verifyToken); + +router.get("/users/summary", async (req, res, next) => { + try { + const totalUsers = await User.count(); + const activeUsers = await User.count({ where: { is_active: true } }); + res.json({ + total: totalUsers, + active: activeUsers, + inactive: totalUsers - activeUsers, + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/rbac_admin copy.js b/backend/src/routes/rbac_admin copy.js new file mode 100644 index 00000000..991c5422 --- /dev/null +++ b/backend/src/routes/rbac_admin copy.js @@ -0,0 +1,126 @@ +// FILE: backend/src/routes/rbac_admin.js +// RBAC admin — ใช้ settings.manage ทั้งหมด +import { Router } from "express"; +import sql from "../db/index.js"; +import { requirePerm } from "../middleware/requirePerm.js"; + +const r = Router(); + +// ROLES +r.get("/roles", requirePerm("settings.manage"), async (_req, res) => { + const [rows] = await sql.query( + "SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code" + ); + res.json(rows); +}); + +// PERMISSIONS +r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => { + const [rows] = await sql.query( + "SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code" + ); + res.json(rows); +}); + +// role -> permissions +r.get( + "/roles/:role_id/permissions", + requirePerm("settings.manage"), + async (req, res) => { + const role_id = Number(req.params.role_id); + const [rows] = await sql.query( + `SELECT p.permission_id, p.perm_code AS permission_code, p.description + FROM role_permissions rp + JOIN permissions p ON p.permission_id = rp.permission_id + WHERE rp.role_id=? ORDER BY p.perm_code`, + [role_id] + ); + res.json(rows); + } +); + +r.post( + "/roles/:role_id/permissions", + requirePerm("settings.manage"), + async (req, res) => { + const role_id = Number(req.params.role_id); + const { permission_id } = req.body || {}; + await sql.query( + "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)", + [role_id, Number(permission_id)] + ); + res.json({ ok: 1 }); + } +); + +r.delete( + "/roles/:role_id/permissions/:permission_id", + requirePerm("settings.manage"), + async (req, res) => { + const role_id = Number(req.params.role_id); + const permission_id = Number(req.params.permission_id); + await sql.query( + "DELETE FROM role_permissions WHERE role_id=? AND permission_id=?", + [role_id, permission_id] + ); + res.json({ ok: 1 }); + } +); + +// user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา) +r.get( + "/users/:user_id/roles", + requirePerm("settings.manage"), + async (req, res) => { + const user_id = Number(req.params.user_id); + const [rows] = await sql.query( + `SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id + FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id + WHERE ur.user_id=? ORDER BY r.role_code`, + [user_id] + ); + res.json(rows); + } +); + +r.post( + "/users/:user_id/roles", + requirePerm("settings.manage"), + async (req, res) => { + const user_id = Number(req.params.user_id); + const { role_id, org_id = null, project_id = null } = req.body || {}; + await sql.query( + "INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)", + [ + user_id, + Number(role_id), + org_id ? Number(org_id) : null, + project_id ? Number(project_id) : null, + ] + ); + res.json({ ok: 1 }); + } +); + +r.delete( + "/users/:user_id/roles", + requirePerm("settings.manage"), + async (req, res) => { + const user_id = Number(req.params.user_id); + const { role_id, org_id = null, project_id = null } = req.body || {}; + // สร้างเงื่อนไขแบบ dynamic สำหรับ NULL-safe compare + const whereOrg = org_id === null ? "ur.org_id IS NULL" : "ur.org_id = ?"; + const wherePrj = + project_id === null ? "ur.project_id IS NULL" : "ur.project_id = ?"; + const params = [user_id, Number(role_id)]; + if (org_id !== null) params.push(Number(org_id)); + if (project_id !== null) params.push(Number(project_id)); + await sql.query( + `DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`, + params + ); + res.json({ ok: 1 }); + } +); + +export default r; diff --git a/backend/src/routes/rbac_admin.js b/backend/src/routes/rbac_admin.js index 991c5422..6e2f61f0 100644 --- a/backend/src/routes/rbac_admin.js +++ b/backend/src/routes/rbac_admin.js @@ -1,126 +1,144 @@ // FILE: backend/src/routes/rbac_admin.js -// RBAC admin — ใช้ settings.manage ทั้งหมด import { Router } from "express"; -import sql from "../db/index.js"; -import { requirePerm } from "../middleware/requirePerm.js"; +import { Role, Permission, UserProjectRole, Project } from "../db/index.js"; +import { authJwt, permGuard } from "../middleware/index.js"; -const r = Router(); +const router = Router(); -// ROLES -r.get("/roles", requirePerm("settings.manage"), async (_req, res) => { - const [rows] = await sql.query( - "SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code" - ); - res.json(rows); +// กำหนดให้ทุก route ในไฟล์นี้ต้องมีสิทธิ์ 'manage_rbac' +router.use(authJwt.verifyToken, permGuard("manage_rbac")); + +// == ROLES Management == +router.get("/roles", async (req, res, next) => { + try { + const roles = await Role.findAll({ + include: [ + { + model: Permission, + attributes: ["id", "name"], + through: { attributes: [] }, + }, + ], + order: [["name", "ASC"]], + }); + res.json(roles); + } catch (error) { + next(error); + } }); -// PERMISSIONS -r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => { - const [rows] = await sql.query( - "SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code" - ); - res.json(rows); +router.post("/roles", async (req, res, next) => { + try { + const { name, description } = req.body; + if (!name) + return res.status(400).json({ message: "Role name is required." }); + const newRole = await Role.create({ name, description }); + res.status(201).json(newRole); + } catch (error) { + if (error.name === "SequelizeUniqueConstraintError") { + return res + .status(409) + .json({ message: `Role '${name}' already exists.` }); + } + next(error); + } }); -// role -> permissions -r.get( - "/roles/:role_id/permissions", - requirePerm("settings.manage"), - async (req, res) => { - const role_id = Number(req.params.role_id); - const [rows] = await sql.query( - `SELECT p.permission_id, p.perm_code AS permission_code, p.description - FROM role_permissions rp - JOIN permissions p ON p.permission_id = rp.permission_id - WHERE rp.role_id=? ORDER BY p.perm_code`, - [role_id] - ); - res.json(rows); - } -); +router.put("/roles/:id/permissions", async (req, res, next) => { + try { + const { permissionIds } = req.body; + if (!Array.isArray(permissionIds)) + return res + .status(400) + .json({ message: "permissionIds must be an array." }); -r.post( - "/roles/:role_id/permissions", - requirePerm("settings.manage"), - async (req, res) => { - const role_id = Number(req.params.role_id); - const { permission_id } = req.body || {}; - await sql.query( - "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)", - [role_id, Number(permission_id)] - ); - res.json({ ok: 1 }); - } -); + const role = await Role.findByPk(req.params.id); + if (!role) return res.status(404).json({ message: "Role not found." }); -r.delete( - "/roles/:role_id/permissions/:permission_id", - requirePerm("settings.manage"), - async (req, res) => { - const role_id = Number(req.params.role_id); - const permission_id = Number(req.params.permission_id); - await sql.query( - "DELETE FROM role_permissions WHERE role_id=? AND permission_id=?", - [role_id, permission_id] - ); - res.json({ ok: 1 }); + await role.setPermissions(permissionIds); + const updatedRole = await Role.findByPk(req.params.id, { + include: [ + { + model: Permission, + attributes: ["id", "name"], + through: { attributes: [] }, + }, + ], + }); + res.json(updatedRole); + } catch (error) { + next(error); } -); +}); -// user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา) -r.get( - "/users/:user_id/roles", - requirePerm("settings.manage"), - async (req, res) => { - const user_id = Number(req.params.user_id); - const [rows] = await sql.query( - `SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id - FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id - WHERE ur.user_id=? ORDER BY r.role_code`, - [user_id] - ); - res.json(rows); +// == PERMISSIONS Management == +router.get("/permissions", async (req, res, next) => { + try { + const permissions = await Permission.findAll({ order: [["name", "ASC"]] }); + res.json(permissions); + } catch (error) { + next(error); } -); +}); -r.post( - "/users/:user_id/roles", - requirePerm("settings.manage"), - async (req, res) => { - const user_id = Number(req.params.user_id); - const { role_id, org_id = null, project_id = null } = req.body || {}; - await sql.query( - "INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)", - [ - user_id, - Number(role_id), - org_id ? Number(org_id) : null, - project_id ? Number(project_id) : null, - ] - ); - res.json({ ok: 1 }); +// == USER-PROJECT-ROLES Management == +router.get("/user-project-roles", async (req, res, next) => { + const { userId } = req.query; + if (!userId) + return res + .status(400) + .json({ message: "userId query parameter is required." }); + try { + const assignments = await UserProjectRole.findAll({ + where: { user_id: userId }, + include: [ + { model: Project, attributes: ["id", "name"] }, + { model: Role, attributes: ["id", "name"] }, + ], + }); + res.json(assignments); + } catch (error) { + next(error); } -); +}); -r.delete( - "/users/:user_id/roles", - requirePerm("settings.manage"), - async (req, res) => { - const user_id = Number(req.params.user_id); - const { role_id, org_id = null, project_id = null } = req.body || {}; - // สร้างเงื่อนไขแบบ dynamic สำหรับ NULL-safe compare - const whereOrg = org_id === null ? "ur.org_id IS NULL" : "ur.org_id = ?"; - const wherePrj = - project_id === null ? "ur.project_id IS NULL" : "ur.project_id = ?"; - const params = [user_id, Number(role_id)]; - if (org_id !== null) params.push(Number(org_id)); - if (project_id !== null) params.push(Number(project_id)); - await sql.query( - `DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`, - params - ); - res.json({ ok: 1 }); +router.post("/user-project-roles", async (req, res, next) => { + const { userId, projectId, roleId } = req.body; + if (!userId || !projectId || !roleId) + return res + .status(400) + .json({ message: "userId, projectId, and roleId are required." }); + try { + const [assignment, created] = await UserProjectRole.findOrCreate({ + where: { user_id: userId, project_id: projectId, role_id: roleId }, + defaults: { user_id: userId, project_id: projectId, role_id: roleId }, + }); + if (!created) + return res + .status(409) + .json({ message: "This assignment already exists." }); + res.status(201).json(assignment); + } catch (error) { + next(error); } -); +}); -export default r; +router.delete("/user-project-roles", async (req, res, next) => { + const { userId, projectId, roleId } = req.body; + if (!userId || !projectId || !roleId) + return res + .status(400) + .json({ message: "userId, projectId, and roleId are required." }); + try { + const deletedCount = await UserProjectRole.destroy({ + where: { user_id: userId, project_id: projectId, role_id: roleId }, + }); + if (deletedCount === 0) + return res.status(404).json({ message: "Assignment not found." }); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/users copy.js b/backend/src/routes/users copy.js new file mode 100644 index 00000000..9f96432c --- /dev/null +++ b/backend/src/routes/users copy.js @@ -0,0 +1,55 @@ +// FILE: backend/src/routes/users.js +import { Router } from "express"; +import sql from "../db/index.js"; +import { requirePerm } from "../middleware/requirePerm.js"; + +const r = Router(); + +// ME (ทุกคน) +r.get("/me", async (req, res) => { + const p = req.principal; + const [[u]] = await sql.query( + `SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`, + [p.user_id] + ); + if (!u) return res.status(404).json({ error: "User not found" }); + const [roles] = await sql.query( + `SELECT r.role_code, r.role_name, ur.org_id, ur.project_id + FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id + WHERE ur.user_id=?`, + [p.user_id] + ); + res.json({ + ...u, + roles, + role_codes: roles.map((r) => r.role_code), + permissions: [...(p.permissions || [])], + project_ids: p.project_ids, + org_ids: p.org_ids, + is_superadmin: p.is_superadmin, + }); +}); + +// USERS LIST (ORG scope) — admin.access +r.get( + "/", + requirePerm("admin.access", { orgParam: "org_id" }), + async (req, res) => { + const P = req.principal; + let rows = []; + if (P.is_superadmin) { + [rows] = await sql.query( + "SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500" + ); + } else if (P.org_ids?.length) { + const inSql = P.org_ids.map(() => "?").join(","); + [rows] = await sql.query( + `SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`, + P.org_ids + ); + } + res.json(rows); + } +); + +export default r; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 9f96432c..ee3e7e10 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -1,55 +1,136 @@ -// FILE: backend/src/routes/users.js +// File: backend/src/routes/users.js import { Router } from "express"; -import sql from "../db/index.js"; -import { requirePerm } from "../middleware/requirePerm.js"; +import { User, Role } from "../db/index.js"; +import { authJwt, permGuard } from "../middleware/index.js"; +import { hashPassword } from "../utils/passwords.js"; -const r = Router(); +const router = Router(); -// ME (ทุกคน) -r.get("/me", async (req, res) => { - const p = req.principal; - const [[u]] = await sql.query( - `SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`, - [p.user_id] - ); - if (!u) return res.status(404).json({ error: "User not found" }); - const [roles] = await sql.query( - `SELECT r.role_code, r.role_name, ur.org_id, ur.project_id - FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id - WHERE ur.user_id=?`, - [p.user_id] - ); - res.json({ - ...u, - roles, - role_codes: roles.map((r) => r.role_code), - permissions: [...(p.permissions || [])], - project_ids: p.project_ids, - org_ids: p.org_ids, - is_superadmin: p.is_superadmin, - }); -}); +// Middleware สำหรับทุก route ในไฟล์นี้ +router.use(authJwt.verifyToken); -// USERS LIST (ORG scope) — admin.access -r.get( +// GET /api/users - ดึงรายชื่อผู้ใช้ทั้งหมด +router.get( "/", - requirePerm("admin.access", { orgParam: "org_id" }), - async (req, res) => { - const P = req.principal; - let rows = []; - if (P.is_superadmin) { - [rows] = await sql.query( - "SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500" - ); - } else if (P.org_ids?.length) { - const inSql = P.org_ids.map(() => "?").join(","); - [rows] = await sql.query( - `SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`, - P.org_ids - ); + permGuard("manage_users"), // ตรวจสอบสิทธิ์ + async (req, res, next) => { + try { + const users = await User.findAll({ + attributes: { exclude: ["password_hash"] }, // **สำคัญมาก: ห้ามส่ง password hash ออกไป** + include: [ + { + model: Role, + attributes: ["id", "name"], + through: { attributes: [] }, // ไม่ต้องเอาข้อมูลจากตาราง join (UserRoles) มา + }, + ], + order: [["username", "ASC"]], + }); + res.json(users); + } catch (error) { + next(error); } - res.json(rows); } ); -export default r; +// POST /api/users - สร้างผู้ใช้ใหม่ +router.post("/", permGuard("manage_users"), async (req, res, next) => { + const { username, email, password, first_name, last_name, is_active, roles } = + req.body; + + if (!username || !email || !password) { + return res + .status(400) + .json({ message: "Username, email, and password are required" }); + } + + try { + const password_hash = await hashPassword(password); + const newUser = await User.create({ + username, + email, + password_hash, + first_name, + last_name, + is_active: is_active !== false, + }); + + if (roles && roles.length > 0) { + await newUser.setRoles(roles); + } + + const userWithRoles = await User.findByPk(newUser.id, { + attributes: { exclude: ["password_hash"] }, + include: [ + { + model: Role, + attributes: ["id", "name"], + through: { attributes: [] }, + }, + ], + }); + + res.status(201).json(userWithRoles); + } catch (error) { + if (error.name === "SequelizeUniqueConstraintError") { + return res + .status(409) + .json({ message: "Username or email already exists." }); + } + next(error); + } +}); + +// PUT /api/users/:id - อัปเดตข้อมูลผู้ใช้ +router.put("/:id", permGuard("manage_users"), async (req, res, next) => { + const { id } = req.params; + const { email, first_name, last_name, is_active, roles } = req.body; + + try { + const user = await User.findByPk(id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + user.email = email ?? user.email; + user.first_name = first_name ?? user.first_name; + user.last_name = last_name ?? user.last_name; + user.is_active = is_active ?? user.is_active; + await user.save(); + + if (roles) { + await user.setRoles(roles); + } + + const updatedUser = await User.findByPk(id, { + attributes: { exclude: ["password_hash"] }, + include: [ + { + model: Role, + attributes: ["id", "name"], + through: { attributes: [] }, + }, + ], + }); + res.json(updatedUser); + } catch (error) { + next(error); + } +}); + +// DELETE /api/users/:id - ลบผู้ใช้ (Soft Delete) +router.delete("/:id", permGuard("manage_users"), async (req, res, next) => { + try { + const user = await User.findByPk(req.params.id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + user.is_active = false; // Soft Delete + await user.save(); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/frontend/app/(protected)/admin/_components/confirm-delete-dialog.jsx b/frontend/app/(protected)/admin/_components/confirm-delete-dialog.jsx new file mode 100644 index 00000000..1d809160 --- /dev/null +++ b/frontend/app/(protected)/admin/_components/confirm-delete-dialog.jsx @@ -0,0 +1,38 @@ +// File: frontend/app/(protected)/admin/_components/confirm-delete-dialog.jsx +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" + +export function ConfirmDeleteDialog({ + isOpen, + setIsOpen, + title, + description, + onConfirm, + isLoading, +}) { + return ( + + + + {title} + {description} + + + Cancel + + {isLoading ? 'Processing...' : 'Confirm'} + + + + + ); +} \ No newline at end of file diff --git a/frontend/app/(protected)/admin/_components/role-form-dialog.jsx b/frontend/app/(protected)/admin/_components/role-form-dialog.jsx new file mode 100644 index 00000000..abefdbd6 --- /dev/null +++ b/frontend/app/(protected)/admin/_components/role-form-dialog.jsx @@ -0,0 +1,146 @@ +// File: frontend/app/(protected)/admin/_components/role-form-dialog.jsx +'use client'; + +import { useState, useEffect } from 'react'; +import api from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +export function RoleFormDialog({ role, allPermissions, isOpen, setIsOpen, onSuccess }) { + const [formData, setFormData] = useState({ name: '', description: '' }); + const [selectedPermissions, setSelectedPermissions] = useState(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const isEditMode = !!role; + + useEffect(() => { + if (isOpen) { + if (isEditMode) { + setFormData({ name: role.name, description: role.description || '' }); + setSelectedPermissions(new Set(role.Permissions?.map(p => p.id) || [])); + } else { + setFormData({ name: '', description: '' }); + setSelectedPermissions(new Set()); + } + setError(''); + } + }, [role, isOpen]); + + const handleInputChange = (e) => { + const { id, value } = e.target; + setFormData((prev) => ({ ...prev, [id]: value })); + }; + + const handlePermissionChange = (permissionId) => { + setSelectedPermissions(prev => { + const newSet = new Set(prev); + if (newSet.has(permissionId)) { + newSet.delete(permissionId); + } else { + newSet.add(permissionId); + } + return newSet; + }); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + if (isEditMode) { + // ในโหมดแก้ไข เราจะอัปเดตสิทธิ์เสมอ + await api.put(`/rbac/roles/${role.id}/permissions`, { + permissionIds: Array.from(selectedPermissions) + }); + // (Optional) อาจจะเพิ่มการแก้ไขชื่อ/description ของ role ที่นี่ด้วยก็ได้ + // await api.put(`/rbac/roles/${role.id}`, { name: formData.name, description: formData.description }); + } else { + // ในโหมดสร้างใหม่ + const newRoleRes = await api.post('/rbac/roles', formData); + // ถ้าสร้าง Role สำเร็จ และมีการเลือก Permission ไว้ ให้ทำการผูกสิทธิ์ทันที + if (newRoleRes.data && selectedPermissions.size > 0) { + await api.put(`/rbac/roles/${newRoleRes.data.id}/permissions`, { + permissionIds: Array.from(selectedPermissions) + }); + } + } + onSuccess(); + setIsOpen(false); + } catch (err) { + setError(err.response?.data?.message || 'An unexpected error occurred.'); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+ + {isEditMode ? `Edit Permissions for ${role.name}` : 'Create New Role'} + + Select the permissions for this role. + + + +
+ {!isEditMode && ( + <> +
+ + +
+
+ + +
+ + )} + +
+ + +
+ {allPermissions.map(perm => ( +
+ handlePermissionChange(perm.id)} + /> + +
+ ))} +
+
+
+
+ + {error &&

{error}

} + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/(protected)/admin/_components/user-form-dialog.jsx b/frontend/app/(protected)/admin/_components/user-form-dialog.jsx new file mode 100644 index 00000000..b48c1010 --- /dev/null +++ b/frontend/app/(protected)/admin/_components/user-form-dialog.jsx @@ -0,0 +1,172 @@ +// File: frontend/app/(protected)/admin/users/_components/user-form-dialog.jsx +'use client'; + +import { useState, useEffect } from 'react'; +import api from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Checkbox } from "@/components/ui/checkbox" + +export function UserFormDialog({ user, isOpen, setIsOpen, onSuccess }) { + const [formData, setFormData] = useState({}); + const [allRoles, setAllRoles] = useState([]); + const [selectedRoles, setSelectedRoles] = useState(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const isEditMode = !!user; + + useEffect(() => { + // ดึงข้อมูล Role ทั้งหมดมาเตรียมไว้ + const fetchRoles = async () => { + try { + const res = await api.get('/rbac/roles'); + setAllRoles(res.data); + } catch (err) { + console.error('Failed to fetch roles', err); + } + }; + fetchRoles(); + }, []); + + useEffect(() => { + // เมื่อ user prop เปลี่ยน (เปิด dialog เพื่อแก้ไข) ให้ตั้งค่าฟอร์ม + if (isEditMode) { + setFormData({ + username: user.username, + email: user.email, + first_name: user.first_name || '', + last_name: user.last_name || '', + is_active: user.is_active, + }); + setSelectedRoles(new Set(user.Roles?.map(role => role.id) || [])); + } else { + // ถ้าเป็นการสร้างใหม่ ให้เคลียร์ฟอร์ม + setFormData({ + username: '', + email: '', + password: '', + first_name: '', + last_name: '', + is_active: true, + }); + setSelectedRoles(new Set()); + } + setError(''); + }, [user, isOpen]); + + const handleInputChange = (e) => { + const { id, value } = e.target; + setFormData((prev) => ({ ...prev, [id]: value })); + }; + + const handleRoleChange = (roleId) => { + setSelectedRoles(prev => { + const newSet = new Set(prev); + if (newSet.has(roleId)) { + newSet.delete(roleId); + } else { + newSet.add(roleId); + } + return newSet; + }); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + const payload = { ...formData, roles: Array.from(selectedRoles) }; + + try { + if (isEditMode) { + await api.put(`/users/${user.id}`, payload); + } else { + await api.post('/users', payload); + } + onSuccess(); // บอกให้หน้าหลัก refresh ข้อมูล + setIsOpen(false); // ปิด Dialog + } catch (err) { + setError(err.response?.data?.message || 'An unexpected error occurred.'); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+ + {isEditMode ? 'Edit User' : 'Create New User'} + + {isEditMode ? `Editing ${user.username}` : 'Fill in the details for the new user.'} + + +
+
+ + +
+
+ + +
+ {!isEditMode && ( +
+ + +
+ )} +
+ + +
+
+ + +
+
+ +
+ {allRoles.map(role => ( +
+ handleRoleChange(role.id)} + /> + +
+ ))} +
+
+
+ + setFormData(prev => ({...prev, is_active: checked}))} /> +
+
+ {error &&

{error}

} + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/(protected)/admin/layout.jsx b/frontend/app/(protected)/admin/layout.jsx new file mode 100644 index 00000000..0e987894 --- /dev/null +++ b/frontend/app/(protected)/admin/layout.jsx @@ -0,0 +1,43 @@ +// File: frontend/app/(protected)/admin/layout.jsx +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Users, ShieldCheck } from 'lucide-react'; +import { cn } from '@/lib/utils'; // ตรวจสอบว่า import cn มาจากที่ถูกต้อง + +export default function AdminLayout({ children }) { + const pathname = usePathname(); + + const navLinks = [ + { href: '/admin/users', label: 'User Management', icon: Users }, + { href: '/admin/roles', label: 'Role & Permission', icon: ShieldCheck }, + ]; + + return ( +
+
+

Admin Settings

+

Manage users, roles, and system permissions.

+
+
+ {navLinks.map(({ href, label, icon: Icon }) => ( + + + {label} + + ))} +
+
{children}
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/(protected)/admin/roles/page.jsx b/frontend/app/(protected)/admin/roles/page.jsx new file mode 100644 index 00000000..2a63a454 --- /dev/null +++ b/frontend/app/(protected)/admin/roles/page.jsx @@ -0,0 +1,105 @@ +// File: frontend/app/(protected)/admin/roles/page.jsx +'use client'; + +import { useState, useEffect } from 'react'; +import api from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { ShieldCheck, PlusCircle } from 'lucide-react'; + +// Import Dialog component ที่เราเพิ่งสร้าง +import { RoleFormDialog } from '../_components/role-form-dialog'; + +export default function RolesPage() { + const [roles, setRoles] = useState([]); + const [allPermissions, setAllPermissions] = useState([]); + const [loading, setLoading] = useState(true); + + // State สำหรับควบคุม Dialog + const [isFormOpen, setIsFormOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); + + const fetchData = async () => { + try { + setLoading(true); + const [rolesRes, permsRes] = await Promise.all([ + api.get('/rbac/roles'), + api.get('/rbac/permissions'), + ]); + setRoles(rolesRes.data); + setAllPermissions(permsRes.data); + } catch (error) { + console.error("Failed to fetch RBAC data", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleCreate = () => { + setSelectedRole(null); // ไม่มี Role ที่เลือก = สร้างใหม่ + setIsFormOpen(true); + }; + + const handleEdit = (role) => { + setSelectedRole(role); + setIsFormOpen(true); + }; + + if (loading) return
Loading role settings...
; + + return ( + <> +
+
+

Roles & Permissions

+ +
+ {roles.map(role => ( + + +
+
+ + + {role.name} + + {role.description || 'No description'} +
+ +
+
+ +

Assigned Permissions:

+
+ {role.Permissions.length > 0 ? ( + role.Permissions.map(perm => ( + {perm.name} + )) + ) : ( +

No permissions assigned.

+ )} +
+
+
+ ))} +
+ + + + ); +} \ No newline at end of file diff --git a/frontend/app/(protected)/admin/users/page.jsx b/frontend/app/(protected)/admin/users/page.jsx new file mode 100644 index 00000000..fcd21926 --- /dev/null +++ b/frontend/app/(protected)/admin/users/page.jsx @@ -0,0 +1,161 @@ +// File: frontend/app/(protected)/admin/users/page.jsx +'use client'; + +import { useState, useEffect } from 'react'; +import { PlusCircle, MoreHorizontal } from 'lucide-react'; +import api from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Badge } from '@/components/ui/badge'; + +// Import components ที่เราเพิ่งสร้าง +import { UserFormDialog } from '../_components/user-form-dialog'; +import { ConfirmDeleteDialog } from '../_components/confirm-delete-dialog'; + + +export default function UsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + + // State สำหรับควบคุม Dialog ทั้งหมด + const [isFormOpen, setIsFormOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Function สำหรับดึงข้อมูลใหม่ + const fetchUsers = async () => { + try { + setLoading(true); + const res = await api.get('/users'); + setUsers(res.data); + } catch (error) { + console.error("Failed to fetch users", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUsers(); + }, []); + + // Handlers สำหรับเปิด Dialog + const handleCreate = () => { + setSelectedUser(null); + setIsFormOpen(true); + }; + + const handleEdit = (user) => { + setSelectedUser(user); + setIsFormOpen(true); + }; + + const handleDelete = (user) => { + setSelectedUser(user); + setIsDeleteOpen(true); + }; + + // Function ที่จะทำงานเมื่อยืนยันการลบ + const confirmDeactivate = async () => { + if (!selectedUser) return; + setIsSubmitting(true); + try { + await api.delete(`/users/${selectedUser.id}`); + fetchUsers(); // Refresh ข้อมูล + setIsDeleteOpen(false); + } catch (error) { + console.error("Failed to deactivate user", error); + // ควรมี Alert แจ้งเตือน + } finally { + setIsSubmitting(false); + } + }; + + + return ( + <> + + +
+
+ User Accounts + Manage all user accounts and their roles. +
+ +
+
+ + + + + Username + Email + Roles + Status + Actions + + + + {loading ? ( + Loading... + ) : ( + users.map((user) => ( + + {user.username} + {user.email} + +
+ {user.Roles?.map(role => {role.name})} +
+
+ + + {user.is_active ? 'Active' : 'Inactive'} + + + + + + + + + Actions + handleEdit(user)}>Edit + handleDelete(user)} className="text-red-500"> + Deactivate + + + + +
+ )) + )} +
+
+
+
+ + {/* Render Dialogs ที่นี่ (มันจะไม่แสดงผลจนกว่า state จะเป็น true) */} + + + + + ); +} \ No newline at end of file diff --git a/frontend/app/(protected)/dashboard/page copy.jsx b/frontend/app/(protected)/dashboard/page copy.jsx new file mode 100644 index 00000000..c7c4ebdc --- /dev/null +++ b/frontend/app/(protected)/dashboard/page copy.jsx @@ -0,0 +1,977 @@ +// frontend/app//(protected)/dashboard/page.jsx +"use client"; +import React from "react"; +import Link from "next/link"; +import { motion } from "framer-motion"; +import { + LayoutDashboard, + FileText, + Files, + Send, + Layers, + Users, + Settings, + Activity, + Search, + ChevronRight, + ShieldCheck, + Workflow, + Database, + Mail, + Server, + Shield, + BookOpen, + PanelLeft, + PanelRight, + ChevronDown, + Plus, + Filter, + Eye, + EyeOff, + SlidersHorizontal, + Columns3, + X, + ExternalLink, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Progress } from "@/components/ui/progress"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Switch } from "@/components/ui/switch"; +import { API_BASE } from "@/lib/api"; + +const sea = { + light: "#E6F7FB", + light2: "#F3FBFD", + mid: "#2A7F98", + dark: "#0D5C75", + textDark: "#0E2932", +}; +const can = (user, perm) => new Set(user?.permissions || []).has(perm); +const Tag = ({ children }) => ( + + {children} + +); +const SidebarItem = ({ label, icon: Icon, active = false, badge }) => ( + +); +const KPI = ({ label, value, icon: Icon, onClick }) => ( + + +
+ {label} +
+ +
+
+
+ {value} +
+
+ +
+
+
+); +function PreviewDrawer({ open, onClose, children }) { + return ( +
+
+
รายละเอียด
+ +
+
{children}
+
+ ); +} + +export default function DashboardPage() { + const [user, setUser] = React.useState(null); + const [sidebarOpen, setSidebarOpen] = React.useState(true); + const [densityCompact, setDensityCompact] = React.useState(false); + const [showCols, setShowCols] = React.useState({ + type: true, + id: true, + title: true, + status: true, + due: true, + owner: true, + actions: true, + }); + const [previewOpen, setPreviewOpen] = React.useState(false); + const [filters, setFilters] = React.useState({ + type: "All", + status: "All", + overdue: false, + }); + const [activeQuery, setActiveQuery] = React.useState({}); + + React.useEffect(() => { + fetch(`${API_BASE}/auth/me`, { credentials: "include" }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => setUser(data?.user || null)) + .catch(() => setUser(null)); + }, []); + + const quickLinks = [ + { + label: "สร้าง RFA", + icon: FileText, + perm: "rfa:create", + href: "/rfas/new", + }, + { + label: "อัปโหลด Drawing", + icon: Layers, + perm: "drawing:upload", + href: "/drawings/upload", + }, + { + label: "สร้าง Transmittal", + icon: Send, + perm: "transmittal:create", + href: "/transmittals/new", + }, + { + label: "บันทึกหนังสือสื่อสาร", + icon: Mail, + perm: "correspondence:create", + href: "/correspondences/new", + }, + ]; + const nav = [ + { label: "แดชบอร์ด", icon: LayoutDashboard }, + { label: "Drawings", icon: Layers }, + { label: "RFAs", icon: FileText }, + { label: "Transmittals", icon: Send }, + { label: "Contracts & Volumes", icon: BookOpen }, + { label: "Correspondences", icon: Files }, + { label: "ผู้ใช้/บทบาท", icon: Users, perm: "users:manage" }, + { label: "Reports", icon: Activity }, + { label: "Workflow (n8n)", icon: Workflow, perm: "workflow:view" }, + { label: "Health", icon: Server, perm: "health:view" }, + { label: "Admin", icon: Settings, perm: "admin:view" }, + ]; + const kpis = [ + { + key: "rfa-pending", + label: "RFAs รออนุมัติ", + value: 12, + icon: FileText, + query: { type: "RFA", status: "pending" }, + }, + { + key: "drawings", + label: "แบบ (Drawings) ล่าสุด", + value: 326, + icon: Layers, + query: { type: "Drawing" }, + }, + { + key: "trans-month", + label: "Transmittals เดือนนี้", + value: 18, + icon: Send, + query: { type: "Transmittal", month: "current" }, + }, + { + key: "overdue", + label: "เกินกำหนด (Overdue)", + value: 5, + icon: Activity, + query: { overdue: true }, + }, + ]; + const recent = [ + { + type: "RFA", + code: "RFA-LCP3-0012", + title: "ปรับปรุงรายละเอียดเสาเข็มท่าเรือ", + who: "สุรเชษฐ์ (Editor)", + when: "เมื่อวาน 16:40", + }, + { + type: "Drawing", + code: "DWG-C-210A-Rev.3", + title: "แปลนโครงสร้างท่าเรือส่วนที่ 2", + who: "วรวิชญ์ (Admin)", + when: "วันนี้ 09:15", + }, + { + type: "Transmittal", + code: "TR-2025-0916-04", + title: "ส่งแบบ Rebar Shop Drawing ชุด A", + who: "Supansa (Viewer)", + when: "16 ก.ย. 2025", + }, + { + type: "Correspondence", + code: "CRSP-58", + title: "แจ้งเลื่อนประชุมตรวจแบบ", + who: "Kitti (Editor)", + when: "15 ก.ย. 2025", + }, + ]; + const items = [ + { + t: "RFA", + id: "RFA-LCP3-0013", + title: "ยืนยันรายละเอียดท่อระบายน้ำ", + status: "Pending", + due: "20 ก.ย. 2025", + owner: "คุณแดง", + }, + { + t: "Drawing", + id: "DWG-S-115-Rev.1", + title: "Section เสาเข็มพื้นที่ส่วนที่ 1", + status: "Review", + due: "19 ก.ย. 2025", + owner: "วิทยา", + }, + { + t: "Transmittal", + id: "TR-2025-0915-03", + title: "ส่งแบบโครงสร้างท่าเรือ ชุด B", + status: "Sent", + due: "—", + owner: "สุธิดา", + }, + ]; + const visibleItems = items.filter((r) => { + if (filters.type !== "All" && r.t !== filters.type) return false; + if (filters.status !== "All" && r.status !== filters.status) return false; + if (filters.overdue && r.due === "—") return false; + return true; + }); + const onKpiClick = (q) => { + setActiveQuery(q); + if (q?.type) setFilters((f) => ({ ...f, type: q.type })); + if (q?.overdue) setFilters((f) => ({ ...f, overdue: true })); + }; + + return ( + +
+
+
+ +
+
+ Document Management System +
+
+ โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 — ส่วนที่ 1–4 +
+
+ Phase 3 + Port Infrastructure + + + + + + + ระบบ + {can(user, "admin:view") && ( + + Admin + + )} + {can(user, "users:manage") && ( + + ผู้ใช้/บทบาท + + )} + {can(user, "health:view") && ( + + + Health{" "} + + + + )} + {can(user, "workflow:view") && ( + + + Workflow (n8n){" "} + + + + )} + + + + + + + + + {quickLinks.map(({ label, icon: Icon, perm, href }) => + can(user, perm) ? ( + + + + {label} + + + ) : ( + + +
+ + {label} +
+
+ + ไม่มีสิทธิ์ใช้งาน ({perm}) + +
+ ) + )} + + + Import / Bulk upload + +
+
+
+
+ +
+ {sidebarOpen && ( + + )} + +
+ +
+ {kpis.map((k) => ( + onKpiClick(k.query)} /> + ))} +
+
+ +
+
+ ผลลัพธ์จากตัวกรอง: {filters.type}/{filters.status} + {filters.overdue ? " • Overdue" : ""} +
+
+ + + + + + + {Object.keys(showCols).map((key) => ( + + setShowCols((s) => ({ ...s, [key]: !s[key] })) + } + > + {showCols[key] ? ( + + ) : ( + + )} + {key} + + ))} + + +
+
+ + + +
+ + + + {showCols.type && } + {showCols.id && } + {showCols.title && ( + + )} + {showCols.status && ( + + )} + {showCols.due && ( + + )} + {showCols.owner && ( + + )} + {showCols.actions && ( + + )} + + + + {visibleItems.length === 0 && ( + + + + )} + {visibleItems.map((row) => ( + setPreviewOpen(true)} + > + {showCols.type && ( + + )} + {showCols.id && ( + + )} + {showCols.title && ( + + )} + {showCols.status && ( + + )} + {showCols.due && ( + + )} + {showCols.owner && ( + + )} + {showCols.actions && ( + + )} + + ))} + +
ประเภทรหัสชื่อเรื่องสถานะกำหนดส่งผู้รับผิดชอบจัดการ
+ ไม่พบรายการตามตัวกรองที่เลือก +
{row.t}{row.id}{row.title} + {row.status} + {row.due}{row.owner} +
+ + +
+
+
+
+ เคล็ดลับ: ใช้ปุ่ม ↑/↓ เลื่อนแถว, Enter เปิด, / โฟกัสค้นหา +
+
+
+ + + + ภาพรวม + รายงาน + + +
+ + +
+
+ สถานะโครงการ +
+ Phase 3 • ส่วนที่ 1–4 +
+
+
+
+ ความคืบหน้าโดยรวม +
+ +
+
+
+
ส่วนที่ 1
+
+ เสร็จ 70% +
+
+
+
ส่วนที่ 2
+
+ เสร็จ 58% +
+
+
+
+ ส่วนที่ 3–4 +
+
+ เสร็จ 59% +
+
+
+
+
+
+ + +
+
+ System Health +
+ QNAP • Container Station +
+
+
+ Nginx Reverse Proxy{" "} + + Healthy + +
+
+ MariaDB 10.11{" "} + + OK + +
+
+ n8n (Postgres){" "} + + OK + +
+
+ RBAC Enforcement{" "} + + Enabled + +
+
+
+ +
+
+
+
+ + +
+
+ กิจกรรมล่าสุด +
+
+ Admin + Editor + Viewer +
+
+
+ {recent.map((r) => ( +
+
+ {r.type} • {r.code} +
+
+ {r.title} +
+
{r.who}
+
{r.when}
+
+ ))} +
+
+
+
+ +
+ + +
+ Report A: RFA → Drawings → Revisions +
+
+ รวมทุก Drawing Revision + Code +
+
+ +
+
+
+ + +
+ Report B: ไทม์ไลน์ RFA vs Drawing Rev +
+
+ อิง Query #2 ที่กำหนดไว้ +
+
+ +
+
+
+
+
+
+ +
+ Sea-themed Dashboard • Sidebar ซ่อนได้ • RBAC แสดง/ซ่อน • Faceted + search • KPI click-through • Preview drawer • Column + visibility/Density +
+
+
+ + setPreviewOpen(false)}> +
+
+ รหัส: RFA-LCP3-0013 +
+
+ ชื่อเรื่อง:{" "} + ยืนยันรายละเอียดท่อระบายน้ำ +
+
+ สถานะ: Pending +
+
+ แนบไฟล์: 2 รายการ (PDF, DWG) +
+
+ {can(user, "rfa:create") && ( + + )} + +
+
+
+ + +
+
+ ); +} diff --git a/frontend/app/(protected)/dashboard/page.jsx b/frontend/app/(protected)/dashboard/page.jsx index 65eb54fc..aa08b975 100755 --- a/frontend/app/(protected)/dashboard/page.jsx +++ b/frontend/app/(protected)/dashboard/page.jsx @@ -1,977 +1,155 @@ -// frontend/app//(protected)/dashboard/page.jsx -"use client"; -import React from "react"; -import Link from "next/link"; -import { motion } from "framer-motion"; -import { - LayoutDashboard, - FileText, - Files, - Send, - Layers, - Users, - Settings, - Activity, - Search, - ChevronRight, - ShieldCheck, - Workflow, - Database, - Mail, - Server, - Shield, - BookOpen, - PanelLeft, - PanelRight, - ChevronDown, - Plus, - Filter, - Eye, - EyeOff, - SlidersHorizontal, - Columns3, - X, - ExternalLink, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { Progress } from "@/components/ui/progress"; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuLabel, -} from "@/components/ui/dropdown-menu"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Switch } from "@/components/ui/switch"; -import { API_BASE } from "@/lib/api"; +// frontend/app/(protected)/dashboard/page.jsx +'use client'; -const sea = { - light: "#E6F7FB", - light2: "#F3FBFD", - mid: "#2A7F98", - dark: "#0D5C75", - textDark: "#0E2932", -}; -const can = (user, perm) => new Set(user?.permissions || []).has(perm); -const Tag = ({ children }) => ( - - {children} - -); -const SidebarItem = ({ label, icon: Icon, active = false, badge }) => ( - -); -const KPI = ({ label, value, icon: Icon, onClick }) => ( - - -
- {label} -
- -
-
-
- {value} -
-
- -
-
-
-); -function PreviewDrawer({ open, onClose, children }) { - return ( -
-
-
รายละเอียด
- -
-
{children}
-
- ); -} +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Activity, File, FilePlus, ArrowRight, BellDot, Settings } from 'lucide-react'; +import api from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import { can } from '@/lib/rbac'; export default function DashboardPage() { - const [user, setUser] = React.useState(null); - const [sidebarOpen, setSidebarOpen] = React.useState(true); - const [densityCompact, setDensityCompact] = React.useState(false); - const [showCols, setShowCols] = React.useState({ - type: true, - id: true, - title: true, - status: true, - due: true, - owner: true, - actions: true, - }); - const [previewOpen, setPreviewOpen] = React.useState(false); - const [filters, setFilters] = React.useState({ - type: "All", - status: "All", - overdue: false, - }); - const [activeQuery, setActiveQuery] = React.useState({}); + const { user } = useAuth(); + const [stats, setStats] = useState(null); + const [myTasks, setMyTasks] = useState([]); + const [recentActivity, setRecentActivity] = useState([]); + const [loading, setLoading] = useState(true); - React.useEffect(() => { - fetch(`${API_BASE}/auth/me`, { credentials: "include" }) - .then((r) => (r.ok ? r.json() : null)) - .then((data) => setUser(data?.user || null)) - .catch(() => setUser(null)); + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + // เรียก API ที่จำเป็นสำหรับ Dashboard พร้อมกัน + // หมายเหตุ: Endpoint เหล่านี้อาจจะต้องสร้างเพิ่มเติมในฝั่ง Backend + const [statsRes, tasksRes, activityRes] = await Promise.all([ + api.get('/dashboard/stats').catch(e => ({ data: { totalDocuments: 0, newThisWeek: 0, pendingRfas: 0 }})), // สมมติ endpoint สำหรับ KPI + api.get('/dashboard/my-tasks').catch(e => ({ data: [] })), // สมมติ endpoint สำหรับ Action Items + api.get('/dashboard/recent-activity').catch(e => ({ data: [] })), // สมมติ endpoint สำหรับ Recent Activity + ]); + + setStats(statsRes.data); + setMyTasks(tasksRes.data); + setRecentActivity(activityRes.data); + + } catch (error) { + console.error("Failed to fetch dashboard data:", error); + } finally { + setLoading(false); + } + }; + + fetchData(); }, []); - const quickLinks = [ - { - label: "สร้าง RFA", - icon: FileText, - perm: "rfa:create", - href: "/rfas/new", - }, - { - label: "อัปโหลด Drawing", - icon: Layers, - perm: "drawing:upload", - href: "/drawings/upload", - }, - { - label: "สร้าง Transmittal", - icon: Send, - perm: "transmittal:create", - href: "/transmittals/new", - }, - { - label: "บันทึกหนังสือสื่อสาร", - icon: Mail, - perm: "correspondence:create", - href: "/correspondences/new", - }, - ]; - const nav = [ - { label: "แดชบอร์ด", icon: LayoutDashboard }, - { label: "Drawings", icon: Layers }, - { label: "RFAs", icon: FileText }, - { label: "Transmittals", icon: Send }, - { label: "Contracts & Volumes", icon: BookOpen }, - { label: "Correspondences", icon: Files }, - { label: "ผู้ใช้/บทบาท", icon: Users, perm: "users:manage" }, - { label: "Reports", icon: Activity }, - { label: "Workflow (n8n)", icon: Workflow, perm: "workflow:view" }, - { label: "Health", icon: Server, perm: "health:view" }, - { label: "Admin", icon: Settings, perm: "admin:view" }, - ]; - const kpis = [ - { - key: "rfa-pending", - label: "RFAs รออนุมัติ", - value: 12, - icon: FileText, - query: { type: "RFA", status: "pending" }, - }, - { - key: "drawings", - label: "แบบ (Drawings) ล่าสุด", - value: 326, - icon: Layers, - query: { type: "Drawing" }, - }, - { - key: "trans-month", - label: "Transmittals เดือนนี้", - value: 18, - icon: Send, - query: { type: "Transmittal", month: "current" }, - }, - { - key: "overdue", - label: "เกินกำหนด (Overdue)", - value: 5, - icon: Activity, - query: { overdue: true }, - }, - ]; - const recent = [ - { - type: "RFA", - code: "RFA-LCP3-0012", - title: "ปรับปรุงรายละเอียดเสาเข็มท่าเรือ", - who: "สุรเชษฐ์ (Editor)", - when: "เมื่อวาน 16:40", - }, - { - type: "Drawing", - code: "DWG-C-210A-Rev.3", - title: "แปลนโครงสร้างท่าเรือส่วนที่ 2", - who: "วรวิชญ์ (Admin)", - when: "วันนี้ 09:15", - }, - { - type: "Transmittal", - code: "TR-2025-0916-04", - title: "ส่งแบบ Rebar Shop Drawing ชุด A", - who: "Supansa (Viewer)", - when: "16 ก.ย. 2025", - }, - { - type: "Correspondence", - code: "CRSP-58", - title: "แจ้งเลื่อนประชุมตรวจแบบ", - who: "Kitti (Editor)", - when: "15 ก.ย. 2025", - }, - ]; - const items = [ - { - t: "RFA", - id: "RFA-LCP3-0013", - title: "ยืนยันรายละเอียดท่อระบายน้ำ", - status: "Pending", - due: "20 ก.ย. 2025", - owner: "คุณแดง", - }, - { - t: "Drawing", - id: "DWG-S-115-Rev.1", - title: "Section เสาเข็มพื้นที่ส่วนที่ 1", - status: "Review", - due: "19 ก.ย. 2025", - owner: "วิทยา", - }, - { - t: "Transmittal", - id: "TR-2025-0915-03", - title: "ส่งแบบโครงสร้างท่าเรือ ชุด B", - status: "Sent", - due: "—", - owner: "สุธิดา", - }, - ]; - const visibleItems = items.filter((r) => { - if (filters.type !== "All" && r.t !== filters.type) return false; - if (filters.status !== "All" && r.status !== filters.status) return false; - if (filters.overdue && r.due === "—") return false; - return true; - }); - const onKpiClick = (q) => { - setActiveQuery(q); - if (q?.type) setFilters((f) => ({ ...f, type: q.type })); - if (q?.overdue) setFilters((f) => ({ ...f, overdue: true })); - }; + if (loading) { + // อาจจะใช้ Skeleton UI ที่นี่เพื่อให้ UX ดีขึ้น + return
Loading Dashboard...
; + } return ( - -
-
-
- -
-
- Document Management System -
-
- โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 — ส่วนที่ 1–4 -
-
- Phase 3 - Port Infrastructure - - - - - - - ระบบ - {can(user, "admin:view") && ( - - Admin - - )} - {can(user, "users:manage") && ( - - ผู้ใช้/บทบาท - - )} - {can(user, "health:view") && ( - - - Health{" "} - - - - )} - {can(user, "workflow:view") && ( - - - Workflow (n8n){" "} - - - - )} - - - - - - - - - {quickLinks.map(({ label, icon: Icon, perm, href }) => - can(user, perm) ? ( - - - - {label} - - - ) : ( - - -
- - {label} -
-
- - ไม่มีสิทธิ์ใช้งาน ({perm}) - -
- ) - )} - - - Import / Bulk upload - -
-
-
-
- -
- {sidebarOpen && ( - - )} - -
- -
- {kpis.map((k) => ( - onKpiClick(k.query)} /> - ))} -
-
- -
-
- ผลลัพธ์จากตัวกรอง: {filters.type}/{filters.status} - {filters.overdue ? " • Overdue" : ""} -
-
- - - - - - - {Object.keys(showCols).map((key) => ( - - setShowCols((s) => ({ ...s, [key]: !s[key] })) - } - > - {showCols[key] ? ( - - ) : ( - - )} - {key} - - ))} - - -
-
- - - -
- - - - {showCols.type && } - {showCols.id && } - {showCols.title && ( - - )} - {showCols.status && ( - - )} - {showCols.due && ( - - )} - {showCols.owner && ( - - )} - {showCols.actions && ( - - )} - - - - {visibleItems.length === 0 && ( - - - - )} - {visibleItems.map((row) => ( - setPreviewOpen(true)} - > - {showCols.type && ( - - )} - {showCols.id && ( - - )} - {showCols.title && ( - - )} - {showCols.status && ( - - )} - {showCols.due && ( - - )} - {showCols.owner && ( - - )} - {showCols.actions && ( - - )} - - ))} - -
ประเภทรหัสชื่อเรื่องสถานะกำหนดส่งผู้รับผิดชอบจัดการ
- ไม่พบรายการตามตัวกรองที่เลือก -
{row.t}{row.id}{row.title} - {row.status} - {row.due}{row.owner} -
- - -
-
-
-
- เคล็ดลับ: ใช้ปุ่ม ↑/↓ เลื่อนแถว, Enter เปิด, / โฟกัสค้นหา -
-
-
- - - - ภาพรวม - รายงาน - - -
- - -
-
- สถานะโครงการ -
- Phase 3 • ส่วนที่ 1–4 -
-
-
-
- ความคืบหน้าโดยรวม -
- -
-
-
-
ส่วนที่ 1
-
- เสร็จ 70% -
-
-
-
ส่วนที่ 2
-
- เสร็จ 58% -
-
-
-
- ส่วนที่ 3–4 -
-
- เสร็จ 59% -
-
-
-
-
-
- - -
-
- System Health -
- QNAP • Container Station -
-
-
- Nginx Reverse Proxy{" "} - - Healthy - -
-
- MariaDB 10.11{" "} - - OK - -
-
- n8n (Postgres){" "} - - OK - -
-
- RBAC Enforcement{" "} - - Enabled - -
-
-
- -
-
-
-
- - -
-
- กิจกรรมล่าสุด -
-
- Admin - Editor - Viewer -
-
-
- {recent.map((r) => ( -
-
- {r.type} • {r.code} -
-
- {r.title} -
-
{r.who}
-
{r.when}
-
- ))} -
-
-
-
- -
- - -
- Report A: RFA → Drawings → Revisions -
-
- รวมทุก Drawing Revision + Code -
-
- -
-
-
- - -
- Report B: ไทม์ไลน์ RFA vs Drawing Rev -
-
- อิง Query #2 ที่กำหนดไว้ -
-
- -
-
-
-
-
-
- -
- Sea-themed Dashboard • Sidebar ซ่อนได้ • RBAC แสดง/ซ่อน • Faceted - search • KPI click-through • Preview drawer • Column - visibility/Density -
-
+
+ {/* ส่วน Header และ Quick Access Buttons */} +
+
+

Dashboard

+

Welcome back, {user?.username || 'User'}!

+
+
+ + {/* ปุ่ม Admin Settings จะแสดงเมื่อมีสิทธิ์ 'manage_users' เท่านั้น */} + {user && can(user, 'manage_users') && ( + + )}
- - setPreviewOpen(false)}> -
-
- รหัส: RFA-LCP3-0013 -
-
- ชื่อเรื่อง:{" "} - ยืนยันรายละเอียดท่อระบายน้ำ -
-
- สถานะ: Pending -
-
- แนบไฟล์: 2 รายการ (PDF, DWG) -
-
- {can(user, "rfa:create") && ( - - )} - -
-
-
- -
- + + {/* ส่วน Key Metrics (KPIs) */} +
+ + + Total Documents + + + +
{stats?.totalDocuments || 0}
+

+{stats?.newThisWeek || 0} this week

+
+
+ + + Pending RFAs + + + +
{stats?.pendingRfas || 0}
+

Require your attention

+
+
+ {/* สามารถเพิ่ม Card อื่นๆ ตามต้องการ */} +
+ + {/* ส่วน Action Items และ Recent Activity */} +
+ {/* Action Items */} + + + My Action Items + Tasks that require your immediate attention. + + + {myTasks && myTasks.length > 0 ? ( +
    + {myTasks.map(task => ( +
  • + + + {task.title} + +
  • + ))} +
+ ) : ( +

No pending tasks. You're all caught up!

+ )} +
+
+ + {/* Recent Activity */} + + + Recent Project Activity + Latest updates from the team. + + + {recentActivity && recentActivity.length > 0 ? ( +
    + {recentActivity.map(activity => ( +
  • + +
    +

    +

    {new Date(activity.timestamp).toLocaleString()}

    +
    +
  • + ))} +
+ ) : ( +

No recent activity.

+ )} +
+
+
+
); -} +} \ No newline at end of file diff --git a/frontend/app/(protected)/layout.jsx b/frontend/app/(protected)/layout.jsx index c6b9b269..48f1d705 100755 --- a/frontend/app/(protected)/layout.jsx +++ b/frontend/app/(protected)/layout.jsx @@ -1,8 +1,10 @@ // frontend/app/(protected)/layout.jsx import Link from "next/link"; import { redirect } from "next/navigation"; +import { usePathname } from 'next/navigation'; import { cookies, headers } from "next/headers"; import { can } from "@/lib/rbac"; +import { Home, FileText, Users, Settings } from 'lucide-react'; // เพิ่ม Users, Settings หรือไอคอนที่ต้องการ export const metadata = { title: "DMS | Protected" }; diff --git a/frontend/app/layout.jsx b/frontend/app/layout.jsx index b748dd72..060a63c6 100755 --- a/frontend/app/layout.jsx +++ b/frontend/app/layout.jsx @@ -1,114 +1,157 @@ // frontend/app/layout.jsx -import "./globals.css"; -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { cookies, headers } from "next/headers"; +'use client'; -export const metadata = { - title: "DMS", - description: "Document Management System — LCBP3 Phase 3", -}; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { + Bell, + Home, + Users, + Settings, + Package2, + FileText, // Added for example + LineChart, // Added for example +} from 'lucide-react'; -const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, ""); +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; -/** ดึงสถานะผู้ใช้แบบ global (ไม่บังคับล็อกอิน) */ -async function fetchGlobalSession() { - const cookieStore = await cookies(); - const cookieHeader = cookieStore.toString(); +// **1. Import `useAuth` และ `can` จากไฟล์จริงของคุณ** +import { useAuth } from '@/lib/auth'; +import { can } from '@/lib/rbac'; - const hdrs = await headers(); - const hostHdr = hdrs.get("host"); - const protoHdr = hdrs.get("x-forwarded-proto") || "https"; +export default function ProtectedLayout({ children }) { + const pathname = usePathname(); + + // **2. เรียกใช้งาน useAuth hook เพื่อดึงข้อมูล user** + const { user, logout } = useAuth(); - const res = await fetch(`${API_BASE}/api/auth/me`, { - method: "GET", - headers: { - Cookie: cookieHeader, - "X-Forwarded-Host": hostHdr || "", - "X-Forwarded-Proto": protoHdr, - Accept: "application/json", - }, - cache: "no-store", - }); - - if (!res.ok) return null; - try { - const data = await res.json(); - return data?.ok ? data : null; - } catch { - return null; - } -} - -/** ปุ่ม Logout แบบ Server Action (ไม่ต้องมี client component) */ -async function LogoutAction() { - "use server"; - const cookieStore = await cookies(); - const cookieHeader = cookieStore.toString(); - - const hdrs = await headers(); - const hostHdr = hdrs.get("host"); - const protoHdr = hdrs.get("x-forwarded-proto") || "https"; - - // เรียก backend ให้ลบคุกกี้ออก (HttpOnly cookies) - await fetch(`${API_BASE}/api/auth/logout`, { - method: "POST", - headers: { - Cookie: cookieHeader, - "X-Forwarded-Host": hostHdr || "", - "X-Forwarded-Proto": protoHdr, - Accept: "application/json", - }, - cache: "no-store", - }); - - // กลับไปหน้า login พร้อม next ไป dashboard - redirect("/login?next=/dashboard"); -} - -export default async function RootLayout({ children }) { - const session = await fetchGlobalSession(); - const loggedIn = !!session?.user; + const navLinks = [ + { href: '/dashboard', label: 'Dashboard', icon: Home }, + { href: '/correspondences', label: 'Correspondences', icon: FileText }, + { href: '/drawings', label: 'Drawings', icon: FileText }, + { href: '/rfas', label: 'RFAs', icon: FileText }, + { href: '/transmittals', label: 'Transmittals', icon: FileText }, + { href: '/reports', label: 'Reports', icon: LineChart }, + ]; + + // **3. สร้าง object สำหรับเมนู Admin โดยเฉพาะ** + const adminLink = { + href: '/admin/users', + label: 'Admin', + icon: Settings, + requiredPermission: 'manage_users' + }; return ( - - - {/* Header รวมทุกหน้า */} -
-

Document Management System

- -
- {loggedIn ? ( -
- สวัสดี, {session.user.username} ({session.user.role}) -
- ) : ( -
ยังไม่ได้เข้าสู่ระบบ
- )} - - {/* ปุ่ม Login/Logout */} - {loggedIn ? ( -
- -
- ) : ( - - เข้าสู่ระบบ - - )} +
+
+
+
+ + + LCB P3 DMS + +
-
+
+ +
+
+ + + Need Help? + + Contact support for any issues or questions. + + + + + + +
+
+
+
+
+ {/* Mobile navigation can be added here */} +
+ {/* Optional: Add a search bar */} +
+ + + + + + {user ? user.username : 'My Account'} + + Settings + Support + + Logout + + +
+
+ {children} +
+
+ ); -} +} \ No newline at end of file