initial: standalone repo from monorepo split
This commit is contained in:
418
src/app/(admin)/support/[id]/page.tsx
Normal file
418
src/app/(admin)/support/[id]/page.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
uploadedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
authorName: string;
|
||||
authorType: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
interface Ticket {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
tenantSlug: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
assignedToId: string | null;
|
||||
assignee: { id: string; firstName: string; lastName: string } | null;
|
||||
comments: Comment[];
|
||||
attachments: Attachment[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const statusOptions = ['open', 'in_progress', 'waiting_tenant', 'resolved', 'closed'];
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const ticketId = params.id as string;
|
||||
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadTicket();
|
||||
}, [ticketId]);
|
||||
|
||||
async function loadTicket() {
|
||||
try {
|
||||
const res = await api.get(`/support/tickets/${ticketId}`);
|
||||
setTicket(res.data.data);
|
||||
} catch {
|
||||
// ticket not found
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddComment() {
|
||||
if (!comment.trim() && selectedFiles.length === 0) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (comment.trim()) {
|
||||
await api.post(`/support/tickets/${ticketId}/comments`, { content: comment });
|
||||
}
|
||||
|
||||
if (selectedFiles.length > 0) {
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach((f) => formData.append('files', f));
|
||||
await api.post(`/support/tickets/${ticketId}/attachments`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
setComment('');
|
||||
setSelectedFiles([]);
|
||||
loadTicket();
|
||||
} catch (err) {
|
||||
console.error('Failed to submit:', err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
try {
|
||||
await api.patch(`/support/tickets/${ticketId}`, { status: newStatus });
|
||||
loadTicket();
|
||||
} catch (err) {
|
||||
console.error('Failed to update status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign() {
|
||||
try {
|
||||
await api.patch(`/support/tickets/${ticketId}/assign`, { adminId: 'self' });
|
||||
loadTicket();
|
||||
} catch (err) {
|
||||
console.error('Failed to assign:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadFiles() {
|
||||
if (selectedFiles.length === 0) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach((f) => formData.append('files', f));
|
||||
await api.post(`/support/tickets/${ticketId}/attachments`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
setSelectedFiles([]);
|
||||
loadTicket();
|
||||
} catch (err) {
|
||||
console.error('Failed to upload:', err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAttachment(attachmentId: string) {
|
||||
try {
|
||||
await api.delete(`/support/tickets/${ticketId}/attachments/${attachmentId}`);
|
||||
loadTicket();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete attachment:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-surface-400">Loading...</div>;
|
||||
if (!ticket) return <div className="p-8 text-center text-surface-400">Ticket not found</div>;
|
||||
|
||||
const ADMIN_API_URL = process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-sm text-surface-500 hover:text-surface-700"
|
||||
>
|
||||
← Back to tickets
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white rounded-xl border border-surface-200 p-6">
|
||||
<h1 className="text-xl font-semibold text-surface-900 mb-2">{ticket.subject}</h1>
|
||||
<p className="text-surface-600 whitespace-pre-wrap">{ticket.description}</p>
|
||||
<div className="mt-4 text-xs text-surface-400">
|
||||
Created {new Date(ticket.createdAt).toLocaleString()} by {ticket.tenantName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticket-level attachments */}
|
||||
{ticket.attachments.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-surface-200 p-4">
|
||||
<h3 className="text-sm font-medium text-surface-700 mb-3">Attachments</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ticket.attachments.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-2 px-3 py-2 bg-surface-50 border border-surface-200 rounded-lg text-sm">
|
||||
{a.mimeType.startsWith('image/') ? (
|
||||
<svg className="w-4 h-4 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-4.586 4.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
|
||||
)}
|
||||
<a
|
||||
href={`${ADMIN_API_URL}/support/tickets/uploads/${a.fileName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
{a.originalName}
|
||||
</a>
|
||||
<span className="text-surface-400">({formatBytes(a.sizeBytes)})</span>
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(a.id)}
|
||||
className="text-surface-400 hover:text-red-500 ml-1"
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments thread */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-surface-700">
|
||||
Conversation ({ticket.comments.length})
|
||||
</h3>
|
||||
{ticket.comments.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`bg-white rounded-lg border p-4 ${
|
||||
c.authorType === 'super_admin' ? 'border-primary-200 bg-primary-50/30' : 'border-surface-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-surface-700">{c.authorName}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
c.authorType === 'super_admin' ? 'bg-primary-100 text-primary-700' : 'bg-surface-100 text-surface-600'
|
||||
}`}>
|
||||
{c.authorType === 'super_admin' ? 'Admin' : 'Tenant'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-surface-400">{new Date(c.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="text-sm text-surface-700 whitespace-pre-wrap">{c.content}</p>
|
||||
|
||||
{/* Comment attachments */}
|
||||
{c.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-surface-100">
|
||||
{c.attachments.map((a) => (
|
||||
<a
|
||||
key={a.id}
|
||||
href={`${ADMIN_API_URL}/support/tickets/uploads/${a.fileName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-2 py-1 bg-surface-50 border border-surface-200 rounded text-xs hover:bg-surface-100"
|
||||
>
|
||||
<svg className="w-3 h-3 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-4.586 4.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
|
||||
{a.originalName} ({formatBytes(a.sizeBytes)})
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add comment */}
|
||||
<div className="bg-white rounded-xl border border-surface-200 p-4 space-y-3">
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment((e.target as HTMLTextAreaElement).value)}
|
||||
rows={3}
|
||||
placeholder="Type your response..."
|
||||
className="w-full border border-surface-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
|
||||
{/* Selected files preview */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFiles.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-1 px-2 py-1 bg-surface-50 border border-surface-200 rounded text-xs">
|
||||
<span className="text-surface-700">{f.name}</span>
|
||||
<span className="text-surface-400">({formatBytes(f.size)})</span>
|
||||
<button
|
||||
onClick={() => setSelectedFiles(selectedFiles.filter((_, j) => j !== i))}
|
||||
className="text-surface-400 hover:text-red-500 ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf,.txt,.doc,.docx"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
setSelectedFiles([...selectedFiles, ...Array.from(e.target.files!)].slice(0, 5));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-3 py-1.5 text-sm text-surface-600 border border-surface-300 rounded-lg hover:bg-surface-50"
|
||||
>
|
||||
Attach files
|
||||
</button>
|
||||
<span className="text-xs text-surface-400">Max 5 files, 10MB each</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddComment}
|
||||
disabled={submitting || (!comment.trim() && selectedFiles.length === 0)}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Sending...' : 'Send Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl border border-surface-200 p-4 space-y-4">
|
||||
<h3 className="text-sm font-medium text-surface-700">Ticket Info</h3>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-surface-500">Status</label>
|
||||
<select
|
||||
value={ticket.status}
|
||||
onChange={(e) => handleStatusChange((e.target as HTMLSelectElement).value)}
|
||||
className="w-full mt-1 px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||
>
|
||||
{statusOptions.map((s) => (
|
||||
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-surface-500">Priority</label>
|
||||
<p className="text-sm font-medium mt-1 capitalize">{ticket.priority}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-surface-500">Category</label>
|
||||
<p className="text-sm font-medium mt-1 capitalize">{ticket.category.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-surface-500">Tenant</label>
|
||||
<p className="text-sm font-medium mt-1">{ticket.tenantName}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-surface-500">Assigned To</label>
|
||||
<div className="mt-1">
|
||||
{ticket.assignee ? (
|
||||
<p className="text-sm">{ticket.assignee.firstName} {ticket.assignee.lastName}</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
className="text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
Assign to me
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload files */}
|
||||
<div className="bg-white rounded-xl border border-surface-200 p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-surface-700">Upload Files</h3>
|
||||
<p className="text-xs text-surface-500">Attach files directly to this ticket.</p>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf,.txt,.doc,.docx"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setSelectedFiles(Array.from(e.target.files));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
className="block w-full text-sm text-surface-500 file:mr-2 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
||||
/>
|
||||
{selectedFiles.length > 0 && (
|
||||
<button
|
||||
onClick={handleUploadFiles}
|
||||
disabled={uploading}
|
||||
className="w-full px-3 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Uploading...' : `Upload ${selectedFiles.length} file(s)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-surface-200 p-4 space-y-2">
|
||||
<h3 className="text-sm font-medium text-surface-700">Quick Actions</h3>
|
||||
{ticket.status !== 'resolved' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('resolved')}
|
||||
className="w-full px-3 py-2 text-sm bg-green-50 text-green-700 rounded-lg hover:bg-green-100"
|
||||
>
|
||||
Mark as Resolved
|
||||
</button>
|
||||
)}
|
||||
{ticket.status !== 'closed' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('closed')}
|
||||
className="w-full px-3 py-2 text-sm bg-surface-100 text-surface-600 rounded-lg hover:bg-surface-200"
|
||||
>
|
||||
Close Ticket
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user