Support Ticket Detail
blockcommunicationCustomer support ticket conversation thread, SLA monitor & staff workbench with internal notes, canned response macros, and customer metadata sidebar.
Installation
$pnpm dlx uipkge-ng@latest add support-ticket-detail$npx uipkge-ng@latest add support-ticket-detail$yarn dlx uipkge-ng@latest add support-ticket-detail$bunx uipkge-ng@latest add support-ticket-detailManifest:
https://uipkge.dev/r/angular/support-ticket-detail.jsonInstalls to:src/app/components/blocks/support-ticket-detail/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
SupportTicketThreadMessageinterface SupportTicketThreadMessage {
id: string
authorName: string
authorRole: string
avatarText: string
badgeText: string
badgeVariant?: 'default' | 'secondary' | 'outline' | 'destructive' | 'success' | 'warning' | 'info'
timestamp: string
isInternal: boolean
content: string
}SupportTicketMacrointerface SupportTicketMacro {
id: string
name: string
text: string
}Files installed (2)
src/app/components/blocks/support-ticket-detail/support-ticket-detail.component.ts41.1 kB
// Edit this file to change the ticket, the thread, the canned macros or the copy. // Everything is local state: replies and internal notes are appended to the thread, // "Resolve Ticket" / "Escalate" / "Send & Mark Solved" set the status select. import { Component, DestroyRef, inject, signal, ChangeDetectionStrategy } from '@angular/core' import { AlertCircle, AlertTriangle, ArrowUpRight, Bold, CheckCheck, CheckCircle2, ChevronRight, Clock, Code, Copy, Download, Eye, FileCode2, Globe, ImageIcon, Italic, Link2, Lock, LucideAngularModule, MessageSquare, Paperclip, Send, ShieldAlert, Tag, X, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent } from '@/ui/tabs/tabs.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export type SupportTicketComposerTab = 'public' | 'internal' export interface SupportTicketThreadMessage { id: string authorName: string authorRole: string avatarText: string badgeText: string badgeVariant?: 'default' | 'secondary' | 'outline' | 'destructive' | 'success' | 'warning' | 'info' timestamp: string isInternal: boolean content: string } export interface SupportTicketMacro { id: string name: string text: string } const cannedMacros: SupportTicketMacro[] = [ { id: 'ssl-guide', name: 'SSL Guide', text: `Hi Sarah,\n\nTo resolve the ACME challenge issue, please ensure your DNS provider includes the following CAA record:\n\n0 issue "letsencrypt.org"\n\nOnce added, DNS propagation typically takes 2-5 minutes, after which you can re-run verification in your domain dashboard.`, }, { id: 'request-logs', name: 'Request logs', text: `Hi Sarah,\n\nCould you please provide the full HAR export and debug network logs from your browser console during the SSL verification attempt? This will help us trace the challenge request.`, }, { id: 'refund-confirmation', name: 'Refund confirmation', text: `Hi Sarah,\n\nWe have processed the billing adjustment for your dedicated SSL add-on. You will see the credit reflected on your next statement.`, }, ] const errorLog = `[2026-10-24T09:12:44Z] [SSL: CERTIFICATE_VERIFY_FAILED] ACME challenge failed for _acme-challenge.api.acme-corp.io [2026-10-24T09:13:58Z] DNS TXT record validation timed out after 600s. Response status: 400 Bad Request [2026-10-24T09:14:02Z] Detail: CAA record restriction on 'acme-corp.io' prevented Let's Encrypt issuance.` /** * Support ticket workbench (React `SupportTicketDetail` 1:1): ticket header with status select * and SLA pill, the conversation thread (customer message with error log + attachment, an * internal note, an agent reply, then anything sent from the composer), the public-reply / * internal-note composer with canned macros and a formatting toolbar, and the customer, * ticket-attribute and SLA sidebar cards. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-support-ticket-detail, [ui-support-ticket-detail]', standalone: true, host: { class: 'block' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent, UiTextareaComponent, ], template: ` <div data-slot="support-ticket-detail" class="bg-background text-foreground w-full space-y-6"> <!-- Ticket Header --> <header class="bg-card rounded-xl border p-5 shadow-xs sm:p-6"> <div class="flex flex-col gap-4"> <!-- Top Breadcrumb & Actions Row --> <div class="flex flex-wrap items-center justify-between gap-3"> <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> <span>Support</span> <lucide-icon [img]="ChevronRight" class="size-3.5" /> <span>Tickets</span> <lucide-icon [img]="ChevronRight" class="size-3.5" /> <span class="text-foreground font-mono font-medium">#TICK-8492</span> </div> <div class="flex items-center gap-2"> <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs font-medium" (click)="handleEscalate()" > <lucide-icon [img]="AlertTriangle" class="text-warning size-3.5" /> Escalate </button> <button ui-button size="sm" class="gap-1.5 text-xs font-medium" (click)="handleResolve()"> <lucide-icon [img]="CheckCircle2" class="size-3.5" /> Resolve Ticket </button> </div> </div> <!-- Ticket Title & Metadata --> <div class="space-y-1.5"> <div class="flex flex-wrap items-center gap-2"> <span class="text-muted-foreground font-mono text-xs font-bold tracking-tight">#TICK-8492</span> <h1 class="text-foreground text-lg font-bold tracking-tight sm:text-xl"> Cannot configure custom domain SSL certificate </h1> </div> <p class="text-muted-foreground text-xs"> Opened 2 hours ago by <span class="text-foreground font-medium">Sarah Davis</span> (Acme Corp) via Web Portal </p> </div> <ui-separator /> <!-- Status Badges & SLA Strip --> <div class="flex flex-wrap items-center gap-3"> <span ui-badge variant="destructive" class="gap-1 font-medium"> <lucide-icon [img]="AlertCircle" class="size-3" /> High Priority </span> <div class="w-44"> <ui-select [value]="ticketStatus()" (valueChange)="ticketStatus.set($event)"> <button ui-select-trigger size="sm" class="h-7 text-xs font-medium"> <ui-select-value placeholder="Status" /> </button> <ui-select-content> <ui-select-item value="in_progress"> <span class="flex items-center gap-1.5" ><span class="bg-warning size-2 rounded-full"></span>In Progress</span > </ui-select-item> <ui-select-item value="waiting_on_customer"> <span class="flex items-center gap-1.5" ><span class="bg-info size-2 rounded-full"></span>Waiting on Customer</span > </ui-select-item> <ui-select-item value="escalated"> <span class="flex items-center gap-1.5" ><span class="bg-destructive size-2 rounded-full"></span>Escalated</span > </ui-select-item> <ui-select-item value="resolved"> <span class="flex items-center gap-1.5" ><span class="bg-success size-2 rounded-full"></span>Resolved</span > </ui-select-item> </ui-select-content> </ui-select> </div> <div class="border-warning/30 bg-warning/10 text-warning inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium" > <lucide-icon [img]="Clock" class="size-3.5" /> <span>SLA: 42m remaining</span> </div> <span ui-badge variant="outline" class="text-muted-foreground text-xs">SSL & Custom Domains</span> </div> </div> </header> <!-- 2-Column Workspace --> <div class="grid grid-cols-1 gap-6 lg:grid-cols-12"> <!-- Left Column: Conversation Thread & Composer (2/3) --> <main class="space-y-6 lg:col-span-8"> <!-- Message 1: Customer Initial Request --> <article class="bg-card rounded-xl border p-5 shadow-xs sm:p-6"> <div class="flex items-start justify-between gap-3"> <div class="flex items-center gap-3"> <span ui-avatar class="size-10"> <span ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-semibold">SD</span> </span> <div> <div class="flex items-center gap-2"> <span class="text-foreground text-sm font-semibold">Sarah Davis</span> <span ui-badge variant="secondary" class="text-xs font-normal">Customer</span> </div> <p class="text-muted-foreground text-xs">VP of Engineering · Acme Corp (sarah@acme-corp.io)</p> </div> </div> <time class="text-muted-foreground text-xs whitespace-nowrap">Today, 09:14 AM (2h ago)</time> </div> <div class="text-foreground/90 mt-4 space-y-3.5 text-sm leading-relaxed"> <p> We are attempting to configure our custom apex domain <code class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono text-xs">api.acme-corp.io</code> on our production cluster, but automated SSL certificate provisioning fails repeatedly during the ACME DNS-01 verification challenge. </p> <p> We verified that the CNAME and DNS TXT records exist in our Cloudflare dashboard, but the challenge validation daemon continues to time out after 10 minutes with the stderr log below: </p> <!-- Error Code Block Placeholder --> <div class="overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100"> <div class="text-muted-foreground flex items-center justify-between border-b border-zinc-800 bg-zinc-900/90 px-3.5 py-2 text-xs" > <div class="flex items-center gap-2"> <lucide-icon [img]="FileCode2" class="text-destructive size-3.5" /> <span class="font-mono text-xs">acme-provisioner-stderr.log</span> </div> <button ui-button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100" (click)="copyErrorLog()" > <lucide-icon [img]="Copy" class="size-3" /> {{ copiedLog() ? 'Copied' : 'Copy log' }} </button> </div> <pre class="overflow-x-auto p-3.5 font-mono text-xs leading-relaxed text-zinc-300" ><code>{{ errorLog }}</code></pre> </div> <!-- Screenshot / Attachment Placeholder --> <div class="bg-muted/40 rounded-lg border p-3"> <div class="flex items-center justify-between gap-3"> <div class="flex min-w-0 items-center gap-2.5"> <div class="bg-background flex size-9 shrink-0 items-center justify-center rounded-md border"> <lucide-icon [img]="ImageIcon" class="text-muted-foreground size-4" /> </div> <div class="min-w-0"> <p class="text-foreground truncate text-xs font-medium">cloudflare-dns-settings.png</p> <p class="text-muted-foreground text-xs">1.4 MB · PNG Image Screenshot</p> </div> </div> <div class="flex items-center gap-1"> <button ui-button variant="ghost" size="sm" class="h-7 text-xs"> <lucide-icon [img]="Eye" class="size-3.5" /> Preview </button> <button ui-button aria-label="Download attachment" variant="ghost" size="sm" class="h-7 text-xs"> <lucide-icon [img]="Download" class="size-3.5" /> Download </button> </div> </div> </div> </div> </article> <!-- Message 2: Internal Staff Note (distinct yellow/amber tint background) --> <article class="border-warning/30 bg-warning/10 dark:bg-warning/10 rounded-xl border p-5 shadow-xs sm:p-6"> <div class="flex items-start justify-between gap-3"> <div class="flex items-center gap-3"> <span ui-avatar class="size-10"> <span ui-avatar-fallback class="bg-warning/20 text-warning text-xs font-semibold">AK</span> </span> <div> <div class="flex items-center gap-2"> <span class="text-foreground text-sm font-semibold">Alex Kim</span> <span ui-badge variant="outline" class="border-warning/40 bg-warning/20 text-warning gap-1 text-xs font-medium" > <lucide-icon [img]="Lock" class="size-3" /> Internal Note - Only visible to team </span> </div> <p class="text-muted-foreground text-xs">Senior DevOps Engineer · Tier 2 Infrastructure</p> </div> </div> <time class="text-muted-foreground text-xs whitespace-nowrap">Today, 09:35 AM (1h ago)</time> </div> <div class="text-foreground/90 mt-4 space-y-2 text-sm leading-relaxed"> <p> Checked Cloudflare DNS propagation on their nameservers via <code class="text-foreground bg-warning/20 rounded px-1 py-0.5 font-mono text-xs" >dig CAA acme-corp.io</code >. Looks like their root zone has a CAA record restricting certificate issuance strictly to DigiCert (<code class="text-foreground bg-warning/20 rounded px-1 py-0.5 font-mono text-xs" >0 issue "digicert.com"</code >), while our automated ACME pipeline requests Let's Encrypt certificates. </p> <p> If they add <code class="text-foreground bg-warning/20 rounded px-1 py-0.5 font-mono text-xs" >0 issue "letsencrypt.org"</code > to their DNS CAA records, the validation handshake will complete within 2 minutes. </p> </div> </article> <!-- Message 3: Agent response --> <article class="bg-card rounded-xl border p-5 shadow-xs sm:p-6"> <div class="flex items-start justify-between gap-3"> <div class="flex items-center gap-3"> <span ui-avatar class="size-10"> <span ui-avatar-fallback class="bg-primary text-primary-foreground text-xs font-semibold">EM</span> </span> <div> <div class="flex items-center gap-2"> <span class="text-foreground text-sm font-semibold">Elena Martinez</span> <span ui-badge class="text-xs font-normal">Support Engineer</span> </div> <p class="text-muted-foreground text-xs">Lead Support Engineer · UIPKGE Staff</p> </div> </div> <time class="text-muted-foreground text-xs whitespace-nowrap">Today, 09:48 AM (45m ago)</time> </div> <div class="text-foreground/90 mt-4 space-y-3 text-sm leading-relaxed"> <p> Hi Sarah, thank you for providing the detailed error log! We analyzed the ACME challenge failure and identified that your apex domain's DNS CAA records currently block Let's Encrypt certificate issuance. </p> <p>Please follow these step-by-step instructions to enable Let's Encrypt validation:</p> <ol class="text-foreground/90 list-inside list-decimal space-y-1.5 pl-1 text-xs sm:text-sm"> <li> Log in to your Cloudflare DNS dashboard for <code class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">acme-corp.io</code>. </li> <li>Navigate to <strong>DNS Settings</strong> → <strong>Add Record</strong>.</li> <li> Select <strong>CAA</strong>, set Flag to <code class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">0</code>, Tag to <code class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">issue</code>, and Value to <code class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">"letsencrypt.org"</code>. </li> <li>Allow up to 2 minutes for DNS TTL cache propagation across edge resolvers.</li> <li>Return to your cluster settings dashboard and click <strong>Retry Verification</strong>.</li> </ol> <p> Feel free to reply directly once you've saved the record, and we'll monitor the validation handshake from our side! </p> </div> </article> <!-- Dynamic User Messages --> @for (msg of dynamicMessages(); track msg.id) { <article [class]="articleClass(msg)"> <div class="flex items-start justify-between gap-3"> <div class="flex items-center gap-3"> <span ui-avatar class="size-10"> <span ui-avatar-fallback [class]="avatarFallbackClass(msg)">{{ msg.avatarText }}</span> </span> <div> <div class="flex items-center gap-2"> <span class="text-foreground text-sm font-semibold">{{ msg.authorName }}</span> <span ui-badge [variant]="msg.isInternal ? 'outline' : 'default'" [class]="badgeClass(msg)"> @if (msg.isInternal) { <lucide-icon [img]="Lock" class="size-3" /> } {{ msg.badgeText }} </span> </div> <p class="text-muted-foreground text-xs">{{ msg.authorRole }}</p> </div> </div> <time class="text-muted-foreground text-xs whitespace-nowrap">{{ msg.timestamp }}</time> </div> <div class="text-foreground/90 mt-4 text-sm leading-relaxed whitespace-pre-wrap">{{ msg.content }}</div> </article> } <!-- Reply Box Composer --> <section class="bg-card overflow-hidden rounded-xl border shadow-xs"> <div ui-tabs [value]="composerTab()" (valueChange)="setComposerTab($event)" defaultValue="public"> <!-- Composer Header with Tabs & Macro Dropdown --> <div class="bg-muted/30 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-2.5"> <div ui-tabs-list class="h-8"> <button ui-tabs-trigger value="public" class="gap-1.5 text-xs"> <lucide-icon [img]="MessageSquare" class="size-3.5" /> Public Reply </button> <button ui-tabs-trigger value="internal" class="gap-1.5 text-xs"> <lucide-icon [img]="Lock" class="size-3.5" /> Internal Note </button> </div> <!-- Canned Responses / Macros Dropdown --> <div class="w-48"> <ui-select [value]="selectedMacro()" (valueChange)="handleSelectMacro($event)"> <button ui-select-trigger size="sm" class="h-8 text-xs"> <ui-select-value placeholder="Canned macro..." /> </button> <ui-select-content> @for (macro of cannedMacros; track macro.id) { <ui-select-item [value]="macro.id">{{ macro.name }}</ui-select-item> } </ui-select-content> </ui-select> </div> </div> <div class="space-y-3 p-4"> <!-- Rich Formatting Toolbar --> <div class="text-muted-foreground flex items-center gap-1 border-b pb-2"> <button ui-button variant="ghost" size="icon" class="size-7" aria-label="Format bold" (click)="applyFormat('bold')" > <lucide-icon [img]="Bold" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="size-7" aria-label="Format italic" (click)="applyFormat('bold')" > <lucide-icon [img]="Italic" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="size-7" aria-label="Format code" (click)="applyFormat('code')" > <lucide-icon [img]="Code" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="size-7" aria-label="Insert link" (click)="applyFormat('link')" > <lucide-icon [img]="Link2" class="size-3.5" /> </button> <ui-separator orientation="vertical" class="mx-1 h-4" /> <button ui-button variant="ghost" size="icon" class="size-7" aria-label="Attach file"> <lucide-icon [img]="Paperclip" class="size-3.5" /> </button> <span class="text-muted-foreground ml-auto text-xs">Markdown supported</span> </div> <!-- Textarea with dynamic background if internal note --> <ui-textarea [value]="replyDraft()" (valueChange)="replyDraft.set($event)" [placeholder]=" composerTab() === 'public' ? 'Write a public reply to Sarah Davis...' : 'Add an internal note only visible to team members...' " [rows]="4" [class]="textareaClass()" /> <!-- Composer Actions --> <div class="flex flex-wrap items-center justify-between gap-3 pt-1"> <div class="text-muted-foreground flex items-center gap-2 text-xs"> <lucide-icon [img]="Paperclip" class="size-3.5" /> <span>Attachments up to 25MB</span> </div> <div class="flex items-center gap-2"> @if (composerTab() === 'public') { <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs" [disabled]="!hasDraft()" (click)="sendAndSolve()" > <lucide-icon [img]="CheckCheck" class="size-3.5" /> Send & Mark Solved </button> } <button ui-button size="sm" class="gap-1.5 text-xs" [disabled]="!hasDraft()" (click)="sendReply()"> <lucide-icon [img]="Send" class="size-3.5" /> {{ composerTab() === 'public' ? 'Send Reply' : 'Add Internal Note' }} </button> </div> </div> </div> </div> </section> </main> <!-- Right Column: Customer & Ticket Metadata Sidebar (1/3) --> <aside class="space-y-6 lg:col-span-4"> <!-- Customer Info Card --> <ui-card> <ui-card-header class="pb-3"> <div class="flex items-start justify-between gap-2"> <div class="flex items-center gap-3"> <span ui-avatar class="size-11"> <span ui-avatar-fallback class="bg-primary/10 text-primary text-sm font-semibold">SD</span> </span> <div> <ui-card-title class="text-sm font-semibold">Sarah Davis</ui-card-title> <ui-card-description class="text-xs">VP of Engineering</ui-card-description> </div> </div> <span ui-badge variant="default" class="text-xs font-medium">Enterprise</span> </div> </ui-card-header> <ui-card-content class="space-y-3 pt-0"> <ui-separator /> <dl class="space-y-2.5 text-xs"> <div class="flex items-center justify-between gap-2"> <dt class="text-muted-foreground">Company</dt> <dd class="text-foreground font-medium">Acme Corp</dd> </div> <div class="flex items-center justify-between gap-2"> <dt class="text-muted-foreground">Email</dt> <dd class="text-foreground max-w-[180px] truncate font-medium">sarah@acme-corp.io</dd> </div> <div class="flex items-center justify-between gap-2"> <dt class="text-muted-foreground">Total Tickets</dt> <dd class="text-foreground font-medium tabular-nums">12 (10 resolved)</dd> </div> <div class="flex items-center justify-between gap-2"> <dt class="text-muted-foreground">Customer Since</dt> <dd class="text-foreground font-medium">Jan 2024</dd> </div> <div class="flex items-center justify-between gap-2"> <dt class="text-muted-foreground">SLA Plan</dt> <dd class="text-foreground font-medium">1h Critical Response</dd> </div> <div class="flex items-center justify-between gap-2"> <dt class="text-muted-foreground">Timezone</dt> <dd class="text-foreground font-medium">America/Los_Angeles (UTC-7)</dd> </div> </dl> <ui-separator /> <button ui-button variant="outline" size="sm" class="w-full justify-center gap-1.5 text-xs"> View Customer in CRM <lucide-icon [img]="ArrowUpRight" class="size-3.5" /> </button> </ui-card-content> </ui-card> <!-- Ticket Attributes Card --> <ui-card> <ui-card-header class="pb-3"> <ui-card-title class="text-sm font-semibold">Ticket Attributes</ui-card-title> </ui-card-header> <ui-card-content class="space-y-4 pt-0"> <!-- Assigned Agent --> <div class="space-y-1.5"> <label class="text-muted-foreground text-xs font-medium">Assigned Agent</label> <ui-select [value]="assignedAgent()" (valueChange)="assignedAgent.set($event)"> <button ui-select-trigger size="sm" class="w-full text-xs"> <ui-select-value placeholder="Select Agent" /> </button> <ui-select-content> <ui-select-item value="elena_martinez">Elena Martinez (DevOps)</ui-select-item> <ui-select-item value="alex_kim">Alex Kim (Tier 2)</ui-select-item> <ui-select-item value="marcus_vance">Marcus Vance (Platform)</ui-select-item> <ui-select-item value="unassigned">Unassigned</ui-select-item> </ui-select-content> </ui-select> </div> <!-- Department --> <div class="space-y-1.5"> <label class="text-muted-foreground text-xs font-medium">Department</label> <ui-select [value]="department()" (valueChange)="department.set($event)"> <button ui-select-trigger size="sm" class="w-full text-xs"> <ui-select-value placeholder="Department" /> </button> <ui-select-content> <ui-select-item value="platform">Platform Infrastructure</ui-select-item> <ui-select-item value="billing">Billing & Subscriptions</ui-select-item> <ui-select-item value="core_api">Core API Support</ui-select-item> <ui-select-item value="security">Security & Compliance</ui-select-item> </ui-select-content> </ui-select> </div> <!-- Priority --> <div class="space-y-1.5"> <label class="text-muted-foreground text-xs font-medium">Priority</label> <ui-select [value]="ticketPriority()" (valueChange)="ticketPriority.set($event)"> <button ui-select-trigger size="sm" class="w-full text-xs"> <ui-select-value placeholder="Priority" /> </button> <ui-select-content> <ui-select-item value="urgent">Urgent (P0)</ui-select-item> <ui-select-item value="high">High (P1)</ui-select-item> <ui-select-item value="medium">Medium (P2)</ui-select-item> <ui-select-item value="low">Low (P3)</ui-select-item> </ui-select-content> </ui-select> </div> <ui-separator /> <!-- Tags Section --> <div class="space-y-2"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">Tags</span> @if (!isAddingTag()) { <button type="button" class="text-primary min-h-6 text-xs font-medium hover:underline" (click)="isAddingTag.set(true)" > + Add Tag </button> } </div> <div class="flex flex-wrap gap-1.5"> @for (tag of tags(); track tag) { <span class="bg-muted text-foreground inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs" > <lucide-icon [img]="Tag" class="text-muted-foreground size-3" /> {{ tag }} <button type="button" class="text-muted-foreground hover:text-foreground ml-0.5" aria-label="Remove tag" (click)="removeTag(tag)" > <lucide-icon [img]="X" class="size-3" /> </button> </span> } </div> @if (isAddingTag()) { <div class="flex items-center gap-1.5 pt-1"> <ui-input [value]="newTagInput()" (valueChange)="newTagInput.set($event)" placeholder="New tag..." class="h-7 text-xs" (keydown)="onTagKeydown($event)" /> <button ui-button size="sm" class="h-7 px-2 text-xs" (click)="addTag()">Add</button> <button ui-button variant="ghost" size="sm" class="h-7 px-2 text-xs" (click)="isAddingTag.set(false)" > Cancel </button> </div> } </div> </ui-card-content> </ui-card> <!-- SLA & Metrics Card --> <ui-card> <ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <ui-card-title class="text-sm font-semibold">SLA & Metrics</ui-card-title> <span ui-badge variant="outline" class="text-xs font-medium">Active SLA</span> </div> </ui-card-header> <ui-card-content class="space-y-3 pt-0"> <dl class="space-y-3 text-xs"> <div class="flex items-center justify-between gap-2"> <div> <dt class="text-foreground font-medium">First Response Time</dt> <dd class="text-muted-foreground text-xs">Target: < 15m</dd> </div> <div class="text-right"> <span class="text-success font-semibold">8m</span> <span class="text-muted-foreground block text-xs">7m ahead</span> </div> </div> <ui-separator /> <div class="flex items-center justify-between gap-2"> <div> <dt class="text-foreground font-medium">Target Resolution</dt> <dd class="text-muted-foreground text-xs">Target: 4h (Elapsed: 3h 18m)</dd> </div> <div class="text-right"> <span class="text-warning font-semibold">42m</span> <span class="text-muted-foreground block text-xs">remaining</span> </div> </div> <ui-separator /> <div class="flex items-center justify-between gap-2"> <div> <dt class="text-foreground font-medium">CSAT Prediction</dt> <dd class="text-muted-foreground text-xs">Based on sentiment analysis</dd> </div> <span ui-badge variant="secondary" class="font-medium">98% High</span> </div> <ui-separator /> <div class="flex items-center justify-between gap-2"> <dt class="text-muted-foreground flex items-center gap-1.5"> <lucide-icon [img]="Globe" class="size-3.5" /> Source Channel </dt> <dd class="text-foreground font-medium">Web Portal</dd> </div> <div class="flex items-center justify-between gap-2"> <dt class="text-muted-foreground flex items-center gap-1.5"> <lucide-icon [img]="ShieldAlert" class="size-3.5" /> Escalation Tier </dt> <dd class="text-foreground font-medium">Tier 2 Support</dd> </div> </dl> </ui-card-content> </ui-card> </aside> </div> </div> `, }) export class UiSupportTicketDetailComponent { private copyTimer?: ReturnType<typeof setTimeout> readonly ticketStatus = signal('in_progress') readonly ticketPriority = signal('high') readonly assignedAgent = signal('elena_martinez') readonly department = signal('platform') readonly composerTab = signal<SupportTicketComposerTab>('public') readonly replyDraft = signal('') readonly selectedMacro = signal('') readonly copiedLog = signal(false) readonly tags = signal(['ssl-certificate', 'custom-domain', 'acme-challenge', 'enterprise-tier']) readonly newTagInput = signal('') readonly isAddingTag = signal(false) readonly dynamicMessages = signal<SupportTicketThreadMessage[]>([]) protected readonly cannedMacros = cannedMacros protected readonly errorLog = errorLog protected readonly AlertCircle = AlertCircle protected readonly AlertTriangle = AlertTriangle protected readonly ArrowUpRight = ArrowUpRight protected readonly Bold = Bold protected readonly CheckCheck = CheckCheck protected readonly CheckCircle2 = CheckCircle2 protected readonly ChevronRight = ChevronRight protected readonly Clock = Clock protected readonly Code = Code protected readonly Copy = Copy protected readonly Download = Download protected readonly Eye = Eye protected readonly FileCode2 = FileCode2 protected readonly Globe = Globe protected readonly ImageIcon = ImageIcon protected readonly Italic = Italic protected readonly Link2 = Link2 protected readonly Lock = Lock protected readonly MessageSquare = MessageSquare protected readonly Paperclip = Paperclip protected readonly Send = Send protected readonly ShieldAlert = ShieldAlert protected readonly Tag = Tag protected readonly X = X constructor() { inject(DestroyRef).onDestroy(() => clearTimeout(this.copyTimer)) } hasDraft(): boolean { return this.replyDraft().trim() !== '' } setComposerTab(value: string): void { this.composerTab.set(value === 'internal' ? 'internal' : 'public') } handleEscalate(): void { this.ticketStatus.set('escalated') } handleResolve(): void { this.ticketStatus.set('resolved') } handleSelectMacro(value: string): void { this.selectedMacro.set(value) const macro = cannedMacros.find((m) => m.id === value) if (macro) this.replyDraft.set(macro.text) } applyFormat(type: 'bold' | 'code' | 'link'): void { const prev = this.replyDraft() if (type === 'bold') { this.replyDraft.set(prev ? `${prev} **bold text**` : '**bold text**') } else if (type === 'code') { this.replyDraft.set(prev ? `${prev}\n\`\`\`\ncode snippet\n\`\`\`` : '```\ncode snippet\n```') } else if (type === 'link') { this.replyDraft.set(prev ? `${prev} [link title](https://example.com)` : '[link title](https://example.com)') } } copyErrorLog(): void { this.copiedLog.set(true) clearTimeout(this.copyTimer) this.copyTimer = setTimeout(() => this.copiedLog.set(false), 2000) } sendReply(): void { const body = this.replyDraft().trim() if (!body) return const isInternal = this.composerTab() === 'internal' this.dynamicMessages.update((prev) => [ ...prev, { id: `msg-${Date.now()}`, authorName: isInternal ? 'Alex Kim' : 'Elena Martinez', authorRole: isInternal ? 'Senior DevOps' : 'Lead Support Engineer', avatarText: isInternal ? 'AK' : 'EM', badgeText: isInternal ? 'Internal Note - Only visible to team' : 'Support Engineer', badgeVariant: isInternal ? 'warning' : 'default', timestamp: 'Just now', isInternal, content: body, }, ]) this.replyDraft.set('') this.selectedMacro.set('') } sendAndSolve(): void { this.sendReply() this.ticketStatus.set('resolved') } addTag(): void { const t = this.newTagInput().trim().toLowerCase() if (t && !this.tags().includes(t)) this.tags.update((prev) => [...prev, t]) this.newTagInput.set('') this.isAddingTag.set(false) } removeTag(tagToRemove: string): void { this.tags.update((prev) => prev.filter((t) => t !== tagToRemove)) } onTagKeydown(event: Event): void { if ((event as KeyboardEvent).key === 'Enter') { event.preventDefault() this.addTag() } } textareaClass(): string { return cn( 'resize-y text-sm', this.composerTab() === 'internal' && 'border-warning/30 bg-warning/5 focus-visible:ring-warning/20', ) } articleClass(msg: SupportTicketThreadMessage): string { return cn('rounded-xl border p-5 shadow-xs sm:p-6', msg.isInternal ? 'border-warning/30 bg-warning/10' : 'bg-card') } avatarFallbackClass(msg: SupportTicketThreadMessage): string { return cn( 'text-xs font-semibold', msg.isInternal ? 'bg-warning/20 text-warning' : 'bg-primary text-primary-foreground', ) } badgeClass(msg: SupportTicketThreadMessage): string { return msg.isInternal ? 'border-warning/40 bg-warning/20 text-warning gap-1 text-xs font-medium' : 'text-xs font-normal' } }src/app/components/blocks/support-ticket-detail/index.ts0.2 kB
export { UiSupportTicketDetailComponent, type SupportTicketComposerTab, type SupportTicketMacro, type SupportTicketThreadMessage, } from './support-ticket-detail.component'
Raw manifest:https://uipkge.dev/r/angular/support-ticket-detail.json