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:
parent
5719daa84b
commit
8315a46da0
2 changed files with 59 additions and 32 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue