rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #61.
This commit is contained in:
@@ -4,8 +4,8 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared";
|
||||
import { AllocationStatus } from "@nexus/shared";
|
||||
import type { AllocationWithDetails, RecurrencePattern } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { toDateInputValue } from "~/lib/format.js";
|
||||
@@ -26,7 +26,8 @@ interface AllocationModalProps {
|
||||
|
||||
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
||||
const isEditing = Boolean(allocation);
|
||||
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
|
||||
const initialEntryKind: EntryKind =
|
||||
allocation && !allocation.resourceId ? "demand" : "assignment";
|
||||
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
|
||||
const isDemandEntry = entryKind === "demand";
|
||||
|
||||
@@ -57,14 +58,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: projects } = trpc.project.list.useQuery(
|
||||
{ limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: rolesData } = trpc.role.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: projects } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
|
||||
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
|
||||
// Fetch existing allocations for the selected resource+project to detect overlaps
|
||||
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
|
||||
@@ -85,20 +80,26 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
const shouldCheckConflicts =
|
||||
!isDemandEntry &&
|
||||
!!debouncedResourceId &&
|
||||
conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) &&
|
||||
conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) &&
|
||||
conflictCheckStart !== null &&
|
||||
!isNaN(conflictCheckStart.getTime()) &&
|
||||
conflictCheckEnd !== null &&
|
||||
!isNaN(conflictCheckEnd.getTime()) &&
|
||||
debouncedHoursPerDay > 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)(
|
||||
{
|
||||
resourceId: debouncedResourceId,
|
||||
startDate: conflictCheckStart,
|
||||
endDate: conflictCheckEnd,
|
||||
hoursPerDay: debouncedHoursPerDay,
|
||||
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
|
||||
},
|
||||
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
|
||||
) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean };
|
||||
const { data: conflictResult, isFetching: checkingConflicts } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(trpc.allocation.checkConflicts.useQuery as any)(
|
||||
{
|
||||
resourceId: debouncedResourceId,
|
||||
startDate: conflictCheckStart,
|
||||
endDate: conflictCheckEnd,
|
||||
hoursPerDay: debouncedHoursPerDay,
|
||||
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
|
||||
},
|
||||
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
|
||||
) as {
|
||||
data: import("@nexus/shared").AllocationConflictCheckResult | undefined;
|
||||
isFetching: boolean;
|
||||
};
|
||||
|
||||
const overlapWarning = useMemo(() => {
|
||||
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
|
||||
@@ -106,7 +107,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
const formEnd = new Date(endDate);
|
||||
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
|
||||
|
||||
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
|
||||
const allocList =
|
||||
(
|
||||
existingAllocations as {
|
||||
allocations?: Array<{
|
||||
id: string;
|
||||
resourceId?: string | null;
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
}>;
|
||||
}
|
||||
).allocations ?? [];
|
||||
for (const existing of allocList) {
|
||||
// Skip the allocation being edited
|
||||
if (isEditing && allocation && existing.id === allocation.id) continue;
|
||||
@@ -121,7 +132,15 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
|
||||
}, [
|
||||
shouldCheckOverlap,
|
||||
existingAllocations,
|
||||
startDate,
|
||||
endDate,
|
||||
isEditing,
|
||||
allocation,
|
||||
resourceId,
|
||||
]);
|
||||
|
||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||
|
||||
@@ -185,7 +204,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
useEffect(() => {
|
||||
setServerError(null);
|
||||
setOverbookingAcknowledged(false);
|
||||
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
|
||||
}, [
|
||||
resourceId,
|
||||
projectId,
|
||||
roleId,
|
||||
roleFreeText,
|
||||
startDate,
|
||||
endDate,
|
||||
hoursPerDay,
|
||||
status,
|
||||
entryKind,
|
||||
]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -222,7 +251,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
// Determine role string from roleId if set
|
||||
const rolesList = rolesData ?? [];
|
||||
const selectedRole = rolesList.find((r) => r.id === roleId);
|
||||
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
|
||||
const roleString = selectedRole ? selectedRole.name : roleFreeText || undefined;
|
||||
|
||||
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
||||
|
||||
@@ -230,12 +259,14 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
updateMutation.mutate({
|
||||
id: getPlanningEntryMutationId(allocation),
|
||||
data: {
|
||||
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
|
||||
resourceId: isDemandEntry ? undefined : resourceId || undefined,
|
||||
projectId,
|
||||
role: roleString,
|
||||
roleId: roleId || undefined,
|
||||
headcount: isDemandEntry ? headcount : 1,
|
||||
...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}),
|
||||
...(isDemandEntry && budgetEur
|
||||
? { budgetCents: Math.round(parseFloat(budgetEur) * 100) }
|
||||
: {}),
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
hoursPerDay,
|
||||
@@ -279,18 +310,22 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
|
||||
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
|
||||
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
|
||||
const resourceList = (resources?.resources ?? []) as Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
}>;
|
||||
const projectList = (projects?.projects ?? []) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
}>;
|
||||
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
|
||||
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
|
||||
|
||||
return (
|
||||
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="allocation-modal"
|
||||
>
|
||||
<div role="dialog" aria-modal="true" data-testid="allocation-modal">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
@@ -333,7 +368,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{isDemandEntry && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
Headcount:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={headcount}
|
||||
@@ -344,7 +381,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Budget (EUR):</label>
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
Budget (EUR):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={budgetEur}
|
||||
@@ -363,7 +402,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{!isDemandEntry && (
|
||||
<div>
|
||||
<label htmlFor="modal-resource" className={labelClass}>
|
||||
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
||||
Resource <span className="text-red-500">*</span>
|
||||
<InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-resource"
|
||||
@@ -385,7 +425,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{/* Project */}
|
||||
<div>
|
||||
<label htmlFor="modal-project" className={labelClass}>
|
||||
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
||||
Project <span className="text-red-500">*</span>
|
||||
<InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-project"
|
||||
@@ -405,7 +446,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
|
||||
<label htmlFor="modal-role" className={labelClass}>
|
||||
Role
|
||||
<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-role"
|
||||
value={roleId}
|
||||
@@ -434,35 +478,43 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{/* Dates */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span>
|
||||
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
|
||||
<span className={labelClass}>
|
||||
Date Range <span className="text-red-500">*</span>
|
||||
</span>
|
||||
<DateRangePresets
|
||||
onSelect={(s, e) => {
|
||||
setStartDate(s);
|
||||
setEndDate(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-start" className={labelClass}>
|
||||
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-end" className={labelClass}>
|
||||
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-start" className={labelClass}>
|
||||
Start Date{" "}
|
||||
<InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-end" className={labelClass}>
|
||||
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +522,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-hours" className={labelClass}>
|
||||
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
||||
Hours / Day
|
||||
<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
||||
</label>
|
||||
<input
|
||||
id="modal-hours"
|
||||
@@ -485,7 +538,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-status" className={labelClass}>
|
||||
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
||||
Status
|
||||
<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-status"
|
||||
@@ -514,7 +568,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span><InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Recurring schedule
|
||||
</span>
|
||||
<InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
||||
</label>
|
||||
{isRecurring && (
|
||||
<div className="mt-2">
|
||||
@@ -548,7 +605,12 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
)}
|
||||
{!conflictResult && checkingConflicts && (
|
||||
<ConflictWarningPanel
|
||||
result={{ isOverbooking: false, overbooking: null, vacationOverlap: [], hasVacationOverlap: false }}
|
||||
result={{
|
||||
isOverbooking: false,
|
||||
overbooking: null,
|
||||
vacationOverlap: [],
|
||||
hasVacationOverlap: false,
|
||||
}}
|
||||
isLoading={true}
|
||||
acknowledged={false}
|
||||
onAcknowledge={() => {}}
|
||||
@@ -568,7 +630,11 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || hasUnacknowledgedOverbooking}
|
||||
title={hasUnacknowledgedOverbooking ? "Acknowledge the overbooking warning above to proceed" : undefined}
|
||||
title={
|
||||
hasUnacknowledgedOverbooking
|
||||
? "Acknowledge the overbooking warning above to proceed"
|
||||
: undefined
|
||||
}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
|
||||
Reference in New Issue
Block a user