Menu

Change language

GitHub repo

Support Ticket Detail

blockcommunication

Customer support ticket conversation thread, SLA monitor & staff workbench with internal notes, canned response macros, and customer metadata sidebar.

Installation

$npx uipkge-ng@latest add support-ticket-detail
Manifest:https://uipkge.dev/r/angular/support-ticket-detail.jsonInstalls to:src/app/components/blocks/support-ticket-detail/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SupportTicketThreadMessage
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
}
SupportTicketMacro
interface 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 &amp; 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&#64;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 &quot;digicert.com&quot;</code
                    >), while our automated ACME pipeline requests Let&apos;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 &quot;letsencrypt.org&quot;</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&apos;s DNS CAA records currently block Let&apos;s Encrypt certificate
                    issuance.
                  </p>
                  <p>Please follow these step-by-step instructions to enable Let&apos;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> &rarr; <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">&quot;letsencrypt.org&quot;</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&apos;ve saved the record, and we&apos;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 &amp; 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&#64;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 &amp; Subscriptions</ui-select-item>
                        <ui-select-item value="core_api">Core API Support</ui-select-item>
                        <ui-select-item value="security">Security &amp; 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 &amp; 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: &lt; 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

Raw manifest:https://uipkge.dev/r/angular/support-ticket-detail.json