Support attaching a file / pasting a screenshot when creating a new ticket
The "New ticket" modal only took a plain-text message. It now matches the existing reply box on an open ticket: a "Прикрепить файл" button, and Ctrl+V paste of a clipboard screenshot attaches it directly. POST /api/tickets switched from JSON to multipart/form-data to carry the optional file, reusing the same saveAttachment/attachment-delivery path replies already use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
1 parent
955fdeee41
commit
5af360fd15
3 files changed
+98
-17
No files matched your search
@@ -1,23 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { X } from "lucide-react";
|
import { Paperclip, X } from "lucide-react";
|
||||||
|
|
||||||
export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const [subject, setSubject] = useState("");
|
const [subject, setSubject] = useState("");
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setSubject("");
|
setSubject("");
|
||||||
setCustomerName("");
|
setCustomerName("");
|
||||||
setCustomerEmail("");
|
setCustomerEmail("");
|
||||||
setBody("");
|
setBody("");
|
||||||
|
setPendingFile(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -25,16 +29,14 @@ export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch("/api/tickets", {
|
const formData = new FormData();
|
||||||
method: "POST",
|
formData.set("subject", subject);
|
||||||
headers: { "Content-Type": "application/json" },
|
formData.set("customerName", customerName);
|
||||||
body: JSON.stringify({
|
if (customerEmail) formData.set("customerEmail", customerEmail);
|
||||||
subject,
|
formData.set("body", body);
|
||||||
customerName,
|
if (pendingFile) formData.set("file", pendingFile);
|
||||||
customerEmail: customerEmail || undefined,
|
|
||||||
body,
|
const res = await fetch("/api/tickets", { method: "POST", body: formData });
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -104,17 +106,56 @@ export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="mb-4 block text-sm">
|
<label className="mb-1.5 block text-sm">
|
||||||
<span className="mb-1 block font-medium text-text-muted">Сообщение</span>
|
<span className="mb-1 block font-medium text-text-muted">Сообщение</span>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
rows={4}
|
rows={4}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
// Same Ctrl+V screenshot support as the reply box on an
|
||||||
|
// existing ticket — see ticket-thread.tsx.
|
||||||
|
const item = Array.from(e.clipboardData.items).find((i) => i.kind === "file" && i.type.startsWith("image/"));
|
||||||
|
if (!item) return;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const extension = item.type.split("/")[1] ?? "png";
|
||||||
|
setPendingFile(new File([file], `screenshot-${Date.now()}.${extension}`, { type: item.type }));
|
||||||
|
}}
|
||||||
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setPendingFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
{pendingFile ? (
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs">
|
||||||
|
<Paperclip size={12} />
|
||||||
|
<span className="flex-1 truncate">{pendingFile.name}</span>
|
||||||
|
<button type="button" onClick={() => setPendingFile(null)} className="text-text-muted hover:text-text">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title="Прикрепить файл"
|
||||||
|
>
|
||||||
|
<Paperclip size={14} />
|
||||||
|
Прикрепить файл
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/lib/auth/require";
|
import { requireSession } from "@/lib/auth/require";
|
||||||
import { listTickets, createManualTicket } from "@/lib/tickets/service";
|
import { listTickets, createManualTicket } from "@/lib/tickets/service";
|
||||||
|
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
@@ -18,19 +19,56 @@ const createSchema = z.object({
|
|||||||
subject: z.string().min(1).max(200),
|
subject: z.string().min(1).max(200),
|
||||||
body: z.string().min(1).max(10_000),
|
body: z.string().min(1).max(10_000),
|
||||||
customerName: z.string().min(1).max(200),
|
customerName: z.string().min(1).max(200),
|
||||||
customerEmail: z.string().email().optional(),
|
customerEmail: z.string().email().optional().or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// multipart/form-data rather than JSON — the modal supports an optional
|
||||||
|
// attachment (incl. Ctrl+V screenshot paste), same as replying on an
|
||||||
|
// existing ticket.
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (!session) return response;
|
if (!session) return response;
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const formData = await request.formData().catch(() => null);
|
||||||
const parsed = createSchema.safeParse(body);
|
if (!formData) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse({
|
||||||
|
subject: formData.get("subject"),
|
||||||
|
body: formData.get("body"),
|
||||||
|
customerName: formData.get("customerName"),
|
||||||
|
customerEmail: formData.get("customerEmail") ?? undefined,
|
||||||
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createManualTicket(parsed.data);
|
const file = formData.get("file");
|
||||||
|
let attachment: { filename: string; mimeType: string; sizeBytes: number; storageKey: string } | undefined;
|
||||||
|
if (file instanceof File) {
|
||||||
|
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||||
|
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
try {
|
||||||
|
const saved = await saveAttachment(buffer);
|
||||||
|
attachment = {
|
||||||
|
filename: file.name || "file",
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
sizeBytes: saved.sizeBytes,
|
||||||
|
storageKey: saved.storageKey,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AttachmentTooLargeError) {
|
||||||
|
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createManualTicket({
|
||||||
|
...parsed.data,
|
||||||
|
customerEmail: parsed.data.customerEmail || undefined,
|
||||||
|
attachment,
|
||||||
|
});
|
||||||
return NextResponse.json(result, { status: 201 });
|
return NextResponse.json(result, { status: 201 });
|
||||||
}
|
}
|
||||||
@@ -619,6 +619,7 @@ export async function createManualTicket(params: {
|
|||||||
body: string;
|
body: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerEmail?: string;
|
customerEmail?: string;
|
||||||
|
attachment?: AttachmentInput;
|
||||||
}) {
|
}) {
|
||||||
const [customer] = await db
|
const [customer] = await db
|
||||||
.insert(customers)
|
.insert(customers)
|
||||||
@@ -642,6 +643,7 @@ export async function createManualTicket(params: {
|
|||||||
authorName: params.customerName,
|
authorName: params.customerName,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
direction: "in",
|
direction: "in",
|
||||||
|
attachments: params.attachment ? [params.attachment] : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user