feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes
Major timeline enhancements: - Right-click drag multi-selection with floating action bar (batch delete/assign) - DemandPopover for demand strip details (replaces broken "Loading" modal) - ResourceHoverCard on name hover showing skills, rates, role, chapter - Merged heatmap+vacation tooltips into unified TimelineTooltip component - Fixed overbooking blink animation (date normalization, z-index ordering) - Fixed dark mode sticky column bleed-through in project view - System roles admin page, notification task management, performance review docs Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const SystemRolesClient = dynamic(
|
||||
() => import("~/components/admin/SystemRolesClient.js").then((m) => m.SystemRolesClient),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse p-6 space-y-4 max-w-4xl mx-auto">
|
||||
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-24 w-full bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function SystemRolesPage() {
|
||||
return <SystemRolesClient />;
|
||||
}
|
||||
@@ -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: () => (
|
||||
<div className="animate-pulse p-6 space-y-4">
|
||||
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function AllocationsPage() {
|
||||
return <AllocationsClient />;
|
||||
|
||||
@@ -70,18 +70,18 @@ type EstimateDetail = {
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<EstimateStatus, string> = {
|
||||
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<EstimateVersionStatus, string> = {
|
||||
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({
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link
|
||||
href={`/estimates/${estimate.id}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-100"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 dark:border-sky-700 bg-brand-50 dark:bg-sky-950/40 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-100 dark:hover:bg-sky-900/40"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
@@ -165,7 +165,7 @@ function EstimateDetailPanel({
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Version {latestVersion.versionNumber}
|
||||
{latestVersion.label ? ` - ${latestVersion.label}` : ""}
|
||||
</span>
|
||||
@@ -212,7 +212,7 @@ function EstimateDetailPanel({
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{latestVersion.scopeItems.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
|
||||
No scope rows captured yet.
|
||||
</p>
|
||||
) : (
|
||||
@@ -245,7 +245,7 @@ function EstimateDetailPanel({
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{latestVersion.demandLines.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
|
||||
No staffing demand captured yet.
|
||||
</p>
|
||||
) : (
|
||||
@@ -273,7 +273,7 @@ function EstimateDetailPanel({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400">
|
||||
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-6 text-sm text-gray-400">
|
||||
No versions available for this estimate yet.
|
||||
</p>
|
||||
)}
|
||||
@@ -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("_", " ")}
|
||||
</span>
|
||||
{estimate.project && (
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
|
||||
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{estimate.project.shortCode}
|
||||
</span>
|
||||
)}
|
||||
@@ -408,7 +408,7 @@ export function EstimatesClient() {
|
||||
return (
|
||||
<>
|
||||
<div className="app-page space-y-6">
|
||||
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-950 dark:via-gray-950 dark:to-brand-950/40">
|
||||
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
|
||||
|
||||
@@ -1296,7 +1296,7 @@ export function ResourcesClient() {
|
||||
{skills.slice(0, 3).map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-950/30 dark:text-brand-200"
|
||||
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-900/60 dark:text-brand-100"
|
||||
>
|
||||
{s.skill}
|
||||
</span>
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="animate-pulse p-6 space-y-4">
|
||||
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function ResourcesPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResourcesClient />
|
||||
</Suspense>
|
||||
);
|
||||
return <ResourcesClient />;
|
||||
}
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="animate-pulse flex flex-col gap-4 h-full p-6">
|
||||
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function TimelinePage() {
|
||||
return (
|
||||
|
||||
@@ -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<string, number>();
|
||||
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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string>;
|
||||
};
|
||||
|
||||
export function SystemRolesClient() {
|
||||
const [editingRole, setEditingRole] = useState<EditingRole | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(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 (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">System Role Management</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure default permissions for each system role. Changes apply to all users with that role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-gray-200 dark:bg-gray-700 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role Cards */}
|
||||
<div className="space-y-3">
|
||||
{configs.map((config) => {
|
||||
const perms = config.defaultPermissions as string[];
|
||||
const color = config.color ?? "gray";
|
||||
return (
|
||||
<div
|
||||
key={config.role}
|
||||
className={`rounded-xl border-2 p-4 transition-colors ${ROLE_COLOR_MAP[color] ?? ROLE_COLOR_MAP.gray}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${ROLE_BADGE_MAP[color] ?? ROLE_BADGE_MAP.gray}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{config.role}
|
||||
</span>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{perms.length === 0 ? (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No default permissions</span>
|
||||
) : (
|
||||
perms.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-medium bg-white/60 dark:bg-white/10 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{PERMISSION_LABELS[p] ?? p}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(config)}
|
||||
className="flex-shrink-0 ml-4 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Permission Matrix Overview */}
|
||||
{configs.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50 mb-3 flex items-center">
|
||||
Permission Matrix <InfoTooltip content="Overview of which permissions each role has by default." />
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600 dark:text-gray-400 sticky left-0 bg-gray-50 dark:bg-gray-800/50">Permission</th>
|
||||
{configs.map((c) => (
|
||||
<th key={c.role} className="px-3 py-2 text-center font-medium text-gray-600 dark:text-gray-400 min-w-[80px]">
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ALL_PERMISSION_KEYS.map((key) => (
|
||||
<tr key={key} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="px-3 py-1.5 text-gray-700 dark:text-gray-300 font-medium sticky left-0 bg-white dark:bg-gray-900">
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</td>
|
||||
{configs.map((c) => {
|
||||
const perms = c.defaultPermissions as string[];
|
||||
const has = perms.includes(key);
|
||||
return (
|
||||
<td key={c.role} className="px-3 py-1.5 text-center">
|
||||
{has ? (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full text-gray-300 dark:text-gray-600">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingRole && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Configure Role
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{editingRole.role}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingRole(null); setActionError(null); }}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
|
||||
{actionError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingRole.label}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingRole.description}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Badge Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{COLOR_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setEditingRole({ ...editingRole, color: opt.value })}
|
||||
className={`w-7 h-7 rounded-full ${opt.class} transition-all ${
|
||||
editingRole.color === opt.value
|
||||
? "ring-2 ring-offset-2 ring-brand-500 dark:ring-offset-gray-900 scale-110"
|
||||
: "opacity-60 hover:opacity-100"
|
||||
}`}
|
||||
title={opt.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Default Permissions ({editingRole.permissions.size}/{ALL_PERMISSION_KEYS.length})
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAll}
|
||||
className="text-[11px] text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectNone}
|
||||
className="text-[11px] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 font-medium"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ALL_PERMISSION_KEYS.map((key) => {
|
||||
const isActive = editingRole.permissions.has(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => togglePermission(key)}
|
||||
className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
|
||||
isActive
|
||||
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
||||
: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${
|
||||
isActive
|
||||
? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
}`}>
|
||||
{isActive && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={isActive ? "text-gray-900 dark:text-gray-100 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</span>
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 truncate">
|
||||
{PERMISSION_DESCRIPTIONS[key] ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingRole(null); setActionError(null); }}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={updateMutation.isPending || !editingRole.label.trim()}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updateMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
Timeline <InfoTooltip content="Settings for the timeline view, including undo history depth." />
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure timeline behavior and undo/redo history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<label className={LABEL_CLASS}>Undo History Depth</label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
value={undoMaxSteps}
|
||||
onChange={(e) => setUndoMaxSteps(parseInt(e.target.value, 10) || 50)}
|
||||
min={1}
|
||||
max={200}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Maximum number of undo steps for timeline operations (single moves and batch shifts). Default: 50.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveTimeline}
|
||||
disabled={saveTimelineMutation.isPending}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{saveTimelineMutation.isPending ? "Saving…" : "Save Timeline Settings"}
|
||||
</button>
|
||||
{timelineSaved && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
|
||||
@@ -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<string, string[]> = {};
|
||||
for (const c of roleConfigs) {
|
||||
map[c.role] = c.defaultPermissions as string[];
|
||||
}
|
||||
return map as Record<SystemRole, string[]>;
|
||||
}, [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 (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -289,7 +327,18 @@ export function UsersClient() {
|
||||
Manage user roles and permission overrides
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{activeData && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 px-3 py-2 text-sm">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||
</span>
|
||||
<span className="font-medium text-green-700 dark:text-green-400">
|
||||
{activeData.count} online
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void autoLinkMutation.mutateAsync().then((r) => {
|
||||
@@ -366,6 +415,8 @@ export function UsersClient() {
|
||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked." />
|
||||
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email." />
|
||||
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" />
|
||||
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">Status</th>
|
||||
<SortableColumnHeader label="Last Login" field="lastLoginAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="When the user last signed in." />
|
||||
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." />
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
@@ -373,14 +424,14 @@ export function UsersClient() {
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-gray-400">
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-gray-400">
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -403,6 +454,22 @@ export function UsersClient() {
|
||||
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{isOnline(user) ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Online
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600" />
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||
{formatRelativeTime(user.lastLoginAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
||||
</td>
|
||||
@@ -576,83 +643,108 @@ export function UsersClient() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Effective Permissions */}
|
||||
{effectivePerms && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
|
||||
Effective Permissions <InfoTooltip content="The final set of permissions after combining the role's defaults with any overrides below. Green = granted, strikethrough = denied." />
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_PERMISSION_KEYS.map((key) => {
|
||||
const isActive = effectivePerms.effectivePermissions.includes(key);
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
isActive
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
|
||||
: "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600 line-through"
|
||||
}`}
|
||||
>
|
||||
{/* Permissions */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
|
||||
Permissions <InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
|
||||
</h3>
|
||||
<div className="flex gap-1.5 mb-3 text-[11px]">
|
||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" /> Role default
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" /> Extra grant
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative"><span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">×</span></span> Denied
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{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 (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={cycleState}
|
||||
className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`}
|
||||
>
|
||||
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}>
|
||||
{state === "default" && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
|
||||
)}
|
||||
{state === "granted" && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" /></svg>
|
||||
)}
|
||||
{state === "denied" && (
|
||||
<span className="text-xs font-bold leading-none">×</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}>
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Permission Overrides */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
Permission Overrides <InfoTooltip content="Override specific permissions for this user. Grants add permissions beyond the role default; Denials remove permissions the role would normally have." />
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Additional Grants */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-green-700 dark:text-green-400 mb-2 uppercase tracking-wide">
|
||||
Additional Grants
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{ALL_PERMISSION_KEYS.map((key) => (
|
||||
<label
|
||||
key={`grant-${key}`}
|
||||
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editState.granted.has(key)}
|
||||
onChange={() => toggleGranted(key)}
|
||||
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explicit Denials */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-red-700 dark:text-red-400 mb-2 uppercase tracking-wide">
|
||||
Explicit Denials
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{ALL_PERMISSION_KEYS.map((key) => (
|
||||
<label
|
||||
key={`deny-${key}`}
|
||||
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editState.denied.has(key)}
|
||||
onChange={() => toggleDenied(key)}
|
||||
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{state === "default" && (
|
||||
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">Role</span>
|
||||
)}
|
||||
{state === "granted" && (
|
||||
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">Extra</span>
|
||||
)}
|
||||
{state === "denied" && (
|
||||
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">Denied</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Chapter Scope */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -35,7 +35,7 @@ const TABS: Array<{ id: WorkspaceTab; label: string }> = [
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -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 })
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<Link
|
||||
href="/estimates"
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-gray-800"
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 transition-colors hover:text-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
@@ -190,21 +190,21 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
Back to Estimates
|
||||
</Link>
|
||||
|
||||
<div className="rounded-[28px] border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50 p-6 shadow-sm">
|
||||
<div className="rounded-[28px] border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 p-6 shadow-sm dark:shadow-black/20">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-gray-900">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600 dark:text-sky-400">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{estimate?.name ?? "Loading estimate"}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-gray-600">
|
||||
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
|
||||
Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{estimate && (
|
||||
<div className="flex flex-col gap-3 lg:items-end">
|
||||
<div className="grid gap-2 text-sm text-gray-500 lg:text-right">
|
||||
<div className="grid gap-2 text-sm text-gray-500 dark:text-gray-400 lg:text-right">
|
||||
<span>{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}</span>
|
||||
<span>Updated {formatDateLong(estimate.updatedAt)}</span>
|
||||
</div>
|
||||
@@ -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"}
|
||||
</button>
|
||||
@@ -238,7 +238,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
{actionMessage && <ActionNotice tone="success">{actionMessage}</ActionNotice>}
|
||||
{actionError && <ActionNotice tone="error">{actionError}</ActionNotice>}
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-200">
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-200 dark:border-gray-700">
|
||||
{TABS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -247,8 +247,8 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
className={clsx(
|
||||
"rounded-t-2xl border-b-2 px-4 py-3 text-sm font-medium transition-colors",
|
||||
tab === item.id
|
||||
? "border-brand-600 text-brand-700"
|
||||
: "border-transparent text-gray-500 hover:text-gray-800",
|
||||
? "border-brand-600 text-brand-700 dark:border-sky-400 dark:text-sky-300"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -23,10 +23,10 @@ function getDefaultDateRange(): { start: string; end: string } {
|
||||
function heatColor(hours: number, maxHours: number): string {
|
||||
if (hours === 0 || maxHours === 0) return "";
|
||||
const ratio = Math.min(hours / maxHours, 1);
|
||||
if (ratio < 0.25) return "bg-blue-50";
|
||||
if (ratio < 0.5) return "bg-blue-100";
|
||||
if (ratio < 0.75) return "bg-blue-200";
|
||||
return "bg-blue-300";
|
||||
if (ratio < 0.25) return "bg-blue-50 dark:bg-blue-900/20";
|
||||
if (ratio < 0.5) return "bg-blue-100 dark:bg-blue-900/30";
|
||||
if (ratio < 0.75) return "bg-blue-200 dark:bg-blue-900/40";
|
||||
return "bg-blue-300 dark:bg-blue-900/50";
|
||||
}
|
||||
|
||||
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
|
||||
@@ -116,43 +116,43 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header / Controls */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Weekly Phasing (4Dispo)
|
||||
</h3>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={data?.hasPhasing ? effectiveStart : startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={data?.hasPhasing ? effectiveEnd : endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Pattern
|
||||
</label>
|
||||
<select
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value as PhasingPattern)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="even">Even Distribution</option>
|
||||
<option value="front_loaded">Front Loaded (60/40)</option>
|
||||
@@ -198,8 +198,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
className={clsx(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
viewMode === "by_line"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
? "bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
|
||||
)}
|
||||
>
|
||||
By Line
|
||||
@@ -210,8 +210,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
className={clsx(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
viewMode === "by_chapter"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
? "bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
|
||||
)}
|
||||
>
|
||||
By Chapter
|
||||
@@ -221,25 +221,25 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
|
||||
{/* Phasing Grid */}
|
||||
{phasingQuery.isLoading && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Loading phasing data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !data.hasPhasing && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No weekly phasing generated yet. Use the controls above to generate a
|
||||
phasing distribution.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.hasPhasing && viewMode === "by_line" && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-left font-medium text-gray-700 dark:text-gray-300 min-w-[200px]">
|
||||
Demand Line
|
||||
</th>
|
||||
{data.weeks.map((week) => {
|
||||
@@ -247,13 +247,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 dark:text-gray-400 min-w-[80px] whitespace-nowrap"
|
||||
>
|
||||
{week.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
|
||||
<th className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300 min-w-[90px]">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
@@ -267,14 +267,14 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
return (
|
||||
<tr
|
||||
key={line.id}
|
||||
className="border-b border-gray-100 hover:bg-gray-50/50"
|
||||
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50/50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
|
||||
<td className="sticky left-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
<div className="truncate max-w-[200px]" title={line.name}>
|
||||
{line.name}
|
||||
</div>
|
||||
{line.chapter && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{line.chapter}
|
||||
</div>
|
||||
)}
|
||||
@@ -286,7 +286,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-right tabular-nums",
|
||||
"px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-200",
|
||||
heatColor(hours, maxHours),
|
||||
)}
|
||||
>
|
||||
@@ -294,7 +294,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
|
||||
<td className="sticky right-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 text-right font-semibold tabular-nums text-gray-900 dark:text-gray-100">
|
||||
{lineTotal.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -302,8 +302,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
|
||||
<tr className="border-t-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
Total
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
@@ -312,13 +312,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900"
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{total > 0 ? total.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
|
||||
<td className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100">
|
||||
{Object.values(columnTotals)
|
||||
.reduce((sum, h) => sum + h, 0)
|
||||
.toFixed(1)}
|
||||
@@ -331,12 +331,12 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
)}
|
||||
|
||||
{data?.hasPhasing && viewMode === "by_chapter" && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-left font-medium text-gray-700 dark:text-gray-300 min-w-[200px]">
|
||||
Chapter
|
||||
</th>
|
||||
{data.weeks.map((week) => {
|
||||
@@ -344,13 +344,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 dark:text-gray-400 min-w-[80px] whitespace-nowrap"
|
||||
>
|
||||
{week.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
|
||||
<th className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300 min-w-[90px]">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
@@ -366,9 +366,9 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
return (
|
||||
<tr
|
||||
key={chapter}
|
||||
className="border-b border-gray-100 hover:bg-gray-50/50"
|
||||
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50/50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
|
||||
<td className="sticky left-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
{chapter}
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
@@ -378,7 +378,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-right tabular-nums",
|
||||
"px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-200",
|
||||
heatColor(hours, maxChapterHours),
|
||||
)}
|
||||
>
|
||||
@@ -386,7 +386,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
|
||||
<td className="sticky right-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 text-right font-semibold tabular-nums text-gray-900 dark:text-gray-100">
|
||||
{chapterTotal.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -394,8 +394,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
|
||||
<tr className="border-t-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
Total
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
@@ -404,13 +404,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900"
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{total > 0 ? total.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
|
||||
<td className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100">
|
||||
{Object.values(chapterColumnTotals)
|
||||
.reduce((sum, h) => sum + h, 0)
|
||||
.toFixed(1)}
|
||||
@@ -424,8 +424,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
|
||||
|
||||
{/* Info about current phasing config */}
|
||||
{data?.hasPhasing && data.config && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">Current phasing:</span>{" "}
|
||||
{data.config.pattern.replace("_", " ")} distribution from{" "}
|
||||
{data.config.startDate} to {data.config.endDate} across{" "}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -24,25 +24,25 @@ export function AssumptionsTab({ estimate }: { estimate: EstimateWorkspaceView }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
|
||||
<div className="border-b border-gray-100 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If any assumption changes, the estimate may need revision." /></h2>
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If any assumption changes, the estimate may need revision." /></h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
{assumptions.map((assumption) => (
|
||||
<div key={assumption.id} className="grid gap-3 px-6 py-4 md:grid-cols-[160px,1fr,1fr]">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Category <InfoTooltip content="Groups assumptions by topic, e.g. 'commercial', 'delivery', 'technical'." /></p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900">{assumption.category}</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{assumption.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Label <InfoTooltip content="Human-readable description of the assumption. The key below is the machine-readable identifier." /></p>
|
||||
<p className="mt-1 text-sm text-gray-800">{assumption.label}</p>
|
||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{assumption.label}</p>
|
||||
<p className="mt-1 text-xs text-gray-400">{assumption.key}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Value <InfoTooltip content="The concrete value or condition for this assumption." /></p>
|
||||
<p className="mt-1 text-sm text-gray-800">{String(assumption.value)}</p>
|
||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{String(assumption.value)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -98,11 +98,11 @@ export function ExportsTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
|
||||
</p>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@ export function ExportsTab({
|
||||
type="button"
|
||||
onClick={() => onCreateExport(latestVersion.id, format)}
|
||||
disabled={isCreatingExport}
|
||||
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isCreatingExport ? "Generating..." : `Create ${format}`}
|
||||
</button>
|
||||
@@ -124,16 +124,16 @@ export function ExportsTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
|
||||
<div className="border-b border-gray-100 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
|
||||
</div>
|
||||
{exports.length === 0 ? (
|
||||
<div className="px-6 py-8">
|
||||
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
{exports.map((estimateExport) => {
|
||||
const payload = isEstimateExportArtifactPayload(estimateExport.payload)
|
||||
? estimateExport.payload
|
||||
@@ -144,57 +144,57 @@ export function ExportsTab({
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-gray-900">{estimateExport.fileName}</p>
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{estimateExport.fileName}</p>
|
||||
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
|
||||
{estimateExport.format}
|
||||
</span>
|
||||
{payload?.sheetNames?.length ? (
|
||||
<span className="rounded-full bg-sky-50 px-2.5 py-1 text-[11px] font-semibold text-sky-700">
|
||||
<span className="rounded-full bg-sky-50 dark:bg-sky-900/30 px-2.5 py-1 text-[11px] font-semibold text-sky-700 dark:text-sky-300">
|
||||
{payload.sheetNames.length} sheets
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{formatDateLong(estimateExport.createdAt)}</span>
|
||||
{payload ? <span>{formatBytes(payload.byteLength)}</span> : null}
|
||||
{payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null}
|
||||
{payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null}
|
||||
</div>
|
||||
{payload ? (
|
||||
<div className="mt-3 grid gap-2 text-xs text-gray-600 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<div className="mt-3 grid gap-2 text-xs text-gray-600 dark:text-gray-400 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
|
||||
<p className="uppercase tracking-wide text-gray-400">Hours</p>
|
||||
<p className="mt-1 font-semibold text-gray-900">
|
||||
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
||||
{payload.summary.totalHours.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
|
||||
<p className="uppercase tracking-wide text-gray-400">Cost</p>
|
||||
<p className="mt-1 font-semibold text-gray-900">
|
||||
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formatMoney(
|
||||
payload.summary.totalCostCents,
|
||||
payload.summary.baseCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
|
||||
<p className="uppercase tracking-wide text-gray-400">Price</p>
|
||||
<p className="mt-1 font-semibold text-gray-900">
|
||||
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formatMoney(
|
||||
payload.summary.totalPriceCents,
|
||||
payload.summary.baseCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
|
||||
<p className="uppercase tracking-wide text-gray-400">Margin</p>
|
||||
<p className="mt-1 font-semibold text-gray-900">
|
||||
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
||||
{payload.summary.marginPercent.toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-amber-700">
|
||||
<p className="mt-3 text-xs text-amber-700 dark:text-amber-300">
|
||||
Legacy export record detected. Regenerate it to get downloadable serializer output.
|
||||
</p>
|
||||
)}
|
||||
@@ -204,7 +204,7 @@ export function ExportsTab({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadEstimateExport(estimateExport)}
|
||||
className="rounded-2xl border border-brand-200 bg-white px-3 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 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { formatMoney } from "~/lib/format.js";
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -77,33 +77,33 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for all demand lines. Avg shows weighted average cost per hour." /></p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for all demand lines. This is the total client-facing revenue." /></p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100. Green = positive, red = negative." /></p>
|
||||
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
|
||||
{formatMoney(marginCents, estimate.baseCurrency)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">{marginPercent.toFixed(1)}% of price</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{marginPercent.toFixed(1)}% of price</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours. Each demand line contributes its hours to this total." /></p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{totals.hours.toFixed(1)} h</p>
|
||||
<p className="mt-1 text-xs text-gray-500">{demandLines.length} demand lines</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{totals.hours.toFixed(1)} h</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{demandLines.length} demand lines</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Margin waterfall: Cost -> Margin -> Price */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-4 text-base font-semibold text-gray-900">Cost to price bridge <InfoTooltip content="Visual waterfall: internal cost + margin = client price. Bar heights are proportional." /></h3>
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<h3 className="mb-4 text-base font-semibold text-gray-900 dark:text-gray-100">Cost to price bridge <InfoTooltip content="Visual waterfall: internal cost + margin = client price. Bar heights are proportional." /></h3>
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{(() => {
|
||||
const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
|
||||
@@ -113,22 +113,22 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full rounded-t-xl bg-gray-300" style={{ height: `${costH}%` }} />
|
||||
<span className="text-xs font-medium text-gray-600">Cost</span>
|
||||
<span className="text-xs text-gray-500">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
|
||||
<div className="w-full rounded-t-xl bg-gray-300 dark:bg-gray-600" style={{ height: `${costH}%` }} />
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Cost</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={clsx("w-full rounded-t-xl", marginCents >= 0 ? "bg-emerald-400" : "bg-red-400")}
|
||||
style={{ height: `${marginH}%` }}
|
||||
/>
|
||||
<span className="text-xs font-medium text-gray-600">Margin</span>
|
||||
<span className="text-xs text-gray-500">{formatMoney(marginCents, estimate.baseCurrency)}</span>
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Margin</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(marginCents, estimate.baseCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full rounded-t-xl bg-brand-500" style={{ height: `${priceH}%` }} />
|
||||
<span className="text-xs font-medium text-gray-600">Price</span>
|
||||
<span className="text-xs text-gray-500">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Price</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -137,12 +137,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
|
||||
</div>
|
||||
|
||||
{/* Chapter breakdown */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Breakdown by chapter <InfoTooltip content="Financial aggregation by department/chapter. Chapter margin % = (price - cost) / price x 100." /></h3>
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900 dark:text-gray-100">Breakdown by chapter <InfoTooltip content="Financial aggregation by department/chapter. Chapter margin % = (price - cost) / price x 100." /></h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Lines</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||
@@ -157,31 +157,31 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
|
||||
const chapterMargin = data.priceCents - data.costCents;
|
||||
const chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0;
|
||||
return (
|
||||
<tr key={chapter} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{chapter}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-600">{data.count}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
<tr key={chapter} className="border-b border-gray-100 dark:border-gray-700/50">
|
||||
<td className="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{chapter}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-600 dark:text-gray-400">{data.count}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{data.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
|
||||
{formatMoney(chapterMargin, estimate.baseCurrency)}
|
||||
</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
|
||||
{chapterMarginPct.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="border-t-2 border-gray-300 font-semibold">
|
||||
<td className="py-2 pr-3 text-gray-900">Total</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{demandLines.length}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{totals.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
<tr className="border-t-2 border-gray-300 dark:border-gray-600 font-semibold">
|
||||
<td className="py-2 pr-3 text-gray-900 dark:text-gray-100">Total</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{demandLines.length}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{totals.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{formatMoney(totals.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
|
||||
{formatMoney(marginCents, estimate.baseCurrency)}
|
||||
</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
|
||||
{marginPercent.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
@@ -192,12 +192,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
|
||||
|
||||
{/* Monthly cost/price phasing */}
|
||||
{sortedMonths.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Monthly financial phasing <InfoTooltip content="Monthly cost and price derived from each line's hourly spread. Cost = monthly hours x line cost rate. Price = monthly hours x line sell rate." /></h3>
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900 dark:text-gray-100">Monthly financial phasing <InfoTooltip content="Monthly cost and price derived from each line's hourly spread. Cost = monthly hours x line cost rate. Price = monthly hours x line sell rate." /></h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">Month</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost</th>
|
||||
@@ -210,12 +210,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
|
||||
const data = monthlyFinancials.get(month)!;
|
||||
const mMargin = data.priceCents - data.costCents;
|
||||
return (
|
||||
<tr key={month} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{month}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
<tr key={month} className="border-b border-gray-100 dark:border-gray-700/50">
|
||||
<td className="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{month}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{data.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
|
||||
{formatMoney(mMargin, estimate.baseCurrency)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -11,18 +11,18 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { formatDateLong, formatMoney } from "~/lib/format.js";
|
||||
|
||||
const STATUS_STYLES: Record<EstimateStatus, string> = {
|
||||
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-900/30 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<EstimateVersionStatus, string> = {
|
||||
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: EstimateMetricView) {
|
||||
@@ -43,13 +43,13 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
return (
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]">
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
|
||||
{estimate.status.replace("_", " ")}
|
||||
</span>
|
||||
{estimate.project && (
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-600">
|
||||
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{estimate.project.shortCode}
|
||||
</span>
|
||||
)}
|
||||
@@ -58,43 +58,43 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." /></p>
|
||||
<p className="mt-1 text-sm text-gray-800">{estimate.opportunityId ?? "Not set"}</p>
|
||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.opportunityId ?? "Not set"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency <InfoTooltip content="The primary currency for all monetary calculations in this estimate." /></p>
|
||||
<p className="mt-1 text-sm text-gray-800">{estimate.baseCurrency}</p>
|
||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.baseCurrency}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version <InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." /></p>
|
||||
<p className="mt-1 text-sm text-gray-800">
|
||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
|
||||
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{formatDateLong(estimate.updatedAt)}</p>
|
||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{formatDateLong(estimate.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestVersion?.notes && (
|
||||
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
|
||||
<div className="mt-5 rounded-2xl border border-gray-100 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
|
||||
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p>
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">{latestVersion.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-gray-900">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p>
|
||||
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||
<div key={item.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-gray-900">{item.name}</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{item.name}</p>
|
||||
<span className="text-xs text-gray-400">{item.scopeType}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,17 +103,17 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-gray-900">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p>
|
||||
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
|
||||
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||
<div key={line.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-gray-900">{line.name}</p>
|
||||
<span className="text-xs text-gray-500">{line.hours.toFixed(1)} h</span>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{line.hours.toFixed(1)} h</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -124,44 +124,44 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-sm font-semibold text-gray-900">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p>
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{latestMetrics.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
|
||||
) : (
|
||||
latestMetrics.map((metric) => (
|
||||
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-sm font-semibold text-gray-900">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p>
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">Status</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Status</span>
|
||||
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
|
||||
{latestVersion.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">Assumptions</span>
|
||||
<span className="text-sm font-medium text-gray-900">{latestVersion.assumptions.length}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Assumptions</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.assumptions.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">Snapshots</span>
|
||||
<span className="text-sm font-medium text-gray-900">{latestVersion.resourceSnapshots.length}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Snapshots</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.resourceSnapshots.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">Exports</span>
|
||||
<span className="text-sm font-medium text-gray-900">{latestVersion.exports.length}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Exports</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.exports.length}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -25,24 +25,24 @@ export function ScopeTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Scope items define the deliverables and work packages in this estimate.</span>
|
||||
<InfoTooltip content="Each scope item represents a distinct deliverable (e.g. a shot, sequence, or asset). Scope items organize the estimate but do not directly affect cost calculations." />
|
||||
</div>
|
||||
{scopeItems.map((item) => (
|
||||
<div key={item.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div key={item.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
|
||||
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
#{item.sequenceNo}
|
||||
</span>
|
||||
<span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
|
||||
{item.scopeType}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-900">{item.name}</h3>
|
||||
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description}</p>}
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-100">{item.name}</h3>
|
||||
{item.description && <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{item.description}</p>}
|
||||
</div>
|
||||
<div className="grid gap-2 text-right text-xs text-gray-400">
|
||||
{item.frameCount != null && <span>{item.frameCount} frames</span>}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { formatMoney } from "~/lib/format.js";
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -71,11 +71,11 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={line.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div key={line.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{line.name}</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{line.name}</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{line.lineType}</span>
|
||||
{line.chapter && <span>{line.chapter}</span>}
|
||||
{line.rateSource && <span>{line.rateSource}</span>}
|
||||
@@ -84,8 +84,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
|
||||
className={clsx(
|
||||
"rounded-full px-2.5 py-1 font-medium",
|
||||
calculation.costRateMode === "resource"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-amber-50 text-amber-700",
|
||||
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
Cost {calculation.costRateMode === "resource" ? "live" : "manual"}
|
||||
@@ -94,8 +94,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
|
||||
className={clsx(
|
||||
"rounded-full px-2.5 py-1 font-medium",
|
||||
calculation.billRateMode === "resource"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-amber-50 text-amber-700",
|
||||
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
Sell {calculation.billRateMode === "resource" ? "live" : "manual"}
|
||||
@@ -103,37 +103,37 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-gray-900">{line.hours.toFixed(1)} h</p>
|
||||
<p className="mt-1 text-xs text-gray-500">{effectiveValues.currency}</p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{line.hours.toFixed(1)} h</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{effectiveValues.currency}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate <InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." /></p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.costRateCents, line.currency)}</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.costRateCents, line.currency)}</p>
|
||||
{linkedSnapshot && calculation.costRateMode === "manual" && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Live snapshot {formatMoney(linkedSnapshot.lcrCents, linkedSnapshot.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate <InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." /></p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.billRateCents, line.currency)}</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.billRateCents, line.currency)}</p>
|
||||
{linkedSnapshot && calculation.billRateMode === "manual" && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Live snapshot {formatMoney(linkedSnapshot.ucrCents, linkedSnapshot.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." /></p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." /></p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,9 +144,9 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
|
||||
{Object.entries(line.monthlySpread)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, hours]) => (
|
||||
<div key={month} className="rounded-xl bg-gray-50 px-3 py-1.5 text-xs">
|
||||
<span className="text-gray-500">{month}</span>
|
||||
<span className="ml-1.5 font-medium text-gray-900">{hours.toFixed(1)} h</span>
|
||||
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-1.5 text-xs">
|
||||
<span className="text-gray-500 dark:text-gray-400">{month}</span>
|
||||
<span className="ml-1.5 font-medium text-gray-900 dark:text-gray-100">{hours.toFixed(1)} h</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -165,17 +165,17 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
|
||||
const months = Object.keys(aggregated).sort();
|
||||
if (months.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p>
|
||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<p className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{months.map((month) => (
|
||||
<div key={month} className="rounded-xl bg-gray-50 px-3 py-2 text-sm">
|
||||
<span className="text-gray-500">{month}</span>
|
||||
<span className="ml-2 font-semibold text-gray-900">{(aggregated[month] ?? 0).toFixed(1)} h</span>
|
||||
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-2 text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">{month}</span>
|
||||
<span className="ml-2 font-semibold text-gray-900 dark:text-gray-100">{(aggregated[month] ?? 0).toFixed(1)} h</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-right text-sm font-semibold text-gray-700">
|
||||
<div className="mt-3 text-right text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Total: {Object.values(aggregated).reduce((a, b) => a + b, 0).toFixed(1)} h
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,11 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { formatDateLong, formatMoney } from "~/lib/format.js";
|
||||
|
||||
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
|
||||
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: EstimateMetricView) {
|
||||
@@ -31,7 +31,7 @@ function formatMetricValue(metric: EstimateMetricView) {
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -75,24 +75,24 @@ export function VersionsTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Versions are immutable snapshots of the estimate for comparison and audit.</span>
|
||||
<InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." />
|
||||
</div>
|
||||
{versions.map((version) => (
|
||||
<div key={version.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div key={version.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-gray-900">v{version.versionNumber}</span>
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">v{version.versionNumber}</span>
|
||||
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}>
|
||||
{version.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600">{version.label ?? "Unlabeled version"}</p>
|
||||
{version.notes && <p className="mt-2 text-sm text-gray-500">{version.notes}</p>}
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{version.label ?? "Unlabeled version"}</p>
|
||||
{version.notes && <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{version.notes}</p>}
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500">
|
||||
<div className="text-right text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Updated {formatDateLong(version.updatedAt)}</p>
|
||||
{version.lockedAt && (
|
||||
<p className="mt-1">Locked {formatDateLong(version.lockedAt)}</p>
|
||||
@@ -130,7 +130,7 @@ export function VersionsTab({
|
||||
type="button"
|
||||
onClick={() => onCreateRevision(version.id)}
|
||||
disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff}
|
||||
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isCreatingRevision ? "Creating revision..." : "Create working revision"}
|
||||
</button>
|
||||
@@ -160,7 +160,7 @@ export function VersionsTab({
|
||||
)}
|
||||
|
||||
{version.status === EstimateVersionStatus.APPROVED && !hasLinkedProject && (
|
||||
<p className="mt-3 text-sm text-amber-700">
|
||||
<p className="mt-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
Link this estimate to a project before handing approved demand into planning.
|
||||
</p>
|
||||
)}
|
||||
@@ -168,9 +168,9 @@ export function VersionsTab({
|
||||
{version.metrics.length > 0 && (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
{version.metrics.map((metric) => (
|
||||
<div key={metric.id} className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div key={metric.id} className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { PreferencesModal } from "./PreferencesModal.js";
|
||||
import { ThemeProvider } from "./ThemeProvider.js";
|
||||
import { NotificationBell } from "../notifications/NotificationBell.js";
|
||||
@@ -139,6 +139,7 @@ const adminNavEntries: AdminEntry[] = [
|
||||
},
|
||||
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <AdminIcon /> },
|
||||
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
|
||||
{ href: "/admin/system-roles", label: "System Roles", icon: <AdminIcon /> },
|
||||
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
|
||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
||||
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
|
||||
@@ -180,6 +181,15 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
|
||||
const pathname = usePathname();
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
|
||||
// Memoize active href set — avoids O(n²) on every render
|
||||
const activeHrefSet = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const href of ALL_NAV_HREFS) {
|
||||
if (isNavItemActive(pathname, href)) set.add(href);
|
||||
}
|
||||
return set;
|
||||
}, [pathname]);
|
||||
|
||||
const visibleSections = navSections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
@@ -194,13 +204,13 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
|
||||
const initial: Record<string, boolean> = {};
|
||||
for (const section of visibleSections) {
|
||||
if (section.collapsed) {
|
||||
const hasActiveRoute = section.items.some((item) => isNavItemActive(pathname, item.href));
|
||||
const hasActiveRoute = section.items.some((item) => activeHrefSet.has(item.href));
|
||||
initial[section.label] = !hasActiveRoute;
|
||||
}
|
||||
}
|
||||
for (const entry of adminNavEntries) {
|
||||
if (isSubGroup(entry) && entry.collapsed) {
|
||||
const hasActiveRoute = entry.items.some((item) => isNavItemActive(pathname, item.href));
|
||||
const hasActiveRoute = entry.items.some((item) => activeHrefSet.has(item.href));
|
||||
initial[entry.label] = !hasActiveRoute;
|
||||
}
|
||||
}
|
||||
@@ -270,7 +280,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
|
||||
isNavItemActive(pathname, item.href)
|
||||
activeHrefSet.has(item.href)
|
||||
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
|
||||
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
@@ -325,7 +335,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"group flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-all",
|
||||
isNavItemActive(pathname, item.href)
|
||||
activeHrefSet.has(item.href)
|
||||
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
|
||||
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
@@ -344,7 +354,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
|
||||
href={entry.href as Route}
|
||||
className={clsx(
|
||||
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
|
||||
isNavItemActive(pathname, entry.href)
|
||||
activeHrefSet.has(entry.href)
|
||||
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
|
||||
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,7 @@ const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] =
|
||||
|
||||
export function PreferencesModal({ onClose }: PreferencesModalProps) {
|
||||
const { prefs, setMode, setAccent } = useTheme();
|
||||
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects } = useAppPreferences();
|
||||
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects, setBlinkOverbookedDays } = useAppPreferences();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -219,6 +219,34 @@ export function PreferencesModal({ onClose }: PreferencesModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overbooked blink */}
|
||||
<label className="flex items-start gap-3 cursor-pointer mb-3">
|
||||
<div className="relative mt-0.5 flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appPrefs.blinkOverbookedDays}
|
||||
onChange={(e) => setBlinkOverbookedDays(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className={clsx(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
appPrefs.blinkOverbookedDays ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
|
||||
)} />
|
||||
<div className={clsx(
|
||||
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
|
||||
appPrefs.blinkOverbookedDays ? "translate-x-4" : "translate-x-0",
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
|
||||
Blink overbooked days
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Highlight days where a resource exceeds 8h with a pulsing animation.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<div className="relative mt-0.5 flex-shrink-0">
|
||||
<input
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { BroadcastModal } from "./BroadcastModal.js";
|
||||
import { CreateTaskModal } from "./CreateTaskModal.js";
|
||||
|
||||
function formatDate(date: string | Date): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
@@ -25,6 +26,7 @@ const TARGET_LABELS: Record<string, string> = {
|
||||
|
||||
export function BroadcastManagementClient() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||
|
||||
const { data: broadcasts = [], isLoading } = trpc.notification.listBroadcasts.useQuery(
|
||||
{ limit: 50 },
|
||||
@@ -42,16 +44,28 @@ export function BroadcastManagementClient() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Broadcast Management</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Send Broadcast
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTaskModal(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-brand-600 px-4 py-2 text-sm font-medium text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
Create Task
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Send Broadcast
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
@@ -139,6 +153,14 @@ export function BroadcastManagementClient() {
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showTaskModal && (
|
||||
<CreateTaskModal
|
||||
onClose={() => setShowTaskModal(false)}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Mode>("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<string>("all");
|
||||
const [targetValue, setTargetValue] = useState("");
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ recipientCount?: number; taskId?: string } | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) handleCloseResult();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") handleCloseResult(); }}
|
||||
>
|
||||
<div className="px-6 py-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<svg className="h-6 w-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Task Created</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{isGroup
|
||||
? `Task sent to ${result.recipientCount ?? 0} recipient${(result.recipientCount ?? 0) !== 1 ? "s" : ""}`
|
||||
: "Task has been assigned successfully"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseResult}
|
||||
className="mt-6 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Create Task</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("single")}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
mode === "single"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
Single User
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("group")}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
mode === "group"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recipient (single mode) */}
|
||||
{mode === "single" && (
|
||||
<div>
|
||||
<label htmlFor="task-user" className={labelClass}>
|
||||
Recipient <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="task-user-search"
|
||||
type="text"
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="Search by name or email..."
|
||||
/>
|
||||
{userId && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Selected: {users.find((u) => u.id === userId)?.name ?? users.find((u) => u.id === userId)?.email ?? userId}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserId("")}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{userSearch.trim() && filteredUsers.length > 0 && (
|
||||
<div className="mt-1 max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
{filteredUsers.slice(0, 20).map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUserId(u.id);
|
||||
setUserSearch("");
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${
|
||||
u.id === userId ? "bg-brand-50 dark:bg-brand-900/20" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{u.name ?? "Unnamed"}
|
||||
</span>
|
||||
<span className="ml-2 text-gray-400 dark:text-gray-500">{u.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{userSearch.trim() && filteredUsers.length === 0 && (
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">No users found.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target (group mode) */}
|
||||
{mode === "group" && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="task-target" className={labelClass}>
|
||||
Target Audience
|
||||
</label>
|
||||
<select
|
||||
id="task-target"
|
||||
value={targetType}
|
||||
onChange={(e) => { setTargetType(e.target.value); setTargetValue(""); }}
|
||||
className={inputClass}
|
||||
>
|
||||
{TARGET_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{targetType === "role" && (
|
||||
<div>
|
||||
<label htmlFor="task-role" className={labelClass}>
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="task-role"
|
||||
value={targetValue}
|
||||
onChange={(e) => setTargetValue(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Select a role...</option>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetType === "project" && (
|
||||
<div>
|
||||
<label htmlFor="task-project" className={labelClass}>
|
||||
Project ID
|
||||
</label>
|
||||
<input
|
||||
id="task-project"
|
||||
type="text"
|
||||
value={targetValue}
|
||||
onChange={(e) => setTargetValue(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="Project ID..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetType === "orgUnit" && (
|
||||
<div>
|
||||
<label htmlFor="task-orgunit" className={labelClass}>
|
||||
Org Unit ID
|
||||
</label>
|
||||
<input
|
||||
id="task-orgunit"
|
||||
type="text"
|
||||
value={targetValue}
|
||||
onChange={(e) => setTargetValue(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="Org Unit ID..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="task-title" className={labelClass}>
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="task-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
className={inputClass}
|
||||
required
|
||||
placeholder="Task title..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<label htmlFor="task-body" className={labelClass}>
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="task-body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
className={`${inputClass} resize-none`}
|
||||
placeholder="Task details..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority + Channel */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="task-priority" className={labelClass}>
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
id="task-priority"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((p) => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="task-channel" className={labelClass}>
|
||||
Channel
|
||||
</label>
|
||||
<select
|
||||
id="task-channel"
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{CHANNEL_OPTIONS.map((c) => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due Date + Time */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="task-due-date" className={labelClass}>
|
||||
Due Date (optional)
|
||||
</label>
|
||||
<DateInput
|
||||
id="task-due-date"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="task-due-time" className={labelClass}>
|
||||
Due Time
|
||||
</label>
|
||||
<input
|
||||
id="task-due-time"
|
||||
type="time"
|
||||
value={dueTime}
|
||||
onChange={(e) => setDueTime(e.target.value)}
|
||||
className={inputClass}
|
||||
disabled={!dueDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link */}
|
||||
<div>
|
||||
<label htmlFor="task-link" className={labelClass}>
|
||||
Link (optional)
|
||||
</label>
|
||||
<input
|
||||
id="task-link"
|
||||
type="text"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="/some/page or https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server error */}
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Creating..." : "Create Task"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
@@ -27,6 +28,9 @@ export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const bellRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
const { data: session, status } = useSession();
|
||||
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
|
||||
|
||||
@@ -34,13 +38,13 @@ export function NotificationBell() {
|
||||
|
||||
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -77,11 +81,32 @@ export function NotificationBell() {
|
||||
},
|
||||
});
|
||||
|
||||
// Compute dropdown position when opening
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!bellRef.current) return;
|
||||
const rect = bellRef.current.getBoundingClientRect();
|
||||
const panelHeight = 440; // approximate max height
|
||||
let top = rect.top;
|
||||
// If it would overflow the bottom, flip upward
|
||||
if (top + panelHeight > window.innerHeight) {
|
||||
top = Math.max(8, window.innerHeight - panelHeight - 8);
|
||||
}
|
||||
setDropdownPos({ top, left: rect.right + 8 });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) updatePosition();
|
||||
}, [open, updatePosition]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
ref.current && !ref.current.contains(target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -113,6 +138,7 @@ export function NotificationBell() {
|
||||
<div ref={ref} className="relative">
|
||||
{/* Bell button */}
|
||||
<button
|
||||
ref={bellRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
@@ -146,9 +172,13 @@ export function NotificationBell() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
|
||||
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||
@@ -335,7 +365,8 @@ export function NotificationBell() {
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { TaskCard } from "./TaskCard.js";
|
||||
import { ReminderModal } from "./ReminderModal.js";
|
||||
import { CreateTaskModal } from "./CreateTaskModal.js";
|
||||
|
||||
type TabKey = "all" | "notifications" | "tasks" | "reminders" | "approvals";
|
||||
|
||||
@@ -27,6 +29,8 @@ export function NotificationCenterClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialTab = (searchParams.get("tab") as TabKey) || "all";
|
||||
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
||||
const { canEdit } = usePermissions();
|
||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||
const [reminderModal, setReminderModal] = useState<{
|
||||
open: boolean;
|
||||
reminder: {
|
||||
@@ -124,6 +128,18 @@ export function NotificationCenterClient() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Notification Center</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{canEdit && activeTab === "tasks" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTaskModal(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Task
|
||||
</button>
|
||||
)}
|
||||
{activeTab === "reminders" && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -389,6 +405,14 @@ export function NotificationCenterClient() {
|
||||
onSuccess={() => setReminderModal({ open: false, reminder: null })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showTaskModal && (
|
||||
<CreateTaskModal
|
||||
onClose={() => setShowTaskModal(false)}
|
||||
onSuccess={() => setShowTaskModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ interface ReminderModalProps {
|
||||
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}`;
|
||||
}
|
||||
|
||||
function toTimeInputValue(date: Date | string | null | undefined): string {
|
||||
|
||||
@@ -25,11 +25,11 @@ const proficiencyLabel: Record<number, string> = {
|
||||
};
|
||||
|
||||
const proficiencyColor: Record<number, string> = {
|
||||
1: "bg-gray-100 text-gray-600",
|
||||
2: "bg-blue-50 text-blue-600",
|
||||
3: "bg-brand-50 text-brand-700",
|
||||
4: "bg-amber-50 text-amber-700",
|
||||
5: "bg-green-50 text-green-700",
|
||||
1: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
|
||||
2: "bg-blue-50 text-blue-600 dark:bg-blue-900/50 dark:text-blue-300",
|
||||
3: "bg-brand-50 text-brand-700 dark:bg-brand-900/50 dark:text-brand-200",
|
||||
4: "bg-amber-50 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||
5: "bg-green-50 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||
};
|
||||
|
||||
const vacationStatusColor: Record<string, string> = {
|
||||
@@ -211,10 +211,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold text-gray-900 truncate">{resource.displayName}</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">{resource.displayName}</h1>
|
||||
<span
|
||||
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
|
||||
resource.isActive ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"
|
||||
resource.isActive ? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" : "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{resource.isActive ? "Active" : "Inactive"}
|
||||
@@ -359,11 +359,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
|
||||
{/* Profile meta (area role, portfolio, last import) */}
|
||||
{(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 flex flex-wrap gap-4 text-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4 flex flex-wrap gap-4 text-sm">
|
||||
{resourceWithMeta.areaRole && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-xs">Area:</span>
|
||||
<span className="font-medium text-gray-800">{resourceWithMeta.areaRole.name}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs">Area:</span>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">{resourceWithMeta.areaRole.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{resourceWithMeta.portfolioUrl && (
|
||||
@@ -398,13 +398,13 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
|
||||
{/* Main Skills Badges */}
|
||||
{mainSkills.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Main Skills<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." /></h2>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Main Skills<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." /></h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mainSkills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full bg-amber-50 text-amber-800 border border-amber-200"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-700"
|
||||
>
|
||||
<span className="text-amber-500">★</span>
|
||||
{s.skill}
|
||||
@@ -422,8 +422,8 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
|
||||
{/* Roles */}
|
||||
{resourceRoles.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Roles<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." /></h2>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Roles<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." /></h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resourceRoles.map((rr) => (
|
||||
<span
|
||||
@@ -445,13 +445,13 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Skills<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." /></h2>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Skills<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." /></h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{s.skill}
|
||||
{s.proficiency != null && (
|
||||
@@ -464,7 +464,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
</span>
|
||||
)}
|
||||
{s.yearsExperience != null && (
|
||||
<span className="text-xs text-gray-400">{s.yearsExperience}y</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">{s.yearsExperience}y</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -8,10 +8,14 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
export function StaffingPanel() {
|
||||
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
|
||||
const [startDate, setStartDate] = useState(new Date().toISOString().split("T")[0] ?? "");
|
||||
const [endDate, setEndDate] = useState(
|
||||
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "",
|
||||
);
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => {
|
||||
const d = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
});
|
||||
const [hoursPerDay, setHoursPerDay] = useState(8);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ export function AllocationPopover({
|
||||
}, [onClose]);
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
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}`;
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface BatchAssignPopoverProps {
|
||||
resourceIds: string[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function toDateDisplay(d: Date): string {
|
||||
return d.toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function BatchAssignPopover({
|
||||
resourceIds,
|
||||
startDate,
|
||||
endDate,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: BatchAssignPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [hoursPerDay, setHoursPerDay] = useState(8);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(true);
|
||||
|
||||
const { data: projectsData } = trpc.project.list.useQuery(
|
||||
{ search, limit: 20 },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const projects = (projectsData?.projects ?? []) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
|
||||
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
onCreated();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on ESC
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
function handleAssign() {
|
||||
if (!selectedProjectId) return;
|
||||
batchMutation.mutate({
|
||||
assignments: resourceIds.map((resourceId) => ({
|
||||
resourceId,
|
||||
projectId: selectedProjectId,
|
||||
startDate,
|
||||
endDate,
|
||||
hoursPerDay,
|
||||
role: "Team Member",
|
||||
status: AllocationStatus.PROPOSED,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const canAssign =
|
||||
!!selectedProjectId && resourceIds.length > 0 && hoursPerDay > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[60] w-[360px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
Batch Assign
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Info line */}
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-0.5">
|
||||
<p>
|
||||
Assigning to{" "}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{resourceIds.length}
|
||||
</span>{" "}
|
||||
resource{resourceIds.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<p>
|
||||
{toDateDisplay(startDate)} – {toDateDisplay(endDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Project picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Project
|
||||
</label>
|
||||
{selectedProject && !dropdownOpen ? (
|
||||
<div
|
||||
className="flex items-center gap-2 border border-sky-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-sky-50 dark:bg-sky-950/30"
|
||||
onClick={() => {
|
||||
setDropdownOpen(true);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">
|
||||
{selectedProject.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
▾
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search projects\u2026"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 dark:focus:ring-sky-500"
|
||||
/>
|
||||
{dropdownOpen && projects.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedProjectId(p.id);
|
||||
setDropdownOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
|
||||
{p.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Hours / day
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
||||
className="w-24 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 dark:focus:ring-sky-500"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[4, 6, 8].map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setHoursPerDay(h)}
|
||||
className={clsx(
|
||||
"px-2 py-1 rounded text-xs font-medium border transition-colors",
|
||||
hoursPerDay === h
|
||||
? "bg-sky-600 text-white border-sky-600 dark:bg-sky-600 dark:border-sky-600"
|
||||
: "border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{batchMutation.isError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
{batchMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssign}
|
||||
disabled={!canAssign || batchMutation.isPending}
|
||||
className={clsx(
|
||||
"flex-1 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
"bg-sky-600 text-white hover:bg-sky-700 dark:bg-sky-600 dark:hover:bg-sky-700",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{batchMutation.isPending
|
||||
? "Assigning\u2026"
|
||||
: `Assign All (${resourceIds.length})`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
|
||||
interface DemandPopoverProps {
|
||||
demand: TimelineDemandEntry;
|
||||
onClose: () => void;
|
||||
onOpenPanel: (projectId: string) => void;
|
||||
onFillDemand: (demand: TimelineDemandEntry) => void;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
}
|
||||
|
||||
export function DemandPopover({
|
||||
demand,
|
||||
onClose,
|
||||
onOpenPanel,
|
||||
onFillDemand,
|
||||
anchorX,
|
||||
anchorY,
|
||||
}: DemandPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
|
||||
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
|
||||
const startDate = new Date(demand.startDate);
|
||||
const endDate = new Date(demand.endDate);
|
||||
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
|
||||
const totalHours = demand.hoursPerDay * days;
|
||||
const budgetCents = demand.dailyCostCents * days;
|
||||
|
||||
const popoverStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.min(anchorX, window.innerWidth - 320),
|
||||
top: Math.min(anchorY + 8, window.innerHeight - 340),
|
||||
zIndex: 50,
|
||||
width: 300,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={popoverStyle}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700"
|
||||
style={{ backgroundColor: `${roleColor}18` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0 border-2 border-dashed"
|
||||
style={{ borderColor: roleColor, backgroundColor: `${roleColor}33` }}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
|
||||
{roleName}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none ml-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Project */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Project:{" "}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
{demand.project.name}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-400 dark:text-gray-500">({demand.project.shortCode})</span>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 border border-dashed border-amber-300 dark:border-amber-700">
|
||||
Open Demand
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{demand.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headcount */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Requested</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{demand.requestedHeadcount} {demand.requestedHeadcount === 1 ? "person" : "people"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Unfilled</div>
|
||||
<div className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{demand.unfilledHeadcount} remaining
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Start</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{formatDateLong(startDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">End</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{formatDateLong(endDate)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Hours / day</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.hoursPerDay}h</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total hours</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">{totalHours}h ({days}d)</div>
|
||||
</div>
|
||||
|
||||
{/* Budget */}
|
||||
{budgetCents > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Daily cost</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{(demand.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total cost</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{(budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Percentage */}
|
||||
{demand.percentage > 0 && (
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Percentage</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.percentage}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
{demand.unfilledHeadcount > 0 && (
|
||||
<button
|
||||
onClick={() => { onClose(); onFillDemand(demand); }}
|
||||
className="flex-1 py-1.5 rounded-lg text-sm font-medium bg-amber-500 text-white hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
Fill Demand
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { onClose(); onOpenPanel(demand.projectId); }}
|
||||
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Open Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface FloatingActionBarProps {
|
||||
selectedAllocationCount: number;
|
||||
selectedResourceCount: number;
|
||||
onDelete: () => void;
|
||||
onAssign: () => void;
|
||||
onClear: () => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function FloatingActionBar({
|
||||
selectedAllocationCount,
|
||||
selectedResourceCount,
|
||||
onDelete,
|
||||
onAssign,
|
||||
onClear,
|
||||
isDeleting,
|
||||
}: FloatingActionBarProps) {
|
||||
const totalCount = selectedAllocationCount + selectedResourceCount;
|
||||
if (totalCount === 0) return null;
|
||||
|
||||
const label =
|
||||
selectedAllocationCount > 0 && selectedResourceCount > 0
|
||||
? `${selectedAllocationCount} allocation${selectedAllocationCount !== 1 ? "s" : ""} + ${selectedResourceCount} resource${selectedResourceCount !== 1 ? "s" : ""} selected`
|
||||
: selectedAllocationCount > 0
|
||||
? `${selectedAllocationCount} allocation${selectedAllocationCount !== 1 ? "s" : ""} selected`
|
||||
: `${selectedResourceCount} resource${selectedResourceCount !== 1 ? "s" : ""} selected`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed bottom-6 left-1/2 -translate-x-1/2 z-50",
|
||||
"flex items-center gap-3 rounded-full px-5 py-2.5",
|
||||
"bg-white dark:bg-gray-800",
|
||||
"border border-gray-200 dark:border-gray-700",
|
||||
"shadow-xl dark:shadow-black/40",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{selectedAllocationCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className={clsx(
|
||||
"text-xs font-medium px-3 py-1.5 rounded-full transition-colors",
|
||||
"bg-red-600 hover:bg-red-700 text-white",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{isDeleting ? "Deleting\u2026" : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedResourceCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAssign}
|
||||
className={clsx(
|
||||
"text-xs font-medium px-3 py-1.5 rounded-full transition-colors",
|
||||
"bg-sky-600 hover:bg-sky-700 dark:bg-sky-600 dark:hover:bg-sky-700 text-white",
|
||||
)}
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className={clsx(
|
||||
"text-xs font-medium px-2 py-1.5 transition-colors",
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"hover:text-gray-700 dark:hover:text-gray-300",
|
||||
)}
|
||||
>
|
||||
Clear{" "}
|
||||
<span className="text-gray-400 dark:text-gray-500">(ESC)</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,10 @@ interface NewAllocationPopoverProps {
|
||||
}
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
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 NewAllocationPopover({
|
||||
@@ -50,7 +53,8 @@ export function NewAllocationPopover({
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const projects = projectsData?.projects ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const projects = (projectsData?.projects ?? []) as Array<{ id: string; name: string; orderType?: string }>;
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||
?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
|
||||
|
||||
@@ -94,57 +98,50 @@ export function NewAllocationPopover({
|
||||
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
|
||||
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
|
||||
|
||||
const ORDER_COLORS: Record<string, string> = {
|
||||
CHARGEABLE: "bg-emerald-100 text-emerald-700",
|
||||
INTERNAL: "bg-blue-100 text-blue-700",
|
||||
BD: "bg-violet-100 text-violet-700",
|
||||
OVERHEAD: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
|
||||
className="bg-white border border-gray-200 rounded-xl shadow-2xl overflow-hidden"
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm font-semibold text-gray-700">Assign to Project</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Assign to Project</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Date range */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Start</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Start</label>
|
||||
<DateInput
|
||||
value={start}
|
||||
onChange={setStart}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">End</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">End</label>
|
||||
<DateInput
|
||||
value={end}
|
||||
onChange={setEnd}
|
||||
min={start}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Project</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Project</label>
|
||||
{selectedProject && !dropdownOpen ? (
|
||||
<div
|
||||
className="flex items-center gap-2 border border-brand-300 rounded-lg px-3 py-2 cursor-pointer bg-brand-50"
|
||||
className="flex items-center gap-2 border border-brand-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-brand-50 dark:bg-sky-950/30"
|
||||
onClick={() => { setDropdownOpen(true); setSearch(""); }}
|
||||
>
|
||||
<span className="text-sm text-gray-800 truncate flex-1">{selectedProject.name}</span>
|
||||
<span className="text-xs text-gray-400">▾</span>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">{selectedProject.name}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">▾</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
@@ -155,18 +152,18 @@ export function NewAllocationPopover({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
{dropdownOpen && projects.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 bg-white border border-gray-200 rounded-xl shadow-lg mt-1 max-h-44 overflow-y-auto">
|
||||
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => { setSelectedProjectId(p.id); setDropdownOpen(false); setSearch(""); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2 border-b border-gray-50 last:border-0"
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-gray-800 truncate">{p.name}</span>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -177,18 +174,18 @@ export function NewAllocationPopover({
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
|
||||
<input
|
||||
type="text"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Hours / day</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -197,7 +194,7 @@ export function NewAllocationPopover({
|
||||
step={0.5}
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
||||
className="w-24 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-24 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[4, 6, 8].map((h) => (
|
||||
@@ -209,7 +206,7 @@ export function NewAllocationPopover({
|
||||
"px-2 py-1 rounded text-xs font-medium border transition-colors",
|
||||
hoursPerDay === h
|
||||
? "bg-brand-600 text-white border-brand-600"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50",
|
||||
: "border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{h}h
|
||||
@@ -220,13 +217,13 @@ export function NewAllocationPopover({
|
||||
</div>
|
||||
|
||||
{/* Overbooking notice */}
|
||||
<p className="text-xs text-amber-600 bg-amber-50 px-3 py-2 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 rounded-lg">
|
||||
Overlapping allocations are allowed — resource may be overbooked.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
{createMutation.isError && (
|
||||
<p className="text-xs text-red-600">{createMutation.error.message}</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{createMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
@@ -243,7 +240,7 @@ export function NewAllocationPopover({
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
@@ -77,7 +77,11 @@ const STATUS_COLORS = {
|
||||
};
|
||||
|
||||
function toDateInput(d: Date | string): string {
|
||||
return new Date(d).toISOString().split("T")[0] ?? "";
|
||||
const dt = new Date(d);
|
||||
const y = dt.getFullYear();
|
||||
const m = String(dt.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(dt.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function normalizeRole(value: string | null | undefined): string {
|
||||
@@ -518,6 +522,17 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
|
||||
}
|
||||
|
||||
function PanelShell({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-[420px] bg-white border-l border-gray-200 shadow-2xl z-40 flex flex-col">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { SkillEntry } from "@planarchy/shared";
|
||||
|
||||
interface ResourceHoverCardProps {
|
||||
resourceId: string;
|
||||
anchorEl: HTMLElement;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: 0, top: 0 });
|
||||
|
||||
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
|
||||
{ id: resourceId },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
// Position relative to anchor element
|
||||
useEffect(() => {
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
setPos({
|
||||
left: rect.right + 8,
|
||||
top: Math.min(rect.top, window.innerHeight - 320),
|
||||
});
|
||||
}, [anchorEl]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose, anchorEl]);
|
||||
|
||||
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
|
||||
const mainSkills = skills.filter((s) => s.isMainSkill);
|
||||
const topSkills = skills
|
||||
.filter((s) => !s.isMainSkill && s.proficiency >= 4)
|
||||
.sort((a, b) => b.proficiency - a.proficiency)
|
||||
.slice(0, 6);
|
||||
|
||||
const popoverStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.min(pos.left, window.innerWidth - 300),
|
||||
top: pos.top,
|
||||
zIndex: 50,
|
||||
width: 280,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-resource-hover-card="true"
|
||||
style={popoverStyle}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
||||
onMouseLeave={onClose}
|
||||
>
|
||||
{isLoading || !data ? (
|
||||
<div className="p-4 text-xs text-gray-400 dark:text-gray-500">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-750">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
|
||||
{data.displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
|
||||
{data.displayName}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">{data.eid}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-2.5 text-xs">
|
||||
{/* Role & Chapter */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
||||
{data.areaRole && (
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Role</div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-200 flex items-center gap-1">
|
||||
{data.areaRole.color && (
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: data.areaRole.color }} />
|
||||
)}
|
||||
{data.areaRole.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.chapter && (
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chapter</div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-200">{data.chapter}</div>
|
||||
</div>
|
||||
)}
|
||||
{data.country && (
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Location</div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-200">{data.country.name}</div>
|
||||
</div>
|
||||
)}
|
||||
{data.managementLevel && (
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Level</div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-200">{data.managementLevel.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rates */}
|
||||
<div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-gray-50 dark:bg-gray-750">
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div>
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{(data.lcrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div>
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{(data.ucrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chg%</div>
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">{data.chargeabilityTarget}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Skills */}
|
||||
{mainSkills.length > 0 && (
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Main Skills</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mainSkills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded-md text-[11px] font-medium bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300"
|
||||
>
|
||||
{s.skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Skills */}
|
||||
{topSkills.length > 0 && (
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Top Skills</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{topSkills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[11px] bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{s.skill}
|
||||
<span className="text-[9px] text-gray-400 dark:text-gray-500">L{s.proficiency}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No skills */}
|
||||
{skills.length === 0 && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">No skills imported yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -145,6 +145,7 @@ export interface TimelineContextValue {
|
||||
// ─ Display preferences
|
||||
displayMode: TimelineDisplayMode;
|
||||
heatmapScheme: HeatmapColorScheme;
|
||||
blinkOverbookedDays: boolean;
|
||||
|
||||
// ─ Loading
|
||||
isLoading: boolean;
|
||||
@@ -287,6 +288,7 @@ export function TimelineProvider({
|
||||
const { prefs: appPrefs } = useAppPreferences();
|
||||
const displayMode = appPrefs.timelineDisplayMode;
|
||||
const heatmapScheme = appPrefs.heatmapColorScheme;
|
||||
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
|
||||
|
||||
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
|
||||
@@ -300,7 +302,7 @@ export function TimelineProvider({
|
||||
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev },
|
||||
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
|
||||
|
||||
@@ -309,7 +311,7 @@ export function TimelineProvider({
|
||||
|
||||
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
|
||||
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
|
||||
{ placeholderData: (prev) => prev },
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
|
||||
const vacationsByResource = useMemo(() => {
|
||||
@@ -593,6 +595,7 @@ export function TimelineProvider({
|
||||
today,
|
||||
displayMode,
|
||||
heatmapScheme,
|
||||
blinkOverbookedDays,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
totalAllocCount,
|
||||
@@ -618,6 +621,7 @@ export function TimelineProvider({
|
||||
today,
|
||||
displayMode,
|
||||
heatmapScheme,
|
||||
blinkOverbookedDays,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
totalAllocCount,
|
||||
|
||||
@@ -30,15 +30,15 @@ export function TimelineHeader({
|
||||
<>
|
||||
{/* Month header */}
|
||||
<div
|
||||
className="sticky top-0 z-40 flex bg-white border-b border-gray-100"
|
||||
className="sticky top-0 z-40 flex bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800"
|
||||
style={{ height: HEADER_MONTH_HEIGHT }}
|
||||
>
|
||||
<div className="flex-shrink-0 border-r border-gray-200" style={{ width: LABEL_WIDTH }} />
|
||||
<div className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700" style={{ width: LABEL_WIDTH }} />
|
||||
<div className="flex">
|
||||
{monthGroups.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs font-semibold text-gray-500 border-r border-gray-200 px-2 flex items-center bg-gray-50"
|
||||
className="text-xs font-semibold text-gray-500 dark:text-gray-400 border-r border-gray-200 dark:border-gray-700 px-2 flex items-center bg-gray-50 dark:bg-gray-800"
|
||||
style={{ width: m.colCount * CELL_WIDTH }}
|
||||
>
|
||||
{m.label}
|
||||
@@ -50,11 +50,11 @@ export function TimelineHeader({
|
||||
{/* Day header — hidden at month zoom (cells too narrow for labels) */}
|
||||
{zoom !== "month" && (
|
||||
<div
|
||||
className="sticky z-40 flex bg-gray-50 border-b border-gray-200 select-none"
|
||||
className="sticky z-40 flex bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 select-none"
|
||||
style={{ top: HEADER_MONTH_HEIGHT, height: HEADER_DAY_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-gray-200 flex items-center px-4 text-xs font-medium text-gray-400 uppercase tracking-wider"
|
||||
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
{viewMode === "resource" ? "Resource" : "Project / Resource"}
|
||||
@@ -72,10 +72,10 @@ export function TimelineHeader({
|
||||
key={i}
|
||||
className={clsx(
|
||||
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
|
||||
isToday ? "bg-brand-50 border-brand-200" :
|
||||
isSaturday ? "bg-amber-50/60 border-amber-200" :
|
||||
isSunday ? "bg-gray-100/80 border-gray-200" :
|
||||
isMonday ? "border-gray-200" : "border-gray-100",
|
||||
isToday ? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-800" :
|
||||
isSaturday ? "bg-amber-50/60 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800" :
|
||||
isSunday ? "bg-gray-100/80 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700" :
|
||||
isMonday ? "border-gray-200 dark:border-gray-700" : "border-gray-100 dark:border-gray-800",
|
||||
)}
|
||||
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
|
||||
>
|
||||
@@ -83,7 +83,7 @@ export function TimelineHeader({
|
||||
<>
|
||||
<span className={clsx(
|
||||
"font-medium leading-none",
|
||||
isToday ? "text-brand-600" : isSaturday ? "text-amber-600" : "text-gray-600",
|
||||
isToday ? "text-brand-600" : isSaturday ? "text-amber-600 dark:text-amber-400" : "text-gray-600 dark:text-gray-300",
|
||||
)}>
|
||||
{zoom === "week"
|
||||
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
|
||||
@@ -92,7 +92,7 @@ export function TimelineHeader({
|
||||
{zoom === "day" && (
|
||||
<span className={clsx(
|
||||
"text-[9px] leading-none mt-0.5",
|
||||
isSaturday ? "text-amber-400" : "text-gray-300",
|
||||
isSaturday ? "text-amber-400 dark:text-amber-500" : "text-gray-300 dark:text-gray-600",
|
||||
)}>
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][date.getDay()]}
|
||||
</span>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { heatmapColor } from "./heatmapUtils.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { TimelineTooltip } from "./TimelineTooltip.js";
|
||||
import {
|
||||
ROW_HEIGHT,
|
||||
SUB_LANE_HEIGHT,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
PROJECT_HEADER_HEIGHT,
|
||||
ORDER_TYPE_COLORS,
|
||||
} from "./timelineConstants.js";
|
||||
import type { DragState, AllocDragState, RangeState } from "~/hooks/useTimelineDrag.js";
|
||||
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
|
||||
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
@@ -42,6 +43,7 @@ interface TimelineProjectPanelProps {
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void;
|
||||
multiSelectState: MultiSelectState;
|
||||
// Layout from useTimelineLayout
|
||||
CELL_WIDTH: number;
|
||||
dates: Date[];
|
||||
@@ -185,6 +187,7 @@ export function TimelineProjectPanel({
|
||||
onOpenPanel,
|
||||
onOpenDemandClick,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
CELL_WIDTH,
|
||||
dates,
|
||||
totalCanvasWidth,
|
||||
@@ -201,6 +204,7 @@ export function TimelineProjectPanel({
|
||||
filters,
|
||||
displayMode,
|
||||
heatmapScheme,
|
||||
blinkOverbookedDays,
|
||||
activeFilterCount,
|
||||
today,
|
||||
} = useTimelineContext();
|
||||
@@ -411,7 +415,7 @@ export function TimelineProjectPanel({
|
||||
const laneCount = assignDemandLanes(row.openDemands).size > 0
|
||||
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
|
||||
: 1;
|
||||
return Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
|
||||
return Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
}
|
||||
return ROW_HEIGHT;
|
||||
},
|
||||
@@ -602,7 +606,7 @@ export function TimelineProjectPanel({
|
||||
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
|
||||
bg: "bg-gray-400",
|
||||
text: "text-white",
|
||||
light: "bg-gray-50 border-gray-200",
|
||||
light: "bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700",
|
||||
};
|
||||
const isThisProjectShifting =
|
||||
dragState.isDragging && dragState.projectId === project.id;
|
||||
@@ -620,12 +624,12 @@ export function TimelineProjectPanel({
|
||||
return (
|
||||
<div
|
||||
data-project-group="true"
|
||||
className={clsx("flex border-b border-gray-200 group/proj", colors.light)}
|
||||
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
|
||||
style={{ height: PROJECT_HEADER_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex-shrink-0 border-r border-gray-300 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
|
||||
"flex-shrink-0 border-r border-gray-300 dark:border-gray-600 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
|
||||
colors.light,
|
||||
)}
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
@@ -694,31 +698,39 @@ export function TimelineProjectPanel({
|
||||
) : row.type === "open-demand" ? (
|
||||
renderOpenDemandRow(
|
||||
row.openDemands,
|
||||
row.projectId,
|
||||
CELL_WIDTH,
|
||||
totalCanvasWidth,
|
||||
toLeft,
|
||||
toWidth,
|
||||
resourceRowGridStyle,
|
||||
onOpenDemandClick,
|
||||
onAllocMouseDown,
|
||||
onAllocTouchStart,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
allocDragState,
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
data-project-resource-row="true"
|
||||
className="flex border-b border-gray-100 hover:bg-blue-50/20 group"
|
||||
data-project-id={row.project.id}
|
||||
data-resource-id={row.resource.id}
|
||||
className="flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-gray-200 flex items-center pl-8 pr-4 gap-2 bg-white sticky left-0 z-30 group-hover:bg-blue-50"
|
||||
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center pl-8 pr-4 gap-2 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-600 flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-[10px] font-bold text-gray-600 dark:text-gray-300 flex-shrink-0">
|
||||
{row.resource.displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-gray-800 truncate">
|
||||
<div className="min-w-0" data-resource-hover-id={row.resource.id}>
|
||||
<div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
||||
{row.resource.displayName}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 truncate">{row.resource.eid}</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{row.resource.eid}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -771,6 +783,7 @@ export function TimelineProjectPanel({
|
||||
onAllocMouseDown,
|
||||
onAllocTouchStart,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
)}
|
||||
{renderVacationBlocksForProjectRow(
|
||||
vacationsByResource.get(row.resource.id) ?? [],
|
||||
@@ -781,6 +794,12 @@ export function TimelineProjectPanel({
|
||||
totalCanvasWidth,
|
||||
filters.showVacations,
|
||||
)}
|
||||
{blinkOverbookedDays &&
|
||||
renderOverbookingBlinkProject(
|
||||
allocsByResource.get(row.resource.id) ?? [],
|
||||
dates,
|
||||
CELL_WIDTH,
|
||||
)}
|
||||
{renderRangeOverlayProject(
|
||||
rangeState,
|
||||
row.resource.id,
|
||||
@@ -796,7 +815,7 @@ export function TimelineProjectPanel({
|
||||
);
|
||||
})}
|
||||
|
||||
<ProjectPanelTooltips
|
||||
<TimelineTooltip
|
||||
heatmapTooltipRef={heatmapTooltipRef}
|
||||
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
||||
vacationTooltipRef={vacationTooltipRef}
|
||||
@@ -808,111 +827,7 @@ export function TimelineProjectPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectPanelTooltips({
|
||||
heatmapTooltipRef,
|
||||
heatmapTooltipPos,
|
||||
vacationTooltipRef,
|
||||
vacationTooltipPos,
|
||||
heatmapHover,
|
||||
vacationHover,
|
||||
}: {
|
||||
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
heatmapTooltipPos: { left: number; top: number };
|
||||
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
vacationTooltipPos: { left: number; top: number };
|
||||
heatmapHover: {
|
||||
date: Date;
|
||||
totalH: number;
|
||||
pct: number;
|
||||
breakdown: {
|
||||
projectId: string;
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
hoursPerDay: number;
|
||||
responsiblePerson?: string | null;
|
||||
}[];
|
||||
} | null;
|
||||
vacationHover: {
|
||||
type: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
} | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{heatmapHover ? (
|
||||
<div
|
||||
ref={heatmapTooltipRef}
|
||||
style={{
|
||||
left: heatmapTooltipPos.left,
|
||||
top: heatmapTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
||||
<span className="text-[11px] text-gray-300">
|
||||
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{heatmapHover.breakdown.length > 0 ? (
|
||||
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.shortCode}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">
|
||||
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
||||
{entry.projectName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{entry.responsiblePerson
|
||||
? `Lead: ${entry.responsiblePerson}`
|
||||
: entry.orderType}
|
||||
</div>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||
{entry.hoursPerDay}h
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{vacationHover ? (
|
||||
<div
|
||||
ref={vacationTooltipRef}
|
||||
style={{
|
||||
left: vacationTooltipPos.left,
|
||||
top: vacationTooltipPos.top,
|
||||
backgroundColor: "rgba(120, 53, 15, 0.95)",
|
||||
}}
|
||||
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
|
||||
>
|
||||
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
|
||||
<div className="mt-1 text-[11px] text-amber-100/90">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note ? (
|
||||
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
// ProjectPanelTooltips removed — now uses shared TimelineTooltip component
|
||||
|
||||
// ─── Pure render functions ──────────────────────────────────────────────────
|
||||
|
||||
@@ -949,55 +864,97 @@ function assignDemandLanes(
|
||||
return laneMap;
|
||||
}
|
||||
|
||||
const DEMAND_LANE_HEIGHT = 30;
|
||||
const DEMAND_LANE_GAP = 2;
|
||||
|
||||
function renderOpenDemandRow(
|
||||
openDemands: TimelineDemandEntry[],
|
||||
projectId: string,
|
||||
CELL_WIDTH: number,
|
||||
totalCanvasWidth: number,
|
||||
toLeft: (d: Date) => number,
|
||||
toWidth: (s: Date, e: Date) => number,
|
||||
rowGridStyle: CSSProperties,
|
||||
onOpenDemandClick: (demand: OpenDemandAssignment) => void,
|
||||
_onOpenDemandClick: (demand: OpenDemandAssignment) => void,
|
||||
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocationContextMenu: (
|
||||
info: { allocationId: string; projectId: string },
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
allocDragState: AllocDragState,
|
||||
) {
|
||||
if (openDemands.length === 0) return null;
|
||||
|
||||
const laneMap = assignDemandLanes(openDemands);
|
||||
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
|
||||
const rowHeight = Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
|
||||
const rowHeight = Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
|
||||
style={{ minHeight: rowHeight }}
|
||||
data-project-demand-row="true"
|
||||
data-project-id={projectId}
|
||||
className="group relative isolate flex border-b border-dashed border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-slate-950 hover:bg-amber-100/80 dark:hover:bg-slate-900"
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30"
|
||||
style={{ width: LABEL_WIDTH, minHeight: rowHeight }}
|
||||
className="sticky left-0 z-30 flex flex-shrink-0 items-center gap-2 border-r border-amber-200 bg-amber-50 pl-8 pr-4 dark:border-amber-800 dark:bg-slate-950"
|
||||
style={{ width: LABEL_WIDTH, height: rowHeight }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400">
|
||||
?
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-amber-700 truncate">Open demand</div>
|
||||
<div className="text-[10px] text-amber-500 truncate">
|
||||
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
|
||||
<div className="pointer-events-none absolute inset-0 bg-amber-50 dark:bg-slate-950" />
|
||||
<div className="relative z-10 flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-[10px] font-bold text-amber-600 dark:text-amber-400 flex-shrink-0 border border-dashed border-amber-400 dark:border-amber-600">
|
||||
?
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">Open demand</div>
|
||||
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
|
||||
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: totalCanvasWidth, minHeight: rowHeight, ...rowGridStyle }}
|
||||
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
|
||||
style={{ width: totalCanvasWidth, height: rowHeight, ...rowGridStyle }}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
|
||||
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
|
||||
{openDemands.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
const allocEnd = new Date(alloc.endDate);
|
||||
const left = toLeft(allocStart);
|
||||
const width = Math.max(CELL_WIDTH, toWidth(allocStart, allocEnd));
|
||||
|
||||
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
||||
const dispStart =
|
||||
isAllocDragged && allocDragState.currentStartDate
|
||||
? allocDragState.currentStartDate
|
||||
: allocStart;
|
||||
const dispEnd =
|
||||
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
|
||||
|
||||
// Multi-drag visual offset
|
||||
const isMultiDragTarget =
|
||||
multiSelectState.isMultiDragging &&
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id);
|
||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
||||
const multiDragMode = multiSelectState.multiDragMode;
|
||||
|
||||
let left = toLeft(dispStart);
|
||||
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
// Clamp negative left (bar starts before view) to avoid extending outside canvas
|
||||
if (left < 0) {
|
||||
width += left;
|
||||
left = 0;
|
||||
}
|
||||
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||
|
||||
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
||||
left += multiDragPx;
|
||||
width = Math.max(CELL_WIDTH, width - multiDragPx);
|
||||
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
||||
width = Math.max(CELL_WIDTH, width + multiDragPx);
|
||||
}
|
||||
|
||||
const roleEntity = (
|
||||
alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
|
||||
).roleEntity;
|
||||
@@ -1006,39 +963,99 @@ function renderOpenDemandRow(
|
||||
const roleColor = roleEntity?.color ?? "#f59e0b";
|
||||
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
|
||||
const lane = laneMap.get(alloc.id) ?? 0;
|
||||
const top = 4 + lane * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP);
|
||||
const top = 8 + lane * SUB_LANE_HEIGHT;
|
||||
const blockHeight = SUB_LANE_HEIGHT - 8;
|
||||
|
||||
const HANDLE_W = width >= 48 ? 8 : 6;
|
||||
|
||||
const allocInfo: AllocMouseDownInfo = {
|
||||
mode: "move",
|
||||
allocationId: alloc.id,
|
||||
mutationAllocationId: getPlanningEntryMutationId(alloc),
|
||||
projectId: alloc.projectId,
|
||||
projectName: alloc.project.name,
|
||||
resourceId: null,
|
||||
startDate: allocStart,
|
||||
endDate: allocEnd,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alloc.id}
|
||||
className="absolute rounded-md flex items-center px-2 gap-1 overflow-hidden cursor-pointer hover:ring-2 hover:ring-amber-400 hover:ring-offset-1 z-[10]"
|
||||
className={clsx(
|
||||
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
|
||||
isAllocDragged
|
||||
? "ring-2 ring-amber-500 z-20"
|
||||
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||
)}
|
||||
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} – ${formatDateLong(allocEnd)}`}
|
||||
style={{
|
||||
left: left + 2,
|
||||
width: width - 4,
|
||||
top,
|
||||
height: DEMAND_LANE_HEIGHT,
|
||||
backgroundColor: `${roleColor}33`,
|
||||
border: `2px dashed ${roleColor}99`,
|
||||
height: blockHeight,
|
||||
backgroundColor: `${roleColor}4D`,
|
||||
border: `2px dashed ${roleColor}B3`,
|
||||
...(multiDragPx && multiDragMode === "move"
|
||||
? { transform: `translateX(${multiDragPx}px)` }
|
||||
: {}),
|
||||
}}
|
||||
onClick={() => {
|
||||
onOpenDemandClick({
|
||||
id: getPlanningEntryMutationId(alloc),
|
||||
projectId: alloc.projectId,
|
||||
roleId: (alloc as { roleId?: string | null }).roleId ?? null,
|
||||
role: (alloc as { role?: string | null }).role ?? null,
|
||||
headcount,
|
||||
startDate: allocStart,
|
||||
endDate: allocEnd,
|
||||
hoursPerDay: alloc.hoursPerDay,
|
||||
roleEntity: roleEntity ?? null,
|
||||
project: alloc.project as { id: string; name: string; shortCode: string },
|
||||
});
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
||||
{roleName}
|
||||
{headcount > 1 ? ` x${headcount}` : ""}
|
||||
</span>
|
||||
{/* Left resize handle */}
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Center — move + click */}
|
||||
<div
|
||||
className={clsx(
|
||||
"flex-1 min-w-0 flex items-center px-1 gap-1",
|
||||
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, allocInfo);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, allocInfo);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
||||
{roleName}
|
||||
{headcount > 1 ? ` x${headcount}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right resize handle */}
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1157,6 +1174,7 @@ function renderProjectDragHandles(
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
) {
|
||||
return allocs.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
@@ -1170,10 +1188,24 @@ function renderProjectDragHandles(
|
||||
const dispEnd =
|
||||
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
|
||||
|
||||
const left = toLeft(dispStart);
|
||||
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
let left = toLeft(dispStart);
|
||||
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||
|
||||
// Multi-drag visual offset
|
||||
const isMultiDragTarget =
|
||||
multiSelectState.isMultiDragging &&
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id);
|
||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
||||
const multiDragMode = multiSelectState.multiDragMode;
|
||||
|
||||
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
||||
left += multiDragPx;
|
||||
width = Math.max(CELL_WIDTH, width - multiDragPx);
|
||||
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
||||
width = Math.max(CELL_WIDTH, width + multiDragPx);
|
||||
}
|
||||
|
||||
// Always show resize handles — for narrow bars, use overlapping handles
|
||||
const HANDLE_W = width >= 48 ? 8 : 6;
|
||||
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
||||
@@ -1198,8 +1230,20 @@ function renderProjectDragHandles(
|
||||
isAllocDragged
|
||||
? "ring-2 ring-brand-400 z-20"
|
||||
: "hover:ring-1 hover:ring-brand-300/70 z-[15]",
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||
)}
|
||||
style={{ left: left + 2, width: width - 4, top: 2, bottom: 2 }}
|
||||
style={{
|
||||
left: left + 2,
|
||||
width: width - 4,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
...(multiDragPx && multiDragMode === "move"
|
||||
? { transform: `translateX(${multiDragPx}px)` }
|
||||
: {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1315,6 +1359,40 @@ function renderVacationBlocksForProjectRow(
|
||||
|
||||
// ─── Range overlay for project view ─────────────────────────────────────────
|
||||
|
||||
function renderOverbookingBlinkProject(
|
||||
allocs: TimelineAssignmentEntry[],
|
||||
dates: Date[],
|
||||
CELL_WIDTH: number,
|
||||
) {
|
||||
const REF_H = 8;
|
||||
const overbooked: number[] = [];
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const d = new Date(dates[i]!);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const t = d.getTime();
|
||||
let totalH = 0;
|
||||
for (const a of allocs) {
|
||||
const s = new Date(a.startDate);
|
||||
s.setHours(0, 0, 0, 0);
|
||||
const e = new Date(a.endDate);
|
||||
e.setHours(0, 0, 0, 0);
|
||||
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
|
||||
}
|
||||
if (totalH > REF_H) overbooked.push(i);
|
||||
}
|
||||
|
||||
if (overbooked.length === 0) return null;
|
||||
|
||||
return overbooked.map((i) => (
|
||||
<div
|
||||
key={`ob-${i}`}
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
|
||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function renderRangeOverlayProject(
|
||||
rangeState: RangeState,
|
||||
resourceId: string,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ConflictOverlay } from "./ConflictOverlay.js";
|
||||
import { computeSubLanes } from "./utils.js";
|
||||
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { TimelineTooltip } from "./TimelineTooltip.js";
|
||||
import {
|
||||
ROW_HEIGHT,
|
||||
SUB_LANE_HEIGHT,
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
AllocDragState,
|
||||
RangeState,
|
||||
ShiftPreviewData,
|
||||
MultiSelectState,
|
||||
} from "~/hooks/useTimelineDrag.js";
|
||||
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||
|
||||
@@ -45,6 +46,7 @@ interface TimelineResourcePanelProps {
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void;
|
||||
multiSelectState: MultiSelectState;
|
||||
// Layout from useTimelineLayout
|
||||
CELL_WIDTH: number;
|
||||
dates: Date[];
|
||||
@@ -86,6 +88,7 @@ export function TimelineResourcePanel({
|
||||
onRowMouseDown,
|
||||
onRowTouchStart,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
CELL_WIDTH,
|
||||
dates,
|
||||
totalCanvasWidth,
|
||||
@@ -103,6 +106,7 @@ export function TimelineResourcePanel({
|
||||
viewEnd,
|
||||
displayMode,
|
||||
heatmapScheme,
|
||||
blinkOverbookedDays,
|
||||
activeFilterCount,
|
||||
} = useTimelineContext();
|
||||
|
||||
@@ -407,7 +411,7 @@ export function TimelineResourcePanel({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex border-b border-gray-100 hover:bg-blue-50/20 group transition-colors",
|
||||
"flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group transition-colors",
|
||||
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
|
||||
)}
|
||||
style={{ height: rowHeight }}
|
||||
@@ -415,19 +419,19 @@ export function TimelineResourcePanel({
|
||||
{/* Label column */}
|
||||
<div
|
||||
className={clsx(
|
||||
"flex-shrink-0 border-r border-gray-200 flex items-center px-4 gap-2.5 bg-white sticky left-0 z-30 group-hover:bg-blue-50",
|
||||
dragState.isDragging && isContextResource && "bg-brand-50",
|
||||
"flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 gap-2.5 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800",
|
||||
dragState.isDragging && isContextResource && "bg-brand-50 dark:bg-brand-950/40",
|
||||
)}
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-xs font-bold text-brand-700 flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
|
||||
{resource.displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
<div className="min-w-0" data-resource-hover-id={resource.id}>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
||||
{resource.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
||||
{resource.chapter ?? resource.eid}
|
||||
</div>
|
||||
</div>
|
||||
@@ -467,6 +471,7 @@ export function TimelineResourcePanel({
|
||||
toLeft,
|
||||
toWidth,
|
||||
totalCanvasWidth,
|
||||
multiSelectState,
|
||||
)
|
||||
: renderAllocBlocksFromData(
|
||||
precomputed?.blockData ?? [],
|
||||
@@ -480,6 +485,7 @@ export function TimelineResourcePanel({
|
||||
onAllocMouseDown,
|
||||
onAllocTouchStart,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
)}
|
||||
{renderVacationBlocksForRow(
|
||||
vacationBlocksByResource.get(resource.id) ?? [],
|
||||
@@ -488,6 +494,8 @@ export function TimelineResourcePanel({
|
||||
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
|
||||
{displayMode === "heatmap" &&
|
||||
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
|
||||
{blinkOverbookedDays &&
|
||||
renderOverbookingBlink(allocs, dates, CELL_WIDTH)}
|
||||
{renderRangeOverlay(
|
||||
rangeState,
|
||||
resource.id,
|
||||
@@ -523,7 +531,7 @@ export function TimelineResourcePanel({
|
||||
})}
|
||||
|
||||
{/* Tooltips rendered inside the panel so they live near their data source */}
|
||||
<ResourcePanelTooltips
|
||||
<TimelineTooltip
|
||||
heatmapTooltipRef={heatmapTooltipRef}
|
||||
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
||||
vacationTooltipRef={vacationTooltipRef}
|
||||
@@ -535,113 +543,7 @@ export function TimelineResourcePanel({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tooltip sub-component (portal-free: positioned fixed) ──────────────────
|
||||
|
||||
function ResourcePanelTooltips({
|
||||
heatmapTooltipRef,
|
||||
heatmapTooltipPos,
|
||||
vacationTooltipRef,
|
||||
vacationTooltipPos,
|
||||
heatmapHover,
|
||||
vacationHover,
|
||||
}: {
|
||||
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
heatmapTooltipPos: { left: number; top: number };
|
||||
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
vacationTooltipPos: { left: number; top: number };
|
||||
heatmapHover: {
|
||||
date: Date;
|
||||
totalH: number;
|
||||
pct: number;
|
||||
breakdown: {
|
||||
projectId: string;
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
hoursPerDay: number;
|
||||
responsiblePerson?: string | null;
|
||||
}[];
|
||||
} | null;
|
||||
vacationHover: {
|
||||
type: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
} | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{heatmapHover ? (
|
||||
<div
|
||||
ref={heatmapTooltipRef}
|
||||
style={{
|
||||
left: heatmapTooltipPos.left,
|
||||
top: heatmapTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
||||
<span className="text-[11px] text-gray-300">
|
||||
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{heatmapHover.breakdown.length > 0 ? (
|
||||
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.shortCode}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">
|
||||
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
||||
{entry.projectName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{entry.responsiblePerson
|
||||
? `Lead: ${entry.responsiblePerson}`
|
||||
: entry.orderType}
|
||||
</div>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||
{entry.hoursPerDay}h
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{vacationHover ? (
|
||||
<div
|
||||
ref={vacationTooltipRef}
|
||||
style={{
|
||||
left: vacationTooltipPos.left,
|
||||
top: vacationTooltipPos.top,
|
||||
backgroundColor: "rgba(120, 53, 15, 0.95)",
|
||||
}}
|
||||
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
|
||||
>
|
||||
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
|
||||
<div className="mt-1 text-[11px] text-amber-100/90">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note ? (
|
||||
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
// ResourcePanelTooltips removed — now uses shared TimelineTooltip component
|
||||
|
||||
// ─── Helper types ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -749,6 +651,7 @@ function renderAllocBlocksFromData(
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
) {
|
||||
const anyDragActive = dragState.isDragging || allocDragState.isActive;
|
||||
|
||||
@@ -771,8 +674,23 @@ function renderAllocBlocksFromData(
|
||||
dispEnd = allocDragState.currentEndDate;
|
||||
}
|
||||
|
||||
const left = toLeft(dispStart);
|
||||
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
// Multi-drag offset: shift selected allocations visually during multi-drag
|
||||
const isMultiDragTarget =
|
||||
multiSelectState.isMultiDragging &&
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id);
|
||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
||||
const multiDragMode = multiSelectState.multiDragMode;
|
||||
|
||||
let left = toLeft(dispStart);
|
||||
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
|
||||
// For multi-drag resize, adjust left/width instead of using translateX
|
||||
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
||||
left += multiDragPx;
|
||||
width = Math.max(CELL_WIDTH, width - multiDragPx);
|
||||
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
||||
width = Math.max(CELL_WIDTH, width + multiDragPx);
|
||||
}
|
||||
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||
|
||||
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
|
||||
@@ -811,6 +729,7 @@ function renderAllocBlocksFromData(
|
||||
: isOtherDragged
|
||||
? "opacity-30 z-[10]"
|
||||
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||
)}
|
||||
style={{
|
||||
left: left + 2,
|
||||
@@ -818,6 +737,12 @@ function renderAllocBlocksFromData(
|
||||
top: blockTop,
|
||||
height: blockHeight,
|
||||
...(customColor ? { backgroundColor: customColor } : {}),
|
||||
...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Stop right-click mouseDown from bubbling to the canvas,
|
||||
// which would falsely start a multi-selection rectangle.
|
||||
if (e.button === 2) e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -965,6 +890,45 @@ function renderHeatmapOverlay(
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Overbooking blink overlay ───────────────────────────────────────────────
|
||||
|
||||
function renderOverbookingBlink(
|
||||
allocs: TimelineAssignmentEntry[],
|
||||
dates: Date[],
|
||||
CELL_WIDTH: number,
|
||||
) {
|
||||
const REF_H = 8;
|
||||
const overbooked: number[] = [];
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const d = new Date(dates[i]!);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const t = d.getTime();
|
||||
let totalH = 0;
|
||||
for (const a of allocs) {
|
||||
const s = new Date(a.startDate);
|
||||
s.setHours(0, 0, 0, 0);
|
||||
const e = new Date(a.endDate);
|
||||
e.setHours(0, 0, 0, 0);
|
||||
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
|
||||
}
|
||||
if (totalH > REF_H) overbooked.push(i);
|
||||
}
|
||||
|
||||
if (overbooked.length === 0) return null;
|
||||
|
||||
return overbooked.map((i) => (
|
||||
<div
|
||||
key={`ob-${i}`}
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
|
||||
style={{
|
||||
left: i * CELL_WIDTH,
|
||||
width: CELL_WIDTH,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
|
||||
|
||||
function renderDailyBars(
|
||||
@@ -983,6 +947,7 @@ function renderDailyBars(
|
||||
toLeft: (d: Date) => number,
|
||||
toWidth: (s: Date, e: Date) => number,
|
||||
totalCanvasWidth: number,
|
||||
multiSelectState: MultiSelectState,
|
||||
) {
|
||||
const BAR_AREA = rowHeight - 8;
|
||||
const REF_H = 8;
|
||||
@@ -1061,8 +1026,21 @@ function renderDailyBars(
|
||||
isBeingDragged
|
||||
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
|
||||
: "hover:opacity-80 z-[10]",
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||
)}
|
||||
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, height: segH, bottom }}
|
||||
style={{
|
||||
left: i * CELL_WIDTH + 2,
|
||||
width: CELL_WIDTH - 4,
|
||||
height: segH,
|
||||
bottom,
|
||||
...(multiSelectState.isMultiDragging &&
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id)
|
||||
? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` }
|
||||
: {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
|
||||
export type HeatmapHoverData = {
|
||||
date: Date;
|
||||
totalH: number;
|
||||
pct: number;
|
||||
breakdown: {
|
||||
projectId: string;
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
hoursPerDay: number;
|
||||
responsiblePerson?: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type VacationHoverData = {
|
||||
type: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
interface TimelineTooltipProps {
|
||||
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
heatmapTooltipPos: { left: number; top: number };
|
||||
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
vacationTooltipPos: { left: number; top: number };
|
||||
heatmapHover: HeatmapHoverData | null;
|
||||
vacationHover: VacationHoverData | null;
|
||||
}
|
||||
|
||||
export function TimelineTooltip({
|
||||
heatmapTooltipRef,
|
||||
heatmapTooltipPos,
|
||||
vacationTooltipRef,
|
||||
vacationTooltipPos,
|
||||
heatmapHover,
|
||||
vacationHover,
|
||||
}: TimelineTooltipProps) {
|
||||
// When both are active, render a single merged tooltip using the heatmap position
|
||||
if (heatmapHover && vacationHover) {
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
// Wire both refs to the same element so position updates work from either handler
|
||||
(heatmapTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
}}
|
||||
style={{
|
||||
left: heatmapTooltipPos.left,
|
||||
top: heatmapTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
||||
>
|
||||
{/* Date + hours header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
||||
<span className="text-[11px] text-gray-300">
|
||||
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Project breakdown */}
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{heatmapHover.breakdown.length > 0 ? (
|
||||
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.shortCode}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">
|
||||
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
||||
{entry.projectName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{entry.responsiblePerson
|
||||
? `Lead: ${entry.responsiblePerson}`
|
||||
: entry.orderType}
|
||||
</div>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||
{entry.hoursPerDay}h
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vacation section — merged below */}
|
||||
<div className="mt-2 pt-2 border-t border-amber-700/40">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
|
||||
<span className="font-semibold text-amber-300">
|
||||
{vacationHover.type.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note ? (
|
||||
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Heatmap only
|
||||
if (heatmapHover) {
|
||||
return (
|
||||
<div
|
||||
ref={heatmapTooltipRef}
|
||||
style={{
|
||||
left: heatmapTooltipPos.left,
|
||||
top: heatmapTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
||||
<span className="text-[11px] text-gray-300">
|
||||
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{heatmapHover.breakdown.length > 0 ? (
|
||||
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.shortCode}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">
|
||||
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
||||
{entry.projectName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{entry.responsiblePerson
|
||||
? `Lead: ${entry.responsiblePerson}`
|
||||
: entry.orderType}
|
||||
</div>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||
{entry.hoursPerDay}h
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vacation only
|
||||
if (vacationHover) {
|
||||
return (
|
||||
<div
|
||||
ref={vacationTooltipRef}
|
||||
style={{
|
||||
left: vacationTooltipPos.left,
|
||||
top: vacationTooltipPos.top,
|
||||
backgroundColor: "rgba(120, 53, 15, 0.95)",
|
||||
}}
|
||||
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
|
||||
>
|
||||
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
|
||||
<div className="mt-1 text-[11px] text-amber-100/90">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note ? (
|
||||
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
|
||||
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
|
||||
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
|
||||
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
|
||||
import { AllocationPopover } from "./AllocationPopover.js";
|
||||
import { DemandPopover } from "./DemandPopover.js";
|
||||
import { ResourceHoverCard } from "./ResourceHoverCard.js";
|
||||
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
||||
import { BatchAssignPopover } from "./BatchAssignPopover.js";
|
||||
import { FloatingActionBar } from "./FloatingActionBar.js";
|
||||
import { NewAllocationPopover } from "./NewAllocationPopover.js";
|
||||
import { ProjectPanel } from "./ProjectPanel.js";
|
||||
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
|
||||
@@ -31,9 +37,11 @@ import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProje
|
||||
export function TimelineView() {
|
||||
const mousePosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const { push: pushHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
|
||||
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
|
||||
const pushHistoryRef = useRef(pushHistory);
|
||||
pushHistoryRef.current = pushHistory;
|
||||
const pushBatchHistoryRef = useRef(pushBatchHistory);
|
||||
pushBatchHistoryRef.current = pushBatchHistory;
|
||||
|
||||
const [popover, setPopover] = useState<{
|
||||
allocationId: string;
|
||||
@@ -48,6 +56,10 @@ export function TimelineView() {
|
||||
suggestedProjectId: string | null;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
/** Selection coordinates to keep the overlay visible while popover is open */
|
||||
selectionResourceId: string;
|
||||
selectionStart: Date;
|
||||
selectionEnd: Date;
|
||||
} | null>(null);
|
||||
|
||||
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
|
||||
@@ -55,10 +67,22 @@ export function TimelineView() {
|
||||
// We start with 40 (day zoom default) and update via a ref.
|
||||
const cellWidthRef = useRef(40);
|
||||
|
||||
const outerUtils = trpc.useUtils();
|
||||
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
|
||||
onSuccess: () => {
|
||||
void outerUtils.timeline.getEntries.invalidate();
|
||||
void outerUtils.timeline.getEntriesView.invalidate();
|
||||
void outerUtils.timeline.getProjectContext.invalidate();
|
||||
void outerUtils.timeline.getBudgetStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
dragState,
|
||||
allocDragState,
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying,
|
||||
@@ -69,6 +93,8 @@ export function TimelineView() {
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseUp,
|
||||
onCanvasMouseLeave,
|
||||
onCanvasRightMouseDown,
|
||||
clearMultiSelect,
|
||||
onProjectBarTouchStart,
|
||||
onAllocTouchStart,
|
||||
onRowTouchStart,
|
||||
@@ -92,11 +118,33 @@ export function TimelineView() {
|
||||
suggestedProjectId: info.suggestedProjectId,
|
||||
anchorX: info.anchorX,
|
||||
anchorY: info.anchorY,
|
||||
selectionResourceId: info.resourceId,
|
||||
selectionStart: info.startDate,
|
||||
selectionEnd: info.endDate,
|
||||
});
|
||||
},
|
||||
onAllocationMoved: (snapshot) => {
|
||||
pushHistoryRef.current(snapshot);
|
||||
},
|
||||
onShiftClickAlloc: (allocationId: string) => {
|
||||
setMultiSelectState(prev => {
|
||||
const ids = new Set(prev.selectedAllocationIds);
|
||||
if (ids.has(allocationId)) {
|
||||
ids.delete(allocationId);
|
||||
} else {
|
||||
ids.add(allocationId);
|
||||
}
|
||||
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
|
||||
});
|
||||
},
|
||||
onMultiDragComplete: (daysDelta, mode) => {
|
||||
const ids = multiSelectState.selectedAllocationIds;
|
||||
if (ids.length > 0 && daysDelta !== 0) {
|
||||
pushBatchHistoryRef.current(ids, daysDelta, mode);
|
||||
batchShiftMutationOuter.mutate({ allocationIds: ids, daysDelta, mode });
|
||||
clearMultiSelect();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
|
||||
@@ -115,6 +163,10 @@ export function TimelineView() {
|
||||
dragState={dragState}
|
||||
allocDragState={allocDragState}
|
||||
rangeState={rangeState}
|
||||
multiSelectState={multiSelectState}
|
||||
setMultiSelectState={setMultiSelectState}
|
||||
onCanvasRightMouseDown={onCanvasRightMouseDown}
|
||||
clearMultiSelect={clearMultiSelect}
|
||||
shiftPreview={shiftPreview}
|
||||
isPreviewLoading={isPreviewLoading}
|
||||
isApplying={isApplying}
|
||||
@@ -154,6 +206,10 @@ function TimelineViewContent({
|
||||
dragState,
|
||||
allocDragState,
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
onCanvasRightMouseDown,
|
||||
clearMultiSelect,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying,
|
||||
@@ -186,6 +242,10 @@ function TimelineViewContent({
|
||||
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
|
||||
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
|
||||
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
|
||||
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
|
||||
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
|
||||
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
|
||||
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
|
||||
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
|
||||
isPreviewLoading: boolean;
|
||||
isApplying: boolean;
|
||||
@@ -211,6 +271,9 @@ function TimelineViewContent({
|
||||
suggestedProjectId: string | null;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
selectionResourceId: string;
|
||||
selectionStart: Date;
|
||||
selectionEnd: Date;
|
||||
} | null;
|
||||
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
|
||||
openPanelProjectId: string | null;
|
||||
@@ -224,6 +287,8 @@ function TimelineViewContent({
|
||||
const {
|
||||
resources,
|
||||
projectGroups,
|
||||
allocsByResource,
|
||||
openDemandsByProject,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
viewDays,
|
||||
@@ -248,12 +313,69 @@ function TimelineViewContent({
|
||||
const dragTooltipRef = useRef<HTMLDivElement>(null);
|
||||
const allocTooltipRef = useRef<HTMLDivElement>(null);
|
||||
const rangeHintRef = useRef<HTMLDivElement>(null);
|
||||
const multiDragTooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
|
||||
const [demandPopover, setDemandPopover] = useState<{
|
||||
demand: TimelineDemandEntry;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [showBatchAssign, setShowBatchAssign] = useState(false);
|
||||
const [resourceHover, setResourceHover] = useState<{
|
||||
resourceId: string;
|
||||
anchorEl: HTMLElement;
|
||||
} | null>(null);
|
||||
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
clearMultiSelect();
|
||||
},
|
||||
});
|
||||
|
||||
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
|
||||
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
|
||||
const hasActivePointerOverlay =
|
||||
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting;
|
||||
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
|
||||
|
||||
// ─── Keep selection overlay visible while popover is open ───────────────────
|
||||
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
|
||||
? rangeState
|
||||
: newAllocPopover
|
||||
? {
|
||||
isSelecting: true,
|
||||
resourceId: newAllocPopover.selectionResourceId,
|
||||
startDate: newAllocPopover.selectionStart,
|
||||
currentDate: newAllocPopover.selectionEnd,
|
||||
suggestedProjectId: newAllocPopover.suggestedProjectId,
|
||||
startClientX: 0,
|
||||
}
|
||||
: rangeState;
|
||||
|
||||
// ─── Auto-suggest project for resource-view range select ───────────────────
|
||||
const enrichedSuggestedProjectId = useMemo(() => {
|
||||
if (!newAllocPopover) return null;
|
||||
// Already has a suggestion (e.g. from project view)
|
||||
if (newAllocPopover.suggestedProjectId) return newAllocPopover.suggestedProjectId;
|
||||
// Resource view: find the project with the most hours in this resource's row
|
||||
const allocs = allocsByResource.get(newAllocPopover.resourceId);
|
||||
if (!allocs || allocs.length === 0) return null;
|
||||
const projectHours = new Map<string, number>();
|
||||
for (const alloc of allocs) {
|
||||
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay);
|
||||
}
|
||||
let maxPid: string | null = null;
|
||||
let maxH = 0;
|
||||
for (const [pid, h] of projectHours) {
|
||||
if (h > maxH) { maxH = h; maxPid = pid; }
|
||||
}
|
||||
return maxPid;
|
||||
}, [newAllocPopover, allocsByResource]);
|
||||
|
||||
function openAllocationPopoverAt(
|
||||
info: {
|
||||
@@ -263,6 +385,13 @@ function TimelineViewContent({
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) {
|
||||
// Check if this is a demand (not an assignment) — route to DemandPopover
|
||||
const demands = openDemandsByProject.get(info.projectId);
|
||||
const demand = demands?.find((d) => d.id === info.allocationId);
|
||||
if (demand) {
|
||||
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
||||
return;
|
||||
}
|
||||
setPopover({
|
||||
allocationId: info.allocationId,
|
||||
projectId: info.projectId,
|
||||
@@ -295,10 +424,16 @@ function TimelineViewContent({
|
||||
rangeHintRef.current.style.left = `${x + 12}px`;
|
||||
rangeHintRef.current.style.top = `${y - 28}px`;
|
||||
}
|
||||
if (multiDragTooltipRef.current) {
|
||||
multiDragTooltipRef.current.style.left = `${x + 14}px`;
|
||||
multiDragTooltipRef.current.style.top = `${y - 36}px`;
|
||||
}
|
||||
};
|
||||
el.addEventListener("mousemove", handler, { passive: true });
|
||||
return () => el.removeEventListener("mousemove", handler);
|
||||
}, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// During multi-drag, listen on document (cursor may leave canvas)
|
||||
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
|
||||
target.addEventListener("mousemove", handler as EventListener, { passive: true });
|
||||
return () => target.removeEventListener("mousemove", handler as EventListener);
|
||||
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -333,6 +468,92 @@ function TimelineViewContent({
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [undo, redo]);
|
||||
|
||||
// ─── ESC to close overlays (topmost first) ─────────────────────────────────
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) {
|
||||
e.preventDefault();
|
||||
clearMultiSelect();
|
||||
return;
|
||||
}
|
||||
if (demandPopover) {
|
||||
e.preventDefault();
|
||||
setDemandPopover(null);
|
||||
} else if (popover) {
|
||||
e.preventDefault();
|
||||
setPopover(null);
|
||||
} else if (newAllocPopover) {
|
||||
e.preventDefault();
|
||||
setNewAllocPopover(null);
|
||||
} else if (openDemandToAssign) {
|
||||
e.preventDefault();
|
||||
setOpenDemandToAssign(null);
|
||||
} else if (openPanelProjectId) {
|
||||
e.preventDefault();
|
||||
setOpenPanelProjectId(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]);
|
||||
|
||||
// ─── Resource hover card — event delegation on label columns ──────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const HOVER_DELAY = 400;
|
||||
|
||||
function onMouseOver(e: MouseEvent) {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
|
||||
if (!target) return;
|
||||
const rid = target.dataset.resourceHoverId;
|
||||
if (!rid) return;
|
||||
|
||||
// Clear any pending hide
|
||||
if (resourceHoverTimerRef.current) {
|
||||
clearTimeout(resourceHoverTimerRef.current);
|
||||
resourceHoverTimerRef.current = null;
|
||||
}
|
||||
|
||||
// If already showing this resource, skip
|
||||
if (resourceHover?.resourceId === rid) return;
|
||||
|
||||
resourceHoverTimerRef.current = setTimeout(() => {
|
||||
resourceHoverTimerRef.current = null;
|
||||
setResourceHover({ resourceId: rid, anchorEl: target });
|
||||
}, HOVER_DELAY);
|
||||
}
|
||||
|
||||
function onMouseOut(e: MouseEvent) {
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
// Don't close if moving into another resource-hover target or the hover card itself
|
||||
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
|
||||
|
||||
if (resourceHoverTimerRef.current) {
|
||||
clearTimeout(resourceHoverTimerRef.current);
|
||||
resourceHoverTimerRef.current = null;
|
||||
}
|
||||
// Small delay before hiding to allow moving into hover card
|
||||
resourceHoverTimerRef.current = setTimeout(() => {
|
||||
resourceHoverTimerRef.current = null;
|
||||
setResourceHover(null);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
canvas.addEventListener("mouseover", onMouseOver, { passive: true });
|
||||
canvas.addEventListener("mouseout", onMouseOut, { passive: true });
|
||||
return () => {
|
||||
canvas.removeEventListener("mouseover", onMouseOver);
|
||||
canvas.removeEventListener("mouseout", onMouseOut);
|
||||
if (resourceHoverTimerRef.current) {
|
||||
clearTimeout(resourceHoverTimerRef.current);
|
||||
resourceHoverTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [resourceHover?.resourceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
|
||||
function handleContainerScroll() {
|
||||
const el = scrollContainerRef.current;
|
||||
@@ -348,6 +569,126 @@ function TimelineViewContent({
|
||||
onCanvasMouseMove(e);
|
||||
};
|
||||
|
||||
// ─── Multi-select intersection computation ────────────────────────────────
|
||||
useEffect(() => {
|
||||
// Only compute when drag just ended (isSelecting false but has coordinates)
|
||||
if (multiSelectState.isSelecting) return;
|
||||
if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return;
|
||||
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return;
|
||||
|
||||
const canvasEl = canvasRef.current;
|
||||
if (!canvasEl) return;
|
||||
|
||||
// Selection rectangle in viewport coordinates (same coordinate space as
|
||||
// getBoundingClientRect). Using viewport coords directly avoids any
|
||||
// coordinate transformation errors from sticky headers or virtualizer offsets.
|
||||
const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY);
|
||||
const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY);
|
||||
const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX);
|
||||
const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX);
|
||||
|
||||
// For X-axis: convert viewport X to canvas-relative X for allocation matching.
|
||||
// Query any row element to find the actual canvas area position.
|
||||
const canvasRect = canvasEl.getBoundingClientRect();
|
||||
const canvasXOffset = canvasRect.left + LABEL_WIDTH;
|
||||
const toCanvasX = (clientX: number) => clientX - canvasXOffset;
|
||||
|
||||
const selLeftCanvas = toCanvasX(selLeft);
|
||||
const selRightCanvas = toCanvasX(selRight);
|
||||
|
||||
// Derive date range from pixel X positions
|
||||
const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
|
||||
const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
|
||||
const startDate = dates[colIndexStart] ?? today;
|
||||
const endDate = dates[colIndexEnd] ?? today;
|
||||
|
||||
// Find allocations within the rectangle by querying actual DOM positions.
|
||||
// This avoids any mismatch between computed row positions and actual rendering.
|
||||
const selectedIds: string[] = [];
|
||||
const selectedResIds: string[] = [];
|
||||
|
||||
// Query all rendered row elements (virtualizer only renders visible + overscan rows)
|
||||
const rowElements = canvasEl.querySelectorAll<HTMLElement>("[data-index]");
|
||||
|
||||
if (viewMode === "resource") {
|
||||
rowElements.forEach((rowEl) => {
|
||||
const idx = Number(rowEl.dataset.index);
|
||||
const resource = resources[idx];
|
||||
if (!resource) return;
|
||||
|
||||
const rowRect = rowEl.getBoundingClientRect();
|
||||
// Compare directly in viewport coordinates
|
||||
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
|
||||
selectedResIds.push(resource.id);
|
||||
|
||||
const allocs = allocsByResource.get(resource.id) ?? [];
|
||||
for (const alloc of allocs) {
|
||||
const allocLeft = toLeft(new Date(alloc.startDate));
|
||||
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
|
||||
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
|
||||
selectedIds.push(alloc.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (viewMode === "project") {
|
||||
// Project view: query actual resource row DOM elements by data attribute.
|
||||
// Each row carries data-project-id and data-resource-id for alloc lookup.
|
||||
const projectRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-resource-row]");
|
||||
projectRowEls.forEach((rowEl) => {
|
||||
const rowRect = rowEl.getBoundingClientRect();
|
||||
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
|
||||
|
||||
const projectId = rowEl.dataset.projectId;
|
||||
const resourceId = rowEl.dataset.resourceId;
|
||||
if (!projectId || !resourceId) return;
|
||||
|
||||
// Find matching group and row
|
||||
const group = projectGroups.find((g) => g.id === projectId);
|
||||
if (!group) return;
|
||||
const row = group.resourceRows.find((r) => r.resource.id === resourceId);
|
||||
if (!row) return;
|
||||
|
||||
for (const alloc of row.allocs) {
|
||||
const allocLeft = toLeft(new Date(alloc.startDate));
|
||||
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
|
||||
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
|
||||
selectedIds.push(alloc.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also check demand rows for open demand selection
|
||||
const demandRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-demand-row]");
|
||||
demandRowEls.forEach((rowEl) => {
|
||||
const rowRect = rowEl.getBoundingClientRect();
|
||||
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
|
||||
|
||||
const projectId = rowEl.dataset.projectId;
|
||||
if (!projectId) return;
|
||||
|
||||
const demands = openDemandsByProject.get(projectId) ?? [];
|
||||
for (const demand of demands) {
|
||||
const allocLeft = toLeft(new Date(demand.startDate));
|
||||
const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
|
||||
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
|
||||
selectedIds.push(demand.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedIds.length > 0 || selectedResIds.length > 0) {
|
||||
setMultiSelectState(prev => ({
|
||||
...prev,
|
||||
selectedAllocationIds: selectedIds,
|
||||
selectedResourceIds: selectedResIds,
|
||||
dateRange: { start: startDate, end: endDate },
|
||||
}));
|
||||
} else {
|
||||
clearMultiSelect();
|
||||
}
|
||||
}, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
|
||||
{/* Toolbar */}
|
||||
@@ -404,14 +745,21 @@ function TimelineViewContent({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={(e) => void onCanvasMouseUp(e)}
|
||||
onMouseLeave={onCanvasMouseLeave}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) {
|
||||
onCanvasRightMouseDown(e);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onTouchMove={(e) => {
|
||||
if (!hasActivePointerOverlay) return;
|
||||
onCanvasTouchMove(e);
|
||||
}}
|
||||
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
|
||||
className={clsx(
|
||||
(dragState.isDragging || allocDragState.isActive) && "cursor-grabbing select-none",
|
||||
(dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none",
|
||||
rangeState.isSelecting && "cursor-crosshair select-none",
|
||||
multiSelectState.isSelecting && "cursor-crosshair select-none",
|
||||
)}
|
||||
>
|
||||
{viewMode === "resource" && (
|
||||
@@ -419,7 +767,7 @@ function TimelineViewContent({
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
dragState={dragState}
|
||||
allocDragState={allocDragState}
|
||||
rangeState={rangeState}
|
||||
rangeState={effectiveRangeState}
|
||||
shiftPreview={shiftPreview}
|
||||
contextResourceIds={contextResourceIds}
|
||||
onAllocMouseDown={onAllocMouseDown}
|
||||
@@ -427,6 +775,7 @@ function TimelineViewContent({
|
||||
onRowMouseDown={onRowMouseDown}
|
||||
onRowTouchStart={onRowTouchStart}
|
||||
onAllocationContextMenu={openAllocationPopoverAt}
|
||||
multiSelectState={multiSelectState}
|
||||
CELL_WIDTH={CELL_WIDTH}
|
||||
dates={dates}
|
||||
totalCanvasWidth={totalCanvasWidth}
|
||||
@@ -442,7 +791,8 @@ function TimelineViewContent({
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
dragState={dragState}
|
||||
allocDragState={allocDragState}
|
||||
rangeState={rangeState}
|
||||
rangeState={effectiveRangeState}
|
||||
multiSelectState={multiSelectState}
|
||||
onProjectBarMouseDown={onProjectBarMouseDown}
|
||||
onProjectBarTouchStart={onProjectBarTouchStart}
|
||||
onAllocMouseDown={onAllocMouseDown}
|
||||
@@ -466,6 +816,19 @@ function TimelineViewContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Multi-select rectangle overlay */}
|
||||
{multiSelectState.isSelecting && (
|
||||
<div
|
||||
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
|
||||
style={{
|
||||
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
|
||||
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
|
||||
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
|
||||
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Saving indicators */}
|
||||
{(isApplying || isAllocSaving) && (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
|
||||
@@ -540,18 +903,95 @@ function TimelineViewContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allocation popover */}
|
||||
{popover && (
|
||||
<AllocationPopover
|
||||
allocationId={popover.allocationId}
|
||||
projectId={popover.projectId}
|
||||
onClose={() => setPopover(null)}
|
||||
{/* Multi-drag tooltip */}
|
||||
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
|
||||
<div
|
||||
ref={multiDragTooltipRef}
|
||||
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
|
||||
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
|
||||
>
|
||||
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""}
|
||||
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
|
||||
{multiSelectState.multiDragDaysDelta}d
|
||||
{" "}
|
||||
({multiSelectState.selectedAllocationIds.length} allocations)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allocation / Demand popover (click path) */}
|
||||
{popover && (() => {
|
||||
// Check if clicked allocation is actually a demand
|
||||
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
|
||||
if (clickedDemand) {
|
||||
return (
|
||||
<DemandPopover
|
||||
demand={clickedDemand}
|
||||
onClose={() => setPopover(null)}
|
||||
onOpenPanel={(pid) => {
|
||||
setPopover(null);
|
||||
setOpenPanelProjectId(pid);
|
||||
}}
|
||||
onFillDemand={(d) => {
|
||||
setPopover(null);
|
||||
setOpenDemandToAssign({
|
||||
id: d.id,
|
||||
projectId: d.projectId,
|
||||
roleId: d.roleId,
|
||||
role: d.role,
|
||||
headcount: d.requestedHeadcount,
|
||||
startDate: new Date(d.startDate),
|
||||
endDate: new Date(d.endDate),
|
||||
hoursPerDay: d.hoursPerDay,
|
||||
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
|
||||
...(d.project !== undefined ? { project: d.project } : {}),
|
||||
});
|
||||
}}
|
||||
anchorX={popover.x}
|
||||
anchorY={popover.y}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AllocationPopover
|
||||
allocationId={popover.allocationId}
|
||||
projectId={popover.projectId}
|
||||
onClose={() => setPopover(null)}
|
||||
onOpenPanel={(pid) => {
|
||||
setPopover(null);
|
||||
setOpenPanelProjectId(pid);
|
||||
}}
|
||||
anchorX={popover.x}
|
||||
anchorY={popover.y}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Demand popover */}
|
||||
{demandPopover && (
|
||||
<DemandPopover
|
||||
demand={demandPopover.demand}
|
||||
onClose={() => setDemandPopover(null)}
|
||||
onOpenPanel={(pid) => {
|
||||
setPopover(null);
|
||||
setDemandPopover(null);
|
||||
setOpenPanelProjectId(pid);
|
||||
}}
|
||||
anchorX={popover.x}
|
||||
anchorY={popover.y}
|
||||
onFillDemand={(d) => {
|
||||
setDemandPopover(null);
|
||||
setOpenDemandToAssign({
|
||||
id: d.id,
|
||||
projectId: d.projectId,
|
||||
roleId: d.roleId,
|
||||
role: d.role,
|
||||
headcount: d.requestedHeadcount,
|
||||
startDate: new Date(d.startDate),
|
||||
endDate: new Date(d.endDate),
|
||||
hoursPerDay: d.hoursPerDay,
|
||||
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
|
||||
...(d.project !== undefined ? { project: d.project } : {}),
|
||||
});
|
||||
}}
|
||||
anchorX={demandPopover.x}
|
||||
anchorY={demandPopover.y}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -561,7 +1001,7 @@ function TimelineViewContent({
|
||||
resourceId={newAllocPopover.resourceId}
|
||||
startDate={newAllocPopover.startDate}
|
||||
endDate={newAllocPopover.endDate}
|
||||
suggestedProjectId={newAllocPopover.suggestedProjectId}
|
||||
suggestedProjectId={enrichedSuggestedProjectId}
|
||||
anchorX={newAllocPopover.anchorX}
|
||||
anchorY={newAllocPopover.anchorY}
|
||||
onClose={() => setNewAllocPopover(null)}
|
||||
@@ -582,6 +1022,45 @@ function TimelineViewContent({
|
||||
onSuccess={() => setOpenDemandToAssign(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Multi-select floating action bar */}
|
||||
<FloatingActionBar
|
||||
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
|
||||
selectedResourceCount={multiSelectState.selectedResourceIds.length}
|
||||
onDelete={() => {
|
||||
if (multiSelectState.selectedAllocationIds.length === 0) return;
|
||||
const msg = `Delete ${multiSelectState.selectedAllocationIds.length} allocation(s)? This cannot be undone.`;
|
||||
if (window.confirm(msg)) {
|
||||
batchDeleteMutation.mutate({ ids: multiSelectState.selectedAllocationIds });
|
||||
}
|
||||
}}
|
||||
onAssign={() => setShowBatchAssign(true)}
|
||||
onClear={clearMultiSelect}
|
||||
isDeleting={batchDeleteMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Batch assign popover */}
|
||||
{showBatchAssign && multiSelectState.dateRange && (
|
||||
<BatchAssignPopover
|
||||
resourceIds={multiSelectState.selectedResourceIds}
|
||||
startDate={multiSelectState.dateRange.start}
|
||||
endDate={multiSelectState.dateRange.end}
|
||||
onClose={() => setShowBatchAssign(false)}
|
||||
onCreated={() => {
|
||||
setShowBatchAssign(false);
|
||||
clearMultiSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Resource hover card */}
|
||||
{resourceHover && (
|
||||
<ResourceHoverCard
|
||||
resourceId={resourceHover.resourceId}
|
||||
anchorEl={resourceHover.anchorEl}
|
||||
onClose={() => setResourceHover(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ interface VacationModalProps {
|
||||
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 VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
||||
|
||||
@@ -5,16 +5,27 @@ import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
|
||||
|
||||
export type { AllocationMovedSnapshot };
|
||||
|
||||
const MAX_HISTORY = 20;
|
||||
/** A single allocation move or a batch shift of multiple allocations */
|
||||
export type HistoryEntry =
|
||||
| { type: "single"; snapshot: AllocationMovedSnapshot }
|
||||
| { type: "batch"; allocationIds: string[]; daysDelta: number; mode: "move" | "resize-start" | "resize-end" };
|
||||
|
||||
const DEFAULT_MAX_HISTORY = 50;
|
||||
|
||||
export function useAllocationHistory() {
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const past = useRef<AllocationMovedSnapshot[]>([]);
|
||||
const future = useRef<AllocationMovedSnapshot[]>([]);
|
||||
const past = useRef<HistoryEntry[]>([]);
|
||||
const future = useRef<HistoryEntry[]>([]);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Configurable max steps from system settings
|
||||
const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY;
|
||||
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
@@ -24,12 +35,35 @@ export function useAllocationHistory() {
|
||||
},
|
||||
});
|
||||
|
||||
const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const push = useCallback((snapshot: AllocationMovedSnapshot) => {
|
||||
past.current = [...past.current.slice(-MAX_HISTORY + 1), snapshot];
|
||||
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "single", snapshot }];
|
||||
future.current = [];
|
||||
setCanUndo(true);
|
||||
setCanRedo(false);
|
||||
}, []);
|
||||
}, [maxHistory]);
|
||||
|
||||
const pushBatch = useCallback((allocationIds: string[], daysDelta: number, mode: "move" | "resize-start" | "resize-end" = "move") => {
|
||||
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "batch", allocationIds, daysDelta, mode }];
|
||||
future.current = [];
|
||||
setCanUndo(true);
|
||||
setCanRedo(false);
|
||||
}, [maxHistory]);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
}, [utils]);
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
const last = past.current[past.current.length - 1];
|
||||
@@ -38,12 +72,25 @@ export function useAllocationHistory() {
|
||||
future.current = [last, ...future.current];
|
||||
setCanUndo(past.current.length > 0);
|
||||
setCanRedo(true);
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: last.mutationAllocationId,
|
||||
startDate: last.before.startDate,
|
||||
endDate: last.before.endDate,
|
||||
});
|
||||
}, [updateMutation]);
|
||||
|
||||
if (last.type === "single") {
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: last.snapshot.mutationAllocationId,
|
||||
startDate: last.snapshot.before.startDate,
|
||||
endDate: last.snapshot.before.endDate,
|
||||
});
|
||||
} else {
|
||||
// Batch: reverse the shift (for resize modes, reverse means shifting the same edge back)
|
||||
const reverseMode = last.mode === "resize-start" ? "resize-start"
|
||||
: last.mode === "resize-end" ? "resize-end"
|
||||
: "move";
|
||||
await batchShiftMutation.mutateAsync({
|
||||
allocationIds: last.allocationIds,
|
||||
daysDelta: -last.daysDelta,
|
||||
mode: reverseMode,
|
||||
});
|
||||
}
|
||||
}, [updateMutation, batchShiftMutation]);
|
||||
|
||||
const redo = useCallback(async () => {
|
||||
const next = future.current[0];
|
||||
@@ -52,12 +99,22 @@ export function useAllocationHistory() {
|
||||
past.current = [...past.current, next];
|
||||
setCanUndo(true);
|
||||
setCanRedo(future.current.length > 0);
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: next.mutationAllocationId,
|
||||
startDate: next.after.startDate,
|
||||
endDate: next.after.endDate,
|
||||
});
|
||||
}, [updateMutation]);
|
||||
|
||||
return { push, undo, redo, canUndo, canRedo };
|
||||
if (next.type === "single") {
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: next.snapshot.mutationAllocationId,
|
||||
startDate: next.snapshot.after.startDate,
|
||||
endDate: next.snapshot.after.endDate,
|
||||
});
|
||||
} else {
|
||||
// Batch: re-apply the shift
|
||||
await batchShiftMutation.mutateAsync({
|
||||
allocationIds: next.allocationIds,
|
||||
daysDelta: next.daysDelta,
|
||||
mode: next.mode,
|
||||
});
|
||||
}
|
||||
}, [updateMutation, batchShiftMutation]);
|
||||
|
||||
return { push, pushBatch, undo, redo, canUndo, canRedo };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface AppPreferences {
|
||||
heatmapColorScheme: HeatmapColorScheme;
|
||||
/** Show open demand / placeholder entries by default when loading the timeline. Default: true. */
|
||||
showDemandProjects: boolean;
|
||||
/** Blink overbooked days (>8h) as a warning on the timeline. Default: false. */
|
||||
blinkOverbookedDays: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "planarchy_prefs";
|
||||
@@ -28,6 +30,7 @@ const DEFAULT: AppPreferences = {
|
||||
timelineDisplayMode: "strip",
|
||||
heatmapColorScheme: "green-red",
|
||||
showDemandProjects: true,
|
||||
blinkOverbookedDays: false,
|
||||
};
|
||||
|
||||
export function readAppPreferences(): AppPreferences {
|
||||
@@ -94,5 +97,13 @@ export function useAppPreferences() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects };
|
||||
const setBlinkOverbookedDays = useCallback((value: boolean) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, blinkOverbookedDays: value };
|
||||
saveAppPreferences(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects, setBlinkOverbookedDays };
|
||||
}
|
||||
|
||||
@@ -118,6 +118,39 @@ const INITIAL_RANGE_STATE: RangeState = {
|
||||
startClientX: 0,
|
||||
};
|
||||
|
||||
// ─── Multi-select state ────────────────────────────────────────────────────
|
||||
|
||||
export interface MultiSelectState {
|
||||
isSelecting: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
selectedAllocationIds: string[];
|
||||
selectedResourceIds: string[];
|
||||
dateRange: { start: Date; end: Date } | null;
|
||||
/** When multi-dragging, the number of days all selected allocations are shifted */
|
||||
multiDragDaysDelta: number;
|
||||
/** Whether a multi-drag is currently in progress */
|
||||
isMultiDragging: boolean;
|
||||
/** The drag mode during multi-drag (move, resize-start, resize-end) */
|
||||
multiDragMode: AllocDragMode;
|
||||
}
|
||||
|
||||
const INITIAL_MULTI_SELECT: MultiSelectState = {
|
||||
isSelecting: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
selectedAllocationIds: [],
|
||||
selectedResourceIds: [],
|
||||
dateRange: null,
|
||||
multiDragDaysDelta: 0,
|
||||
isMultiDragging: false,
|
||||
multiDragMode: "move",
|
||||
};
|
||||
|
||||
// ─── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AllocationMovedSnapshot {
|
||||
@@ -134,20 +167,28 @@ export function useTimelineDrag({
|
||||
onBlockClick,
|
||||
onRangeSelected,
|
||||
onAllocationMoved,
|
||||
onShiftClickAlloc,
|
||||
onMultiDragComplete,
|
||||
}: {
|
||||
cellWidth: number;
|
||||
onShiftApplied?: (projectId: string) => void;
|
||||
onBlockClick?: (info: BlockClickInfo) => void;
|
||||
onRangeSelected?: (info: RangeSelectedInfo) => void;
|
||||
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
|
||||
onShiftClickAlloc?: (allocationId: string) => void;
|
||||
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode) => void;
|
||||
}) {
|
||||
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
|
||||
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
|
||||
const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
|
||||
const [multiSelectState, setMultiSelectState] = useState<MultiSelectState>(INITIAL_MULTI_SELECT);
|
||||
|
||||
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
|
||||
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
|
||||
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
|
||||
const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
|
||||
// Keep ref in sync with state so document-level handlers read the latest selection
|
||||
multiSelectRef.current = multiSelectState;
|
||||
|
||||
// Keep always-current refs for values used inside document event handlers
|
||||
const cellWidthRef = useRef(cellWidth);
|
||||
@@ -166,6 +207,12 @@ export function useTimelineDrag({
|
||||
const onAllocationMovedRef = useRef(onAllocationMoved);
|
||||
onAllocationMovedRef.current = onAllocationMoved;
|
||||
|
||||
const onShiftClickAllocRef = useRef(onShiftClickAlloc);
|
||||
onShiftClickAllocRef.current = onShiftClickAlloc;
|
||||
|
||||
const onMultiDragCompleteRef = useRef(onMultiDragComplete);
|
||||
onMultiDragCompleteRef.current = onMultiDragComplete;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Project-shift preview
|
||||
@@ -312,6 +359,54 @@ export function useTimelineDrag({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const wasShift = e.shiftKey;
|
||||
|
||||
// Check if this allocation is part of a multi-selection → multi-drag mode
|
||||
const ms = multiSelectRef.current;
|
||||
const isMultiSelected =
|
||||
ms.selectedAllocationIds.length > 1 &&
|
||||
ms.selectedAllocationIds.includes(opts.allocationId);
|
||||
|
||||
if (isMultiSelected) {
|
||||
// ── Multi-drag: move/resize all selected allocations together ──
|
||||
const startMouseX = e.clientX;
|
||||
let currentDaysDelta = 0;
|
||||
const dragMode = opts.mode;
|
||||
|
||||
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
|
||||
multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };
|
||||
|
||||
function handleMultiMove(ev: MouseEvent) {
|
||||
const deltaX = ev.clientX - startMouseX;
|
||||
const daysDelta = Math.round(deltaX / cellWidthRef.current);
|
||||
if (daysDelta === currentDaysDelta) return;
|
||||
currentDaysDelta = daysDelta;
|
||||
|
||||
setMultiSelectState((prev) => ({ ...prev, multiDragDaysDelta: daysDelta }));
|
||||
multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
|
||||
}
|
||||
|
||||
function handleMultiUp() {
|
||||
document.removeEventListener("mousemove", handleMultiMove);
|
||||
document.removeEventListener("mouseup", handleMultiUp);
|
||||
|
||||
const finalDelta = currentDaysDelta;
|
||||
|
||||
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
|
||||
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
|
||||
|
||||
if (finalDelta !== 0) {
|
||||
onMultiDragCompleteRef.current?.(finalDelta, dragMode);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMultiMove);
|
||||
document.addEventListener("mouseup", handleMultiUp);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Single allocation drag ────────────────────────────────────────────
|
||||
|
||||
const initial: AllocDragState = {
|
||||
isActive: true,
|
||||
mode: opts.mode,
|
||||
@@ -375,14 +470,20 @@ export function useTimelineDrag({
|
||||
if (!alloc.isActive) return;
|
||||
|
||||
if (alloc.daysDelta === 0 && alloc.allocationId) {
|
||||
// No movement → treat as click, open alloc popover
|
||||
onBlockClickRef.current?.({
|
||||
allocationId: alloc.allocationId,
|
||||
projectId: alloc.projectId ?? "",
|
||||
projectName: alloc.projectName ?? "",
|
||||
startDate: alloc.originalStartDate!,
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
// No movement → treat as click
|
||||
if (wasShift) {
|
||||
// Shift+Click → toggle multi-selection for this allocation
|
||||
onShiftClickAllocRef.current?.(alloc.allocationId);
|
||||
} else {
|
||||
// Normal click → open alloc popover
|
||||
onBlockClickRef.current?.({
|
||||
allocationId: alloc.allocationId,
|
||||
projectId: alloc.projectId ?? "",
|
||||
projectName: alloc.projectName ?? "",
|
||||
startDate: alloc.originalStartDate!,
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
}
|
||||
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
|
||||
pendingSnapshotRef.current = {
|
||||
allocationId: alloc.allocationId,
|
||||
@@ -550,6 +651,81 @@ export function useTimelineDrag({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Multi-select (right-click drag) ─────────────────────────────────────────
|
||||
|
||||
const onCanvasRightMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 2) return;
|
||||
e.preventDefault();
|
||||
|
||||
const initial: MultiSelectState = {
|
||||
isSelecting: true,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
currentX: e.clientX,
|
||||
currentY: e.clientY,
|
||||
selectedAllocationIds: [],
|
||||
selectedResourceIds: [],
|
||||
dateRange: null,
|
||||
multiDragDaysDelta: 0,
|
||||
isMultiDragging: false,
|
||||
multiDragMode: "move",
|
||||
};
|
||||
multiSelectRef.current = initial;
|
||||
setMultiSelectState(initial);
|
||||
|
||||
function handleMove(ev: MouseEvent) {
|
||||
const ms = multiSelectRef.current;
|
||||
if (!ms.isSelecting) return;
|
||||
|
||||
const updated: MultiSelectState = {
|
||||
...ms,
|
||||
currentX: ev.clientX,
|
||||
currentY: ev.clientY,
|
||||
};
|
||||
multiSelectRef.current = updated;
|
||||
setMultiSelectState(updated);
|
||||
}
|
||||
|
||||
function handleUp(ev: MouseEvent) {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
|
||||
const ms = multiSelectRef.current;
|
||||
if (!ms.isSelecting) return;
|
||||
|
||||
const distance = Math.hypot(ev.clientX - ms.startX, ev.clientY - ms.startY);
|
||||
|
||||
if (distance < 5) {
|
||||
// Minimal movement → not a drag selection, reset.
|
||||
// Let existing onContextMenu handlers on allocation blocks handle right-click.
|
||||
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
||||
setMultiSelectState(INITIAL_MULTI_SELECT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the rectangle coordinates for the parent to compute intersection.
|
||||
// isSelecting is set to false to indicate the drag is done, but the
|
||||
// rectangle data (startX/Y, currentX/Y) is preserved so TimelineView
|
||||
// can resolve which allocations/resources fall within the selection.
|
||||
const finished: MultiSelectState = {
|
||||
...ms,
|
||||
isSelecting: false,
|
||||
currentX: ev.clientX,
|
||||
currentY: ev.clientY,
|
||||
};
|
||||
multiSelectRef.current = finished;
|
||||
setMultiSelectState(finished);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
}, []);
|
||||
|
||||
const clearMultiSelect = useCallback(() => {
|
||||
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
||||
setMultiSelectState(INITIAL_MULTI_SELECT);
|
||||
}, []);
|
||||
|
||||
// ── Touch support ───────────────────────────────────────────────────────────
|
||||
|
||||
// Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback)
|
||||
@@ -682,6 +858,8 @@ export function useTimelineDrag({
|
||||
dragState,
|
||||
allocDragState,
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying: applyShiftMutation.isPending,
|
||||
@@ -693,6 +871,8 @@ export function useTimelineDrag({
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseUp,
|
||||
onCanvasMouseLeave,
|
||||
onCanvasRightMouseDown,
|
||||
clearMultiSelect,
|
||||
// Touch equivalents
|
||||
onProjectBarTouchStart,
|
||||
onAllocTouchStart,
|
||||
|
||||
@@ -44,10 +44,10 @@ export function useTimelineLayout(
|
||||
key={i}
|
||||
className={clsx(
|
||||
"absolute top-0 bottom-0 border-r",
|
||||
isToday ? "border-brand-300 border-r-2" :
|
||||
isSaturday ? "border-amber-200 bg-amber-50/40" :
|
||||
isSunday ? "border-gray-200 bg-gray-100/60" :
|
||||
"border-gray-100",
|
||||
isToday ? "border-brand-300 dark:border-brand-700 border-r-2" :
|
||||
isSaturday ? "border-amber-200 dark:border-amber-800 bg-amber-50/40 dark:bg-amber-950/20" :
|
||||
isSunday ? "border-gray-200 dark:border-gray-700 bg-gray-100/60 dark:bg-gray-800/40" :
|
||||
"border-gray-100 dark:border-gray-800",
|
||||
)}
|
||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
staleTime: 60 * 1000, // 60 seconds — reduces refetches on navigation
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,6 +28,12 @@ const authConfig = {
|
||||
const isValid = await verify(user.passwordHash, password);
|
||||
if (!isValid) return null;
|
||||
|
||||
// Track last login time
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createTRPCContext } from "@planarchy/api";
|
||||
import { createTRPCContext, loadRoleDefaults } from "@planarchy/api";
|
||||
import { appRouter } from "@planarchy/api/router";
|
||||
import { createCallerFactory } from "@planarchy/api/trpc";
|
||||
import { prisma } from "@planarchy/db";
|
||||
@@ -18,7 +18,8 @@ export async function createCaller() {
|
||||
})
|
||||
: null;
|
||||
|
||||
const ctx = createTRPCContext({ session, dbUser });
|
||||
const roleDefaults = await loadRoleDefaults();
|
||||
const ctx = createTRPCContext({ session, dbUser, roleDefaults });
|
||||
const callerFactory = createCallerFactory(appRouter);
|
||||
return callerFactory(ctx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
# Performance Optimization Review
|
||||
|
||||
Date: 2026-03-18
|
||||
|
||||
Scope: analysis only. No runtime behavior was changed in this pass.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The biggest performance costs in Planarchy currently come from three patterns:
|
||||
|
||||
1. Broad data fetches followed by repeated in-memory filtering/grouping.
|
||||
2. Expensive client-side derivations on screens with large datasets, especially Timeline.
|
||||
3. Broad cache invalidation that forces heavy queries to refetch and recompute after small mutations.
|
||||
|
||||
The highest-value optimization target is the Timeline stack. After that, the best wins are server-side aggregation for chargeability/dashboard workloads and narrowing resource/report queries to visible scope.
|
||||
|
||||
## Main Hotspots
|
||||
|
||||
### 1. Timeline
|
||||
|
||||
Relevant files:
|
||||
|
||||
- [TimelineContext.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/timeline/TimelineContext.tsx)
|
||||
- [TimelineView.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/timeline/TimelineView.tsx)
|
||||
- [TimelineResourcePanel.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/timeline/TimelineResourcePanel.tsx)
|
||||
- [TimelineProjectPanel.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/timeline/TimelineProjectPanel.tsx)
|
||||
- [timeline.ts](/home/hartmut/Documents/Copilot/planarchy/packages/api/src/router/timeline.ts)
|
||||
|
||||
Observed issues:
|
||||
|
||||
- `TimelineContext` derives multiple large collections in the client from the same raw payload.
|
||||
- Resource and project filtering is partly pushed to the backend, but large read-model payloads are still constructed and further filtered/grouped in the browser.
|
||||
- Timeline mutations invalidate multiple heavy queries at once.
|
||||
- Timeline SSE events also trigger broad invalidations.
|
||||
|
||||
Impact:
|
||||
|
||||
- Slow initial render.
|
||||
- Lag on interaction and hover.
|
||||
- Slow recovery after edits because the full timeline dataset is often fetched again.
|
||||
|
||||
### 2. Chargeability Report
|
||||
|
||||
Relevant files:
|
||||
|
||||
- [ChargeabilityReportClient.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/reports/ChargeabilityReportClient.tsx)
|
||||
- [chargeability-report.ts](/home/hartmut/Documents/Copilot/planarchy/packages/api/src/router/chargeability-report.ts)
|
||||
|
||||
Observed issues:
|
||||
|
||||
- The server loads matching resources, then all bookings in range, then repeatedly filters bookings and vacations per resource and per month.
|
||||
- The client renders a wide report grid and still performs filtering/grouping locally.
|
||||
- Public holiday support is still marked as TODO in the route, so future correctness work will likely add more cost unless the data model is optimized first.
|
||||
|
||||
Impact:
|
||||
|
||||
- CPU-heavy report generation.
|
||||
- Larger-than-needed payloads.
|
||||
- Increased render cost on wide month ranges.
|
||||
|
||||
### 3. Dashboard Chargeability / Analytics Widgets
|
||||
|
||||
Relevant files:
|
||||
|
||||
- [dashboard.ts](/home/hartmut/Documents/Copilot/planarchy/packages/api/src/router/dashboard.ts)
|
||||
- [get-chargeability-overview.ts](/home/hartmut/Documents/Copilot/planarchy/packages/application/src/use-cases/dashboard/get-chargeability-overview.ts)
|
||||
- [get-overview.ts](/home/hartmut/Documents/Copilot/planarchy/packages/application/src/use-cases/dashboard/get-overview.ts)
|
||||
|
||||
Observed issues:
|
||||
|
||||
- `getDashboardChargeabilityOverview()` fetches all relevant resources and all bookings for the month, then filters the full booking set again for each resource.
|
||||
- Similar patterns exist in dashboard summary use cases where relatively small widgets are backed by broad read logic.
|
||||
|
||||
Impact:
|
||||
|
||||
- Avoidable server CPU and latency.
|
||||
- Widgets become more expensive as the database grows even if the UI remains small.
|
||||
|
||||
### 4. Resources Screen
|
||||
|
||||
Relevant files:
|
||||
|
||||
- [ResourcesClient.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/app/(app)/resources/ResourcesClient.tsx)
|
||||
- [resource.ts](/home/hartmut/Documents/Copilot/planarchy/packages/api/src/router/resource.ts)
|
||||
|
||||
Observed issues:
|
||||
|
||||
- Infinite scrolling is in place, which is good, but the screen still does substantial client-side sort/filter/order work.
|
||||
- Separate global stats queries can be expensive when only the currently visible rows need enriched values.
|
||||
- Large dropdown/filter datasets are fully loaded to support selection UIs.
|
||||
|
||||
Impact:
|
||||
|
||||
- Good enough at small scale, but it will degrade with larger teams and imported planning data.
|
||||
|
||||
### 5. Shared Booking Read Model
|
||||
|
||||
Relevant files:
|
||||
|
||||
- [list-assignment-bookings.ts](/home/hartmut/Documents/Copilot/planarchy/packages/application/src/use-cases/allocation/list-assignment-bookings.ts)
|
||||
|
||||
Observed issues:
|
||||
|
||||
- This is a clean shared entry point, but it returns broad booking rows and leaves most grouping/aggregation to callers.
|
||||
- Several heavy endpoints call this and then repeatedly scan the returned array.
|
||||
|
||||
Impact:
|
||||
|
||||
- Logic is easy to reuse, but performance scales poorly because each consumer re-aggregates independently.
|
||||
|
||||
## Current Database Index Baseline
|
||||
|
||||
Relevant schema:
|
||||
|
||||
- [schema.prisma](/home/hartmut/Documents/Copilot/planarchy/packages/db/prisma/schema.prisma)
|
||||
|
||||
Good coverage already exists for:
|
||||
|
||||
- `Assignment`: `resourceId`, `projectId`, `status`, date ranges, and composite indices.
|
||||
- `DemandRequirement`: `projectId`, `status`, date ranges.
|
||||
- `Vacation`: `resourceId`, `status`, date ranges.
|
||||
- `Resource`: `chapter`, `isActive`, `countryId`, `orgUnitId`, `resourceType`.
|
||||
- `Project`: `status`, date range, `orderType`, `clientId`.
|
||||
|
||||
Likely gaps for current query patterns:
|
||||
|
||||
- `Resource` filters used together in analytics screens: `isActive`, `chgResponsibility`, `departed`, `rolledOff`, `countryId`, `managementLevelGroupId`.
|
||||
- `Project` filters used in timeline/report contexts: `clientId` combined with active date windows.
|
||||
- Potential overlap-heavy access paths where PostgreSQL may benefit more from specialized query shapes or materialized read models than from adding more conventional B-tree indexes.
|
||||
|
||||
Conclusion:
|
||||
|
||||
The schema is not unindexed. The main issue is query shape and repeated in-memory work, not just missing indexes. Index work should support query redesign, not replace it.
|
||||
|
||||
## Recommended Optimizations
|
||||
|
||||
### A. Move Timeline Derivations Closer to the Backend
|
||||
|
||||
Proposal:
|
||||
|
||||
- Add a timeline read endpoint that returns data already grouped for the active view mode and active filters.
|
||||
- Return normalized maps and compact slices instead of large repeated nested objects.
|
||||
- Keep the raw endpoint only if another screen truly needs it.
|
||||
|
||||
Pros:
|
||||
|
||||
- Largest likely user-visible win.
|
||||
- Reduces browser CPU, garbage collection, and hover lag.
|
||||
- Shrinks payload size if redundant nested fields are removed.
|
||||
|
||||
Cons:
|
||||
|
||||
- More backend read-model code.
|
||||
- Slightly higher coupling between API shape and timeline UI needs.
|
||||
- Requires careful migration to avoid breaking existing interactions.
|
||||
|
||||
Recommended priority: P0
|
||||
|
||||
Estimated effort: medium to large
|
||||
|
||||
### B. Build Indexed Maps Once Instead of Repeated Array Scans
|
||||
|
||||
Proposal:
|
||||
|
||||
- In timeline/report/dashboard code, replace repeated `.filter()`, `.find()`, and `.some()` passes with prebuilt maps keyed by `resourceId`, `projectId`, `monthKey`, and status where appropriate.
|
||||
- Standardize this as shared helper utilities for read-model construction.
|
||||
|
||||
Pros:
|
||||
|
||||
- Low-risk improvement.
|
||||
- Can be introduced incrementally.
|
||||
- Helps both client and server hot paths.
|
||||
|
||||
Cons:
|
||||
|
||||
- Does not solve oversized payloads by itself.
|
||||
- Adds some code complexity if done ad hoc instead of through shared helpers.
|
||||
|
||||
Recommended priority: P0
|
||||
|
||||
Estimated effort: small to medium
|
||||
|
||||
### C. Replace Broad Invalidations with Scoped Cache Updates
|
||||
|
||||
Proposal:
|
||||
|
||||
- Audit timeline mutations and SSE handlers that currently invalidate `getEntries`, `getEntriesView`, `getProjectContext`, and `getBudgetStatus` together.
|
||||
- Invalidate only the affected query keys.
|
||||
- Where practical, patch query cache locally after small edits instead of refetching everything.
|
||||
|
||||
Pros:
|
||||
|
||||
- Faster feedback after drag/drop and edit flows.
|
||||
- Less network churn.
|
||||
- Lower server load during active planning sessions.
|
||||
|
||||
Cons:
|
||||
|
||||
- Cache correctness becomes more complex.
|
||||
- Needs strong regression coverage around drag/drop and split allocation flows.
|
||||
|
||||
Recommended priority: P0
|
||||
|
||||
Estimated effort: medium
|
||||
|
||||
### D. Push Chargeability Aggregation into Dedicated Server Read Models
|
||||
|
||||
Proposal:
|
||||
|
||||
- Replace repeated per-resource filtering of a flat booking array with server-side grouped structures.
|
||||
- Precompute `bookingsByResource` and `vacationsByResource` once before month iteration.
|
||||
- Consider a dedicated monthly chargeability read model for reports and dashboard widgets.
|
||||
|
||||
Pros:
|
||||
|
||||
- Strong improvement for reports and widgets.
|
||||
- Eliminates duplicate aggregation logic across dashboard/resource/report endpoints.
|
||||
- Better foundation for public holiday integration.
|
||||
|
||||
Cons:
|
||||
|
||||
- Requires consolidating overlapping business logic.
|
||||
- Read-model duplication is possible if not designed carefully.
|
||||
|
||||
Recommended priority: P1
|
||||
|
||||
Estimated effort: medium
|
||||
|
||||
### E. Narrow Data Fetches to Visible Scope
|
||||
|
||||
Proposal:
|
||||
|
||||
- Avoid loading large supporting datasets such as `resource.list({ limit: 500 })` or “all projects” queries when only label resolution for selected IDs is required.
|
||||
- Add lightweight lookup endpoints like `getByIds()` or `resolveLabels()`.
|
||||
- On the resources page, fetch chargeability/stat enrichments only for the visible page or current filtered result slice.
|
||||
|
||||
Pros:
|
||||
|
||||
- Smaller payloads.
|
||||
- Faster filter bar and screen startup.
|
||||
- Simple, targeted changes.
|
||||
|
||||
Cons:
|
||||
|
||||
- More API endpoints or variants.
|
||||
- Some UI components become slightly more stateful.
|
||||
|
||||
Recommended priority: P1
|
||||
|
||||
Estimated effort: small to medium
|
||||
|
||||
### F. Add Read-Optimized Analytics Endpoints or Materialized Views
|
||||
|
||||
Proposal:
|
||||
|
||||
- For expensive monthly analytics, introduce a read-optimized table or materialized view refreshed on schedule or after import batches.
|
||||
- Use this for dashboard and chargeability summaries, not for transactional edit screens.
|
||||
|
||||
Pros:
|
||||
|
||||
- Best long-term scalability for imported dispo-scale data.
|
||||
- Predictable response times for reports.
|
||||
- Reduces repeated heavy joins and calculations.
|
||||
|
||||
Cons:
|
||||
|
||||
- More infrastructure and refresh logic.
|
||||
- Risk of stale analytics if refresh semantics are unclear.
|
||||
- Higher implementation complexity than direct query improvements.
|
||||
|
||||
Recommended priority: P2
|
||||
|
||||
Estimated effort: large
|
||||
|
||||
### G. Add Composite Indexes Only Where Query Patterns Justify Them
|
||||
|
||||
Proposal:
|
||||
|
||||
- Review actual PostgreSQL query plans for:
|
||||
- resource analytics filters
|
||||
- timeline date-overlap filters
|
||||
- project/client/date combinations
|
||||
- Add composite indexes only after measuring the slow plans.
|
||||
|
||||
Pros:
|
||||
|
||||
- Cheap improvement when aligned to real plans.
|
||||
- Complements query redesign.
|
||||
|
||||
Cons:
|
||||
|
||||
- Limited value if broad in-memory processing remains.
|
||||
- Too many indexes can slow writes/imports.
|
||||
|
||||
Recommended priority: P1
|
||||
|
||||
Estimated effort: small
|
||||
|
||||
Suggested candidates to measure first:
|
||||
|
||||
- `Resource(isActive, departed, rolledOff, chgResponsibility, countryId)`
|
||||
- `Resource(isActive, managementLevelGroupId, countryId)`
|
||||
- `Project(clientId, status, startDate, endDate)`
|
||||
|
||||
### H. Consider Row Virtualization for Heavy Tables
|
||||
|
||||
Proposal:
|
||||
|
||||
- Add virtualization for very large row-based screens if profiling confirms DOM/render cost remains high after data/query optimizations.
|
||||
- Candidate screens: resources table, chargeability report table, timeline side panels.
|
||||
|
||||
Pros:
|
||||
|
||||
- Strong render win when many rows are mounted.
|
||||
- Independent of backend changes.
|
||||
|
||||
Cons:
|
||||
|
||||
- More UI complexity.
|
||||
- Can complicate drag/drop, sticky headers, and keyboard navigation.
|
||||
- Should not be the first fix for timeline if the main problem is data churn.
|
||||
|
||||
Recommended priority: P2
|
||||
|
||||
Estimated effort: medium
|
||||
|
||||
## Priority Order
|
||||
|
||||
### Phase 1
|
||||
|
||||
- A. Move timeline derivations closer to the backend.
|
||||
- B. Replace repeated array scans with indexed maps.
|
||||
- C. Scope cache invalidation for timeline mutations and SSE.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Best chance of making timeline interaction materially faster without removing functionality.
|
||||
|
||||
### Phase 2
|
||||
|
||||
- D. Server-side chargeability read models.
|
||||
- E. Narrow data fetches to visible scope.
|
||||
- G. Add measured composite indexes.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Faster reports, dashboard widgets, and filter-heavy screens.
|
||||
|
||||
### Phase 3
|
||||
|
||||
- F. Materialized analytics/read models.
|
||||
- H. Virtualization where profiling still shows render bottlenecks.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Better scalability once data volume grows further.
|
||||
|
||||
## Suggested Measurement Plan
|
||||
|
||||
Before implementation:
|
||||
|
||||
- Capture browser performance traces for:
|
||||
- timeline initial load
|
||||
- timeline hover/selection
|
||||
- timeline mutation save path
|
||||
- chargeability report load
|
||||
- resources page load and scroll
|
||||
- Capture server timings for:
|
||||
- `timeline.getEntriesView`
|
||||
- `chargeabilityReport.getReport`
|
||||
- `dashboard.getChargeabilityOverview`
|
||||
- `resource.getChargeabilityStats`
|
||||
- Record PostgreSQL query plans for the slowest endpoints.
|
||||
|
||||
During implementation:
|
||||
|
||||
- Compare payload sizes before and after endpoint changes.
|
||||
- Track React commit duration on Timeline and Resources screens.
|
||||
- Measure query invalidation counts after common edits.
|
||||
|
||||
Success criteria:
|
||||
|
||||
- Timeline hover and selection feel immediate under imported dispo-scale data.
|
||||
- Timeline mutation flows no longer trigger full-screen refetch storms.
|
||||
- Chargeability report load time drops materially for multi-month ranges.
|
||||
- Dashboard widgets stop scanning large booking arrays per resource.
|
||||
|
||||
## No-Regression Guidance
|
||||
|
||||
Do not trade away any of the following:
|
||||
|
||||
- Existing planning semantics for assignments, proposed work, and demand visibility.
|
||||
- Timeline drag/drop correctness.
|
||||
- Tooltip, right-click, and selection behavior.
|
||||
- Anonymization behavior.
|
||||
- Filter parity between resources, timeline, and reports.
|
||||
|
||||
Recommended safeguards:
|
||||
|
||||
- Add endpoint-level benchmarks for the heavy routes.
|
||||
- Add UI regression checks around timeline interaction paths.
|
||||
- Roll out timeline changes behind a temporary feature flag if the payload shape changes significantly.
|
||||
- Compare old and new aggregation outputs on the same imported dataset before removing legacy paths.
|
||||
|
||||
## Suggested Ticket Breakdown
|
||||
|
||||
1. Profile and baseline the four slowest endpoints plus timeline render path.
|
||||
2. Refactor timeline read model to return pre-grouped data for current filters/view.
|
||||
3. Replace timeline broad invalidations with scoped invalidation and selective cache patching.
|
||||
4. Refactor chargeability report aggregation into grouped server-side structures.
|
||||
5. Narrow lookup/filter support queries to selected IDs or visible pages.
|
||||
6. Review PostgreSQL query plans and add only measured composite indexes.
|
||||
7. Re-profile and decide whether virtualization or materialized analytics are still needed.
|
||||
|
||||
## Bottom Line
|
||||
|
||||
This codebase can be made substantially faster without removing functionality. The main gains will not come from cosmetic micro-optimizations. They will come from changing where data is shaped, reducing repeated scans, and stopping broad cache churn on heavy views.
|
||||
@@ -1,4 +1,4 @@
|
||||
export { appRouter, type AppRouter } from "./router/index.js";
|
||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission } from "./trpc.js";
|
||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.js";
|
||||
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
||||
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
|
||||
|
||||
@@ -116,10 +116,11 @@ export const assistantRouter = createTRPCRouter({
|
||||
const temperature = settings?.aiTemperature ?? 0.7;
|
||||
const model = settings?.azureOpenAiDeployment ?? "gpt-4o-mini";
|
||||
|
||||
// 2. Resolve granular permissions
|
||||
// 2. Resolve granular permissions (using DB-based role defaults if available)
|
||||
const permissions = resolvePermissions(
|
||||
userRole as SystemRole,
|
||||
(ctx.dbUser?.permissionOverrides as PermissionOverrides | null) ?? null,
|
||||
ctx.roleDefaults ?? undefined,
|
||||
);
|
||||
const permissionList = [...permissions];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { resourceRouter } from "./resource.js";
|
||||
import { roleRouter } from "./role.js";
|
||||
import { settingsRouter } from "./settings.js";
|
||||
import { staffingRouter } from "./staffing.js";
|
||||
import { systemRoleConfigRouter } from "./system-role-config.js";
|
||||
import { timelineRouter } from "./timeline.js";
|
||||
import { userRouter } from "./user.js";
|
||||
import { utilizationCategoryRouter } from "./utilization-category.js";
|
||||
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
|
||||
chargeabilityReport: chargeabilityReportRouter,
|
||||
calculationRule: calculationRuleRouter,
|
||||
computationGraph: computationGraphRouter,
|
||||
systemRoleConfig: systemRoleConfigRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -220,7 +220,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
/** Get task counts for the current user */
|
||||
/** Get task counts for the current user — single groupBy instead of 5 counts */
|
||||
taskCounts: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
const now = new Date();
|
||||
@@ -230,11 +230,12 @@ export const notificationRouter = createTRPCRouter({
|
||||
category: { in: ["TASK" as const, "APPROVAL" as const] },
|
||||
};
|
||||
|
||||
const [open, inProgress, done, dismissed, overdue] = await Promise.all([
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "OPEN" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "IN_PROGRESS" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "DONE" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "DISMISSED" } }),
|
||||
const [grouped, overdue] = await Promise.all([
|
||||
ctx.db.notification.groupBy({
|
||||
by: ["taskStatus"],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
ctx.db.notification.count({
|
||||
where: {
|
||||
...where,
|
||||
@@ -244,7 +245,18 @@ export const notificationRouter = createTRPCRouter({
|
||||
}),
|
||||
]);
|
||||
|
||||
return { open, inProgress, done, dismissed, overdue };
|
||||
const counts: Record<string, number> = {};
|
||||
for (const g of grouped) {
|
||||
if (g.taskStatus) counts[g.taskStatus] = g._count;
|
||||
}
|
||||
|
||||
return {
|
||||
open: counts["OPEN"] ?? 0,
|
||||
inProgress: counts["IN_PROGRESS"] ?? 0,
|
||||
done: counts["DONE"] ?? 0,
|
||||
dismissed: counts["DISMISSED"] ?? 0,
|
||||
overdue,
|
||||
};
|
||||
}),
|
||||
|
||||
/** Update task status */
|
||||
|
||||
@@ -291,6 +291,54 @@ export const resourceRouter = createTRPCRouter({
|
||||
return { resources, total, page, limit, nextCursor };
|
||||
}),
|
||||
|
||||
/** Lightweight resource card for hover tooltips on the timeline. */
|
||||
getHoverCard: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.id },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
email: true,
|
||||
chapter: true,
|
||||
lcrCents: true,
|
||||
ucrCents: true,
|
||||
currency: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
availability: true,
|
||||
isActive: true,
|
||||
areaRole: { select: { id: true, name: true, color: true } },
|
||||
country: { select: { name: true, code: true } },
|
||||
managementLevel: { select: { name: true } },
|
||||
resourceType: true,
|
||||
},
|
||||
});
|
||||
if (!resource) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
const anon = anonymizeResource(resource, directory);
|
||||
return {
|
||||
id: anon.id,
|
||||
displayName: anon.displayName ?? "",
|
||||
eid: anon.eid ?? "",
|
||||
chapter: resource.chapter,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
currency: resource.currency,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
skills: resource.skills as Record<string, unknown>[],
|
||||
isActive: resource.isActive,
|
||||
resourceType: resource.resourceType,
|
||||
areaRole: resource.areaRole,
|
||||
country: resource.country,
|
||||
managementLevel: resource.managementLevel,
|
||||
};
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -48,6 +48,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
hasDalleApiKey: !!settings?.azureDalleApiKey,
|
||||
// Vacation defaults
|
||||
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
|
||||
// Timeline
|
||||
timelineUndoMaxSteps: settings?.timelineUndoMaxSteps ?? 50,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -94,6 +96,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
azureDalleApiKey: z.string().optional(),
|
||||
// Vacation
|
||||
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
|
||||
// Timeline
|
||||
timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -144,6 +148,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
data.azureDalleApiKey = input.azureDalleApiKey || null;
|
||||
// Vacation
|
||||
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
|
||||
// Timeline
|
||||
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps;
|
||||
|
||||
await ctx.db.systemSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { adminProcedure, createTRPCRouter, invalidateRoleDefaultsCache, protectedProcedure } from "../trpc.js";
|
||||
|
||||
export const systemRoleConfigRouter = createTRPCRouter({
|
||||
/** List all role configs (sorted by sortOrder) */
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.systemRoleConfig.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
}),
|
||||
|
||||
/** Update a role's default permissions, label, description, and color */
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
role: z.string(),
|
||||
label: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
color: z.string().nullable().optional(),
|
||||
defaultPermissions: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const data: Record<string, unknown> = {};
|
||||
if (input.label !== undefined) data.label = input.label;
|
||||
if (input.description !== undefined) data.description = input.description;
|
||||
if (input.color !== undefined) data.color = input.color;
|
||||
if (input.defaultPermissions !== undefined) data.defaultPermissions = input.defaultPermissions;
|
||||
|
||||
const result = await ctx.db.systemRoleConfig.update({
|
||||
where: { role: input.role as never },
|
||||
data,
|
||||
});
|
||||
|
||||
// Invalidate cached role defaults so changes take effect immediately
|
||||
invalidateRoleDefaultsCache();
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -721,6 +721,182 @@ export const timelineRouter = createTRPCRouter({
|
||||
return allocation;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch quick-assign multiple resources to a project for a date range.
|
||||
* Used by the multi-selection floating action bar.
|
||||
*/
|
||||
batchQuickAssign: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
assignments: z
|
||||
.array(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
projectId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0.5).max(24).default(8),
|
||||
role: z.string().min(1).max(200).default("Team Member"),
|
||||
status: z
|
||||
.nativeEnum(AllocationStatus)
|
||||
.default(AllocationStatus.PROPOSED),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
// Validate all date ranges
|
||||
for (const a of input.assignments) {
|
||||
if (a.endDate < a.startDate) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "End date must be after start date",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const results = await ctx.db.$transaction(async (tx) => {
|
||||
const created = [];
|
||||
for (const a of input.assignments) {
|
||||
const percentage = Math.min(
|
||||
100,
|
||||
Math.round((a.hoursPerDay / 8) * 100),
|
||||
);
|
||||
const metadata = {
|
||||
source: "batchQuickAssign",
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
const assignment = await createAssignment(
|
||||
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||
{
|
||||
resourceId: a.resourceId,
|
||||
projectId: a.projectId,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
percentage,
|
||||
role: a.role,
|
||||
status: a.status,
|
||||
metadata,
|
||||
},
|
||||
);
|
||||
created.push(assignment);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
// Fire SSE events
|
||||
for (const assignment of results) {
|
||||
emitAllocationCreated({
|
||||
id: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
return { count: results.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch-shift multiple allocations by the same number of days.
|
||||
* Used by multi-select drag on the timeline.
|
||||
*/
|
||||
batchShiftAllocations: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
allocationIds: z.array(z.string()).min(1).max(100),
|
||||
daysDelta: z.number().int().min(-3650).max(3650),
|
||||
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
if (input.daysDelta === 0) return { count: 0 };
|
||||
|
||||
// Load all allocations
|
||||
const entries = await Promise.all(
|
||||
input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)),
|
||||
);
|
||||
const resolved = entries.filter(
|
||||
(e): e is NonNullable<typeof e> => e !== null,
|
||||
);
|
||||
|
||||
if (resolved.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
|
||||
}
|
||||
|
||||
const results = await ctx.db.$transaction(async (tx) => {
|
||||
const updated = [];
|
||||
for (const entry of resolved) {
|
||||
const existing = entry.entry;
|
||||
const newStart = new Date(existing.startDate);
|
||||
const newEnd = new Date(existing.endDate);
|
||||
|
||||
if (input.mode === "move") {
|
||||
newStart.setDate(newStart.getDate() + input.daysDelta);
|
||||
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
||||
} else if (input.mode === "resize-start") {
|
||||
newStart.setDate(newStart.getDate() + input.daysDelta);
|
||||
// Clamp: start must not exceed end
|
||||
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
|
||||
} else {
|
||||
// resize-end
|
||||
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
||||
// Clamp: end must not precede start
|
||||
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
|
||||
}
|
||||
|
||||
const result = await updateAllocationEntry(
|
||||
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
||||
{
|
||||
id: existing.id,
|
||||
demandRequirementUpdate: {
|
||||
startDate: newStart,
|
||||
endDate: newEnd,
|
||||
},
|
||||
assignmentUpdate: {
|
||||
startDate: newStart,
|
||||
endDate: newEnd,
|
||||
},
|
||||
},
|
||||
);
|
||||
updated.push(result.allocation);
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
entityType: "Allocation",
|
||||
entityId: input.allocationIds.join(","),
|
||||
action: "UPDATE",
|
||||
changes: {
|
||||
operation: "batchShift",
|
||||
mode: input.mode,
|
||||
daysDelta: input.daysDelta,
|
||||
count: resolved.length,
|
||||
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Fire SSE events
|
||||
for (const alloc of results) {
|
||||
emitAllocationUpdated({
|
||||
id: alloc.id,
|
||||
projectId: alloc.projectId,
|
||||
resourceId: alloc.resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
return { count: results.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get budget status for a project.
|
||||
*/
|
||||
|
||||
@@ -12,9 +12,21 @@ import { Prisma } from "@planarchy/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
/** Lightweight user list for task assignment (ADMIN + MANAGER) */
|
||||
listAssignable: managerProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}),
|
||||
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.user.findMany({
|
||||
select: {
|
||||
@@ -23,11 +35,23 @@ export const userRouter = createTRPCRouter({
|
||||
email: true,
|
||||
systemRole: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
lastActiveAt: true,
|
||||
permissionOverrides: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}),
|
||||
|
||||
/** Count of users active in the last 5 minutes */
|
||||
activeCount: adminProcedure.query(async ({ ctx }) => {
|
||||
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const count = await ctx.db.user.count({
|
||||
where: { lastActiveAt: { gte: fiveMinAgo } },
|
||||
});
|
||||
return { count };
|
||||
}),
|
||||
|
||||
me: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
|
||||
@@ -15,16 +15,47 @@ export interface TRPCContext {
|
||||
session: Session | null;
|
||||
db: typeof prisma;
|
||||
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
||||
roleDefaults: Record<string, PermissionKey[]> | null;
|
||||
}
|
||||
|
||||
// Cache role defaults for 60 seconds to avoid DB hit on every request
|
||||
let _roleDefaultsCache: Record<string, PermissionKey[]> | null = null;
|
||||
let _roleDefaultsCacheTime = 0;
|
||||
const ROLE_DEFAULTS_TTL = 60_000;
|
||||
|
||||
export async function loadRoleDefaults(): Promise<Record<string, PermissionKey[]>> {
|
||||
const now = Date.now();
|
||||
if (_roleDefaultsCache && now - _roleDefaultsCacheTime < ROLE_DEFAULTS_TTL) {
|
||||
return _roleDefaultsCache;
|
||||
}
|
||||
const configs = await prisma.systemRoleConfig.findMany({
|
||||
select: { role: true, defaultPermissions: true },
|
||||
});
|
||||
const map: Record<string, PermissionKey[]> = {};
|
||||
for (const c of configs) {
|
||||
map[c.role] = c.defaultPermissions as PermissionKey[];
|
||||
}
|
||||
_roleDefaultsCache = map;
|
||||
_roleDefaultsCacheTime = now;
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Invalidate the role defaults cache (call after updating SystemRoleConfig) */
|
||||
export function invalidateRoleDefaultsCache(): void {
|
||||
_roleDefaultsCache = null;
|
||||
_roleDefaultsCacheTime = 0;
|
||||
}
|
||||
|
||||
export function createTRPCContext(opts: {
|
||||
session: Session | null;
|
||||
dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
||||
roleDefaults?: Record<string, PermissionKey[]> | null;
|
||||
}): TRPCContext {
|
||||
return {
|
||||
session: opts.session,
|
||||
db: prisma,
|
||||
dbUser: opts.dbUser ?? null,
|
||||
roleDefaults: opts.roleDefaults ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,7 +117,8 @@ export const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
}
|
||||
const permissions = resolvePermissions(
|
||||
user.systemRole as SystemRole,
|
||||
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null
|
||||
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null,
|
||||
ctx.roleDefaults ?? undefined,
|
||||
);
|
||||
return next({ ctx: { ...ctx, user, permissions } });
|
||||
});
|
||||
@@ -104,7 +136,8 @@ export const controllerProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
}
|
||||
const permissions = resolvePermissions(
|
||||
user.systemRole as SystemRole,
|
||||
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null
|
||||
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null,
|
||||
ctx.roleDefaults ?? undefined,
|
||||
);
|
||||
return next({ ctx: { ...ctx, user, permissions } });
|
||||
});
|
||||
@@ -117,7 +150,7 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (!user || user.systemRole !== SystemRole.ADMIN) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" });
|
||||
}
|
||||
const permissions = resolvePermissions(SystemRole.ADMIN, null);
|
||||
const permissions = resolvePermissions(SystemRole.ADMIN, null, ctx.roleDefaults ?? undefined);
|
||||
return next({ ctx: { ...ctx, user, permissions } });
|
||||
});
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ model User {
|
||||
dashboardLayout Json? @db.JsonB
|
||||
columnPreferences Json? @db.JsonB
|
||||
favoriteProjectIds Json? @db.JsonB // string[] of project IDs
|
||||
lastLoginAt DateTime?
|
||||
lastActiveAt DateTime?
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
@@ -1359,6 +1361,8 @@ model Notification {
|
||||
@@index([userId, category, taskStatus])
|
||||
@@index([nextRemindAt])
|
||||
@@index([assigneeId, taskStatus])
|
||||
@@index([category, nextRemindAt])
|
||||
@@index([userId, dueDate])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
@@ -1390,6 +1394,22 @@ model NotificationBroadcast {
|
||||
@@map("notification_broadcasts")
|
||||
}
|
||||
|
||||
// ─── System Role Configuration ────────────────────────────────────────────────
|
||||
|
||||
model SystemRoleConfig {
|
||||
role SystemRole @id
|
||||
label String // Display label, e.g. "Manager"
|
||||
description String? // Optional description of the role
|
||||
defaultPermissions Json @db.JsonB // PermissionKey[] — default permissions for this role
|
||||
color String? // Badge color, e.g. "purple", "blue"
|
||||
sortOrder Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("system_role_configs")
|
||||
}
|
||||
|
||||
// ─── System Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
model SystemSettings {
|
||||
@@ -1419,6 +1439,8 @@ model SystemSettings {
|
||||
anonymizationAliases Json? @db.JsonB
|
||||
// Vacation defaults
|
||||
vacationDefaultDays Int? @default(28) // default annual entitlement
|
||||
// Timeline undo
|
||||
timelineUndoMaxSteps Int? @default(50) // max undo history depth
|
||||
// DALL-E image generation (Azure requires separate deployment)
|
||||
azureDalleDeployment String? // e.g. "dall-e-3" — Azure DALL-E deployment name
|
||||
azureDalleEndpoint String? // Optional: separate endpoint for DALL-E (if different from chat)
|
||||
|
||||
@@ -49,9 +49,12 @@ export const ROLE_DEFAULT_PERMISSIONS: Record<SystemRole, PermissionKey[]> = {
|
||||
|
||||
export function resolvePermissions(
|
||||
systemRole: SystemRole,
|
||||
overrides?: PermissionOverrides | null
|
||||
overrides?: PermissionOverrides | null,
|
||||
/** Optional DB-based defaults per role. If provided, takes precedence over ROLE_DEFAULT_PERMISSIONS. */
|
||||
roleDefaults?: Record<string, PermissionKey[]>,
|
||||
): Set<PermissionKey> {
|
||||
const base = new Set<PermissionKey>(ROLE_DEFAULT_PERMISSIONS[systemRole] ?? []);
|
||||
const defaults = roleDefaults?.[systemRole] ?? ROLE_DEFAULT_PERMISSIONS[systemRole] ?? [];
|
||||
const base = new Set<PermissionKey>(defaults);
|
||||
if (overrides?.granted) {
|
||||
for (const p of overrides.granted) base.add(p);
|
||||
}
|
||||
|
||||
@@ -1,397 +1,321 @@
|
||||
# Enterprise Notification & Task Management System
|
||||
# Right-Click Multi-Selection on Timeline
|
||||
|
||||
## Anforderungsanalyse
|
||||
|
||||
### Was wird gebaut?
|
||||
Ein mehrstufiges Notification- und Task-Management-System, das die bestehende Notification-Infrastruktur (Prisma-Model, Bell-Icon, SSE, SMTP) zu einem vollwertigen Enterprise-System ausbaut. Vier Kernfähigkeiten:
|
||||
Vier neue Funktionen für die Timeline:
|
||||
|
||||
1. **Personal Reminders** — User legen eigene Erinnerungen an (Datum/Zeit, optionale Wiederholung, verknüpft mit Entity)
|
||||
2. **Targeted Notifications** — Admins/Manager senden Notifications an User, Rollen, Projektbeteiligte, OrgUnits
|
||||
3. **Task Management** — Actionable Tasks mit Status-Tracking, Dashboard-Widget, Entity-Verknüpfung
|
||||
4. **AI Assistant Integration** — Assistent liest offene Tasks, führt sie aus (Urlaub genehmigen, Allokation erstellen, etc.)
|
||||
1. **Right-click + Drag Multi-Selection** — Rechtsklick + Drag auf dem Canvas, um mehrere Allocation-Blocks auszuwählen. Die Selektion kann über mehrere Ressourcen-Zeilen hinweggehen (Lasso/Rectangle-Selection).
|
||||
|
||||
### Bestehende Infrastruktur (wiederverwendbar)
|
||||
| Komponente | Status | Datei |
|
||||
|------------|--------|-------|
|
||||
| `Notification` Prisma-Model | Vorhanden (einfach) | `packages/db/prisma/schema.prisma:1291` |
|
||||
| Notification tRPC-Router | list, unreadCount, markRead, create | `packages/api/src/router/notification.ts` |
|
||||
| NotificationBell + Drawer | Bell-Icon mit Badge, Dropdown-Panel | `apps/web/src/components/notifications/NotificationBell.tsx` |
|
||||
| SSE EventBus (Redis Pub/Sub) | `NOTIFICATION_CREATED` Event | `packages/api/src/sse/event-bus.ts` |
|
||||
| SMTP Email | `sendEmail()` + SystemSettings | `packages/api/src/lib/email.ts` |
|
||||
| AI Assistant Tools | `list_notifications`, `mark_notification_read` | `packages/api/src/router/assistant-tools.ts` |
|
||||
| Dashboard Widget-Registry | 8 Widgets, Pattern etabliert | `apps/web/src/components/dashboard/widgets/` |
|
||||
2. **Shift+Click Additive Selection** — Shift+Klick auf einen Allocation-Block fügt ihn zur bestehenden Multi-Selection hinzu (oder entfernt ihn, wenn bereits selektiert = Toggle). Ermöglicht präzise Einzelauswahl ohne Lasso. Funktioniert auch als Einstieg: Erster Shift+Click startet die Multi-Selection, weitere Shift+Clicks erweitern sie.
|
||||
|
||||
### Betroffene Pakete
|
||||
- **packages/db** — Schema-Erweiterung (Notification -> Task-Felder, neues Broadcast-Model)
|
||||
- **packages/shared** — Enums, Typen, Zod-Schemas
|
||||
- **packages/api** — Router-Erweiterung (notification.ts, assistant-tools.ts), Targeting-Logik, Scheduler
|
||||
- **apps/web** — UI (Task-Widget, Reminder-UI, Notification-Center, Admin-Panel)
|
||||
3. **Floating Action Bar** — Bei aktiver Multi-Selection erscheint eine schwebende Toolbar mit:
|
||||
- **"Delete / Cancel"** — Batch-Löschung aller selektierten Allocations (`allocation.batchDelete` existiert bereits)
|
||||
- **"Assign"** — Auf leeren Zeilen: Batch-Erstellung neuer Allocations über `timeline.quickAssign` (eine pro selektierter Ressource-Zeile)
|
||||
- **"Clear Selection"** — Selektion aufheben
|
||||
|
||||
---
|
||||
4. **Multi-Resource Assignment** — Right-click + Drag über leere Zeilen mehrerer Ressourcen → öffnet einen Popover/Bar, der ein Projekt wählen lässt und dann Allocations für alle selektierten Ressourcen auf einmal erstellt.
|
||||
|
||||
## Datenmodell-Design
|
||||
|
||||
### Erweiterung des bestehenden `Notification`-Models
|
||||
Das bestehende Model wird um Task-/Reminder-/Targeting-Felder erweitert. Kein neues Model nötig — ein einheitliches System für Notifications + Tasks + Reminders.
|
||||
|
||||
```prisma
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
// -- Typ & Kategorie --
|
||||
category NotificationCategory @default(NOTIFICATION) // NEU
|
||||
type String // z.B. "VACATION_REQUESTED", "TASK_ASSIGNED", "REMINDER"
|
||||
priority NotificationPriority @default(NORMAL) // NEU
|
||||
|
||||
// -- Inhalt --
|
||||
title String
|
||||
body String?
|
||||
entityId String?
|
||||
entityType String?
|
||||
link String? // NEU: Deep-Link zur relevanten Seite
|
||||
|
||||
// -- Task-Felder (nur fuer category TASK / APPROVAL) --
|
||||
taskStatus TaskStatus? // NEU: OPEN / IN_PROGRESS / DONE / DISMISSED
|
||||
taskAction String? // NEU: maschinenlesbare Aktion z.B. "approve_vacation:clxyz123"
|
||||
assigneeId String? // NEU: wem der Task zugewiesen ist
|
||||
dueDate DateTime? // NEU: Faelligkeitsdatum
|
||||
completedAt DateTime? // NEU: Zeitpunkt der Erledigung
|
||||
completedBy String? // NEU: wer hat erledigt (User-ID, oder "ai-assistant")
|
||||
|
||||
// -- Reminder-Felder --
|
||||
remindAt DateTime? // NEU: wann soll erinnert werden
|
||||
recurrence String? // NEU: "daily" | "weekly" | "monthly" | null
|
||||
nextRemindAt DateTime? // NEU: naechster Erinnerungszeitpunkt (berechnet)
|
||||
|
||||
// -- Targeting-Metadaten (fuer Bulk-Sends) --
|
||||
sourceId String? // NEU: Referenz auf die urspruengliche Broadcast-Nachricht
|
||||
senderId String? // NEU: wer hat die Notification erstellt (User-ID)
|
||||
channel String @default("in_app") // NEU: "in_app" | "email" | "both"
|
||||
|
||||
// -- Timestamps --
|
||||
readAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt // NEU
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
assignee User? @relation("taskAssignee", fields: [assigneeId], references: [id])
|
||||
sender User? @relation("notificationSender", fields: [senderId], references: [id])
|
||||
|
||||
@@index([userId, readAt])
|
||||
@@index([userId, category, taskStatus]) // NEU: Task-Queries
|
||||
@@index([nextRemindAt]) // NEU: Reminder-Scheduler
|
||||
@@index([assigneeId, taskStatus]) // NEU: Assigned-Tasks
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
enum NotificationCategory {
|
||||
NOTIFICATION // System/Admin-Benachrichtigung (read-only)
|
||||
REMINDER // Persoenliche Erinnerung (self-created)
|
||||
TASK // Actionable Task mit Status-Tracking
|
||||
APPROVAL // Genehmigungsworkflow (approve/reject)
|
||||
}
|
||||
|
||||
enum NotificationPriority {
|
||||
LOW
|
||||
NORMAL
|
||||
HIGH
|
||||
URGENT
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
DONE
|
||||
DISMISSED
|
||||
}
|
||||
```
|
||||
|
||||
### Broadcast-Model (fuer Gruppen-Notifications)
|
||||
|
||||
```prisma
|
||||
model NotificationBroadcast {
|
||||
id String @id @default(cuid())
|
||||
senderId String
|
||||
title String
|
||||
body String?
|
||||
link String?
|
||||
category NotificationCategory @default(NOTIFICATION)
|
||||
priority NotificationPriority @default(NORMAL)
|
||||
channel String @default("in_app")
|
||||
|
||||
// -- Targeting --
|
||||
targetType String // "user" | "role" | "project" | "orgUnit" | "all"
|
||||
targetValue String? // Role-Name, Project-ID, OrgUnit-ID, oder null fuer "all"
|
||||
|
||||
// -- Scheduling --
|
||||
scheduledAt DateTime? // null = sofort
|
||||
sentAt DateTime?
|
||||
recipientCount Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
sender User @relation(fields: [senderId], references: [id])
|
||||
|
||||
@@index([senderId])
|
||||
@@index([scheduledAt, sentAt])
|
||||
@@map("notification_broadcasts")
|
||||
}
|
||||
```
|
||||
|
||||
### Task-Action Registry (Enterprise-Pattern)
|
||||
|
||||
Maschinenlesbare Aktionen ermoeglichen dem AI-Assistenten, Tasks direkt zu erledigen:
|
||||
|
||||
```typescript
|
||||
// packages/api/src/lib/task-actions.ts
|
||||
const TASK_ACTION_REGISTRY: Record<string, TaskActionHandler> = {
|
||||
"approve_vacation": { permission: "manageVacations", execute: ... },
|
||||
"reject_vacation": { permission: "manageVacations", execute: ... },
|
||||
"fill_demand": { permission: "manageAllocations", execute: ... },
|
||||
"confirm_allocation":{ permission: "manageAllocations", execute: ... },
|
||||
"review_budget": { permission: "manageProjects", execute: ... },
|
||||
};
|
||||
```
|
||||
|
||||
Format: `"action_name:entity_id"` — einfach parsbar, erweiterbar.
|
||||
|
||||
---
|
||||
**Betroffene Pakete:** `apps/web` (Frontend), `packages/api` (neuer `batchQuickAssign`-Endpoint)
|
||||
|
||||
## Betroffene Pakete & Dateien
|
||||
|
||||
| Paket | Dateien | Art |
|
||||
|-------|---------|-----|
|
||||
| `packages/db` | `prisma/schema.prisma` | edit — Notification erweitern, Enums, Broadcast-Model |
|
||||
| `packages/shared` | `src/types/notification.ts` | create — Typen, Enums, Zod-Schemas |
|
||||
| `packages/shared` | `src/types/enums.ts` | edit — re-exportieren |
|
||||
| `packages/api` | `src/router/notification.ts` | edit — Task-CRUD, Reminder-CRUD, Broadcast, Targeting |
|
||||
| `packages/api` | `src/router/index.ts` | edit — ggf. neuen Router registrieren |
|
||||
| `packages/api` | `src/router/assistant-tools.ts` | edit — neue Tools: list_tasks, execute_task_action, etc. |
|
||||
| `packages/api` | `src/router/assistant.ts` | edit — TOOL_PERMISSION_MAP + System-Prompt |
|
||||
| `packages/api` | `src/sse/event-bus.ts` | edit — neue Event-Types |
|
||||
| `packages/api` | `src/lib/email.ts` | edit — Notification-Email-Templates |
|
||||
| `packages/api` | `src/lib/notification-targeting.ts` | create — Recipient-Aufloesung |
|
||||
| `packages/api` | `src/lib/task-actions.ts` | create — Action-Registry |
|
||||
| `packages/api` | `src/lib/reminder-scheduler.ts` | create — Reminder-Dispatcher |
|
||||
| `apps/web` | `src/components/notifications/NotificationBell.tsx` | edit — Tabs, Task-Badge |
|
||||
| `apps/web` | `src/components/notifications/NotificationCenter.tsx` | create — Full-Page |
|
||||
| `apps/web` | `src/components/notifications/ReminderModal.tsx` | create |
|
||||
| `apps/web` | `src/components/notifications/BroadcastModal.tsx` | create |
|
||||
| `apps/web` | `src/components/notifications/TaskCard.tsx` | create |
|
||||
| `apps/web` | `src/components/dashboard/widgets/TaskWidget.tsx` | create |
|
||||
| `apps/web` | `src/app/(app)/notifications/page.tsx` | create |
|
||||
| `apps/web` | `src/app/(app)/admin/notifications/page.tsx` | create |
|
||||
| `apps/web` | `src/components/layout/AppShell.tsx` | edit — Nav-Links |
|
||||
| `apps/web` | `src/hooks/useTimelineSSE.ts` | edit — Task-Events |
|
||||
| Paket | Dateien | Art der Änderung |
|
||||
|-------|---------|-----------------:|
|
||||
| apps/web | `src/hooks/useTimelineDrag.ts` | edit — neuer `multiSelectState`, right-click drag handler |
|
||||
| apps/web | `src/components/timeline/TimelineView.tsx` | edit — multiSelectState empfangen, FloatingActionBar rendern, batch-Actions verdrahten |
|
||||
| apps/web | `src/components/timeline/FloatingActionBar.tsx` | create — schwebende Toolbar-Komponente |
|
||||
| apps/web | `src/components/timeline/BatchAssignPopover.tsx` | create — Projekt-Picker für Multi-Resource-Assignment |
|
||||
| apps/web | `src/components/timeline/TimelineResourcePanel.tsx` | edit — Selection-Overlay rendern, onContextMenu anpassen |
|
||||
| apps/web | `src/components/timeline/TimelineProjectPanel.tsx` | edit — Selection-Overlay rendern, onContextMenu anpassen |
|
||||
| packages/api | `src/router/timeline.ts` | edit — neuer `batchQuickAssign`-Endpoint |
|
||||
|
||||
---
|
||||
## Architektur-Entscheidungen
|
||||
|
||||
## Task-Liste (atomare Schritte)
|
||||
### Right-click vs. Left-click vs. Shift-click Abgrenzung
|
||||
- **Left-click drag** (button 0): Bestehende Funktionen — Alloc-Move, Alloc-Resize, Range-Select (NewAllocationPopover)
|
||||
- **Right-click drag** (button 2): Neue Multi-Selection (Lasso-Rectangle)
|
||||
- **Right-click ohne Drag** auf bestehende Allocation: Öffnet weiterhin `AllocationPopover` (Einzelbearbeitung) — das ist der bestehende `onContextMenu`-Handler
|
||||
- **Shift+Click** auf Allocation-Block: Toggle-Selektion (addiert/entfernt Block zur/aus Multi-Selection). Startet Multi-Selection wenn noch keine aktiv. Kein Drag nötig — sofortige Einzelauswahl.
|
||||
|
||||
### Phase N.1 — Datenmodell & Shared Types
|
||||
### Multi-Select State
|
||||
Neuer State `MultiSelectState` im `useTimelineDrag`-Hook:
|
||||
|
||||
- [ ] **Task 1:** Shared-Typen erstellen -> `packages/shared/src/types/notification.ts`
|
||||
- NotificationCategory, NotificationPriority, TaskStatus Enums
|
||||
- CreateReminderSchema, CreateBroadcastSchema, UpdateTaskStatusSchema (Zod)
|
||||
- TaskAction Interface
|
||||
|
||||
- [ ] **Task 2:** Prisma-Schema erweitern -> `packages/db/prisma/schema.prisma`
|
||||
- Notification-Model: category, priority, taskStatus, taskAction, assigneeId, dueDate, completedAt, completedBy, remindAt, recurrence, nextRemindAt, sourceId, senderId, channel, link, updatedAt
|
||||
- Enums: NotificationCategory, NotificationPriority, TaskStatus
|
||||
- Model: NotificationBroadcast
|
||||
- User-Relations: taskAssignee, notificationSender, broadcasts
|
||||
- Indexes: [userId, category, taskStatus], [nextRemindAt], [assigneeId, taskStatus]
|
||||
|
||||
- [ ] **Task 3:** `pnpm db:push` + Dev-Server neu starten
|
||||
|
||||
### Phase N.2 — API: Router + Targeting + Scheduler
|
||||
|
||||
- [ ] **Task 4:** SSE Event-Types erweitern -> `packages/api/src/sse/event-bus.ts`
|
||||
- TASK_ASSIGNED, TASK_COMPLETED, TASK_STATUS_CHANGED, REMINDER_DUE, BROADCAST_SENT
|
||||
- Emit-Helper: emitTaskAssigned(), emitTaskCompleted(), emitReminderDue()
|
||||
|
||||
- [ ] **Task 5:** Notification-Router erweitern -> `packages/api/src/router/notification.ts`
|
||||
- list: Filter nach category, taskStatus, priority
|
||||
- listTasks (protectedProcedure): offene Tasks + zugewiesene Tasks
|
||||
- taskCounts (protectedProcedure): Counts nach Status
|
||||
- updateTaskStatus (protectedProcedure): OPEN->IN_PROGRESS->DONE/DISMISSED
|
||||
- createReminder (protectedProcedure): eigene Erinnerung anlegen
|
||||
- updateReminder / deleteReminder (protectedProcedure)
|
||||
- createBroadcast (managerProcedure): Targeted Notification an Gruppe
|
||||
- listBroadcasts (managerProcedure)
|
||||
- createTask (managerProcedure): Task fuer User/Gruppe
|
||||
- assignTask (managerProcedure): Task zuweisen
|
||||
- delete (protectedProcedure): eigene Notifications loeschen
|
||||
|
||||
- [ ] **Task 6:** Broadcast-Targeting -> `packages/api/src/lib/notification-targeting.ts` (create)
|
||||
- resolveRecipients(targetType, targetValue, db): User-IDs aufloesen
|
||||
- "user" -> einzelner User
|
||||
- "role" -> alle User mit SystemRole
|
||||
- "project" -> Ressourcen mit aktiver Allokation -> verknuepfte User
|
||||
- "orgUnit" -> Ressourcen in OrgUnit -> verknuepfte User
|
||||
- "all" -> alle aktiven User
|
||||
|
||||
- [ ] **Task 7:** Email-Templates -> `packages/api/src/lib/email.ts` (edit)
|
||||
- sendNotificationEmail(userId, notification): HTML mit Title, Body, Deep-Link
|
||||
- sendTaskEmail(userId, task): Template mit Task-Details + Action-Link
|
||||
|
||||
- [ ] **Task 8:** Task-Action-Registry -> `packages/api/src/lib/task-actions.ts` (create)
|
||||
- Registry-Pattern: action_name -> { permission, execute(entityId, ctx) }
|
||||
- Initiale Actions: approve_vacation, reject_vacation, fill_demand, confirm_allocation
|
||||
|
||||
- [ ] **Task 9:** Reminder-Scheduler -> `packages/api/src/lib/reminder-scheduler.ts` (create)
|
||||
- Intervall (60s): WHERE nextRemindAt <= NOW()
|
||||
- Fuer jeden faelligen Reminder: In-App Notification + optional Email
|
||||
- nextRemindAt neu berechnen oder null setzen
|
||||
- Catch-up bei Start (ueberfaellige sofort ausloesen)
|
||||
|
||||
### Phase N.3 — AI Assistant Integration
|
||||
|
||||
- [ ] **Task 10:** Neue Tool-Definitionen -> `packages/api/src/router/assistant-tools.ts`
|
||||
- list_tasks: offene Tasks/Approvals mit Filter
|
||||
- get_task_detail: Details inkl. verknuepfter Entity
|
||||
- update_task_status: Status aendern
|
||||
- execute_task_action: maschinenlesbare Aktion ausfuehren
|
||||
- create_reminder: Erinnerung anlegen
|
||||
- create_task_for_user: Task fuer anderen User (Manager-only)
|
||||
- send_broadcast: Notification an Gruppe (Manager-only)
|
||||
|
||||
- [ ] **Task 11:** Tool-Executors implementieren -> `packages/api/src/router/assistant-tools.ts`
|
||||
- execute_task_action: parst taskAction-String, dispatcht an Action-Registry
|
||||
- Permission-Check pro Action (nicht pauschal)
|
||||
|
||||
- [ ] **Task 12:** Permission-Map + Prompt -> `packages/api/src/router/assistant.ts`
|
||||
- TOOL_PERMISSION_MAP erweitern
|
||||
- System-Prompt: Tasks/Reminders als Faehigkeit beschreiben
|
||||
|
||||
### Phase N.4 — Frontend
|
||||
|
||||
- [ ] **Task 13:** NotificationBell erweitern -> `apps/web/src/components/notifications/NotificationBell.tsx`
|
||||
- Zweiter Badge: Task-Count (orange) neben Notification-Count (rot)
|
||||
- Tabs: "Alle" | "Tasks" | "Erinnerungen"
|
||||
- Task-Items mit Quick-Actions (Done/Dismiss)
|
||||
- Link zu "/notifications"
|
||||
|
||||
- [ ] **Task 14:** TaskCard-Komponente -> `apps/web/src/components/notifications/TaskCard.tsx` (create)
|
||||
- Titel, Body, Due-Date, Priority-Badge, Entity-Link
|
||||
- Aktionen: "Start" / "Done" / "Dismiss"
|
||||
- Approval-Variante: "Approve" / "Reject"
|
||||
- Priority-farbcodiert (URGENT=rot, HIGH=orange, NORMAL=blau, LOW=grau)
|
||||
|
||||
- [ ] **Task 15:** ReminderModal -> `apps/web/src/components/notifications/ReminderModal.tsx` (create)
|
||||
- Titel, Body, Datum/Uhrzeit, Wiederholung (keine/taeglich/woechentlich/monatlich)
|
||||
- Optional: Entity-Verknuepfung (Projekt/Ressource Dropdown)
|
||||
|
||||
- [ ] **Task 16:** BroadcastModal -> `apps/web/src/components/notifications/BroadcastModal.tsx` (create)
|
||||
- Manager/Admin-only
|
||||
- Targeting: Dropdown (Alle/Rolle/Projekt/OrgUnit) + Wert-Auswahl
|
||||
- Inhalt: Titel, Body, Priority, Kategorie
|
||||
- Kanal: In-App / Email / Beides
|
||||
- Scheduling: Sofort / Zeitgesteuert
|
||||
- Vorschau: "Wird an X Empfaenger gesendet"
|
||||
|
||||
- [ ] **Task 17:** NotificationCenter -> `apps/web/src/app/(app)/notifications/page.tsx` (create)
|
||||
- Tabs: Alle | Notifications | Tasks | Reminders | Approvals
|
||||
- Filter: Status, Priority, Zeitraum
|
||||
- Bulk: "Alle lesen", "Alle erledigt"
|
||||
- "Neue Erinnerung" Button
|
||||
|
||||
- [ ] **Task 18:** TaskWidget -> `apps/web/src/components/dashboard/widgets/TaskWidget.tsx` (create)
|
||||
- Kompakte Liste offener Tasks (max 5-7)
|
||||
- Sortiert: Priority -> Due-Date
|
||||
- Quick-Actions: Done/Dismiss
|
||||
- Footer: "X offene Tasks — Alle anzeigen"
|
||||
- In Widget-Registry eintragen
|
||||
|
||||
- [ ] **Task 19:** Admin Broadcast-Seite -> `apps/web/src/app/(app)/admin/notifications/page.tsx` (create)
|
||||
- Liste gesendeter Broadcasts
|
||||
- "Neue Benachrichtigung senden" Button
|
||||
- Statistiken: gesendet/gelesen pro Broadcast
|
||||
|
||||
- [ ] **Task 20:** AppShell Navigation -> `apps/web/src/components/layout/AppShell.tsx` (edit)
|
||||
- "Notifications" fuer alle Rollen
|
||||
- "Broadcast" unter Admin (ADMIN/MANAGER)
|
||||
|
||||
- [ ] **Task 21:** SSE-Hook -> `apps/web/src/hooks/useTimelineSSE.ts` (edit)
|
||||
- Auf TASK_ASSIGNED, TASK_COMPLETED, REMINDER_DUE reagieren
|
||||
- React-Query invalidieren: notification.listTasks, notification.taskCounts
|
||||
|
||||
### Phase N.5 — Auto-Tasks & Audit
|
||||
|
||||
- [ ] **Task 22:** Automatische Task-Erzeugung bei Business-Events
|
||||
- vacation.create -> Task "Urlaubsantrag genehmigen" an Manager (APPROVAL)
|
||||
- Ueberallokation -> Task "Ueberallokation aufloesen" an Manager
|
||||
- Projekt-Deadline < 30 Tage + offene Demands -> Task "Demands besetzen"
|
||||
- demand.create -> Task "Demand besetzen" an Manager
|
||||
|
||||
- [ ] **Task 23:** Audit-Trail -> `packages/api/src/lib/audit.ts` (create)
|
||||
- logTaskAction(taskId, userId, action, details)
|
||||
- completedBy: "ai-assistant" fuer AI-erledigte Tasks
|
||||
|
||||
---
|
||||
|
||||
## Abhaengigkeiten
|
||||
|
||||
```
|
||||
Task 1 (Shared Types) ---+
|
||||
Task 2 (Schema) ---------+--> Task 3 (db:push)
|
||||
|
|
||||
Task 3 --> Task 4 (SSE) |
|
||||
Task 3 --> Task 5 (Router)|
|
||||
Task 3 --> Task 6 (Targeting)
|
||||
Task 3 --> Task 7 (Email) |
|
||||
Task 3 --> Task 8 (Actions)|
|
||||
|
|
||||
Task 5 + 6 --> Task 9 (Scheduler)
|
||||
Task 5 + 8 --> Task 10-12 (AI)
|
||||
|
|
||||
Task 5 --> Task 13-21 (Frontend, parallel moeglich)
|
||||
Task 5 --> Task 22 (Auto-Tasks)
|
||||
```ts
|
||||
interface MultiSelectState {
|
||||
isSelecting: boolean;
|
||||
// Rectangle coordinates (canvas-relative pixels)
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
// Resolved after mouseUp:
|
||||
selectedAllocationIds: string[];
|
||||
selectedResourceIds: string[]; // Resources within the rectangle (for empty-row assign)
|
||||
dateRange: { start: Date; end: Date } | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel:**
|
||||
- Task 4 + 5 + 6 + 7 + 8 (verschiedene Dateien)
|
||||
- Task 13-21 (verschiedene Dateien, 13+14 vor 17+18 empfohlen)
|
||||
### Intersection-Logik
|
||||
Die Selektion geschieht als **Rectangle Intersection**:
|
||||
1. Während des Drag wird ein visuelles Rechteck gezeichnet (semi-transparenter blauer Rahmen)
|
||||
2. Bei mouseUp wird berechnet, welche Allocation-Blocks innerhalb des Rechtecks liegen (Pixel-basiert: Block-Position vs. Selection-Rect)
|
||||
3. Selektierte Blocks erhalten einen visuellen Highlight (z.B. `ring-2 ring-brand-500`)
|
||||
|
||||
**Sequentiell:**
|
||||
- Task 1 -> 2 -> 3 (Schema)
|
||||
- Task 5 -> 10 -> 11 (Router -> Tools -> Executors)
|
||||
**Berechnung der Intersection:** Da die Allocation-Blocks als absolute `left/width/top/height` positioniert sind, können wir die Intersection über die `toLeft()`/`toWidth()`-Funktionen + Zeilen-Index berechnen. Die Berechnung geschieht im `TimelineResourcePanel`/`TimelineProjectPanel` und gibt IDs zurück.
|
||||
|
||||
---
|
||||
### Batch-API
|
||||
- **Delete:** `allocation.batchDelete` existiert bereits (max 100 IDs)
|
||||
- **Assign:** Neuer `timeline.batchQuickAssign`-Endpoint, der ein Array von `{ resourceId, projectId, startDate, endDate, hoursPerDay }` akzeptiert und in einer Transaktion erstellt
|
||||
|
||||
## Task-Liste
|
||||
|
||||
### Task 1: Multi-Select State im Drag-Hook
|
||||
|
||||
- [ ] **Task 1a:** `MultiSelectState` Interface + Initial State definieren → Datei: `useTimelineDrag.ts`
|
||||
|
||||
```ts
|
||||
export interface MultiSelectState {
|
||||
isSelecting: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
selectedAllocationIds: string[];
|
||||
selectedResourceIds: string[];
|
||||
dateRange: { start: Date; end: Date } | null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Task 1b:** Right-click drag handlers implementieren → Datei: `useTimelineDrag.ts`
|
||||
|
||||
Neuer `onCanvasRightMouseDown` Handler:
|
||||
- Prüfe `e.button === 2`
|
||||
- `e.preventDefault()` (verhindert nativen Kontextmenü)
|
||||
- Starte `multiSelectState` mit `isSelecting: true` und Mausposition
|
||||
- Registriere `document.addEventListener("mousemove", ...)` und `document.addEventListener("mouseup", ...)` (analog zum AllocDrag-Pattern)
|
||||
- Bei mousemove: Update `currentX/currentY`
|
||||
- Bei mouseUp ohne Bewegung (< 5px): Fallback auf bestehenden `onAllocationContextMenu` (Einzelblock-Rechtsklick)
|
||||
- Bei mouseUp mit Bewegung: Setze `isSelecting: false` aber behalte `selectedAllocationIds`/`selectedResourceIds` (werden vom Parent berechnet und reingesetzt)
|
||||
|
||||
Neuer `onCanvasContextMenu` Handler:
|
||||
- Wird auf dem Canvas registriert, um `e.preventDefault()` global zu setzen (verhindert Browser-Kontextmenü)
|
||||
|
||||
Return-Werte erweitern um `multiSelectState`, `setMultiSelectState`, `onCanvasRightMouseDown`, `clearMultiSelect`.
|
||||
|
||||
- [ ] **Task 1c:** `clearMultiSelect` Funktion → Datei: `useTimelineDrag.ts`
|
||||
|
||||
Setzt `multiSelectState` auf Initial zurück. Wird von ESC-Handler und FloatingActionBar genutzt.
|
||||
|
||||
### Task 1d: Shift+Click Toggle-Selection
|
||||
|
||||
- [ ] **Task 1d:** Shift+Click Handler für Allocation-Blocks → Datei: `useTimelineDrag.ts`
|
||||
|
||||
Im bestehenden `onAllocMouseDown`-Handler (und im mouseUp-Pfad wo `daysDelta === 0` als Click behandelt wird):
|
||||
|
||||
**Logik im mouseUp (daysDelta === 0):**
|
||||
```ts
|
||||
if (e.shiftKey) {
|
||||
// Toggle this allocation in multi-select
|
||||
setMultiSelectState(prev => {
|
||||
const ids = new Set(prev.selectedAllocationIds);
|
||||
if (ids.has(alloc.allocationId)) {
|
||||
ids.delete(alloc.allocationId); // Deselect
|
||||
} else {
|
||||
ids.add(alloc.allocationId); // Add to selection
|
||||
}
|
||||
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
|
||||
});
|
||||
return; // Don't open popover
|
||||
}
|
||||
// ... existing click → popover logic
|
||||
```
|
||||
|
||||
**Wichtig:** Der Shift-Key-Check muss im mouseUp geschehen (nicht mouseDown), weil erst dort feststeht ob es ein Click (daysDelta === 0) oder Drag war.
|
||||
|
||||
**Interaktion mit bestehendem Code:**
|
||||
- `onAllocMouseDown` startet den Drag (button 0 check auf Zeile 311)
|
||||
- Im `handleUp` closure (Zeile 370): Wenn `daysDelta === 0` → aktuell wird `onBlockClickRef.current` aufgerufen
|
||||
- **Änderung:** Vor dem `onBlockClick`-Call prüfen ob `shiftKey` gedrückt war (muss im mouseDown-Event gespeichert werden, da mouseUp ein document-event ist und das Original-React-Event nicht mehr verfügbar)
|
||||
- → `shiftKeyRef` im Closure capturen: `const wasShift = e.shiftKey;` im `onAllocMouseDown`
|
||||
|
||||
Neuer Callback in Hook-Return: `onShiftClickAllocation?: (allocationId: string) => void` — wird vom Parent (`TimelineView`) gesetzt und toggelt den multiSelectState.
|
||||
|
||||
**Alternativ (einfacher):** Den Shift-Check direkt in `TimelineView.onBlockClick` machen, da dieser Callback bereits den `multiSelectState`-Zugriff hat.
|
||||
|
||||
### Task 2: Selection-Overlay in Panels rendern
|
||||
|
||||
- [ ] **Task 2a:** Selection-Rectangle als visuelles Overlay rendern → Datei: `TimelineResourcePanel.tsx`
|
||||
|
||||
Props erweitern um `multiSelectState: MultiSelectState`.
|
||||
|
||||
Neues `<div>` im Canvas-Bereich:
|
||||
```tsx
|
||||
{multiSelectState.isSelecting && (
|
||||
<div
|
||||
className="absolute border-2 border-brand-500 bg-brand-500/10 pointer-events-none z-30 rounded"
|
||||
style={{
|
||||
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
|
||||
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
|
||||
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
|
||||
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
Allocation-Blocks die in `selectedAllocationIds` sind: Extra CSS-Klasse `ring-2 ring-brand-500 ring-offset-1 z-20`.
|
||||
|
||||
- [ ] **Task 2b:** Dasselbe für `TimelineProjectPanel.tsx`
|
||||
|
||||
### Task 3: Intersection-Berechnung
|
||||
|
||||
- [ ] **Task 3a:** Funktion `computeSelectedAllocations` → Datei: `TimelineResourcePanel.tsx` (oder neue Utility-Datei)
|
||||
|
||||
Wird als `useMemo` in `TimelineViewContent` berechnet. Nimmt `multiSelectState` + Layout-Daten (`toLeft`, `toWidth`, Row-Höhen, Ressource-Reihenfolge) und gibt `{ allocationIds: string[], resourceIds: string[], dateRange }` zurück.
|
||||
|
||||
**Algorithmus:**
|
||||
1. Konvertiere Pixel-Rechteck zu Datums-Range (via `xToDate`) und Zeilen-Range (via Row-Index-Berechnung)
|
||||
2. Für jede Ressource im Zeilen-Range: Prüfe alle Allocations ob sie zeitlich überlappen
|
||||
3. Sammle Treffer-IDs
|
||||
|
||||
**Wichtig:** Die Berechnung muss die Scroll-Position des Containers berücksichtigen (`scrollContainerRef.scrollLeft/scrollTop`).
|
||||
|
||||
- [ ] **Task 3b:** `setMultiSelectState` mit berechneten IDs updaten → Datei: `TimelineView.tsx`
|
||||
|
||||
Nach der Intersection-Berechnung via `useEffect`: Wenn `multiSelectState` sich ändert und nicht mehr `isSelecting`, update die `selectedAllocationIds`/`selectedResourceIds`.
|
||||
|
||||
### Task 4: Floating Action Bar
|
||||
|
||||
- [ ] **Task 4a:** `FloatingActionBar` Komponente erstellen → Datei: `FloatingActionBar.tsx`
|
||||
|
||||
```tsx
|
||||
interface FloatingActionBarProps {
|
||||
selectedCount: number;
|
||||
selectedResourceCount: number;
|
||||
onDelete: () => void;
|
||||
onAssign: () => void;
|
||||
onClear: () => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Positionierung: `fixed bottom-6 left-1/2 -translate-x-1/2` — zentriert am unteren Bildschirmrand.
|
||||
|
||||
UI: Pill-förmige Bar mit:
|
||||
- Zähler: "3 allocations selected" oder "5 resources × 10 days selected"
|
||||
- Delete-Button (rot, nur wenn Allocations selektiert)
|
||||
- Assign-Button (brand, nur wenn leere Ressource-Zeilen im Bereich)
|
||||
- Clear-Button (grau)
|
||||
- Keyboard hint: "ESC to clear"
|
||||
|
||||
Dark-Mode: `dark:bg-gray-800 dark:border-gray-700` etc.
|
||||
|
||||
- [ ] **Task 4b:** FloatingActionBar in `TimelineView.tsx` einbinden
|
||||
|
||||
Rendern wenn `multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0`.
|
||||
|
||||
Actions verdrahten:
|
||||
- Delete → `allocation.batchDelete.mutate({ ids: selectedAllocationIds })`
|
||||
- Assign → öffnet `BatchAssignPopover`
|
||||
- Clear → `clearMultiSelect()`
|
||||
|
||||
ESC-Handler erweitern: Multi-Select hat Priorität vor anderen Overlays.
|
||||
|
||||
### Task 5: Batch-Assign Popover
|
||||
|
||||
- [ ] **Task 5a:** `BatchAssignPopover` Komponente erstellen → Datei: `BatchAssignPopover.tsx`
|
||||
|
||||
Ähnlich wie `NewAllocationPopover`, aber für mehrere Ressourcen:
|
||||
- Projekt-Picker (Dropdown mit Suche)
|
||||
- Hours/day Selector
|
||||
- Anzeige: "Assigning to N resources, Start – End"
|
||||
- "Assign All" Button
|
||||
|
||||
Props:
|
||||
```tsx
|
||||
interface BatchAssignPopoverProps {
|
||||
resourceIds: string[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Task 5b:** `batchQuickAssign` API-Endpoint erstellen → Datei: `packages/api/src/router/timeline.ts`
|
||||
|
||||
```ts
|
||||
batchQuickAssign: managerProcedure
|
||||
.input(z.object({
|
||||
assignments: z.array(z.object({
|
||||
resourceId: z.string(),
|
||||
projectId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0.5).max(24).default(8),
|
||||
role: z.string().min(1).max(200).default("Team Member"),
|
||||
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
||||
})).min(1).max(50),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
// Validate all, then create in single transaction
|
||||
})
|
||||
```
|
||||
|
||||
### Task 6: Integration & Polish
|
||||
|
||||
- [ ] **Task 6a:** `onContextMenu` auf Canvas-Level `e.preventDefault()` setzen → Datei: `TimelineView.tsx`
|
||||
|
||||
Damit das Browser-Kontextmenü nicht erscheint während der Rechtsklick-Drag aktiv ist.
|
||||
|
||||
- [ ] **Task 6b:** ESC-Handler Priorität anpassen → Datei: `TimelineView.tsx`
|
||||
|
||||
Reihenfolge: Multi-Select clear → Popover → NewAllocPopover → DemandModal → ProjectPanel
|
||||
|
||||
- [ ] **Task 6c:** Cursor-Style anpassen → Datei: `TimelineView.tsx`
|
||||
|
||||
`cursor-crosshair` wenn Multi-Select aktiv.
|
||||
|
||||
- [ ] **Task 6d:** Confirmation-Dialog vor Batch-Delete → Datei: `FloatingActionBar.tsx` oder `TimelineView.tsx`
|
||||
|
||||
Einfacher `window.confirm()` oder inline-Bestätigung: "Delete 5 allocations? This cannot be undone."
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
- Task 1 (Hook) muss vor Task 2–6 abgeschlossen sein (State-Grundlage)
|
||||
- Task 2 (Overlay) und Task 3 (Intersection) können parallel entwickelt werden, aber Task 3 hängt logisch von Task 2 ab (Overlay-Koordinaten)
|
||||
- Task 4 (ActionBar) hängt von Task 1 ab (braucht multiSelectState)
|
||||
- Task 5a (BatchAssignPopover) ist unabhängig vom Rest
|
||||
- Task 5b (API) ist unabhängig vom Rest
|
||||
- Task 6 (Integration) kommt zuletzt
|
||||
|
||||
**Empfohlene Reihenfolge:** Task 1a → 1b → 1c → parallel(Task 2a+2b, Task 5b) → Task 3a → 3b → Task 4a → 4b → Task 5a → Task 6a–6d
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] `pnpm db:push` ohne Fehler
|
||||
- [ ] `pnpm --filter @planarchy/api exec tsc --noEmit` — 0 Errors
|
||||
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — 0 Errors
|
||||
- [ ] `pnpm test:unit` — alle Tests gruen
|
||||
- [ ] User kann eigene Erinnerung anlegen (Datum, Wiederholung, Entity)
|
||||
- [ ] Admin/Manager kann Broadcast an Rolle/Projekt/OrgUnit senden
|
||||
- [ ] Broadcast erzeugt individuelle Notifications pro Empfaenger
|
||||
- [ ] Tasks im Dashboard-Widget, sortiert nach Priority + Due-Date
|
||||
- [ ] Task-Status aenderbar ueber UI (Open -> In Progress -> Done/Dismissed)
|
||||
- [ ] AI-Assistent kann list_tasks aufrufen und offene Tasks anzeigen
|
||||
- [ ] AI-Assistent kann execute_task_action ausfuehren (z.B. Urlaub genehmigen)
|
||||
- [ ] Erledigte Tasks zeigen completedBy (User oder "AI-Assistent")
|
||||
- [ ] Email-Versand bei channel "email" oder "both"
|
||||
- [ ] SSE-Events invalidieren React-Query-Caches
|
||||
- [ ] Reminder-Scheduler erzeugt puenktlich Notifications
|
||||
- [ ] RBAC: User sehen nur eigene; Manager zugewiesene; Admin Broadcasts
|
||||
|
||||
---
|
||||
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
|
||||
- [ ] `pnpm --filter @planarchy/api exec tsc --noEmit` — keine neuen Errors
|
||||
- [ ] Resource View: Rechtsklick + Drag zeichnet Selection-Rechteck über Canvas
|
||||
- [ ] Resource View: Allocation-Blocks innerhalb des Rechtecks werden highlighted
|
||||
- [ ] Floating Action Bar erscheint mit korrektem Zähler
|
||||
- [ ] "Delete" löscht alle selektierten Allocations nach Bestätigung
|
||||
- [ ] "Assign" öffnet BatchAssignPopover mit korrekten Ressourcen und Datumrange
|
||||
- [ ] BatchAssign erstellt Allocations für alle selektierten Ressourcen in einer Transaktion
|
||||
- [ ] Project View: Multi-Selection funktioniert analog
|
||||
- [ ] Rechtsklick auf einzelnen Block ohne Drag: Öffnet weiterhin AllocationPopover
|
||||
- [ ] Shift+Click auf Allocation-Block: Fügt ihn zur Multi-Selection hinzu (FloatingActionBar erscheint)
|
||||
- [ ] Shift+Click auf bereits selektierten Block: Entfernt ihn aus der Selection
|
||||
- [ ] Shift+Click + Rechtsklick-Drag kombinierbar: Erst Shift-Clicks, dann Lasso erweitert die Selection
|
||||
- [ ] ESC räumt Multi-Selection auf
|
||||
- [ ] Dark Mode: Alle neuen Komponenten haben `dark:` Klassen
|
||||
- [ ] Browser-Kontextmenü wird unterdrückt während Multi-Select aktiv
|
||||
|
||||
## Risiken & offene Fragen
|
||||
|
||||
### Risiken
|
||||
1. **Reminder-Scheduler Zuverlaessigkeit**: Node.js-setInterval kann bei Restart verpassen. Mitigation: Catch-up bei Start (alle ueberfaelligen sofort ausloesen).
|
||||
2. **Broadcast-Skalierung**: "An alle" mit 500 Usern = 500 Rows. Mitigation: Batch-Insert (createMany).
|
||||
3. **Task-Action-Sicherheit**: Permissions pro Action pruefen, nicht pauschal. Mitigation: Action-Registry mit Permission pro Handler.
|
||||
4. **Schema-Migration**: Neue Felder nullable oder mit Default -> bestehende Notifications funktionieren weiter.
|
||||
|
||||
### Offene Fragen
|
||||
1. **Scheduler**: setInterval im SSE-Handler oder separater Worker/Cron? Empfehlung: setInterval (reicht fuer <1000 User)
|
||||
2. **Task-Delegation**: User duerfen Tasks an andere weiterdelegieren? Empfehlung: Ja (Manager-only)
|
||||
3. **Retention**: Wie lange alte Notifications aufbewahren? Empfehlung: 90 Tage Auto-Cleanup
|
||||
4. **Recurring Tasks**: Tasks wiederkehrend wie Reminders? Empfehlung: Phase 2
|
||||
5. **Approval-Chains**: Mehrstufige Genehmigung? Empfehlung: Phase 2, erstmal einstufig
|
||||
- **Scroll-Position:** Die Intersection-Berechnung muss `scrollLeft`/`scrollTop` berücksichtigen. Sonst stimmen die Pixel-Koordinaten nicht mit den Allocation-Positionen überein.
|
||||
- **Virtualisierung:** `TimelineResourcePanel` nutzt `@tanstack/react-virtual`. Nicht-sichtbare Zeilen sind nicht im DOM → die Intersection-Berechnung muss auf Daten-Ebene (nicht DOM-Ebene) erfolgen.
|
||||
- **Performance:** Bei vielen Allocations könnte die Intersection-Berechnung während des Drag teuer werden. Lösung: Nur bei mouseUp berechnen, nicht während mousemove.
|
||||
- **Rechtsklick-Einzelblock:** Muss sauber vom Drag unterschieden werden (< 5px Threshold). Der bestehende `onContextMenu`-Handler auf Allocation-Blocks (`e.stopPropagation()`) sollte erhalten bleiben.
|
||||
- **Touch-Support:** Rechtsklick hat kein Touch-Äquivalent. Long-press wäre möglich, ist aber ein separates Feature. Zunächst nur Mouse.
|
||||
- **`batchQuickAssign` Limit:** Max 50 Assignments pro Call, um DB-Last zu begrenzen.
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 135 KiB |
Reference in New Issue
Block a user