diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5f15b9d3..f80bffad 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(npm run *)" + "Bash(npm run *)", + "Bash(xargs grep -l \"apiRequest\\\\|fetch\\\\|axios\")" ] } } diff --git a/.claude/test-cases/AHE-3789-project-progress-cards.md b/.claude/test-cases/AHE-3789-project-progress-cards.md new file mode 100644 index 00000000..e1613cef --- /dev/null +++ b/.claude/test-cases/AHE-3789-project-progress-cards.md @@ -0,0 +1,72 @@ +# AHE-3789 — Project-progress & resource dashboard cards + +Five new **read-only** dashboard cards, added to the customizable card system. +All additive — no existing card, endpoint, or data path was modified. + +> **Work by Category** (point 5) is the redesign of the earlier "Users by Work +> Category" prototype: **task count** per category (distinct tasks each user +> logged time on in the window), a **totals** row, and a team-split **summary +> bar**. All filters use the **standard edit-card settings modal** (Projects / +> Teams-Users / Include-subtasks) plus a category→task-type **template** editor +> embedded in that same modal — no separate config modal. Unmapped task types +> are **ignored** (no "Uncategorized"). **Active Time Trackers** (formerly "Live +> Work") is a table with a tracker-memo column. + +**Cards** +| Card key | Title | Source | +|----------|-------|--------| +| `ActiveProjectsCard` | Active Projects | `metric: active_projects` (company-wide count) | +| `ProjectsByTypeCard` | Projects by Type | `metric: projects_by_type` (company-wide) | +| `RunningProjectsCard` | Running Projects | `metric: running_projects` (period) | +| `LiveWorkTableCard` | Active Time Trackers | `metric: live_work` — who's tracking now + task/project + tracker memo | +| `UsersByCategoryCard` | Work by Category | `metric: users_by_category` — task count per user, bucketed into user-defined categories | + +**Files** +- Backend: `Modules/UserDashboard/controller.js` (`getProjectProgressMetric`), `routes.js` (`POST /api/v1/dashboard/project-metrics`) +- Frontend: `ProjectMetricsCard.vue` (Active Projects, Projects by Type), `ProjectResourceCard.vue` (Running Projects), `LiveWorkCard.vue` (Active Time Trackers), `UsersByCategoryCard.vue` (Work by Category), `CategoryTaskTypeMapper.vue` (category template, embedded in the edit-card modal), `CardFieldComponent.vue` (renders the mapper for this card), `DashBoardCard.vue` (header period + refresh), `HomePage.vue` (register/wire), `utils/cardComponent.json`, `composable/commonFunction.js`, `locales/en.js` + +--- + +## Prerequisites +1. **Restart the backend** (catalog is `require`d from `cardComponent.json` at boot). +2. Frontend dev server / built app. +3. **Log in as Owner/Admin (roleType 1/2)** — Running Projects, Active Time Trackers & Work by Category are role-scoped for non-admins. +4. Seed: ≥ 3 active projects w/ different `ProjectType` + ≥ 1 closed; time logged today/this week across ≥ 2 task types; a running tracker (with a memo). + +--- + +## Test cases + +| ID | Card / area | Steps | Expected | Status | +|----|-------------|-------|----------|:--:| +| PPC-01 | Catalog | Add Card | "Project Progress" category lists **5** cards, readable titles/descriptions. | ⬜ | +| PPC-02 | Active Projects | Add | Company-wide count (`statusType !== 'close'`, not deleted); matches DB `activeProjects`. | ⬜ | +| PPC-03 | Projects by Type | Add | Bars per `ProjectType` sum to Active count; untyped → "Unspecified". | ⬜ | +| PPC-04 | Running Projects | Header period Today → wider | Distinct active projects with logged time in the period; grows for wider window; persists. | ⬜ | +| PPC-05 | Running Projects | Log time in a closed project | Not counted (active only). | ⬜ | +| PPC-06 | Active Time Trackers | Start a tracker (with memo) → refresh | Table row: name + live dot, task, project, **Working on** memo. Clicking the task opens the **TaskDetail sidebar**. | ⬜ | +| PPC-07 | Active Time Trackers | Header **N users tracking** + **user search** | Count = distinct users; table filters by user name; stop tracker + refresh → row leaves. | ⬜ | +| PPC-08 | Role — member | Non-admin views the activity cards | Reflects only **their** activity (role-scoped). | ⬜ | +| PPC-09 | Header UI | All cards | Refresh re-fetches; skeleton while loading; period dropdown (Running Projects, Work by Category) sits before the gear; cards resize narrow without the header breaking. | ⬜ | +| PPC-10 | Regression | Existing cards + add/edit/remove/drag | Work as before; **no console errors**. | ⬜ | +| PPC-11 | Boot integrity | Restart backend; `GET /api/v1/cardcomponent` | Returns **31** cards; no JSON parse error. | ⬜ | +| PPC-12 | Multi-tenant | Second company | Each card shows only the current company's data. | ⬜ | +| WBC-01 | Work by Category — first add | Add the card | Shows a **"Set up categories"** prompt pointing to the **⚙ settings** (no template yet). | ⬜ | +| WBC-02 | Settings — template | Card **settings (⚙)** → **Category template** → pick **Development**, tick 2 task types; pick **QA**, tick 1 → **Save** | Table columns appear per non-empty category; each ticked type belongs to exactly **one** category (re-ticking moves it). Template **persists** after reload. | ⬜ | +| WBC-03 | Data correctness | With time logged across the mapped types | Per-user rows show the **count of distinct tasks** they logged in each category (centered under each column header); **tfoot Total row** = column sums; summary bar segments match the category totals. | ⬜ | +| WBC-04 | Unmapped types | Log time on a task whose type isn't mapped to any category | It is **not** counted anywhere — no "Uncategorized" column; only mapped-category tasks appear. | ⬜ | +| WBC-05 | Settings — projects | Settings (⚙) → **Location** (project) picker → choose a subset → Save | Only the selected projects' logged time counts. | ⬜ | +| WBC-06 | Settings — teams/users | Settings (⚙) → **Show Assignees** → pick a team and/or users → Save | Only those users' time counts (team expands to member ids). | ⬜ | +| WBC-07 | Settings — subtasks | Settings (⚙) → **Include subtasks** off → Save | Time logged on subtasks (`isParentTask:false`) is excluded. | ⬜ | +| WBC-08 | Period + refresh | Change header period; click refresh | Re-fetches for the window; period persists on the card. | ⬜ | +| WBC-09 | Sorting | Click column headers | Rows sort by user / any category / total (asc↔desc). | ⬜ | +| WBC-10 | Role — member | Non-admin views the card | Only **their** logged time, still bucketed by the template. | ⬜ | + +--- + +## Notes +- **Active Projects / Projects by Type** — server-side, company-wide (`statusType !== 'close'`, not deleted). +- **Running Projects** — distinct active projects with logged time in the period; role-scoped; header period dropdown. +- **Active Time Trackers** — trackers running within the last **10 min**; **Working on** = the tracker's `LogDescription`; user count + user search + click-to-open TaskDetail; refresh via header icon. +- **Work by Category** — per user, the **count of distinct tasks** they logged time on in the window, per category (bucketed via the task's `TaskType` against `categoryMap`); unmapped types are **ignored** (no uncategorized). **All filters live in the standard edit-card settings modal** (⚙): Projects (`projectId`/Location), Teams-Users (`AssigneeUserId`/Show Assignees), Include-subtasks (`isParentTask` toggle), and the **category template** via the embedded `CategoryTaskTypeMapper` — saved into `cardData` through the normal card-config submit path (no custom modal). Team ids (`tId_*`) expand to member user ids before the request. Period + refresh in the card chrome (default This Week); role-scoped. +- All queries are read-only and companyId-scoped. diff --git a/.claude/test-cases/DashboardEnhancements-v2.md b/.claude/test-cases/DashboardEnhancements-v2.md new file mode 100644 index 00000000..825a1c0f --- /dev/null +++ b/.claude/test-cases/DashboardEnhancements-v2.md @@ -0,0 +1,85 @@ +# Dashboard Enhancements v2 — On Leave card, drill-downs, period dropdowns, global date range + +Four additive dashboard features on the home page. No existing endpoint semantics changed; +all new backend reads are companyId-scoped and read-only. + +**Features** +1. **On Leave card** (`OnLeaveCard`) — leaves are managed as tickets in a PMS project + (e.g. *Support → HR Support*). Card settings pick that project + the status(es) that + mean "Leave approved". Card lists the tickets whose leave period (startDate–DueDate) + overlaps the selected window, plus **AB/PR** headcounts: + **AB (Absent)** = distinct applicants (first assignee of each ticket) · **PR (Present)** = + active company members − AB. +2. **Project-count drill-downs** — Project Pulse (Active / Working counters + each type-mix + bar), Active Projects, Projects by Type (each bar), Running Projects: clicking opens a + modal listing the projects behind the number with **type + status** (+ *worked in period* + for Project Pulse). +3. **Header period dropdown** — Project Pulse, Worked Tasks, Team Effort Breakdown, + Team Logged vs ETA now use the card-header period `` (defaults: Today / This Week / This Week / This Week); the old in-card period label is gone. | ⬜ | +| PDD-02 | Persistence | Change a period, reload | Selection persisted (cardData.timerange) and data reflects it. | ⬜ | +| PDD-03 | Settings parity | Same timerange in settings ⚙ | Settings dropdown shows the value picked in the header (incl. Auto) and vice-versa. | ⬜ | +| GDR-01 | Global range | Dashboard top bar | Single **range picker** (CalenderCompo, same as timesheets): one input opens a two-date calendar with Current Week / Current Month presets and Apply/Cancel; default = this week; persists per user across reloads. | ⬜ | +| GDR-02 | Auto mode | Set a card's period to **Auto**, change the global range → Apply | Card re-fetches for the new window; non-Auto cards don't. | ⬜ | +| GDR-03 | Validation | Try to Apply with only one date picked / cancel mid-pick | Apply stays disabled until both dates are chosen; Cancel restores the previous range; future dates are selectable (needed for upcoming leaves). | ⬜ | +| GDR-04 | Cards with Auto | Running Projects, Work by Category, Project Pulse, Worked Tasks, Team Effort, Team Logged vs ETA, On Leave | All expose Auto and honour the global range. | ⬜ | +| OLV-07 | Avatars | Users with and without profile photos in the On Leave list | Photos render via the UserProfile atom (store `Employee_profileImageURL`, Wasabi keys handled); users without a photo get the default avatar — never a broken image. | ⬜ | +| OLV-08 | Ticket link | Click a ticket key/name in the On Leave list | The **TaskDetail sidebar** opens for that leave ticket (same as Active Time Trackers); closing it returns to the intact card. | ⬜ | +| PDD-04 | Refresh icons | All cards added in the recent dashboard PRs (Pulse, Active Work, Free Resources, Worked Tasks, Team Effort, Team Logged vs ETA, Tasks by Status/Project, Total Tasks, On Leave, …) | Header shows the refresh icon; clicking re-fetches that card only. | ⬜ | +| PDD-05 | Card skeletons | Reload the dashboard on a slow network | Every self-fetching card shows a shimmer **skeleton** while loading (shared `CardSkeleton` atom; counter blocks on Project Pulse / On Leave) — no bare "…" placeholders anywhere. | ⬜ | +| REG-01 | Regression | Existing cards, add/edit/remove/drag, refresh icons | Behave as before; `GET /api/v1/cardcomponent` returns 32 cards; no JSON parse error; no console errors. | ⬜ | +| REG-02 | Multi-tenant | Second company | On-leave rows/headcounts and drill-down lists show only that company's data. | ⬜ | + +## Notes / assumptions +- **AB/PR** is interpreted as **Absent / Present** headcounts (attendance shorthand). The + applicant is the ticket's **first assignee** — matches the HR-Support convention where + the requester is assignee #1 and approvers are added after. If your workflow differs + (e.g. creator = applicant), change `getOnLeaveBoard`'s `AssigneeUserId[0]` pick. +- On-leave board is **not role-gated** (same as the utilization summary): who-is-on-leave + is team-visible information. +- Drill-down uses the same endpoints with `includeProjects: true` — no new list endpoints. +- The four resource cards keep their settings-modal timerange field; header dropdown and + settings write the same `cardData.timerange`. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..77743444 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Git hooks are executed by sh — CRLF line endings break them on Windows +# ("husky - command not found"), so force LF regardless of core.autocrlf. +.husky/* text eol=lf diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..da994831 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit "$1" diff --git a/.husky/install.mjs b/.husky/install.mjs new file mode 100644 index 00000000..24bd24ee --- /dev/null +++ b/.husky/install.mjs @@ -0,0 +1,14 @@ +// Husky bootstrap — runs from the package.json "prepare" script. +// Production/CI installs omit devDependencies, so husky isn't present there; +// requiring it directly made `npm install` fail on the deploy server +// (sh: husky: not found, exit 127). Skip silently in that case — git hooks +// are a dev-machine concern only. +if (process.env.NODE_ENV === 'production' || process.env.CI === 'true') { + process.exit(0); +} +try { + const { default: husky } = await import('husky'); + console.log(husky()); +} catch { + // husky not installed (devDependencies omitted) — nothing to set up. +} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 00000000..8eefb07e --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,29 @@ +# Validate the branch name before pushing — mirrors +# .github/workflows/branch-name.yml so the failure happens locally +# instead of on the PR. Keep the patterns in sync with that workflow +# and BRANCHING.md § Topic branches. + +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +STANDARD='^(feat|fix|hotfix|refactor|chore|docs|perf|test|ci|build|style)/[a-z0-9]+(-[a-z0-9.]+)*$' +RELEASE='^release/v[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$' +BACKPORT='^hotfix-backport/[a-z0-9]+(-[a-z0-9.]+)*$' +BOT='^(dependabot|renovate)/.+$' +LONG_RUNNING='^(staging|main)$' + +for pattern in "$STANDARD" "$RELEASE" "$BACKPORT" "$BOT" "$LONG_RUNNING"; do + if echo "$BRANCH" | grep -Eq "$pattern"; then + exit 0 + fi +done + +echo "❌ Branch name '$BRANCH' does not follow the convention." +echo "" +echo "Expected: /" +echo "Allowed types: feat, fix, hotfix, refactor, chore, docs, perf, test, ci, build, style" +echo "" +echo "Examples: feat/employee-workload-report fix/login-validation-error" +echo "" +echo "See BRANCHING.md (§ Topic branches). CI enforces this on every PR" +echo "(.github/workflows/branch-name.yml), so pushing would fail there anyway." +exit 1 diff --git a/Modules/UserDashboard/controller.js b/Modules/UserDashboard/controller.js index 1dacf0c5..a3ecfbfa 100644 --- a/Modules/UserDashboard/controller.js +++ b/Modules/UserDashboard/controller.js @@ -6,6 +6,54 @@ const mongoose = require("mongoose"); const logger = require("../../Config/loggerConfig"); const dashboardTemplate = require("../../utils/dashboardTemplate.json"); const cardComponent = require("../../utils/cardComponent.json"); +const { + getDayOrRangeBounds, + buildUserTeamMap, + getLoggedAndTasksInRange, + getUserNameMap, + getSprintTypeMap, +} = require("./helpers/resourceHelpers"); + +// Resolve a project's current status to its display name + colour from the +// project's own status palette (projectStatusData: [{ value, name, +// textColor, … }]) — per-project, since projects created from different +// templates carry different status sets. Used by the drill-down modals. +function projectStatusMeta(p) { + const meta = (Array.isArray(p.projectStatusData) ? p.projectStatusData : []) + .find((s) => s && String(s.value || "").toLowerCase() === String(p.status || "").toLowerCase()) || {}; + return { statusName: meta.name || "", statusColor: meta.textColor || "" }; +} + +// Shared role-visibility resolver for the resource cards. Mirrors +// getEmployeeWorkloadReport: roleType 1/2 → all users (null = no +// restriction); everyone else → only themselves. +function resolveVisibleUserIds(payload = {}) { + const roleType = Number(payload.callerRoleType || 3); + if (roleType === 1 || roleType === 2) return null; + const self = String(payload.callerUserId || ""); + return self ? [self] : []; +} + +// SECURITY: resolve the caller's real role for this company from the DB, never +// from the request body. These routes are JWT-protected (req.uid is the +// verified user; the companyid header is checked against the token audience), +// but roleType isn't in the token — so look it up in company_users. Fails +// closed to the most-restricted role (3) when absent, so a forged +// callerRoleType in the body can never widen the visibility scope. +async function resolveCallerRoleType(companyId, uid) { + if (!uid) return 3; + try { + const row = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.COMPANY_USERS, + data: [{ userId: String(uid), isDelete: { $ne: true } }, { roleType: 1 }], + }, "findOne"); + const rt = Number(row && row.roleType); + return Number.isFinite(rt) && rt > 0 ? rt : 3; + } catch (e) { + logger.error(`resolveCallerRoleType error (company=${companyId}, uid=${uid}): ${e.message || e}`); + return 3; + } +} /** * This endpoint is used to get user user dashboard template @@ -192,6 +240,12 @@ exports.getEmployeeWorkloadReport = async (req, res) => { } const payload = req.body || {}; + // SECURITY (CodeRabbit): derive caller identity + role from the + // authenticated session, never the request body. req.uid is the verified + // JWT user; the role is resolved server-side. A forged + // callerUserId/callerRoleType in the body can no longer widen scope. + payload.callerUserId = String(req.uid || ""); + payload.callerRoleType = await resolveCallerRoleType(companyId, req.uid); // All thresholds come from the caller's card config — no // fallback constants here. If they're missing we just skip // the badge calculation and return 'normal' for everyone. @@ -220,6 +274,9 @@ exports.getEmployeeWorkloadReport = async (req, res) => { // "Current" mode — restrict the report to employees with a // running tracker right now (who is working on what live). currentOnly: payload.currentOnly === true, + // Advanced "Add filter" builder → Mongo match on task fields + // (buildFilterQuery on the client). Merged into the task query. + taskMatch: (payload.taskMatch && typeof payload.taskMatch === "object") ? payload.taskMatch : null, activeWithinMinutes: Number(payload.activeWithinMinutes) || null, idleAfterMinutes: Number(payload.idleAfterMinutes) || null, overloadCapacityMinutesPerDay: Number(payload.overloadCapacityMinutesPerDay) || null, @@ -315,6 +372,7 @@ exports.getEmployeeWorkloadReport = async (req, res) => { // 2. Logged hours in range (per user, per task) from timesheets. const loggedByUserTask = {}; // `${uid}|${tid}` → logged minutes in range + const lastLogByUserTask = {}; // `${uid}|${tid}` → { desc, start } latest work comment { const tsFilter = { Loggeduser: { $in: employeeIdStrs } }; if (dateFromSec != null || dateToSec != null) { @@ -324,12 +382,20 @@ exports.getEmployeeWorkloadReport = async (req, res) => { } const tlogs = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.TIMESHEET, - data: [tsFilter, { Loggeduser: 1, TicketID: 1, LogTimeDuration: 1 }], + data: [tsFilter, { Loggeduser: 1, TicketID: 1, LogTimeDuration: 1, LogDescription: 1, LogStartTime: 1 }], }, "find").catch(() => []); (tlogs || []).forEach((ts) => { if (!ts.TicketID) return; const key = `${ts.Loggeduser}|${ts.TicketID}`; loggedByUserTask[key] = (loggedByUserTask[key] || 0) + (Number(ts.LogTimeDuration) || 0); + // Keep the most recent non-empty work comment for this user+task. + const desc = (ts.LogDescription || "").trim(); + if (desc) { + const start = Number(ts.LogStartTime) || 0; + if (!lastLogByUserTask[key] || start >= lastLogByUserTask[key].start) { + lastLogByUserTask[key] = { desc, start }; + } + } }); } @@ -358,6 +424,7 @@ exports.getEmployeeWorkloadReport = async (req, res) => { const RUNNING_WINDOW_SEC = 10 * 60; // matches frontend's 10-min rule const nowSec = Math.floor(Date.now() / 1000); const activeTrackerPairs = new Set(); + const activeTrackerDesc = {}; // `${uid}|${tid}` → running entry's work comment { const activeFilter = { Loggeduser: { $in: employeeIdStrs }, @@ -365,11 +432,14 @@ exports.getEmployeeWorkloadReport = async (req, res) => { }; const activeLogs = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.TIMESHEET, - data: [activeFilter, { Loggeduser: 1, TicketID: 1 }], + data: [activeFilter, { Loggeduser: 1, TicketID: 1, LogDescription: 1 }], }, "find").catch(() => []); (activeLogs || []).forEach((ts) => { if (!ts.Loggeduser || !ts.TicketID) return; - activeTrackerPairs.add(`${ts.Loggeduser}|${ts.TicketID}`); + const key = `${ts.Loggeduser}|${ts.TicketID}`; + activeTrackerPairs.add(key); + const desc = (ts.LogDescription || "").trim(); + if (desc) activeTrackerDesc[key] = desc; }); } @@ -418,6 +488,19 @@ exports.getEmployeeWorkloadReport = async (req, res) => { { aiTaskCategoryManual: { $exists: false }, aiTaskCategory: cfg.taskType }, ]; } + // Merge the advanced filter match. Fold into $and so it can't + // clobber the taskType $or above (buildFilterQuery may also emit $or). + if (cfg.taskMatch) { + const extra = cfg.taskMatch; + const andParts = []; + if (taskFilter.$or) { andParts.push({ $or: taskFilter.$or }); delete taskFilter.$or; } + if (Array.isArray(extra.$and)) andParts.push(...extra.$and); + if (Array.isArray(extra.$or)) andParts.push({ $or: extra.$or }); + Object.keys(extra).forEach((k) => { + if (k !== "$and" && k !== "$or") taskFilter[k] = extra[k]; + }); + if (andParts.length) taskFilter.$and = (taskFilter.$and || []).concat(andParts); + } const tasks = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.TASKS, data: [ @@ -461,6 +544,45 @@ exports.getEmployeeWorkloadReport = async (req, res) => { pairsByUser[uid].add(tid); }); + // ─── Approved/pending PTO overlapping the window (cards #7/#10) ── + // Approved PTO is what actually removes capacity ("free today" and + // on-leave); pending is surfaced for display only. Overlap rule: + // entry.startDate <= rangeEnd AND entry.endDate >= rangeStart. + const ptoByUser = {}; // uid → { approved:bool, pending:bool, type } + if (cfg.dateFrom && cfg.dateTo) { + const ptoRows = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PTO_ENTRIES, + data: [ + { + deletedStatusKey: 0, + userId: { $in: employeeIdStrs }, + status: { $in: ["approved", "pending"] }, + startDate: { $lte: cfg.dateTo }, + endDate: { $gte: cfg.dateFrom }, + }, + { userId: 1, status: 1, type: 1 }, + ], + }, "find").catch(() => []); + (ptoRows || []).forEach((p) => { + const uid = String(p.userId); + if (!ptoByUser[uid]) ptoByUser[uid] = { approved: false, pending: false, type: null }; + if (p.status === "approved") ptoByUser[uid].approved = true; + if (p.status === "pending") ptoByUser[uid].pending = true; + if (!ptoByUser[uid].type) ptoByUser[uid].type = p.type || null; + }); + } + + // Full-range logged time per user across ALL their filtered tasks — + // computed independently of the currentOnly live-task restriction so a + // live-work view can show each person's total logged for the day, not + // just the task they're tracking right now. + const rangeLoggedByUser = {}; + Object.keys(loggedByUserTask).forEach((key) => { + const [uid, tid] = key.split("|"); + if (!taskMap[tid]) return; // only tasks that passed the filters + rangeLoggedByUser[uid] = (rangeLoggedByUser[uid] || 0) + loggedByUserTask[key]; + }); + // ─── Build per-employee rows ────────────────────────────── const teamLearning = { learning: 0, actual: 0, unknown: 0 }; const workloadByEmployee = []; @@ -512,6 +634,11 @@ exports.getEmployeeWorkloadReport = async (req, res) => { // Currently-running tracker flag — drives the "Running" // badge in the UI. isTracking: activeTrackerPairs.has(`${uidStr}|${tid}`), + // Work comment from the timesheet: prefer the running entry's + // note, else the latest logged note for this user+task. + logDescription: activeTrackerDesc[`${uidStr}|${tid}`] + || (lastLogByUserTask[`${uidStr}|${tid}`] && lastLogByUserTask[`${uidStr}|${tid}`].desc) + || "", }; }; @@ -547,6 +674,11 @@ exports.getEmployeeWorkloadReport = async (req, res) => { assignedTaskCount: tids.length, projectCount: projectIdsForUser.size, overdueCount, + // Total logged for the whole window (all filtered tasks), even + // when currentOnly narrows `tasks` to the live one(s). + dayLoggedMinutes: rangeLoggedByUser[uidStr] || 0, + // PTO status for the window — drives FreeResources/OnLeave cards. + onLeave: ptoByUser[uidStr] || { approved: false, pending: false, type: null }, tasks: taskDetails, }; workloadByEmployee.push({ employeeId: uidStr, name: row.name, loggedMinutes: row.loggedMinutes }); @@ -577,4 +709,903 @@ exports.getEmployeeWorkloadReport = async (req, res) => { error: error && error.message ? error.message : String(error), }); } -}; \ No newline at end of file +}; + +/** + * Project Utilization Summary — backs the ProjectPulseCard (screenshot + * metrics #1-#3): + * 1. Active Projects — projects whose statusType is not 'close' + * 2. Working Projects — distinct projects with a timesheet in the window + * (defaults to today; respects dateFrom/dateTo) + * 3. Type mix — active projects grouped by ProjectType + * (In House / Fixed / Hourly / …) + * + * Company-scoped and read-only. Not user-specific, so no role gate is + * needed — an admin/management widget over company-wide project state. + */ +exports.getProjectUtilizationSummary = async (req, res) => { + try { + const companyId = req.headers["companyid"]; + if (!companyId) { + return res.status(400).json({ status: false, message: "companyId header required" }); + } + + const { fromSec, toSec } = getDayOrRangeBounds(req.body || {}); + // Drill-down mode — the card's count/bar was clicked and the modal + // needs the per-project rows behind each number, not just totals. + const includeProjects = req.body && req.body.includeProjects === true; + + // 1 + 3 — active projects and their ProjectType mix. Drill-down also + // needs each project's status + its per-project status palette + // (projectStatusData) so the modal can render the status in colour. + const activeProjects = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, + data: [ + { statusType: { $nin: ["close"] }, deletedStatusKey: 0 }, + includeProjects + ? { ProjectType: 1, ProjectName: 1, status: 1, statusType: 1, projectStatusData: 1 } + : { ProjectType: 1, ProjectName: 1 }, + ], + }, "find").catch(() => []); + + // "In House" is not a ProjectType — it's a naming convention: the + // project name is prefixed with "IH" (e.g. "IH - Foo", "IH Foo", + // "IH_Foo"). The negative lookahead (?![A-Za-z]) accepts any + // separator/space/digit/end after "IH" but rejects real words like + // "IHelp". + const IH_PREFIX = /^\s*IH(?![A-Za-z])/i; + const typeOf = (p) => (IH_PREFIX.test(p.ProjectName || "") + ? "In House" + : (p.ProjectType || "Unspecified")); + const typeCounts = {}; + (activeProjects || []).forEach((p) => { + const key = typeOf(p); + typeCounts[key] = (typeCounts[key] || 0) + 1; + }); + const typeMix = Object.keys(typeCounts).map((type) => ({ type, count: typeCounts[type] })); + + // 2 — distinct projects worked (a timesheet logged) in the window. + const timelogs = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TIMESHEET, + data: [ + { LogStartTime: { $gte: fromSec, $lte: toSec } }, + { ProjectId: 1 }, + ], + }, "find").catch(() => []); + + const workingProjectIds = new Set(); + (timelogs || []).forEach((ts) => { + if (ts.ProjectId) workingProjectIds.add(String(ts.ProjectId)); + }); + + const data = { + activeProjects: (activeProjects || []).length, + workingProjects: workingProjectIds.size, + typeMix, + }; + if (includeProjects) { + data.projects = (activeProjects || []).map((p) => ({ + _id: String(p._id), + name: p.ProjectName || "—", + type: typeOf(p), + status: p.status || "", + statusType: p.statusType || "", + ...projectStatusMeta(p), + isWorking: workingProjectIds.has(String(p._id)), + })).sort((a, b) => a.name.localeCompare(b.name)); + } + + return res.status(200).json({ status: true, data }); + } catch (error) { + logger.error(`getProjectUtilizationSummary error: ${error && error.message ? error.message : error}`); + return res.status(500).json({ + status: false, + message: "An error occurred while building the project utilization summary.", + error: error && error.message ? error.message : String(error), + }); + } +}; + +/** + * Team Effort Breakdown (screenshot metric #5, generalized). Sums logged + * minutes and rolls users into their teams (teams_management; a user in + * multiple teams counts under each, none → "Unassigned"), bucketed by a + * selectable DIMENSION: + * - "type" → task type (Task / Design / Bid …) [default] + * - "effort_nature" → Rework (Bug/Revision) / Backlog (backlog sprint) / + * New (created in range, from the task _id timestamp) / + * In-flight + * - "work_category" → Actual / Learning / Uncategorized (aiTaskCategory) + * - "billable" → Billable / Non-billable (timesheet.billable) + * + * Response: + * { dimension, teams: [{ teamId, name, color, totalMinutes, + * buckets: [{ label, minutes, users: [{ userId, name, minutes }] }] }] } + */ +exports.getTeamTaskTypeBreakdown = async (req, res) => { + try { + const companyId = req.headers["companyid"]; + if (!companyId) { + return res.status(400).json({ status: false, message: "companyId header required" }); + } + const payload = req.body || {}; + // SECURITY (CodeRabbit): derive caller identity + role from the + // authenticated session, never the request body. req.uid is the verified + // JWT user; the role is resolved server-side. A forged + // callerUserId/callerRoleType in the body can no longer widen scope. + payload.callerUserId = String(req.uid || ""); + payload.callerRoleType = await resolveCallerRoleType(companyId, req.uid); + const dimension = ["type", "effort_nature", "work_category", "billable"].includes(payload.dimension) + ? payload.dimension : "type"; + const { fromSec, toSec, dateFrom, dateTo } = getDayOrRangeBounds(payload); + const visibleUserIds = resolveVisibleUserIds(payload); + if (Array.isArray(visibleUserIds) && !visibleUserIds.length) { + return res.status(200).json({ status: true, data: { dimension, teams: [] } }); + } + const projectIds = Array.isArray(payload.projectIds) ? payload.projectIds + : Array.isArray(payload.projectId) ? payload.projectId : []; + const statusKeys = Array.isArray(payload.statusKeys) ? payload.statusKeys + : Array.isArray(payload.statusKey) ? payload.statusKey : []; + const taskMatch = (payload.taskMatch && typeof payload.taskMatch === "object") ? payload.taskMatch : null; + + const { map: userTeamMap } = await buildUserTeamMap(companyId); + const UNASSIGNED = { teamId: "unassigned", name: "Unassigned", color: null }; + const agg = {}; // teamId → bucketLabel → uid → minutes + const teamMeta = {}; + const userIdsInvolved = new Set(); + const addLogged = (uid, bucket, minutes) => { + if (!minutes) return; + userIdsInvolved.add(uid); + const teams = (userTeamMap[uid] && userTeamMap[uid].length) ? userTeamMap[uid] : [UNASSIGNED]; + teams.forEach((team) => { + teamMeta[team.teamId] = { name: team.name, color: team.color }; + if (!agg[team.teamId]) agg[team.teamId] = {}; + if (!agg[team.teamId][bucket]) agg[team.teamId][bucket] = {}; + agg[team.teamId][bucket][uid] = (agg[team.teamId][bucket][uid] || 0) + minutes; + }); + }; + + if (dimension === "billable") { + // Timesheet-level split (billable lives on the timesheet, not the + // task). Applies project + user filters; status/advanced filters + // don't apply here (no task join). + const tsFilter = { LogStartTime: { $gte: fromSec, $lte: toSec } }; + if (Array.isArray(visibleUserIds)) tsFilter.Loggeduser = { $in: visibleUserIds.map(String) }; + if (projectIds.length) tsFilter.ProjectId = { $in: projectIds.map(String) }; + const tlogs = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TIMESHEET, + data: [tsFilter, { Loggeduser: 1, LogTimeDuration: 1, billable: 1 }], + }, "find").catch(() => []); + (tlogs || []).forEach((ts) => { + if (!ts.Loggeduser) return; + const bucket = ts.billable === false ? "Non-billable" : "Billable"; + addLogged(String(ts.Loggeduser), bucket, Number(ts.LogTimeDuration) || 0); + }); + } else { + const { loggedByUserTask, taskMap } = await getLoggedAndTasksInRange(companyId, { + fromSec, toSec, projectIds, statusKeys, visibleUserIds, taskMatch, + }); + + let sprintTypeMap = {}; + if (dimension === "effort_nature") { + sprintTypeMap = await getSprintTypeMap( + companyId, + Object.values(taskMap).map((t) => t.sprintId).filter(Boolean), + ); + } + const REWORK = /bug|revision|rework|defect/i; + const bucketFor = (task, tid) => { + if (dimension === "work_category") { + const c = String(task.aiTaskCategoryManual || task.aiTaskCategory || "").toLowerCase(); + if (c === "actual") return "Actual"; + if (c === "learning") return "Learning"; + return "Uncategorized"; + } + if (dimension === "effort_nature") { + if (REWORK.test(task.TaskType || "")) return "Rework"; + if ((sprintTypeMap[String(task.sprintId)] || "").includes("backlog")) return "Backlog"; + let createdMs = 0; + try { createdMs = new mongoose.Types.ObjectId(tid).getTimestamp().getTime(); } catch (e) { createdMs = 0; } + if (createdMs && dateFrom && dateTo && createdMs >= dateFrom.getTime() && createdMs <= dateTo.getTime()) return "New"; + return "In-flight"; + } + return task.TaskType || "Unspecified"; // dimension === "type" + }; + + Object.keys(loggedByUserTask).forEach((key) => { + const [uid, tid] = key.split("|"); + const task = taskMap[tid]; + if (!task) return; + addLogged(uid, bucketFor(task, tid), loggedByUserTask[key]); + }); + } + + const nameMap = await getUserNameMap(Array.from(userIdsInvolved)); + + const teams = Object.keys(agg).map((teamId) => { + let teamTotal = 0; + const buckets = Object.keys(agg[teamId]).map((label) => { + const usersObj = agg[teamId][label]; + const users = Object.keys(usersObj).map((uid) => ({ + userId: uid, name: nameMap[uid] || "—", minutes: usersObj[uid], + })).sort((a, b) => b.minutes - a.minutes); + const minutes = users.reduce((s, u) => s + u.minutes, 0); + teamTotal += minutes; + return { label, minutes, users }; + }).sort((a, b) => b.minutes - a.minutes); + return { + teamId, + name: teamMeta[teamId] ? teamMeta[teamId].name : teamId, + color: teamMeta[teamId] ? teamMeta[teamId].color : null, + totalMinutes: teamTotal, + buckets, + }; + }).sort((a, b) => b.totalMinutes - a.totalMinutes); + + return res.status(200).json({ status: true, data: { dimension, teams } }); + } catch (error) { + logger.error(`getTeamTaskTypeBreakdown error: ${error && error.message ? error.message : error}`); + return res.status(500).json({ + status: false, + message: "An error occurred while building the team effort breakdown.", + error: error && error.message ? error.message : String(error), + }); + } +}; + +/** + * Team-wise Time Logged vs ETA (screenshot metric #6), as a drill-down: + * Team → User → Task, each level carrying loggedMinutes + etaMinutes. + * - task.etaMinutes = the task's totalEstimatedTime (shared estimate) + * - task.loggedMinutes = that user's logged time on the task in the window + * - user totals = Σ of their tasks + * - team totals = Σ member logged; ETA deduped across the team so a + * task worked by two members isn't counted twice. + * + * Response: { teams: [{ teamId, name, color, loggedMinutes, etaMinutes, + * users: [{ userId, name, loggedMinutes, etaMinutes, + * tasks: [{ taskId, taskName, taskKey, + * projectName, loggedMinutes, + * etaMinutes }] }] }] } + */ +exports.getTeamLoggedVsEta = async (req, res) => { + try { + const companyId = req.headers["companyid"]; + if (!companyId) { + return res.status(400).json({ status: false, message: "companyId header required" }); + } + const payload = req.body || {}; + // SECURITY (CodeRabbit): derive caller identity + role from the + // authenticated session, never the request body. req.uid is the verified + // JWT user; the role is resolved server-side. A forged + // callerUserId/callerRoleType in the body can no longer widen scope. + payload.callerUserId = String(req.uid || ""); + payload.callerRoleType = await resolveCallerRoleType(companyId, req.uid); + const { fromSec, toSec } = getDayOrRangeBounds(payload); + const visibleUserIds = resolveVisibleUserIds(payload); + if (Array.isArray(visibleUserIds) && !visibleUserIds.length) { + return res.status(200).json({ status: true, data: { teams: [] } }); + } + + const { loggedByUserTask, taskMap, userIds } = await getLoggedAndTasksInRange(companyId, { + fromSec, toSec, + projectIds: Array.isArray(payload.projectIds) ? payload.projectIds + : Array.isArray(payload.projectId) ? payload.projectId : [], + statusKeys: Array.isArray(payload.statusKeys) ? payload.statusKeys + : Array.isArray(payload.statusKey) ? payload.statusKey : [], + visibleUserIds, + taskMatch: (payload.taskMatch && typeof payload.taskMatch === "object") ? payload.taskMatch : null, + }); + + const [{ map: userTeamMap }, nameMap] = await Promise.all([ + buildUserTeamMap(companyId), + getUserNameMap(Array.from(userIds)), + ]); + + // Project-name lookup for the task rows. + const projectIdSet = new Set(Object.values(taskMap).map((t) => String(t.ProjectID))); + const projectsMap = {}; + if (projectIdSet.size) { + const projects = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, + data: [ + { _id: { $in: Array.from(projectIdSet).filter((id) => mongoose.Types.ObjectId.isValid(id)).map((id) => new mongoose.Types.ObjectId(id)) } }, + { ProjectName: 1 }, + ], + }, "find").catch(() => []); + (projects || []).forEach((p) => { projectsMap[String(p._id)] = p.ProjectName || ""; }); + } + + const UNASSIGNED = { teamId: "unassigned", name: "Unassigned", color: null }; + // teamId → { meta, users: { uid → { tasks: { tid → row } } } } + const agg = {}; + const teamMeta = {}; + + Object.keys(loggedByUserTask).forEach((key) => { + const [uid, tid] = key.split("|"); + const task = taskMap[tid]; + if (!task) return; + const logged = loggedByUserTask[key]; + const eta = Number(task.totalEstimatedTime) || 0; + const teams = (userTeamMap[uid] && userTeamMap[uid].length) ? userTeamMap[uid] : [UNASSIGNED]; + teams.forEach((team) => { + teamMeta[team.teamId] = { name: team.name, color: team.color }; + if (!agg[team.teamId]) agg[team.teamId] = {}; + if (!agg[team.teamId][uid]) agg[team.teamId][uid] = {}; + agg[team.teamId][uid][tid] = { + taskId: tid, + taskName: task.TaskName || "", + taskKey: task.TaskKey || "", + projectName: projectsMap[String(task.ProjectID)] || "", + loggedMinutes: logged, + etaMinutes: eta, + }; + }); + }); + + const teams = Object.keys(agg).map((teamId) => { + const users = Object.keys(agg[teamId]).map((uid) => { + const tasks = Object.values(agg[teamId][uid]).sort((a, b) => b.loggedMinutes - a.loggedMinutes); + const loggedMinutes = tasks.reduce((s, t) => s + t.loggedMinutes, 0); + const etaMinutes = tasks.reduce((s, t) => s + t.etaMinutes, 0); + return { userId: uid, name: nameMap[uid] || "—", loggedMinutes, etaMinutes, tasks }; + }).sort((a, b) => b.loggedMinutes - a.loggedMinutes); + + const loggedMinutes = users.reduce((s, u) => s + u.loggedMinutes, 0); + // Dedupe ETA across the team (a task worked by 2 members counts once). + const seenTasks = new Set(); + let etaMinutes = 0; + users.forEach((u) => u.tasks.forEach((t) => { + if (!seenTasks.has(t.taskId)) { seenTasks.add(t.taskId); etaMinutes += t.etaMinutes; } + })); + + return { + teamId, + name: teamMeta[teamId] ? teamMeta[teamId].name : teamId, + color: teamMeta[teamId] ? teamMeta[teamId].color : null, + loggedMinutes, + etaMinutes, + users, + }; + }).sort((a, b) => b.loggedMinutes - a.loggedMinutes); + + return res.status(200).json({ status: true, data: { teams } }); + } catch (error) { + logger.error(`getTeamLoggedVsEta error: ${error && error.message ? error.message : error}`); + return res.status(500).json({ + status: false, + message: "An error occurred while building the team logged-vs-ETA report.", + error: error && error.message ? error.message : String(error), + }); + } +}; + +/** + * AHE-3789 — Project-progress & resource dashboard cards. + * + * One read-only, companyId-scoped endpoint serving the project-progress + * metrics. It is purely additive (a new route + handler) and reads the same + * collections the Employee Workload report already reads, so it cannot affect + * any existing dashboard data path. + * + * metric: 'active_projects' → count of active projects (company-wide) + * metric: 'projects_by_type' → active projects grouped by ProjectType + * metric: 'running_projects' → count of active projects that had logged + * time within the date range + * metric: 'live_work' → users with a tracker running RIGHT NOW + * (startTimeTracker within the last 10 min) + + * the task/project and the tracker memo + * (LogDescription) of what they're working on + * metric: 'users_by_category' → per user, the COUNT of distinct tasks they + * logged time on in the window, bucketed into a + * caller-supplied category→task-type template + * (via the task's TaskType); unmapped types are + * ignored (no "uncategorized" bucket) + * + * Role visibility mirrors getEmployeeWorkloadReport: roleType 1/2 + * (Owner/Admin/Manager) see everyone; everyone else sees only themselves. + */ +exports.getProjectProgressMetric = async (req, res) => { + try { + const companyId = req.headers["companyid"]; + if (!companyId) { + return res.status(400).json({ status: false, message: "companyId header required" }); + } + + const payload = req.body || {}; + // SECURITY (CodeRabbit): derive caller identity + role from the + // authenticated session, never the request body. req.uid is the verified + // JWT user; the role is resolved server-side. A forged + // callerUserId/callerRoleType in the body can no longer widen scope. + payload.callerUserId = String(req.uid || ""); + payload.callerRoleType = await resolveCallerRoleType(companyId, req.uid); + const metric = String(payload.metric || ""); + const dateFrom = payload.dateFrom ? new Date(payload.dateFrom) : null; + const dateTo = payload.dateTo ? new Date(payload.dateTo) : null; + const dateFromSec = dateFrom ? Math.floor(dateFrom.getTime() / 1000) : null; + const dateToSec = dateTo ? Math.floor(dateTo.getTime() / 1000) : null; + + // Role-based visibility — non-admins are scoped to their own logs. + const callerUserId = String(payload.callerUserId || ""); + const callerRoleType = Number(payload.callerRoleType || 3); + const restrictToSelf = !(callerRoleType === 1 || callerRoleType === 2); + const userScope = () => (restrictToSelf && callerUserId ? { Loggeduser: callerUserId } : {}); + const objIds = (arr) => [...new Set((arr || []).filter(Boolean).map(String))] + .filter((id) => mongoose.Types.ObjectId.isValid(id)) + .map((id) => new mongoose.Types.ObjectId(id)); + + const rangeTsFilter = () => { + const f = { ...userScope() }; + if (dateFromSec != null || dateToSec != null) { + f.LogStartTime = {}; + if (dateFromSec != null) f.LogStartTime.$gte = dateFromSec; + if (dateToSec != null) f.LogStartTime.$lte = dateToSec; + } + return f; + }; + + // Active project criteria — matches the app's "active" definition: + // not closed, not deleted. Counted COMPANY-WIDE (no viewer scope). + const activeProjectFilter = { + statusType: { $ne: "close" }, + deletedStatusKey: { $in: [0, null] }, + }; + + // Drill-down mode — clicking a count/bar opens a modal listing the + // projects behind the number, so the project rows come back too. + const includeProjects = payload.includeProjects === true; + // "In House" derivation — same naming convention as the utilization + // summary: an active project whose name starts with the "IH" marker. + const IH_PREFIX = /^\s*IH(?![A-Za-z])/i; + const projectRow = (p) => ({ + _id: String(p._id), + name: p.ProjectName || "—", + type: IH_PREFIX.test(p.ProjectName || "") ? "In House" : (p.ProjectType || "Unspecified"), + status: p.status || "", + statusType: p.statusType || "", + ...projectStatusMeta(p), + }); + const PROJECT_LIST_FIELDS = { ProjectName: 1, ProjectType: 1, status: 1, statusType: 1, projectStatusData: 1 }; + + // ── metric: active projects (company-wide count) ── + if (metric === "active_projects") { + const projects = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, + data: [activeProjectFilter, includeProjects ? PROJECT_LIST_FIELDS : { _id: 1 }], + }, "find").catch(() => []); + const data = { count: (projects || []).length }; + if (includeProjects) { + data.projects = (projects || []).map(projectRow).sort((a, b) => a.name.localeCompare(b.name)); + } + return res.status(200).json({ status: true, data }); + } + + // ── metric: active projects grouped by type (company-wide) ── + if (metric === "projects_by_type") { + const projects = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, + data: [activeProjectFilter, includeProjects ? PROJECT_LIST_FIELDS : { ProjectType: 1 }], + }, "find").catch(() => []); + const counts = {}; + (projects || []).forEach((p) => { + const raw = (p.ProjectType && String(p.ProjectType).trim()) || "unspecified"; + counts[raw] = (counts[raw] || 0) + 1; + }); + const rows = Object.entries(counts).map(([key, value]) => ({ key, value })).sort((a, b) => b.value - a.value); + const data = { rows }; + if (includeProjects) { + // Keep the raw ProjectType as the modal's filter key so it + // matches the bar rows (no In-House derivation here). + data.projects = (projects || []).map((p) => ({ + ...projectRow(p), + type: (p.ProjectType && String(p.ProjectType).trim()) || "unspecified", + })).sort((a, b) => a.name.localeCompare(b.name)); + } + return res.status(200).json({ status: true, data }); + } + + // ── metric 1: running/working projects (active + logged in range) ── + if (metric === "running_projects") { + const tlogs = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TIMESHEET, data: [rangeTsFilter(), { TicketID: 1 }], + }, "find").catch(() => []); + const taskIds = objIds((tlogs || []).map((t) => t.TicketID)); + let running = []; + if (taskIds.length) { + const tasks = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, data: [{ _id: { $in: taskIds }, deletedStatusKey: 0 }, { ProjectID: 1 }], + }, "find").catch(() => []); + const pids = objIds((tasks || []).map((t) => t.ProjectID)); + if (pids.length) { + const projects = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, + data: [{ _id: { $in: pids } }, { ...PROJECT_LIST_FIELDS, deletedStatusKey: 1 }], + }, "find").catch(() => []); + running = (projects || []).filter((p) => p.statusType !== "close" && !p.deletedStatusKey); + } + } + const data = { count: running.length }; + if (includeProjects) { + data.projects = running.map(projectRow).sort((a, b) => a.name.localeCompare(b.name)); + } + return res.status(200).json({ status: true, data }); + } + + // ── metric: live work — who is tracking right now, on what, and the + // tracker memo (LogDescription) they entered describing the work. ── + if (metric === "live_work") { + const RUNNING_WINDOW_SEC = 10 * 60; // matches the app's 10-min "is running" rule + const nowSec = Math.floor(Date.now() / 1000); + const activeLogs = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TIMESHEET, + data: [{ ...userScope(), startTimeTracker: { $gte: nowSec - RUNNING_WINDOW_SEC } }, { Loggeduser: 1, TicketID: 1, startTimeTracker: 1, LogDescription: 1 }], + }, "find").catch(() => []); + // Dedup by user|task, keeping the latest tracker (and its memo). + const pairMap = {}; + (activeLogs || []).forEach((ts) => { + if (!ts.Loggeduser || !ts.TicketID) return; + const key = `${ts.Loggeduser}|${ts.TicketID}`; + if (!pairMap[key] || (ts.startTimeTracker || 0) > pairMap[key].startTimeTracker) { + pairMap[key] = { + userId: String(ts.Loggeduser), + taskId: String(ts.TicketID), + startTimeTracker: ts.startTimeTracker || 0, + memo: (ts.LogDescription && String(ts.LogDescription).trim()) || "", + }; + } + }); + const pairs = Object.values(pairMap); + + // Logged minutes TODAY — per user|task ("this task") and per user + // (day total), mirroring the Active Work table's columns. Sums the + // recorded LogTimeDuration; the currently running tracker adds up + // once it is stopped/synced (same convention as employee-workload). + const loggedByUserTask = {}; + const loggedByUser = {}; + if (pairs.length) { + const dayStart = new Date(); + dayStart.setHours(0, 0, 0, 0); + const todaysLogs = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TIMESHEET, + data: [ + { + Loggeduser: { $in: [...new Set(pairs.map((p) => p.userId))] }, + LogStartTime: { $gte: Math.floor(dayStart.getTime() / 1000), $lte: nowSec }, + }, + { Loggeduser: 1, TicketID: 1, LogTimeDuration: 1 }, + ], + }, "find").catch(() => []); + (todaysLogs || []).forEach((ts) => { + if (!ts.Loggeduser) return; + const uid = String(ts.Loggeduser); + const mins = Number(ts.LogTimeDuration) || 0; + loggedByUser[uid] = (loggedByUser[uid] || 0) + mins; + if (ts.TicketID) { + const key = `${uid}|${ts.TicketID}`; + loggedByUserTask[key] = (loggedByUserTask[key] || 0) + mins; + } + }); + } + + const taskMap = {}, projMap = {}, userMap = {}; + const taskIds = objIds(pairs.map((p) => p.taskId)); + if (taskIds.length) { + const tasks = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, data: [{ _id: { $in: taskIds } }, { TaskName: 1, TaskKey: 1, ProjectID: 1, sprintArray: 1 }], + }, "find").catch(() => []); + (tasks || []).forEach((t) => { taskMap[String(t._id)] = t; }); + const pids = objIds(Object.values(taskMap).map((t) => t.ProjectID)); + if (pids.length) { + const projects = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, data: [{ _id: { $in: pids } }, { ProjectName: 1, ProjectCode: 1 }], + }, "find").catch(() => []); + (projects || []).forEach((p) => { projMap[String(p._id)] = p; }); + } + } + const userIds = objIds(pairs.map((p) => p.userId)); + if (userIds.length) { + const users = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, { + type: SCHEMA_TYPE.USERS, data: [{ _id: { $in: userIds } }, { Employee_Name: 1, Employee_FName: 1, Employee_LName: 1, Employee_profileImage: 1 }], + }, "find").catch(() => []); + (users || []).forEach((u) => { userMap[String(u._id)] = u; }); + } + const rows = pairs.map((p) => { + const t = taskMap[p.taskId] || {}; + const u = userMap[p.userId] || {}; + const proj = projMap[String(t.ProjectID)] || {}; + return { + userId: p.userId, + userName: u.Employee_Name || `${u.Employee_FName || ""} ${u.Employee_LName || ""}`.trim() || "—", + avatar: u.Employee_profileImage || "", + taskId: p.taskId, + taskName: t.TaskName || "—", + taskKey: t.TaskKey || "", + projectId: t.ProjectID ? String(t.ProjectID) : "", + sprintId: (t.sprintArray && t.sprintArray.id) || "", + projectName: proj.ProjectName || "", + memo: p.memo, + startTimeTracker: p.startTimeTracker, + taskLoggedMinutes: loggedByUserTask[`${p.userId}|${p.taskId}`] || 0, + dayLoggedMinutes: loggedByUser[p.userId] || 0, + }; + }).sort((a, b) => (b.startTimeTracker || 0) - (a.startTimeTracker || 0)); + return res.status(200).json({ status: true, data: { rows, count: rows.length } }); + } + + // ── metric: users' task count grouped by work category ── + // The caller supplies a category→task-type template + // (categoryMap: { "Development": ["Bug","Task"], "QA": [...] }). For each + // user we count the DISTINCT tasks they logged time on in the window and + // bucket them by category via the task's TaskType. Types not mapped to + // any category are ignored (no "uncategorized" bucket). + if (metric === "users_by_category") { + const rawMap = (payload.categoryMap && typeof payload.categoryMap === "object" && !Array.isArray(payload.categoryMap)) + ? payload.categoryMap : {}; + // Column order follows the template's key order, but only + // categories that actually have ≥1 task type mapped become columns. + const categories = Object.keys(rawMap) + .filter((c) => c && String(c).trim() && Array.isArray(rawMap[c]) && rawMap[c].length); + // Reverse lookup: normalized task-type name → category. A type maps + // to at most one category (last assignment wins). + const typeToCat = {}; + categories.forEach((cat) => { + (rawMap[cat] || []).forEach((tp) => { + const norm = String(tp || "").trim().toLowerCase(); + if (norm) typeToCat[norm] = cat; + }); + }); + + const includeSubtasks = payload.includeSubtasks === undefined ? true : !!payload.includeSubtasks; + const projectIds = objIds(payload.projectIds); + // User filter is a plain string-id match on Loggeduser (mirrors the + // workload report). Non-admins are already pinned to themselves by + // userScope(), so this explicit filter only applies for admins. + const userIdStrs = [...new Set((payload.userIds || []).filter(Boolean).map(String))] + .filter((id) => mongoose.Types.ObjectId.isValid(id)); + + const tsFilter = rangeTsFilter(); + if (!restrictToSelf && userIdStrs.length) { + tsFilter.Loggeduser = { $in: userIdStrs }; + } + const tlogs = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TIMESHEET, + data: [tsFilter, { Loggeduser: 1, TicketID: 1 }], + }, "find").catch(() => []); + + // Distinct (user, task) pairs the user logged time on in the window. + const userTaskPairs = new Set(); // `${uid}|${tid}` + const taskIdSet = new Set(); + (tlogs || []).forEach((ts) => { + if (!ts.Loggeduser || !ts.TicketID) return; + userTaskPairs.add(`${ts.Loggeduser}|${ts.TicketID}`); + taskIdSet.add(String(ts.TicketID)); + }); + + // Fetch the touched tasks + apply the task-level filters. Tasks that + // don't survive the filters drop out of the tally entirely. + const taskMap = {}; + const tIds = objIds([...taskIdSet]); + if (tIds.length) { + const taskFilter = { _id: { $in: tIds }, deletedStatusKey: 0 }; + if (projectIds.length) taskFilter.ProjectID = { $in: projectIds }; + if (includeSubtasks === false) taskFilter.isParentTask = true; + const tasks = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [taskFilter, { TaskType: 1 }], + }, "find").catch(() => []); + (tasks || []).forEach((t) => { taskMap[String(t._id)] = t; }); + } + + // Count DISTINCT tasks per user per category (each user|task pair = + // one task). Only tasks whose TaskType is mapped to a category are + // counted; unmapped types are ignored (no "uncategorized"). + const byUser = {}; // uid → { total, cats:{} } + userTaskPairs.forEach((key) => { + const sep = key.indexOf("|"); + const uid = key.slice(0, sep); + const tid = key.slice(sep + 1); + const t = taskMap[tid]; + if (!t) return; // filtered out (project / subtask / deleted) + const cat = typeToCat[String(t.TaskType || "").trim().toLowerCase()]; + if (!cat) return; // unmapped type → ignored + if (!byUser[uid]) byUser[uid] = { total: 0, cats: {} }; + byUser[uid].cats[cat] = (byUser[uid].cats[cat] || 0) + 1; + byUser[uid].total += 1; + }); + + // Join user names / avatars. + const uids = objIds(Object.keys(byUser)); + const userMap = {}; + if (uids.length) { + const users = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, { + type: SCHEMA_TYPE.USERS, + data: [{ _id: { $in: uids } }, { Employee_Name: 1, Employee_FName: 1, Employee_LName: 1, Employee_profileImage: 1 }], + }, "find").catch(() => []); + (users || []).forEach((u) => { userMap[String(u._id)] = u; }); + } + + const rows = Object.keys(byUser).map((uid) => { + const b = byUser[uid]; + const u = userMap[uid] || {}; + const cats = {}; + categories.forEach((c) => { cats[c] = b.cats[c] || 0; }); + return { + userId: uid, + userName: u.Employee_Name || `${u.Employee_FName || ""} ${u.Employee_LName || ""}`.trim() || "—", + avatar: u.Employee_profileImage || "", + categories: cats, + total: b.total, + }; + }).filter((r) => r.total > 0).sort((a, b) => b.total - a.total); + + const byCategory = {}; + categories.forEach((c) => { byCategory[c] = 0; }); + let grand = 0; + rows.forEach((r) => { + categories.forEach((c) => { byCategory[c] += r.categories[c]; }); + grand += r.total; + }); + + return res.status(200).json({ + status: true, + data: { + categories, + rows, + totals: { byCategory, grand }, + userCount: rows.length, + }, + }); + } + + return res.status(400).json({ status: false, message: "Unknown metric" }); + } catch (error) { + logger.error(`getProjectProgressMetric error: ${error && error.message ? error.message : error}`); + return res.status(500).json({ + status: false, + message: "An error occurred while building project-progress metrics.", + error: error && error.message ? error.message : String(error), + }); + } +}; + +/** + * On Leave board — backs the OnLeaveCard. Leaves are managed as ordinary + * tasks ("leave tickets") inside a designated project (e.g. Support / HR): + * the card config picks that project (projectIds) plus the status(es) that + * mean the leave is approved (statusKeys). A ticket counts for the window + * when its startDate–DueDate period overlaps [dateFrom, dateTo]. + * + * The person on leave is the ticket's FIRST assignee (the applicant, by the + * HR workflow convention; later assignees are the approvers). + * + * Returns the ticket rows plus the AB/PR headcounts: + * absent (AB) — distinct applicants with an overlapping approved ticket + * present (PR) — active company members minus the absent ones + * + * Company-scoped and read-only. Like the utilization summary, this is a + * team-visibility widget, so no role gate: everyone sees who is on leave. + */ +exports.getOnLeaveBoard = async (req, res) => { + try { + const companyId = req.headers["companyid"]; + if (!companyId) { + return res.status(400).json({ status: false, message: "companyId header required" }); + } + + const payload = req.body || {}; + // SECURITY (CodeRabbit): derive caller identity + role from the + // authenticated session, never the request body. req.uid is the verified + // JWT user; the role is resolved server-side. A forged + // callerUserId/callerRoleType in the body can no longer widen scope. + payload.callerUserId = String(req.uid || ""); + payload.callerRoleType = await resolveCallerRoleType(companyId, req.uid); + const projectIds = (Array.isArray(payload.projectIds) ? payload.projectIds : []) + .filter((id) => mongoose.Types.ObjectId.isValid(String(id))) + .map((id) => new mongoose.Types.ObjectId(String(id))); + if (!projectIds.length) { + return res.status(200).json({ + status: true, + data: { rows: [], stats: { absent: 0, present: 0, totalUsers: 0, tickets: 0 } }, + }); + } + const statusKeys = (Array.isArray(payload.statusKeys) ? payload.statusKeys : []) + .map(Number).filter((n) => !Number.isNaN(n)); + + const { dateFrom, dateTo } = getDayOrRangeBounds(payload); + + // Leave-period overlap on the ticket's startDate/DueDate (Date fields): + // starts in the window, ends in the window, or spans it entirely. + // Tickets with neither date can't be placed on a timeline → excluded. + const taskFilter = { + ProjectID: { $in: projectIds }, + deletedStatusKey: 0, + $or: [ + { startDate: { $gte: dateFrom, $lte: dateTo } }, + { DueDate: { $gte: dateFrom, $lte: dateTo } }, + { startDate: { $lte: dateFrom }, DueDate: { $gte: dateTo } }, + ], + }; + if (statusKeys.length) taskFilter.statusKey = { $in: statusKeys }; + + const tickets = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [taskFilter, { + TaskName: 1, TaskKey: 1, AssigneeUserId: 1, + startDate: 1, DueDate: 1, statusKey: 1, ProjectID: 1, sprintArray: 1, + }], + }, "find").catch(() => []); + + // AssigneeUserId entries are usually plain id strings; tolerate object + // shapes the same way buildUserTeamMap does. + const extractId = (entry) => { + if (entry == null) return ""; + if (typeof entry === "object") return String(entry.userId || entry._id || entry.id || ""); + return String(entry); + }; + + const absentIds = new Set(); + const rows = (tickets || []).map((t) => { + const applicantId = extractId((t.AssigneeUserId || [])[0]); + if (applicantId) absentIds.add(applicantId); + return { + taskId: String(t._id), + taskName: t.TaskName || "—", + taskKey: t.TaskKey || "", + projectId: t.ProjectID ? String(t.ProjectID) : "", + sprintId: (t.sprintArray && t.sprintArray.id) || "", + userId: applicantId, + startDate: t.startDate || null, + dueDate: t.DueDate || null, + statusKey: t.statusKey, + }; + }).sort((a, b) => new Date(a.startDate || a.dueDate || 0) - new Date(b.startDate || b.dueDate || 0)); + + // Join applicant names/avatars (global users collection). + const uids = [...absentIds] + .filter((id) => mongoose.Types.ObjectId.isValid(id)) + .map((id) => new mongoose.Types.ObjectId(id)); + const userMap = {}; + if (uids.length) { + const users = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, { + type: SCHEMA_TYPE.USERS, + data: [{ _id: { $in: uids } }, { Employee_Name: 1, Employee_FName: 1, Employee_LName: 1, Employee_profileImage: 1 }], + }, "find").catch(() => []); + (users || []).forEach((u) => { userMap[String(u._id)] = u; }); + } + rows.forEach((r) => { + const u = userMap[r.userId] || {}; + r.userName = u.Employee_Name || `${u.Employee_FName || ""} ${u.Employee_LName || ""}`.trim() || "—"; + r.avatar = u.Employee_profileImage || ""; + }); + + // PR headcount base — active members of this company (same query the + // workload report uses to enumerate the team). + const members = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, { + type: SCHEMA_TYPE.USERS, + data: [{ isActive: true, AssignCompany: companyId }, { _id: 1 }], + }, "find").catch(() => []); + const totalUsers = (members || []).length; + const memberIdSet = new Set((members || []).map((m) => String(m._id))); + const absent = [...absentIds].filter((id) => memberIdSet.has(id)).length; + + return res.status(200).json({ + status: true, + data: { + rows, + stats: { + absent, + present: Math.max(totalUsers - absent, 0), + totalUsers, + tickets: rows.length, + }, + }, + }); + } catch (error) { + logger.error(`getOnLeaveBoard error: ${error && error.message ? error.message : error}`); + return res.status(500).json({ + status: false, + message: "An error occurred while building the on-leave board.", + error: error && error.message ? error.message : String(error), + }); + } +}; diff --git a/Modules/UserDashboard/helpers/resourceHelpers.js b/Modules/UserDashboard/helpers/resourceHelpers.js new file mode 100644 index 00000000..09095790 --- /dev/null +++ b/Modules/UserDashboard/helpers/resourceHelpers.js @@ -0,0 +1,235 @@ +/** + * Shared helpers for the Resource Utilization & Consumption dashboard cards. + * + * These back the new resolvers in controller.js (project-utilization-summary, + * team-tasktype-breakdown, team-logged-vs-eta). They deliberately mirror the + * conventions already used by getEmployeeWorkloadReport: + * - timesheets pivot on Unix-SECONDS `LogStartTime` + * - estimated_time pivots on a JS `Date` in the `Date` field + * - joins are done in JS by String(_id) keys, never $lookup, because + * timesheet TicketID/ProjectId are Strings while task/project _id are + * ObjectIds. + */ +const mongoose = require("mongoose"); +const { SCHEMA_TYPE } = require("../../../Config/schemaType"); +const { MongoDbCrudOpration } = require("../../../utils/mongo-handler/mongoQueries"); + +/** + * Resolve the reporting window from a card payload. + * Accepts `dateFrom`/`dateTo` ISO strings (as the frontend cards send). When + * absent, defaults to the current day (server-local midnight → 23:59:59.999). + * + * @param {{dateFrom?:string, dateTo?:string}} payload + * @returns {{dateFrom:Date, dateTo:Date, fromSec:number, toSec:number}} + */ +function getDayOrRangeBounds(payload = {}) { + let dateFrom; + let dateTo; + if (payload.dateFrom) { + dateFrom = new Date(payload.dateFrom); + } else { + dateFrom = new Date(); + dateFrom.setHours(0, 0, 0, 0); + } + if (payload.dateTo) { + dateTo = new Date(payload.dateTo); + } else { + dateTo = new Date(); + dateTo.setHours(23, 59, 59, 999); + } + return { + dateFrom, + dateTo, + fromSec: Math.floor(dateFrom.getTime() / 1000), + toSec: Math.floor(dateTo.getTime() / 1000), + }; +} + +/** + * Build a userId → teams lookup from the teams_management collection. + * A user can belong to multiple teams. Users in no team are handled by the + * caller (bucketed under an "Unassigned" group). + * + * @param {string} companyId + * @returns {Promise<{ map: Object.>, teams: Array }>} + */ +async function buildUserTeamMap(companyId) { + // NOTE: the teams_management schema has no `deletedStatusKey` field, so we + // must NOT filter on it (doing so matches zero docs → everyone "Unassigned"). + // `$ne: 1` still tolerates any future soft-delete flag while matching docs + // that lack the field. + const teams = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TEAMS_MANAGEMENT, + data: [{ deletedStatusKey: { $ne: 1 } }, { name: 1, value: 1, assigneeUsersArray: 1, teamColor: 1 }], + }, "find").catch(() => []); + + // assigneeUsersArray entries are usually plain user-id strings, but tolerate + // object shapes ({ userId } / { _id } / { id }) just in case. + const extractId = (entry) => { + if (entry == null) return ""; + if (typeof entry === "object") return String(entry.userId || entry._id || entry.id || ""); + return String(entry); + }; + + const map = {}; + (teams || []).forEach((t) => { + const entry = { teamId: String(t._id), name: t.name, color: t.teamColor || null }; + (t.assigneeUsersArray || []).forEach((u) => { + const key = extractId(u); + if (!key) return; + if (!map[key]) map[key] = []; + map[key].push(entry); + }); + }); + return { map, teams: teams || [] }; +} + +/** + * Status-name → canonical bucket keyword map for the worked-tasks table. + * Best-effort substring match on the (lowercased) status name. Tunable here. + */ +const STATUS_BUCKET_KEYWORDS = { + backlog: ["backlog", "todo", "to do", "open", "new"], + review: ["review", "qa", "testing", "verify"], + progress: ["progress", "doing", "active", "wip", "development", "dev"], +}; + +/** + * Bucket an arbitrary per-project status into one of the four canonical + * columns: complete | review | progress | backlog. + * + * `statusType === 'close'` always wins → complete. Otherwise we name-match; + * anything active but unmatched falls through to `progress` (the safe default + * called out in the plan). + * + * @param {string} statusName human-readable status label + * @param {string} statusType normalized type ('active' | 'close') + * @returns {'complete'|'review'|'progress'|'backlog'} + */ +function bucketForStatus(statusName, statusType) { + if (statusType === "close" || statusType === "done") return "complete"; + const name = String(statusName || "").toLowerCase(); + for (const bucket of ["review", "backlog", "progress"]) { + if (STATUS_BUCKET_KEYWORDS[bucket].some((kw) => name.includes(kw))) return bucket; + } + return "progress"; +} + +/** + * Fetch logged time (per user|task) and the worked tasks for a window. + * Shared by the team-tasktype-breakdown and team-logged-vs-eta resolvers. + * Follows the JS-join convention: timesheet TicketID (String) → task _id + * (ObjectId), collected in JS rather than $lookup. + * + * @param {string} companyId + * @param {{fromSec:number, toSec:number, projectIds?:string[], statusKeys?:number[], visibleUserIds?:string[]|null}} opts + * @returns {Promise<{loggedByUserTask:Object., taskMap:Object., userIds:Set}>} + */ +async function getLoggedAndTasksInRange(companyId, opts = {}) { + const { fromSec, toSec, projectIds = [], statusKeys = [], visibleUserIds = null, taskMatch = null } = opts; + + const tsFilter = { LogStartTime: { $gte: fromSec, $lte: toSec } }; + if (Array.isArray(visibleUserIds)) { + tsFilter.Loggeduser = { $in: visibleUserIds.map(String) }; + } + const tlogs = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TIMESHEET, + data: [tsFilter, { Loggeduser: 1, TicketID: 1, LogTimeDuration: 1 }], + }, "find").catch(() => []); + + const loggedByUserTask = {}; + const ticketIds = new Set(); + const userIds = new Set(); + (tlogs || []).forEach((ts) => { + if (!ts.TicketID) return; + const key = `${ts.Loggeduser}|${ts.TicketID}`; + loggedByUserTask[key] = (loggedByUserTask[key] || 0) + (Number(ts.LogTimeDuration) || 0); + ticketIds.add(String(ts.TicketID)); + userIds.add(String(ts.Loggeduser)); + }); + + const taskMap = {}; + if (ticketIds.size) { + const validIds = Array.from(ticketIds) + .filter((id) => mongoose.Types.ObjectId.isValid(id)) + .map((id) => new mongoose.Types.ObjectId(id)); + const taskFilter = { _id: { $in: validIds }, deletedStatusKey: 0 }; + if (projectIds.length) { + taskFilter.ProjectID = { $in: projectIds.map((id) => new mongoose.Types.ObjectId(String(id))) }; + } + if (statusKeys.length) { + taskFilter.statusKey = { $in: statusKeys.map(Number) }; + } + // Advanced "Add filter" builder — the frontend translates filterData + // into a Mongo match (buildFilterQuery) on task fields ($and/$or) and + // sends it as taskMatch. Merge it into the task query so the card's + // Filters section actually narrows results. + if (taskMatch && typeof taskMatch === "object" && Object.keys(taskMatch).length) { + Object.assign(taskFilter, taskMatch); + } + const tasks = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [taskFilter, { + TaskType: 1, ProjectID: 1, statusKey: 1, totalEstimatedTime: 1, + TaskName: 1, TaskKey: 1, sprintId: 1, + aiTaskCategory: 1, aiTaskCategoryManual: 1, + }], + }, "find").catch(() => []); + (tasks || []).forEach((t) => { taskMap[String(t._id)] = t; }); + } + + return { loggedByUserTask, taskMap, userIds }; +} + +/** + * Fetch a userId → display-name map for the given ids (global users collection). + * @param {Array} userIds + * @returns {Promise>} + */ +async function getUserNameMap(userIds = []) { + const ids = Array.from(new Set((userIds || []).map(String))) + .filter((id) => mongoose.Types.ObjectId.isValid(id)); + if (!ids.length) return {}; + const users = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, { + type: SCHEMA_TYPE.USERS, + data: [ + { _id: { $in: ids.map((id) => new mongoose.Types.ObjectId(id)) } }, + { Employee_Name: 1, Employee_FName: 1, Employee_LName: 1 }, + ], + }, "find").catch(() => []); + const map = {}; + (users || []).forEach((u) => { + map[String(u._id)] = u.Employee_Name || `${u.Employee_FName || ""} ${u.Employee_LName || ""}`.trim() || "—"; + }); + return map; +} + +/** + * Fetch a sprintId → (lowercased) sprint type map, for classifying tasks as + * backlog vs active in the Effort Nature breakdown. + * @param {string} companyId + * @param {Array} sprintIds + * @returns {Promise>} + */ +async function getSprintTypeMap(companyId, sprintIds = []) { + const ids = Array.from(new Set((sprintIds || []).map(String))) + .filter((id) => mongoose.Types.ObjectId.isValid(id)); + if (!ids.length) return {}; + const sprints = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.SPRINTS, + data: [{ _id: { $in: ids.map((id) => new mongoose.Types.ObjectId(id)) } }, { type: 1 }], + }, "find").catch(() => []); + const map = {}; + (sprints || []).forEach((s) => { map[String(s._id)] = String(s.type || "").toLowerCase(); }); + return map; +} + +module.exports = { + getDayOrRangeBounds, + buildUserTeamMap, + bucketForStatus, + STATUS_BUCKET_KEYWORDS, + getLoggedAndTasksInRange, + getUserNameMap, + getSprintTypeMap, +}; diff --git a/Modules/UserDashboard/routes.js b/Modules/UserDashboard/routes.js index a21f3eb5..88f8eb2f 100644 --- a/Modules/UserDashboard/routes.js +++ b/Modules/UserDashboard/routes.js @@ -9,4 +9,17 @@ exports.init = (app) => { // caller's request body. POST (not GET) because the filter // payload is structured. app.post('/api/v1/dashboard/employee-workload', ctrl.getEmployeeWorkloadReport); + // Resource Utilization & Consumption cards. + // ProjectPulseCard — active projects, working-today, and type mix. + app.post('/api/v1/dashboard/project-utilization-summary', ctrl.getProjectUtilizationSummary); + // TeamCategoryBreakdownCard — team → task type → user logged time. + app.post('/api/v1/dashboard/team-tasktype-breakdown', ctrl.getTeamTaskTypeBreakdown); + // TeamLoggedVsEtaCard — per-team logged vs estimated. + app.post('/api/v1/dashboard/team-logged-vs-eta', ctrl.getTeamLoggedVsEta); + // AHE-3789 — project-progress & resource cards (running projects, + // live work, users-by-task-type). Additive, read-only, companyId-scoped. + app.post('/api/v1/dashboard/project-metrics', ctrl.getProjectProgressMetric); + // OnLeaveCard — approved leave tickets from the configured HR project + // that overlap the selected window, plus AB/PR headcounts. + app.post('/api/v1/dashboard/on-leave', ctrl.getOnLeaveBoard); } \ No newline at end of file diff --git a/Modules/service.js b/Modules/service.js index b408a0bf..5205a6bb 100644 --- a/Modules/service.js +++ b/Modules/service.js @@ -1,61 +1,53 @@ const nodemailer = require("nodemailer"); const config = require('../Config/config.js'); -const awsRef = require('../Config/aws.js'); const logger = require("../Config/loggerConfig.js"); -// BUG-041 / #95 — drop the duplicate `aws-sdk` v2 import. The SES -// client lives in Config/aws.js as the v3-based `awsRef.ses` shim, and -// the v3 raw client (`awsRef.sesClient`) is exposed for nodemailer's -// `SES` transport which historically needed a v2 client instance. -const ses = awsRef.sesClient; +// TLS certificate validation is ON by default. Only disable it (e.g. a +// self-signed SMTP cert in a controlled environment) via an explicit env +// opt-in — never silently, since disabling it exposes SMTP traffic to MITM. +const allowSelfSigned = String(process.env.NODEMAILER_ALLOW_SELF_SIGNED || "").toLowerCase() === "true"; + /** - * Send email with AWS - * @param {*} subject - * @param {*} html - * @param {*} toMail - * @param {*} isHtml - * @param {*} cb + * Send mail via email + * @param {*} subject + * @param {*} html + * @param {*} toMail + * @param {*} isHtml + * @param {*} cb */ exports.SendEmail = async (subject, html, toMail, isHtml, cb) => { try { - const tmpToMail = toMail.toLowerCase().split(","); - let toArr = []; - if (tmpToMail.length === 1) { - toArr = [toMail.toLowerCase()]; - } else { - toArr = tmpToMail; - } - const params = { - Destination: { - ToAddresses: toArr - }, - Message: { - Body: { - ...(isHtml && { Html: { Charset: 'UTF-8', Data: html } }), - ...(!isHtml && { Text: { Charset: 'UTF-8', Data: html } }) - }, - Subject: { - Charset: 'UTF-8', - Data: subject - } + let transporter = nodemailer.createTransport({ + host: config.NODEMAILER_HOST, + port: config.NODEMAILER_PORT, + secure: config.NODEMAILER_PORT == 465, // true for 465, false for other ports + auth: { + user: config.NODEMAILER_EMAIL, // generated ethereal user + pass: config.NODEMAILER_EMAIL_PASSWORD, // generated ethereal password }, - ReturnPath: `${config.APP_NAME} <${config.AWS_SES_FROM_DEFAULT}>`, - Source: `${config.APP_NAME} <${config.AWS_SES_FROM_DEFAULT}>`, - }; - - await awsRef.ses.sendEmail(params, (error, data) => { - if (error) { + tls: { + rejectUnauthorized: !allowSelfSigned + } + }); + + await transporter.sendMail({ + from: ""+'<'+config.NODEMAILER_EMAIL+'>', // sender address + to: toMail, // list of receivers + subject: subject, // Subject line + [isHtml ? "html" : "text"]: html + },(err, res)=>{ + if (err) { cb({ - status: false, - error: error.message - }); + status:false, + error: err.message ? err.message : err, + }) } else { cb({ status: true, - data, - }); + data: res + }) } }); } catch(error) { @@ -68,49 +60,45 @@ exports.SendEmail = async (subject, html, toMail, isHtml, cb) => { /** - * Send notification via AWS email - * @param {*} subject - * @param {*} html - * @param {*} toMail - * @param {*} isHtml - * @param {*} cb + * Send notification via email + * @param {*} subject + * @param {*} html + * @param {*} toMail + * @param {*} isHtml + * @param {*} cb */ exports.SendNotificationEmail = async (subject, html, toMail, isHtml, cb) => { try { - let toArr = []; - toMail.forEach((email) => { - toArr.push(email.toLowerCase()); - }); - - const params = { - Destination: { - BccAddresses: toArr, - }, - Message: { - Body: { - ...(isHtml && { Html: { Charset: 'UTF-8', Data: html } }), - ...(!isHtml && { Text: { Charset: 'UTF-8', Data: html } }) - }, - Subject: { - Charset: 'UTF-8', - Data: subject - } + const toArr = toMail.toString().toLowerCase() + let transporter = nodemailer.createTransport({ + host: config.NODEMAILER_HOST, + port: config.NODEMAILER_PORT, + secure: config.NODEMAILER_PORT == 465, // true for 465, false for other ports + auth: { + user: config.NODEMAILER_EMAIL, // generated ethereal user + pass: config.NODEMAILER_EMAIL_PASSWORD, // generated ethereal password }, - ReturnPath: `${config.APP_NAME} <${config.AWS_SES_FROM_DEFAULT}>`, - Source: `${config.APP_NAME} <${config.AWS_SES_FROM_DEFAULT}>`, - }; + tls: { + rejectUnauthorized: !allowSelfSigned + } + }); - await awsRef.ses.sendEmail(params, (error, data) => { - if (error) { + await transporter.sendMail({ + from: ""+'<'+config.NODEMAILER_EMAIL+'>', // sender address + bcc: toArr, // list of receivers + subject: subject, // Subject line + [isHtml ? "html" : "text"]: html + },(err, res)=>{ + if (err) { cb({ - status: false, - error: error.message - }); + status:false, + error: err.message ? err.message : err, + }) } else { cb({ status: true, - data, - }); + data: res + }) } }); } catch(error) { @@ -124,22 +112,28 @@ exports.SendNotificationEmail = async (subject, html, toMail, isHtml, cb) => { /** - * Send attachment via AWS email - * @param {*} subject - * @param {*} html - * @param {*} toMail - * @param {*} attachMents - * @param {*} cb + * Send Attachment via email + * @param {*} subject + * @param {*} html + * @param {*} toMail + * @param {*} attachMents + * @param {*} cb */ exports.sendAttachMail = (subject, html, toMail,attachMents, cb) => { - // BUG-041 / #95 — nodemailer 6.x accepts the v3 SES transport as - // `{ SES: { ses, aws } }`. `aws` is the @aws-sdk/client-ses module - // (used for command classes), `ses` is the v3 client instance. let transporter = nodemailer.createTransport({ - SES: { ses, aws: require('@aws-sdk/client-ses') } + host: config.NODEMAILER_HOST, + port: config.NODEMAILER_PORT, + secure: config.NODEMAILER_PORT == 465, // true for 465, false for other ports + auth: { + user: config.NODEMAILER_EMAIL, // generated ethereal user + pass: config.NODEMAILER_EMAIL_PASSWORD, // generated ethereal password + }, + tls: { + rejectUnauthorized: !allowSelfSigned + } }); transporter.sendMail({ - from: config.AWS_SES_FROM_DEFAULT, // sender address + from: ""+'<'+config.NODEMAILER_EMAIL+'>', // sender address to: toMail, // list of receivers subject: subject, // Subject line html : html, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0c92f4dc..4dbb341b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -125,7 +125,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1982,7 +1981,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.13.tgz", "integrity": "sha512-OZiDAEK/lDB6xy/XzYAyJJkaDqmQ+BCtOEPLqFvxWKUz5JbBmej7IiiRHdtiIOD/twW7O5AxVsfaaGA/V1bNsA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.6.9", "@firebase/logger": "0.4.2", @@ -2040,7 +2038,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.43.tgz", "integrity": "sha512-HM96ZyIblXjAC7TzE8wIk2QhHlSvksYkQ4Ukh1GmEenzkucSNUmUX4QvoKrqeWsLEQ8hdcojABeCV8ybVyZmeg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.10.13", "@firebase/component": "0.6.9", @@ -2053,8 +2050,7 @@ "version": "0.9.2", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@firebase/auth": { "version": "1.7.9", @@ -2475,7 +2471,6 @@ "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -2791,7 +2786,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.1.tgz", "integrity": "sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.1" }, @@ -2850,7 +2844,6 @@ "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.15.tgz", "integrity": "sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q==", "license": "MIT", - "peer": true, "dependencies": { "preact": "~10.12.1" } @@ -3626,7 +3619,6 @@ "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/Fuzzyma" @@ -3650,7 +3642,6 @@ "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 14.18" }, @@ -4291,7 +4282,6 @@ "integrity": "sha512-yTX7GVyM19tEbd+y5/gA6MkVKA6K61nVYHYAivD61Hx6odVFmQsaC3/R3cWAHM1P5oVKCevBbumPljbT+tFG2w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-compilation-targets": "^7.12.16", "@soda/friendly-errors-webpack-plugin": "^1.8.0", @@ -4977,7 +4967,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5046,7 +5035,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5183,7 +5171,6 @@ "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", "integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==", "license": "MIT", - "peer": true, "dependencies": { "@svgdotjs/svg.draggable.js": "^3.0.4", "@svgdotjs/svg.filter.js": "^3.0.8", @@ -5614,7 +5601,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6404,7 +6390,6 @@ "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.33", @@ -6493,7 +6478,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7017,7 +7001,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7787,7 +7770,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7929,7 +7911,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10609,7 +10590,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11523,7 +11503,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12888,7 +12867,8 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz", "integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/select-hose": { "version": "2.0.0", @@ -13753,7 +13733,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14181,7 +14160,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -14228,7 +14206,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.13", "@vue/compiler-sfc": "3.5.13", @@ -14655,7 +14632,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -14793,7 +14769,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14911,7 +14886,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15001,7 +14975,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/frontend/package.json b/frontend/package.json index 1c1d5c7d..67bef93b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,105 +1,108 @@ { - "name": "alian-hub-v3", - "version": "8.36.0", - "private": true, - "license": "AGPL-3.0-or-later", - "author": "Alian Software ", - "scripts": { - "serve": "vue-cli-service serve", - "build": "vue-cli-service build", - "lint": "vue-cli-service lint", - "build-report": "vue-cli-service build --report" - }, - "dependencies": { - "@chargebee/chargebee-js-vue-wrapper": "0.3.2", - "@editorjs/checklist": "1.6.0", - "@editorjs/code": "2.9.3", - "@editorjs/editorjs": "2.30.7", - "@editorjs/embed": "2.7.6", - "@editorjs/header": "2.8.8", - "@editorjs/inline-code": "1.5.1", - "@editorjs/marker": "1.4.0", - "@editorjs/nested-list": "1.4.3", - "@editorjs/table": "2.4.3", - "@formkit/pro": "^0.117.7", - "@formkit/themes": "^0.18.5", - "@formkit/validation": "^0.18.5", - "@formkit/vue": "^0.18.5", - "@fortawesome/fontawesome-svg-core": "6.7.1", - "@fortawesome/free-brands-svg-icons": "6.7.1", - "@fortawesome/free-regular-svg-icons": "6.7.1", - "@fortawesome/free-solid-svg-icons": "6.7.1", - "@fortawesome/vue-fontawesome": "3.0.8", - "@fullcalendar/core": "6.1.15", - "@fullcalendar/daygrid": "6.1.15", - "@fullcalendar/interaction": "6.1.15", - "@fullcalendar/timegrid": "6.1.15", - "@fullcalendar/vue3": "6.1.15", - "@vuepic/vue-datepicker": "^10.0.0", - "apexcharts": "^4.4.0", - "axios": "^1.12.2", - "chart.js": "^4.4.7", - "country-state-city": "3.2.1", - "d3": "^7.9.0", - "detectrtc": "1.4.1", - "dhtmlx-gantt": "^8.0.0", - "driver.js": "1.3.1", - "firebase": "10.14.1", - "grid-layout-plus": "^1.0.6", - "highcharts": "^12.1.2", - "js-cookie": "^3.0.5", - "jszip": "^3.10.1", - "markdown-it": "14.1.0", - "mic-recorder-to-mp3": "2.2.2", - "moment": "2.30.1", - "phone": "^3.1.58", - "socket.io-client": "4.8.1", - "sweetalert2": "11.15.0", - "v-calendar": "3.1.2", - "vue": "3.5.13", - "vue-advanced-cropper": "2.8.9", - "vue-i18n": "9.14.2", - "vue-pdf-embed": "^2.1.2", - "vue-router": "4.5.0", - "vue-toast-notification": "3.1.3", - "vue3-apexcharts": "^1.8.0", - "vue3-editor": "0.1.1", - "vue3-timepicker": "1.0.0-beta.2", - "vuedraggable": "4.1.0", - "vuex": "4.1.0", - "xlsx": "^0.18.5" - }, - "devDependencies": { - "@babel/core": "^7.12.16", - "@babel/eslint-parser": "^7.12.16", - "@vue/cli-plugin-babel": "~5.0.0", - "@vue/cli-plugin-eslint": "~5.0.0", - "@vue/cli-plugin-router": "~5.0.0", - "@vue/cli-plugin-vuex": "~5.0.0", - "@vue/cli-service": "~5.0.0", - "eslint": "^8.40.0", - "eslint-plugin-vue": "^9.11.1" - }, - "eslintConfig": { - "root": true, - "env": { - "node": true - }, - "extends": [ - "plugin:vue/vue3-essential", - "eslint:recommended" - ], - "parserOptions": { - "parser": "@babel/eslint-parser" - }, - "rules": { - "vue/require-toggle-inside-transition": "off" - } - }, - "browserslist": [ - "> 1%", - "last 2 versions", - "not dead", - "not ie 11" - ] + "name": "alian-hub-v3", + "version": "8.36.0", + "private": true, + "license": "AGPL-3.0-or-later", + "author": "Alian Software ", + "scripts": { + "serve": "vue-cli-service serve", + "build": "vue-cli-service build", + "lint": "vue-cli-service lint", + "build-report": "vue-cli-service build --report" + }, + "dependencies": { + "@chargebee/chargebee-js-vue-wrapper": "0.3.2", + "@editorjs/checklist": "1.6.0", + "@editorjs/code": "2.9.3", + "@editorjs/editorjs": "2.30.7", + "@editorjs/embed": "2.7.6", + "@editorjs/header": "2.8.8", + "@editorjs/inline-code": "1.5.1", + "@editorjs/marker": "1.4.0", + "@editorjs/nested-list": "1.4.3", + "@editorjs/table": "2.4.3", + "@formkit/pro": "^0.117.7", + "@formkit/themes": "^0.18.5", + "@formkit/validation": "^0.18.5", + "@formkit/vue": "^0.18.5", + "@fortawesome/fontawesome-svg-core": "6.7.1", + "@fortawesome/free-brands-svg-icons": "6.7.1", + "@fortawesome/free-regular-svg-icons": "6.7.1", + "@fortawesome/free-solid-svg-icons": "6.7.1", + "@fortawesome/vue-fontawesome": "3.0.8", + "@fullcalendar/core": "6.1.15", + "@fullcalendar/daygrid": "6.1.15", + "@fullcalendar/interaction": "6.1.15", + "@fullcalendar/timegrid": "6.1.15", + "@fullcalendar/vue3": "6.1.15", + "@vuepic/vue-datepicker": "^10.0.0", + "apexcharts": "^4.4.0", + "axios": "^1.12.2", + "chart.js": "^4.4.7", + "country-state-city": "3.2.1", + "d3": "^7.9.0", + "detectrtc": "1.4.1", + "dhtmlx-gantt": "^8.0.0", + "driver.js": "1.3.1", + "firebase": "10.14.1", + "grid-layout-plus": "^1.0.6", + "highcharts": "^12.1.2", + "js-cookie": "^3.0.5", + "jszip": "^3.10.1", + "markdown-it": "14.1.0", + "mic-recorder-to-mp3": "2.2.2", + "moment": "2.30.1", + "phone": "^3.1.58", + "socket.io-client": "4.8.1", + "sweetalert2": "11.15.0", + "v-calendar": "3.1.2", + "vue": "3.5.13", + "vue-advanced-cropper": "2.8.9", + "vue-i18n": "9.14.2", + "vue-pdf-embed": "^2.1.2", + "vue-router": "4.5.0", + "vue-toast-notification": "3.1.3", + "vue3-apexcharts": "^1.8.0", + "vue3-editor": "0.1.1", + "vue3-timepicker": "1.0.0-beta.2", + "vuedraggable": "4.1.0", + "vuex": "4.1.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@babel/core": "^7.12.16", + "@babel/eslint-parser": "^7.12.16", + "@vue/cli-plugin-babel": "~5.0.0", + "@vue/cli-plugin-eslint": "~5.0.0", + "@vue/cli-plugin-router": "~5.0.0", + "@vue/cli-plugin-vuex": "~5.0.0", + "@vue/cli-service": "~5.0.0", + "eslint": "^8.40.0", + "eslint-plugin-vue": "^9.11.1" + }, + "eslintConfig": { + "root": true, + "env": { + "node": true + }, + "extends": [ + "plugin:vue/vue3-essential", + "eslint:recommended" + ], + "parserOptions": { + "parser": "@babel/eslint-parser" + }, + "rules": { + "vue/require-toggle-inside-transition": "off" + } + }, + "browserslist": [ + "> 1%", + "last 2 versions", + "not dead", + "not ie 11" + ], + "lint-staged": { + "*.{js,vue}": "npx eslint --fix" + } } diff --git a/frontend/src/assets/images/svg/auditDoc.svg b/frontend/src/assets/images/svg/auditDoc.svg new file mode 100644 index 00000000..3cd26e66 --- /dev/null +++ b/frontend/src/assets/images/svg/auditDoc.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/images/svg/auditDocActive.svg b/frontend/src/assets/images/svg/auditDocActive.svg new file mode 100644 index 00000000..aca14511 --- /dev/null +++ b/frontend/src/assets/images/svg/auditDocActive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/images/svg/integrationPuzzle.svg b/frontend/src/assets/images/svg/integrationPuzzle.svg new file mode 100644 index 00000000..3de9778f --- /dev/null +++ b/frontend/src/assets/images/svg/integrationPuzzle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/svg/integrationPuzzleActive.svg b/frontend/src/assets/images/svg/integrationPuzzleActive.svg new file mode 100644 index 00000000..bac05a2d --- /dev/null +++ b/frontend/src/assets/images/svg/integrationPuzzleActive.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/svg/scimUsers.svg b/frontend/src/assets/images/svg/scimUsers.svg new file mode 100644 index 00000000..a27d9789 --- /dev/null +++ b/frontend/src/assets/images/svg/scimUsers.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/images/svg/scimUsersActive.svg b/frontend/src/assets/images/svg/scimUsersActive.svg new file mode 100644 index 00000000..ed3915bc --- /dev/null +++ b/frontend/src/assets/images/svg/scimUsersActive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/images/svg/ssoKey.svg b/frontend/src/assets/images/svg/ssoKey.svg new file mode 100644 index 00000000..0a62e127 --- /dev/null +++ b/frontend/src/assets/images/svg/ssoKey.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/images/svg/ssoKeyActive.svg b/frontend/src/assets/images/svg/ssoKeyActive.svg new file mode 100644 index 00000000..d3def796 --- /dev/null +++ b/frontend/src/assets/images/svg/ssoKeyActive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/images/svg/twoFactorLock.svg b/frontend/src/assets/images/svg/twoFactorLock.svg new file mode 100644 index 00000000..af5e534d --- /dev/null +++ b/frontend/src/assets/images/svg/twoFactorLock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/images/svg/twoFactorLockActive.svg b/frontend/src/assets/images/svg/twoFactorLockActive.svg new file mode 100644 index 00000000..137e0842 --- /dev/null +++ b/frontend/src/assets/images/svg/twoFactorLockActive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/components/atom/CardSkeleton/CardSkeleton.vue b/frontend/src/components/atom/CardSkeleton/CardSkeleton.vue new file mode 100644 index 00000000..c6f6323a --- /dev/null +++ b/frontend/src/components/atom/CardSkeleton/CardSkeleton.vue @@ -0,0 +1,46 @@ + + + + + + + diff --git a/frontend/src/components/molecules/CardFieldComponent/CardFieldComponent.vue b/frontend/src/components/molecules/CardFieldComponent/CardFieldComponent.vue index bc680bdd..6e70f504 100644 --- a/frontend/src/components/molecules/CardFieldComponent/CardFieldComponent.vue +++ b/frontend/src/components/molecules/CardFieldComponent/CardFieldComponent.vue @@ -93,8 +93,16 @@ @dismiss="dismissNewProject" /> + + -
+

{{$t('Filters.filter')}}

+ + + + + + diff --git a/frontend/src/components/organisms/ActiveWorkTableCard/ActiveWorkTableCard.vue b/frontend/src/components/organisms/ActiveWorkTableCard/ActiveWorkTableCard.vue new file mode 100644 index 00000000..7737058b --- /dev/null +++ b/frontend/src/components/organisms/ActiveWorkTableCard/ActiveWorkTableCard.vue @@ -0,0 +1,149 @@ + + + + + + + diff --git a/frontend/src/components/organisms/FreeResourcesCard/FreeResourcesCard.vue b/frontend/src/components/organisms/FreeResourcesCard/FreeResourcesCard.vue new file mode 100644 index 00000000..9e371f17 --- /dev/null +++ b/frontend/src/components/organisms/FreeResourcesCard/FreeResourcesCard.vue @@ -0,0 +1,155 @@ + + + + + + + diff --git a/frontend/src/components/organisms/LiveWorkCard/LiveWorkCard.vue b/frontend/src/components/organisms/LiveWorkCard/LiveWorkCard.vue new file mode 100644 index 00000000..5e840916 --- /dev/null +++ b/frontend/src/components/organisms/LiveWorkCard/LiveWorkCard.vue @@ -0,0 +1,191 @@ + + + + + + + diff --git a/frontend/src/components/organisms/MetricSummaryCard/MetricSummaryCard.vue b/frontend/src/components/organisms/MetricSummaryCard/MetricSummaryCard.vue index b78441e7..21ce5bf9 100644 --- a/frontend/src/components/organisms/MetricSummaryCard/MetricSummaryCard.vue +++ b/frontend/src/components/organisms/MetricSummaryCard/MetricSummaryCard.vue @@ -1,6 +1,6 @@