From feb0d28c2503d673de6041bb9215251d6fc94913 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 17 Jun 2026 22:32:28 -0400 Subject: [PATCH] Violations: fix auto-escalation on re-record + surface new vs old by date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-escalation was broken: calculateNextNoticeLevel queried unit_id but the dialog passed the owner id, so it always returned "First Notice." Rewrote it to advance one step past the property's highest CURRENTLY-OPEN stage (First → Second → Third & Final, capped), identifying the property by unit, else owner, else association+address; the dialog now passes all of those and re-runs when the unit/address changes. So re-recording a violation on the same property auto-selects the next stage. New vs old by date: list now loads newest-first, and cards show a "New" badge for violations recorded in the last 7 days (violation_date was already captured and shown, plus the timeline group-by-date view). Co-Authored-By: Claude Opus 4.8 --- src/components/ViolationCard.jsx | 5 +++ src/components/ViolationDialog.jsx | 12 ++++-- src/lib/ViolationNoticeProgressionHelper.js | 44 ++++++++++++++++----- src/pages/ViolationsPage.tsx | 2 +- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/components/ViolationCard.jsx b/src/components/ViolationCard.jsx index 14463a7..58fb6c3 100644 --- a/src/components/ViolationCard.jsx +++ b/src/components/ViolationCard.jsx @@ -190,6 +190,11 @@ const ViolationCard = ({ violation, onStatusChange, isSelected, onSelect, showSe {stage} + {violation.created_at && (Date.now() - new Date(violation.created_at).getTime()) < 7 * 24 * 60 * 60 * 1000 && ( + + New + + )}

{violation.violation_type || violation.title}

diff --git a/src/components/ViolationDialog.jsx b/src/components/ViolationDialog.jsx index 2a068d3..ae6813f 100644 --- a/src/components/ViolationDialog.jsx +++ b/src/components/ViolationDialog.jsx @@ -137,15 +137,21 @@ function ViolationDialog({ open, onOpenChange, violation, onSuccess, association useEffect(() => { const updateLevel = async () => { - if (!violation && formData.property_id) { + // Only auto-set the stage for NEW violations once a property is chosen. + if (!violation && (formData.property_id || formData.unit_id || formData.address)) { setCalculatingLevel(true); - const level = await calculateNextNoticeLevel(formData.property_id); + const level = await calculateNextNoticeLevel({ + ownerId: formData.property_id || null, + unitId: formData.unit_id || null, + associationId: formData.association_id || null, + address: formData.address || null, + }); setFormData(prev => ({ ...prev, stage: level })); setCalculatingLevel(false); } }; updateLevel(); - }, [formData.property_id, violation]); + }, [formData.property_id, formData.unit_id, formData.address, formData.association_id, violation]); const handleDefinedTypeChange = (typeId) => { const selected = definedTypes.find(t => t.id === typeId); diff --git a/src/lib/ViolationNoticeProgressionHelper.js b/src/lib/ViolationNoticeProgressionHelper.js index 6b4f6a2..f84a733 100644 --- a/src/lib/ViolationNoticeProgressionHelper.js +++ b/src/lib/ViolationNoticeProgressionHelper.js @@ -18,6 +18,16 @@ export const getNoticeLevel = (count) => { return NOTICE_LEVELS.FIRST; }; +// Ordered stages, low → high. The stage after the last is the final (capped). +export const STAGE_ORDER = [NOTICE_LEVELS.FIRST, NOTICE_LEVELS.SECOND, NOTICE_LEVELS.THIRD]; + +// The next stage after `current` (caps at Third & Final). Unknown → First. +export const getNextStage = (current) => { + const i = STAGE_ORDER.indexOf(current); + if (i === -1) return NOTICE_LEVELS.FIRST; + return STAGE_ORDER[Math.min(i + 1, STAGE_ORDER.length - 1)]; +}; + export const getNoticeColor = (level) => { switch (level) { case NOTICE_LEVELS.THIRD: @@ -30,18 +40,34 @@ export const getNoticeColor = (level) => { } }; -export const calculateNextNoticeLevel = async (unitId) => { - if (!unitId) return NOTICE_LEVELS.FIRST; - +// Determine the stage for a NEW violation on a property: advance one step past +// the property's highest CURRENTLY-OPEN stage (so re-recording the same property +// escalates First → Second → Third & Final). No open violations → First Notice. +// Identify the property by unit, else owner, else (association + address). +export const calculateNextNoticeLevel = async ({ ownerId, unitId, associationId, address } = {}) => { try { - const { count, error } = await supabase - .from('violations') - .select('*', { count: 'exact', head: true }) - .eq('unit_id', unitId); + let query = supabase.from('violations').select('stage, notice_level, status'); + if (unitId) query = query.eq('unit_id', unitId); + else if (ownerId) query = query.eq('owner_id', ownerId); + else if (address && associationId) query = query.eq('association_id', associationId).eq('address', address); + else return NOTICE_LEVELS.FIRST; + const { data, error } = await query; if (error) throw error; - - return getNoticeLevel((count || 0) + 1); + + const open = (data || []).filter( + (v) => !['resolved', 'closed'].includes(String(v.status || '').toLowerCase()), + ); + if (open.length === 0) return NOTICE_LEVELS.FIRST; + + let maxIdx = -1; + for (const v of open) { + const s = v.stage || v.notice_level || NOTICE_LEVELS.FIRST; + const idx = STAGE_ORDER.indexOf(s); + if (idx > maxIdx) maxIdx = idx; + } + if (maxIdx === -1) return NOTICE_LEVELS.FIRST; + return STAGE_ORDER[Math.min(maxIdx + 1, STAGE_ORDER.length - 1)]; } catch (err) { console.error('Error calculating notice level:', err); return NOTICE_LEVELS.FIRST; diff --git a/src/pages/ViolationsPage.tsx b/src/pages/ViolationsPage.tsx index 1169961..f8cb9ef 100644 --- a/src/pages/ViolationsPage.tsx +++ b/src/pages/ViolationsPage.tsx @@ -229,7 +229,7 @@ export default function ViolationsPage({ boardAssociationIds }: { boardAssociati let violQuery = supabase .from("violations") .select("*, units(unit_number), associations(name), owners(first_name, last_name, property_address, email, electronic_consent, status)") - .order("created_at", { ascending: true }); + .order("created_at", { ascending: false }); let assocQuery = supabase.from("associations").select("*").eq("status", "active").order("name"); if (isBoardView) {