feat(interests): manual stage override + Residential Partner system role

Manual stage override
  Sales reps need to skip canTransitionStage rules when the data was
  entered out of order — e.g. recording a contract_signed deal whose
  earlier stages were never tracked in the system.

  - New permission flag interests.override_stage in RolePermissions.
    Plumbed through the schema TS type, the role-editor UI, the seed
    file's pre-built roles (super_admin/director/sales_manager get it,
    sales_agent + viewer don't), and the test factories.
  - changeStageSchema gains an optional `override` boolean and the
    service checks it before evaluating canTransitionStage. When
    override=true the reason field becomes required (min 5 chars) and
    is recorded in the audit log.
  - The route handler gates `override` on the new permission so a
    sales_agent without it can't pass override=true and bypass.
  - InterestStagePicker auto-detects when the requested transition is
    blocked by the table and switches into "override mode" — shows an
    amber warning, requires the reason, button label flips to
    "Override stage". When the operator lacks the permission, the
    warning is red and the button is disabled.

Residential Partner role
  Per the smart-archive scoping conversation: external partners who
  handle residential inquiries shouldn't see marina clients, yachts,
  berths, or financials. The two residential_* permission groups
  already exist; this commit just seeds a pre-built system role
  ("residential_partner") with those flags + minimal own-reminders, so
  admins can invite a partner today via /admin/users without manually
  building the permission set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 18:32:57 +02:00
parent fb02f3d5e1
commit 789656bc70
8 changed files with 203 additions and 10 deletions

View File

@@ -2,7 +2,8 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -22,7 +23,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, STAGE_LABELS, stageLabel } from '@/lib/constants';
import { usePermissions } from '@/hooks/use-permissions';
import { PIPELINE_STAGES, STAGE_LABELS, stageLabel, canTransitionStage } from '@/lib/constants';
interface InterestStagePickerProps {
open: boolean;
@@ -38,20 +40,41 @@ export function InterestStagePicker({
currentStage,
}: InterestStagePickerProps) {
const queryClient = useQueryClient();
const { can, isSuperAdmin } = usePermissions();
const [newStage, setNewStage] = useState<string>(currentStage);
const [reason, setReason] = useState('');
const [override, setOverride] = useState(false);
// The transition table allows reasonable forward jumps; rejecting a
// proposed stage flips the UI into "override" mode if the user has
// permission to skip the rules.
const transitionAllowed = newStage === currentStage || canTransitionStage(currentStage, newStage);
const canOverride = isSuperAdmin || can('interests', 'override_stage');
const overrideRequired = !transitionAllowed;
const overrideEffective = override || overrideRequired;
const reasonRequiredByOverride = overrideEffective;
const reasonValid = !reasonRequiredByOverride || reason.trim().length >= 5;
const mutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: { pipelineStage: newStage, reason: reason || undefined },
body: {
pipelineStage: newStage,
reason: reason || undefined,
override: overrideEffective || undefined,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
onOpenChange(false);
setReason('');
setOverride(false);
toast.success(overrideEffective ? 'Stage overridden' : 'Stage updated');
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Stage change failed');
},
});
@@ -84,12 +107,52 @@ export function InterestStagePicker({
</Select>
</div>
{overrideRequired && (
<div
className={`flex items-start gap-2 rounded-md border p-3 text-xs ${
canOverride
? 'border-amber-300 bg-amber-50 text-amber-900'
: 'border-red-300 bg-red-50 text-red-900'
}`}
>
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
{canOverride ? (
<span>
This is not a normal forward transition. Override is enabled supply a reason
below explaining the manual stage change. Recorded in the audit log.
</span>
) : (
<span>
This stage transition isn&rsquo;t allowed by the pipeline rules. You don&rsquo;t
have permission to override.
</span>
)}
</div>
)}
{transitionAllowed && canOverride && newStage !== currentStage && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={override}
onChange={(e) => setOverride(e.target.checked)}
/>
Force-override (skip transition rules) &mdash; requires a reason
</label>
)}
<div className="space-y-1">
<Label>Reason (optional)</Label>
<Label>
Reason {reasonRequiredByOverride && <span className="text-red-600">*</span>}
</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason for stage change..."
placeholder={
reasonRequiredByOverride
? 'Required: why is this stage being overridden? (min 5 chars)'
: 'Optional: reason for stage change...'
}
rows={2}
/>
</div>
@@ -105,10 +168,15 @@ export function InterestStagePicker({
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || newStage === currentStage}
disabled={
mutation.isPending ||
newStage === currentStage ||
(overrideRequired && !canOverride) ||
!reasonValid
}
>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm
{overrideEffective ? 'Override stage' : 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>