Trader replies to the customer from the conversation; every desk line is tagged AI or trader
- Conversation panel labels each desk line: 'Enquiry Desk · AI' or '<Name> · 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 <noreply@anthropic.com>
This commit is contained in:
parent
6ebb101e49
commit
fe0b6c4fc7
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
: <span className="inline-flex items-center gap-1 text-sm text-muted"><MessageCircle size={14} /> +{e.customer_contact.replace(/^\+/, '')} · WhatsApp</span>)}
|
||||
</div>
|
||||
{e.ai_summary && <p className="text-muted">{e.ai_summary}</p>}
|
||||
{e.assigned_trader && <p className="inline-flex items-center gap-1 text-sm text-muted"><User size={14} /> Trader on this enquiry: <span className="font-semibold text-ink">{e.assigned_trader}</span></p>}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div className="space-y-4">
|
||||
@ -123,7 +124,7 @@ export function EnquiryPage() {
|
||||
<OfferCard e={e} />
|
||||
<Outcome e={e} />
|
||||
</div>
|
||||
<Conversation log={e.conversation_log} channel={e.channel === 'email' || (e.customer_contact ?? '').includes('@') ? 'email' : 'whatsapp'} />
|
||||
<Conversation e={e} onDone={q.refetch} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -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 (<tag>): …" entries separated by
|
||||
* blank lines. The tag says who wrote the desk's line: "AI" for the intake
|
||||
* agent, "<Name>, 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 (
|
||||
<Card title={channel === 'email' ? 'Email thread' : 'WhatsApp conversation'} className="lg:sticky lg:top-20 lg:self-start">
|
||||
<div className="max-h-[70vh] space-y-3 overflow-y-auto">
|
||||
<Card title={channel === 'email' ? 'Email thread' : 'WhatsApp conversation'} className="lg:sticky lg:top-20 lg:self-start"
|
||||
action={waiting ? <Badge tone="notice">Customer waiting for a trader</Badge> : undefined}>
|
||||
{waiting && (
|
||||
<div className="mb-3 rounded border border-notice bg-notice-soft px-3 py-2 text-sm">
|
||||
<div className="font-semibold">Needs a trader's answer{e.question_asked_at ? ` · asked ${timeAgo(e.question_asked_at)}` : ''}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap">{e.open_question}</div>
|
||||
<div className="mt-1 text-xs text-muted">{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.'}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[60vh] space-y-3 overflow-y-auto">
|
||||
{entries.length === 0 && <Empty>No messages yet.</Empty>}
|
||||
{entries.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.who === 'Customer' ? 'justify-start' : 'justify-end'}`}>
|
||||
<div className={`max-w-[88%] rounded-lg border border-line px-3 py-2 text-sm ${m.who === 'Customer' ? 'bg-bubble-in' : 'bg-bubble-out'}`}>
|
||||
<div className="mb-0.5 text-xs font-semibold text-muted">{m.who === 'Customer' ? 'Customer' : m.tag ? `Desk · ${m.tag}` : 'Desk'}</div>
|
||||
<div className="whitespace-pre-wrap">{m.text}</div>
|
||||
{entries.map((m, i) => {
|
||||
const a = m.who === 'Customer' ? null : author(m.tag);
|
||||
return (
|
||||
<div key={i} className={`flex ${m.who === 'Customer' ? 'justify-start' : 'justify-end'}`}>
|
||||
<div className={`max-w-[88%] rounded-lg border border-line px-3 py-2 text-sm ${m.who === 'Customer' ? 'bg-bubble-in' : a?.tone === 'human' ? 'bg-accent-soft' : 'bg-bubble-out'}`}>
|
||||
<div className="mb-0.5 inline-flex items-center gap-1 text-xs font-semibold text-muted">
|
||||
{a?.tone === 'ai' && <Sparkles size={12} className="text-ai" />}
|
||||
{a?.tone === 'human' && <User size={12} className="text-accent" />}
|
||||
{m.who === 'Customer' ? 'Customer' : a!.label}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap">{m.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{canReply && <ReplyBox e={e} onDone={onDone} name={myName || 'Trading Desk'} channel={channel} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<form className="mt-3 border-t border-line pt-3" onSubmit={(ev) => { ev.preventDefault(); send(); }}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-muted">
|
||||
<span>Reply as <span className="font-semibold text-ink">{name}</span> · goes to the customer {channel === 'email' ? 'in this email thread' : 'on WhatsApp'}, tagged with your name</span>
|
||||
</div>
|
||||
<textarea className="field-input" rows={3} placeholder={e.awaiting_trader_reply === 'yes' ? 'Answer the customer…' : 'Message the customer…'}
|
||||
value={text} onChange={(x) => setText(x.target.value)}
|
||||
onKeyDown={(x) => { if ((x.ctrlKey || x.metaKey) && x.key === 'Enter') send(); }} />
|
||||
{error && <div className="mt-2"><ErrorNote>{error}</ErrorNote></div>}
|
||||
{ok && <p className="mt-2 text-sm text-positive">{ok}</p>}
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted">Ctrl+Enter to send. The reference and status line is added for you.</span>
|
||||
<button className="btn btn-cta inline-flex items-center gap-1" disabled={busy || !text.trim()}><Send size={14} /> {busy ? 'Sending…' : 'Send'}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions: what THIS user may do in THIS state. Field keys are the activity's
|
||||
// own suffixed keys (seeded in 02_workflow.sql); core rejects any other key.
|
||||
@ -352,7 +417,7 @@ function OfferSent({ e, onDone }: { e: Enquiry; onDone: () => void }) {
|
||||
return (
|
||||
<Card title="Offer is with the customer" action={replied ? <Badge tone="notice">Customer replied</Badge> : undefined}>
|
||||
<p className="mb-3 text-sm text-muted">
|
||||
{replied ? 'The customer answered without a plain yes or no. Read the conversation, then revise the offer or close it.'
|
||||
{replied ? 'The customer answered without a plain yes or no. Answer them in the conversation, revise the offer, or close it.'
|
||||
: 'A plain "confirm" from the customer, on WhatsApp or by email, books the stem automatically. A counter or a question lands here for you.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@ -10,6 +10,7 @@ import { Badge, Empty, ErrorNote, StateBadge, qtyText } from '../components/ui';
|
||||
function needsMe(e: Enquiry, has: (...r: string[]) => boolean): boolean {
|
||||
if (e.current_state_name === 'Awaiting Ops Input') return has('bunker_ops', 'trading_head');
|
||||
if (e.current_state_name === 'Awaiting Offer') return has('bunker_trader', 'trading_head');
|
||||
if (e.awaiting_trader_reply === 'yes') return has('bunker_trader', 'trading_head');
|
||||
if (e.current_state_name === 'Offer Sent' && e.customer_decision === 'query') return has('bunker_trader', 'trading_head');
|
||||
if (e.current_state_name === 'Confirmed') return has('bunker_ops', 'trading_head');
|
||||
return false;
|
||||
@ -87,7 +88,8 @@ export function Inbox() {
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StateBadge state={r.current_state_name} />
|
||||
{r.current_state_name === 'Offer Sent' && r.customer_decision === 'query' && <Badge tone="notice">Customer replied</Badge>}
|
||||
{r.awaiting_trader_reply === 'yes' ? <Badge tone="notice">Customer question{r.assigned_trader ? ` · ${r.assigned_trader.split(' ')[0]}` : ''}</Badge>
|
||||
: r.current_state_name === 'Offer Sent' && r.customer_decision === 'query' && <Badge tone="notice">Customer replied</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user