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:
@@ -10,6 +10,7 @@ import Image from "next/image";
|
||||
import { clsx } from "clsx";
|
||||
import { motion } from "framer-motion";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
|
||||
import { ProjectModal } from "~/components/projects/ProjectModal.js";
|
||||
import { ProjectWizard } from "~/components/projects/ProjectWizard.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
@@ -183,6 +184,7 @@ export function ProjectsClient() {
|
||||
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
|
||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
||||
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
|
||||
const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]);
|
||||
@@ -336,6 +345,25 @@ export function ProjectsClient() {
|
||||
function closeModal() { setModalOpen(false); setEditingProject(null); }
|
||||
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 = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(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 */}
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
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 { trpc } from "~/lib/trpc/client.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ResourceModal } from "~/components/resources/ResourceModal.js";
|
||||
import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
|
||||
@@ -508,6 +509,22 @@ export function ResourcesClient() {
|
||||
return "Departed: no";
|
||||
}, [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 = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(chapterFilter.length > 0
|
||||
@@ -1429,10 +1446,15 @@ export function ResourcesClient() {
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
{
|
||||
label: "Export Selected",
|
||||
variant: "default" as const,
|
||||
onClick: exportSelectedCsv,
|
||||
},
|
||||
...(filterableFields.length > 0
|
||||
? [
|
||||
{
|
||||
label: "Edit Custom Fields",
|
||||
label: "Bulk Edit",
|
||||
variant: "default" as const,
|
||||
onClick: () => setModal({ type: "bulkEdit" }),
|
||||
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 rawWatch = data?.watchlist ?? [];
|
||||
const month = data?.month ?? "";
|
||||
const month = (data?.month as string) ?? "";
|
||||
|
||||
const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => {
|
||||
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) {
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
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 { SkillTagInput } from "~/components/ui/SkillTagInput.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() {
|
||||
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
|
||||
@@ -18,6 +28,14 @@ export function StaffingPanel() {
|
||||
});
|
||||
const [hoursPerDay, setHoursPerDay] = useState(8);
|
||||
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(
|
||||
{
|
||||
@@ -29,8 +47,19 @@ export function StaffingPanel() {
|
||||
{ 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 (
|
||||
<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="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">
|
||||
@@ -63,7 +92,7 @@ export function StaffingPanel() {
|
||||
<SkillTagInput
|
||||
value={requiredSkills}
|
||||
onChange={setRequiredSkills}
|
||||
placeholder="Add skill…"
|
||||
placeholder="Add skill..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -134,62 +163,23 @@ export function StaffingPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions && suggestions.length === 0 && (
|
||||
{visibleSuggestions && visibleSuggestions.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{suggestions && suggestions.length > 0 && (
|
||||
{visibleSuggestions && visibleSuggestions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div key={suggestion.resourceId} 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">
|
||||
{idx + 1}
|
||||
</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="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>
|
||||
{visibleSuggestions.map((suggestion, idx) => (
|
||||
<SuggestionCard
|
||||
key={suggestion.resourceId}
|
||||
suggestion={suggestion}
|
||||
rank={idx + 1}
|
||||
searchCriteria={searchCriteria}
|
||||
onAssigned={handleAssigned}
|
||||
onError={(msg) => setToast({ show: true, message: msg, variant: "warning" })}
|
||||
/>
|
||||
))}
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
|
||||
@@ -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,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 { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("*");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -29,7 +29,9 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.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 { invalidateDashboardCache } from "../lib/cache.js";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.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,
|
||||
resourceId: allocation.resourceId,
|
||||
});
|
||||
void invalidateDashboardCache();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, allocation.projectId);
|
||||
return allocation;
|
||||
}),
|
||||
|
||||
@@ -445,6 +450,7 @@ export const allocationRouter = createTRPCRouter({
|
||||
projectId: demandRequirement.projectId,
|
||||
resourceId: null,
|
||||
});
|
||||
void invalidateDashboardCache();
|
||||
|
||||
// Create staffing tasks for managers
|
||||
const [project, roleEntity, managers] = await Promise.all([
|
||||
@@ -487,6 +493,8 @@ export const allocationRouter = createTRPCRouter({
|
||||
emitNotificationCreated(manager.id, task.id);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, demandRequirement.projectId);
|
||||
return demandRequirement;
|
||||
}),
|
||||
|
||||
@@ -508,6 +516,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
projectId: updated.projectId,
|
||||
resourceId: null,
|
||||
});
|
||||
void invalidateDashboardCache();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, updated.projectId);
|
||||
|
||||
return updated;
|
||||
}),
|
||||
@@ -529,6 +540,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
});
|
||||
void invalidateDashboardCache();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, assignment.projectId);
|
||||
|
||||
return assignment;
|
||||
}),
|
||||
@@ -551,6 +565,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
projectId: updated.projectId,
|
||||
resourceId: updated.resourceId,
|
||||
});
|
||||
void invalidateDashboardCache();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, updated.projectId);
|
||||
|
||||
return updated;
|
||||
}),
|
||||
@@ -585,6 +602,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
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 };
|
||||
}),
|
||||
@@ -607,6 +627,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
projectId: result.updatedDemandRequirement.projectId,
|
||||
resourceId: null,
|
||||
});
|
||||
void invalidateDashboardCache();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, result.assignment.projectId);
|
||||
|
||||
return result;
|
||||
}),
|
||||
@@ -623,6 +646,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
if (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;
|
||||
}),
|
||||
@@ -665,6 +691,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
projectId: updated.projectId,
|
||||
resourceId: updated.resourceId,
|
||||
});
|
||||
void invalidateDashboardCache();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, updated.projectId);
|
||||
|
||||
return updated;
|
||||
}),
|
||||
@@ -699,6 +728,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
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 };
|
||||
}),
|
||||
@@ -726,6 +758,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
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 };
|
||||
}),
|
||||
@@ -760,6 +795,13 @@ export const allocationRouter = createTRPCRouter({
|
||||
for (const a of existing) {
|
||||
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 };
|
||||
}),
|
||||
@@ -804,6 +846,13 @@ export const allocationRouter = createTRPCRouter({
|
||||
for (const a of updated) {
|
||||
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 };
|
||||
}),
|
||||
|
||||
@@ -8,9 +8,20 @@ import {
|
||||
getDashboardTopValueResources,
|
||||
} from "@planarchy/application";
|
||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { cacheGet, cacheSet } from "../lib/cache.js";
|
||||
|
||||
const DEFAULT_TTL = 60; // seconds
|
||||
|
||||
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
|
||||
.input(
|
||||
@@ -21,27 +32,40 @@ export const dashboardRouter = createTRPCRouter({
|
||||
groupBy: z.enum(["project", "chapter", "resource"]).default("project"),
|
||||
}),
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
getDashboardPeakTimes(ctx.db, {
|
||||
.query(async ({ ctx, input }) => {
|
||||
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),
|
||||
endDate: new Date(input.endDate),
|
||||
granularity: input.granularity,
|
||||
groupBy: input.groupBy,
|
||||
}),
|
||||
),
|
||||
});
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}),
|
||||
|
||||
getTopValueResources: protectedProcedure
|
||||
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
|
||||
.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([
|
||||
getDashboardTopValueResources(ctx.db, {
|
||||
limit: input.limit,
|
||||
userRole:
|
||||
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER",
|
||||
userRole,
|
||||
}),
|
||||
getAnonymizationDirectory(ctx.db),
|
||||
]);
|
||||
return anonymizeResources(resources, directory);
|
||||
const result = anonymizeResources(resources, directory);
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}),
|
||||
|
||||
getDemand: protectedProcedure
|
||||
@@ -52,13 +76,19 @@ export const dashboardRouter = createTRPCRouter({
|
||||
groupBy: z.enum(["project", "person", "chapter"]).default("project"),
|
||||
}),
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
getDashboardDemand(ctx.db, {
|
||||
.query(async ({ ctx, input }) => {
|
||||
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),
|
||||
endDate: new Date(input.endDate),
|
||||
groupBy: input.groupBy,
|
||||
}),
|
||||
),
|
||||
});
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}),
|
||||
|
||||
getChargeabilityOverview: controllerProcedure
|
||||
.input(
|
||||
@@ -71,6 +101,15 @@ export const dashboardRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.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([
|
||||
getDashboardChargeabilityOverview(ctx.db, {
|
||||
includeProposed: input.includeProposed,
|
||||
@@ -82,10 +121,12 @@ export const dashboardRouter = createTRPCRouter({
|
||||
getAnonymizationDirectory(ctx.db),
|
||||
]);
|
||||
|
||||
return {
|
||||
const result = {
|
||||
...overview,
|
||||
top: anonymizeResources(overview.top, directory),
|
||||
watchlist: anonymizeResources(overview.watchlist, directory),
|
||||
};
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.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)
|
||||
|
||||
@@ -155,6 +156,7 @@ export const projectRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
void invalidateDashboardCache();
|
||||
return project;
|
||||
}),
|
||||
|
||||
@@ -207,6 +209,7 @@ export const projectRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
void invalidateDashboardCache();
|
||||
return updated;
|
||||
}),
|
||||
|
||||
@@ -214,10 +217,12 @@ export const projectRouter = createTRPCRouter({
|
||||
.input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
return ctx.db.project.update({
|
||||
const result = await ctx.db.project.update({
|
||||
where: { id: input.id },
|
||||
data: { status: input.status },
|
||||
});
|
||||
void invalidateDashboardCache();
|
||||
return result;
|
||||
}),
|
||||
|
||||
batchUpdateStatus: managerProcedure
|
||||
@@ -244,6 +249,7 @@ export const projectRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
void invalidateDashboardCache();
|
||||
return { count: updated.length };
|
||||
}),
|
||||
|
||||
@@ -349,9 +355,50 @@ export const projectRouter = createTRPCRouter({
|
||||
});
|
||||
});
|
||||
|
||||
void invalidateDashboardCache();
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
generateCover: managerProcedure
|
||||
|
||||
Reference in New Issue
Block a user