Compare commits

..

2 Commits

Author SHA1 Message Date
7103e7723f Prefill the Ops delivery window from the ETA or laycan, and the offer validity with 24 hours
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 13:22:35 +05:30
c3dba17ba9 Show the enquiry's channel: email thread vs WhatsApp, and the offer button says which
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 17:26:19 +05:30
2 changed files with 74 additions and 12 deletions

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, MessageCircle, Sparkles, 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';
@ -20,6 +20,65 @@ function nowLocal(): string {
d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16); return d.toISOString().slice(0, 16);
} }
const MONTHS: Record<string, number> = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, sept: 8, oct: 9, nov: 10, dec: 11 };
function toLocalInput(y: number, m: number, d: number, hh: number): string {
const dt = new Date(y, m, d, hh, 0, 0);
if (isNaN(dt.getTime())) return '';
dt.setMinutes(dt.getMinutes() - dt.getTimezoneOffset());
return dt.toISOString().slice(0, 16);
}
/**
* A delivery window suggested from the ETA / laycan text the customer gave
* ("ETA 14 Nov 2026", "Laycan 12-15 Nov 2026", "14/11/2026"). A single date
* becomes a 06:00-12:00 slot that day, a laycan runs 06:00 on the first day to
* 18:00 on the last. Anything unparseable leaves the fields empty for Ops.
*/
function suggestedWindow(text: string | null | undefined): { from: string; to: string } {
const none = { from: '', to: '' };
if (!text) return none;
const t = text.toLowerCase();
const now = new Date();
const year = (y?: string) => {
if (y) return y.length === 2 ? 2000 + Number(y) : Number(y);
return now.getFullYear();
};
const bump = (y: number, m: number, d: number) => (new Date(y, m, d + 1) < now && !/\d{4}/.test(t) ? y + 1 : y);
// "12-15 nov 2026", "12 - 15 nov", "12 to 15 nov 2026"
let m = t.match(/(\d{1,2})\s*(?:-|–|to)\s*(\d{1,2})\s+([a-z]{3,9})\.?,?\s*(\d{4}|\d{2})?/);
if (m && (MONTHS[m[3].slice(0, 4)] !== undefined || MONTHS[m[3].slice(0, 3)] !== undefined)) {
const mo = MONTHS[m[3].slice(0, 4)] ?? MONTHS[m[3].slice(0, 3)];
const y = bump(year(m[4]), mo, Number(m[1]));
return { from: toLocalInput(y, mo, Number(m[1]), 6), to: toLocalInput(y, mo, Number(m[2]), 18) };
}
// "14 nov 2026", "14 nov", "14th nov"
m = t.match(/(\d{1,2})(?:st|nd|rd|th)?\s+([a-z]{3,9})\.?,?\s*(\d{4}|\d{2})?/);
if (m && (MONTHS[m[2].slice(0, 4)] !== undefined || MONTHS[m[2].slice(0, 3)] !== undefined)) {
const mo = MONTHS[m[2].slice(0, 4)] ?? MONTHS[m[2].slice(0, 3)];
const y = bump(year(m[3]), mo, Number(m[1]));
return { from: toLocalInput(y, mo, Number(m[1]), 6), to: toLocalInput(y, mo, Number(m[1]), 12) };
}
// "nov 14, 2026", "nov 14"
m = t.match(/([a-z]{3,9})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s*(\d{4})?/);
if (m && (MONTHS[m[1].slice(0, 4)] !== undefined || MONTHS[m[1].slice(0, 3)] !== undefined)) {
const mo = MONTHS[m[1].slice(0, 4)] ?? MONTHS[m[1].slice(0, 3)];
const y = bump(year(m[3]), mo, Number(m[2]));
return { from: toLocalInput(y, mo, Number(m[2]), 6), to: toLocalInput(y, mo, Number(m[2]), 12) };
}
// "14/11/2026", "14-11-2026", "14.11.26" (day first, as the desk writes it)
m = t.match(/(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{4}|\d{2})/);
if (m) {
const y = year(m[3]);
return { from: toLocalInput(y, Number(m[2]) - 1, Number(m[1]), 6), to: toLocalInput(y, Number(m[2]) - 1, Number(m[1]), 12) };
}
return none;
}
/** A default offer validity: 24 hours from now, on the hour. */
function defaultValidity(): string {
const d = new Date();
d.setHours(d.getHours() + 25, 0, 0, 0);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
/** A delivery window as the text the offer and the customer read: "12 Nov 2026, 06:00 - 12:00 IST". */ /** A delivery window as the text the offer and the customer read: "12 Nov 2026, 06:00 - 12:00 IST". */
function fmtWindow(from: string, to: string): string { function fmtWindow(from: string, to: string): string {
const a = fmtDateTime(from); const a = fmtDateTime(from);
@ -49,7 +108,9 @@ export function EnquiryPage() {
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold">{e.enquiry_ref ?? `Enquiry #${e.instance_id}`}</h1> <h1 className="text-2xl font-bold">{e.enquiry_ref ?? `Enquiry #${e.instance_id}`}</h1>
<StateBadge state={e.current_state_name} /> <StateBadge state={e.current_state_name} />
{e.customer_contact && <span className="inline-flex items-center gap-1 text-sm text-muted"><MessageCircle size={14} /> +{e.customer_contact.replace(/^\+/, '')} · {e.channel ?? 'whatsapp'}</span>} {e.customer_contact && (e.channel === 'email' || e.customer_contact.includes('@')
? <span className="inline-flex items-center gap-1 text-sm text-muted"><Mail size={14} /> {e.customer_contact} · email</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>}
@ -61,7 +122,7 @@ export function EnquiryPage() {
<OfferCard e={e} /> <OfferCard e={e} />
<Outcome e={e} /> <Outcome e={e} />
</div> </div>
<Conversation log={e.conversation_log} /> <Conversation log={e.conversation_log} channel={e.channel === 'email' || (e.customer_contact ?? '').includes('@') ? 'email' : 'whatsapp'} />
</div> </div>
</div> </div>
); );
@ -140,13 +201,13 @@ function Outcome({ e }: { e: Enquiry }) {
} }
/** conversation_log is "Customer: …" / "Desk: …" entries separated by blank lines. */ /** conversation_log is "Customer: …" / "Desk: …" entries separated by blank lines. */
function Conversation({ log }: { log: string | null }) { 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 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]*)$/); 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 }; return m ? { who: m[1], tag: m[2].trim().replace(/[()]/g, ''), text: m[3] } : { who: 'Desk', tag: '', text: chunk };
}); });
return ( return (
<Card title="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"> <div className="max-h-[70vh] 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) => (
@ -215,7 +276,8 @@ function Field({ label, required, children }: { label: string; required?: boolea
function OpsForm({ e, onDone }: { e: Enquiry; onDone: () => void }) { function OpsForm({ e, onDone }: { e: Enquiry; onDone: () => void }) {
const { busy, error, ok, submit } = useSubmit(e, onDone); const { busy, error, ok, submit } = useSubmit(e, onDone);
const [f, setF] = useState({ stock: 'available', mode: 'barge', from: '', to: '', qty: String(e.max_qty_mt ?? ''), remarks: '' }); const win = suggestedWindow(e.eta_laycan);
const [f, setF] = useState({ stock: 'available', mode: 'barge', from: win.from, to: win.to, qty: String(e.max_qty_mt ?? e.min_qty_mt ?? ''), remarks: '' });
const windowError = f.from && f.to && f.to <= f.from ? 'The window must end after it starts' : null; const windowError = f.from && f.to && f.to <= f.from ? 'The window must end after it starts' : null;
const [regret, setRegret] = useState(false); const [regret, setRegret] = useState(false);
if (regret) return <ReasonForm e={e} onDone={onDone} activity={ACT.REGRET} field="lost_reason__regret" title="Regret the enquiry" cta="Send regret to customer" onCancel={() => setRegret(false)} />; if (regret) return <ReasonForm e={e} onDone={onDone} activity={ACT.REGRET} field="lost_reason__regret" title="Regret the enquiry" cta="Send regret to customer" onCancel={() => setRegret(false)} />;
@ -254,7 +316,7 @@ function OpsForm({ e, onDone }: { e: Enquiry; onDone: () => void }) {
function OfferForm({ e, onDone, revise, onCancel }: { e: Enquiry; onDone: () => void; revise: boolean; onCancel?: () => void }) { function OfferForm({ e, onDone, revise, onCancel }: { e: Enquiry; onDone: () => void; revise: boolean; onCancel?: () => void }) {
const { busy, error, ok, submit } = useSubmit(e, onDone); const { busy, error, ok, submit } = useSubmit(e, onDone);
const [f, setF] = useState({ price: revise && e.price_usd_mt ? String(e.price_usd_mt) : '', basis: e.price_basis ?? `delivered ex-${e.supply_mode === 'pipeline' ? 'wharf' : 'barge'} ${e.port ?? ''}`.trim(), const [f, setF] = useState({ price: revise && e.price_usd_mt ? String(e.price_usd_mt) : '', basis: e.price_basis ?? `delivered ex-${e.supply_mode === 'pipeline' ? 'wharf' : 'barge'} ${e.port ?? ''}`.trim(),
valid: '', terms: e.payment_terms ?? '30 days from date of delivery', remarks: '' }); valid: defaultValidity(), terms: e.payment_terms ?? '30 days from date of delivery', remarks: '' });
const [regret, setRegret] = useState(false); const [regret, setRegret] = useState(false);
if (regret) return <ReasonForm e={e} onDone={onDone} activity={ACT.REGRET} field="lost_reason__regret" title="Regret the enquiry" cta="Send regret to customer" onCancel={() => setRegret(false)} />; if (regret) return <ReasonForm e={e} onDone={onDone} activity={ACT.REGRET} field="lost_reason__regret" title="Regret the enquiry" cta="Send regret to customer" onCancel={() => setRegret(false)} />;
const sfx = revise ? 'revise' : 'offer'; const sfx = revise ? 'revise' : 'offer';
@ -272,7 +334,7 @@ function OfferForm({ e, onDone, revise, onCancel }: { e: Enquiry; onDone: () =>
{error && <div className="sm:col-span-2"><ErrorNote>{error}</ErrorNote></div>} {error && <div className="sm:col-span-2"><ErrorNote>{error}</ErrorNote></div>}
{ok && <p className="text-sm text-positive sm:col-span-2">{ok}</p>} {ok && <p className="text-sm text-positive sm:col-span-2">{ok}</p>}
<div className="flex flex-wrap gap-2 sm:col-span-2"> <div className="flex flex-wrap gap-2 sm:col-span-2">
<button className="btn btn-cta" disabled={busy}>{busy ? 'Sending…' : 'Send offer on WhatsApp'}</button> <button className="btn btn-cta" disabled={busy}>{busy ? 'Sending…' : (e.customer_contact ?? '').includes('@') ? 'Send offer by email' : 'Send offer on WhatsApp'}</button>
{onCancel && <button type="button" className="btn btn-secondary" onClick={onCancel}>Cancel</button>} {onCancel && <button type="button" className="btn btn-secondary" onClick={onCancel}>Cancel</button>}
{!revise && <button type="button" className="btn btn-negative" onClick={() => setRegret(true)}>Regret</button>} {!revise && <button type="button" className="btn btn-negative" onClick={() => setRegret(true)}>Regret</button>}
</div> </div>
@ -290,7 +352,7 @@ function OfferSent({ e, onDone }: { e: Enquiry; onDone: () => void }) {
<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. Read the conversation, then revise the offer or close it.'
: 'A plain "confirm" from the customer 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">
<button className="btn btn-cta" onClick={() => setMode('revise')}>Revise offer</button> <button className="btn btn-cta" onClick={() => setMode('revise')}>Revise offer</button>

View File

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react'; import { Mail, MessageCircle, Search } from 'lucide-react';
import { useQuery, useZino } from '../api/provider'; import { useQuery, useZino } from '../api/provider';
import { RV_ENQUIRIES, STATES } from '../api/config'; import { RV_ENQUIRIES, STATES } from '../api/config';
import type { Enquiry } from '../api/types'; import type { Enquiry } from '../api/types';
@ -44,7 +44,7 @@ export function Inbox() {
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-bold">Enquiries</h1> <h1 className="text-2xl font-bold">Enquiries</h1>
<p className="text-sm text-muted">Every bunker enquiry received on WhatsApp and email, from first message to confirmed stem.</p> <p className="text-sm text-muted">Every bunker enquiry received on WhatsApp or email, from first message to confirmed stem.</p>
</div> </div>
<label className="relative w-full sm:w-72"> <label className="relative w-full sm:w-72">
<Search size={16} className="pointer-events-none absolute left-2.5 top-2.5 text-muted" /> <Search size={16} className="pointer-events-none absolute left-2.5 top-2.5 text-muted" />
@ -75,7 +75,7 @@ export function Inbox() {
<tbody> <tbody>
{shown.map((r) => ( {shown.map((r) => (
<tr key={r.instance_id} onClick={() => nav(`/enquiries/${r.instance_id}`)} className="cursor-pointer border-b border-line last:border-0 hover:bg-surface-2"> <tr key={r.instance_id} onClick={() => nav(`/enquiries/${r.instance_id}`)} className="cursor-pointer border-b border-line last:border-0 hover:bg-surface-2">
<td className="px-3 py-2.5 font-semibold text-accent">{r.enquiry_ref ?? `#${r.instance_id}`}</td> <td className="px-3 py-2.5 font-semibold text-accent"><span className="inline-flex items-center gap-1.5">{(r.customer_contact ?? '').includes('@') ? <Mail size={14} className="text-muted" /> : <MessageCircle size={14} className="text-muted" />}{r.enquiry_ref ?? `#${r.instance_id}`}</span></td>
<td className="px-3 py-2.5">{r.vessel_name ?? <span className="text-muted">-</span>}{r.imo_number && <div className="text-xs text-muted">IMO {r.imo_number}</div>}</td> <td className="px-3 py-2.5">{r.vessel_name ?? <span className="text-muted">-</span>}{r.imo_number && <div className="text-xs text-muted">IMO {r.imo_number}</div>}</td>
<td className="px-3 py-2.5">{r.port ?? '-'}</td> <td className="px-3 py-2.5">{r.port ?? '-'}</td>
<td className="px-3 py-2.5">{r.eta_laycan ?? '-'}</td> <td className="px-3 py-2.5">{r.eta_laycan ?? '-'}</td>