Compare commits
2 Commits
470ddc98c4
...
7103e7723f
| Author | SHA1 | Date | |
|---|---|---|---|
| 7103e7723f | |||
| c3dba17ba9 |
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
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 { ACT, RV_ENQUIRIES, TOUCH_POINTS, WF } from '../api/config';
|
||||
import type { Enquiry } from '../api/types';
|
||||
@ -20,6 +20,65 @@ function nowLocal(): string {
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
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". */
|
||||
function fmtWindow(from: string, to: string): string {
|
||||
const a = fmtDateTime(from);
|
||||
@ -49,7 +108,9 @@ export function EnquiryPage() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{e.enquiry_ref ?? `Enquiry #${e.instance_id}`}</h1>
|
||||
<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>
|
||||
{e.ai_summary && <p className="text-muted">{e.ai_summary}</p>}
|
||||
|
||||
@ -61,7 +122,7 @@ export function EnquiryPage() {
|
||||
<OfferCard e={e} />
|
||||
<Outcome e={e} />
|
||||
</div>
|
||||
<Conversation log={e.conversation_log} />
|
||||
<Conversation log={e.conversation_log} channel={e.channel === 'email' || (e.customer_contact ?? '').includes('@') ? 'email' : 'whatsapp'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -140,13 +201,13 @@ function Outcome({ e }: { e: Enquiry }) {
|
||||
}
|
||||
|
||||
/** 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 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 (
|
||||
<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">
|
||||
{entries.length === 0 && <Empty>No messages yet.</Empty>}
|
||||
{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 }) {
|
||||
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 [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)} />;
|
||||
@ -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 }) {
|
||||
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(),
|
||||
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);
|
||||
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';
|
||||
@ -272,7 +334,7 @@ function OfferForm({ e, onDone, revise, onCancel }: { e: Enquiry; onDone: () =>
|
||||
{error && <div className="sm:col-span-2"><ErrorNote>{error}</ErrorNote></div>}
|
||||
{ok && <p className="text-sm text-positive sm:col-span-2">{ok}</p>}
|
||||
<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>}
|
||||
{!revise && <button type="button" className="btn btn-negative" onClick={() => setRegret(true)}>Regret</button>}
|
||||
</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}>
|
||||
<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.'
|
||||
: '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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="btn btn-cta" onClick={() => setMode('revise')}>Revise offer</button>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
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 { RV_ENQUIRIES, STATES } from '../api/config';
|
||||
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>
|
||||
<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>
|
||||
<label className="relative w-full sm:w-72">
|
||||
<Search size={16} className="pointer-events-none absolute left-2.5 top-2.5 text-muted" />
|
||||
@ -75,7 +75,7 @@ export function Inbox() {
|
||||
<tbody>
|
||||
{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">
|
||||
<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.port ?? '-'}</td>
|
||||
<td className="px-3 py-2.5">{r.eta_laycan ?? '-'}</td>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user