Translate all hardcoded English strings to use i18n translation keys: Components: - TransactionDetailModal: payment details, refunds, technical info - ConnectOnboarding/ConnectOnboardingEmbed: Stripe Connect setup - StripeApiKeysForm: API key management - DomainPurchase: domain registration flow - Sidebar: navigation labels - Schedule/Sidebar, PendingSidebar: scheduler UI - MasqueradeBanner: masquerade status - Dashboard widgets: metrics, capacity, customers, tickets - Marketing: PricingTable, PluginShowcase, BenefitsSection - ConfirmationModal, ServiceList: common UI Pages: - Staff: invitation flow, role management - Customers: form placeholders - Payments: transactions, payouts, billing - BookingSettings: URL and redirect configuration - TrialExpired: upgrade prompts and features - PlatformSettings, PlatformBusinesses: admin UI - HelpApiDocs: API documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
import React from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Link } from 'react-router-dom';
|
|
import { GripVertical, X, AlertCircle, Clock, ChevronRight } from 'lucide-react';
|
|
import { Ticket } from '../../types';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
interface OpenTicketsWidgetProps {
|
|
tickets: Ticket[];
|
|
isEditing?: boolean;
|
|
onRemove?: () => void;
|
|
}
|
|
|
|
const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
|
|
tickets,
|
|
isEditing,
|
|
onRemove,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const openTickets = tickets.filter(ticket => ticket.status === 'open' || ticket.status === 'in_progress');
|
|
const urgentCount = openTickets.filter(t => t.priority === 'urgent' || t.isOverdue).length;
|
|
|
|
const getPriorityColor = (priority: string, isOverdue?: boolean) => {
|
|
if (isOverdue) return 'text-red-600 dark:text-red-400';
|
|
switch (priority) {
|
|
case 'urgent': return 'text-red-600 dark:text-red-400';
|
|
case 'high': return 'text-orange-600 dark:text-orange-400';
|
|
case 'medium': return 'text-yellow-600 dark:text-yellow-400';
|
|
default: return 'text-gray-600 dark:text-gray-400';
|
|
}
|
|
};
|
|
|
|
const getPriorityBg = (priority: string, isOverdue?: boolean) => {
|
|
if (isOverdue) return 'bg-red-50 dark:bg-red-900/20';
|
|
switch (priority) {
|
|
case 'urgent': return 'bg-red-50 dark:bg-red-900/20';
|
|
case 'high': return 'bg-orange-50 dark:bg-orange-900/20';
|
|
case 'medium': return 'bg-yellow-50 dark:bg-yellow-900/20';
|
|
default: return 'bg-gray-50 dark:bg-gray-700/50';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
|
|
{isEditing && (
|
|
<>
|
|
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
|
<GripVertical size={16} />
|
|
</div>
|
|
<button
|
|
onClick={onRemove}
|
|
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div className={`flex items-center justify-between mb-4 ${isEditing ? 'pl-5' : ''}`}>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Open Tickets
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
{urgentCount > 0 && (
|
|
<span className="flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
|
|
<AlertCircle size={12} />
|
|
{urgentCount} urgent
|
|
</span>
|
|
)}
|
|
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
{openTickets.length} open
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-2">
|
|
{openTickets.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
|
|
<AlertCircle size={32} className="mb-2 opacity-50" />
|
|
<p className="text-sm">{t('dashboard.noOpenTickets')}</p>
|
|
</div>
|
|
) : (
|
|
openTickets.slice(0, 5).map((ticket) => (
|
|
<Link
|
|
key={ticket.id}
|
|
to="/tickets"
|
|
className={`block p-3 rounded-lg ${getPriorityBg(ticket.priority, ticket.isOverdue)} hover:opacity-80 transition-opacity`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
{ticket.subject}
|
|
</p>
|
|
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
<span className={getPriorityColor(ticket.priority, ticket.isOverdue)}>
|
|
{ticket.isOverdue ? 'Overdue' : ticket.priority}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock size={10} />
|
|
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<ChevronRight size={16} className="text-gray-400 flex-shrink-0" />
|
|
</div>
|
|
</Link>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{openTickets.length > 5 && (
|
|
<Link
|
|
to="/tickets"
|
|
className="mt-3 text-sm text-brand-600 dark:text-brand-400 hover:underline text-center"
|
|
>
|
|
View all {openTickets.length} tickets
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default OpenTicketsWidget;
|