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 (
+
+ );
+}
+
// ---------------------------------------------------------------------------
// 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 (
Customer replied : undefined}>
- {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.'}
diff --git a/src/screens/Inbox.tsx b/src/screens/Inbox.tsx
index 8dab48c..d03230a 100644
--- a/src/screens/Inbox.tsx
+++ b/src/screens/Inbox.tsx
@@ -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() {
- {r.current_state_name === 'Offer Sent' && r.customer_decision === 'query' && Customer replied}
+ {r.awaiting_trader_reply === 'yes' ? Customer question{r.assigned_trader ? ` · ${r.assigned_trader.split(' ')[0]}` : ''}
+ : r.current_state_name === 'Offer Sent' && r.customer_decision === 'query' && Customer replied}
|