feat: Sprint 1 — staffing assign, dashboard cache, bulk ops, notifications

Staffing "Assign" Button:
- Inline assignment form on each suggestion card in StaffingPanel
- Pre-fills project, dates, hours from search criteria
- 1-click confirm creates allocation with PROPOSED status
- Success/error toasts, removes assigned suggestions from list

Dashboard Redis Caching:
- New cache utility (packages/api/src/lib/cache.ts) with get/set/invalidate
- All 5 dashboard queries wrapped with 60s TTL cache-aside pattern
- Auto-invalidation on allocation + project mutations (fire-and-forget)
- Graceful fallthrough to DB if Redis unavailable

Bulk Operations:
- CSV export for selected resources and projects (apps/web/src/lib/csv-export.ts)
- Project batch delete mutation with cascade (assignments, demands, rules)
- Export/Delete buttons added to BatchActionBar on both list pages

Budget Overrun Notifications:
- checkBudgetThresholds() alerts at 80% (HIGH) and 100% (URGENT)
- Called after every allocation mutation, duplicate-safe
- Targets ADMIN + MANAGER users with SSE delivery

Estimate Approval Reminders:
- checkPendingEstimateReminders() finds SUBMITTED versions > 3 days old
- Cron endpoint: GET /api/cron/estimate-reminders (optional CRON_SECRET auth)
- Creates in-app REMINDER notifications, duplicate-safe

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 20:43:36 +01:00
parent 0d78fe1770
commit 4118995319
14 changed files with 1042 additions and 71 deletions
@@ -10,6 +10,7 @@ import Image from "next/image";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
import { ProjectModal } from "~/components/projects/ProjectModal.js"; import { ProjectModal } from "~/components/projects/ProjectModal.js";
import { ProjectWizard } from "~/components/projects/ProjectWizard.js"; import { ProjectWizard } from "~/components/projects/ProjectWizard.js";
import { useSelection } from "~/hooks/useSelection.js"; import { useSelection } from "~/hooks/useSelection.js";
@@ -183,6 +184,7 @@ export function ProjectsClient() {
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null); const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
const selection = useSelection(); const selection = useSelection();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -195,6 +197,13 @@ export function ProjectsClient() {
}, },
}); });
const batchDeleteMutation = trpc.project.batchDelete.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
selection.clear();
},
});
// ─── Favorites ────────────────────────────────────────────────────────── // ─── Favorites ──────────────────────────────────────────────────────────
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 }); const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]); const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]);
@@ -336,6 +345,25 @@ export function ProjectsClient() {
function closeModal() { setModalOpen(false); setEditingProject(null); } function closeModal() { setModalOpen(false); setEditingProject(null); }
function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); } function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); }
const exportSelectedCsv = useCallback(() => {
const selected = projects.filter((p) => selection.selectedIds.has(p.id));
if (selected.length === 0) return;
const csv = generateCsv(selected, [
{ header: "Short Code", accessor: (p) => p.shortCode },
{ header: "Name", accessor: (p) => p.name },
{ header: "Status", accessor: (p) => p.status },
{ header: "Order Type", accessor: (p) => p.orderType },
{ header: "Start Date", accessor: (p) => formatDate(p.startDate) },
{ header: "End Date", accessor: (p) => formatDate(p.endDate) },
{ header: "Budget (cents)", accessor: (p) => p.budgetCents },
{ header: "Win Probability", accessor: (p) => p.winProbability },
{ header: "Total Cost (cents)", accessor: (p) => p.totalCostCents },
{ header: "Person Days", accessor: (p) => p.totalPersonDays },
{ header: "Utilization %", accessor: (p) => p.utilizationPercent },
]);
downloadCsv(csv, `projects-export-${new Date().toISOString().slice(0, 10)}.csv`);
}, [projects, selection.selectedIds]);
const chips = [ const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []), ...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
@@ -682,12 +710,34 @@ export function ProjectsClient() {
/> />
)} )}
{/* Confirm batch delete */}
{confirmBatchDelete && (
<ConfirmDialog
title="Delete Projects"
message={`Permanently delete ${confirmBatchDelete.length} project${confirmBatchDelete.length !== 1 ? "s" : ""}? This will also remove all associated allocations and demands. This action cannot be undone.`}
confirmLabel="Delete All"
variant="danger"
onConfirm={() => {
batchDeleteMutation.mutate({ ids: confirmBatchDelete });
setConfirmBatchDelete(null);
}}
onCancel={() => setConfirmBatchDelete(null)}
/>
)}
{/* Batch Action Bar */} {/* Batch Action Bar */}
<BatchActionBar <BatchActionBar
count={selection.count} count={selection.count}
onClear={selection.clear} onClear={selection.clear}
actions={[ actions={[
{ label: "Set Status…", onClick: () => setBatchStatusPicker(true) }, { label: "Export Selected", onClick: exportSelectedCsv },
{ label: "Set Status...", onClick: () => setBatchStatusPicker(true) },
{
label: `Delete (${selection.count})`,
variant: "danger" as const,
onClick: () => setConfirmBatchDelete(selection.selectedArray),
disabled: batchDeleteMutation.isPending,
},
]} ]}
/> />
@@ -8,6 +8,7 @@ import { RESOURCE_COLUMNS } from "@planarchy/shared";
import { BlueprintTarget, ResourceType } from "@planarchy/shared"; import { BlueprintTarget, ResourceType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { formatMoney } from "~/lib/format.js"; import { formatMoney } from "~/lib/format.js";
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ResourceModal } from "~/components/resources/ResourceModal.js"; import { ResourceModal } from "~/components/resources/ResourceModal.js";
import { BulkEditModal } from "~/components/resources/BulkEditModal.js"; import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
@@ -508,6 +509,22 @@ export function ResourcesClient() {
return "Departed: no"; return "Departed: no";
}, [departedFilter]); }, [departedFilter]);
const exportSelectedCsv = useCallback(() => {
const selected = displayedResources.filter((r) => selection.selectedIds.has(r.id));
if (selected.length === 0) return;
const csv = generateCsv(selected, [
{ header: "EID", accessor: (r) => r.eid },
{ header: "Name", accessor: (r) => r.displayName },
{ header: "Email", accessor: (r) => r.email },
{ header: "Chapter", accessor: (r) => r.chapter ?? "" },
{ header: "LCR (cents)", accessor: (r) => r.lcrCents },
{ header: "Currency", accessor: (r) => r.currency },
{ header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget },
{ header: "Active", accessor: (r) => r.isActive ? "Yes" : "No" },
]);
downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`);
}, [displayedResources, selection.selectedIds]);
const chips = [ const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(chapterFilter.length > 0 ...(chapterFilter.length > 0
@@ -1429,10 +1446,15 @@ export function ResourcesClient() {
count={selection.count} count={selection.count}
onClear={selection.clear} onClear={selection.clear}
actions={[ actions={[
{
label: "Export Selected",
variant: "default" as const,
onClick: exportSelectedCsv,
},
...(filterableFields.length > 0 ...(filterableFields.length > 0
? [ ? [
{ {
label: "Edit Custom Fields", label: "Bulk Edit",
variant: "default" as const, variant: "default" as const,
onClick: () => setModal({ type: "bulkEdit" }), onClick: () => setModal({ type: "bulkEdit" }),
disabled: false, disabled: false,
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { prisma } from "@planarchy/db";
import { checkPendingEstimateReminders } from "@planarchy/api";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* GET /api/cron/estimate-reminders
*
* Scans for estimates that have been in SUBMITTED status for more than 3 days
* without approval and creates in-app reminder notifications for managers.
*
* Intended to be called by an external cron job (e.g. `curl http://host/api/cron/estimate-reminders`)
* or a scheduled task runner.
*
* Optionally protect this endpoint with a shared secret via the `CRON_SECRET`
* environment variable. When set, requests must include the header
* `Authorization: Bearer <secret>`.
*/
export async function GET(request: Request) {
const cronSecret = process.env["CRON_SECRET"];
if (cronSecret) {
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reminderCount = await checkPendingEstimateReminders(prisma as any);
return NextResponse.json({
ok: true,
remindersCreated: reminderCount,
checkedAt: new Date().toISOString(),
});
} catch (error) {
console.error("[cron/estimate-reminders] Error:", error);
return NextResponse.json(
{ ok: false, error: "Internal error" },
{ status: 500 },
);
}
}
@@ -160,7 +160,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const rawTop = data?.top ?? []; const rawTop = data?.top ?? [];
const rawWatch = data?.watchlist ?? []; const rawWatch = data?.watchlist ?? [];
const month = data?.month ?? ""; const month = (data?.month as string) ?? "";
const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => { const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => {
const mult = topDir === "asc" ? 1 : -1; const mult = topDir === "asc" ? 1 : -1;
@@ -40,7 +40,7 @@ export function TopValueWidget({ config }: WidgetProps) {
); );
} }
const list = data ?? []; const list = (data ?? []) as Array<{ id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number; valueScore: number | null }>;
if (list.length === 0) { if (list.length === 0) {
return ( return (
@@ -1,10 +1,20 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationStatus } from "@planarchy/shared";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js"; import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
import { Button } from "@planarchy/ui";
interface SearchCriteria {
startDate: string;
endDate: string;
hoursPerDay: number;
}
export function StaffingPanel() { export function StaffingPanel() {
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]); const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
@@ -18,6 +28,14 @@ export function StaffingPanel() {
}); });
const [hoursPerDay, setHoursPerDay] = useState(8); const [hoursPerDay, setHoursPerDay] = useState(8);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set());
const [toast, setToast] = useState<{ show: boolean; message: string; variant: "success" | "warning" }>({
show: false,
message: "",
variant: "success",
});
const clearToast = useCallback(() => setToast((t) => ({ ...t, show: false })), []);
const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery( const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery(
{ {
@@ -29,8 +47,19 @@ export function StaffingPanel() {
{ enabled: submitted }, { enabled: submitted },
); );
const visibleSuggestions = suggestions?.filter((s) => !assignedIds.has(s.resourceId));
const handleAssigned = useCallback((resourceId: string, resourceName: string) => {
setAssignedIds((prev) => new Set(prev).add(resourceId));
setToast({ show: true, message: `${resourceName} assigned successfully`, variant: "success" });
}, []);
const searchCriteria: SearchCriteria = { startDate, endDate, hoursPerDay };
return ( return (
<div className="app-page space-y-6"> <div className="app-page space-y-6">
<SuccessToast show={toast.show} message={toast.message} variant={toast.variant} onDone={clearToast} />
<div className="app-page-header gap-4"> <div className="app-page-header gap-4">
<div className="space-y-3"> <div className="space-y-3">
<span className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-brand-700 dark:border-brand-900/60 dark:bg-brand-900/30 dark:text-brand-200"> <span className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-brand-700 dark:border-brand-900/60 dark:bg-brand-900/30 dark:text-brand-200">
@@ -63,7 +92,7 @@ export function StaffingPanel() {
<SkillTagInput <SkillTagInput
value={requiredSkills} value={requiredSkills}
onChange={setRequiredSkills} onChange={setRequiredSkills}
placeholder="Add skill" placeholder="Add skill..."
/> />
</div> </div>
@@ -134,62 +163,23 @@ export function StaffingPanel() {
</div> </div>
)} )}
{suggestions && suggestions.length === 0 && ( {visibleSuggestions && visibleSuggestions.length === 0 && (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500"> <div className="app-surface-strong py-16 text-center text-sm text-gray-500">
No resources found matching your criteria. {assignedIds.size > 0 ? "All suggestions have been assigned." : "No resources found matching your criteria."}
</div> </div>
)} )}
{suggestions && suggestions.length > 0 && ( {visibleSuggestions && visibleSuggestions.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
{suggestions.map((suggestion, idx) => ( {visibleSuggestions.map((suggestion, idx) => (
<div key={suggestion.resourceId} className="app-surface p-5"> <SuggestionCard
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> key={suggestion.resourceId}
<div className="flex items-center gap-4"> suggestion={suggestion}
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200"> rank={idx + 1}
{idx + 1} searchCriteria={searchCriteria}
</div> onAssigned={handleAssigned}
<div> onError={(msg) => setToast({ show: true, message: msg, variant: "warning" })}
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div> />
<div className="text-sm text-gray-500">{suggestion.eid}</div>
</div>
</div>
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
{skill}
</span>
))}
{suggestion.missingSkills.map((skill) => (
<span key={skill} className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300">
{skill} missing
</span>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} /h</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
{suggestion.availabilityConflicts.length > 0 && (
<span className="font-medium text-amber-600 dark:text-amber-300">
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
</span>
)}
</div>
</div>
))} ))}
</div> </div>
)} )}
@@ -210,6 +200,291 @@ export function StaffingPanel() {
); );
} }
/* -------------------------------------------------------------------------- */
/* Suggestion Card */
/* -------------------------------------------------------------------------- */
interface SuggestionLike {
resourceId: string;
resourceName: string;
eid: string;
score: number;
scoreBreakdown: {
skillScore: number;
availabilityScore: number;
costScore: number;
utilizationScore: number;
};
matchedSkills: string[];
missingSkills: string[];
availabilityConflicts: string[];
estimatedDailyCostCents: number;
currentUtilization: number;
}
interface SuggestionCardProps {
suggestion: SuggestionLike;
rank: number;
searchCriteria: SearchCriteria;
onAssigned: (resourceId: string, resourceName: string) => void;
onError: (message: string) => void;
}
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="app-surface p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
{rank}
</div>
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
<div className="text-sm text-gray-500">{suggestion.eid}</div>
</div>
</div>
<div className="flex items-start gap-3">
<Button
variant="primary"
size="sm"
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? "Cancel" : "Assign"}
</Button>
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
</div>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
{skill}
</span>
))}
{suggestion.missingSkills.map((skill) => (
<span key={skill} className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300">
{skill} missing
</span>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
{suggestion.availabilityConflicts.length > 0 && (
<span className="font-medium text-amber-600 dark:text-amber-300">
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
</span>
)}
</div>
{expanded && (
<AssignForm
resourceId={suggestion.resourceId}
resourceName={suggestion.resourceName}
searchCriteria={searchCriteria}
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
onError={onError}
onCancel={() => setExpanded(false)}
/>
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Inline Assign Form */
/* -------------------------------------------------------------------------- */
interface AssignFormProps {
resourceId: string;
resourceName: string;
searchCriteria: SearchCriteria;
onAssigned: () => void;
onError: (message: string) => void;
onCancel: () => void;
}
function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onError, onCancel }: AssignFormProps) {
const [projectId, setProjectId] = useState("");
const [assignStartDate, setAssignStartDate] = useState(searchCriteria.startDate);
const [assignEndDate, setAssignEndDate] = useState(searchCriteria.endDate);
const [assignHours, setAssignHours] = useState(searchCriteria.hoursPerDay);
const [roleId, setRoleId] = useState("");
const [roleFreeText, setRoleFreeText] = useState("");
const invalidatePlanningViews = useInvalidatePlanningViews();
const { data: projects } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
);
const { data: roles } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createAssignment = (trpc.allocation.createAssignment.useMutation as any)({
onSuccess: () => {
invalidatePlanningViews();
onAssigned();
},
onError: (err: { message: string }) => {
onError(err.message || "Failed to create assignment");
},
}) as { mutate: (input: unknown) => void; isPending: boolean };
const canSubmit = projectId && assignStartDate && assignEndDate && assignHours > 0;
const handleSubmit = () => {
if (!canSubmit) return;
createAssignment.mutate({
resourceId,
projectId,
startDate: new Date(assignStartDate),
endDate: new Date(assignEndDate),
hoursPerDay: assignHours,
percentage: 100,
...(roleId ? { roleId } : {}),
...(roleFreeText ? { role: roleFreeText } : {}),
status: AllocationStatus.PROPOSED,
metadata: {},
});
};
const projectList = (projects as { projects?: Array<{ id: string; name: string; shortCode?: string | null }> } | undefined)?.projects ?? [];
const rolesList = (roles ?? []) as Array<{ id: string; name: string }>;
const selectedProject = projectList.find((p) => p.id === projectId);
return (
<div className="mt-4 rounded-xl border border-brand-200 bg-brand-50/50 p-4 dark:border-brand-900/40 dark:bg-brand-950/30">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Assign {resourceName}
</h4>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="app-label">Project *</label>
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="app-input"
>
<option value="">Select project...</option>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.shortCode ? `[${p.shortCode}] ` : ""}{p.name}
</option>
))}
</select>
</div>
<div>
<label className="app-label">Start Date</label>
<DateInput
value={assignStartDate}
onChange={setAssignStartDate}
className="app-input"
/>
</div>
<div>
<label className="app-label">End Date</label>
<DateInput
value={assignEndDate}
onChange={setAssignEndDate}
min={assignStartDate}
className="app-input"
/>
</div>
<div>
<label className="app-label">Hours / Day</label>
<input
type="number"
value={assignHours}
onChange={(e) => setAssignHours(Number(e.target.value))}
min={0.5}
max={24}
step={0.5}
className="app-input"
/>
</div>
<div>
<label className="app-label">Role</label>
{rolesList.length > 0 ? (
<select
value={roleId}
onChange={(e) => setRoleId(e.target.value)}
className="app-input"
>
<option value="">No role</option>
{rolesList.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
) : (
<input
type="text"
value={roleFreeText}
onChange={(e) => setRoleFreeText(e.target.value)}
placeholder="e.g. 3D Artist"
className="app-input"
/>
)}
</div>
</div>
{selectedProject && (
<div className="mt-3 text-xs text-gray-500">
Assigning to <span className="font-medium text-gray-700 dark:text-gray-300">{selectedProject.name}</span> from {assignStartDate} to {assignEndDate} at {assignHours}h/day
</div>
)}
<div className="mt-4 flex items-center gap-2">
<Button
variant="primary"
size="sm"
disabled={!canSubmit || createAssignment.isPending}
onClick={handleSubmit}
>
{createAssignment.isPending ? "Assigning..." : "Confirm Assignment"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onCancel}
disabled={createAssignment.isPending}
>
Cancel
</Button>
</div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Score Bar */
/* -------------------------------------------------------------------------- */
function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) { function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) {
return ( return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40"> <div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
+39
View File
@@ -0,0 +1,39 @@
/**
* Generic CSV export utility.
* Generates a CSV string from an array of objects and triggers a download.
*/
function escapeCsvValue(value: unknown): string {
if (value == null) return "";
const str = String(value);
// Wrap in quotes if it contains comma, quote, or newline
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
interface CsvColumn<T> {
header: string;
accessor: (row: T) => unknown;
}
export function generateCsv<T>(rows: T[], columns: CsvColumn<T>[]): string {
const header = columns.map((c) => escapeCsvValue(c.header)).join(",");
const body = rows
.map((row) => columns.map((col) => escapeCsvValue(col.accessor(row))).join(","))
.join("\n");
return `${header}\n${body}`;
}
export function downloadCsv(csvContent: string, filename: string): void {
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
+2
View File
@@ -2,3 +2,5 @@ export { appRouter, type AppRouter } from "./router/index.js";
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } 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 { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js"; export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
export { checkBudgetThresholds } from "./lib/budget-alerts.js";
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
+141
View File
@@ -0,0 +1,141 @@
import { listAssignmentBookings } from "@planarchy/application";
import { emitNotificationCreated } from "../sse/event-bus.js";
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
project: {
findUnique: (args: {
where: { id: string };
select: { id: true; name: true; shortCode: true; budgetCents: true };
}) => Promise<{
id: string;
name: string;
shortCode: string;
budgetCents: number;
} | null>;
};
notification: {
findFirst: (args: {
where: {
entityId: string;
entityType: string;
type: string;
};
select: { id: true };
}) => Promise<{ id: string } | null>;
create: (args: {
data: {
userId: string;
type: string;
category: string;
priority: string;
title: string;
body: string;
entityId: string;
entityType: string;
link: string;
channel: string;
};
}) => Promise<{ id: string; userId: string }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
const THRESHOLDS = [
{ percent: 100, type: "BUDGET_OVERRUN_100", label: "100%", priority: "URGENT" as const },
{ percent: 80, type: "BUDGET_OVERRUN_80", label: "80%", priority: "HIGH" as const },
] as const;
/**
* Check whether a project's current spend has crossed 80% or 100% of its budget.
* Creates in-app notifications for all managers/admins when a threshold is
* crossed for the first time.
*
* Safe to call repeatedly -- duplicate notifications are prevented by checking
* whether a notification with the same entityId + type already exists.
*/
export async function checkBudgetThresholds(
db: DbClient,
projectId: string,
): Promise<void> {
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true, shortCode: true, budgetCents: true },
});
if (!project || project.budgetCents <= 0) return;
// Compute total spend from assignment bookings (same logic as listWithCosts)
const bookings = await listAssignmentBookings(db, {
startDate: new Date("1900-01-01T00:00:00.000Z"),
endDate: new Date("2100-12-31T23:59:59.999Z"),
projectIds: [projectId],
});
let totalCostCents = 0;
for (const booking of bookings) {
const days =
(new Date(booking.endDate).getTime() -
new Date(booking.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
totalCostCents += booking.dailyCostCents * days;
}
totalCostCents = Math.round(totalCostCents);
const spendPercent = (totalCostCents / project.budgetCents) * 100;
for (const threshold of THRESHOLDS) {
if (spendPercent < threshold.percent) continue;
// Check if we already sent this alert
const existing = await db.notification.findFirst({
where: {
entityId: projectId,
entityType: "project_budget",
type: threshold.type,
},
select: { id: true },
});
if (existing) continue;
// Get all managers and admins
const managers = await db.user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
select: { id: true },
});
const formattedSpend = (totalCostCents / 100).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const formattedBudget = (project.budgetCents / 100).toLocaleString(
"de-DE",
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
);
for (const manager of managers) {
const notification = await db.notification.create({
data: {
userId: manager.id,
type: threshold.type,
category: "NOTIFICATION",
priority: threshold.priority,
title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`,
body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`,
entityId: projectId,
entityType: "project_budget",
link: `/projects/${projectId}`,
channel: "in_app",
},
});
emitNotificationCreated(manager.id, notification.id);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import { Redis } from "ioredis";
const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380";
const KEY_PREFIX = "dashboard:";
const DEFAULT_TTL_SECONDS = 60;
let redis: Redis | null = null;
function getRedis(): Redis {
if (!redis) {
redis = new Redis(REDIS_URL, {
lazyConnect: false,
enableReadyCheck: false,
// Don't let cache operations block the app if Redis is slow
commandTimeout: 2000,
});
redis.on("error", (e: unknown) => {
console.error("[Redis cache]", e);
});
}
return redis;
}
/**
* Retrieve a cached value by key.
* Returns null on cache miss or if Redis is unavailable.
*/
export async function cacheGet<T>(key: string): Promise<T | null> {
try {
const raw = await getRedis().get(`${KEY_PREFIX}${key}`);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch {
// Redis down or parse error — fall through to DB
return null;
}
}
/**
* Store a value in the cache with a TTL.
* Silently ignores errors when Redis is unavailable.
*/
export async function cacheSet(
key: string,
value: unknown,
ttlSeconds: number = DEFAULT_TTL_SECONDS,
): Promise<void> {
try {
await getRedis().set(
`${KEY_PREFIX}${key}`,
JSON.stringify(value),
"EX",
ttlSeconds,
);
} catch {
// Redis down — silently ignore, data will be served from DB next time
}
}
/**
* Delete all keys matching a glob pattern (e.g. "dashboard:*").
* The pattern is automatically prefixed with the KEY_PREFIX unless it already starts with it.
*/
export async function cacheInvalidate(pattern: string): Promise<void> {
try {
const fullPattern = pattern.startsWith(KEY_PREFIX)
? pattern
: `${KEY_PREFIX}${pattern}`;
const r = getRedis();
let cursor = "0";
do {
const [nextCursor, keys] = await r.scan(
cursor,
"MATCH",
fullPattern,
"COUNT",
100,
);
cursor = nextCursor;
if (keys.length > 0) {
await r.del(...keys);
}
} while (cursor !== "0");
} catch {
// Redis down — nothing to invalidate
}
}
/**
* Invalidate all dashboard cache entries.
* Convenience wrapper used from mutation hooks.
*/
export async function invalidateDashboardCache(): Promise<void> {
await cacheInvalidate("*");
}
+164
View File
@@ -0,0 +1,164 @@
import { emitNotificationCreated } from "../sse/event-bus.js";
type DbClient = {
estimate: {
findMany: (args: {
where: {
versions: {
some: {
status: string;
submittedAt: { lte: Date };
};
};
};
select: {
id: true;
name: true;
projectId: true;
versions: {
where: { status: string };
select: { id: true; versionNumber: true; submittedAt: true };
orderBy: { versionNumber: "desc" };
take: 1;
};
};
}) => Promise<
Array<{
id: string;
name: string;
projectId: string | null;
versions: Array<{
id: string;
versionNumber: number;
submittedAt: Date | null;
}>;
}>
>;
};
notification: {
findFirst: (args: {
where: {
entityId: string;
entityType: string;
type: string;
};
select: { id: true };
}) => Promise<{ id: string } | null>;
create: (args: {
data: {
userId: string;
type: string;
category: string;
priority: string;
title: string;
body: string;
entityId: string;
entityType: string;
link: string;
channel: string;
};
}) => Promise<{ id: string; userId: string }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
const REMINDER_DAYS = 3;
/**
* Find all estimates that have a version in SUBMITTED status for longer than
* REMINDER_DAYS days and create a single reminder notification per estimate
* for all managers/admins.
*
* Returns the number of new reminders created.
*/
export async function checkPendingEstimateReminders(
db: DbClient,
): Promise<number> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - REMINDER_DAYS);
const pendingEstimates = await db.estimate.findMany({
where: {
versions: {
some: {
status: "SUBMITTED",
submittedAt: { lte: cutoff },
},
},
},
select: {
id: true,
name: true,
projectId: true,
versions: {
where: { status: "SUBMITTED" },
select: { id: true, versionNumber: true, submittedAt: true },
orderBy: { versionNumber: "desc" },
take: 1,
},
},
});
if (pendingEstimates.length === 0) return 0;
const managers = await db.user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
select: { id: true },
});
if (managers.length === 0) return 0;
let reminderCount = 0;
for (const estimate of pendingEstimates) {
const version = estimate.versions[0];
if (!version) continue;
// Check if we already sent a reminder for this version
const existing = await db.notification.findFirst({
where: {
entityId: version.id,
entityType: "estimate_approval_reminder",
type: "ESTIMATE_APPROVAL_REMINDER",
},
select: { id: true },
});
if (existing) continue;
const daysPending = version.submittedAt
? Math.floor(
(Date.now() - new Date(version.submittedAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: REMINDER_DAYS;
for (const manager of managers) {
const notification = await db.notification.create({
data: {
userId: manager.id,
type: "ESTIMATE_APPROVAL_REMINDER",
category: "REMINDER",
priority: "HIGH",
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
entityId: version.id,
entityType: "estimate_approval_reminder",
link: `/estimates/${estimate.id}`,
channel: "in_app",
},
});
emitNotificationCreated(manager.id, notification.id);
}
reminderCount++;
}
return reminderCount;
}
+49
View File
@@ -29,7 +29,9 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { checkBudgetThresholds } from "../lib/budget-alerts.js";
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js"; import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
@@ -242,6 +244,9 @@ export const allocationRouter = createTRPCRouter({
projectId: allocation.projectId, projectId: allocation.projectId,
resourceId: allocation.resourceId, resourceId: allocation.resourceId,
}); });
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, allocation.projectId);
return allocation; return allocation;
}), }),
@@ -445,6 +450,7 @@ export const allocationRouter = createTRPCRouter({
projectId: demandRequirement.projectId, projectId: demandRequirement.projectId,
resourceId: null, resourceId: null,
}); });
void invalidateDashboardCache();
// Create staffing tasks for managers // Create staffing tasks for managers
const [project, roleEntity, managers] = await Promise.all([ const [project, roleEntity, managers] = await Promise.all([
@@ -487,6 +493,8 @@ export const allocationRouter = createTRPCRouter({
emitNotificationCreated(manager.id, task.id); emitNotificationCreated(manager.id, task.id);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, demandRequirement.projectId);
return demandRequirement; return demandRequirement;
}), }),
@@ -508,6 +516,9 @@ export const allocationRouter = createTRPCRouter({
projectId: updated.projectId, projectId: updated.projectId,
resourceId: null, resourceId: null,
}); });
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, updated.projectId);
return updated; return updated;
}), }),
@@ -529,6 +540,9 @@ export const allocationRouter = createTRPCRouter({
projectId: assignment.projectId, projectId: assignment.projectId,
resourceId: assignment.resourceId, resourceId: assignment.resourceId,
}); });
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, assignment.projectId);
return assignment; return assignment;
}), }),
@@ -551,6 +565,9 @@ export const allocationRouter = createTRPCRouter({
projectId: updated.projectId, projectId: updated.projectId,
resourceId: updated.resourceId, resourceId: updated.resourceId,
}); });
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, updated.projectId);
return updated; return updated;
}), }),
@@ -585,6 +602,9 @@ export const allocationRouter = createTRPCRouter({
}); });
emitAllocationDeleted(existing.id, existing.projectId); emitAllocationDeleted(existing.id, existing.projectId);
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, existing.projectId);
return { success: true }; return { success: true };
}), }),
@@ -607,6 +627,9 @@ export const allocationRouter = createTRPCRouter({
projectId: result.updatedDemandRequirement.projectId, projectId: result.updatedDemandRequirement.projectId,
resourceId: null, resourceId: null,
}); });
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, result.assignment.projectId);
return result; return result;
}), }),
@@ -623,6 +646,9 @@ export const allocationRouter = createTRPCRouter({
if (result.updatedAllocation) { if (result.updatedAllocation) {
emitAllocationUpdated(result.updatedAllocation); emitAllocationUpdated(result.updatedAllocation);
} }
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, result.createdAllocation.projectId as string);
return result; return result;
}), }),
@@ -665,6 +691,9 @@ export const allocationRouter = createTRPCRouter({
projectId: updated.projectId, projectId: updated.projectId,
resourceId: updated.resourceId, resourceId: updated.resourceId,
}); });
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, updated.projectId);
return updated; return updated;
}), }),
@@ -699,6 +728,9 @@ export const allocationRouter = createTRPCRouter({
}); });
emitAllocationDeleted(existing.id, existing.projectId); emitAllocationDeleted(existing.id, existing.projectId);
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, existing.projectId);
return { success: true }; return { success: true };
}), }),
@@ -726,6 +758,9 @@ export const allocationRouter = createTRPCRouter({
}); });
emitAllocationDeleted(existing.entry.id, existing.projectId); emitAllocationDeleted(existing.entry.id, existing.projectId);
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, existing.projectId);
return { success: true }; return { success: true };
}), }),
@@ -760,6 +795,13 @@ export const allocationRouter = createTRPCRouter({
for (const a of existing) { for (const a of existing) {
emitAllocationDeleted(a.entry.id, a.projectId); emitAllocationDeleted(a.entry.id, a.projectId);
} }
void invalidateDashboardCache();
// Check budget thresholds for each affected project
const affectedProjectIds = [...new Set(existing.map((a) => a.projectId))];
for (const pid of affectedProjectIds) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, pid);
}
return { count: existing.length }; return { count: existing.length };
}), }),
@@ -804,6 +846,13 @@ export const allocationRouter = createTRPCRouter({
for (const a of updated) { for (const a of updated) {
emitAllocationUpdated({ id: a.id, projectId: a.projectId, resourceId: a.resourceId }); emitAllocationUpdated({ id: a.id, projectId: a.projectId, resourceId: a.resourceId });
} }
void invalidateDashboardCache();
// Check budget thresholds for each affected project
const affectedProjectIds = [...new Set(updated.map((a) => a.projectId))];
for (const pid of affectedProjectIds) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, pid);
}
return { count: updated.length }; return { count: updated.length };
}), }),
+54 -13
View File
@@ -8,9 +8,20 @@ import {
getDashboardTopValueResources, getDashboardTopValueResources,
} from "@planarchy/application"; } from "@planarchy/application";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import { cacheGet, cacheSet } from "../lib/cache.js";
const DEFAULT_TTL = 60; // seconds
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
getOverview: protectedProcedure.query(({ ctx }) => getDashboardOverview(ctx.db)), getOverview: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = "overview";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardOverview(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getPeakTimes: protectedProcedure getPeakTimes: protectedProcedure
.input( .input(
@@ -21,27 +32,40 @@ export const dashboardRouter = createTRPCRouter({
groupBy: z.enum(["project", "chapter", "resource"]).default("project"), groupBy: z.enum(["project", "chapter", "resource"]).default("project"),
}), }),
) )
.query(({ ctx, input }) => .query(async ({ ctx, input }) => {
getDashboardPeakTimes(ctx.db, { const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardPeakTimes(ctx.db, {
startDate: new Date(input.startDate), startDate: new Date(input.startDate),
endDate: new Date(input.endDate), endDate: new Date(input.endDate),
granularity: input.granularity, granularity: input.granularity,
groupBy: input.groupBy, groupBy: input.groupBy,
}), });
), await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getTopValueResources: protectedProcedure getTopValueResources: protectedProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) })) .input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userRole =
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
const cacheKey = `topValue:${input.limit}:${userRole}`;
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
if (cached) return cached;
const [resources, directory] = await Promise.all([ const [resources, directory] = await Promise.all([
getDashboardTopValueResources(ctx.db, { getDashboardTopValueResources(ctx.db, {
limit: input.limit, limit: input.limit,
userRole: userRole,
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER",
}), }),
getAnonymizationDirectory(ctx.db), getAnonymizationDirectory(ctx.db),
]); ]);
return anonymizeResources(resources, directory); const result = anonymizeResources(resources, directory);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}), }),
getDemand: protectedProcedure getDemand: protectedProcedure
@@ -52,13 +76,19 @@ export const dashboardRouter = createTRPCRouter({
groupBy: z.enum(["project", "person", "chapter"]).default("project"), groupBy: z.enum(["project", "person", "chapter"]).default("project"),
}), }),
) )
.query(({ ctx, input }) => .query(async ({ ctx, input }) => {
getDashboardDemand(ctx.db, { const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardDemand(ctx.db, {
startDate: new Date(input.startDate), startDate: new Date(input.startDate),
endDate: new Date(input.endDate), endDate: new Date(input.endDate),
groupBy: input.groupBy, groupBy: input.groupBy,
}), });
), await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getChargeabilityOverview: controllerProcedure getChargeabilityOverview: controllerProcedure
.input( .input(
@@ -71,6 +101,15 @@ export const dashboardRouter = createTRPCRouter({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`;
type ChargeResult = Awaited<ReturnType<typeof getDashboardChargeabilityOverview>>;
const cached = await cacheGet<{
top: unknown[];
watchlist: unknown[];
[key: string]: unknown;
}>(cacheKey);
if (cached) return cached;
const [overview, directory] = await Promise.all([ const [overview, directory] = await Promise.all([
getDashboardChargeabilityOverview(ctx.db, { getDashboardChargeabilityOverview(ctx.db, {
includeProposed: input.includeProposed, includeProposed: input.includeProposed,
@@ -82,10 +121,12 @@ export const dashboardRouter = createTRPCRouter({
getAnonymizationDirectory(ctx.db), getAnonymizationDirectory(ctx.db),
]); ]);
return { const result = {
...overview, ...overview,
top: anonymizeResources(overview.top, directory), top: anonymizeResources(overview.top, directory),
watchlist: anonymizeResources(overview.watchlist, directory), watchlist: anonymizeResources(overview.watchlist, directory),
}; };
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}), }),
}); });
+48 -1
View File
@@ -12,6 +12,7 @@ import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js"; import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
import { invalidateDashboardCache } from "../lib/cache.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload) const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
@@ -155,6 +156,7 @@ export const projectRouter = createTRPCRouter({
}, },
}); });
void invalidateDashboardCache();
return project; return project;
}), }),
@@ -207,6 +209,7 @@ export const projectRouter = createTRPCRouter({
}, },
}); });
void invalidateDashboardCache();
return updated; return updated;
}), }),
@@ -214,10 +217,12 @@ export const projectRouter = createTRPCRouter({
.input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) })) .input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return ctx.db.project.update({ const result = await ctx.db.project.update({
where: { id: input.id }, where: { id: input.id },
data: { status: input.status }, data: { status: input.status },
}); });
void invalidateDashboardCache();
return result;
}), }),
batchUpdateStatus: managerProcedure batchUpdateStatus: managerProcedure
@@ -244,6 +249,7 @@ export const projectRouter = createTRPCRouter({
}, },
}); });
void invalidateDashboardCache();
return { count: updated.length }; return { count: updated.length };
}), }),
@@ -349,9 +355,50 @@ export const projectRouter = createTRPCRouter({
}); });
}); });
void invalidateDashboardCache();
return { id: input.id, name: project.name }; return { id: input.id, name: project.name };
}), }),
batchDelete: adminProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(50),
}),
)
.mutation(async ({ ctx, input }) => {
const projects = await ctx.db.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, name: true, shortCode: true },
});
if (projects.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No projects found" });
}
await ctx.db.$transaction(async (tx) => {
const ids = projects.map((p) => p.id);
await tx.assignment.deleteMany({ where: { projectId: { in: ids } } });
await tx.demandRequirement.deleteMany({ where: { projectId: { in: ids } } });
await tx.calculationRule.updateMany({
where: { projectId: { in: ids } },
data: { projectId: null },
});
await tx.project.deleteMany({ where: { id: { in: ids } } });
await tx.auditLog.create({
data: {
entityType: "Project",
entityId: ids.join(","),
action: "DELETE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: { before: projects } as never,
},
});
});
void invalidateDashboardCache();
return { count: projects.length };
}),
// ─── Cover Art ────────────────────────────────────────────────────────────── // ─── Cover Art ──────────────────────────────────────────────────────────────
generateCover: managerProcedure generateCover: managerProcedure