From fe0b6c4fc7a58d799b6881ed0fff8c037bf6ebf0 Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 28 Oct 2026 11:22:40 +0530 Subject: [PATCH] Trader replies to the customer from the conversation; every desk line is tagged AI or trader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Conversation panel labels each desk line: 'Enquiry Desk · AI' or ' · Trader' - Banner when the customer is waiting for a trader, with the open question, its age and who it is routed to (assigned trader, else the whole desk) - Reply box for traders in Awaiting Ops / Awaiting Offer / Offer Sent / Confirmed: performs abk-act-reply; the engine signs it with the signed-in user's name - Inbox: 'Customer question' badge, and such enquiries count as needing a trader Co-Authored-By: Claude Fable 5.1 --- src/api/config.ts | 1 + src/api/types.ts | 6 +++ src/screens/Enquiry.tsx | 99 ++++++++++++++++++++++++++++++++++------- src/screens/Inbox.tsx | 4 +- 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/api/config.ts b/src/api/config.ts index 025b362..e392e4b 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -13,6 +13,7 @@ export const ACT = { REGRET: 'abk-act-regret', LOST: 'abk-act-lost', DELIVERED: 'abk-act-delivered', + REPLY: 'abk-act-reply', } as const; export const STATES = ['Intake', 'Awaiting Ops Input', 'Awaiting Offer', 'Offer Sent', 'Confirmed', 'Delivered', 'Lost'] as const; diff --git a/src/api/types.ts b/src/api/types.ts index 7a13f5c..b4dcc39 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -26,6 +26,12 @@ export interface Enquiry { touch_points_found: number | null; missing_fields: string | null; ai_summary: string | null; + assigned_trader: string | null; + assigned_trader_email: string | null; + awaiting_trader_reply: string | null; + open_question: string | null; + question_asked_at: string | null; + last_trader_reply: string | null; vessel_name: string | null; imo_number: string | null; port: string | null; diff --git a/src/screens/Enquiry.tsx b/src/screens/Enquiry.tsx index dc73c8f..bd13355 100644 --- a/src/screens/Enquiry.tsx +++ b/src/screens/Enquiry.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import type { ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; -import { ArrowLeft, Check, Mail, MessageCircle, Sparkles, X } from 'lucide-react'; +import { ArrowLeft, Check, Mail, MessageCircle, Send, Sparkles, User, X } from 'lucide-react'; import { useQuery, useZino } from '../api/provider'; import { ACT, RV_ENQUIRIES, TOUCH_POINTS, WF } from '../api/config'; import type { Enquiry } from '../api/types'; @@ -114,6 +114,7 @@ export function EnquiryPage() { : +{e.customer_contact.replace(/^\+/, '')} · WhatsApp)} {e.ai_summary &&

{e.ai_summary}

} + {e.assigned_trader &&

Trader on this enquiry: {e.assigned_trader}

}
@@ -123,7 +124,7 @@ export function EnquiryPage() {
- +
); @@ -201,29 +202,93 @@ function Outcome({ e }: { e: Enquiry }) { return null; } -/** conversation_log is "Customer: …" / "Desk: …" entries separated by blank lines. */ -function Conversation({ log, channel }: { log: string | null; channel: string }) { - const entries = (log ?? '').split(/\n+(?=(?:Customer|Desk)(?: \([^)\n]{1,24}\))?: )/).map((c) => c.trim()).filter(Boolean).map((chunk) => { - const m = chunk.match(/^(Customer|Desk)((?: \([^)\n]{1,24}\))?): ([\s\S]*)$/); - return m ? { who: m[1], tag: m[2].trim().replace(/[()]/g, ''), text: m[3] } : { who: 'Desk', tag: '', text: chunk }; +/** + * conversation_log is "Customer: …" / "Desk (): …" entries separated by + * blank lines. The tag says who wrote the desk's line: "AI" for the intake + * agent, ", Trader" for a trader, "Offer" / "Revised offer" for the + * templated offer. Untagged "Desk:" lines predate the tags. + */ +const ENTRY = /\n+(?=(?:Customer|Desk)(?: \([^)\n]{1,64}\))?: )/; +const LINE = /^(Customer|Desk)((?: \([^)\n]{1,64}\))?): ([\s\S]*)$/; +function author(tag: string): { label: string; tone: 'ai' | 'human' | 'auto' } { + if (!tag || tag === 'AI') return { label: 'Enquiry Desk · AI', tone: 'ai' }; + const m = tag.match(/^(.+), Trader$/); + if (m) return { label: `${m[1]} · Trader`, tone: 'human' }; + return { label: `Desk · ${tag}`, tone: 'auto' }; +} +const REPLY_STATES = ['Awaiting Ops Input', 'Awaiting Offer', 'Offer Sent', 'Confirmed']; + +function Conversation({ e, onDone }: { e: Enquiry; onDone: () => void }) { + const { has, user } = useZino(); + const channel = e.channel === 'email' || (e.customer_contact ?? '').includes('@') ? 'email' : 'whatsapp'; + const entries = (e.conversation_log ?? '').split(ENTRY).map((c) => c.trim()).filter(Boolean).map((chunk) => { + const m = chunk.match(LINE); + return m ? { who: m[1], tag: m[2].trim().replace(/^\(|\)$/g, ''), text: m[3] } : { who: 'Desk', tag: '', text: chunk }; }); + const canReply = has('bunker_trader', 'trading_head') && REPLY_STATES.includes(e.current_state_name); + const waiting = e.awaiting_trader_reply === 'yes'; + const myName = (user?.name ?? '').replace(/\s*\(.*\)\s*$/, '').trim(); + const mine = !!e.assigned_trader && !!myName && e.assigned_trader === myName; return ( - -
+ Customer waiting for a trader : undefined}> + {waiting && ( +
+
Needs a trader's answer{e.question_asked_at ? ` · asked ${timeAgo(e.question_asked_at)}` : ''}
+
{e.open_question}
+
{e.assigned_trader ? (mine ? 'Routed to you.' : `Routed to ${e.assigned_trader}.`) : 'Unassigned: the whole desk was notified, the first trader to reply takes the enquiry.'}
+
+ )} +
{entries.length === 0 && No messages yet.} - {entries.map((m, i) => ( -
-
-
{m.who === 'Customer' ? 'Customer' : m.tag ? `Desk · ${m.tag}` : 'Desk'}
-
{m.text}
+ {entries.map((m, i) => { + const a = m.who === 'Customer' ? null : author(m.tag); + return ( +
+
+
+ {a?.tone === 'ai' && } + {a?.tone === 'human' && } + {m.who === 'Customer' ? 'Customer' : a!.label} +
+
{m.text}
+
-
- ))} + ); + })}
+ {canReply && } ); } +/** A trader's own words to the customer, in the same chat, under the trader's name. */ +function ReplyBox({ e, onDone, name, channel }: { e: Enquiry; onDone: () => void; name: string; channel: string }) { + const { busy, error, ok, submit } = useSubmit(e, onDone); + const [text, setText] = useState(''); + const send = async () => { + if (!text.trim()) return; + await submit(ACT.REPLY, { last_trader_reply__reply: text.trim() }); + setText(''); + }; + return ( +
{ ev.preventDefault(); send(); }}> +
+ Reply as {name} · goes to the customer {channel === 'email' ? 'in this email thread' : 'on WhatsApp'}, tagged with your name +
+