|
|
|
@ -1,7 +1,7 @@
|
|
|
|
import { useState } from 'react';
|
|
|
|
import { useState } from 'react';
|
|
|
|
import type { ReactNode } from 'react';
|
|
|
|
import type { ReactNode } from 'react';
|
|
|
|
import { Link, useParams } from 'react-router-dom';
|
|
|
|
import { Link, useParams } from 'react-router-dom';
|
|
|
|
import { ArrowLeft, Check, Mail, MessageCircle, Send, Sparkles, User, X } from 'lucide-react';
|
|
|
|
import { ArrowLeft, Check, Mail, MessageCircle, Sparkles, X } from 'lucide-react';
|
|
|
|
import { useQuery, useZino } from '../api/provider';
|
|
|
|
import { useQuery, useZino } from '../api/provider';
|
|
|
|
import { ACT, RV_ENQUIRIES, TOUCH_POINTS, WF } from '../api/config';
|
|
|
|
import { ACT, RV_ENQUIRIES, TOUCH_POINTS, WF } from '../api/config';
|
|
|
|
import type { Enquiry } from '../api/types';
|
|
|
|
import type { Enquiry } from '../api/types';
|
|
|
|
@ -91,8 +91,7 @@ function fmtWindow(from: string, to: string): string {
|
|
|
|
const STOCK_LABEL: Record<string, string> = { available: 'Available', partial: 'Partially available', not_available: 'Not available' };
|
|
|
|
const STOCK_LABEL: Record<string, string> = { available: 'Available', partial: 'Partially available', not_available: 'Not available' };
|
|
|
|
|
|
|
|
|
|
|
|
export function EnquiryPage() {
|
|
|
|
export function EnquiryPage() {
|
|
|
|
// Notification emails link by reference (BNK-71772); the record is keyed by the number.
|
|
|
|
const { id } = useParams();
|
|
|
|
const id = (useParams().id ?? '').replace(/^BNK-/i, '');
|
|
|
|
|
|
|
|
const { client } = useZino();
|
|
|
|
const { client } = useZino();
|
|
|
|
const q = useQuery(async () => {
|
|
|
|
const q = useQuery(async () => {
|
|
|
|
const r = await client.page(RV_ENQUIRIES, { limit: 1, filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }] });
|
|
|
|
const r = await client.page(RV_ENQUIRIES, { limit: 1, filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }] });
|
|
|
|
@ -114,7 +113,6 @@ export function EnquiryPage() {
|
|
|
|
: <span className="inline-flex items-center gap-1 text-sm text-muted"><MessageCircle size={14} /> +{e.customer_contact.replace(/^\+/, '')} · WhatsApp</span>)}
|
|
|
|
: <span className="inline-flex items-center gap-1 text-sm text-muted"><MessageCircle size={14} /> +{e.customer_contact.replace(/^\+/, '')} · WhatsApp</span>)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{e.ai_summary && <p className="text-muted">{e.ai_summary}</p>}
|
|
|
|
{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="grid gap-4 lg:grid-cols-[minmax(0,1fr)_420px]">
|
|
|
|
<div className="space-y-4">
|
|
|
|
<div className="space-y-4">
|
|
|
|
@ -124,7 +122,7 @@ export function EnquiryPage() {
|
|
|
|
<OfferCard e={e} />
|
|
|
|
<OfferCard e={e} />
|
|
|
|
<Outcome e={e} />
|
|
|
|
<Outcome e={e} />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<Conversation e={e} onDone={q.refetch} />
|
|
|
|
<Conversation log={e.conversation_log} channel={e.channel === 'email' || (e.customer_contact ?? '').includes('@') ? 'email' : 'whatsapp'} />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
@ -202,93 +200,29 @@ function Outcome({ e }: { e: Enquiry }) {
|
|
|
|
return null;
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
/** conversation_log is "Customer: …" / "Desk: …" entries separated by blank lines. */
|
|
|
|
* conversation_log is "Customer: …" / "Desk (<tag>): …" entries separated by
|
|
|
|
function Conversation({ log, channel }: { log: string | null; channel: string }) {
|
|
|
|
* blank lines. The tag says who wrote the desk's line: "AI" for the intake
|
|
|
|
const entries = (log ?? '').split(/\n+(?=(?:Customer|Desk)(?: \([^)\n]{1,24}\))?: )/).map((c) => c.trim()).filter(Boolean).map((chunk) => {
|
|
|
|
* agent, "<Name>, Trader" for a trader, "Offer" / "Revised offer" for the
|
|
|
|
const m = chunk.match(/^(Customer|Desk)((?: \([^)\n]{1,24}\))?): ([\s\S]*)$/);
|
|
|
|
* templated offer. Untagged "Desk:" lines predate the tags.
|
|
|
|
return m ? { who: m[1], tag: m[2].trim().replace(/[()]/g, ''), text: m[3] } : { who: 'Desk', tag: '', text: chunk };
|
|
|
|
*/
|
|
|
|
|
|
|
|
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 (
|
|
|
|
return (
|
|
|
|
<Card title={channel === 'email' ? 'Email thread' : 'WhatsApp conversation'} className="lg:sticky lg:top-20 lg:self-start"
|
|
|
|
<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}>
|
|
|
|
<div className="max-h-[70vh] space-y-3 overflow-y-auto">
|
|
|
|
{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.length === 0 && <Empty>No messages yet.</Empty>}
|
|
|
|
{entries.map((m, i) => {
|
|
|
|
{entries.map((m, i) => (
|
|
|
|
const a = m.who === 'Customer' ? null : author(m.tag);
|
|
|
|
<div key={i} className={`flex ${m.who === 'Customer' ? 'justify-start' : 'justify-end'}`}>
|
|
|
|
return (
|
|
|
|
<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 key={i} className={`flex ${m.who === 'Customer' ? 'justify-start' : 'justify-end'}`}>
|
|
|
|
<div className="mb-0.5 text-xs font-semibold text-muted">{m.who === 'Customer' ? 'Customer' : m.tag ? `Desk · ${m.tag}` : 'Desk'}</div>
|
|
|
|
<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="whitespace-pre-wrap">{m.text}</div>
|
|
|
|
<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>
|
|
|
|
})}
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{canReply && <ReplyBox e={e} onDone={onDone} name={myName || 'Trading Desk'} channel={channel} />}
|
|
|
|
|
|
|
|
</Card>
|
|
|
|
</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
|
|
|
|
// 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.
|
|
|
|
// own suffixed keys (seeded in 02_workflow.sql); core rejects any other key.
|
|
|
|
@ -417,7 +351,7 @@ function OfferSent({ e, onDone }: { e: Enquiry; onDone: () => void }) {
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<Card title="Offer is with the customer" action={replied ? <Badge tone="notice">Customer replied</Badge> : undefined}>
|
|
|
|
<Card title="Offer is with the customer" action={replied ? <Badge tone="notice">Customer replied</Badge> : undefined}>
|
|
|
|
<p className="mb-3 text-sm text-muted">
|
|
|
|
<p className="mb-3 text-sm text-muted">
|
|
|
|
{replied ? 'The customer answered without a plain yes or no. Answer them in the conversation, revise the offer, or close it.'
|
|
|
|
{replied ? 'The customer answered without a plain yes or no. Read the conversation, then 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.'}
|
|
|
|
: '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>
|
|
|
|
</p>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|