fix: mobile ticket timeline — hide system/workflow notes by default

- API: marks note_type 13,91,93,94,99,101 as is_system=true
  (13=workflow rule fired, 91=workflow templates, 99=RMM system notes,
   93/94=merge/absorb, 101=other system)
- Frontend: system notes filtered out by default; 'Show system' toggle
  in timeline header reveals them
- Time entry expanded card uses bg-muted/border-border (dark-mode safe)
  instead of hard-coded bg-blue-50 which was unreadable in dark theme
- Priority labels and colors corrected to match actual Autotask values
- Status labels expanded to full set
This commit is contained in:
lorentz 2026-03-17 23:49:50 -04:00
parent 5719daa84b
commit 8315a46da0
2 changed files with 59 additions and 32 deletions

View file

@ -27,7 +27,8 @@ export async function GET(
postgresClient.query(`
SELECT n.id, n.title, n.description, n.note_type, n.publish,
n.create_date_time,
r.first_name || ' ' || r.last_name as author
r.first_name || ' ' || r.last_name as author,
(n.note_type IN (13, 91, 93, 94, 99, 101)) AS is_system
FROM ticket_notes n
LEFT JOIN resources r ON r.id = n.creator_resource_id
WHERE n.ticket_id = $1 AND n.is_deleted = false
@ -52,7 +53,7 @@ export async function GET(
// Merge notes and time entries into a single chronological timeline
type TimelineItem =
| { kind: 'created'; ts: string; ticket: typeof ticket }
| { kind: 'note'; ts: string; data: typeof notes.rows[0] }
| { kind: 'note'; ts: string; data: typeof notes.rows[0]; is_system: boolean }
| { kind: 'time'; ts: string; data: typeof timeEntries.rows[0] }
| { kind: 'resolved'; ts: string };
@ -61,7 +62,7 @@ export async function GET(
timeline.push({ kind: 'created', ts: ticket.create_date, ticket });
for (const n of notes.rows) {
timeline.push({ kind: 'note', ts: n.create_date_time, data: n });
timeline.push({ kind: 'note', ts: n.create_date_time, data: n, is_system: n.is_system });
}
for (const te of timeEntries.rows) {
timeline.push({ kind: 'time', ts: te.entry_date, data: te });

View file

@ -4,20 +4,32 @@ import { useEffect, useState, use } from 'react';
import { useRouter } from 'next/navigation';
import {
ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2,
ChevronDown, ChevronRight, User, Briefcase, AlertCircle,
ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye,
} from 'lucide-react';
const PRIORITY_LABEL: Record<number, string> = { 1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low' };
const PRIORITY_LABEL: Record<number, string> = {
1: 'Standard', 2: 'Medium', 3: 'Standard', 4: 'Critical',
6: 'High', 7: 'Security', 8: 'Minor Svc', 9: 'Major Svc', 10: 'Install', 11: 'Fast Track',
};
const PRIORITY_COLOR: Record<number, string> = {
1: 'text-red-600 bg-red-50 border-red-200',
2: 'text-orange-600 bg-orange-50 border-orange-200',
3: 'text-yellow-600 bg-yellow-50 border-yellow-200',
4: 'text-slate-600 bg-slate-50 border-slate-200',
1: 'text-slate-600 bg-slate-100 border-slate-300',
2: 'text-slate-600 bg-slate-100 border-slate-300',
3: 'text-slate-600 bg-slate-100 border-slate-300',
4: 'text-red-600 bg-red-100 border-red-300',
6: 'text-orange-600 bg-orange-100 border-orange-300',
7: 'text-purple-600 bg-purple-100 border-purple-300',
8: 'text-yellow-700 bg-yellow-100 border-yellow-300',
9: 'text-orange-700 bg-orange-100 border-orange-300',
10: 'text-blue-600 bg-blue-100 border-blue-300',
11: 'text-pink-600 bg-pink-100 border-pink-300',
};
const STATUS_LABEL: Record<number, string> = {
1: 'New', 5: 'Complete', 7: 'In Progress', 8: 'In Progress', 9: 'Scheduled',
12: 'On Hold', 14: 'Waiting Customer', 19: 'Waiting Materials', 25: 'In Review',
47: 'Waiting Customer', 30: 'On Hold', 45: 'Escalated',
1: 'New', 5: 'Complete', 7: 'Waiting Customer', 8: 'In Progress', 9: 'Waiting Materials',
10: 'Dispatched', 11: 'Escalate', 12: 'Waiting Vendor', 13: 'Waiting Approval',
14: 'Resource Assigned', 16: 'Reopened', 19: 'End User Note Added', 25: 'On Hold',
27: 'Resolution Plan', 37: 'Client Non-Responsive', 45: 'Pending Next Site Visit',
47: 'Resource Requested', 48: 'Escalate to MC', 57: 'Escalate to Wulf', 61: 'Escalate to CSM',
66: 'Internal Note Added', 70: 'Waiting Quote Acceptance', 71: 'Waiting Project/Ticket',
};
interface Ticket {
@ -29,7 +41,7 @@ interface Ticket {
}
type TimelineItem =
| { kind: 'created'; ts: string; ticket: Ticket }
| { kind: 'note'; ts: string; data: { id: number; title: string; description: string; note_type: number; publish: number; author: string } }
| { kind: 'note'; ts: string; is_system: boolean; data: { id: number; title: string; description: string; note_type: number; publish: number; author: string } }
| { kind: 'time'; ts: string; data: { id: number; hours_worked: string; notes: string; billable: boolean; resource_name: string } }
| { kind: 'resolved'; ts: string };
@ -86,27 +98,27 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
return (
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center shrink-0">
<Timer className="w-4 h-4 text-blue-500" />
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center shrink-0">
<Timer className="w-4 h-4 text-blue-400" />
</div>
<div className="w-px flex-1 bg-border mt-1" />
</div>
<div className="pb-4 flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
<button onClick={() => setOpen(o => !o)}
className="w-full text-left flex items-center gap-2 group">
className="w-full text-left flex items-center gap-2">
<p className="text-sm font-medium flex-1">
Time entry {fmtHours(te.hours_worked)}
{te.billable ? '' : <span className="ml-1 text-xs text-muted-foreground">(non-bill)</span>}
{!te.billable && <span className="ml-1 text-xs text-muted-foreground">(non-bill)</span>}
</p>
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" /> : <ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />}
</button>
{open && (
<div className="mt-2 rounded-lg bg-blue-50 border border-blue-100 p-3 space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<User className="w-3 h-3" />{te.resource_name}
<div className="mt-2 rounded-lg bg-muted border border-border p-3 space-y-1.5">
<p className="text-xs font-medium text-foreground flex items-center gap-1.5">
<User className="w-3 h-3 text-muted-foreground" />{te.resource_name}
</p>
{te.notes && <p className="text-sm">{stripHtml(te.notes)}</p>}
{te.notes && <p className="text-sm text-foreground leading-relaxed">{stripHtml(te.notes)}</p>}
</div>
)}
</div>
@ -117,12 +129,15 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
// note
const note = item.data;
const isInternal = note.publish === 1;
const isSystem = item.is_system;
const body = stripHtml(note.description ?? '');
const iconColor = isSystem ? 'text-muted-foreground' : isInternal ? 'text-slate-400' : 'text-amber-500';
const iconBg = isSystem ? 'bg-muted' : isInternal ? 'bg-muted' : 'bg-amber-500/10';
return (
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${isInternal ? 'bg-slate-100' : 'bg-amber-50'}`}>
<FileText className={`w-4 h-4 ${isInternal ? 'text-slate-500' : 'text-amber-500'}`} />
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${iconBg}`}>
<FileText className={`w-4 h-4 ${iconColor}`} />
</div>
<div className="w-px flex-1 bg-border mt-1" />
</div>
@ -131,7 +146,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
<button onClick={() => setOpen(o => !o)} className="w-full text-left">
<div className="flex items-start gap-1">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium line-clamp-1">
<p className={`text-sm font-medium line-clamp-1 ${isSystem ? 'text-muted-foreground' : ''}`}>
{note.title || (isInternal ? 'Internal Note' : 'Customer Note')}
</p>
{!open && body && <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{body}</p>}
@ -140,11 +155,11 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
</div>
</button>
{open && (
<div className={`mt-2 rounded-lg border p-3 ${isInternal ? 'bg-slate-50 border-slate-200' : 'bg-amber-50 border-amber-100'}`}>
<p className="text-xs text-muted-foreground flex items-center gap-1 mb-1">
<User className="w-3 h-3" />{note.author} · {isInternal ? 'Internal' : 'Client-visible'}
<div className="mt-2 rounded-lg border border-border bg-muted p-3">
<p className="text-xs text-muted-foreground flex items-center gap-1 mb-1.5">
<User className="w-3 h-3" />{note.author || 'System'} · {isSystem ? 'System' : isInternal ? 'Internal' : 'Client-visible'}
</p>
<p className="text-sm whitespace-pre-wrap">{body}</p>
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{body}</p>
</div>
)}
</div>
@ -158,6 +173,7 @@ export default function TicketTimeline({ params }: { params: Promise<{ id: strin
const [data, setData] = useState<{ ticket: Ticket; timeline: TimelineItem[]; total_hours: number } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showSystem, setShowSystem] = useState(false);
useEffect(() => {
setLoading(true);
@ -229,13 +245,23 @@ export default function TicketTimeline({ params }: { params: Promise<{ id: strin
{/* Timeline */}
<div className="px-4 pt-5">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4">Timeline</p>
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Timeline</p>
<button
onClick={() => setShowSystem(s => !s)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
{showSystem ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
{showSystem ? 'Hide system' : 'Show system'}
</button>
</div>
{timeline.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No activity recorded</p>
) : (
timeline.map((item, i) => (
<TimelineCard key={i} item={item} defaultOpen={item.kind === 'note' && i === timeline.length - 1} />
))
timeline
.filter(item => showSystem || item.kind !== 'note' || !item.is_system)
.map((item, i) => (
<TimelineCard key={i} item={item} defaultOpen={item.kind === 'note' && i === timeline.length - 1} />
))
)}
</div>
</div>