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:
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function fmtEur(cents: number): string {
|
||||||
|
return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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)}%`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user