ticket-actions.tsx263 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7
8type Status = 'no_response' | 'in_review' | 'replied' | 'closed';
9
10const STATUS_OPTIONS: readonly Status[] = ['no_response', 'in_review', 'replied', 'closed'];
11
12const STATUS_LABELS: Record<Status, string> = {
13 no_response: 'no response',
14 in_review: 'in review',
15 replied: 'replied',
16 closed: 'closed',
17};
18
19interface TicketSummary {
20 id: string;
21 status: string;
22 assignedTo: string | null;
23 operatorNotes: string;
24}
25
26interface Props {
27 ticket: TicketSummary;
28 apiOrigin: string;
29}
30
31interface PatchPayload {
32 status?: Status;
33 assignedTo?: string;
34 operatorNotes?: string;
35}
36
37export function TicketActions({ ticket, apiOrigin }: Props) {
38 const router = useRouter();
39 const [busy, setBusy] = useState(false);
40 const [error, setError] = useState<string | null>(null);
41 const [pendingPayload, setPendingPayload] = useState<PatchPayload | null>(null);
42
43 const [status, setStatus] = useState<Status>(ticket.status as Status);
44 const [assignedTo, setAssignedTo] = useState(ticket.assignedTo ?? '');
45 const [operatorNotes, setOperatorNotes] = useState(ticket.operatorNotes);
46 const [editingNotes, setEditingNotes] = useState(false);
47
48 // Reply
49 const [replyBody, setReplyBody] = useState('');
50 const [replying, setReplying] = useState(false);
51 const [replyError, setReplyError] = useState<string | null>(null);
52
53 const [, startTransition] = useTransition();
54
55 async function patch(payload: PatchPayload) {
56 setBusy(true);
57 setError(null);
58 try {
59 const res = await fetch(`${apiOrigin}/v1/admin/tickets/${ticket.id}`, {
60 method: 'PATCH',
61 credentials: 'include',
62 headers: { 'content-type': 'application/json' },
63 body: JSON.stringify(payload),
64 });
65 if (res.status === 403) {
66 const body = (await res.json().catch(() => null)) as { code?: string } | null;
67 if (body?.code === 'step_up_required') {
68 setPendingPayload(payload);
69 return;
70 }
71 }
72 if (!res.ok) {
73 const body = await res.text().catch(() => '');
74 throw new Error(body || `update failed: ${res.status}`);
75 }
76 setEditingNotes(false);
77 startTransition(() => router.refresh());
78 } catch (err) {
79 setError(err instanceof Error ? err.message : 'update failed');
80 } finally {
81 setBusy(false);
82 }
83 }
84
85 async function sendReply() {
86 if (!replyBody.trim()) return;
87 setReplying(true);
88 setReplyError(null);
89 try {
90 const res = await fetch(`${apiOrigin}/v1/admin/tickets/${ticket.id}/reply`, {
91 method: 'POST',
92 credentials: 'include',
93 headers: { 'content-type': 'application/json' },
94 body: JSON.stringify({ body: replyBody.trim() }),
95 });
96 if (!res.ok) {
97 const body = await res.text().catch(() => '');
98 throw new Error(body || `reply failed: ${res.status}`);
99 }
100 setReplyBody('');
101 startTransition(() => router.refresh());
102 } catch (err) {
103 setReplyError(err instanceof Error ? err.message : 'reply failed');
104 } finally {
105 setReplying(false);
106 }
107 }
108
109 return (
110 <div className="flex flex-col gap-6 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
111 <h3 className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
112 operator actions
113 </h3>
114
115 {/* status */}
116 <div className="flex flex-wrap items-center gap-3">
117 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">status</span>
118 <select
119 value={status}
120 disabled={busy}
121 onChange={(e) => setStatus(e.target.value as Status)}
122 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
123 >
124 {STATUS_OPTIONS.map((s) => (
125 <option key={s} value={s}>
126 {STATUS_LABELS[s]}
127 </option>
128 ))}
129 </select>
130 <button
131 type="button"
132 disabled={busy || status === (ticket.status as Status)}
133 onClick={() => void patch({ status })}
134 className="rounded-md bg-[var(--color-primary)] px-3 py-1 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
135 >
136 {busy ? 'saving…' : 'save status'}
137 </button>
138 </div>
139
140 {/* assigned to */}
141 <div className="flex flex-wrap items-center gap-3">
142 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">assigned to</span>
143 <input
144 type="text"
145 value={assignedTo}
146 onChange={(e) => setAssignedTo(e.target.value)}
147 maxLength={200}
148 placeholder="email or name"
149 disabled={busy}
150 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
151 />
152 <button
153 type="button"
154 disabled={busy}
155 onClick={() =>
156 void patch({ assignedTo: assignedTo.trim() !== '' ? assignedTo.trim() : undefined })
157 }
158 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)] disabled:opacity-50"
159 >
160 {busy ? 'saving…' : 'save'}
161 </button>
162 </div>
163
164 {/* operator notes */}
165 {editingNotes ? (
166 <div className="flex flex-col gap-2">
167 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
168 operator notes
169 </span>
170 <textarea
171 value={operatorNotes}
172 onChange={(e) => setOperatorNotes(e.target.value)}
173 maxLength={20_000}
174 rows={4}
175 placeholder="internal notes — never shown to the user."
176 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text-muted)] focus:border-[var(--color-primary)] focus:outline-none"
177 />
178 <div className="flex gap-2">
179 <button
180 type="button"
181 onClick={() => void patch({ operatorNotes })}
182 disabled={busy}
183 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
184 >
185 {busy ? 'saving…' : 'save notes'}
186 </button>
187 <button
188 type="button"
189 onClick={() => {
190 setEditingNotes(false);
191 setOperatorNotes(ticket.operatorNotes);
192 }}
193 className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
194 >
195 cancel
196 </button>
197 </div>
198 </div>
199 ) : (
200 <div className="flex flex-wrap items-center gap-2">
201 <button
202 type="button"
203 onClick={() => setEditingNotes(true)}
204 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
205 >
206 {ticket.operatorNotes ? 'edit notes' : 'add notes'}
207 </button>
208 {ticket.operatorNotes ? (
209 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
210 · {ticket.operatorNotes.length} chars
211 </span>
212 ) : null}
213 </div>
214 )}
215
216 {error ? (
217 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
218 ) : null}
219
220 {pendingPayload ? (
221 <StepUpPrompt
222 apiOrigin={apiOrigin}
223 reason="updating a ticket requires fresh step-up auth. confirm with your password."
224 onSuccess={async () => {
225 const payload = pendingPayload;
226 setPendingPayload(null);
227 if (payload) await patch(payload);
228 }}
229 onCancel={() => setPendingPayload(null)}
230 />
231 ) : null}
232
233 {/* reply box */}
234 <div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-4">
235 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
236 reply to user
237 </span>
238 <textarea
239 value={replyBody}
240 onChange={(e) => setReplyBody(e.target.value)}
241 rows={5}
242 maxLength={10_000}
243 placeholder="your reply — this will be emailed to the customer."
244 disabled={replying}
245 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
246 />
247 {replyError ? (
248 <p className="font-mono text-[10px] text-[var(--color-error)]">{replyError}</p>
249 ) : null}
250 <div>
251 <button
252 type="button"
253 onClick={() => void sendReply()}
254 disabled={replying || !replyBody.trim()}
255 className="rounded-md bg-[var(--color-primary)] px-4 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
256 >
257 {replying ? 'sending…' : 'send reply'}
258 </button>
259 </div>
260 </div>
261 </div>
262 );
263}