216 lines
9.1 KiB
TypeScript
216 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { clsx } from "clsx";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
|
|
interface ApplyEffortRulesProps {
|
|
estimateId: string;
|
|
canEdit: boolean;
|
|
onApplied?: () => void;
|
|
}
|
|
|
|
export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffortRulesProps) {
|
|
const utils = trpc.useUtils();
|
|
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
|
|
|
|
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
|
|
const [mode, setMode] = useState<"replace" | "append">("replace");
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
|
|
const previewQuery = trpc.effortRule.preview.useQuery(
|
|
{ estimateId, ruleSetId: selectedRuleSetId },
|
|
{ enabled: showPreview && Boolean(selectedRuleSetId) },
|
|
);
|
|
|
|
const applyMutation = trpc.effortRule.apply.useMutation({
|
|
onSuccess: (result) => {
|
|
utils.estimate.getById.invalidate();
|
|
setShowPreview(false);
|
|
onApplied?.();
|
|
if (result.warnings.length > 0) {
|
|
alert(`Generated ${result.linesGenerated} demand lines.\n\nWarnings:\n${result.warnings.join("\n")}`);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Auto-select default rule set
|
|
if (!selectedRuleSetId && ruleSets) {
|
|
const defaultSet = ruleSets.find((rs) => rs.isDefault) ?? ruleSets[0];
|
|
if (defaultSet) {
|
|
setSelectedRuleSetId(defaultSet.id);
|
|
}
|
|
}
|
|
|
|
if (!canEdit) return null;
|
|
|
|
if (isLoading) {
|
|
return <p className="text-sm text-gray-400">Loading effort rules...</p>;
|
|
}
|
|
|
|
if (!ruleSets || ruleSets.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 effort rule sets defined.{" "}
|
|
<a href="/admin/effort-rules" className="text-brand-600 hover:underline">
|
|
Create one
|
|
</a>{" "}
|
|
to auto-generate demand lines from scope items.
|
|
</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">Generate demand lines from scope</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">Rule set</span>
|
|
<select
|
|
value={selectedRuleSetId}
|
|
onChange={(e) => {
|
|
setSelectedRuleSetId(e.target.value);
|
|
setShowPreview(false);
|
|
}}
|
|
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
|
>
|
|
{ruleSets.map((rs) => (
|
|
<option key={rs.id} value={rs.id}>
|
|
{rs.name} ({rs.rules.length} rules){rs.isDefault ? " *" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="block">
|
|
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Mode</span>
|
|
<select
|
|
value={mode}
|
|
onChange={(e) => setMode(e.target.value as "replace" | "append")}
|
|
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
|
>
|
|
<option value="replace">Replace existing lines</option>
|
|
<option value="append">Append to existing</option>
|
|
</select>
|
|
</label>
|
|
|
|
<button
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
disabled={!selectedRuleSetId}
|
|
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 (!selectedRuleSetId) return;
|
|
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
|
|
if (confirm(`This will ${action}. Continue?`)) {
|
|
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
|
|
}
|
|
}}
|
|
disabled={!selectedRuleSetId || 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 ? "Generating..." : "Apply rules"}
|
|
</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.scopeItemCount} scope items</span>
|
|
<span>{previewQuery.data.ruleCount} rules</span>
|
|
<span className="font-semibold text-brand-700">{previewQuery.data.lines.length} demand lines would be generated</span>
|
|
{previewQuery.data.unmatchedScopeItems.length > 0 && (
|
|
<span className="text-amber-600">{previewQuery.data.unmatchedScopeItems.length} unmatched scope items</span>
|
|
)}
|
|
</div>
|
|
|
|
{previewQuery.data.warnings.length > 0 && (
|
|
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
|
|
{previewQuery.data.warnings.map((w, i) => (
|
|
<p key={i}>{w}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Aggregated discipline summary */}
|
|
{previewQuery.data.aggregated.length > 0 && (
|
|
<div className="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">Discipline</th>
|
|
<th className="px-3 py-2 font-medium">Chapter</th>
|
|
<th className="px-3 py-2 text-right font-medium">Total hours</th>
|
|
<th className="pl-3 py-2 text-right font-medium">Lines</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{previewQuery.data.aggregated.map((agg, i) => (
|
|
<tr key={i} className="border-b border-gray-100">
|
|
<td className="py-1.5 pr-3 font-medium text-gray-900">{agg.discipline}</td>
|
|
<td className="px-3 py-1.5 text-gray-500">{agg.chapter ?? "\u2014"}</td>
|
|
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{agg.totalHours.toFixed(1)} h</td>
|
|
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">{agg.lineCount}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detailed lines (collapsible) */}
|
|
{previewQuery.data.lines.length > 0 && (
|
|
<details className="text-sm">
|
|
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
|
Show all {previewQuery.data.lines.length} generated 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">Scope item</th>
|
|
<th className="px-3 py-2 font-medium">Discipline</th>
|
|
<th className="px-3 py-2 font-medium">Chapter</th>
|
|
<th className="px-3 py-2 font-medium">Mode</th>
|
|
<th className="px-3 py-2 text-right font-medium">Units</th>
|
|
<th className="px-3 py-2 text-right font-medium">h/unit</th>
|
|
<th className="pl-3 py-2 text-right font-medium">Hours</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{previewQuery.data.lines.map((line, i) => (
|
|
<tr key={i} className={clsx("border-b border-gray-100", i % 2 === 0 ? "" : "bg-gray-50")}>
|
|
<td className="py-1.5 pr-3 text-gray-900">{line.scopeItemName}</td>
|
|
<td className="px-3 py-1.5 text-gray-700">{line.discipline}</td>
|
|
<td className="px-3 py-1.5 text-gray-500">{line.chapter ?? "\u2014"}</td>
|
|
<td className="px-3 py-1.5 text-gray-500">{line.unitMode}</td>
|
|
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.unitCount}</td>
|
|
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.hoursPerUnit}</td>
|
|
<td className="pl-3 py-1.5 text-right tabular-nums font-medium text-gray-900">{line.hours.toFixed(1)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showPreview && previewQuery.isLoading && (
|
|
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|