refactor: consolidate duplicated code across web and API packages

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

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 00:10:08 +01:00
parent ddec3a927a
commit e7b74f13bd
38 changed files with 637 additions and 652 deletions
@@ -1,11 +1,12 @@
"use client";
import { useRef, useState, useMemo, useCallback } from "react";
import { useRef, useState, useMemo } from "react";
import { AllocationStatus } from "@planarchy/shared";
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 { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface OpenDemandAllocation {
@@ -69,15 +70,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 200);
}
const utils = trpc.useUtils();
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 invalidatePlanningViews = useInvalidatePlanningViews();
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
@@ -211,7 +204,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{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>
@@ -414,7 +407,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</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">{(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">
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
{r.coveragePercent}%
@@ -431,7 +424,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{Math.round(consumedHours)}h / {totalDemandHours}h
</td>
<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 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}%
@@ -441,7 +434,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<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 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 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;
return (
<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>
);
})()}