chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyExperienceMultipliersProps {
estimateId: string;
canEdit: boolean;
onApplied?: () => void;
}
function formatCents(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
const utils = trpc.useUtils();
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
const [selectedSetId, setSelectedSetId] = useState<string>("");
const [showPreview, setShowPreview] = useState(false);
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
{ estimateId, multiplierSetId: selectedSetId },
{ enabled: showPreview && Boolean(selectedSetId) },
);
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
onApplied?.();
alert(
`Updated ${result.linesUpdated} demand line(s).\n` +
`Hours: ${result.totalOriginalHours}h -> ${result.totalAdjustedHours}h`,
);
},
});
// Auto-select default set
if (!selectedSetId && sets) {
const defaultSet = sets.find((s) => s.isDefault) ?? sets[0];
if (defaultSet) {
setSelectedSetId(defaultSet.id);
}
}
if (!canEdit) return null;
if (isLoading) {
return <p className="text-sm text-gray-400">Loading experience multipliers...</p>;
}
if (!sets || sets.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
No experience multiplier sets defined.{" "}
<a href="/admin/experience-multipliers" className="text-brand-600 hover:underline">
Create one
</a>{" "}
to apply rate and effort adjustments.
</div>
);
}
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
<select
value={selectedSetId}
onChange={(e) => {
setSelectedSetId(e.target.value);
setShowPreview(false);
}}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
{sets.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.rules.length} rules){s.isDefault ? " *" : ""}
</option>
))}
</select>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
disabled={!selectedSetId}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{showPreview ? "Hide preview" : "Preview"}
</button>
<button
onClick={() => {
if (!selectedSetId) return;
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
}
}}
disabled={!selectedSetId || applyMutation.isPending}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{applyMutation.isPending ? "Applying..." : "Apply multipliers"}
</button>
</div>
{applyMutation.error && (
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
)}
{/* Preview */}
{showPreview && previewQuery.data && (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<span>{previewQuery.data.demandLineCount} demand lines</span>
<span>{previewQuery.data.ruleCount} rules</span>
<span className="font-semibold text-brand-700">
{previewQuery.data.linesChanged} line(s) would be adjusted
</span>
</div>
{previewQuery.data.linesChanged > 0 && (
<div className="rounded-xl bg-blue-50 p-3 text-sm text-blue-700">
Total cost: {formatCents(previewQuery.data.totalOriginalCostCents)} {"->"}{" "}
{formatCents(previewQuery.data.totalAdjustedCostCents)}
</div>
)}
{/* Per-line preview */}
{previewQuery.data.previews.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
Show all {previewQuery.data.previews.length} lines
</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Cost rate</th>
<th className="px-3 py-2 text-right font-medium">Bill rate</th>
<th className="px-3 py-2 text-right font-medium">Hours</th>
<th className="pl-3 py-2 font-medium">Changes</th>
</tr>
</thead>
<tbody>
{previewQuery.data.previews.map((p, i) => (
<tr
key={p.demandLineId}
className={clsx(
"border-b border-gray-100",
p.hasChanges ? "bg-amber-50" : i % 2 === 0 ? "" : "bg-gray-50",
)}
>
<td className="py-1.5 pr-3 text-gray-900">{p.name}</td>
<td className="px-3 py-1.5 text-gray-500">{p.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedCostRateCents !== p.originalCostRateCents ? (
<>
<span className="line-through text-gray-400">{formatCents(p.originalCostRateCents)}</span>{" "}
{formatCents(p.adjustedCostRateCents)}
</>
) : (
formatCents(p.originalCostRateCents)
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedBillRateCents !== p.originalBillRateCents ? (
<>
<span className="line-through text-gray-400">{formatCents(p.originalBillRateCents)}</span>{" "}
{formatCents(p.adjustedBillRateCents)}
</>
) : (
formatCents(p.originalBillRateCents)
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedHours !== p.originalHours ? (
<>
<span className="line-through text-gray-400">{p.originalHours}h</span>{" "}
{p.adjustedHours}h
</>
) : (
`${p.originalHours}h`
)}
</td>
<td className="pl-3 py-1.5 text-xs text-gray-500">
{p.hasChanges ? p.appliedRules[0] ?? "" : "No change"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
</div>
);
}