refactor: consolidate duplicated code across web and API packages

- Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx
- Centralize status badge styles and vacation color maps into status-styles.ts
- Extract dragMath.ts utility from useTimelineDrag for reuse
- Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries)
- Adopt findUniqueOrThrow() and Prisma select constants across API routers
- Add shared fmtEur() helper for API-side money formatting
- Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo
- Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal
- 38 files changed, reducing ~400 lines of duplicated code

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 00:10:08 +01:00
parent ddec3a927a
commit e7b74f13bd
38 changed files with 637 additions and 652 deletions
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { formatDate } from "~/lib/format.js"; import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef } from "@planarchy/shared"; import type { Project, ColumnDef } from "@planarchy/shared";
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared"; import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
import Link from "next/link"; import Link from "next/link";
@@ -400,7 +400,7 @@ export function ProjectsClient() {
return ( return (
<td key={col.key} className="px-4 py-3 min-w-[120px]"> <td key={col.key} className="px-4 py-3 min-w-[120px]">
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100"> <div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} {formatMoney(project.budgetCents)}
</div> </div>
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} /> <BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
</td> </td>
@@ -7,6 +7,7 @@ import type { Resource, SkillEntry } from "@planarchy/shared";
import { RESOURCE_COLUMNS } from "@planarchy/shared"; import { RESOURCE_COLUMNS } from "@planarchy/shared";
import { BlueprintTarget, ResourceType } from "@planarchy/shared"; import { BlueprintTarget, ResourceType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { formatMoney } from "~/lib/format.js";
import { ResourceModal } from "~/components/resources/ResourceModal.js"; import { ResourceModal } from "~/components/resources/ResourceModal.js";
import { ImportModal } from "~/components/resources/ImportModal.js"; import { ImportModal } from "~/components/resources/ImportModal.js";
import { BulkEditModal } from "~/components/resources/BulkEditModal.js"; import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
@@ -1175,7 +1176,7 @@ export function ResourcesClient() {
key={col.key} key={col.key}
className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100" className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100"
> >
{(resource.lcrCents / 100).toFixed(0)} {resource.currency} {formatMoney(resource.lcrCents, resource.currency)}
</td> </td>
); );
case "chargeability": { case "chargeability": {
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useRef, useState, useMemo, useCallback } from "react"; import { useRef, useState, useMemo } from "react";
import { AllocationStatus } from "@planarchy/shared"; import { AllocationStatus } from "@planarchy/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { formatDateMedium } from "~/lib/format.js"; import { formatCents, formatDateMedium } from "~/lib/format.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface OpenDemandAllocation { interface OpenDemandAllocation {
@@ -69,15 +70,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 200); searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 200);
} }
const utils = trpc.useUtils(); const invalidatePlanningViews = useInvalidatePlanningViews();
const invalidatePlanningViews = useCallback(async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
await utils.timeline.getEntries.invalidate();
await utils.timeline.getEntriesView.invalidate();
await utils.timeline.getProjectContext.invalidate();
await utils.timeline.getBudgetStatus.invalidate();
}, [utils]);
const { data: resources } = trpc.resource.list.useQuery( const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, search: debouncedSearch || undefined, limit: 50 }, { isActive: true, search: debouncedSearch || undefined, limit: 50 },
@@ -211,7 +204,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 dark:text-gray-400">
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total {allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR` : ""} {allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
</div> </div>
</div> </div>
</div> </div>
@@ -414,7 +407,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</td> </td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td> <td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td> <td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{(r.estimatedCostCents / 100).toLocaleString("de-DE")} EUR</td> <td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}> <span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
{r.coveragePercent}% {r.coveragePercent}%
@@ -431,7 +424,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{Math.round(consumedHours)}h / {totalDemandHours}h {Math.round(consumedHours)}h / {totalDemandHours}h
</td> </td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300"> <td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{(planned.reduce((s, r) => s + r.estimatedCostCents, 0) / 100).toLocaleString("de-DE")} EUR {formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
</td> </td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300"> <td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}% {totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
@@ -441,7 +434,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<tr> <tr>
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td> <td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300"> <td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR {formatCents(allocation.budgetCents)} EUR
</td> </td>
<td className="px-3 py-1.5 text-right text-xs"> <td className="px-3 py-1.5 text-right text-xs">
{(() => { {(() => {
@@ -449,7 +442,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const remain = allocation.budgetCents! - totalCost; const remain = allocation.budgetCents! - totalCost;
return ( return (
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}> <span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
{remain < 0 ? `${(Math.abs(remain) / 100).toLocaleString("de-DE")} over` : `${(remain / 100).toLocaleString("de-DE")} left`} {remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
</span> </span>
); );
})()} })()}
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { formatCents, formatMoney } from "~/lib/format.js";
import { ProjectStatus } from "@planarchy/shared/types"; import { ProjectStatus } from "@planarchy/shared/types";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js"; import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js";
@@ -271,7 +272,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
</span> </span>
</td> </td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200"> <td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{(p.totalCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {formatCents(p.totalCostCents)}
</td> </td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200"> <td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{p.totalPersonDays}d {p.totalPersonDays}d
@@ -280,7 +281,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
{p.budgetCents > 0 ? ( {p.budgetCents > 0 ? (
<div className="flex items-center justify-end gap-1.5"> <div className="flex items-center justify-end gap-1.5">
<span className="text-gray-700 dark:text-gray-200"> <span className="text-gray-700 dark:text-gray-200">
{(p.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} {formatMoney(p.budgetCents)}
</span> </span>
<span <span
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${overBudget ? "bg-red-500" : util >= 80 ? "bg-amber-500" : "bg-green-500"}`} className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${overBudget ? "bg-red-500" : util >= 80 ? "bg-amber-500" : "bg-green-500"}`}
@@ -2,7 +2,7 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatDate, formatMoney } from "~/lib/format.js"; import { formatCents, formatDate, formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ALLOCATION_STATUS_BADGE } from "~/lib/status-styles.js"; import { ALLOCATION_STATUS_BADGE } from "~/lib/status-styles.js";
import { usePermissions } from "~/hooks/usePermissions.js"; import { usePermissions } from "~/hooks/usePermissions.js";
@@ -127,7 +127,7 @@ export function ProjectAssignmentsTable({ assignments }: ProjectAssignmentsTable
</td> </td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{assignment.hoursPerDay}h</td> <td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{assignment.hoursPerDay}h</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100"> <td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {formatCents(assignment.dailyCostCents)}
</td> </td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100"> <td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{(countWorkingDays(assignment.startDate, assignment.endDate) * assignment.hoursPerDay).toLocaleString("de-DE", { minimumFractionDigits: 1 })}h {(countWorkingDays(assignment.startDate, assignment.endDate) * assignment.hoursPerDay).toLocaleString("de-DE", { minimumFractionDigits: 1 })}h
@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatDate } from "~/lib/format.js"; import { formatCents, formatDate } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js"; import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationModal } from "~/components/allocations/AllocationModal.js"; import { AllocationModal } from "~/components/allocations/AllocationModal.js";
@@ -132,11 +132,11 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
return ( return (
<div> <div>
<div className="text-gray-900 dark:text-gray-100"> <div className="text-gray-900 dark:text-gray-100">
{(demand.budgetCents! / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR {formatCents(demand.budgetCents!)} EUR
</div> </div>
<div className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}> <div className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}>
{bookedCents > 0 ? `${(bookedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} booked` : ""} {bookedCents > 0 ? `${formatCents(bookedCents)} booked` : ""}
{remainCents < 0 ? ` (${(Math.abs(remainCents) / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} over)` : ""} {remainCents < 0 ? ` (${formatCents(Math.abs(remainCents))} over)` : ""}
</div> </div>
</div> </div>
); );
@@ -10,6 +10,7 @@ import { DateInput } from "~/components/ui/DateInput.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js"; import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
import { usePermissions } from "~/hooks/usePermissions.js"; import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js";
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@@ -506,9 +507,9 @@ function Step3({ state, onChange }: Step3Props) {
<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 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>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Project: {(projectBudgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span> <span>Project: {formatCents(projectBudgetCents)} EUR</span>
<span>Allocated: {(allocatedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span> <span>Allocated: {formatCents(allocatedCents)} EUR</span>
<span className="font-semibold">Remaining: {(remainingCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span> <span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
</div> </div>
</div> </div>
); );
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared"; import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { formatDate } from "~/lib/format.js"; import { formatDate, formatMoney } from "~/lib/format.js";
import { ResourceModal } from "./ResourceModal.js"; import { ResourceModal } from "./ResourceModal.js";
import { SkillRadarChart } from "./SkillRadarChart.js"; import { SkillRadarChart } from "./SkillRadarChart.js";
import { AiSummaryCard } from "./AiSummaryCard.js"; import { AiSummaryCard } from "./AiSummaryCard.js";
@@ -276,14 +276,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{canViewCosts && ( {canViewCosts && (
<StatCard <StatCard
label="LCR" label="LCR"
value={`${(resource.lcrCents / 100).toFixed(0)} ${resource.currency}/h`} value={`${formatMoney(resource.lcrCents, resource.currency)}/h`}
tooltip="Loaded Cost Rate: fully-loaded hourly cost including salary, benefits, and overhead. Used in budget calculations." tooltip="Loaded Cost Rate: fully-loaded hourly cost including salary, benefits, and overhead. Used in budget calculations."
/> />
)} )}
{canViewCosts && ( {canViewCosts && (
<StatCard <StatCard
label="UCR" label="UCR"
value={`${(resource.ucrCents / 100).toFixed(0)} ${resource.currency}/h`} value={`${formatMoney(resource.ucrCents, resource.currency)}/h`}
tooltip="Unit Cost Rate: the rate charged to the client or project for this resource's time." tooltip="Unit Cost Rate: the rate charged to the client or project for this resource's time."
/> />
)} )}
@@ -518,7 +518,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{canViewCosts && ( {canViewCosts && (
<td className="px-4 py-3 text-right text-gray-700"> <td className="px-4 py-3 text-right text-gray-700">
{a.dailyCostCents > 0 {a.dailyCostCents > 0
? `${(a.dailyCostCents / 100).toFixed(0)}/d` ? `${formatMoney(a.dailyCostCents)}/d`
: "—"} : "—"}
</td> </td>
)} )}
@@ -4,6 +4,7 @@ import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { AllocationLike, AllocationReadModel, Assignment } from "@planarchy/shared"; import type { AllocationLike, AllocationReadModel, Assignment } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
@@ -29,6 +30,7 @@ export function AllocationPopover({
}: AllocationPopoverProps) { }: AllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery( const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId }, { projectId },
@@ -55,10 +57,7 @@ export function AllocationPopover({
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.timeline.getEntries.invalidate(); invalidateTimeline();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.allocation.listView.invalidate(); void utils.allocation.listView.invalidate();
onClose(); onClose();
}, },
@@ -4,6 +4,7 @@ import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared"; import { AllocationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
interface BatchAssignPopoverProps { interface BatchAssignPopoverProps {
resourceIds: string[]; resourceIds: string[];
@@ -29,7 +30,7 @@ export function BatchAssignPopover({
onCreated, onCreated,
}: BatchAssignPopoverProps) { }: BatchAssignPopoverProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>( const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
@@ -51,10 +52,7 @@ export function BatchAssignPopover({
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({ const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.timeline.getEntries.invalidate(); invalidateTimeline();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
onCreated(); onCreated();
onClose(); onClose();
}, },
@@ -2,7 +2,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js"; import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatDateLong } from "~/lib/format.js"; import { formatCents, formatDateLong } from "~/lib/format.js";
interface DemandPopoverProps { interface DemandPopoverProps {
demand: TimelineDemandEntry; demand: TimelineDemandEntry;
@@ -143,13 +143,13 @@ export function DemandPopover({
<div> <div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Daily cost</div> <div className="text-gray-400 dark:text-gray-500 mb-0.5">Daily cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200"> <div className="font-medium text-gray-800 dark:text-gray-200">
{(demand.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR {formatCents(demand.dailyCostCents)} EUR
</div> </div>
</div> </div>
<div> <div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total cost</div> <div className="text-gray-400 dark:text-gray-500 mb-0.5">Total cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200"> <div className="font-medium text-gray-800 dark:text-gray-200">
{(budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR {formatCents(budgetCents)} EUR
</div> </div>
</div> </div>
</> </>
@@ -4,6 +4,7 @@ import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared"; import { AllocationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
interface NewAllocationPopoverProps { interface NewAllocationPopoverProps {
@@ -36,7 +37,7 @@ export function NewAllocationPopover({
onCreated, onCreated,
}: NewAllocationPopoverProps) { }: NewAllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>( const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
@@ -60,10 +61,7 @@ export function NewAllocationPopover({
const createMutation = trpc.timeline.quickAssign.useMutation({ const createMutation = trpc.timeline.quickAssign.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.timeline.getEntries.invalidate(); invalidateTimeline();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
onCreated(); onCreated();
onClose(); onClose();
}, },
@@ -4,6 +4,7 @@ import { clsx } from "clsx";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared"; import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
@@ -89,7 +90,7 @@ function normalizeRole(value: string | null | undefined): string {
} }
export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
const utils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline();
const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery( const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery(
{ projectId }, { projectId },
@@ -102,29 +103,16 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
); );
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => { onSuccess: invalidateTimeline,
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
},
}); });
const deleteMutation = trpc.allocation.deleteAssignment.useMutation({ const deleteMutation = trpc.allocation.deleteAssignment.useMutation({
onSuccess: () => { onSuccess: invalidateTimeline,
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
},
}); });
const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({ const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.timeline.getProjectContext.invalidate(); invalidateTimeline();
void utils.timeline.getBudgetStatus.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
setAddingMember(false); setAddingMember(false);
setResourceSearch(""); setResourceSearch("");
}, },
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { formatCents } from "~/lib/format.js";
import type { SkillEntry } from "@planarchy/shared"; import type { SkillEntry } from "@planarchy/shared";
interface ResourceHoverCardProps { interface ResourceHoverCardProps {
@@ -120,14 +121,14 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
<div> <div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div> <div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div>
<div className="font-semibold text-gray-700 dark:text-gray-200"> <div className="font-semibold text-gray-700 dark:text-gray-200">
{(data.lcrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h {formatCents(data.lcrCents)} {data.currency}/h
</div> </div>
</div> </div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" /> <div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
<div> <div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div> <div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div>
<div className="font-semibold text-gray-700 dark:text-gray-200"> <div className="font-semibold text-gray-700 dark:text-gray-200">
{(data.ucrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h {formatCents(data.ucrCents)} {data.currency}/h
</div> </div>
</div> </div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" /> <div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
@@ -1,7 +1,7 @@
"use client"; "use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { import {
@@ -22,6 +22,12 @@ import {
} from "./timelineConstants.js"; } from "./timelineConstants.js";
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js"; import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js"; import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
import {
renderVacationBlocks,
renderRangeOverlay,
renderOverbookingBlink,
type VacationBlockInfo,
} from "./renderHelpers.js";
// ─── Props ────────────────────────────────────────────────────────────────── // ─── Props ──────────────────────────────────────────────────────────────────
@@ -173,7 +179,7 @@ function buildProjectRowGridBackground(dates: Date[], CELL_WIDTH: number, today:
// ─── Component ────────────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────────────
export function TimelineProjectPanel({ function TimelineProjectPanelInner({
scrollContainerRef, scrollContainerRef,
dragState, dragState,
allocDragState, allocDragState,
@@ -785,22 +791,30 @@ export function TimelineProjectPanel({
onAllocationContextMenu, onAllocationContextMenu,
multiSelectState, multiSelectState,
)} )}
{renderVacationBlocksForProjectRow( {filters.showVacations &&
vacationsByResource.get(row.resource.id) ?? [], renderVacationBlocks(
ROW_HEIGHT, (vacationsByResource.get(row.resource.id) ?? []).reduce<VacationBlockInfo[]>(
toLeft, (acc, v) => {
toWidth, const vStart = new Date(v.startDate);
CELL_WIDTH, const vEnd = new Date(v.endDate);
totalCanvasWidth, const left = toLeft(vStart);
filters.showVacations, const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
)} if (width > 0 && left < totalCanvasWidth) {
acc.push({ vacation: v, left, width });
}
return acc;
},
[],
),
ROW_HEIGHT,
)}
{blinkOverbookedDays && {blinkOverbookedDays &&
renderOverbookingBlinkProject( renderOverbookingBlink(
allocsByResource.get(row.resource.id) ?? [], allocsByResource.get(row.resource.id) ?? [],
dates, dates,
CELL_WIDTH, CELL_WIDTH,
)} )}
{renderRangeOverlayProject( {renderRangeOverlay(
rangeState, rangeState,
row.resource.id, row.resource.id,
ROW_HEIGHT, ROW_HEIGHT,
@@ -1294,127 +1308,4 @@ function renderProjectDragHandles(
}); });
} }
// ─── Vacation blocks for project view rows ────────────────────────────────── export const TimelineProjectPanel = memo(TimelineProjectPanelInner);
const TYPE_COLORS: Record<string, string> = {
ANNUAL: "bg-orange-400/40",
SICK: "bg-red-500/40",
PUBLIC_HOLIDAY: "bg-violet-400/40",
OTHER: "bg-amber-400/40",
};
const TYPE_BORDER: Record<string, string> = {
ANNUAL: "border-orange-500",
SICK: "border-red-600",
PUBLIC_HOLIDAY: "border-violet-500",
OTHER: "border-amber-500",
};
const TYPE_LABELS_SHORT: Record<string, string> = {
ANNUAL: "Annual",
SICK: "Sick",
PUBLIC_HOLIDAY: "Holiday",
OTHER: "Other",
};
function renderVacationBlocksForProjectRow(
vacations: { id: string; type: string; startDate: Date | string; endDate: Date | string }[],
rowHeight: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
totalCanvasWidth: number,
showVacations: boolean,
) {
if (!showVacations || vacations.length === 0) return null;
return vacations.map((v) => {
const vStart = new Date(v.startDate);
const vEnd = new Date(v.endDate);
const left = toLeft(vStart);
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
if (width <= 0 || left >= totalCanvasWidth) return null;
const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
return (
<div
key={`vac-${v.id}`}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden border-t-2 pointer-events-none",
colorClass,
borderClass,
)}
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
>
{width > 40 && (
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
🏖 {label}
</span>
)}
</div>
);
});
}
// ─── Range overlay for project view ─────────────────────────────────────────
function renderOverbookingBlinkProject(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
/>
));
}
function renderRangeOverlayProject(
rangeState: RangeState,
resourceId: string,
rowHeight: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
) {
if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) {
return null;
}
const end = rangeState.currentDate ?? rangeState.startDate;
const [selStart, selEnd] =
rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate];
const left = toLeft(selStart);
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
return (
<div
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
style={{ left, width, top: 4, height: rowHeight - 8 }}
/>
);
}
@@ -1,12 +1,11 @@
"use client"; "use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { import {
useTimelineContext, useTimelineContext,
type TimelineAssignmentEntry, type TimelineAssignmentEntry,
type VacationEntry,
} from "./TimelineContext.js"; } from "./TimelineContext.js";
import { ConflictOverlay } from "./ConflictOverlay.js"; import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js"; import { computeSubLanes } from "./utils.js";
@@ -27,6 +26,12 @@ import type {
MultiSelectState, MultiSelectState,
} from "~/hooks/useTimelineDrag.js"; } from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
import {
renderVacationBlocks,
renderRangeOverlay,
renderOverbookingBlink,
type VacationBlockInfo,
} from "./renderHelpers.js";
// ─── Props ────────────────────────────────────────────────────────────────── // ─── Props ──────────────────────────────────────────────────────────────────
@@ -76,7 +81,7 @@ export interface RowMouseDownInfo {
// ─── Component ────────────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────────────
export function TimelineResourcePanel({ function TimelineResourcePanelInner({
scrollContainerRef, scrollContainerRef,
dragState, dragState,
allocDragState, allocDragState,
@@ -487,7 +492,7 @@ export function TimelineResourcePanel({
onAllocationContextMenu, onAllocationContextMenu,
multiSelectState, multiSelectState,
)} )}
{renderVacationBlocksForRow( {renderVacationBlocks(
vacationBlocksByResource.get(resource.id) ?? [], vacationBlocksByResource.get(resource.id) ?? [],
rowHeight, rowHeight,
)} )}
@@ -547,12 +552,6 @@ export function TimelineResourcePanel({
// ─── Helper types ─────────────────────────────────────────────────────────── // ─── Helper types ───────────────────────────────────────────────────────────
interface VacationBlockInfo {
vacation: VacationEntry;
left: number;
width: number;
}
interface AllocBlockData { interface AllocBlockData {
alloc: TimelineAssignmentEntry; alloc: TimelineAssignmentEntry;
lane: number; lane: number;
@@ -560,81 +559,6 @@ interface AllocBlockData {
// ─── Pure render functions (no hooks, extracted from TimelineView) ─────────── // ─── Pure render functions (no hooks, extracted from TimelineView) ───────────
const TYPE_COLORS: Record<string, string> = {
ANNUAL: "bg-orange-400/40",
SICK: "bg-red-500/40",
PUBLIC_HOLIDAY: "bg-violet-400/40",
OTHER: "bg-amber-400/40",
};
const TYPE_BORDER: Record<string, string> = {
ANNUAL: "border-orange-500",
SICK: "border-red-600",
PUBLIC_HOLIDAY: "border-violet-500",
OTHER: "border-amber-500",
};
const TYPE_LABELS_SHORT: Record<string, string> = {
ANNUAL: "Annual",
SICK: "Sick",
PUBLIC_HOLIDAY: "Holiday",
OTHER: "Other",
};
function renderVacationBlocksForRow(blocks: VacationBlockInfo[], rowHeight: number) {
if (blocks.length === 0) return null;
return blocks.map(({ vacation: v, left, width }) => {
const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
const isPending = v.status === "PENDING";
return (
<div
key={`vac-${v.id}`}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
colorClass,
isPending ? "border-t-2 border-dashed opacity-60" : "border-t-2",
isPending ? "" : borderClass,
)}
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
>
{width > 40 && (
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
{isPending ? "\u23F3" : "\uD83C\uDFD6"} {label}
</span>
)}
</div>
);
});
}
function renderRangeOverlay(
rangeState: RangeState,
resourceId: string,
rowHeight: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
) {
if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) {
return null;
}
const end = rangeState.currentDate ?? rangeState.startDate;
const [selStart, selEnd] =
rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate];
const left = toLeft(selStart);
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
return (
<div
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
style={{ left, width, top: 4, height: rowHeight - 8 }}
/>
);
}
function renderAllocBlocksFromData( function renderAllocBlocksFromData(
blockData: AllocBlockData[], blockData: AllocBlockData[],
_allocs: TimelineAssignmentEntry[], _allocs: TimelineAssignmentEntry[],
@@ -890,45 +814,6 @@ function renderHeatmapOverlay(
}); });
} }
// ─── Overbooking blink overlay ───────────────────────────────────────────────
function renderOverbookingBlink(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{
left: i * CELL_WIDTH,
width: CELL_WIDTH,
}}
/>
));
}
// ─── Bar-mode: stacked daily bars ──────────────────────────────────────────── // ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
function renderDailyBars( function renderDailyBars(
@@ -1108,5 +993,8 @@ function renderDailyBars(
}); });
} }
export const TimelineResourcePanel = memo(TimelineResourcePanelInner);
// ─── Re-export tooltip types for the parent ───────────────────────────────── // ─── Re-export tooltip types for the parent ─────────────────────────────────
export type { VacationBlockInfo, AllocBlockData }; export type { AllocBlockData };
export type { VacationBlockInfo } from "./renderHelpers.js";
+22 -130
View File
@@ -7,6 +7,7 @@ import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js"; import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js"; import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js"; import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js"; import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js"; import { DemandPopover } from "./DemandPopover.js";
@@ -29,6 +30,7 @@ import {
} from "./TimelineContext.js"; } from "./TimelineContext.js";
import { TimelineResourcePanel } from "./TimelineResourcePanel.js"; import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js"; import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
// ─── Entry point ──────────────────────────────────────────────────────────── // ─── Entry point ────────────────────────────────────────────────────────────
// Two-layer mount: the outer shell creates drag state + project context, // Two-layer mount: the outer shell creates drag state + project context,
@@ -67,14 +69,9 @@ export function TimelineView() {
// We start with 40 (day zoom default) and update via a ref. // We start with 40 (day zoom default) and update via a ref.
const cellWidthRef = useRef(40); const cellWidthRef = useRef(40);
const outerUtils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline();
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({ const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: () => { onSuccess: invalidateTimeline,
void outerUtils.timeline.getEntries.invalidate();
void outerUtils.timeline.getEntriesView.invalidate();
void outerUtils.timeline.getProjectContext.invalidate();
void outerUtils.timeline.getBudgetStatus.invalidate();
},
}); });
const { const {
@@ -327,13 +324,10 @@ function TimelineViewContent({
} | null>(null); } | null>(null);
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const utils = trpc.useUtils(); const invalidateTimelineInner = useInvalidateTimeline();
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({ const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.timeline.getEntries.invalidate(); invalidateTimelineInner();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
clearMultiSelect(); clearMultiSelect();
}, },
}); });
@@ -570,124 +564,22 @@ function TimelineViewContent({
}; };
// ─── Multi-select intersection computation ──────────────────────────────── // ─── Multi-select intersection computation ────────────────────────────────
useEffect(() => { useMultiSelectIntersection({
// Only compute when drag just ended (isSelecting false but has coordinates) multiSelectState,
if (multiSelectState.isSelecting) return; setMultiSelectState,
if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return; clearMultiSelect,
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return; canvasRef,
viewMode,
const canvasEl = canvasRef.current; resources,
if (!canvasEl) return; allocsByResource,
projectGroups,
// Selection rectangle in viewport coordinates (same coordinate space as openDemandsByProject,
// getBoundingClientRect). Using viewport coords directly avoids any dates,
// coordinate transformation errors from sticky headers or virtualizer offsets. today,
const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY); CELL_WIDTH,
const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY); toLeft,
const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX); toWidth,
const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX); });
// For X-axis: convert viewport X to canvas-relative X for allocation matching.
// Query any row element to find the actual canvas area position.
const canvasRect = canvasEl.getBoundingClientRect();
const canvasXOffset = canvasRect.left + LABEL_WIDTH;
const toCanvasX = (clientX: number) => clientX - canvasXOffset;
const selLeftCanvas = toCanvasX(selLeft);
const selRightCanvas = toCanvasX(selRight);
// Derive date range from pixel X positions
const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
const startDate = dates[colIndexStart] ?? today;
const endDate = dates[colIndexEnd] ?? today;
// Find allocations within the rectangle by querying actual DOM positions.
// This avoids any mismatch between computed row positions and actual rendering.
const selectedIds: string[] = [];
const selectedResIds: string[] = [];
// Query all rendered row elements (virtualizer only renders visible + overscan rows)
const rowElements = canvasEl.querySelectorAll<HTMLElement>("[data-index]");
if (viewMode === "resource") {
rowElements.forEach((rowEl) => {
const idx = Number(rowEl.dataset.index);
const resource = resources[idx];
if (!resource) return;
const rowRect = rowEl.getBoundingClientRect();
// Compare directly in viewport coordinates
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
selectedResIds.push(resource.id);
const allocs = allocsByResource.get(resource.id) ?? [];
for (const alloc of allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
} else if (viewMode === "project") {
// Project view: query actual resource row DOM elements by data attribute.
// Each row carries data-project-id and data-resource-id for alloc lookup.
const projectRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-resource-row]");
projectRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
const resourceId = rowEl.dataset.resourceId;
if (!projectId || !resourceId) return;
// Find matching group and row
const group = projectGroups.find((g) => g.id === projectId);
if (!group) return;
const row = group.resourceRows.find((r) => r.resource.id === resourceId);
if (!row) return;
for (const alloc of row.allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
// Also check demand rows for open demand selection
const demandRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-demand-row]");
demandRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
if (!projectId) return;
const demands = openDemandsByProject.get(projectId) ?? [];
for (const demand of demands) {
const allocLeft = toLeft(new Date(demand.startDate));
const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(demand.id);
}
}
});
}
if (selectedIds.length > 0 || selectedResIds.length > 0) {
setMultiSelectState(prev => ({
...prev,
selectedAllocationIds: selectedIds,
selectedResourceIds: selectedResIds,
dateRange: { start: startDate, end: endDate },
}));
} else {
clearMultiSelect();
}
}, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<div className="relative flex flex-1 flex-col gap-4 min-h-0"> <div className="relative flex flex-1 flex-col gap-4 min-h-0">
@@ -0,0 +1,51 @@
/**
* Pure math utilities for timeline drag operations.
* Extracted from useTimelineDrag to make the conversion logic testable
* and reusable across different drag modes.
*/
/** Convert a pixel delta to a number of whole days based on cell width. */
export function pixelsToDays(deltaX: number, cellWidth: number): number {
return Math.round(deltaX / cellWidth);
}
/**
* Shift a date by a given number of days, returning a new Date.
* Does not mutate the input.
*/
export function shiftDate(date: Date, daysDelta: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + daysDelta);
return result;
}
/**
* Compute new start/end dates for a drag operation.
* Handles move, resize-start, and resize-end modes with clamping
* to prevent start from crossing past end (or vice versa).
*/
export function computeDragDates(
mode: "move" | "resize-start" | "resize-end",
originalStart: Date,
originalEnd: Date,
daysDelta: number,
): { start: Date; end: Date } {
const newStart = new Date(originalStart);
const newEnd = new Date(originalEnd);
if (mode === "move") {
newStart.setDate(newStart.getDate() + daysDelta);
newEnd.setDate(newEnd.getDate() + daysDelta);
} else if (mode === "resize-start") {
newStart.setDate(newStart.getDate() + daysDelta);
// Clamp: allow same-day but prevent crossing
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
} else {
// resize-end
newEnd.setDate(newEnd.getDate() + daysDelta);
// Clamp: allow same-day but prevent crossing
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
}
return { start: newStart, end: newEnd };
}
@@ -0,0 +1,121 @@
/**
* Shared pure render functions used by both TimelineResourcePanel and TimelineProjectPanel.
* Extracted to avoid duplication of identical vacation blocks, range overlay, and overbooking blink logic.
*/
import { clsx } from "clsx";
import {
VACATION_TIMELINE_COLORS,
VACATION_TIMELINE_BORDER,
VACATION_TYPE_LABELS_SHORT,
} from "~/lib/status-styles.js";
import type { RangeState } from "~/hooks/useTimelineDrag.js";
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
import type { VacationEntry } from "./TimelineContext.js";
// ─── Shared types ─────────────────────────────────────────────────────────────
export interface VacationBlockInfo {
vacation: VacationEntry;
left: number;
width: number;
}
// ─── Vacation block overlays ─────────────────────────────────────────────────
export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: number) {
if (blocks.length === 0) return null;
return blocks.map(({ vacation: v, left, width }) => {
const colorClass = VACATION_TIMELINE_COLORS[v.type] ?? "bg-orange-400/40";
const borderClass = VACATION_TIMELINE_BORDER[v.type] ?? "border-orange-500";
const label = VACATION_TYPE_LABELS_SHORT[v.type] ?? v.type;
const isPending = v.status === "PENDING";
return (
<div
key={`vac-${v.id}`}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
colorClass,
isPending ? "border-t-2 border-dashed opacity-60" : "border-t-2",
isPending ? "" : borderClass,
)}
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
>
{width > 40 && (
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
{isPending ? "\u23F3" : "\uD83C\uDFD6"} {label}
</span>
)}
</div>
);
});
}
// ─── Range selection overlay ─────────────────────────────────────────────────
export function renderRangeOverlay(
rangeState: RangeState,
resourceId: string,
rowHeight: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
) {
if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) {
return null;
}
const end = rangeState.currentDate ?? rangeState.startDate;
const [selStart, selEnd] =
rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate];
const left = toLeft(selStart);
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
return (
<div
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
style={{ left, width, top: 4, height: rowHeight - 8 }}
/>
);
}
// ─── Overbooking blink overlay ───────────────────────────────────────────────
export function renderOverbookingBlink(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{
left: i * CELL_WIDTH,
width: CELL_WIDTH,
}}
/>
));
}
@@ -4,13 +4,7 @@ import { useState } from "react";
import { VacationStatus } from "@planarchy/shared"; import { VacationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-500",
OTHER: "bg-purple-400",
};
const MONTH_NAMES = [ const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
@@ -49,7 +43,7 @@ export function TeamCalendar() {
const { data: allChapters } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 }); const { data: allChapters } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
const chapters = allChapters ?? []; const chapters = allChapters ?? [];
const resourceList = resources?.resources ?? []; const resourceList: { id: string; displayName: string }[] = resources?.resources ?? [];
const vacationList = (vacations ?? []).filter( const vacationList = (vacations ?? []).filter(
(v) => v.status !== VacationStatus.CANCELLED && v.status !== VacationStatus.REJECTED, (v) => v.status !== VacationStatus.CANCELLED && v.status !== VacationStatus.REJECTED,
); );
@@ -155,7 +149,7 @@ export function TeamCalendar() {
let cellClass = "w-7 h-7"; let cellClass = "w-7 h-7";
if (vac) { if (vac) {
const color = TYPE_COLOR[vac.type] ?? "bg-gray-400"; const color = VACATION_CALENDAR_COLORS[vac.type] ?? "bg-gray-400";
const opacity = vac.status === "PENDING" ? "opacity-50" : ""; const opacity = vac.status === "PENDING" ? "opacity-50" : "";
cellClass += ` ${color} ${opacity}`; cellClass += ` ${color} ${opacity}`;
} else if (isWeekend) { } else if (isWeekend) {
@@ -186,7 +180,7 @@ export function TeamCalendar() {
{/* Legend */} {/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap"> <div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => ( {Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"> <span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} /> <span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")} {type.replace("_", " ")}
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { VacationStatus, VacationType } from "@planarchy/shared"; import { VacationStatus, VacationType } from "@planarchy/shared";
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
interface VacationEntry { interface VacationEntry {
id: string; id: string;
@@ -18,13 +19,6 @@ interface VacationCalendarProps {
initialMonth?: number; // 0-indexed initialMonth?: number; // 0-indexed
} }
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-400",
OTHER: "bg-purple-400",
};
const STATUS_OPACITY: Record<string, string> = { const STATUS_OPACITY: Record<string, string> = {
APPROVED: "opacity-100", APPROVED: "opacity-100",
PENDING: "opacity-60", PENDING: "opacity-60",
@@ -145,7 +139,7 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
</span> </span>
<div className="space-y-0.5"> <div className="space-y-0.5">
{dayVacations.slice(0, 3).map((v) => { {dayVacations.slice(0, 3).map((v) => {
const colorClass = TYPE_COLOR[v.type] ?? "bg-gray-400"; const colorClass = VACATION_CALENDAR_COLORS[v.type] ?? "bg-gray-400";
const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100"; const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100";
const name = v.resource?.displayName ?? "—"; const name = v.resource?.displayName ?? "—";
return ( return (
@@ -169,7 +163,7 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
{/* Legend */} {/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap"> <div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => ( {Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"> <span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} /> <span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")} {type.replace("_", " ")}
@@ -7,16 +7,10 @@ import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
import { useDebounce } from "~/hooks/useDebounce.js"; import { useDebounce } from "~/hooks/useDebounce.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
const VACATION_TYPES = Object.values(VacationType); const VACATION_TYPES = Object.values(VacationType);
const VACATION_TYPE_LABELS: Record<VacationType, string> = {
ANNUAL: "Annual Leave",
SICK: "Sick Leave",
PUBLIC_HOLIDAY: "Public Holiday",
OTHER: "Other",
};
interface VacationModalProps { interface VacationModalProps {
resourceId?: string; resourceId?: string;
onClose: () => void; onClose: () => void;
@@ -118,7 +112,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
const inputClass = const inputClass =
"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"; "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 labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = resources?.resources ?? []; const resourceList: { id: string; displayName: string; eid: string }[] = resources?.resources ?? [];
return ( return (
<div <div
+4 -20
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import type { AllocationMovedSnapshot } from "./useTimelineDrag.js"; import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
export type { AllocationMovedSnapshot }; export type { AllocationMovedSnapshot };
@@ -18,7 +19,7 @@ export function useAllocationHistory() {
const past = useRef<HistoryEntry[]>([]); const past = useRef<HistoryEntry[]>([]);
const future = useRef<HistoryEntry[]>([]); const future = useRef<HistoryEntry[]>([]);
const utils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline();
// Configurable max steps from system settings // Configurable max steps from system settings
const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, { const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, {
@@ -27,21 +28,11 @@ export function useAllocationHistory() {
const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY; const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY;
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => { onSuccess: invalidateTimeline,
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
},
}); });
const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({ const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: () => { onSuccess: invalidateTimeline,
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
},
}); });
const push = useCallback((snapshot: AllocationMovedSnapshot) => { const push = useCallback((snapshot: AllocationMovedSnapshot) => {
@@ -58,13 +49,6 @@ export function useAllocationHistory() {
setCanRedo(false); setCanRedo(false);
}, [maxHistory]); }, [maxHistory]);
const invalidateAll = useCallback(() => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
}, [utils]);
const undo = useCallback(async () => { const undo = useCallback(async () => {
const last = past.current[past.current.length - 1]; const last = past.current[past.current.length - 1];
if (!last) return; if (!last) return;
@@ -1,8 +1,19 @@
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
/** Invalidates just the 4 timeline queries */
export function useInvalidateTimeline() {
const utils = trpc.useUtils();
return () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
};
}
/** Invalidates all 8 planning-related queries (4 timeline + 4 allocation) */
export function useInvalidatePlanningViews() { export function useInvalidatePlanningViews() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
return () => { return () => {
void utils.allocation.list.invalidate(); void utils.allocation.list.invalidate();
void ( void (
@@ -0,0 +1,166 @@
/**
* Computes which allocations/resources fall within the multi-select rectangle
* after the user finishes a right-click drag selection.
*/
import { useEffect } from "react";
import { LABEL_WIDTH } from "~/components/timeline/timelineConstants.js";
import type { MultiSelectState, AllocDragMode } from "~/hooks/useTimelineDrag.js";
import type { TimelineAssignmentEntry } from "~/components/timeline/TimelineContext.js";
import type { ViewMode, ResourceBrief } from "~/components/timeline/TimelineContext.js";
interface ProjectGroup {
id: string;
resourceRows: {
resource: { id: string };
allocs: TimelineAssignmentEntry[];
}[];
}
interface DemandEntry {
id: string;
startDate: Date | string;
endDate: Date | string;
}
export function useMultiSelectIntersection({
multiSelectState,
setMultiSelectState,
clearMultiSelect,
canvasRef,
viewMode,
resources,
allocsByResource,
projectGroups,
openDemandsByProject,
dates,
today,
CELL_WIDTH,
toLeft,
toWidth,
}: {
multiSelectState: MultiSelectState;
setMultiSelectState: React.Dispatch<React.SetStateAction<MultiSelectState>>;
clearMultiSelect: () => void;
canvasRef: React.RefObject<HTMLDivElement | null>;
viewMode: ViewMode;
resources: ResourceBrief[];
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
projectGroups: ProjectGroup[];
openDemandsByProject: Map<string, DemandEntry[]>;
dates: Date[];
today: Date;
CELL_WIDTH: number;
toLeft: (d: Date) => number;
toWidth: (s: Date, e: Date) => number;
}) {
useEffect(() => {
// Only compute when drag just ended (isSelecting false but has coordinates)
if (multiSelectState.isSelecting) return;
if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return;
const canvasEl = canvasRef.current;
if (!canvasEl) return;
// Selection rectangle in viewport coordinates
const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY);
const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY);
const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX);
const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX);
// Convert viewport X to canvas-relative X for allocation matching
const canvasRect = canvasEl.getBoundingClientRect();
const canvasXOffset = canvasRect.left + LABEL_WIDTH;
const toCanvasX = (clientX: number) => clientX - canvasXOffset;
const selLeftCanvas = toCanvasX(selLeft);
const selRightCanvas = toCanvasX(selRight);
// Derive date range from pixel X positions
const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
const startDate = dates[colIndexStart] ?? today;
const endDate = dates[colIndexEnd] ?? today;
const selectedIds: string[] = [];
const selectedResIds: string[] = [];
// Query all rendered row elements (virtualizer only renders visible + overscan rows)
const rowElements = canvasEl.querySelectorAll<HTMLElement>("[data-index]");
if (viewMode === "resource") {
rowElements.forEach((rowEl) => {
const idx = Number(rowEl.dataset.index);
const resource = resources[idx];
if (!resource) return;
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
selectedResIds.push(resource.id);
const allocs = allocsByResource.get(resource.id) ?? [];
for (const alloc of allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
} else if (viewMode === "project") {
const projectRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-resource-row]");
projectRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
const resourceId = rowEl.dataset.resourceId;
if (!projectId || !resourceId) return;
const group = projectGroups.find((g) => g.id === projectId);
if (!group) return;
const row = group.resourceRows.find((r) => r.resource.id === resourceId);
if (!row) return;
for (const alloc of row.allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
// Also check demand rows for open demand selection
const demandRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-demand-row]");
demandRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
if (!projectId) return;
const demands = openDemandsByProject.get(projectId) ?? [];
for (const demand of demands) {
const allocLeft = toLeft(new Date(demand.startDate));
const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(demand.id);
}
}
});
}
if (selectedIds.length > 0 || selectedResIds.length > 0) {
setMultiSelectState(prev => ({
...prev,
selectedAllocationIds: selectedIds,
selectedResourceIds: selectedResIds,
dateRange: { start: startDate, end: endDate },
}));
} else {
clearMultiSelect();
}
}, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
}
+21 -31
View File
@@ -2,6 +2,8 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
// ─── Project-shift drag state ─────────────────────────────────────────────── // ─── Project-shift drag state ───────────────────────────────────────────────
@@ -214,6 +216,7 @@ export function useTimelineDrag({
onMultiDragCompleteRef.current = onMultiDragComplete; onMultiDragCompleteRef.current = onMultiDragComplete;
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
// Project-shift preview // Project-shift preview
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery( const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
@@ -235,9 +238,7 @@ export function useTimelineDrag({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({ const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
onSuccess: (data: { project: { id: string } }) => { onSuccess: (data: { project: { id: string } }) => {
void utils.timeline.getEntries.invalidate(); invalidateTimeline();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.project.list.invalidate(); void utils.project.list.invalidate();
onShiftApplied?.(data.project.id); onShiftApplied?.(data.project.id);
}, },
@@ -251,10 +252,7 @@ export function useTimelineDrag({
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({ const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.timeline.getEntries.invalidate(); invalidateTimeline();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
const snap = pendingSnapshotRef.current; const snap = pendingSnapshotRef.current;
if (snap) { if (snap) {
onAllocationMovedRef.current?.(snap); onAllocationMovedRef.current?.(snap);
@@ -378,7 +376,7 @@ export function useTimelineDrag({
function handleMultiMove(ev: MouseEvent) { function handleMultiMove(ev: MouseEvent) {
const deltaX = ev.clientX - startMouseX; const deltaX = ev.clientX - startMouseX;
const daysDelta = Math.round(deltaX / cellWidthRef.current); const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
if (daysDelta === currentDaysDelta) return; if (daysDelta === currentDaysDelta) return;
currentDaysDelta = daysDelta; currentDaysDelta = daysDelta;
@@ -432,25 +430,15 @@ export function useTimelineDrag({
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return; if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
const deltaX = ev.clientX - alloc.startMouseX; const deltaX = ev.clientX - alloc.startMouseX;
const daysDelta = Math.round(deltaX / cellWidthRef.current); const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
if (daysDelta === alloc.daysDelta) return; if (daysDelta === alloc.daysDelta) return;
const newStart = new Date(alloc.originalStartDate); const { start: newStart, end: newEnd } = computeDragDates(
const newEnd = new Date(alloc.originalEndDate); alloc.mode,
alloc.originalStartDate,
if (alloc.mode === "move") { alloc.originalEndDate,
newStart.setDate(newStart.getDate() + daysDelta); daysDelta,
newEnd.setDate(newEnd.getDate() + daysDelta); );
} else if (alloc.mode === "resize-start") {
newStart.setDate(newStart.getDate() + daysDelta);
// Allow same-day (single day booking), prevent crossing
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
} else {
// resize-end
newEnd.setDate(newEnd.getDate() + daysDelta);
// Allow same-day (single day booking), prevent crossing
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
}
const updated: AllocDragState = { const updated: AllocDragState = {
...alloc, ...alloc,
@@ -545,12 +533,14 @@ export function useTimelineDrag({
const drag = dragStateRef.current; const drag = dragStateRef.current;
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) { if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
const deltaX = e.clientX - drag.startMouseX; const deltaX = e.clientX - drag.startMouseX;
const daysDelta = Math.round(deltaX / cellWidth); const daysDelta = pixelsToDays(deltaX, cellWidth);
if (daysDelta !== drag.daysDelta) { if (daysDelta !== drag.daysDelta) {
const newStart = new Date(drag.originalStartDate); const { start: newStart, end: newEnd } = computeDragDates(
newStart.setDate(newStart.getDate() + daysDelta); "move",
const newEnd = new Date(drag.originalEndDate); drag.originalStartDate,
newEnd.setDate(newEnd.getDate() + daysDelta); drag.originalEndDate,
daysDelta,
);
const updated: DragState = { const updated: DragState = {
...drag, ...drag,
currentStartDate: newStart, currentStartDate: newStart,
@@ -567,7 +557,7 @@ export function useTimelineDrag({
const range = rangeStateRef.current; const range = rangeStateRef.current;
if (range.isSelecting && range.startDate) { if (range.isSelecting && range.startDate) {
const deltaX = e.clientX - range.startClientX; const deltaX = e.clientX - range.startClientX;
const daysDelta = Math.round(deltaX / cellWidth); const daysDelta = pixelsToDays(deltaX, cellWidth);
const currentDate = new Date(range.startDate); const currentDate = new Date(range.startDate);
currentDate.setDate(currentDate.getDate() + daysDelta); currentDate.setDate(currentDate.getDate() + daysDelta);
+30
View File
@@ -46,3 +46,33 @@ export const ORDER_TYPE_BADGE: Record<string, string> = {
INTERNAL: "bg-blue-100 text-blue-700", INTERNAL: "bg-blue-100 text-blue-700",
OVERHEAD: "bg-gray-100 text-gray-700", OVERHEAD: "bg-gray-100 text-gray-700",
}; };
/** Vacation overlay colors for timeline bars */
export const VACATION_TIMELINE_COLORS: Record<string, string> = {
ANNUAL: "bg-orange-400/40",
SICK: "bg-red-500/40",
PUBLIC_HOLIDAY: "bg-violet-400/40",
OTHER: "bg-amber-400/40",
};
export const VACATION_TIMELINE_BORDER: Record<string, string> = {
ANNUAL: "border-orange-500",
SICK: "border-red-600",
PUBLIC_HOLIDAY: "border-violet-500",
OTHER: "border-amber-500",
};
export const VACATION_TYPE_LABELS_SHORT: Record<string, string> = {
ANNUAL: "Annual",
SICK: "Sick",
PUBLIC_HOLIDAY: "Holiday",
OTHER: "Other",
};
/** Vacation calendar dot/bar colors */
export const VACATION_CALENDAR_COLORS: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-500",
OTHER: "bg-purple-400",
};
+3
View File
@@ -0,0 +1,3 @@
export function fmtEur(cents: number): string {
return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
}
+1 -4
View File
@@ -9,6 +9,7 @@ import type { PermissionKey } from "@planarchy/shared";
import { parseTaskAction } from "@planarchy/shared"; import { parseTaskAction } from "@planarchy/shared";
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js"; import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
import { getTaskAction } from "../lib/task-actions.js"; import { getTaskAction } from "../lib/task-actions.js";
import { fmtEur } from "../lib/format-utils.js";
import { resolveRecipients } from "../lib/notification-targeting.js"; import { resolveRecipients } from "../lib/notification-targeting.js";
import { import {
emitNotificationCreated, emitNotificationCreated,
@@ -41,10 +42,6 @@ type ToolExecutor = (params: any, ctx: ToolContext) => Promise<unknown>;
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
function fmtEur(cents: number): string {
return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
}
function fmtDate(d: Date | null | undefined): string | null { function fmtDate(d: Date | null | undefined): string | null {
return d ? d.toISOString().slice(0, 10) : null; return d ? d.toISOString().slice(0, 10) : null;
} }
+18 -18
View File
@@ -2,29 +2,29 @@ import {
CreateCalculationRuleSchema, CreateCalculationRuleSchema,
UpdateCalculationRuleSchema, UpdateCalculationRuleSchema,
} from "@planarchy/shared"; } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { PROJECT_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
export const calculationRuleRouter = createTRPCRouter({ export const calculationRuleRouter = createTRPCRouter({
list: controllerProcedure.query(async ({ ctx }) => { list: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.calculationRule.findMany({ return ctx.db.calculationRule.findMany({
orderBy: [{ priority: "desc" }, { name: "asc" }], orderBy: [{ priority: "desc" }, { name: "asc" }],
include: { project: { select: { id: true, name: true, shortCode: true } } }, include: { project: { select: PROJECT_BRIEF_SELECT } },
}); });
}), }),
getById: controllerProcedure getById: controllerProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const rule = await ctx.db.calculationRule.findUnique({ return findUniqueOrThrow(
where: { id: input.id }, ctx.db.calculationRule.findUnique({
include: { project: { select: { id: true, name: true, shortCode: true } } }, where: { id: input.id },
}); include: { project: { select: PROJECT_BRIEF_SELECT } },
if (!rule) { }),
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" }); "CalculationRule",
} );
return rule;
}), }),
/** Get all active rules (optimized for engine use — no project include) */ /** Get all active rules (optimized for engine use — no project include) */
@@ -58,10 +58,10 @@ export const calculationRuleRouter = createTRPCRouter({
.input(UpdateCalculationRuleSchema) .input(UpdateCalculationRuleSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, ...data } = input; const { id, ...data } = input;
const existing = await ctx.db.calculationRule.findUnique({ where: { id } }); await findUniqueOrThrow(
if (!existing) { ctx.db.calculationRule.findUnique({ where: { id } }),
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" }); "CalculationRule",
} );
// Build update data using exactOptionalPropertyTypes pattern // Build update data using exactOptionalPropertyTypes pattern
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
@@ -85,10 +85,10 @@ export const calculationRuleRouter = createTRPCRouter({
delete: managerProcedure delete: managerProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const existing = await ctx.db.calculationRule.findUnique({ where: { id: input.id } }); await findUniqueOrThrow(
if (!existing) { ctx.db.calculationRule.findUnique({ where: { id: input.id } }),
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" }); "CalculationRule",
} );
await ctx.db.calculationRule.delete({ where: { id: input.id } }); await ctx.db.calculationRule.delete({ where: { id: input.id } });
return { success: true }; return { success: true };
}), }),
+1 -4
View File
@@ -12,6 +12,7 @@ import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailabilit
import { VacationStatus } from "@planarchy/db"; import { VacationStatus } from "@planarchy/db";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js"; import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.js";
// ─── Graph Types (mirrored from client for API response) ──────────────────── // ─── Graph Types (mirrored from client for API response) ────────────────────
@@ -50,10 +51,6 @@ function l(source: string, target: string, formula: string, weight = 1): GraphLi
return { source, target, formula, weight }; return { source, target, formula, weight };
} }
function fmtEur(cents: number): string {
return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
}
function fmtPct(ratio: number): string { function fmtPct(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`; return `${(ratio * 100).toFixed(1)}%`;
} }
+2 -1
View File
@@ -6,6 +6,7 @@
import { VacationType, VacationStatus } from "@planarchy/db"; import { VacationType, VacationStatus } from "@planarchy/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
/** Types that consume from annual leave balance */ /** Types that consume from annual leave balance */
@@ -266,7 +267,7 @@ export const entitlementRouter = createTRPCRouter({
isActive: true, isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}), ...(input.chapter ? { chapter: input.chapter } : {}),
}, },
select: { id: true, displayName: true, eid: true, chapter: true }, select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }], orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
}); });
+5 -7
View File
@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import { import {
emitNotificationCreated, emitNotificationCreated,
@@ -598,13 +599,10 @@ export const notificationRouter = createTRPCRouter({
assignTask: managerProcedure assignTask: managerProcedure
.input(z.object({ id: z.string(), assigneeId: z.string() })) .input(z.object({ id: z.string(), assigneeId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const existing = await ctx.db.notification.findUnique({ const existing = await findUniqueOrThrow(
where: { id: input.id }, ctx.db.notification.findUnique({ where: { id: input.id } }),
}); "Task",
);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Task not found" });
}
if (existing.category !== "TASK" && existing.category !== "APPROVAL") { if (existing.category !== "TASK" && existing.category !== "APPROVAL") {
throw new TRPCError({ throw new TRPCError({
@@ -1,6 +1,7 @@
import { buildSplitAllocationReadModel } from "@planarchy/application"; import { buildSplitAllocationReadModel } from "@planarchy/application";
import type { PrismaClient } from "@planarchy/db"; import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared"; import { AllocationStatus } from "@planarchy/shared";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
export const PROJECT_PLANNING_ALLOCATION_INCLUDE = { export const PROJECT_PLANNING_ALLOCATION_INCLUDE = {
resource: { resource: {
@@ -31,7 +32,7 @@ export const PROJECT_PLANNING_ALLOCATION_INCLUDE = {
}, },
}, },
roleEntity: { roleEntity: {
select: { id: true, name: true, color: true }, select: ROLE_BRIEF_SELECT,
}, },
} as const; } as const;
+39 -37
View File
@@ -295,30 +295,30 @@ export const resourceRouter = createTRPCRouter({
getHoverCard: protectedProcedure getHoverCard: protectedProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({ const resource = await findUniqueOrThrow(
where: { id: input.id }, ctx.db.resource.findUnique({
select: { where: { id: input.id },
id: true, select: {
displayName: true, id: true,
eid: true, displayName: true,
email: true, eid: true,
chapter: true, email: true,
lcrCents: true, chapter: true,
ucrCents: true, lcrCents: true,
currency: true, ucrCents: true,
chargeabilityTarget: true, currency: true,
skills: true, chargeabilityTarget: true,
availability: true, skills: true,
isActive: true, availability: true,
areaRole: { select: { id: true, name: true, color: true } }, isActive: true,
country: { select: { name: true, code: true } }, areaRole: { select: ROLE_BRIEF_SELECT },
managementLevel: { select: { name: true } }, country: { select: { name: true, code: true } },
resourceType: true, managementLevel: { select: { name: true } },
}, resourceType: true,
}); },
if (!resource) { }),
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); "Resource",
} );
const directory = await getAnonymizationDirectory(ctx.db); const directory = await getAnonymizationDirectory(ctx.db);
const anon = anonymizeResource(resource, directory); const anon = anonymizeResource(resource, directory);
return { return {
@@ -633,11 +633,14 @@ export const resourceRouter = createTRPCRouter({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Find the resource linked to this user // Find the resource linked to this user
const user = await ctx.db.user.findUnique({ const user = await findUniqueOrThrow(
where: { email: ctx.session.user?.email ?? "" }, ctx.db.user.findUnique({
include: { resource: true }, where: { email: ctx.session.user?.email ?? "" },
}); include: { resource: true },
if (!user?.resource) { }),
"User",
);
if (!user.resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" }); throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" });
} }
const resourceId = user.resource.id; const resourceId = user.resource.id;
@@ -748,17 +751,16 @@ export const resourceRouter = createTRPCRouter({
.input(z.object({ resourceId: z.string() })) .input(z.object({ resourceId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const [resource, settings] = await Promise.all([ const [resource, settings] = await Promise.all([
ctx.db.resource.findUnique({ findUniqueOrThrow(
where: { id: input.resourceId }, ctx.db.resource.findUnique({
include: { areaRole: { select: { name: true } } }, where: { id: input.resourceId },
}), include: { areaRole: { select: { name: true } } },
}),
"Resource",
),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }), ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]); ]);
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
if (!isAiConfigured(settings)) { if (!isAiConfigured(settings)) {
throw new TRPCError({ throw new TRPCError({
code: "PRECONDITION_FAILED", code: "PRECONDITION_FAILED",
+2 -1
View File
@@ -3,6 +3,7 @@ import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/sh
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js"; import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
@@ -89,7 +90,7 @@ export const roleRouter = createTRPCRouter({
_count: { select: { resourceRoles: true } }, _count: { select: { resourceRoles: true } },
resourceRoles: { resourceRoles: {
include: { include: {
resource: { select: { id: true, displayName: true, eid: true } }, resource: { select: RESOURCE_BRIEF_SELECT },
}, },
}, },
}, },
+31 -33
View File
@@ -143,23 +143,22 @@ async function loadTimelineEntriesReadModel(
async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
const [project, planningRead] = await Promise.all([ const [project, planningRead] = await Promise.all([
db.project.findUnique({ findUniqueOrThrow(
where: { id: projectId }, db.project.findUnique({
select: { where: { id: projectId },
id: true, select: {
budgetCents: true, id: true,
winProbability: true, budgetCents: true,
startDate: true, winProbability: true,
endDate: true, startDate: true,
}, endDate: true,
}), },
}),
"Project",
),
loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), loadProjectPlanningReadModel(db, { projectId, activeOnly: true }),
]); ]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const { demandRequirements, assignments, readModel: projectReadModel } = planningRead; const { demandRequirements, assignments, readModel: projectReadModel } = planningRead;
const resourceIds = getAssignmentResourceIds(projectReadModel); const resourceIds = getAssignmentResourceIds(projectReadModel);
@@ -337,31 +336,30 @@ export const timelineRouter = createTRPCRouter({
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([ const [project, planningRead] = await Promise.all([
ctx.db.project.findUnique({ findUniqueOrThrow(
where: { id: input.projectId }, ctx.db.project.findUnique({
select: { where: { id: input.projectId },
id: true, select: {
name: true, id: true,
shortCode: true, name: true,
orderType: true, shortCode: true,
budgetCents: true, orderType: true,
winProbability: true, budgetCents: true,
status: true, winProbability: true,
startDate: true, status: true,
endDate: true, startDate: true,
staffingReqs: true, endDate: true,
}, staffingReqs: true,
}), },
}),
"Project",
),
loadProjectPlanningReadModel(ctx.db, { loadProjectPlanningReadModel(ctx.db, {
projectId: input.projectId, projectId: input.projectId,
activeOnly: true, activeOnly: true,
}), }),
]); ]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const resourceIds = getAssignmentResourceIds(planningRead.readModel); const resourceIds = getAssignmentResourceIds(planningRead.readModel);
const allResourceAllocations = const allResourceAllocations =
resourceIds.length === 0 resourceIds.length === 0
+6 -5
View File
@@ -3,6 +3,7 @@ import { VacationStatus, VacationType } from "@planarchy/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emitTaskAssigned } from "../sse/event-bus.js"; import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emitTaskAssigned } from "../sse/event-bus.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js"; import { sendEmail } from "../lib/email.js";
@@ -99,7 +100,7 @@ export const vacationRouter = createTRPCRouter({
...(input.endDate ? { startDate: { lte: input.endDate } } : {}), ...(input.endDate ? { startDate: { lte: input.endDate } } : {}),
}, },
include: { include: {
resource: { select: { id: true, displayName: true, eid: true } }, resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } }, requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } }, approvedBy: { select: { id: true, name: true, email: true } },
}, },
@@ -120,7 +121,7 @@ export const vacationRouter = createTRPCRouter({
ctx.db.vacation.findUnique({ ctx.db.vacation.findUnique({
where: { id: input.id }, where: { id: input.id },
include: { include: {
resource: { select: { id: true, displayName: true, eid: true } }, resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } }, requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } }, approvedBy: { select: { id: true, name: true, email: true } },
}, },
@@ -210,7 +211,7 @@ export const vacationRouter = createTRPCRouter({
: {}), : {}),
}, },
include: { include: {
resource: { select: { id: true, displayName: true, eid: true } }, resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } }, requestedBy: { select: { id: true, name: true, email: true } },
}, },
}); });
@@ -539,7 +540,7 @@ export const vacationRouter = createTRPCRouter({
return ctx.db.vacation.findMany({ return ctx.db.vacation.findMany({
where: { status: VacationStatus.PENDING }, where: { status: VacationStatus.PENDING },
include: { include: {
resource: { select: { id: true, displayName: true, eid: true } }, resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } }, requestedBy: { select: { id: true, name: true, email: true } },
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
@@ -576,7 +577,7 @@ export const vacationRouter = createTRPCRouter({
endDate: { gte: input.startDate }, endDate: { gte: input.startDate },
}, },
include: { include: {
resource: { select: { id: true, displayName: true, eid: true } }, resource: { select: RESOURCE_BRIEF_SELECT },
}, },
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
take: 20, take: 20,