updatess on the ui changs

This commit is contained in:
2026-08-18 12:46:50 +05:30
parent 8d154f06d9
commit e420223622
24 changed files with 6640 additions and 1797 deletions

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import dayjs from 'dayjs';
import { MdSmartToy } from 'react-icons/md';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import { Center } from '@astryxdesign/core/Center';
import {
ChatLayout,
ChatMessageList,
ChatMessage,
ChatMessageBubble,
ChatMessageMetadata,
ChatComposer,
ChatToolCalls
} from '@astryxdesign/core/Chat';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import { OpenToast } from 'components/third-party/OpenToast';
import { DT } from 'themes/dt/tokens';
import { answerQuestion, SUPPORTED_QUESTIONS } from './intents';
// ==============================|| Doormile Bot ||============================== //
//
// Deterministic Q&A over live order/rider data — see intents.js for the
// intent catalog and the plan this was built from (no LLM: every answer
// comes from a real API call, never a generated guess). This file owns only
// the chat UI + message state; all data logic lives in intents.js.
let nextId = 1;
const makeId = () => nextId++;
const now = () => dayjs().format('hh:mm A');
export default function Assistant() {
const [messages, setMessages] = useState([]);
const [value, setValue] = useState('');
const [isSending, setIsSending] = useState(false);
const handleSubmit = async (text) => {
const question = text.trim();
if (!question || isSending) return;
setValue('');
setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
setIsSending(true);
try {
const result = await answerQuestion(question);
if (result) {
setMessages((prev) => [
...prev,
{
id: makeId(),
sender: 'assistant',
text: result.headline,
detail: result.detail,
sourceCalls: result.sourceCalls,
timestamp: now()
}
]);
} else {
setMessages((prev) => [
...prev,
{
id: makeId(),
sender: 'assistant',
text: "I can't answer that yet.",
detail: `Right now I can answer:\n${SUPPORTED_QUESTIONS.map((q) => `• ${q}`).join('\n')}`,
timestamp: now()
}
]);
}
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to fetch an answer', 'error', 2500);
setMessages((prev) => [
...prev,
{
id: makeId(),
sender: 'assistant',
text: 'Something went wrong fetching that — try again in a moment.',
timestamp: now()
}
]);
} finally {
setIsSending(false);
}
};
return (
<PageShell fill>
<PageHeader title="Assistant" subtitle="Live · Ask Doormile" live />
<VStack gap={0} padding={0} style={{ flex: 1, minHeight: 0 }}>
<ChatLayout
density="balanced"
composer={
<ChatComposer
value={value}
onChange={setValue}
onSubmit={handleSubmit}
isDisabled={isSending}
placeholder="Ask about today's orders, batches, riders…"
/>
}
emptyState={
<Center style={{ height: '100%' }}>
<VStack gap={2} padding={4} vAlign="center" style={{ textAlign: 'center', maxWidth: 420 }}>
<MdSmartToy size={32} color={DT.textSecondary} />
<Text weight="bold">Ask Doormile</Text>
<Text type="supporting" color="secondary">
I answer from live data only — no guessing. Try:
</Text>
<VStack gap={0.5} padding={0}>
{SUPPORTED_QUESTIONS.map((q) => (
<Text key={q} type="supporting" color="secondary">
• {q}
</Text>
))}
</VStack>
</VStack>
</Center>
}
>
<ChatMessageList isStreaming={isSending}>
{messages.map((m) => (
<ChatMessage key={m.id} sender={m.sender}>
<ChatMessageBubble metadata={<ChatMessageMetadata timestamp={m.timestamp} />}>
<VStack gap={1} padding={0}>
<Text>{m.text}</Text>
{m.detail && (
<Text type="supporting" color="secondary" style={{ whiteSpace: 'pre-line' }}>
{m.detail}
</Text>
)}
</VStack>
</ChatMessageBubble>
{/* Audit trail — shows exactly what was queried and how many
rows came back, so the answer is verifiable, not a claim
the operator has to take on faith. */}
{m.sourceCalls && <ChatToolCalls calls={m.sourceCalls} />}
</ChatMessage>
))}
</ChatMessageList>
</ChatLayout>
</VStack>
</PageShell>
);
}

View File

@@ -0,0 +1,215 @@
import dayjs from 'dayjs';
import { getBookings } from 'pages/api/doormileApi';
import { getalltenants, getallridersummary, mapBookingStatusToDeliveryStatus } from 'pages/api/api';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { getRowBatchId, getBatchLabel } from 'utils/batchBucket';
// ==============================|| Doormile Bot — intent catalog ||============================== //
//
// Deterministic, not LLM-based (see the plan this was built from): every
// answer comes from a real API call against the same functions the rest of
// the console uses, never from a generated guess. `match` extracts params
// from raw text or returns null (this intent doesn't apply); `run` calls
// real data and returns { headline, detail, sourceCalls } — `sourceCalls`
// feeds <ChatToolCalls> so an operator can see exactly what was queried.
//
// `run` may also return null (matched the pattern but couldn't resolve
// something, e.g. an unrecognised tenant name) — the caller then tries the
// next intent in the list rather than answering with a guess.
//
// GET /admin/bookings has no server-side date/status/tenant filter — every
// intent bulk-fetches and filters client-side, the same pattern
// fetchDeliveries (api.js) already uses.
const BULK_PAGESIZE = 1000;
const TODAY = () => dayjs().format('YYYY-MM-DD');
const dayFromWords = (text) => {
if (/\byesterday\b/i.test(text)) return dayjs().subtract(1, 'day').format('YYYY-MM-DD');
return TODAY();
};
const describeDay = (day) => (day === TODAY() ? 'today' : dayjs(day).format('DD MMM YYYY'));
const batchFromWords = (text) => {
if (/\bmorning\b/i.test(text)) return 'morning';
if (/\bafternoon\b/i.test(text)) return 'afternoon';
if (/\bevening\b/i.test(text)) return 'evening';
return null;
};
const statusFromWords = (text) => {
if (/\bpending\b/i.test(text)) return 'pending';
if (/\bcancel(led)?\b/i.test(text)) return 'cancelled';
if (/\baccept(ed)?\b/i.test(text)) return 'accepted';
if (/\bactive\b|\bin[- ]?transit\b/i.test(text)) return 'active';
// Checked after the more specific ones above — "delivered" would also
// false-match "undelivered" without the negative lookahead-ish guard.
if (/\bdeliver(ed)?\b/i.test(text) && !/\bundeliver/i.test(text)) return 'delivered';
return null;
};
// Bulk-fetch bookings and keep only the ones created on `day` — same
// client-side date filter shape as fetchDeliveries' `inRange` in api.js.
const fetchBookingsForDay = async (day) => {
const bookings = (await getBookings(1, BULK_PAGESIZE)) || [];
return bookings.filter((b) => {
const d = parseDoormileTimestamp(b.createdat);
return d.isValid() && d.format('YYYY-MM-DD') === day;
});
};
const summarizeStatuses = (rows) => {
const counts = {};
rows.forEach((b) => {
const s = mapBookingStatusToDeliveryStatus(b.status);
counts[s] = (counts[s] || 0) + 1;
});
return Object.entries(counts)
.map(([k, v]) => `${v} ${k}`)
.join(', ');
};
const resolveTenant = async (text) => {
const tenants = (await getalltenants()) || [];
const lower = text.toLowerCase();
return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase()));
};
const bookingLabel = (b) => b.bookingno || `#${b.bookingid}`;
const INTENTS = [
{
id: 'batchCount',
label: 'Orders in a batch — e.g. "morning batch orders today"',
match: (text) => {
const batch = batchFromWords(text);
if (!batch) return null;
return { batch, day: dayFromWords(text) };
},
run: async ({ batch, day }) => {
const rows = await fetchBookingsForDay(day);
const matched = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch);
return {
headline: `${matched.length} order${matched.length === 1 ? '' : 's'} in the ${getBatchLabel(batch)} ${describeDay(day)}.`,
detail: matched.length
? `Order IDs: ${matched.slice(0, 10).map(bookingLabel).join(', ')}${
matched.length > 10 ? ` …and ${matched.length - 10} more` : ''
}`
: 'No orders fall in this batch for that day.',
sourceCalls: [
{
name: 'getBookings',
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
status: 'complete',
stats: `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}`
}
]
};
}
},
{
id: 'statusBreakdown',
label: 'Orders by status — e.g. "how many delivered orders today"',
match: (text) => {
const status = statusFromWords(text);
if (!status) return null;
return { status, day: dayFromWords(text) };
},
run: async ({ status, day }) => {
const rows = await fetchBookingsForDay(day);
const matched = rows.filter((b) => mapBookingStatusToDeliveryStatus(b.status) === status);
return {
headline: `${matched.length} ${status} order${matched.length === 1 ? '' : 's'} ${describeDay(day)}.`,
detail: `Out of ${rows.length} orders created ${describeDay(day)}.`,
sourceCalls: [
{
name: 'getBookings',
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
status: 'complete',
stats: `${rows.length} total → ${matched.length} ${status}`
}
]
};
}
},
{
id: 'tenantCount',
label: 'Orders for a tenant — e.g. "orders for <tenant name> today"',
match: (text) => {
if (!/\bfor\b/i.test(text) && !/\btenant\b/i.test(text)) return null;
return { text, day: dayFromWords(text) };
},
run: async ({ text, day }) => {
const tenant = await resolveTenant(text);
// No tenant name recognised in the question — don't guess which one
// was meant, fall through to the next intent instead.
if (!tenant) return null;
const rows = await fetchBookingsForDay(day);
const matched = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid));
return {
headline: `${matched.length} order${matched.length === 1 ? '' : 's'} for ${tenant.tenantname} ${describeDay(day)}.`,
detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.`,
sourceCalls: [
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete' },
{
name: 'getBookings',
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
status: 'complete',
stats: `${rows.length} total → ${matched.length} for ${tenant.tenantname}`
}
]
};
}
},
{
id: 'riderCounts',
label: 'Rider availability — e.g. "how many riders are active"',
match: (text) => (/\brider(s)?\b/i.test(text) ? {} : null),
run: async () => {
const summary = await getallridersummary();
return {
headline: `${summary?.active ?? 0} active riders (${summary?.available ?? 0} available, ${
summary?.onDelivery ?? 0
} on a delivery) out of ${summary?.total ?? 0} total.`,
detail: `${summary?.inactive ?? 0} inactive/offline.`,
sourceCalls: [{ name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }]
};
}
},
{
// Deliberately last — the broadest match ("order"/"orders" alone), so
// every more specific intent above gets first refusal.
id: 'totalOrders',
label: 'Today\'s total order count — e.g. "how many orders today"',
match: (text) => (/\border(s)?\b/i.test(text) ? { day: dayFromWords(text) } : null),
run: async ({ day }) => {
const rows = await fetchBookingsForDay(day);
return {
headline: `${rows.length} order${rows.length === 1 ? '' : 's'} created ${describeDay(day)}.`,
detail: rows.length ? `Statuses: ${summarizeStatuses(rows)}` : undefined,
sourceCalls: [
{ name: 'getBookings', target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`, status: 'complete', stats: `${rows.length} matched` }
]
};
}
}
];
export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label);
// Tries each intent in order; the first one whose `match` recognises the
// text AND whose `run` resolves to a real answer wins. Returns null if
// nothing matched (or every match failed to resolve) — the caller shows the
// "I can't answer that yet" fallback rather than a guess.
export async function answerQuestion(text) {
for (const intent of INTENTS) {
const params = intent.match(text);
if (!params) continue;
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params);
if (result) return result;
}
return null;
}