Compare commits

..

2 Commits

Author SHA1 Message Date
fe0b6c4fc7 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>
2026-10-28 11:22:40 +05:30
6ebb101e49 Hide ignored noise from the inbox; open an enquiry by its BNK reference from notification links
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 13:56:58 +05:30
4 changed files with 96 additions and 20 deletions

View File

@ -13,6 +13,7 @@ export const ACT = {
REGRET: 'abk-act-regret', REGRET: 'abk-act-regret',
LOST: 'abk-act-lost', LOST: 'abk-act-lost',
DELIVERED: 'abk-act-delivered', DELIVERED: 'abk-act-delivered',
REPLY: 'abk-act-reply',
} as const; } as const;
export const STATES = ['Intake', 'Awaiting Ops Input', 'Awaiting Offer', 'Offer Sent', 'Confirmed', 'Delivered', 'Lost'] as const; export const STATES = ['Intake', 'Awaiting Ops Input', 'Awaiting Offer', 'Offer Sent', 'Confirmed', 'Delivered', 'Lost'] as const;

View File

@ -26,6 +26,12 @@ export interface Enquiry {
touch_points_found: number | null; touch_points_found: number | null;
missing_fields: string | null; missing_fields: string | null;
ai_summary: 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; vessel_name: string | null;
imo_number: string | null; imo_number: string | null;
port: string | null; port: string | null;

View File

@ -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, Sparkles, X } from 'lucide-react'; import { ArrowLeft, Check, Mail, MessageCircle, Send, Sparkles, User, 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,7 +91,8 @@ 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() {
const { id } = useParams(); // Notification emails link by reference (BNK-71772); the record is keyed by the number.
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' }] });
@ -113,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>)} : <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">
@ -122,7 +124,7 @@ export function EnquiryPage() {
<OfferCard e={e} /> <OfferCard e={e} />
<Outcome e={e} /> <Outcome e={e} />
</div> </div>
<Conversation log={e.conversation_log} channel={e.channel === 'email' || (e.customer_contact ?? '').includes('@') ? 'email' : 'whatsapp'} /> <Conversation e={e} onDone={q.refetch} />
</div> </div>
</div> </div>
); );
@ -200,29 +202,93 @@ function Outcome({ e }: { e: Enquiry }) {
return null; return null;
} }
/** conversation_log is "Customer: …" / "Desk: …" entries separated by blank lines. */ /**
function Conversation({ log, channel }: { log: string | null; channel: string }) { * conversation_log is "Customer: …" / "Desk (<tag>): …" entries separated by
const entries = (log ?? '').split(/\n+(?=(?:Customer|Desk)(?: \([^)\n]{1,24}\))?: )/).map((c) => c.trim()).filter(Boolean).map((chunk) => { * blank lines. The tag says who wrote the desk's line: "AI" for the intake
const m = chunk.match(/^(Customer|Desk)((?: \([^)\n]{1,24}\))?): ([\s\S]*)$/); * agent, "<Name>, Trader" for a trader, "Offer" / "Revised offer" for the
return m ? { who: m[1], tag: m[2].trim().replace(/[()]/g, ''), text: m[3] } : { who: 'Desk', tag: '', text: chunk }; * 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 ( 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"
<div className="max-h-[70vh] space-y-3 overflow-y-auto"> 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.length === 0 && <Empty>No messages yet.</Empty>}
{entries.map((m, i) => ( {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 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={`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 text-xs font-semibold text-muted">{m.who === 'Customer' ? 'Customer' : m.tag ? `Desk · ${m.tag}` : 'Desk'}</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 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.
@ -351,7 +417,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. 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.'} : '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">

View File

@ -10,6 +10,7 @@ import { Badge, Empty, ErrorNote, StateBadge, qtyText } from '../components/ui';
function needsMe(e: Enquiry, has: (...r: string[]) => boolean): boolean { 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 Ops Input') return has('bunker_ops', 'trading_head');
if (e.current_state_name === 'Awaiting Offer') return has('bunker_trader', '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 === 'Offer Sent' && e.customer_decision === 'query') return has('bunker_trader', 'trading_head');
if (e.current_state_name === 'Confirmed') return has('bunker_ops', 'trading_head'); if (e.current_state_name === 'Confirmed') return has('bunker_ops', 'trading_head');
return false; return false;
@ -21,7 +22,8 @@ export function Inbox() {
const [state, setState] = useState<string>('Mine'); const [state, setState] = useState<string>('Mine');
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const { data, error, loading } = useQuery(() => client.page(RV_ENQUIRIES, { limit: 200, sortBy: 'instance_id', sortDir: 'desc' }), [], 5000); const { data, error, loading } = useQuery(() => client.page(RV_ENQUIRIES, { limit: 200, sortBy: 'instance_id', sortDir: 'desc' }), [], 5000);
const rows = (data?.rows ?? []) as unknown as Enquiry[]; // Noise the desk never answered (automated senders, newsletters) stays out of sight.
const rows = ((data?.rows ?? []) as unknown as Enquiry[]).filter((r) => r.intake_status !== 'ignored');
const counts = useMemo(() => { const counts = useMemo(() => {
const c: Record<string, number> = { Mine: 0, All: rows.length }; const c: Record<string, number> = { Mine: 0, All: rows.length };
@ -86,7 +88,8 @@ export function Inbox() {
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<StateBadge state={r.current_state_name} /> <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> </div>
</td> </td>
</tr> </tr>