Violations: fix auto-escalation on re-record + surface new vs old by date

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:32:28 -04:00
parent 71cc71f89f
commit feb0d28c25
4 changed files with 50 additions and 13 deletions
+5
View File
@@ -190,6 +190,11 @@ const ViolationCard = ({ violation, onStatusChange, isSelected, onSelect, showSe
<Badge className={cn("px-2 py-0.5 text-[10px] font-semibold border", getNoticeColor(stage))}> <Badge className={cn("px-2 py-0.5 text-[10px] font-semibold border", getNoticeColor(stage))}>
{stage} {stage}
</Badge> </Badge>
{violation.created_at && (Date.now() - new Date(violation.created_at).getTime()) < 7 * 24 * 60 * 60 * 1000 && (
<Badge className="px-2 py-0.5 text-[10px] font-semibold border bg-emerald-100 text-emerald-700 border-emerald-200">
New
</Badge>
)}
</div> </div>
<h3 className="font-bold text-foreground text-base leading-tight mb-1">{violation.violation_type || violation.title}</h3> <h3 className="font-bold text-foreground text-base leading-tight mb-1">{violation.violation_type || violation.title}</h3>
+9 -3
View File
@@ -137,15 +137,21 @@ function ViolationDialog({ open, onOpenChange, violation, onSuccess, association
useEffect(() => { useEffect(() => {
const updateLevel = async () => { 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); 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 })); setFormData(prev => ({ ...prev, stage: level }));
setCalculatingLevel(false); setCalculatingLevel(false);
} }
}; };
updateLevel(); updateLevel();
}, [formData.property_id, violation]); }, [formData.property_id, formData.unit_id, formData.address, formData.association_id, violation]);
const handleDefinedTypeChange = (typeId) => { const handleDefinedTypeChange = (typeId) => {
const selected = definedTypes.find(t => t.id === typeId); const selected = definedTypes.find(t => t.id === typeId);
+34 -8
View File
@@ -18,6 +18,16 @@ export const getNoticeLevel = (count) => {
return NOTICE_LEVELS.FIRST; 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) => { export const getNoticeColor = (level) => {
switch (level) { switch (level) {
case NOTICE_LEVELS.THIRD: case NOTICE_LEVELS.THIRD:
@@ -30,18 +40,34 @@ export const getNoticeColor = (level) => {
} }
}; };
export const calculateNextNoticeLevel = async (unitId) => { // Determine the stage for a NEW violation on a property: advance one step past
if (!unitId) return NOTICE_LEVELS.FIRST; // 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 { try {
const { count, error } = await supabase let query = supabase.from('violations').select('stage, notice_level, status');
.from('violations') if (unitId) query = query.eq('unit_id', unitId);
.select('*', { count: 'exact', head: true }) else if (ownerId) query = query.eq('owner_id', ownerId);
.eq('unit_id', unitId); 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; 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) { } catch (err) {
console.error('Error calculating notice level:', err); console.error('Error calculating notice level:', err);
return NOTICE_LEVELS.FIRST; return NOTICE_LEVELS.FIRST;
+1 -1
View File
@@ -229,7 +229,7 @@ export default function ViolationsPage({ boardAssociationIds }: { boardAssociati
let violQuery = supabase let violQuery = supabase
.from("violations") .from("violations")
.select("*, units(unit_number), associations(name), owners(first_name, last_name, property_address, email, electronic_consent, status)") .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"); let assocQuery = supabase.from("associations").select("*").eq("status", "active").order("name");
if (isBoardView) { if (isBoardView) {