import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import * as React from 'react'
import {
TicketCard,
type TicketCardProps,
type BoardTicket,
type BoardTicketActivityKind,
} from '../components/features/board'
import type { PendingToolCallData } from '../components/chat/types'
// =============================================================================
// Harness — the card registers its own drag behaviour against the DOM and needs
// no provider around it, so the story is just the card on the column surface it
// normally sits on (`bg-ods-card` behind the card's own `bg-ods-bg`, which is
// what keeps its border readable).
// =============================================================================
function Harness(args: TicketCardProps) {
return (
)
}
// =============================================================================
// Seed data
// =============================================================================
// ~2h ago so the relative timestamp ("2h ago") reads naturally. Assignees carry
// no `avatarUrl`, so `SquareAvatar` renders initials fallbacks — no external
// image dependency in the story.
const CREATED_AT = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()
const ASSIGNEES = [
{ id: 'u1', initials: 'RS', name: 'Rachel Santos' },
{ id: 'u2', initials: 'MP', name: 'Marcus Park' },
{ id: 'u3', initials: 'JD', name: 'Jamie Diaz' },
{ id: 'u4', initials: 'AK', name: 'Aiden Kim' },
{ id: 'u5', initials: 'LN', name: 'Lena Novak' },
]
const BASE_TICKET: BoardTicket = {
id: 'ticket-1',
title: 'VPN connection drops every few minutes',
ticketNumber: '#1042',
status: 'TECH_REQUIRED',
deviceHostnames: ['MBP-15'],
organizationName: 'Acme Corp',
priority: 'high',
assignees: [ASSIGNEES[0]],
tags: ['network', 'vpn'],
createdAt: CREATED_AT,
}
const APPROVAL_TOOL_CALLS: PendingToolCallData[] = [
{
toolExecutionRequestId: 'req-1',
toolName: 'run_script',
toolTitle: 'Reset local admin password policy',
toolExplanation:
'Runs a PowerShell remediation on SRV-MAIL-01 to enforce the password policy.',
toolType: 'TACTICAL',
requiresApproval: true,
approvalType: 'CLIENT',
toolCallArguments: {
script:
'Get-LocalUser | Where-Object { $_.PasswordRequired -eq $false } | Set-LocalUser -PasswordNeverExpires $false',
},
},
]
// =============================================================================
// Meta
// =============================================================================
const meta: Meta = {
title: 'Features/Board/TicketCard',
component: TicketCard,
parameters: { layout: 'centered' },
render: (args) => ,
args: {
ticket: BASE_TICKET,
columnId: 'TECH_REQUIRED',
},
}
export default meta
type Story = StoryObj
// =============================================================================
// Stories
// =============================================================================
/** Full card: title, device/org row, priority flag, one assignee, tags, timestamp. */
export const Default: Story = {}
/** Bare minimum — only the required fields, so every optional row is absent. */
export const Minimal: Story = {
args: {
ticket: {
id: 'ticket-min',
title: 'How to install Teams',
ticketNumber: '#1001',
status: 'ACTIVE',
},
},
}
/** Long title truncates to one line; long device/org row truncates too. */
export const LongContent: Story = {
args: {
ticket: {
...BASE_TICKET,
id: 'ticket-long',
title:
'Outlook keeps prompting for credentials after the latest Microsoft 365 update rollout across the whole org',
deviceHostnames: ['WS-2024-FINANCE-07', 'MBP-15-DESIGN', 'SRV-MAIL-01'],
organizationName: 'Digital Wave Media Group International',
},
},
}
/** Assignee overflow (+N badge) and tag overflow (+N pill). */
export const ManyAssigneesAndTags: Story = {
args: {
ticket: {
...BASE_TICKET,
id: 'ticket-many',
assignees: ASSIGNEES,
tags: ['hardware-failure', 'software-bug', 'urgent', 'escalated'],
},
},
}
/**
* `hasNewMessage` + `columnColor` renders the colored "New Message" tag and
* tints the card border with the column color (readable text auto-derived).
*/
export const WithNewMessage: Story = {
args: {
ticket: { ...BASE_TICKET, id: 'ticket-new-msg', hasNewMessage: true },
columnColor: '#2890fa',
},
}
/**
* The client asked for a human and confirmed the handoff — an amber footer row
* distinguishing it from an inactivity auto-escalation or an admin takeover,
* which reach the same lane silently. Driven by the ticket's `escalatedByUser`
* field, not by a label.
*/
export const EscalatedByUser: Story = {
args: {
ticket: { ...BASE_TICKET, id: 'ticket-escalated', escalatedByUser: true },
},
}
/**
* The `activity` footer row, one card per kind. The three live kinds (animated
* dots loader + grey text) fall back to their built-in labels; `stale` (clock +
* amber text) carries a consumer-computed duration label, since only the
* consumer knows the ticket's idle time — and keeps it ticking.
*/
export const ActivityIndicators: Story = {
render: () => {
const activities: { kind: BoardTicketActivityKind; label?: string }[] = [
{ kind: 'ai-working' },
{ kind: 'user-typing' },
{ kind: 'waiting-external' },
{ kind: 'stale', label: 'No activity for 2 hours' },
]
return (
{activities.map((activity) => (
))}
)
},
}
/**
* Pending CLIENT approval — collapsed grey "Pending client approval" row with a
* loader glyph. Expand it to reach the shared approve/reject affordance.
*/
export const PendingClientApproval: Story = {
args: {
ticket: {
...BASE_TICKET,
id: 'ticket-approval-client',
pendingApproval: {
id: 'appr-1',
approvalType: 'CLIENT',
toolCalls: APPROVAL_TOOL_CALLS,
},
},
onApprove: (ticketId, requestId) =>
console.log('[story] approve', { ticketId, requestId }),
onReject: (ticketId, requestId) =>
console.log('[story] reject', { ticketId, requestId }),
},
}
/** Pending ADMIN approval — yellow shield "Technician approval required" header. */
export const PendingTechApproval: Story = {
args: {
ticket: {
...BASE_TICKET,
id: 'ticket-approval-admin',
pendingApproval: {
id: 'appr-2',
approvalType: 'ADMIN',
toolCalls: APPROVAL_TOOL_CALLS,
},
},
onApprove: (ticketId, requestId) =>
console.log('[story] approve', { ticketId, requestId }),
onReject: (ticketId, requestId) =>
console.log('[story] reject', { ticketId, requestId }),
},
}
/** `href` set — the whole card is a link (Link anchor overlays the surface). */
export const AsLink: Story = {
args: {
ticket: { ...BASE_TICKET, id: 'ticket-link' },
href: '#ticket-1042',
},
}
/** `dragDisabled` — the card is pinned (no grab cursor, sortable listeners off). */
export const DragDisabled: Story = {
args: {
ticket: { ...BASE_TICKET, id: 'ticket-pinned' },
dragDisabled: true,
},
}
/** All four priority flag colors side by side. */
export const Priorities: Story = {
render: () => {
const priorities: BoardTicket['priority'][] = ['low', 'medium', 'high', 'urgent']
return (
{priorities.map((p) => (
))}
)
},
}