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 { 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">
+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);
}