Bunker Enquiry Desk portal: enquiry inbox, enquiry detail, Ops and Trader actions

Ops and Trader portal for the Adani bunker enquiry demo (dev org 900000130 /
app 900000230), standing in for the Adobe enquiry portal. Spectrum-style
layout, Source Sans 3, light and dark tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-10-26 16:17:22 +05:30
commit a42216aa12
24 changed files with 3608 additions and 0 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
# Local development only (`npm run dev`). A deployed build reads config.js,
# which frontgen writes when it places the artifact. One build is promoted
# across environments unchanged, so never bake an environment in here.
VITE_ZINO_API_URL=https://dev.getzino.in

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
.vite
.vscode/*
!.vscode/extensions.json
.idea
npm-debug.log*
.env
*.tsbuildinfo

35
README.md Normal file
View File

@ -0,0 +1,35 @@
# Bunker Enquiry Desk — portal
The Ops and Trader portal for the **Bunker Enquiry Desk** demo (dev: org 900000130 /
app 900000230), standing in for Adani's Adobe enquiry portal. Spectrum-style layout
with Source Sans 3 (Adobe's open-source family; Adobe Clean is proprietary).
- **Enquiries**: every enquiry with state tabs and "Needs my action" per role, refreshed every 5 s.
- **Enquiry**: the eight touch points as read by the AI, the WhatsApp conversation,
Ops input, the offer as sent, and the action for this user and state:
Ops input / Regret, Send offer, Revise / Mark lost, Mark delivered.
Platform config: `sm2/custom-apps/adani-bunker-enquiry/`.
## Runtime contract
- `VITE_ZINO_API_URL` is read at runtime from `config.js` (frontgen writes it); `.env` for `npm run dev`.
- Router basename from the `<base href>` frontgen writes; `vite.config.ts` keeps `base: './'`.
- Every colour is a token in `src/styles.css`; dark mode redefines them.
## Data
| Need | Call |
|---|---|
| Sign in | `POST /usr/login` (org_id string), `GET /usr/me` for roles |
| Lists and detail | `POST /app/900000230/view/recordview` `abk-rv-enquiries` (detail filters `instance_id`) |
| Act | `POST /app/900000230/activity` with the activity's suffixed keys (`price_usd_mt__offer` …) |
## Run
```bash
npm install
cp .env.example .env # VITE_ZINO_API_URL=https://dev.getzino.in
npm run dev # http://localhost:5176
npm run build
```

27
index.html Normal file
View File

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--BASE_HREF-->
<title>Bunker Enquiry Desk</title>
<!-- Inline SVG favicon (a fuel drop): the build is promoted between
environments, so nothing may resolve against a fixed root path. -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' rx='6' fill='%231473e6'/%3E%3Cpath d='M20 8 C20 8 11 19 11 25 a9 9 0 0 0 18 0 C29 19 20 8 20 8Z' fill='%23ffffff'/%3E%3C/svg%3E" />
<meta name="theme-color" content="#ffffff" />
<script>
(function () {
try {
var t = localStorage.getItem('bunker-desk-theme');
if (t !== 'light' && t !== 'dark') t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', t);
} catch (e) { document.documentElement.setAttribute('data-theme', 'light'); }
})();
</script>
<script src="./config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2419
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "bunker-enquiry-desk",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Bunker Enquiry Desk: WhatsApp bunker enquiries read by AI, Ops input, Trader offer, customer confirmation. Simulates the Adobe enquiry portal for the Adani Bunkering demo.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@fontsource/source-sans-3": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.4",
"vite": "^5.4.6"
}
}

20
src/App.tsx Normal file
View File

@ -0,0 +1,20 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { useZino } from './api/provider';
import { Shell } from './components/Shell';
import { Login } from './screens/Login';
import { Inbox } from './screens/Inbox';
import { EnquiryPage } from './screens/Enquiry';
export function App() {
const { user } = useZino();
if (!user) return <Login />;
return (
<Shell>
<Routes>
<Route path="/" element={<Inbox />} />
<Route path="/enquiries/:id" element={<EnquiryPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
);
}

74
src/api/client.ts Normal file
View File

@ -0,0 +1,74 @@
import type { ActivityResult, ApiError, LoginResponse, RecordViewParams, RecordViewResponse, Row, User } from './types';
import { APP_ID } from './config';
const TOKEN_KEY = 'bunker_desk_token';
/** HTTP client for the Zino gateway. App-scoped routes live under /app/<id>;
* login does not. The JWT persists in localStorage. */
export class ZinoClient {
readonly baseUrl: string;
private token: string | null = null;
private onAuthError?: () => void;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
try { this.token = localStorage.getItem(TOKEN_KEY); } catch { this.token = null; }
}
setAuthErrorHandler(fn: () => void): void { this.onAuthError = fn; }
setToken(token: string | null): void {
this.token = token;
try { if (token) localStorage.setItem(TOKEN_KEY, token); else localStorage.removeItem(TOKEN_KEY); } catch { /* private window */ }
}
getToken(): string | null { return this.token; }
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
const res = await fetch(`${this.baseUrl}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined });
if (res.status === 401) {
this.setToken(null);
this.onAuthError?.();
throw { status: 401, message: 'Session expired. Sign in again.' } as ApiError;
}
if (!res.ok) {
let message = res.statusText;
try { const j = (await res.json()) as { error?: string; message?: string }; message = j.error || j.message || message; } catch { /* non-JSON */ }
throw { status: res.status, message } as ApiError;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
/** org_id must be a STRING or the gateway rejects the body. */
async login(email: string, password: string, orgId: string): Promise<LoginResponse> {
const res = await this.request<LoginResponse>('POST', '/usr/login', { email, password, org_id: String(orgId) });
this.setToken(res.token);
return res;
}
logout(): void { this.setToken(null); }
me(): Promise<User> { return this.request<User>('GET', '/usr/me'); }
currentUser(): User | null {
if (!this.token) return null;
try {
const p = JSON.parse(atob(this.token.split('.')[1]));
return { id: String(p.user_id ?? p.sub ?? ''), org_id: String(p.org_id ?? ''), name: p.name ?? '', email: p.email ?? '', roles: p.roles ?? [], groups: p.groups ?? [] };
} catch { return null; }
}
async page(rvUid: string, p: RecordViewParams = {}): Promise<{ rows: Row[]; total: number }> {
const r = await this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvUid,
params: {},
search_query: {
page: p.page ?? 1, limit: p.limit ?? 100, sort_by: p.sortBy ?? '', sort_dir: p.sortDir ?? 'desc', search: p.search ?? '',
filters: (p.filters ?? []).map((f) => ({ field_key: f.field_key, value: f.value, value2: '', data_type: f.data_type ?? 'string' })),
},
});
const rows = r.data ?? r.records ?? [];
return { rows, total: r.pagination?.total_count ?? rows.length };
}
performActivity(workflowUuid: string, instanceId: number, activityUid: string, data: Record<string, unknown>): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, { workflow_uuid: workflowUuid, instance_id: instanceId, activity_id: activityUid, data });
}
}

56
src/api/config.ts Normal file
View File

@ -0,0 +1,56 @@
// Bunker Enquiry Desk. dev, org 900000130 / app 900000230. Every id is the
// readable uid seeded in sm2/custom-apps/adani-bunker-enquiry/.
export const ORG_ID = '900000130';
export const APP_ID = '900000230';
export const WF = 'abk_wf_enquiry';
export const RV_ENQUIRIES = 'abk-rv-enquiries';
export const ACT = {
OPS_INPUT: 'abk-act-ops-input',
OFFER: 'abk-act-offer',
REVISE: 'abk-act-revise',
REGRET: 'abk-act-regret',
LOST: 'abk-act-lost',
DELIVERED: 'abk-act-delivered',
} as const;
export const STATES = ['Intake', 'Awaiting Ops Input', 'Awaiting Offer', 'Offer Sent', 'Confirmed', 'Delivered', 'Lost'] as const;
export type StateName = (typeof STATES)[number];
export type Tone = 'neutral' | 'accent' | 'notice' | 'positive' | 'negative' | 'ai';
export const STATE_TONE: Record<string, Tone> = {
Intake: 'ai',
'Awaiting Ops Input': 'notice',
'Awaiting Offer': 'notice',
'Offer Sent': 'accent',
Confirmed: 'positive',
Delivered: 'neutral',
Lost: 'negative',
};
export const ROLE_LABEL: Record<string, string> = {
bunker_ops: 'Operations',
bunker_trader: 'Trader',
trading_head: 'Head of Trading',
};
/** The eight integration touch points, in the document's order. */
export const TOUCH_POINTS: Array<{ label: string; keys: string[] }> = [
{ label: 'Vessel', keys: ['vessel_name'] },
{ label: 'IMO', keys: ['imo_number'] },
{ label: 'Port', keys: ['port'] },
{ label: 'ETA or Laycan', keys: ['eta_laycan'] },
{ label: 'Product', keys: ['product'] },
{ label: 'Specification', keys: ['specification'] },
{ label: 'Min & Max Qty', keys: ['min_qty_mt', 'max_qty_mt'] },
{ label: 'Buyer / Account', keys: ['buyer_account'] },
];
export const DEMO_USERS = [
{ email: 'ops.mundra@bunker-demo.example', name: 'Rakesh Patel', role: 'Operations, Mundra' },
{ email: 'ops.hazira@bunker-demo.example', name: 'Sneha Iyer', role: 'Operations, Hazira' },
{ email: 'trader.arjun@bunker-demo.example', name: 'Arjun Mehta', role: 'Trader' },
{ email: 'trader.kavita@bunker-demo.example', name: 'Kavita Rao', role: 'Trader' },
{ email: 'head.trading@bunker-demo.example', name: 'Vikram Shah', role: 'Head of Trading' },
];

76
src/api/provider.tsx Normal file
View File

@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { ZinoClient } from './client';
import type { User } from './types';
interface ZinoContextValue {
client: ZinoClient;
user: User | null;
login: (email: string, password: string, orgId: string) => Promise<void>;
logout: () => void;
has: (...roles: string[]) => boolean;
}
const ZinoContext = createContext<ZinoContextValue | null>(null);
export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
const ref = useRef<ZinoClient>();
if (!ref.current) ref.current = new ZinoClient(baseUrl);
const client = ref.current;
const [user, setUser] = useState<User | null>(() => client.currentUser());
useEffect(() => { client.setAuthErrorHandler(() => setUser(null)); }, [client]);
// The JWT carries no roles: a restored session asks the server for the real user.
useEffect(() => {
if (!client.getToken() || (user?.roles?.length ?? 0) > 0) return;
let live = true;
client.me().then((u) => { if (live) setUser(u); }).catch(() => { /* token identity still stands */ });
return () => { live = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, user?.id, user?.roles?.length]);
const value = useMemo<ZinoContextValue>(() => ({
client, user,
login: async (email, password, orgId) => {
const res = await client.login(email, password, orgId);
const u = res.user ?? client.currentUser();
setUser(u);
if (!u?.roles?.length) client.me().then(setUser).catch(() => undefined);
},
logout: () => { client.logout(); setUser(null); },
has: (...roles) => (user?.roles ?? []).some((r) => roles.includes(r)),
}), [client, user]);
return <ZinoContext.Provider value={value}>{children}</ZinoContext.Provider>;
}
export function useZino(): ZinoContextValue {
const ctx = useContext(ZinoContext);
if (!ctx) throw new Error('useZino must be used within <ZinoProvider>');
return ctx;
}
export interface QueryState<T> { data: T | null; loading: boolean; error: string | null; refetch: () => void; }
/** Fire `fn` when deps change, and poll: WhatsApp messages and the AI move enquiries on their own. */
export function useQuery<T>(fn: () => Promise<T>, deps: unknown[], refetchMs?: number): QueryState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const refetch = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
let live = true;
if (data === null) setLoading(true);
fn().then((d) => { if (live) { setData(d); setError(null); } })
.catch((e: unknown) => { if (live) setError((e as { message?: string })?.message ?? 'Request failed'); })
.finally(() => { if (live) setLoading(false); });
return () => { live = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, tick]);
useEffect(() => {
if (!refetchMs) return;
const id = setInterval(() => setTick((t) => t + 1), refetchMs);
return () => clearInterval(id);
}, [refetchMs]);
return { data, loading, error, refetch };
}

54
src/api/types.ts Normal file
View File

@ -0,0 +1,54 @@
export interface ApiError { status: number; message: string; }
export interface User { id: string; org_id: string; name: string; email: string; roles: string[]; groups: string[]; }
export interface LoginResponse { token: string; user?: User; }
export type Row = Record<string, unknown>;
export interface RecordViewResponse {
data?: Row[];
records?: Row[];
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
}
export interface RecordViewParams {
page?: number; limit?: number; sortBy?: string; sortDir?: 'asc' | 'desc'; search?: string;
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
}
export interface ActivityResult { success?: boolean; message?: string; instance_id?: number; status_code?: number; data?: Record<string, unknown>; }
/** One enquiry row from the record view: every global + the state. */
export interface Enquiry {
instance_id: number;
current_state_name: string;
enquiry_ref: string | null;
channel: string | null;
customer_contact: string | null;
last_message: string | null;
conversation_log: string | null;
intake_status: string | null;
touch_points_found: number | null;
missing_fields: string | null;
ai_summary: string | null;
vessel_name: string | null;
imo_number: string | null;
port: string | null;
eta_laycan: string | null;
product: string | null;
specification: string | null;
min_qty_mt: number | null;
max_qty_mt: number | null;
buyer_account: string | null;
stock_status: string | null;
supply_mode: string | null;
delivery_slot: string | null;
supply_qty_mt: number | null;
ops_remarks: string | null;
price_usd_mt: number | null;
price_basis: string | null;
offer_valid_till: string | null;
payment_terms: string | null;
offer_remarks: string | null;
offer_message: string | null;
customer_decision: string | null;
confirmed_at: string | null;
lost_reason: string | null;
bdn_number: string | null;
delivery_note: string | null;
}

53
src/components/Shell.tsx Normal file
View File

@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { Droplet, Inbox, LogOut, Moon, Sun } from 'lucide-react';
import { useZino } from '../api/provider';
import { ROLE_LABEL } from '../api/config';
function ThemeToggle() {
const [theme, setTheme] = useState(() => document.documentElement.getAttribute('data-theme') ?? 'light');
const flip = () => {
const t = theme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('bunker-desk-theme', t); } catch { /* private window */ }
setTheme(t);
};
return (
<button onClick={flip} className="rounded p-1.5 text-muted hover:bg-surface-2" aria-label="Toggle dark mode">
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
);
}
export function Shell({ children }: { children: ReactNode }) {
const { user, logout } = useZino();
const role = (user?.roles ?? []).map((r) => ROLE_LABEL[r] ?? r).join(', ');
return (
<div className="min-h-screen">
<header className="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-topbar px-4">
<span className="flex h-8 w-8 items-center justify-center rounded bg-accent text-white"><Droplet size={18} /></span>
<div className="leading-tight">
<div className="font-bold">Bunker Enquiry Desk</div>
<div className="text-xs text-muted">Receipt of enquiries · WhatsApp and email</div>
</div>
<div className="ml-auto flex items-center gap-3">
<div className="hidden text-right leading-tight sm:block">
<div className="text-sm font-semibold">{user?.name}</div>
<div className="text-xs text-muted">{role}</div>
</div>
<ThemeToggle />
<button onClick={logout} className="rounded p-1.5 text-muted hover:bg-surface-2" aria-label="Sign out"><LogOut size={18} /></button>
</div>
</header>
<div className="flex">
<nav className="sticky top-14 hidden h-[calc(100vh-3.5rem)] w-52 shrink-0 border-r border-line bg-surface p-3 md:block">
<NavLink to="/" end className={({ isActive }) => `flex items-center gap-2 rounded px-3 py-2 text-sm font-semibold ${isActive ? 'bg-accent-soft text-accent' : 'hover:bg-surface-2'}`}>
<Inbox size={16} /> Enquiries
</NavLink>
</nav>
<main className="min-w-0 flex-1 px-4 py-5 md:px-6">{children}</main>
</div>
</div>
);
}

72
src/components/ui.tsx Normal file
View File

@ -0,0 +1,72 @@
import type { ReactNode } from 'react';
import type { Tone } from '../api/config';
import { STATE_TONE } from '../api/config';
const TONE: Record<Tone, string> = {
neutral: 'bg-surface-2 text-muted border-line',
accent: 'bg-accent-soft text-accent border-transparent',
notice: 'bg-notice-soft text-notice border-transparent',
positive: 'bg-positive-soft text-positive border-transparent',
negative: 'bg-negative-soft text-negative border-transparent',
ai: 'bg-ai-soft text-ai border-transparent',
};
export function Badge({ tone = 'neutral', children }: { tone?: Tone; children: ReactNode }) {
return <span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap ${TONE[tone]}`}>{children}</span>;
}
export function StateBadge({ state }: { state: string }) {
return <Badge tone={STATE_TONE[state] ?? 'neutral'}>{state}</Badge>;
}
export function Card({ title, action, children, className = '' }: { title?: ReactNode; action?: ReactNode; children: ReactNode; className?: string }) {
return (
<section className={`card ${className}`}>
{title && (
<header className="flex items-center justify-between gap-3 border-b border-line px-4 py-2.5">
<h2 className="text-[15px] font-bold">{title}</h2>
{action}
</header>
)}
<div className="p-4">{children}</div>
</section>
);
}
export function KV({ label, value, mono = false }: { label: string; value: ReactNode; mono?: boolean }) {
const empty = value === null || value === undefined || value === '';
return (
<div className="min-w-0">
<dt className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</dt>
<dd className={`mt-0.5 break-words ${mono ? 'tabular-nums' : ''} ${empty ? 'text-muted' : ''}`}>{empty ? 'Not given' : value}</dd>
</div>
);
}
export function Empty({ children }: { children: ReactNode }) {
return <p className="py-8 text-center text-muted">{children}</p>;
}
export function ErrorNote({ children }: { children: ReactNode }) {
return <p className="rounded border border-negative bg-negative-soft px-3 py-2 text-sm text-negative">{children}</p>;
}
export function qtyText(min: unknown, max: unknown): string {
const a = Number(min), b = Number(max);
if (!a && !b) return '';
if (!b || a === b) return `${a || b} MT`;
if (!a) return `${b} MT`;
return `${a}–${b} MT`;
}
export function usd(v: unknown): string {
const n = Number(v);
return v === null || v === undefined || v === '' || isNaN(n) ? '' : `USD ${n.toFixed(2)} / MT`;
}
export function timeAgo(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}

24
src/main.tsx Normal file
View File

@ -0,0 +1,24 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { ZinoProvider } from './api/provider';
import { basePath, requireConfigValue } from './runtimeConfig';
import '@fontsource/source-sans-3/400.css';
import '@fontsource/source-sans-3/600.css';
import '@fontsource/source-sans-3/700.css';
import './styles.css';
// The API base URL is read at RUNTIME from config.js (written by frontgen at
// placement); requireConfigValue throws so a build with no config fails loudly.
const baseUrl = requireConfigValue('VITE_ZINO_API_URL');
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename={basePath()}>
<ZinoProvider baseUrl={baseUrl}>
<App />
</ZinoProvider>
</BrowserRouter>
</StrictMode>,
);

33
src/runtimeConfig.ts Normal file
View File

@ -0,0 +1,33 @@
declare global {
interface Window {
__RUNTIME_CONFIG__?: Record<string, string>;
}
}
/**
* Runtime configuration, written by the server as `config.js` when it places a
* build — never baked in. One build is promoted between environments unchanged,
* so an API URL compiled at build time would be the source environment's URL
* everywhere it lands. SAME_ORIGIN means "use the current origin".
*/
export function resolveConfigValue(key: string, fallback = ''): string {
const runtime = (window.__RUNTIME_CONFIG__ || {})[key];
const devFallback = import.meta.env.DEV ? ((import.meta.env as Record<string, string>)[key] ?? '') : '';
const value = runtime || devFallback || fallback;
return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value;
}
export function requireConfigValue(key: string): string {
const value = resolveConfigValue(key);
if (!value) {
throw new Error(
'Missing runtime config "' + key + '". The server writes config.js when it places the build; for local dev set ' + key + ' in .env.',
);
}
return value;
}
/** The mount path, from the <base href> the server writes into index.html. */
export function basePath(): string {
return new URL(document.baseURI).pathname;
}

313
src/screens/Enquiry.tsx Normal file
View File

@ -0,0 +1,313 @@
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 { useQuery, useZino } from '../api/provider';
import { ACT, RV_ENQUIRIES, TOUCH_POINTS, WF } from '../api/config';
import type { Enquiry } from '../api/types';
import { Badge, Card, Empty, ErrorNote, KV, StateBadge, qtyText, timeAgo, usd } from '../components/ui';
const MODE_LABEL: Record<string, string> = { barge: 'Barge', pipeline: 'Pipeline (ex-wharf)', truck: 'Truck' };
const STOCK_LABEL: Record<string, string> = { available: 'Available', partial: 'Partially available', not_available: 'Not available' };
export function EnquiryPage() {
const { id } = useParams();
const { client } = useZino();
const q = useQuery(async () => {
const r = await client.page(RV_ENQUIRIES, { limit: 1, filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }] });
return ((r.rows.find((x) => String(x.instance_id) === String(id)) ?? null) as unknown) as Enquiry | null;
}, [id], 4000);
const e = q.data;
if (q.error && !e) return <ErrorNote>{q.error}</ErrorNote>;
if (!e) return <Empty>{q.loading ? 'Loading…' : 'Enquiry not found.'}</Empty>;
return (
<div className="mx-auto max-w-7xl space-y-4">
<Link to="/" className="inline-flex items-center gap-1 text-sm font-semibold text-accent"><ArrowLeft size={16} /> Enquiries</Link>
<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>}
</div>
{e.ai_summary && <p className="text-muted">{e.ai_summary}</p>}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_420px]">
<div className="space-y-4">
<Actions e={e} onDone={q.refetch} />
<TouchPoints e={e} />
<OpsCard e={e} />
<OfferCard e={e} />
<Outcome e={e} />
</div>
<Conversation log={e.conversation_log} />
</div>
</div>
);
}
function TouchPoints({ e }: { e: Enquiry }) {
const row = e as unknown as Record<string, unknown>;
const found = TOUCH_POINTS.filter((t) => t.keys.some((k) => row[k] !== null && row[k] !== undefined && row[k] !== ''));
const value = (keys: string[]) => (keys[0] === 'min_qty_mt' ? qtyText(e.min_qty_mt, e.max_qty_mt) : (row[keys[0]] as ReactNode));
return (
<Card title={<span className="inline-flex items-center gap-2">Enquiry details <Badge tone="ai"><Sparkles size={12} className="mr-1" />read from the customer's messages</Badge></span>}
action={<span className={`text-sm font-semibold ${found.length >= 3 ? 'text-positive' : 'text-notice'}`}>{found.length}/8 touch points</span>}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
{TOUCH_POINTS.map((t) => {
const v = value(t.keys);
const ok = v !== null && v !== undefined && v !== '';
return (
<div key={t.label} className="flex gap-2">
<span className={`mt-0.5 ${ok ? 'text-positive' : 'text-line-strong'}`}>{ok ? <Check size={16} /> : <X size={16} />}</span>
<KV label={t.label} value={v} />
</div>
);
})}
</dl>
</Card>
);
}
function OpsCard({ e }: { e: Enquiry }) {
if (!e.stock_status) return null;
return (
<Card title="Operations input">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<KV label="Stock" value={STOCK_LABEL[e.stock_status] ?? e.stock_status} />
<KV label="Delivery mode" value={MODE_LABEL[e.supply_mode ?? ''] ?? e.supply_mode} />
<KV label="Delivery slot" value={e.delivery_slot} />
<KV label="Can supply" value={e.supply_qty_mt ? `${e.supply_qty_mt} MT` : ''} mono />
</dl>
{e.ops_remarks && <p className="mt-3 text-sm">{e.ops_remarks}</p>}
</Card>
);
}
function OfferCard({ e }: { e: Enquiry }) {
if (!e.offer_message) return null;
return (
<Card title="Offer" action={<span className="font-bold tabular-nums">{usd(e.price_usd_mt)}</span>}>
<dl className="mb-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
<KV label="Price basis" value={e.price_basis} />
<KV label="Valid till" value={e.offer_valid_till} />
<KV label="Payment" value={e.payment_terms} />
</dl>
<details>
<summary className="cursor-pointer text-sm font-semibold text-accent">Message as sent to the customer</summary>
<pre className="mt-2 whitespace-pre-wrap rounded bg-surface-2 p-3 font-sans text-sm">{e.offer_message}</pre>
</details>
</Card>
);
}
function Outcome({ e }: { e: Enquiry }) {
if (e.current_state_name === 'Confirmed' || e.current_state_name === 'Delivered')
return (
<Card title="Stem">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<KV label="Confirmed by customer" value={timeAgo(e.confirmed_at)} />
<KV label="Accepted delivery date" value={e.eta_laycan} />
<KV label="BDN" value={e.bdn_number} />
<KV label="Delivery note" value={e.delivery_note} />
</dl>
<p className="mt-3 text-xs text-muted">From confirmation the sale is binding: a cancellation or short lift by the buyer is charged under the General Terms and Conditions for Sale of Bunker.</p>
</Card>
);
if (e.current_state_name === 'Lost') return <Card title="Closed as lost"><p>{e.lost_reason ?? 'No reason recorded.'}</p></Card>;
return null;
}
/** conversation_log is "Customer: …" / "Desk: …" entries separated by blank lines. */
function Conversation({ log }: { log: string | null }) {
const entries = (log ?? '').split(/\n\n(?=(?:Customer|Desk)[^:\n]{0,24}: )/).filter(Boolean).map((chunk) => {
const m = chunk.match(/^(Customer|Desk)([^:\n]{0,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">
<div className="max-h-[70vh] space-y-3 overflow-y-auto">
{entries.length === 0 && <Empty>No messages yet.</Empty>}
{entries.map((m, i) => (
<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="mb-0.5 text-xs font-semibold text-muted">{m.who === 'Customer' ? 'Customer' : m.tag ? `Desk · ${m.tag}` : 'Desk'}</div>
<div className="whitespace-pre-wrap">{m.text}</div>
</div>
</div>
))}
</div>
</Card>
);
}
// ---------------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------------
function Actions({ e, onDone }: { e: Enquiry; onDone: () => void }) {
const { has } = useZino();
const s = e.current_state_name;
const ops = has('bunker_ops', 'trading_head');
const trader = has('bunker_trader', 'trading_head');
if (s === 'Intake') return <Card><p className="text-sm text-muted">The desk is still collecting details from the customer on WhatsApp. The enquiry moves to Ops on its own once three touch points are known.</p></Card>;
if (s === 'Awaiting Ops Input' && ops) return <OpsForm e={e} onDone={onDone} />;
if (s === 'Awaiting Offer' && trader) return <OfferForm e={e} onDone={onDone} revise={false} />;
if (s === 'Offer Sent' && trader) return <OfferSent e={e} onDone={onDone} />;
if (s === 'Confirmed' && ops) return <DeliveredForm e={e} onDone={onDone} />;
if (s === 'Awaiting Ops Input') return <Waiting who="Operations" />;
if (s === 'Awaiting Offer') return <Waiting who="the trader" />;
if (s === 'Offer Sent') return <Waiting who="the customer" />;
return null;
}
function Waiting({ who }: { who: string }) {
return <Card><p className="text-sm text-muted">Waiting on {who}.</p></Card>;
}
function useSubmit(e: Enquiry, onDone: () => void) {
const { client } = useZino();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
const submit = async (activity: string, data: Record<string, unknown>) => {
setBusy(true); setError(null); setOk(null);
try {
const r = await client.performActivity(WF, e.instance_id, activity, data);
if (r.success === false) throw { message: r.message ?? 'The action was refused' };
setOk(r.message ?? 'Done');
onDone();
} catch (err) { setError((err as { message?: string })?.message ?? 'The action failed'); }
finally { setBusy(false); }
};
return { busy, error, ok, submit };
}
function Field({ label, required, children }: { label: string; required?: boolean; children: ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm font-semibold">{label}{required && <span className="text-negative"> *</span>}</span>
{children}
</label>
);
}
function OpsForm({ e, onDone }: { e: Enquiry; onDone: () => void }) {
const { busy, error, ok, submit } = useSubmit(e, onDone);
const [f, setF] = useState({ stock: 'available', mode: 'barge', slot: '', qty: String(e.max_qty_mt ?? ''), 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)} />;
return (
<Card title="Record operations input" action={<Badge tone="notice">Your action</Badge>}>
<form className="grid gap-3 sm:grid-cols-2" onSubmit={(ev) => { ev.preventDefault(); submit(ACT.OPS_INPUT, {
stock_status__ops: f.stock, supply_mode__ops: f.mode, delivery_slot__ops: f.slot,
supply_qty_mt__ops: f.qty === '' ? null : Number(f.qty), ops_remarks__ops: f.remarks,
}); }}>
<Field label="Stock" required>
<select className="field-input" value={f.stock} onChange={(x) => setF({ ...f, stock: x.target.value })}>
<option value="available">Available</option><option value="partial">Partially available</option><option value="not_available">Not available</option>
</select>
</Field>
<Field label="Delivery mode" required>
<select className="field-input" value={f.mode} onChange={(x) => setF({ ...f, mode: x.target.value })}>
<option value="barge">Barge</option><option value="pipeline">Pipeline (ex-wharf)</option><option value="truck">Truck</option>
</select>
</Field>
<Field label="Delivery slot" required><input className="field-input" required placeholder="12 Nov, 06:00-12:00 IST" value={f.slot} onChange={(x) => setF({ ...f, slot: x.target.value })} /></Field>
<Field label="Quantity Ops can supply (MT)"><input className="field-input" type="number" min="0" value={f.qty} onChange={(x) => setF({ ...f, qty: x.target.value })} /></Field>
<div className="sm:col-span-2"><Field label="Remarks"><textarea className="field-input" rows={2} placeholder="Barge allotted, berth constraints, anything the trader should know" value={f.remarks} onChange={(x) => setF({ ...f, remarks: x.target.value })} /></Field></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>}
<div className="flex flex-wrap gap-2 sm:col-span-2">
<button className="btn btn-cta" disabled={busy}>{busy ? 'Saving…' : 'Send to trader'}</button>
<button type="button" className="btn btn-negative" onClick={() => setRegret(true)}>Regret</button>
</div>
</form>
</Card>
);
}
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: e.offer_valid_till ?? '', 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';
return (
<Card title={revise ? 'Revise the offer' : 'Price and send the offer'} action={<Badge tone="notice">Your action</Badge>}>
<form className="grid gap-3 sm:grid-cols-2" onSubmit={(ev) => { ev.preventDefault();
const data: Record<string, unknown> = { [`price_usd_mt__${sfx}`]: Number(f.price), [`offer_valid_till__${sfx}`]: f.valid, [`offer_remarks__${sfx}`]: f.remarks };
if (!revise) { data.price_basis__offer = f.basis; data.payment_terms__offer = f.terms; }
submit(revise ? ACT.REVISE : ACT.OFFER, data); }}>
<Field label="Price (USD per MT)" required><input className="field-input" type="number" step="0.01" min="0" required value={f.price} onChange={(x) => setF({ ...f, price: x.target.value })} /></Field>
<Field label="Offer valid till" required><input className="field-input" required placeholder="26 Oct, 18:00 IST" value={f.valid} onChange={(x) => setF({ ...f, valid: x.target.value })} /></Field>
{!revise && <Field label="Price basis"><input className="field-input" value={f.basis} onChange={(x) => setF({ ...f, basis: x.target.value })} /></Field>}
{!revise && <Field label="Payment terms"><input className="field-input" value={f.terms} onChange={(x) => setF({ ...f, terms: x.target.value })} /></Field>}
<div className="sm:col-span-2"><Field label="Remarks for the customer"><textarea className="field-input" rows={2} value={f.remarks} onChange={(x) => setF({ ...f, remarks: x.target.value })} /></Field></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>}
<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>
{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>
</form>
</Card>
);
}
function OfferSent({ e, onDone }: { e: Enquiry; onDone: () => void }) {
const [mode, setMode] = useState<'none' | 'revise' | 'lost'>('none');
if (mode === 'revise') return <OfferForm e={e} onDone={() => { setMode('none'); onDone(); }} revise onCancel={() => setMode('none')} />;
if (mode === 'lost') return <ReasonForm e={e} onDone={onDone} activity={ACT.LOST} field="lost_reason__lost" title="Mark the enquiry lost" cta="Close and inform customer" onCancel={() => setMode('none')} />;
const replied = e.customer_decision === 'query';
return (
<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.'}
</p>
<div className="flex flex-wrap gap-2">
<button className="btn btn-cta" onClick={() => setMode('revise')}>Revise offer</button>
<button className="btn btn-negative" onClick={() => setMode('lost')}>Mark lost</button>
</div>
</Card>
);
}
function DeliveredForm({ e, onDone }: { e: Enquiry; onDone: () => void }) {
const { busy, error, ok, submit } = useSubmit(e, onDone);
const [bdn, setBdn] = useState('');
const [note, setNote] = useState('');
return (
<Card title="Record the delivery" action={<Badge tone="positive">Stem booked</Badge>}>
<form className="grid gap-3 sm:grid-cols-2" onSubmit={(ev) => { ev.preventDefault(); submit(ACT.DELIVERED, { bdn_number__deliv: bdn, delivery_note__deliv: note }); }}>
<Field label="BDN number" required><input className="field-input" required value={bdn} onChange={(x) => setBdn(x.target.value)} /></Field>
<Field label="Delivery note"><input className="field-input" placeholder="Delivered 545 MT, barge Adani Shakti" value={note} onChange={(x) => setNote(x.target.value)} /></Field>
{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="sm:col-span-2"><button className="btn btn-cta" disabled={busy}>{busy ? 'Saving…' : 'Mark delivered'}</button></div>
</form>
</Card>
);
}
function ReasonForm({ e, onDone, activity, field, title, cta, onCancel }:
{ e: Enquiry; onDone: () => void; activity: string; field: string; title: string; cta: string; onCancel: () => void }) {
const { busy, error, submit } = useSubmit(e, onDone);
const [reason, setReason] = useState('');
return (
<Card title={title}>
<form className="space-y-3" onSubmit={(ev) => { ev.preventDefault(); submit(activity, { [field]: reason }); }}>
<Field label="Internal reason" required><textarea className="field-input" rows={2} required value={reason} onChange={(x) => setReason(x.target.value)} /></Field>
<p className="text-xs text-muted">The customer receives a standard closing message. This reason stays internal.</p>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex gap-2">
<button className="btn btn-negative" disabled={busy}>{busy ? 'Closing…' : cta}</button>
<button type="button" className="btn btn-secondary" onClick={onCancel}>Cancel</button>
</div>
</form>
</Card>
);
}

101
src/screens/Inbox.tsx Normal file
View File

@ -0,0 +1,101 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import { useQuery, useZino } from '../api/provider';
import { RV_ENQUIRIES, STATES } from '../api/config';
import type { Enquiry } from '../api/types';
import { Badge, Empty, ErrorNote, StateBadge, qtyText } from '../components/ui';
/** Which enquiries are waiting on THIS user. */
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.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;
}
export function Inbox() {
const { client, has } = useZino();
const nav = useNavigate();
const [state, setState] = useState<string>('Mine');
const [q, setQ] = useState('');
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[];
const counts = useMemo(() => {
const c: Record<string, number> = { Mine: 0, All: rows.length };
for (const s of STATES) c[s] = 0;
for (const r of rows) { c[r.current_state_name] = (c[r.current_state_name] ?? 0) + 1; if (needsMe(r, has)) c.Mine++; }
return c;
}, [rows, has]);
const shown = rows.filter((r) => {
if (state === 'Mine' && !needsMe(r, has)) return false;
if (state !== 'Mine' && state !== 'All' && r.current_state_name !== state) return false;
if (!q.trim()) return true;
const hay = [r.enquiry_ref, r.vessel_name, r.imo_number, r.port, r.product, r.buyer_account, r.customer_contact].join(' ').toLowerCase();
return hay.includes(q.trim().toLowerCase());
});
const tabs = ['Mine', 'All', ...STATES];
return (
<div className="mx-auto max-w-7xl space-y-4">
<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>
</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" />
<input className="field-input pl-8" placeholder="Search ref, vessel, IMO, buyer" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
</div>
<div className="flex gap-1 overflow-x-auto border-b border-line">
{tabs.map((t) => (
<button key={t} onClick={() => setState(t)}
className={`-mb-px flex shrink-0 items-center gap-2 border-b-2 px-3 py-2 text-sm font-semibold ${state === t ? 'border-accent text-ink' : 'border-transparent text-muted hover:text-ink'}`}>
{t === 'Mine' ? 'Needs my action' : t}
<span className={`rounded-full px-1.5 text-xs ${state === t ? 'bg-accent text-white' : 'bg-surface-2'}`}>{counts[t] ?? 0}</span>
</button>
))}
</div>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="card overflow-x-auto">
<table className="w-full min-w-[900px] text-sm">
<thead>
<tr className="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-muted">
<th className="px-3 py-2">Reference</th><th className="px-3 py-2">Vessel</th><th className="px-3 py-2">Port</th>
<th className="px-3 py-2">ETA / Laycan</th><th className="px-3 py-2">Product</th><th className="px-3 py-2">Quantity</th>
<th className="px-3 py-2">Buyer</th><th className="px-3 py-2">Touch points</th><th className="px-3 py-2">Status</th>
</tr>
</thead>
<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">{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>
<td className="px-3 py-2.5">{r.product ?? '-'}</td>
<td className="px-3 py-2.5 tabular-nums">{qtyText(r.min_qty_mt, r.max_qty_mt) || '-'}</td>
<td className="px-3 py-2.5">{r.buyer_account ?? '-'}</td>
<td className="px-3 py-2.5 tabular-nums">{r.touch_points_found ?? 0}/8</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<StateBadge state={r.current_state_name} />
{r.current_state_name === 'Offer Sent' && r.customer_decision === 'query' && <Badge tone="notice">Customer replied</Badge>}
</div>
</td>
</tr>
))}
</tbody>
</table>
{!loading && shown.length === 0 && <Empty>{state === 'Mine' ? 'Nothing is waiting on you.' : 'No enquiries here.'}</Empty>}
{loading && rows.length === 0 && <Empty>Loading…</Empty>}
</div>
</div>
);
}

59
src/screens/Login.tsx Normal file
View File

@ -0,0 +1,59 @@
import { useState } from 'react';
import { Droplet } from 'lucide-react';
import { useZino } from '../api/provider';
import { DEMO_USERS, ORG_ID } from '../api/config';
import { ErrorNote } from '../components/ui';
export function Login() {
const { login } = useZino();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true); setError(null);
try { await login(email.trim(), password, ORG_ID); }
catch (err) { setError((err as { message?: string })?.message ?? 'Sign-in failed'); }
finally { setBusy(false); }
};
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="mb-6 flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded bg-accent text-white"><Droplet size={22} /></span>
<div>
<h1 className="text-xl font-bold">Bunker Enquiry Desk</h1>
<p className="text-sm text-muted">Operations and trading sign-in</p>
</div>
</div>
<form onSubmit={submit} className="card space-y-4 p-6">
<label className="block">
<span className="mb-1 block text-sm font-semibold">Email</span>
<input className="field-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" required />
</label>
<label className="block">
<span className="mb-1 block text-sm font-semibold">Password</span>
<input className="field-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
</label>
{error && <ErrorNote>{error}</ErrorNote>}
<button className="btn btn-cta w-full justify-center" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
</form>
<div className="card mt-4 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Demo users</p>
<ul className="space-y-1">
{DEMO_USERS.map((u) => (
<li key={u.email}>
<button type="button" onClick={() => setEmail(u.email)} className="flex w-full items-center justify-between rounded px-2 py-1 text-left text-sm hover:bg-surface-2">
<span className="font-semibold">{u.name}</span><span className="text-muted">{u.role}</span>
</button>
</li>
))}
</ul>
</div>
</div>
</div>
);
}

95
src/styles.css Normal file
View File

@ -0,0 +1,95 @@
@import "tailwindcss";
/* Spectrum-style tokens. Every colour in a component is one of these; dark mode
redefines them. Blue = action; green / orange / red = status; violet = what
the AI read (never used for anything a person decided). */
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-2: #fafafa;
--border: #e1e1e1;
--border-strong: #cacaca;
--text: #2c2c2c;
--muted: #6e6e6e;
--accent: #1473e6;
--accent-hover: #0d66d0;
--accent-soft: #e5f0fd;
--positive: #268e6c;
--positive-soft: #e3f5ee;
--notice: #cb6f10;
--notice-soft: #fdf0e2;
--negative: #d7373f;
--negative-soft: #fbe8e9;
--ai: #7e4ccb;
--ai-soft: #f1eafb;
--bubble-in: #ffffff;
--bubble-out: #e5f0fd;
--topbar: #ffffff;
--shadow: 0 1px 3px rgb(0 0 0 / 0.08);
}
:root[data-theme="dark"] {
--bg: #1d1d1d;
--surface: #2c2c2c;
--surface-2: #323232;
--border: #3e3e3e;
--border-strong: #555555;
--text: #e8e8e8;
--muted: #a3a3a3;
--accent: #4b9cf5;
--accent-hover: #6aaef7;
--accent-soft: #1d3554;
--positive: #3db58f;
--positive-soft: #1b3a30;
--notice: #f29423;
--notice-soft: #43301a;
--negative: #f0565d;
--negative-soft: #4a2427;
--ai: #a77ef0;
--ai-soft: #352a4a;
--bubble-in: #383838;
--bubble-out: #1d3554;
--topbar: #252525;
--shadow: 0 1px 3px rgb(0 0 0 / 0.4);
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-line: var(--border);
--color-line-strong: var(--border-strong);
--color-ink: var(--text);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
--color-positive: var(--positive);
--color-positive-soft: var(--positive-soft);
--color-notice: var(--notice);
--color-notice-soft: var(--notice-soft);
--color-negative: var(--negative);
--color-negative-soft: var(--negative-soft);
--color-ai: var(--ai);
--color-ai-soft: var(--ai-soft);
--color-bubble-in: var(--bubble-in);
--color-bubble-out: var(--bubble-out);
--color-topbar: var(--topbar);
--font-sans: "Source Sans 3", "Segoe UI", system-ui, sans-serif;
}
html, body { background: var(--bg); color: var(--text); }
body { font-family: var(--font-sans); font-size: 15px; -webkit-font-smoothing: antialiased; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; box-shadow: var(--shadow); }
.field-input {
width: 100%; background: var(--surface); color: var(--text); border: 1px solid var(--border-strong);
border-radius: 4px; padding: 6px 10px; font-size: 14px; outline: none;
}
.field-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.btn { display: inline-flex; align-items: center; gap: 6px; border-radius: 16px; padding: 5px 16px; font-weight: 600; font-size: 14px; border: 2px solid transparent; cursor: pointer; transition: background .12s; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-cta { background: var(--accent); color: #fff; }
.btn-cta:hover:not(:disabled) { background: var(--accent-hover); }
.btn-secondary { background: transparent; color: var(--text); border-color: var(--border-strong); }
.btn-secondary:hover:not(:disabled) { background: var(--surface-2); }
.btn-negative { background: transparent; color: var(--negative); border-color: var(--negative); }
.btn-negative:hover:not(:disabled) { background: var(--negative-soft); }

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.app.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

4
tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

14
tsconfig.node.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// Relative base: asset refs become "./assets/…", so the <base href> the server
// writes at placement decides where they resolve. One build serves any mount
// path. Do NOT reintroduce a build-time base — the artifact is promoted between
// environments unchanged.
export default defineConfig({
plugins: [react(), tailwindcss()],
base: './',
server: { port: 5176 },
});