diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 49e369a..e12a96a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,9 @@ import path from "path"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { + experimental: { + optimizePackageImports: ["recharts", "date-fns"], + }, transpilePackages: [ "@planarchy/api", "@planarchy/db", diff --git a/apps/web/src/app/(app)/admin/system-roles/page.tsx b/apps/web/src/app/(app)/admin/system-roles/page.tsx new file mode 100644 index 0000000..ff21d62 --- /dev/null +++ b/apps/web/src/app/(app)/admin/system-roles/page.tsx @@ -0,0 +1,21 @@ +import dynamic from "next/dynamic"; + +const SystemRolesClient = dynamic( + () => import("~/components/admin/SystemRolesClient.js").then((m) => m.SystemRolesClient), + { + loading: () => ( +
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ ), + }, +); + +export default function SystemRolesPage() { + return ; +} diff --git a/apps/web/src/app/(app)/allocations/page.tsx b/apps/web/src/app/(app)/allocations/page.tsx index 54fd548..3ff485d 100644 --- a/apps/web/src/app/(app)/allocations/page.tsx +++ b/apps/web/src/app/(app)/allocations/page.tsx @@ -1,4 +1,21 @@ -import { AllocationsClient } from "~/components/allocations/AllocationsClient.js"; +import dynamic from "next/dynamic"; + +const AllocationsClient = dynamic( + () => import("~/components/allocations/AllocationsClient.js").then((m) => m.AllocationsClient), + { + loading: () => ( +
+
+
+
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+
+ ), + }, +); export default function AllocationsPage() { return ; diff --git a/apps/web/src/app/(app)/estimates/EstimatesClient.tsx b/apps/web/src/app/(app)/estimates/EstimatesClient.tsx index 5e8bd0c..d92e1ac 100644 --- a/apps/web/src/app/(app)/estimates/EstimatesClient.tsx +++ b/apps/web/src/app/(app)/estimates/EstimatesClient.tsx @@ -70,18 +70,18 @@ type EstimateDetail = { }; const STATUS_STYLES: Record = { - DRAFT: "bg-slate-100 text-slate-700", - IN_REVIEW: "bg-amber-100 text-amber-700", - APPROVED: "bg-emerald-100 text-emerald-700", - ARCHIVED: "bg-zinc-200 text-zinc-700", + DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300", + IN_REVIEW: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", + APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", + ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300", }; const VERSION_STYLES: Record = { - WORKING: "bg-sky-100 text-sky-700", - BASELINE: "bg-violet-100 text-violet-700", - SUBMITTED: "bg-amber-100 text-amber-700", - APPROVED: "bg-emerald-100 text-emerald-700", - SUPERSEDED: "bg-zinc-200 text-zinc-700", + WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300", + BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300", + SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", + APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", + SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300", }; function formatMetricValue(metric: EstimateMetric) { @@ -146,7 +146,7 @@ function EstimateDetailPanel({
Open workspace @@ -165,7 +165,7 @@ function EstimateDetailPanel({ {latestVersion ? ( <>
- + Version {latestVersion.versionNumber} {latestVersion.label ? ` - ${latestVersion.label}` : ""} @@ -212,7 +212,7 @@ function EstimateDetailPanel({
{latestVersion.scopeItems.length === 0 ? ( -

+

No scope rows captured yet.

) : ( @@ -245,7 +245,7 @@ function EstimateDetailPanel({
{latestVersion.demandLines.length === 0 ? ( -

+

No staffing demand captured yet.

) : ( @@ -273,7 +273,7 @@ function EstimateDetailPanel({
) : ( -

+

No versions available for this estimate yet.

)} @@ -302,8 +302,8 @@ function EstimateCard({ className={clsx( "w-full rounded-3xl border p-5 text-left transition", active - ? "border-brand-500 bg-brand-50 shadow-sm dark:bg-brand-950/30" - : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:hover:border-gray-700", + ? "border-brand-500 bg-brand-50 shadow-sm dark:border-sky-400 dark:bg-sky-950/30" + : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600", !canInspect && "cursor-default", )} > @@ -319,7 +319,7 @@ function EstimateCard({ {estimate.status.replace("_", " ")} {estimate.project && ( - + {estimate.project.shortCode} )} @@ -408,7 +408,7 @@ export function EstimatesClient() { return ( <>
-
+

diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index 01500b5..d7a4568 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -1296,7 +1296,7 @@ export function ResourcesClient() { {skills.slice(0, 3).map((s) => ( {s.skill} diff --git a/apps/web/src/app/(app)/resources/page.tsx b/apps/web/src/app/(app)/resources/page.tsx index fbde078..514907e 100644 --- a/apps/web/src/app/(app)/resources/page.tsx +++ b/apps/web/src/app/(app)/resources/page.tsx @@ -1,10 +1,22 @@ -import { Suspense } from "react"; -import { ResourcesClient } from "./ResourcesClient.js"; +import dynamic from "next/dynamic"; + +const ResourcesClient = dynamic( + () => import("./ResourcesClient.js").then((m) => m.ResourcesClient), + { + loading: () => ( +

+
+
+
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+
+ ), + }, +); export default function ResourcesPage() { - return ( - - - - ); + return ; } diff --git a/apps/web/src/app/(app)/timeline/page.tsx b/apps/web/src/app/(app)/timeline/page.tsx index 00e149f..a69b666 100644 --- a/apps/web/src/app/(app)/timeline/page.tsx +++ b/apps/web/src/app/(app)/timeline/page.tsx @@ -1,4 +1,17 @@ -import { TimelineView } from "~/components/timeline/TimelineView.js"; +import dynamic from "next/dynamic"; + +const TimelineView = dynamic( + () => import("~/components/timeline/TimelineView.js").then((m) => m.TimelineView), + { + loading: () => ( +
+
+
+
+
+ ), + }, +); export default function TimelinePage() { return ( diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index 48d5321..b50a242 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -1,10 +1,25 @@ -import { createTRPCContext } from "@planarchy/api"; +import { createTRPCContext, loadRoleDefaults } from "@planarchy/api"; import { appRouter } from "@planarchy/api/router"; import { prisma } from "@planarchy/db"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "~/server/auth.js"; +// Throttle lastActiveAt updates: max once per 60s per user +const lastActiveCache = new Map(); +const ACTIVITY_THROTTLE_MS = 60_000; + +function trackActivity(userId: string) { + const now = Date.now(); + const last = lastActiveCache.get(userId) ?? 0; + if (now - last < ACTIVITY_THROTTLE_MS) return; + lastActiveCache.set(userId, now); + prisma.user.update({ + where: { id: userId }, + data: { lastActiveAt: new Date(now) }, + }).catch(() => {/* ignore */}); +} + const handler = async (req: NextRequest) => { const session = await auth(); @@ -15,12 +30,18 @@ const handler = async (req: NextRequest) => { }) : null; + // Track user activity (throttled, fire-and-forget) + if (dbUser) trackActivity(dbUser.id); + + // Load configurable role defaults (cached, 60s TTL) + const roleDefaults = await loadRoleDefaults(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const options: any = { endpoint: "/api/trpc", req, router: appRouter, - createContext: () => createTRPCContext({ session, dbUser }), + createContext: () => createTRPCContext({ session, dbUser, roleDefaults }), }; if (process.env["NODE_ENV"] === "development") { diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9084f9e..77c16a2 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -335,7 +335,7 @@ color: rgb(196 181 253) !important; } .dark .bg-amber-50 { - background-color: rgb(120 53 15 / 0.2) !important; + background-color: rgb(120 53 15) !important; } /* Modal / overlay */ @@ -427,3 +427,19 @@ @apply opacity-75 shadow-lg scale-105; } } + +/* ─── Overbooking blink animation ──────────────────────────────────────────── */ +@keyframes overbooking-blink { + 0%, 100% { background-color: rgba(239, 68, 68, 0); } + 50% { background-color: rgba(239, 68, 68, 0.18); } +} +.dark .animate-overbooking-blink { + animation: overbooking-blink-dark 2s ease-in-out infinite; +} +@keyframes overbooking-blink-dark { + 0%, 100% { background-color: rgba(239, 68, 68, 0); } + 50% { background-color: rgba(239, 68, 68, 0.25); } +} +.animate-overbooking-blink { + animation: overbooking-blink 2s ease-in-out infinite; +} diff --git a/apps/web/src/components/admin/SystemRolesClient.tsx b/apps/web/src/components/admin/SystemRolesClient.tsx new file mode 100644 index 0000000..996f1de --- /dev/null +++ b/apps/web/src/components/admin/SystemRolesClient.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { useState } from "react"; +import { PermissionKey } from "@planarchy/shared"; +import { trpc } from "~/lib/trpc/client.js"; +import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; + +const ALL_PERMISSION_KEYS = Object.values(PermissionKey); + +const PERMISSION_LABELS: Record = { + viewCosts: "View Costs", + exportData: "Export Data", + importData: "Import Data", + approveVacations: "Approve Vacations", + manageBlueprints: "Manage Blueprints", + viewAllResources: "View All Resources", + manageResources: "Manage Resources", + manageProjects: "Manage Projects", + manageAllocations: "Manage Allocations", + manageRoles: "Manage Roles", + manageUsers: "Manage Users", + viewScores: "View Scores", +}; + +const PERMISSION_DESCRIPTIONS: Record = { + viewCosts: "Access to cost data, budget views, and financial reports", + exportData: "Export data to Excel, CSV, or PDF formats", + importData: "Import data from external sources (Dispo, Excel)", + approveVacations: "Approve or reject vacation requests", + manageBlueprints: "Create and edit blueprint field definitions", + viewAllResources: "View all resources (not just own team)", + manageResources: "Create, edit, and deactivate resource records", + manageProjects: "Create, edit, and manage project records", + manageAllocations: "Create, edit, and delete allocations", + manageRoles: "Create and edit project roles", + manageUsers: "Manage user accounts and permissions", + viewScores: "View value scores and skill analytics", +}; + +const COLOR_OPTIONS = [ + { value: "purple", label: "Purple", class: "bg-purple-500" }, + { value: "blue", label: "Blue", class: "bg-blue-500" }, + { value: "amber", label: "Amber", class: "bg-amber-500" }, + { value: "green", label: "Green", class: "bg-green-500" }, + { value: "red", label: "Red", class: "bg-red-500" }, + { value: "gray", label: "Gray", class: "bg-gray-500" }, + { value: "indigo", label: "Indigo", class: "bg-indigo-500" }, + { value: "teal", label: "Teal", class: "bg-teal-500" }, +]; + +const ROLE_COLOR_MAP: Record = { + purple: "border-purple-300 bg-purple-50 dark:border-purple-700 dark:bg-purple-900/20", + blue: "border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/20", + amber: "border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20", + green: "border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-900/20", + red: "border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20", + gray: "border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50", + indigo: "border-indigo-300 bg-indigo-50 dark:border-indigo-700 dark:bg-indigo-900/20", + teal: "border-teal-300 bg-teal-50 dark:border-teal-700 dark:bg-teal-900/20", +}; + +const ROLE_BADGE_MAP: Record = { + purple: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400", + blue: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400", + amber: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400", + green: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400", + red: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400", + gray: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", + indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400", + teal: "bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-400", +}; + +type RoleConfig = { + role: string; + label: string; + description: string | null; + defaultPermissions: unknown; + color: string | null; + sortOrder: number; +}; + +type EditingRole = { + role: string; + label: string; + description: string; + color: string; + permissions: Set; +}; + +export function SystemRolesClient() { + const [editingRole, setEditingRole] = useState(null); + const [actionError, setActionError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const utils = trpc.useUtils(); + const { data: roleConfigs, isLoading } = trpc.systemRoleConfig.list.useQuery(undefined, { + staleTime: 10_000, + }); + + const updateMutation = trpc.systemRoleConfig.update.useMutation({ + onSuccess: async () => { + await utils.systemRoleConfig.list.invalidate(); + setEditingRole(null); + setActionError(null); + setSuccessMessage("Role permissions updated successfully"); + setTimeout(() => setSuccessMessage(null), 3000); + }, + onError: (err) => setActionError(err.message), + }); + + function openEdit(config: RoleConfig) { + setEditingRole({ + role: config.role, + label: config.label, + description: config.description ?? "", + color: config.color ?? "gray", + permissions: new Set(config.defaultPermissions as string[]), + }); + setActionError(null); + setSuccessMessage(null); + } + + function togglePermission(key: string) { + if (!editingRole) return; + const next = new Set(editingRole.permissions); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + setEditingRole({ ...editingRole, permissions: next }); + } + + function selectAll() { + if (!editingRole) return; + setEditingRole({ ...editingRole, permissions: new Set(ALL_PERMISSION_KEYS) }); + } + + function selectNone() { + if (!editingRole) return; + setEditingRole({ ...editingRole, permissions: new Set() }); + } + + async function handleSave() { + if (!editingRole) return; + setActionError(null); + await updateMutation.mutateAsync({ + role: editingRole.role, + label: editingRole.label, + description: editingRole.description || null, + color: editingRole.color, + defaultPermissions: Array.from(editingRole.permissions), + }); + } + + const configs = (roleConfigs ?? []) as unknown as RoleConfig[]; + + return ( +
+
+

System Role Management

+

+ Configure default permissions for each system role. Changes apply to all users with that role. +

+
+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {isLoading && ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ )} + + {/* Role Cards */} +
+ {configs.map((config) => { + const perms = config.defaultPermissions as string[]; + const color = config.color ?? "gray"; + return ( +
+
+
+
+ + {config.label} + + + {config.role} + +
+ {config.description && ( +

+ {config.description} +

+ )} +
+ {perms.length === 0 ? ( + No default permissions + ) : ( + perms.map((p) => ( + + {PERMISSION_LABELS[p] ?? p} + + )) + )} +
+
+ +
+
+ ); + })} +
+ + {/* Permission Matrix Overview */} + {configs.length > 0 && ( +
+

+ Permission Matrix +

+
+ + + + + {configs.map((c) => ( + + ))} + + + + {ALL_PERMISSION_KEYS.map((key) => ( + + + {configs.map((c) => { + const perms = c.defaultPermissions as string[]; + const has = perms.includes(key); + return ( + + ); + })} + + ))} + +
Permission + {c.label} +
+ {PERMISSION_LABELS[key] ?? key} + + {has ? ( + + + + + + ) : ( + + — + + )} +
+
+
+ )} + + {/* Edit Modal */} + {editingRole && ( +
+
+
+
+

+ Configure Role +

+

+ {editingRole.role} +

+
+ +
+ +
+ {actionError && ( +
+ {actionError} +
+ )} + + {/* Label */} +
+ + setEditingRole({ ...editingRole, label: e.target.value })} + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +
+ + {/* Description */} +
+ + setEditingRole({ ...editingRole, description: e.target.value })} + placeholder="Brief description of this role..." + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +
+ + {/* Color */} +
+ +
+ {COLOR_OPTIONS.map((opt) => ( +
+
+ + {/* Permissions */} +
+
+ +
+ + +
+
+
+ {ALL_PERMISSION_KEYS.map((key) => { + const isActive = editingRole.permissions.has(key); + return ( + + ); + })} +
+
+
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin/SystemSettingsClient.tsx b/apps/web/src/components/admin/SystemSettingsClient.tsx index bc2f2f6..a9d7265 100644 --- a/apps/web/src/components/admin/SystemSettingsClient.tsx +++ b/apps/web/src/components/admin/SystemSettingsClient.tsx @@ -118,6 +118,10 @@ export function SystemSettingsClient() { const [vacationDefaultDays, setVacationDefaultDays] = useState(28); const [vacationSaved, setVacationSaved] = useState(false); + // Timeline + const [undoMaxSteps, setUndoMaxSteps] = useState(50); + const [timelineSaved, setTimelineSaved] = useState(false); + const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, { staleTime: 0, }); @@ -152,6 +156,8 @@ export function SystemSettingsClient() { setAnonymizationSeed(""); // Vacation setVacationDefaultDays(settings.vacationDefaultDays ?? 28); + // Timeline + setUndoMaxSteps(settings.timelineUndoMaxSteps ?? 50); } }, [settings]); @@ -227,6 +233,13 @@ export function SystemSettingsClient() { }, }); + const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({ + onSuccess: () => { + setTimelineSaved(true); + setTimeout(() => setTimelineSaved(false), 3000); + }, + }); + function handleSaveSmtp() { saveSmtpMutation.mutate({ smtpHost: smtpHost || undefined, @@ -242,6 +255,10 @@ export function SystemSettingsClient() { saveVacationMutation.mutate({ vacationDefaultDays }); } + function handleSaveTimeline() { + saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps }); + } + function handleSaveAnonymization() { saveAnonymizationMutation.mutate({ anonymizationEnabled, @@ -1226,6 +1243,46 @@ export function SystemSettingsClient() {
+
+
+

+ Timeline +

+

+ Configure timeline behavior and undo/redo history. +

+
+ +
+ + setUndoMaxSteps(parseInt(e.target.value, 10) || 50)} + min={1} + max={200} + /> +

+ Maximum number of undo steps for timeline operations (single moves and batch shifts). Default: 50. +

+
+ +
+ + {timelineSaved && ( + Saved! + )} +
+
+

diff --git a/apps/web/src/components/admin/UsersClient.tsx b/apps/web/src/components/admin/UsersClient.tsx index 4730b49..e936a4d 100644 --- a/apps/web/src/components/admin/UsersClient.tsx +++ b/apps/web/src/components/admin/UsersClient.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { SystemRole, PermissionKey, type PermissionOverrides } from "@planarchy/shared"; +import { useState, useMemo } from "react"; +import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; import { FilterChips } from "~/components/ui/FilterChips.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; @@ -56,6 +56,9 @@ type UserRow = { email: string; systemRole: string; createdAt: Date; + lastLoginAt: Date | null; + lastActiveAt: Date | null; + permissionOverrides: PermissionOverrides | null; }; type EditState = { @@ -94,6 +97,25 @@ export function UsersClient() { staleTime: 10_000, }); + const { data: roleConfigs } = trpc.systemRoleConfig.list.useQuery(undefined, { + staleTime: 60_000, + }); + + // Build dynamic role defaults map from DB config (fallback to hardcoded) + const roleDefaultsMap = useMemo(() => { + if (!roleConfigs) return ROLE_DEFAULT_PERMISSIONS; + const map: Record = {}; + for (const c of roleConfigs) { + map[c.role] = c.defaultPermissions as string[]; + } + return map as Record; + }, [roleConfigs]); + + const { data: activeData } = trpc.user.activeCount.useQuery(undefined, { + staleTime: 30_000, + refetchInterval: 30_000, + }); + const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery( { userId: selectedUserId ?? "" }, { enabled: !!selectedUserId }, @@ -146,13 +168,14 @@ export function UsersClient() { function openEdit(user: UserRow) { const role = (user.systemRole as SystemRole) ?? SystemRole.USER; + const overrides = user.permissionOverrides as PermissionOverrides | null; setSelectedUserId(user.id); setEditState({ userId: user.id, systemRole: role, - granted: new Set(), - denied: new Set(), - chapterIds: "", + granted: new Set(overrides?.granted ?? []), + denied: new Set(overrides?.denied ?? []), + chapterIds: (overrides?.chapterIds ?? []).join(", "), }); setActionError(null); } @@ -280,6 +303,21 @@ export function UsersClient() { ...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []), ]; + function isOnline(user: UserRow) { + if (!user.lastActiveAt) return false; + return Date.now() - new Date(user.lastActiveAt).getTime() < 5 * 60 * 1000; + } + + function formatRelativeTime(date: Date | null) { + if (!date) return "Never"; + const d = new Date(date); + const diff = Date.now() - d.getTime(); + if (diff < 60_000) return "Just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); + } + return (
@@ -289,7 +327,18 @@ export function UsersClient() { Manage user roles and permission overrides

-
+
+ {activeData && ( +
+ + + + + + {activeData.count} online + +
+ )}
- {/* Effective Permissions */} - {effectivePerms && ( -
-

- Effective Permissions -

-
- {ALL_PERMISSION_KEYS.map((key) => { - const isActive = effectivePerms.effectivePermissions.includes(key); - return ( - + {/* Permissions */} +
+

+ Permissions +

+
+ + Role default + + + Extra grant + + + × Denied + +
+
+ {ALL_PERMISSION_KEYS.map((key) => { + const roleDefaults = new Set(roleDefaultsMap[editState.systemRole] ?? []); + const isRoleDefault = roleDefaults.has(key as PermissionKey); + const isGranted = editState.granted.has(key); + const isDenied = editState.denied.has(key); + + // Determine display state + let state: "default" | "granted" | "denied" | "off"; + if (isDenied) state = "denied"; + else if (isGranted) state = "granted"; + else if (isRoleDefault) state = "default"; + else state = "off"; + + function cycleState() { + if (!editState) return; + const nextGranted = new Set(editState.granted); + const nextDenied = new Set(editState.denied); + + if (isRoleDefault) { + // Role default: off → denied → off + if (isDenied) { + nextDenied.delete(key); + } else { + nextDenied.add(key); + nextGranted.delete(key); + } + } else { + // Non-default: off → granted → off + if (isGranted) { + nextGranted.delete(key); + } else { + nextGranted.add(key); + nextDenied.delete(key); + } + } + setEditState({ ...editState, granted: nextGranted, denied: nextDenied }); + } + + const stateStyles = { + default: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800", + granted: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800", + denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800", + off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700", + }; + + const checkStyles = { + default: "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40", + granted: "text-blue-600 border-blue-300 bg-blue-100 dark:bg-blue-900/40", + denied: "text-red-600 border-red-300 bg-red-100 dark:bg-red-900/40", + off: "text-gray-400 border-gray-300 dark:border-gray-600", + }; + + return ( +
-
- )} - - {/* Permission Overrides */} -
-

- Permission Overrides -

-
- {/* Additional Grants */} -
-

- Additional Grants -

-
- {ALL_PERMISSION_KEYS.map((key) => ( - - ))} -
-
- - {/* Explicit Denials */} -
-

- Explicit Denials -

-
- {ALL_PERMISSION_KEYS.map((key) => ( - - ))} -
-
+ {state === "default" && ( + Role + )} + {state === "granted" && ( + Extra + )} + {state === "denied" && ( + Denied + )} + + ); + })}
{/* Chapter Scope */} diff --git a/apps/web/src/components/allocations/AllocationModal.tsx b/apps/web/src/components/allocations/AllocationModal.tsx index afe604e..fa5c3ce 100644 --- a/apps/web/src/components/allocations/AllocationModal.tsx +++ b/apps/web/src/components/allocations/AllocationModal.tsx @@ -23,7 +23,10 @@ interface AllocationModalProps { function toDateInputValue(date: Date | string | null | undefined): string { if (!date) return ""; const d = typeof date === "string" ? new Date(date) : date; - return d.toISOString().split("T")[0] ?? ""; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; } export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) { diff --git a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx index adbebe8..a1d7987 100644 --- a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx +++ b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx @@ -35,7 +35,7 @@ const TABS: Array<{ id: WorkspaceTab; label: string }> = [ function EmptyState({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); @@ -53,8 +53,8 @@ function ActionNotice({ className={clsx( "rounded-2xl border px-4 py-3 text-sm", tone === "success" - ? "border-emerald-200 bg-emerald-50 text-emerald-800" - : "border-rose-200 bg-rose-50 text-rose-800", + ? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300" + : "border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300", )} > {children} @@ -182,7 +182,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
@@ -190,21 +190,21 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string }) Back to Estimates -
+
-

Estimate Workspace

-

+

Estimate Workspace

+

{estimate?.name ?? "Loading estimate"}

-

+

Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs.

{estimate && (
-
+
{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"} Updated {formatDateLong(estimate.updatedAt)}
@@ -215,7 +215,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string }) if (!editableTab && !isEditing) return; setIsEditing((current) => !current); }} - className="rounded-2xl border border-brand-200 bg-white px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50" + className="rounded-2xl border border-brand-200 dark:border-sky-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-50 dark:hover:bg-gray-700" > {isEditing ? "Close editor" : editableTab ? "Edit working draft" : "Draft editor available in editable tabs"} @@ -238,7 +238,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string }) {actionMessage && {actionMessage}} {actionError && {actionError}} -
+
{TABS.map((item) => ( +
+ + +
{/* Loading */} @@ -139,6 +153,14 @@ export function BroadcastManagementClient() { onSuccess={handleSuccess} /> )} + + {/* Create Task Modal */} + {showTaskModal && ( + setShowTaskModal(false)} + onSuccess={handleSuccess} + /> + )}
); } diff --git a/apps/web/src/components/notifications/CreateTaskModal.tsx b/apps/web/src/components/notifications/CreateTaskModal.tsx new file mode 100644 index 0000000..cc4153e --- /dev/null +++ b/apps/web/src/components/notifications/CreateTaskModal.tsx @@ -0,0 +1,510 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useFocusTrap } from "~/hooks/useFocusTrap.js"; +import { trpc } from "~/lib/trpc/client.js"; +import { DateInput } from "~/components/ui/DateInput.js"; + +type Mode = "single" | "group"; + +const TARGET_TYPES = [ + { value: "all", label: "All Users" }, + { value: "role", label: "By Role" }, + { value: "project", label: "By Project" }, + { value: "orgUnit", label: "By Org Unit" }, +] as const; + +const ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const; + +const PRIORITY_OPTIONS = [ + { value: "LOW", label: "Low" }, + { value: "NORMAL", label: "Normal" }, + { value: "HIGH", label: "High" }, + { value: "URGENT", label: "Urgent" }, +] as const; + +const CHANNEL_OPTIONS = [ + { value: "in_app", label: "In-App" }, + { value: "email", label: "Email" }, + { value: "both", label: "Both" }, +] as const; + +interface CreateTaskModalProps { + onClose: () => void; + onSuccess: () => void; +} + +export function CreateTaskModal({ onClose, onSuccess }: CreateTaskModalProps) { + const [mode, setMode] = useState("single"); + const [userId, setUserId] = useState(""); + const [userSearch, setUserSearch] = useState(""); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [priority, setPriority] = useState("NORMAL"); + const [dueDate, setDueDate] = useState(""); + const [dueTime, setDueTime] = useState("09:00"); + const [channel, setChannel] = useState("in_app"); + const [link, setLink] = useState(""); + const [targetType, setTargetType] = useState("all"); + const [targetValue, setTargetValue] = useState(""); + const [serverError, setServerError] = useState(null); + const [result, setResult] = useState<{ recipientCount?: number; taskId?: string } | null>(null); + + const panelRef = useRef(null); + useFocusTrap(panelRef, true); + + const utils = trpc.useUtils(); + + const { data: users = [] } = trpc.user.listAssignable.useQuery(undefined, { + staleTime: 60_000, + }); + + const filteredUsers = userSearch.trim() + ? users.filter( + (u) => + (u.name ?? "").toLowerCase().includes(userSearch.toLowerCase()) || + u.email.toLowerCase().includes(userSearch.toLowerCase()), + ) + : users; + + const createTaskMutation = trpc.notification.createTask.useMutation({ + onSuccess: async (data) => { + await utils.notification.listTasks.invalidate(); + await utils.notification.list.invalidate(); + await utils.notification.taskCounts.invalidate(); + await utils.notification.unreadCount.invalidate(); + const id = (data as { id?: string }).id; + setResult(id !== undefined ? { taskId: id } : {}); + }, + onError: (err) => setServerError(err.message), + }); + + const createBroadcastMutation = trpc.notification.createBroadcast.useMutation({ + onSuccess: async (data) => { + await utils.notification.listBroadcasts.invalidate(); + await utils.notification.list.invalidate(); + await utils.notification.taskCounts.invalidate(); + await utils.notification.unreadCount.invalidate(); + const count = (data as { recipientCount?: number }).recipientCount ?? 0; + setResult({ recipientCount: count }); + }, + onError: (err) => setServerError(err.message), + }); + + const isPending = createTaskMutation.isPending || createBroadcastMutation.isPending; + + function buildDueDate(): Date | undefined { + if (!dueDate) return undefined; + const [hours, minutes] = dueTime.split(":").map(Number); + const d = new Date(dueDate + "T00:00:00"); + d.setHours(hours ?? 9, minutes ?? 0, 0, 0); + return d; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setServerError(null); + + if (!title.trim()) { + setServerError("Title is required."); + return; + } + + if (mode === "single") { + if (!userId) { + setServerError("Please select a recipient."); + return; + } + const due = buildDueDate(); + createTaskMutation.mutate({ + userId, + title: title.trim(), + ...(body.trim() ? { body: body.trim() } : {}), + priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT", + ...(due !== undefined ? { dueDate: due } : {}), + channel: channel as "in_app" | "email" | "both", + ...(link.trim() ? { link: link.trim() } : {}), + }); + } else { + createBroadcastMutation.mutate({ + title: title.trim(), + ...(body.trim() ? { body: body.trim() } : {}), + targetType: targetType as "all" | "role" | "project" | "orgUnit", + ...(targetType !== "all" && targetValue.trim() ? { targetValue: targetValue.trim() } : {}), + priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT", + channel: channel as "in_app" | "email" | "both", + ...(link.trim() ? { link: link.trim() } : {}), + category: "TASK", + }); + } + } + + function handleCloseResult() { + onSuccess(); + onClose(); + } + + const inputClass = + "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"; + const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; + + // After successful send, show result + if (result) { + const isGroup = mode === "group"; + return ( +
{ + if (e.target === e.currentTarget) handleCloseResult(); + }} + > +
{ if (e.key === "Escape") handleCloseResult(); }} + > +
+
+ + + +
+

Task Created

+

+ {isGroup + ? `Task sent to ${result.recipientCount ?? 0} recipient${(result.recipientCount ?? 0) !== 1 ? "s" : ""}` + : "Task has been assigned successfully"} +

+ +
+
+
+ ); + } + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
{ if (e.key === "Escape") onClose(); }} + > + {/* Header */} +
+

Create Task

+ +
+ +
+ {/* Mode toggle */} +
+ + +
+ + {/* Recipient (single mode) */} + {mode === "single" && ( +
+ + setUserSearch(e.target.value)} + className={inputClass} + placeholder="Search by name or email..." + /> + {userId && ( +
+ + Selected: {users.find((u) => u.id === userId)?.name ?? users.find((u) => u.id === userId)?.email ?? userId} + + +
+ )} + {userSearch.trim() && filteredUsers.length > 0 && ( +
+ {filteredUsers.slice(0, 20).map((u) => ( + + ))} +
+ )} + {userSearch.trim() && filteredUsers.length === 0 && ( +

No users found.

+ )} +
+ )} + + {/* Target (group mode) */} + {mode === "group" && ( + <> +
+ + +
+ + {targetType === "role" && ( +
+ + +
+ )} + + {targetType === "project" && ( +
+ + setTargetValue(e.target.value)} + className={inputClass} + placeholder="Project ID..." + /> +
+ )} + + {targetType === "orgUnit" && ( +
+ + setTargetValue(e.target.value)} + className={inputClass} + placeholder="Org Unit ID..." + /> +
+ )} + + )} + + {/* Title */} +
+ + setTitle(e.target.value)} + maxLength={200} + className={inputClass} + required + placeholder="Task title..." + /> +
+ + {/* Body */} +
+ +