feat: AI assistant (HartBOT), demand filling, budget-per-role, project favorites, and UX improvements
AI Assistant (HartBOT): - Chat panel with inline layout, session persistence, message history (up-arrow recall) - OpenAI function calling with 20+ tools (search, navigate, create/cancel allocations, update status) - RBAC-aware tool filtering, fuzzy search with word-level matching - Navigation actions (router.push) and data invalidation after mutations - Country/metro city/org unit/role filtering on resource search Demand Filling Enhancements: - Two-phase fill modal: plan multiple resources, then confirm & assign all at once - Availability preview per resource (available/partial/conflict days, existing bookings) - Coverage bar showing demand hours distribution across assigned resources - Fill demand from project detail page (new Assign button per demand) - Fixed: filled demands no longer shown on timeline, demand bars no longer overlap Budget per Role: - DemandRequirement.budgetCents field (schema + API + UI) - Project wizard step 3: budget input per role with allocation summary bar - Project detail: allocated vs booked budget per demand - Fill demand modal: role budget display with cost estimates - AllocationModal: budget field for demand editing Project Favorites: - User.favoriteProjectIds (JSONB) with toggle API - Star button on projects list and detail page (optimistic updates) - "My Projects" dashboard widget (favorites + responsible person projects) Project Management: - Edit project from detail page (ProjectModal integration) - Edit demands from detail page (AllocationModal integration) - Admin-only project deletion (cascades assignments + demands) - Create user accounts from admin panel Timeline Fixes: - Country multi-select filter with backend support - URL param sync for same-page navigation (AI assistant integration) - Demand lane stacking (no more overlapping bars) - Single-day booking resize handles (always visible, min 6px) - Single-day resize allowed (start === end) - "All Clients" toggle (select all / deselect all) Other Fixes: - crypto.randomUUID fallback for non-secure contexts - Chat message limit raised (200 max, client sends last 40) - Status dropdown portal (no longer clipped by table overflow) - Cents display restored in budget views (2 decimal places) - Allocations grouped view with project sub-groups (collapsed by default) - Server-side resource search for project wizard (no 500 limit) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -20,8 +20,8 @@ function formatEur(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ function formatEur(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
|
||||
import { AllocationModal } from "~/components/allocations/AllocationModal.js";
|
||||
import type { AllocationWithDetails } from "@planarchy/shared";
|
||||
import type { OpenDemandAssignment } from "~/components/timeline/TimelineProjectPanel.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const ALLOC_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
PROPOSED: "bg-yellow-100 text-yellow-700",
|
||||
CONFIRMED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-gray-100 text-gray-500",
|
||||
COMPLETED: "bg-blue-100 text-blue-600",
|
||||
};
|
||||
|
||||
interface DemandRow {
|
||||
id: string;
|
||||
projectId: string;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
roleEntity?: { id: string; name: string; color: string | null } | null;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
headcount: number;
|
||||
budgetCents?: number;
|
||||
requestedHeadcount: number;
|
||||
unfilledHeadcount: number;
|
||||
status: string;
|
||||
project?: { id: string; name: string; shortCode: string };
|
||||
assignments?: Array<{ dailyCostCents: number; startDate: Date | string; endDate: Date | string; status: string }>;
|
||||
}
|
||||
|
||||
interface ProjectDemandsTableProps {
|
||||
demands: DemandRow[];
|
||||
project: { id: string; name: string; shortCode: string };
|
||||
}
|
||||
|
||||
export function ProjectDemandsTable({ demands, project }: ProjectDemandsTableProps) {
|
||||
const [fillTarget, setFillTarget] = useState<OpenDemandAssignment | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<AllocationWithDetails | null>(null);
|
||||
const { canEdit } = usePermissions();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
function handleMutationSuccess() {
|
||||
// Invalidate budget status so BudgetStatusCard refetches
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.allocation.listView.invalidate();
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const activeDemands = demands.filter((d) => d.status !== "CANCELLED" && d.status !== "COMPLETED");
|
||||
const allDemands = demands;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Open Demands ({allDemands.length})
|
||||
</h2>
|
||||
{activeDemands.length > 0 && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{activeDemands.reduce((sum, d) => sum + d.unfilledHeadcount, 0)} seats unfilled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Period
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Headcount <InfoTooltip content="Requested / Unfilled seats for this demand." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Hours/Day
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Budget <InfoTooltip content="Allocated role budget vs. booked cost from assignments." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
{canEdit && (
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{allDemands.map((demand) => {
|
||||
const isFillable = demand.status !== "CANCELLED" && demand.status !== "COMPLETED" && demand.unfilledHeadcount > 0;
|
||||
return (
|
||||
<tr key={demand.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
{demand.roleEntity?.name ?? demand.role ?? "Unassigned"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDate(demand.startDate)} → {formatDate(demand.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
|
||||
<span className="font-medium">{demand.unfilledHeadcount}</span>
|
||||
<span className="text-gray-400"> / {demand.requestedHeadcount}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{demand.hoursPerDay}h</td>
|
||||
<td className="px-4 py-3 text-right text-sm">
|
||||
{demand.budgetCents && demand.budgetCents > 0 ? (() => {
|
||||
// Calculate booked cost from assignments
|
||||
const bookedCents = (demand.assignments ?? [])
|
||||
.filter((a) => a.status !== "CANCELLED")
|
||||
.reduce((sum, a) => {
|
||||
const s = new Date(a.startDate);
|
||||
const e = new Date(a.endDate);
|
||||
let days = 0;
|
||||
const cur = new Date(s);
|
||||
while (cur <= e) { if (cur.getDay() !== 0 && cur.getDay() !== 6) days++; cur.setDate(cur.getDate() + 1); }
|
||||
return sum + a.dailyCostCents * days;
|
||||
}, 0);
|
||||
const remainCents = demand.budgetCents! - bookedCents;
|
||||
return (
|
||||
<div>
|
||||
<div className="text-gray-900 dark:text-gray-100">
|
||||
{(demand.budgetCents! / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
|
||||
</div>
|
||||
<div className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}>
|
||||
{bookedCents > 0 ? `${(bookedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} booked` : ""}
|
||||
{remainCents < 0 ? ` (${(Math.abs(remainCents) / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} over)` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<span className="text-gray-400 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}>
|
||||
{demand.status}
|
||||
</span>
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditTarget(demand as unknown as AllocationWithDetails)}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{isFillable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFillTarget({
|
||||
id: demand.id,
|
||||
projectId: demand.projectId,
|
||||
roleId: demand.roleId,
|
||||
role: demand.role,
|
||||
headcount: demand.headcount,
|
||||
...(demand.budgetCents ? { budgetCents: demand.budgetCents } : {}),
|
||||
startDate: new Date(demand.startDate),
|
||||
endDate: new Date(demand.endDate),
|
||||
hoursPerDay: demand.hoursPerDay,
|
||||
roleEntity: demand.roleEntity ?? null,
|
||||
project,
|
||||
})}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
Assign
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{allDemands.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No open demands for this project.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fillTarget && (
|
||||
<FillOpenDemandModal
|
||||
allocation={fillTarget as never}
|
||||
onClose={() => { setFillTarget(null); handleMutationSuccess(); }}
|
||||
onSuccess={() => { setFillTarget(null); handleMutationSuccess(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editTarget && (
|
||||
<AllocationModal
|
||||
allocation={editTarget}
|
||||
onClose={() => { setEditTarget(null); handleMutationSuccess(); }}
|
||||
onSuccess={() => { setEditTarget(null); handleMutationSuccess(); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Project } from "@planarchy/shared";
|
||||
import { ProjectModal } from "./ProjectModal.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface ProjectDetailActionsProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function ProjectDetailActions({ project }: ProjectDetailActionsProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const { canEdit, role } = usePermissions();
|
||||
const isAdmin = role === "ADMIN";
|
||||
const router = useRouter();
|
||||
|
||||
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
|
||||
const isFavorite = useMemo(() => (favoriteIds ?? []).includes(project.id), [favoriteIds, project.id]);
|
||||
const utils = trpc.useUtils();
|
||||
const toggleFav = trpc.user.toggleFavoriteProject.useMutation({
|
||||
onSuccess: () => void utils.user.getFavoriteProjectIds.invalidate(),
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.project.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push("/projects");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFav.mutate({ projectId: project.id })}
|
||||
className={`text-xl transition-colors ${isFavorite ? "text-amber-500 hover:text-amber-600" : "text-gray-300 hover:text-amber-400 dark:text-gray-600 dark:hover:text-amber-500"}`}
|
||||
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{isFavorite ? "★" : "☆"}
|
||||
</button>
|
||||
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-600 shadow-sm hover:bg-red-50 transition disabled:opacity-50 dark:border-red-700 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
|
||||
{editOpen && (
|
||||
<ProjectModal
|
||||
project={project}
|
||||
onClose={() => {
|
||||
setEditOpen(false);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
message={`Permanently delete "${project.name}" (${(project as unknown as { shortCode: string }).shortCode})? This will also delete all assignments and demand requirements. This cannot be undone.`}
|
||||
confirmLabel="Delete Project"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate({ id: project.id });
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { clsx } from "clsx";
|
||||
import type { StaffingRequirement } from "@planarchy/shared";
|
||||
import { BlueprintTarget, OrderType, AllocationType, ProjectStatus, AllocationStatus } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { uuid } from "~/lib/uuid.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
@@ -93,7 +94,7 @@ function makeDefaultState(): WizardState {
|
||||
|
||||
function makeReq(): StaffingRequirement {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuid(),
|
||||
role: "",
|
||||
requiredSkills: [],
|
||||
preferredSkills: [],
|
||||
@@ -301,29 +302,26 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [query, setQuery] = useState(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debounce search query to avoid excessive API calls
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(query), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
// Server-side search — no client-side limit, searches full database
|
||||
const { data } = trpc.resource.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
{ isActive: true, search: debouncedSearch || undefined, limit: 30 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
||||
);
|
||||
const resources = useMemo(
|
||||
const filtered = useMemo(
|
||||
() => (data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>,
|
||||
[data],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase().replace(/[-_\s]/g, "");
|
||||
if (!q) return resources.slice(0, 20);
|
||||
return resources
|
||||
.filter((r) => {
|
||||
const name = r.displayName.toLowerCase().replace(/[-_\s]/g, "");
|
||||
const eid = r.eid.toLowerCase();
|
||||
return name.includes(q) || eid.includes(q);
|
||||
})
|
||||
.slice(0, 20);
|
||||
}, [resources, query]);
|
||||
|
||||
// Sync local query when external value changes (e.g. wizard reset)
|
||||
useEffect(() => {
|
||||
setQuery(value);
|
||||
@@ -490,6 +488,30 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget allocation summary */}
|
||||
{state.budgetEur && parseFloat(state.budgetEur) > 0 && state.staffingReqs.length > 0 && (() => {
|
||||
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
|
||||
const allocatedCents = state.staffingReqs.reduce((sum, r) => sum + (r.budgetCents ?? 0), 0);
|
||||
const remainingCents = projectBudgetCents - allocatedCents;
|
||||
const pct = projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0;
|
||||
return (
|
||||
<div className={`mb-3 rounded-lg border p-3 text-xs ${remainingCents < 0 ? "bg-red-50 border-red-200 text-red-700" : remainingCents === 0 ? "bg-green-50 border-green-200 text-green-700" : "bg-amber-50 border-amber-200 text-amber-700"}`}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="font-semibold">Budget Allocation</span>
|
||||
<span>{pct}% allocated</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
|
||||
<div className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`} style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Project: {(projectBudgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
|
||||
<span>Allocated: {(allocatedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
|
||||
<span className="font-semibold">Remaining: {(remainingCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="space-y-3 max-h-[45vh] overflow-y-auto pr-1">
|
||||
{state.staffingReqs.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">
|
||||
@@ -557,6 +579,21 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<label className="text-xs text-gray-400">Budget (EUR)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={req.budgetCents ? req.budgetCents / 100 : ""}
|
||||
min={0}
|
||||
step={100}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
updateReq(idx, { budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0 } as Partial<StaffingRequirement>);
|
||||
}}
|
||||
placeholder="0"
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-5">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user