detailview ui improved
This commit is contained in:
parent
4d7beaef0f
commit
930809a581
@ -8,6 +8,7 @@ import { CallsPage } from './screens/CallsPage'
|
|||||||
import { StoresPage } from './screens/StoresPage'
|
import { StoresPage } from './screens/StoresPage'
|
||||||
import { DailyLogsPage } from './screens/DailyLogsPage'
|
import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||||
import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
||||||
|
import { ReportPage } from './screens/ReportPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -31,6 +32,7 @@ function App() {
|
|||||||
<Route path="daily" element={<DailyLogsPage />} />
|
<Route path="daily" element={<DailyLogsPage />} />
|
||||||
<Route path="daily/:instanceId" element={<DailyLogsPage />} />
|
<Route path="daily/:instanceId" element={<DailyLogsPage />} />
|
||||||
|
|
||||||
|
<Route path="reports/:reportType" element={<ReportPage />} />
|
||||||
<Route path="sales-report" element={<DailySalesReportPage />} />
|
<Route path="sales-report" element={<DailySalesReportPage />} />
|
||||||
<Route path="*" element={<Navigate to="/orders" replace />} />
|
<Route path="*" element={<Navigate to="/orders" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@ -125,6 +125,7 @@ export class ZinoClient {
|
|||||||
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
|
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
|
||||||
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
||||||
rv_template_uid: rvUid,
|
rv_template_uid: rvUid,
|
||||||
|
...(params.presetAlias ? { preset_alias: params.presetAlias } : {}),
|
||||||
search_query: {
|
search_query: {
|
||||||
page: params.page ?? 1,
|
page: params.page ?? 1,
|
||||||
limit: params.limit ?? 50,
|
limit: params.limit ?? 50,
|
||||||
|
|||||||
@ -65,6 +65,15 @@ export const ORDER_BOOKING = {
|
|||||||
recordViews: {
|
recordViews: {
|
||||||
ORDERS: '63714ac2-c9fb-40e2-abba-d4081a70b768',
|
ORDERS: '63714ac2-c9fb-40e2-abba-d4081a70b768',
|
||||||
CALLS: '56b21b46-6d2c-4e10-b5a3-c63d058d0afa',
|
CALLS: '56b21b46-6d2c-4e10-b5a3-c63d058d0afa',
|
||||||
|
PRODUCT_WISE_ORDERS:'f1b372de-9e00-4cc6-81b5-1e1a98e7daef',
|
||||||
|
ROUTE_WISE_ORDERS:'bfb346b6-b512-4870-946b-a6f2e621870e',
|
||||||
|
SO_WISE_ORDERS:'c155d266-4eef-420d-9fb8-cd6ace43c569',
|
||||||
|
SO_WISE_VISITS:'08767452-6f0d-46cc-ae7f-ddf2cb480159',
|
||||||
|
YEARLY_ORDERS:'b8fd04da-f076-4e8d-b711-cf9282bf450a',
|
||||||
|
MONTHLY_ORDERS:'6d4ce86a-7a05-446b-8c81-cd0b98b6b244',
|
||||||
|
WEEKLY_ORDERS:'c6e2c57f-ed17-4c92-a2de-189c61c89422',
|
||||||
|
STORE_WISE_PRODUCTS:'f8d71b1d-dffd-450a-b9c9-3a63db8992c5'
|
||||||
|
|
||||||
},
|
},
|
||||||
detailViews: {
|
detailViews: {
|
||||||
ORDERS: '0804c6c3-6cf9-4050-94bb-fa48dd5de87d',
|
ORDERS: '0804c6c3-6cf9-4050-94bb-fa48dd5de87d',
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export interface RecordViewParams {
|
|||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
search?: string;
|
search?: string;
|
||||||
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
||||||
|
presetAlias?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Form schema (POST /app/{appId}/view/form-screens) ---
|
// --- Form schema (POST /app/{appId}/view/form-screens) ---
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger';
|
export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger' | 'outline';
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
export type ButtonSize = 'sm' | 'md' | 'lg' | 'fab';
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
/** Visual style. @default "primary" */
|
/** Visual style. @default "primary" */
|
||||||
@ -19,14 +20,16 @@ const SIZES: Record<ButtonSize, string> = {
|
|||||||
sm: 'h-[34px] px-3.5 text-[13px] rounded-sm',
|
sm: 'h-[34px] px-3.5 text-[13px] rounded-sm',
|
||||||
md: 'h-[42px] px-[18px] text-base rounded-md',
|
md: 'h-[42px] px-[18px] text-base rounded-md',
|
||||||
lg: 'h-[50px] px-6 text-md rounded-md',
|
lg: 'h-[50px] px-6 text-md rounded-md',
|
||||||
|
fab: 'h-[56px] w-[56px] text-sm rounded-full',
|
||||||
};
|
};
|
||||||
|
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary: 'bg-sunrise text-white border border-transparent shadow-sunrise',
|
primary: 'z-btn border border-transparent shadow-blue-600/30',
|
||||||
navy: 'bg-navy-900 text-on-navy border border-transparent shadow-sm',
|
navy: 'bg-navy-900 text-on-navy border border-transparent shadow-sm',
|
||||||
secondary: 'bg-card text-strong border border-border-default shadow-xs',
|
secondary: 'bg-card text-strong border border-border-default shadow-xs',
|
||||||
ghost: 'bg-transparent text-body border border-transparent',
|
ghost: 'bg-transparent text-body border border-transparent',
|
||||||
danger: 'bg-ruby-600 text-white border border-transparent shadow-sm',
|
danger: 'bg-ruby-600 text-white border border-transparent shadow-sm',
|
||||||
|
outline: 'z-btn-outline'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,7 +55,7 @@ export function Button({
|
|||||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
|
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
|
||||||
SIZES[size],
|
SIZES[size],
|
||||||
VARIANTS[variant],
|
VARIANTS[variant],
|
||||||
full ? 'w-full' : 'w-auto',
|
full ? 'w-full' : '',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
45
src/components/buttons/style.css
Normal file
45
src/components/buttons/style.css
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
.z-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--z-border-radius-md);
|
||||||
|
font-size: var(--z-font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: var(--z-btn-primary-bg);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-btn-primary {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-btn:hover {
|
||||||
|
background: var(--primary-btn-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-btn-secondary {
|
||||||
|
background-color: var(--z-block-bg);
|
||||||
|
color: var(--z-text-primary);
|
||||||
|
border: 1px solid var(--z-block-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-btn-secondary:hover {
|
||||||
|
background-color: var(--z-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--z-btn-outline-color);
|
||||||
|
border: 1px solid var(--z-btn-outline-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-btn-outline:hover {
|
||||||
|
background-color: var(--z-color-primary-light);
|
||||||
|
}
|
||||||
@ -1,17 +1,371 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { orderBookingClient } from '../../api/clients';
|
import { orderBookingClient } from '../../api/clients';
|
||||||
import { ORDER_BOOKING } from '../../api/config';
|
import { ORDER_BOOKING, APP_ID } from '../../api/config';
|
||||||
import { DetailView } from './DetailView';
|
import { Card } from '../reusable/Card';
|
||||||
import type { WiredDetailViewProps } from './OrderDetail';
|
import { Spinner } from '../reusable/Spinner';
|
||||||
|
import { EmptyState } from '../reusable/EmptyState';
|
||||||
|
import { formatValue } from '../../lib/format';
|
||||||
|
import { ShoppingCart, Store, User, ClipboardList, TrendingUp } from 'lucide-react';
|
||||||
|
import { PotentialMiningTable, type MiningItem } from './PotentialMiningTable';
|
||||||
|
export interface CallDetailProps {
|
||||||
|
instanceId: number | string;
|
||||||
|
selectedRow?: Record<string, any>;
|
||||||
|
potentialMiningAction?: ReactNode;
|
||||||
|
placeOrderAction?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
/** Call detail view (Order Booking workflow). */
|
/** Custom Call detail view matching specific groupings. */
|
||||||
export function CallDetail({ instanceId, columns }: WiredDetailViewProps) {
|
export function CallDetail({ instanceId, selectedRow, potentialMiningAction, placeOrderAction }: CallDetailProps) {
|
||||||
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
async function run() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await orderBookingClient.detailView(ORDER_BOOKING.detailViews.CALLS, instanceId);
|
||||||
|
if (live) {
|
||||||
|
setData(r.data || {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||||
|
} finally {
|
||||||
|
if (live) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<DetailView
|
<Card title="Call Details">
|
||||||
client={orderBookingClient}
|
<EmptyState title="Couldn’t load record" hint={error} />
|
||||||
dvUid={ORDER_BOOKING.detailViews.CALLS}
|
</Card>
|
||||||
instanceId={instanceId}
|
);
|
||||||
title="Call"
|
}
|
||||||
columns={columns}
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card title="Call Details">
|
||||||
|
<div className="py-8 flex justify-center">
|
||||||
|
<Spinner label="Loading details…" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return (
|
||||||
|
<Card title="Call Details">
|
||||||
|
<EmptyState title="No details found" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify state
|
||||||
|
const rowSrc = selectedRow || data;
|
||||||
|
const stateName = String(rowSrc.current_state_name || rowSrc.current_state_name_ || rowSrc.current_state || rowSrc.status || 'Unknown');
|
||||||
|
const valLower = stateName.toLowerCase();
|
||||||
|
const isSuccess = valLower.includes('approve') || valLower.includes('complete') || valLower.includes('success') || valLower.includes('active') || valLower.includes('productive') || valLower.includes('ordered');
|
||||||
|
const isWarning = valLower.includes('pending') || valLower.includes('draft') || valLower.includes('hold');
|
||||||
|
const isDanger = valLower.includes('reject') || valLower.includes('fail') || valLower.includes('cancel');
|
||||||
|
let colorClass = "text-blue-500";
|
||||||
|
if (isSuccess) colorClass = "text-emerald-500";
|
||||||
|
else if (isWarning) colorClass = "text-amber-500";
|
||||||
|
else if (isDanger) colorClass = "text-red-500";
|
||||||
|
|
||||||
|
// Data Extraction and Fallback
|
||||||
|
const remainingData = { ...data };
|
||||||
|
|
||||||
|
const extract = (key: string, obj: any = data) => {
|
||||||
|
if (obj && key in obj) {
|
||||||
|
const val = obj[key];
|
||||||
|
if (obj === remainingData) {
|
||||||
|
delete remainingData[key];
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStore = (data.select_store as any) || {};
|
||||||
|
if ('select_store' in remainingData) {
|
||||||
|
delete remainingData.select_store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove current_state to not render them again
|
||||||
|
delete remainingData.current_state_id;
|
||||||
|
delete remainingData.current_state_name;
|
||||||
|
|
||||||
|
let orderIdVal = extract('order_id', remainingData);
|
||||||
|
let instanceIdVal = extract('instance_id', remainingData);
|
||||||
|
const orderId = orderIdVal !== '-' ? orderIdVal : instanceIdVal;
|
||||||
|
const orderDate = extract('date_of_order_3', remainingData);
|
||||||
|
const totalBags = extract('total_bags_3', remainingData);
|
||||||
|
const totalKgs = extract('total_kgs_3', remainingData);
|
||||||
|
const orderDetailsGrid = extract('order_details_3', remainingData);
|
||||||
|
|
||||||
|
let potentialGrid: any[] = [];
|
||||||
|
if ('potential' in remainingData) {
|
||||||
|
const pot = remainingData.potential as any;
|
||||||
|
if (Array.isArray(pot)) {
|
||||||
|
potentialGrid = pot;
|
||||||
|
} else if (pot && typeof pot === 'object' && Array.isArray(pot.potential)) {
|
||||||
|
potentialGrid = pot.potential;
|
||||||
|
}
|
||||||
|
delete remainingData.potential;
|
||||||
|
}
|
||||||
|
|
||||||
|
const miningData: MiningItem[] = potentialGrid.map((item: any, index: number) => {
|
||||||
|
let badgeType: 'error' | 'success' | 'info' | 'warning' | 'default' = 'default';
|
||||||
|
const reason = item.reason || '';
|
||||||
|
if (reason.toLowerCase().includes('quality')) badgeType = 'error';
|
||||||
|
else if (reason.toLowerCase().includes('stock')) badgeType = 'success';
|
||||||
|
else if (reason.toLowerCase().includes('price')) badgeType = 'warning';
|
||||||
|
else if (reason.toLowerCase().includes('competitive')) badgeType = 'info';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(item.id || index),
|
||||||
|
productName: item.product_category || item.product_category_ || '-',
|
||||||
|
badge: reason ? { label: reason, type: badgeType } : undefined,
|
||||||
|
potentialKgs: Number(item.actual_potential || item.store_potential || 0),
|
||||||
|
orderedKgs: Number(item.total_ordered || 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeName = selectStore.business_name || selectStore.store_name || '-';
|
||||||
|
const storeLocation = selectStore.area || selectStore.location || '-';
|
||||||
|
const dateOfVisit = extract('date_of_visit', remainingData);
|
||||||
|
|
||||||
|
const customerId = selectStore.store_code || selectStore.customer_id || '-';
|
||||||
|
const customerName = selectStore.owner_name || selectStore.customer_name || '-';
|
||||||
|
|
||||||
|
let phoneNumber = '-';
|
||||||
|
if (selectStore.phone_number) {
|
||||||
|
phoneNumber = typeof selectStore.phone_number === 'object'
|
||||||
|
? selectStore.phone_number.phone_with_dial_code || selectStore.phone_number.phone
|
||||||
|
: selectStore.phone_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||||
|
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
|
||||||
|
|
||||||
|
const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id'));
|
||||||
|
const userObj = userIdKey ? extract(userIdKey, remainingData) : null;
|
||||||
|
const userName = userObj && typeof userObj === 'object' ? userObj.name || userObj.email : '-';
|
||||||
|
|
||||||
|
const uploadImage = extract('upload_image', remainingData);
|
||||||
|
|
||||||
|
// Helper for rendering a row
|
||||||
|
const Row = ({ label, value }: { label: string, value: any }) => (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
|
||||||
|
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
|
||||||
|
<span className="text-sm font-bold text-strong sm:w-2/3">{formatValue(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* 1. Status Card */}
|
||||||
|
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
|
||||||
|
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div>
|
||||||
|
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
|
||||||
|
{stateName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{placeOrderAction && (
|
||||||
|
<div>{placeOrderAction}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 2. Order Details Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<ShoppingCart className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<div className="flex flex-col mb-4">
|
||||||
|
<Row label="Order ID" value={orderId} />
|
||||||
|
<Row label="Order Date" value={orderDate} />
|
||||||
|
<Row label="Total Bags" value={totalBags} />
|
||||||
|
<Row label="Total Kgs" value={totalKgs} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Array.isArray(orderDetailsGrid) && orderDetailsGrid.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
|
<h4 className="text-[11px] font-bold text-[var(--z-bg-primary)] opacity-80 uppercase tracking-widest mb-1">Line Items</h4>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border-subtle shadow-sm bg-white">
|
||||||
|
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||||
|
<thead className="bg-slate-50 border-b border-border-subtle text-xs uppercase text-muted tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-semibold">Product Name</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">SKU</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">SKU Code</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">BR Code</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Bags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle text-strong">
|
||||||
|
{orderDetailsGrid.map((item: any, idx: number) => {
|
||||||
|
const skuCode = item.sku_code_3 || '-';
|
||||||
|
let chipColor = 'bg-slate-50 text-slate-700 border-slate-200';
|
||||||
|
if (skuCode !== '-') {
|
||||||
|
const hash = skuCode.split('').reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0);
|
||||||
|
const colors = [
|
||||||
|
'bg-blue-50 text-blue-700 border-blue-200',
|
||||||
|
'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||||
|
'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
|
'bg-rose-50 text-rose-700 border-rose-200',
|
||||||
|
'bg-purple-50 text-purple-700 border-purple-200',
|
||||||
|
'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||||
|
'bg-pink-50 text-pink-700 border-pink-200',
|
||||||
|
'bg-cyan-50 text-cyan-700 border-cyan-200'
|
||||||
|
];
|
||||||
|
chipColor = colors[hash % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={idx} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-medium">{item.product_name_3 || item.product_category_3 || '-'}</td>
|
||||||
|
<td className="px-4 py-3">{item.sku_3 || '-'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border shadow-sm ${chipColor}`}>
|
||||||
|
{skuCode}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{item.br_code_3 || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-semibold">{item.bags_3 || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{potentialMiningAction && (
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
{potentialMiningAction}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col mt-4 pt-4 border-t border-border-subtle">
|
||||||
|
<Row label="Order Created By" value={userName} />
|
||||||
|
<Row label="Order Created At" value={createdAt} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 3. Store Details Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Store className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Store Name" value={storeName} />
|
||||||
|
<Row label="Store Location" value={storeLocation} />
|
||||||
|
<Row label="Date Of Visit" value={dateOfVisit} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 4. Customer Details Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<User className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Customer Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Customer ID" value={customerId} />
|
||||||
|
<Row label="Customer Name" value={customerName} />
|
||||||
|
<Row label="Phone Number" value={phoneNumber} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 5. Potential Mining Card */}
|
||||||
|
{miningData.length > 0 && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<TrendingUp className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Potential Mining</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-0 sm:p-5">
|
||||||
|
<PotentialMiningTable data={miningData} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 6. Log Visit Details Fallback */}
|
||||||
|
{Object.keys(remainingData).length > 0 && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Log Visit Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
{Object.entries(remainingData).map(([key, value]) => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (lowerKey.includes('uuid') || /^\d+$/.test(key)) return null;
|
||||||
|
// we just render them all as rows for simplicity in this custom view
|
||||||
|
let finalValue = value;
|
||||||
|
if (finalValue && typeof finalValue === 'object') {
|
||||||
|
try {
|
||||||
|
finalValue = JSON.stringify(finalValue);
|
||||||
|
} catch (e) {
|
||||||
|
finalValue = '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// format label to be readable
|
||||||
|
const label = key.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, l => l.toUpperCase())
|
||||||
|
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
|
||||||
|
.replace(/^\s+|\s+$/g, '');
|
||||||
|
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return <Row key={key} label={label} value={finalValue} />;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{Array.isArray(uploadImage) && uploadImage.length > 0 && (
|
||||||
|
<div className="flex flex-col mt-4 pt-4 border-t border-border-subtle">
|
||||||
|
<span className="text-sm text-muted mb-2 font-medium">Uploaded Image:</span>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{uploadImage.map((file: any, idx: number) => {
|
||||||
|
const previewUrl = `${orderBookingClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
||||||
|
return (
|
||||||
|
<div key={file.uuid || idx} className="relative w-full rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={file.original_name || 'Upload'}
|
||||||
|
className="w-full h-auto max-h-[400px] object-contain transition-transform group-hover:scale-[1.02]"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 z-10"></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,242 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { dailyReportsClient } from '../../api/clients';
|
import { dailyReportsClient } from '../../api/clients';
|
||||||
import { DAILY_REPORTS } from '../../api/config';
|
import { DAILY_REPORTS, APP_ID } from '../../api/config';
|
||||||
import { DetailView } from './DetailView';
|
import { Card } from '../reusable/Card';
|
||||||
import type { WiredDetailViewProps } from './OrderDetail';
|
import { Spinner } from '../reusable/Spinner';
|
||||||
|
import { EmptyState } from '../reusable/EmptyState';
|
||||||
|
import { formatValue } from '../../lib/format';
|
||||||
|
import { Button } from '../buttons/Button';
|
||||||
|
import { Map, User, ClipboardList, Camera, Clock } from 'lucide-react';
|
||||||
|
|
||||||
/** Daily Log detail view (Daily Reports workflow). */
|
export interface DailyLogDetailProps {
|
||||||
export function DailyLogDetail({ instanceId, columns }: WiredDetailViewProps) {
|
instanceId: number | string;
|
||||||
|
onPunchOut?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyLogDetail({ instanceId, onPunchOut }: DailyLogDetailProps) {
|
||||||
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
async function run() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await dailyReportsClient.detailView(DAILY_REPORTS.detailViews.DAILY_LOGS, instanceId);
|
||||||
|
if (live) setData(r.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||||
|
} finally {
|
||||||
|
if (live) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<DetailView
|
<Card title="Daily Log Details">
|
||||||
client={dailyReportsClient}
|
<EmptyState title="Couldn’t load record" hint={error} />
|
||||||
dvUid={DAILY_REPORTS.detailViews.DAILY_LOGS}
|
</Card>
|
||||||
instanceId={instanceId}
|
);
|
||||||
title="Daily Log"
|
}
|
||||||
columns={columns}
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card title="Daily Log Details">
|
||||||
|
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return (
|
||||||
|
<Card title="Daily Log Details">
|
||||||
|
<EmptyState title="No details found" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingData = { ...data };
|
||||||
|
const extract = (key: string, obj: any = data) => {
|
||||||
|
if (obj && key in obj) {
|
||||||
|
const val = obj[key];
|
||||||
|
delete obj[key];
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
const stateName = extract('current_state_name', remainingData) as string;
|
||||||
|
delete remainingData.current_state_id;
|
||||||
|
|
||||||
|
const isPunchedIn = stateName.toLowerCase().includes('punched in');
|
||||||
|
const isPunchedOut = stateName.toLowerCase().includes('punched out');
|
||||||
|
const colorClass = isPunchedOut ? 'text-emerald-500' : (isPunchedIn ? 'text-blue-500' : 'text-slate-500');
|
||||||
|
|
||||||
|
// General Info
|
||||||
|
const date = extract('date', remainingData);
|
||||||
|
const time = extract('time', remainingData);
|
||||||
|
const routeCode = extract('route_code', remainingData);
|
||||||
|
const subRoute = extract('sub_route', remainingData);
|
||||||
|
const dayPlanNotes = extract('day_plan_notes', remainingData);
|
||||||
|
|
||||||
|
// Officer Info
|
||||||
|
const officerNameRaw = extract('sales_officer_name', remainingData);
|
||||||
|
let officerName = '-';
|
||||||
|
let officerEmail = '-';
|
||||||
|
if (officerNameRaw && typeof officerNameRaw === 'object') {
|
||||||
|
officerName = (officerNameRaw as any).name || '-';
|
||||||
|
officerEmail = (officerNameRaw as any).email || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image
|
||||||
|
const storeImage = extract('store_image', remainingData);
|
||||||
|
|
||||||
|
// EOD Info
|
||||||
|
const totalProductive = extract('total_productive_calls', remainingData);
|
||||||
|
const totalNonProductive = extract('total_non_productive_calls', remainingData);
|
||||||
|
const eodNotes = extract('eod_notes_remarks', remainingData);
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||||
|
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
|
||||||
|
|
||||||
|
// Helper Row
|
||||||
|
const Row = ({ label, value }: { label: string, value: any }) => {
|
||||||
|
let finalValue = value;
|
||||||
|
if (finalValue && typeof finalValue === 'object') {
|
||||||
|
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
|
||||||
|
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
|
||||||
|
<span className="text-sm font-bold text-slate-800 sm:w-2/3">{formatValue(finalValue)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* 1. Status Card */}
|
||||||
|
<Card pad={false} className={`border-t-[4px] shadow-md ${isPunchedOut ? 'border-emerald-500' : 'border-[var(--z-bg-primary)]'}`}>
|
||||||
|
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Current State</div>
|
||||||
|
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
|
||||||
|
{stateName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isPunchedIn && onPunchOut && (
|
||||||
|
<Button onClick={onPunchOut}>Punch Out</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 2. Route & Plan Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Map className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Day Plan & Route</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Date" value={date} />
|
||||||
|
<Row label="Time" value={time} />
|
||||||
|
<Row label="Route Code" value={routeCode} />
|
||||||
|
<Row label="Sub Route" value={subRoute} />
|
||||||
|
<Row label="Day Plan Notes" value={dayPlanNotes} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 3. Officer Info */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<User className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Sales Officer</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Officer Name" value={officerName} />
|
||||||
|
<Row label="Officer Email" value={officerEmail} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 4. EOD Details (Only if not punched in) */}
|
||||||
|
{!isPunchedIn && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">End of Day Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Total Productive Calls" value={totalProductive} />
|
||||||
|
<Row label="Total Non-Productive Calls" value={totalNonProductive} />
|
||||||
|
<Row label="EOD Notes (Remarks)" value={eodNotes} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 5. Image Card */}
|
||||||
|
{Array.isArray(storeImage) && storeImage.length > 0 && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Camera className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Image</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{storeImage.map((file: any, idx: number) => {
|
||||||
|
const previewUrl = `${dailyReportsClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
||||||
|
return (
|
||||||
|
<div key={file.uuid || idx} className="relative w-full rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={file.original_name || 'Upload'}
|
||||||
|
className="w-full h-auto max-h-[400px] object-contain transition-transform group-hover:scale-[1.02]"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 z-10"></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 6. Other Details */}
|
||||||
|
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k) && k !== 'instance_id').length > 0) && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Clock className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Performed At" value={createdAt} />
|
||||||
|
{Object.entries(remainingData).map(([key, value]) => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
|
||||||
|
|
||||||
|
const label = key.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, l => l.toUpperCase())
|
||||||
|
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
|
||||||
|
.replace(/^\s+|\s+$/g, '');
|
||||||
|
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return <Row key={key} label={label} value={value} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
import type { ZinoClient } from '../../api/client';
|
import type { ZinoClient } from '../../api/client';
|
||||||
import type { AuditEntry } from '../../api/types';
|
import { APP_ID } from '../../api/config';
|
||||||
import { WORKFLOWS } from '../../api/config';
|
|
||||||
import { Card } from '../reusable/Card';
|
import { Card } from '../reusable/Card';
|
||||||
import { Spinner } from '../reusable/Spinner';
|
import { Spinner } from '../reusable/Spinner';
|
||||||
import { EmptyState } from '../reusable/EmptyState';
|
import { EmptyState } from '../reusable/EmptyState';
|
||||||
@ -11,7 +10,7 @@ import { EmptyState } from '../reusable/EmptyState';
|
|||||||
export interface DetailViewProps {
|
export interface DetailViewProps {
|
||||||
/** Workflow-bound client (see api/clients.ts). */
|
/** Workflow-bound client (see api/clients.ts). */
|
||||||
client: ZinoClient;
|
client: ZinoClient;
|
||||||
/** detailview template uid (deprecated, using audit endpoint now). */
|
/** detailview template uid. */
|
||||||
dvUid?: string;
|
dvUid?: string;
|
||||||
/** Instance to render. */
|
/** Instance to render. */
|
||||||
instanceId: number | string;
|
instanceId: number | string;
|
||||||
@ -23,29 +22,13 @@ export interface DetailViewProps {
|
|||||||
columns?: 1 | 2 | 3;
|
columns?: 1 | 2 | 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to resolve a human-readable activity name from its UID.
|
|
||||||
*/
|
|
||||||
function getActivityName(uid: string): string | undefined {
|
|
||||||
for (const wf of Object.values(WORKFLOWS)) {
|
|
||||||
for (const [actName, actDef] of Object.entries(wf.activities)) {
|
|
||||||
if (actDef.uid === uid) {
|
|
||||||
return actName
|
|
||||||
.split('_')
|
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic Zino detail view. Fetches `GET /app/{id}/view/audit` and
|
* Generic Zino detail view. Fetches `GET /app/{id}/view/detailview/{dvUid}` and
|
||||||
* renders the audit trail as a labeled definition grid.
|
* renders the data as a labeled definition grid.
|
||||||
*/
|
*/
|
||||||
export function DetailView({ client, instanceId, title, columns = 2 }: DetailViewProps) {
|
export function DetailView({ client, dvUid, instanceId, title, fields, columns = 2 }: DetailViewProps) {
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -55,8 +38,11 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const r = await client.audit(instanceId);
|
if (!dvUid) throw new Error("dvUid is required to fetch detail view data.");
|
||||||
if (live) setEntries(r);
|
const r = await client.detailView(dvUid, instanceId);
|
||||||
|
if (live) {
|
||||||
|
setData(r.data || {});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||||
} finally {
|
} finally {
|
||||||
@ -67,7 +53,7 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
|||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [client, instanceId]);
|
}, [client, dvUid, instanceId]);
|
||||||
|
|
||||||
const gridCols = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-3' }[columns];
|
const gridCols = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-3' }[columns];
|
||||||
|
|
||||||
@ -89,29 +75,27 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (!data || Object.keys(data).length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card title={title}>
|
<Card title={title}>
|
||||||
<EmptyState title="No history found" />
|
<EmptyState title="No details found" />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{entries.map((entry, i) => {
|
|
||||||
const actName = getActivityName(entry.activity_id);
|
|
||||||
const headerTitle = actName || (i === 0 && title ? title : 'Update');
|
|
||||||
const updatedAt = new Date(entry.created_at).toLocaleString();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
<Card
|
||||||
key={entry.id}
|
title={title || 'Details'}
|
||||||
title={headerTitle}
|
className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md"
|
||||||
action={<span className="text-xs font-medium text-faint">{updatedAt}</span>}
|
bodyClassName="bg-slate-50/30"
|
||||||
>
|
>
|
||||||
<dl className={cn('grid gap-x-6 gap-y-4', gridCols)}>
|
<dl className={cn('grid gap-x-6 gap-y-6', gridCols)}>
|
||||||
{Object.entries(entry.data).map(([key, value]) => {
|
{Object.entries(data).map(([key, value]) => {
|
||||||
|
if (fields && !fields.includes(key)) return null;
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (lowerKey.includes('uuid') || /^\d+$/.test(key)) return null;
|
||||||
|
|
||||||
const isGridArray =
|
const isGridArray =
|
||||||
Array.isArray(value) &&
|
Array.isArray(value) &&
|
||||||
value.length > 0 &&
|
value.length > 0 &&
|
||||||
@ -127,7 +111,7 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
|
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
|
||||||
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
|
<dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
|
||||||
{key.replace(/_/g, ' ')}
|
{key.replace(/_/g, ' ')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="m-0 text-sm text-strong font-medium overflow-x-auto rounded border border-border-subtle shadow-sm">
|
<dd className="m-0 text-sm text-strong font-medium overflow-x-auto rounded border border-border-subtle shadow-sm">
|
||||||
@ -146,7 +130,7 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
|||||||
<tr key={idx} className="hover:bg-slate-50/50">
|
<tr key={idx} className="hover:bg-slate-50/50">
|
||||||
{headers.map(h => (
|
{headers.map(h => (
|
||||||
<td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong">
|
<td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong">
|
||||||
{formatValue(row[h], h)}
|
{formatValue(row[h])}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@ -158,21 +142,60 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFileArray =
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length > 0 &&
|
||||||
|
typeof value[0] === 'object' &&
|
||||||
|
value[0] !== null &&
|
||||||
|
('original_name' in value[0] || 'uuid' in value[0]);
|
||||||
|
|
||||||
|
if (isFileArray) {
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-1 min-w-0">
|
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
|
||||||
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
|
<dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
|
||||||
|
{key.replace(/_/g, ' ')}
|
||||||
|
</dt>
|
||||||
|
<dd className="m-0 flex flex-wrap gap-4">
|
||||||
|
{(value as any[]).map((file, idx) => {
|
||||||
|
const previewUrl = `${client.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
||||||
|
return (
|
||||||
|
<div key={file.uuid || idx} className="relative w-32 h-32 rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={file.original_name || 'Attached File'}
|
||||||
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={previewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="absolute inset-0 z-10"
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
<dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
|
||||||
{key.replace(/_/g, ' ')}
|
{key.replace(/_/g, ' ')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="m-0 text-sm text-strong font-medium break-words">
|
<dd className="m-0 text-sm text-strong font-medium break-words">
|
||||||
{formatValue(value, key)}
|
{formatValue(value)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/components/dv/GridTable.tsx
Normal file
75
src/components/dv/GridTable.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import type { FormScreenField } from '../../api/types';
|
||||||
|
|
||||||
|
export interface GridTableProps {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
columns: FormScreenField[];
|
||||||
|
onEdit?: (idx: number) => void;
|
||||||
|
onDelete?: (idx: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridTable({ data, columns, onEdit, onDelete }: GridTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white rounded-lg shadow-sm border border-slate-200 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left whitespace-nowrap">
|
||||||
|
<thead className="bg-white border-b border-slate-100 text-[11px] font-bold text-slate-500 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<th key={col.id || idx} className={`px-6 py-4 ${col.data_type === 'number' ? 'text-center' : ''}`}>{col.name}</th>
|
||||||
|
))}
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<th className="px-6 py-4 text-center w-24">Actions</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{data.map((row, rowIdx) => (
|
||||||
|
<tr key={rowIdx} className="hover:bg-slate-50 transition-colors bg-white group">
|
||||||
|
{columns.map((col, colIdx) => {
|
||||||
|
const val = row[col.id];
|
||||||
|
let displayVal = String(val ?? '-');
|
||||||
|
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||||
|
const opt = col.properties?.options?.find((o: any) => String(o.value) === String(val));
|
||||||
|
if (opt) displayVal = opt.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNumeric = col.data_type === 'number';
|
||||||
|
return (
|
||||||
|
<td key={col.id || colIdx} className={`px-6 py-4 font-bold text-slate-800 ${isNumeric ? 'text-center text-[15px]' : 'text-[14.5px]'}`}>
|
||||||
|
{displayVal}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-3 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEdit(rowIdx)}
|
||||||
|
className="p-2 text-blue-500 hover:text-blue-600 hover:bg-blue-50 rounded border border-blue-100 transition-colors shadow-sm"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(rowIdx)}
|
||||||
|
className="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 rounded border border-red-100 transition-colors shadow-sm"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,21 +1,248 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { orderBookingClient } from '../../api/clients';
|
import { orderBookingClient } from '../../api/clients';
|
||||||
import { ORDER_BOOKING } from '../../api/config';
|
import { ORDER_BOOKING } from '../../api/config';
|
||||||
import { DetailView } from './DetailView';
|
import { Card } from '../reusable/Card';
|
||||||
|
import { Spinner } from '../reusable/Spinner';
|
||||||
|
import { EmptyState } from '../reusable/EmptyState';
|
||||||
|
import { formatValue } from '../../lib/format';
|
||||||
|
import { ShoppingCart, Store, User, Package, Clock, ClipboardList } from 'lucide-react';
|
||||||
|
import { GridTable } from './GridTable';
|
||||||
|
|
||||||
export interface WiredDetailViewProps {
|
export interface WiredDetailViewProps {
|
||||||
instanceId: number | string;
|
instanceId: number | string;
|
||||||
columns?: 1 | 2 | 3;
|
columns?: 1 | 2 | 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Order detail view (Order Booking workflow). */
|
export function OrderDetail({ instanceId }: WiredDetailViewProps) {
|
||||||
export function OrderDetail({ instanceId, columns }: WiredDetailViewProps) {
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
async function run() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await orderBookingClient.detailView(ORDER_BOOKING.detailViews.ORDERS, instanceId);
|
||||||
|
if (live) setData(r.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||||
|
} finally {
|
||||||
|
if (live) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<DetailView
|
<Card title="Order Details">
|
||||||
client={orderBookingClient}
|
<EmptyState title="Couldn’t load record" hint={error} />
|
||||||
dvUid={ORDER_BOOKING.detailViews.ORDERS}
|
</Card>
|
||||||
instanceId={instanceId}
|
);
|
||||||
title="Order"
|
}
|
||||||
columns={columns}
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card title="Order Details">
|
||||||
|
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return (
|
||||||
|
<Card title="Order Details">
|
||||||
|
<EmptyState title="No details found" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingData = { ...data };
|
||||||
|
const extract = (key: string, obj: any = data) => {
|
||||||
|
if (obj && key in obj) {
|
||||||
|
const val = obj[key];
|
||||||
|
delete obj[key];
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
const stateName = extract('current_state_name', remainingData);
|
||||||
|
delete remainingData.current_state_id;
|
||||||
|
const isSuccess = ['ordered', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s));
|
||||||
|
const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500';
|
||||||
|
|
||||||
|
// Order Details
|
||||||
|
const orderId = extract('order_id', remainingData);
|
||||||
|
const dateOfOrder = extract('date_of_order_3', remainingData);
|
||||||
|
|
||||||
|
// SO Info
|
||||||
|
const soKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')) || 'so_name';
|
||||||
|
const soRaw = extract(soKey, remainingData);
|
||||||
|
let soName = '-';
|
||||||
|
let soEmail = '-';
|
||||||
|
if (soRaw && typeof soRaw === 'object') {
|
||||||
|
soName = (soRaw as any).name || '-';
|
||||||
|
soEmail = (soRaw as any).email || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store Info
|
||||||
|
const storeRaw = extract('select_store', remainingData);
|
||||||
|
let storeName = '-';
|
||||||
|
let distributorName = '-';
|
||||||
|
let routeName = '-';
|
||||||
|
let routeCode = '-';
|
||||||
|
if (storeRaw && typeof storeRaw === 'object') {
|
||||||
|
storeName = (storeRaw as any).business_name || '-';
|
||||||
|
distributorName = (storeRaw as any).distributor_name || '-';
|
||||||
|
routeName = (storeRaw as any).route_name || '-';
|
||||||
|
routeCode = (storeRaw as any).route_code || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line Items
|
||||||
|
const orderDetailsGrid = extract('order_details_3', remainingData);
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
const totalBags = extract('total_bags_3', remainingData);
|
||||||
|
const totalKgs = extract('total_kgs_3', remainingData);
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||||
|
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
|
||||||
|
|
||||||
|
// Helper Row
|
||||||
|
const Row = ({ label, value, highlight = false }: { label: string, value: any, highlight?: boolean }) => {
|
||||||
|
let finalValue = value;
|
||||||
|
if (finalValue && typeof finalValue === 'object') {
|
||||||
|
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
|
||||||
|
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
|
||||||
|
<span className={`text-sm ${highlight ? 'font-black text-[var(--z-bg-primary)] text-base' : 'font-bold text-slate-800'} sm:w-2/3`}>
|
||||||
|
{formatValue(finalValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* 1. Status Card */}
|
||||||
|
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
|
||||||
|
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div>
|
||||||
|
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
|
||||||
|
{stateName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 2. Order Summary Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<ShoppingCart className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Summary</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Order ID" value={orderId} />
|
||||||
|
<Row label="Date of Order" value={dateOfOrder} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 3. Store details Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Store className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Business Name" value={storeName} />
|
||||||
|
<Row label="Distributor" value={distributorName} />
|
||||||
|
<Row label="Route" value={`${routeName} (${routeCode})`} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 4. Sales Officer Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<User className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Sales Officer</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Name" value={soName} />
|
||||||
|
<Row label="Email" value={soEmail} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 5. Line Items Grid */}
|
||||||
|
{Array.isArray(orderDetailsGrid) && orderDetailsGrid.length > 0 && (
|
||||||
|
<Card pad={false} className="shadow-md overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Package className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Line Items</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-0 sm:p-5">
|
||||||
|
<GridTable
|
||||||
|
data={orderDetailsGrid}
|
||||||
|
columns={[
|
||||||
|
{ id: 'product_category_3', uid: '', name: 'Product Category', data_type: 'text' },
|
||||||
|
{ id: 'product_name_3', uid: '', name: 'Product Name', data_type: 'text' },
|
||||||
|
{ id: 'sku_3', uid: '', name: 'SKU', data_type: 'text' },
|
||||||
|
{ id: 'sku_code_3', uid: '', name: 'SKU Code', data_type: 'text' },
|
||||||
|
{ id: 'br_code_3', uid: '', name: 'BR Code', data_type: 'text' },
|
||||||
|
{ id: 'bags_3', uid: '', name: 'Bags', data_type: 'number' }
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 6. Order Totals */}
|
||||||
|
<Card pad={false} className="shadow-md bg-blue-50/30">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-blue-100/30">
|
||||||
|
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Totals</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Total Bags" value={totalBags} highlight />
|
||||||
|
<Row label="Total KGs" value={totalKgs} highlight />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 7. Log Visit Details Fallback & Meta */}
|
||||||
|
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k) && k !== 'instance_id').length > 0 || createdAt !== '-') && (
|
||||||
|
<Card pad={false} className="shadow-md mt-6">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Clock className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Created At" value={createdAt} />
|
||||||
|
{Object.entries(remainingData).map(([key, value]) => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
|
||||||
|
|
||||||
|
const label = key.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, l => l.toUpperCase())
|
||||||
|
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
|
||||||
|
.replace(/^\s+|\s+$/g, '');
|
||||||
|
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return <Row key={key} label={label} value={value} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/components/dv/PotentialMiningTable.tsx
Normal file
136
src/components/dv/PotentialMiningTable.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { ArrowUpRight, ArrowDownRight, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
|
export interface MiningItem {
|
||||||
|
id: string;
|
||||||
|
productName: string;
|
||||||
|
badge?: {
|
||||||
|
label: string;
|
||||||
|
type: 'error' | 'success' | 'info' | 'warning' | 'default';
|
||||||
|
};
|
||||||
|
status?: string;
|
||||||
|
potentialKgs: number;
|
||||||
|
orderedKgs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PotentialMiningTableProps {
|
||||||
|
data: MiningItem[];
|
||||||
|
onEdit?: (item: MiningItem) => void;
|
||||||
|
onDelete?: (item: MiningItem) => void;
|
||||||
|
hideOrderDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PotentialMiningTable({ data, onEdit, onDelete, hideOrderDetails }: PotentialMiningTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white rounded-lg shadow-sm border border-slate-200 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left whitespace-nowrap">
|
||||||
|
<thead className="bg-white border-b border-slate-100 text-[11px] font-bold text-slate-500 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4">Product</th>
|
||||||
|
<th className="px-6 py-4 text-center">Potential (Kgs)</th>
|
||||||
|
{!hideOrderDetails && (
|
||||||
|
<>
|
||||||
|
<th className="px-6 py-4 text-center">Ordered (Kgs)</th>
|
||||||
|
<th className="px-6 py-4 text-center">Difference</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<th className="px-6 py-4 text-center">Actions</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{data.map((item) => {
|
||||||
|
const difference = item.orderedKgs - item.potentialKgs;
|
||||||
|
const isSurplus = difference >= 0;
|
||||||
|
const absDiff = Math.abs(difference);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="hover:bg-slate-50 transition-colors bg-white group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-bold text-slate-800 text-sm">{item.productName}</span>
|
||||||
|
{item.badge && (
|
||||||
|
<span className={cn(
|
||||||
|
"px-2 py-0.5 rounded text-[11px] font-semibold whitespace-nowrap",
|
||||||
|
item.badge.type === 'error' && "bg-red-50 text-red-500",
|
||||||
|
item.badge.type === 'success' && "bg-emerald-50 text-emerald-600",
|
||||||
|
item.badge.type === 'info' && "bg-blue-50 text-blue-500",
|
||||||
|
item.badge.type === 'warning' && "bg-amber-50 text-amber-600",
|
||||||
|
item.badge.type === 'default' && "bg-slate-100 text-slate-600"
|
||||||
|
)}>
|
||||||
|
{item.badge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center text-slate-700 font-medium text-base">
|
||||||
|
{item.potentialKgs}
|
||||||
|
</td>
|
||||||
|
{!hideOrderDetails && (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-4 text-center text-slate-700 font-medium text-base">
|
||||||
|
{item.orderedKgs}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className={cn(
|
||||||
|
"flex flex-col items-center justify-center min-w-[110px] px-3 py-1.5 rounded-md border",
|
||||||
|
isSurplus ? "bg-emerald-50/50 border-emerald-100" : "bg-red-50/50 border-red-100"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-1 font-bold text-sm",
|
||||||
|
isSurplus ? "text-emerald-700" : "text-red-600"
|
||||||
|
)}>
|
||||||
|
{isSurplus ? (
|
||||||
|
<ArrowUpRight size={16} strokeWidth={2.5} />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight size={16} strokeWidth={2.5} />
|
||||||
|
)}
|
||||||
|
<span>{absDiff} Kgs</span>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[11px] font-medium mt-0.5",
|
||||||
|
isSurplus ? "text-emerald-600/80" : "text-red-500/80"
|
||||||
|
)}>
|
||||||
|
{isSurplus ? 'Surplus' : 'Shortfall'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-3 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
className="p-2 text-blue-500 hover:text-blue-600 hover:bg-blue-50 rounded border border-blue-100 transition-colors shadow-sm"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
className="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 rounded border border-red-100 transition-colors shadow-sm"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,17 +1,319 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { storeClient } from '../../api/clients';
|
import { storeClient } from '../../api/clients';
|
||||||
import { STORE } from '../../api/config';
|
import { STORE, APP_ID } from '../../api/config';
|
||||||
import { DetailView } from './DetailView';
|
import { Card } from '../reusable/Card';
|
||||||
|
import { Spinner } from '../reusable/Spinner';
|
||||||
|
import { EmptyState } from '../reusable/EmptyState';
|
||||||
|
import { formatValue } from '../../lib/format';
|
||||||
|
import { Store, User, Truck, MapPin, TrendingUp, Camera, ClipboardList } from 'lucide-react';
|
||||||
|
import { GridTable } from './GridTable';
|
||||||
import type { WiredDetailViewProps } from './OrderDetail';
|
import type { WiredDetailViewProps } from './OrderDetail';
|
||||||
|
|
||||||
/** Store detail view (Store workflow). */
|
export function StoreDetail({ instanceId }: WiredDetailViewProps) {
|
||||||
export function StoreDetail({ instanceId, columns }: WiredDetailViewProps) {
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
async function run() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await storeClient.detailView(STORE.detailViews.STORE, instanceId);
|
||||||
|
if (live) setData(r.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||||
|
} finally {
|
||||||
|
if (live) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<DetailView
|
<Card title="Store Details">
|
||||||
client={storeClient}
|
<EmptyState title="Couldn’t load record" hint={error} />
|
||||||
dvUid={STORE.detailViews.STORE}
|
</Card>
|
||||||
instanceId={instanceId}
|
);
|
||||||
title="Store"
|
}
|
||||||
columns={columns}
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card title="Store Details">
|
||||||
|
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return (
|
||||||
|
<Card title="Store Details">
|
||||||
|
<EmptyState title="No details found" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingData = { ...data };
|
||||||
|
const extract = (key: string, obj: any = data) => {
|
||||||
|
if (obj && key in obj) {
|
||||||
|
const val = obj[key];
|
||||||
|
delete obj[key];
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
const stateName = extract('current_state_name', remainingData);
|
||||||
|
delete remainingData.current_state_id;
|
||||||
|
const isSuccess = ['created', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s));
|
||||||
|
const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500';
|
||||||
|
|
||||||
|
// Store Overview
|
||||||
|
const storeCode = extract('store_code', remainingData);
|
||||||
|
const businessName = extract('business_name', remainingData);
|
||||||
|
const area = extract('area', remainingData);
|
||||||
|
const completeAddress = extract('complete_address', remainingData);
|
||||||
|
const pinCode = extract('pin_code', remainingData);
|
||||||
|
const notes = extract('notes', remainingData);
|
||||||
|
|
||||||
|
// Owner Info
|
||||||
|
const ownerName = extract('owner_name', remainingData);
|
||||||
|
const email = extract('email', remainingData);
|
||||||
|
let phoneNumber = extract('phone_number', remainingData);
|
||||||
|
if (typeof phoneNumber === 'object' && phoneNumber !== null) {
|
||||||
|
phoneNumber = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distributor Info
|
||||||
|
const distributorName = extract('distributor_name', remainingData);
|
||||||
|
const distributorOwnerName = extract('distributor_owner_name', remainingData);
|
||||||
|
const distributorEmail = extract('distributor_email', remainingData);
|
||||||
|
let distributorPhone = extract('distributor_phone_number', remainingData);
|
||||||
|
if (typeof distributorPhone === 'object' && distributorPhone !== null) {
|
||||||
|
distributorPhone = (distributorPhone as any).phone_with_dial_code || (distributorPhone as any).phone || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route Info
|
||||||
|
const routeCode = extract('route_code', remainingData);
|
||||||
|
const routeName = extract('route_name', remainingData);
|
||||||
|
const subRoute = extract('sub_route', remainingData);
|
||||||
|
|
||||||
|
// Location
|
||||||
|
const storeLocation = extract('store_location', remainingData);
|
||||||
|
let lat = null;
|
||||||
|
let lng = null;
|
||||||
|
if (storeLocation) {
|
||||||
|
let locObj = storeLocation;
|
||||||
|
if (typeof locObj === 'string') {
|
||||||
|
try { locObj = JSON.parse(locObj); } catch (e) {}
|
||||||
|
}
|
||||||
|
if (locObj && typeof locObj === 'object') {
|
||||||
|
lat = (locObj as any).latitude;
|
||||||
|
lng = (locObj as any).longitude;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Potential
|
||||||
|
const potential = extract('potential', remainingData);
|
||||||
|
|
||||||
|
// Image
|
||||||
|
const storeImage = extract('store_image', remainingData);
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||||
|
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
|
||||||
|
|
||||||
|
const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id'));
|
||||||
|
const userObj = userIdKey ? extract(userIdKey, remainingData) : null;
|
||||||
|
const userName = userObj && typeof userObj === 'object' ? (userObj as any).name || (userObj as any).email : '-';
|
||||||
|
|
||||||
|
// Helper Row
|
||||||
|
const Row = ({ label, value }: { label: string, value: any }) => {
|
||||||
|
let finalValue = value;
|
||||||
|
if (finalValue && typeof finalValue === 'object') {
|
||||||
|
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
|
||||||
|
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
|
||||||
|
<span className="text-sm font-bold text-slate-800 sm:w-2/3">{formatValue(finalValue)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* 1. Status Card */}
|
||||||
|
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
|
||||||
|
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div>
|
||||||
|
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
|
||||||
|
{stateName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 2. Store Overview Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Store className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Overview</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Store Code" value={storeCode} />
|
||||||
|
<Row label="Business Name" value={businessName} />
|
||||||
|
<Row label="Area" value={area} />
|
||||||
|
<Row label="Complete Address" value={completeAddress} />
|
||||||
|
<Row label="PIN Code" value={pinCode} />
|
||||||
|
<Row label="Notes" value={notes} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 3. Owner Information Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<User className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Owner Information</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Owner Name" value={ownerName} />
|
||||||
|
<Row label="Phone Number" value={phoneNumber} />
|
||||||
|
<Row label="Email" value={email} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 4. Distributor Details Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Truck className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Distributor Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Distributor Name" value={distributorName} />
|
||||||
|
<Row label="Distributor Owner Name" value={distributorOwnerName} />
|
||||||
|
<Row label="Phone Number" value={distributorPhone} />
|
||||||
|
<Row label="Email" value={distributorEmail} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 5. Route Details Card */}
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<MapPin className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Route Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Route Name" value={routeName} />
|
||||||
|
<Row label="Route Code" value={routeCode} />
|
||||||
|
<Row label="Sub Route" value={subRoute} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 6. Potential Card */}
|
||||||
|
{Array.isArray(potential) && potential.length > 0 && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<TrendingUp className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Potential</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-0 sm:p-5">
|
||||||
|
<GridTable
|
||||||
|
data={potential}
|
||||||
|
columns={[
|
||||||
|
{ id: 'product_category', uid: '', name: 'Product Category', data_type: 'text' },
|
||||||
|
{ id: 'quantity', uid: '', name: 'Quantity (Kgs)', data_type: 'number' }
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 7. Store Image Card */}
|
||||||
|
{Array.isArray(storeImage) && storeImage.length > 0 && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<Camera className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Image</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{storeImage.map((file: any, idx: number) => {
|
||||||
|
const previewUrl = `${storeClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
||||||
|
return (
|
||||||
|
<div key={file.uuid || idx} className="relative w-full rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={file.original_name || 'Store'}
|
||||||
|
className="w-full h-auto max-h-[400px] object-contain transition-transform group-hover:scale-[1.02]"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 z-10"></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 7.5 Store Location Map */}
|
||||||
|
{lat && lng && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<MapPin className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Location</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-0">
|
||||||
|
<iframe
|
||||||
|
title="Store Location Map"
|
||||||
|
width="100%"
|
||||||
|
height="350"
|
||||||
|
style={{ border: 0 }}
|
||||||
|
loading="lazy"
|
||||||
|
allowFullScreen
|
||||||
|
src={`https://maps.google.com/maps?q=${lat},${lng}&hl=en&z=15&output=embed`}
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 8. Log Visit Details Fallback & Meta */}
|
||||||
|
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k)).length > 0 || userName !== '-') && (
|
||||||
|
<Card pad={false} className="shadow-md">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||||
|
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
|
||||||
|
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 flex flex-col">
|
||||||
|
<Row label="Created By" value={userName} />
|
||||||
|
<Row label="Created At" value={createdAt} />
|
||||||
|
{Object.entries(remainingData).map(([key, value]) => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
|
||||||
|
|
||||||
|
const label = key.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, l => l.toUpperCase())
|
||||||
|
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
|
||||||
|
.replace(/^\s+|\s+$/g, '');
|
||||||
|
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return <Row key={key} label={label} value={value} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,3 +5,7 @@ export type { WiredDetailViewProps } from './OrderDetail';
|
|||||||
export { CallDetail } from './CallDetail';
|
export { CallDetail } from './CallDetail';
|
||||||
export { StoreDetail } from './StoreDetail';
|
export { StoreDetail } from './StoreDetail';
|
||||||
export { DailyLogDetail } from './DailyLogDetail';
|
export { DailyLogDetail } from './DailyLogDetail';
|
||||||
|
export { PotentialMiningTable } from './PotentialMiningTable';
|
||||||
|
export { GridTable } from './GridTable';
|
||||||
|
export type { GridTableProps } from './GridTable';
|
||||||
|
export type { MiningItem, PotentialMiningTableProps } from './PotentialMiningTable';
|
||||||
|
|||||||
@ -191,12 +191,12 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
|||||||
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) {
|
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) {
|
||||||
const uploadedFiles = [];
|
const uploadedFiles = [];
|
||||||
for (const file of val) {
|
for (const file of val) {
|
||||||
const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id });
|
const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
|
||||||
uploadedFiles.push(fileMeta);
|
uploadedFiles.push(fileMeta);
|
||||||
}
|
}
|
||||||
payload[f.id] = uploadedFiles;
|
payload[f.id] = uploadedFiles;
|
||||||
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) {
|
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) {
|
||||||
const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id });
|
const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
|
||||||
payload[f.id] = [fileMeta];
|
payload[f.id] = [fileMeta];
|
||||||
} else {
|
} else {
|
||||||
payload[f.id] = val;
|
payload[f.id] = val;
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import { useState, useRef } from 'react';
|
|||||||
import { Button } from '../../buttons/Button';
|
import { Button } from '../../buttons/Button';
|
||||||
import { Select } from '../../reusable/Select';
|
import { Select } from '../../reusable/Select';
|
||||||
import { Input } from '../../reusable/Input';
|
import { Input } from '../../reusable/Input';
|
||||||
import { Trash2, Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
import type { FormScreenField } from '../../../api/types';
|
import type { FormScreenField } from '../../../api/types';
|
||||||
|
import { PotentialMiningTable } from '../../dv/PotentialMiningTable';
|
||||||
|
import { GridTable } from '../../dv/GridTable';
|
||||||
|
|
||||||
export function SmartGridField({
|
export function SmartGridField({
|
||||||
label,
|
label,
|
||||||
@ -43,6 +45,9 @@ export function SmartGridField({
|
|||||||
setEditingIdx(idx);
|
setEditingIdx(idx);
|
||||||
setNewRow({ ...value[idx] });
|
setNewRow({ ...value[idx] });
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startAdd = () => {
|
const startAdd = () => {
|
||||||
@ -101,6 +106,18 @@ export function SmartGridField({
|
|||||||
row['row_kgs'] = skuVal * bagsVal;
|
row['row_kgs'] = skuVal * bagsVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-calculate difference for potential mining
|
||||||
|
const potentialCol = columns.find(c => c.id.toLowerCase().includes('potential') || c.name.toLowerCase().includes('potential'));
|
||||||
|
const totalOrderedCol = columns.find(c => c.id.toLowerCase().includes('total_ordered') || c.name.toLowerCase().includes('total ordered'));
|
||||||
|
if (potentialCol && totalOrderedCol) {
|
||||||
|
const p = Number(row[potentialCol.id]) || 0;
|
||||||
|
const t = Number(row[totalOrderedCol.id]) || 0;
|
||||||
|
const diffCol = columns.find(c => c.id.toLowerCase().includes('difference') || c.name.toLowerCase().includes('difference'));
|
||||||
|
if (diffCol) {
|
||||||
|
row[diffCol.id] = t - p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setNewRow(row);
|
setNewRow(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -152,82 +169,59 @@ export function SmartGridField({
|
|||||||
setEditingIdx(null);
|
setEditingIdx(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPotentialGrid = label.toLowerCase().includes('potential');
|
||||||
|
const hasOrderedColumn = columns.some(c => c.id.toLowerCase().includes('ordered') || (c.name || '').toLowerCase().includes('ordered'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
|
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
|
||||||
<span className="text-sm font-semibold text-strong mb-2">{label}</span>
|
<span className="text-sm font-semibold text-strong mb-2">{label}</span>
|
||||||
|
|
||||||
{value.length === 0 ? (
|
{value.length === 0 ? (
|
||||||
<span className="text-sm text-faint italic">No rows added.</span>
|
<span className="text-sm text-faint italic">No rows added.</span>
|
||||||
) : (
|
) : isPotentialGrid ? (
|
||||||
<div className="flex flex-col gap-3">
|
<PotentialMiningTable
|
||||||
{value.map((row, i) => {
|
hideOrderDetails={!hasOrderedColumn}
|
||||||
let productName = 'Unknown Product';
|
data={value.map((row, i) => {
|
||||||
let bags = '0';
|
const reason = String(row.reason || row.Reason || row.potential_remarks || '');
|
||||||
|
let badgeType: 'error' | 'success' | 'info' | 'warning' | 'default' = 'default';
|
||||||
|
const lowerReason = reason.toLowerCase();
|
||||||
|
if (lowerReason.includes('stock')) badgeType = 'success';
|
||||||
|
else if (lowerReason.includes('quality')) badgeType = 'error';
|
||||||
|
else if (lowerReason.includes('price')) badgeType = 'warning';
|
||||||
|
else if (lowerReason.includes('competitive') || lowerReason.includes('loyalty')) badgeType = 'info';
|
||||||
|
|
||||||
|
let productName = 'Unknown Product';
|
||||||
visibleColumns.forEach(col => {
|
visibleColumns.forEach(col => {
|
||||||
const isName = col.id.toLowerCase().includes('name') || col.name.toLowerCase().includes('name') || (col.id.toLowerCase().includes('category') && productName === 'Unknown Product');
|
const isName = col.id.toLowerCase().includes('name') || col.name.toLowerCase().includes('name') || (col.id.toLowerCase().includes('category') && productName === 'Unknown Product');
|
||||||
const isBags = col.id.toLowerCase().includes('bags') || col.name.toLowerCase().includes('bags') || col.id.toLowerCase().includes('quantity');
|
if (isName) {
|
||||||
|
|
||||||
if (isName || isBags) {
|
|
||||||
const val = row[col.id];
|
const val = row[col.id];
|
||||||
let displayVal = String(val ?? '-');
|
let displayVal = String(val ?? '-');
|
||||||
|
|
||||||
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||||
const opt = col.properties?.options?.find(o => String(o.value) === String(val));
|
const opt = col.properties?.options?.find(o => String(o.value) === String(val));
|
||||||
if (opt) displayVal = opt.label;
|
if (opt) displayVal = opt.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isName && (productName === 'Unknown Product' || col.id.toLowerCase().includes('name'))) {
|
|
||||||
productName = displayVal;
|
productName = displayVal;
|
||||||
}
|
}
|
||||||
if (isBags) bags = displayVal;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<div key={i} className="bg-slate-50 border border-slate-200 rounded-xl p-3 shadow-sm flex items-center justify-between">
|
id: String(i),
|
||||||
<div className="flex flex-col min-w-0 pr-4 w-full">
|
productName,
|
||||||
<span className="font-bold text-slate-800 text-[14px] truncate">{productName}</span>
|
badge: reason ? { label: reason, type: badgeType } : undefined,
|
||||||
{label.toLowerCase().includes('potential') ? (
|
potentialKgs: Number(row.actual_potential ?? row.Actual_Potential ?? row.store_potential ?? row.Store_Potential ?? row.quantity ?? row.Quantity ?? 0),
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1.5 text-[12px]">
|
orderedKgs: Number(row.total_ordered ?? row.Total_Ordered ?? 0),
|
||||||
<div className="flex justify-between">
|
};
|
||||||
<span className="text-slate-500">Total Ordered:</span>
|
|
||||||
<span className="font-bold text-slate-700">{String(row.total_ordered ?? row.Total_Ordered ?? '-')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Potential:</span>
|
|
||||||
<span className="font-bold text-slate-700">{String(row.actual_potential ?? row.Actual_Potential ?? row.store_potential ?? row.Store_Potential ?? '-')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between col-span-2 border-t border-slate-100 pt-1 mt-0.5">
|
|
||||||
<span className="text-slate-500">Difference:</span>
|
|
||||||
{(() => {
|
|
||||||
const diff = Number(row.difference ?? row.Difference);
|
|
||||||
if (isNaN(diff)) return <span className="font-bold text-slate-700">-</span>;
|
|
||||||
return (
|
|
||||||
<span className={`font-bold ${diff < 0 ? 'text-red-600' : 'text-green-600'}`}>
|
|
||||||
{diff < 0 ? `${Math.abs(diff)} Kgs Less Ordered` : `${diff} Kgs More Ordered`}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="font-extrabold text-indigo-600 text-[13px] mt-0.5">{bags} Bags</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 pl-2">
|
|
||||||
<button type="button" onClick={() => startEdit(i)} className="text-indigo-600 bg-white border border-indigo-100 hover:bg-indigo-50 p-1.5 rounded-lg transition-colors flex items-center justify-center shadow-sm" title="Edit">
|
|
||||||
<Pencil size={14} />
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => removeRow(i)} className="text-red-600 bg-white border border-red-100 hover:bg-red-50 p-1.5 rounded-lg transition-colors flex items-center justify-center shadow-sm" title="Remove">
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
onEdit={(item) => startEdit(Number(item.id))}
|
||||||
|
onDelete={(item) => removeRow(Number(item.id))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GridTable
|
||||||
|
data={value}
|
||||||
|
columns={visibleColumns}
|
||||||
|
onEdit={startEdit}
|
||||||
|
onDelete={removeRow}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end mt-2">
|
<div className="flex justify-end mt-2">
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer
|
ResponsiveContainer,
|
||||||
|
Legend
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
export interface ChartRow {
|
export interface ChartRow {
|
||||||
dimension: string;
|
dimension: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
series?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartConfig {
|
export interface ChartConfig {
|
||||||
@ -37,19 +39,42 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
|
|||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
.replace(/\b\w/g, c => c.toUpperCase());
|
.replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
|
||||||
// Safely format dimension string (fallbacks for empty strings)
|
const hasSeries = chart.rows?.some(r => r.series);
|
||||||
const formattedRows = (chart.rows || []).map(r => ({
|
let finalData: any[] = [];
|
||||||
...r,
|
let seriesKeys: string[] = [];
|
||||||
dimension: String(r.dimension || '').trim() || 'Unknown'
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (formattedRows.length === 0) return null;
|
if (hasSeries) {
|
||||||
|
const grouped = new Map<string, any>();
|
||||||
|
const sKeys = new Set<string>();
|
||||||
|
chart.rows?.forEach(r => {
|
||||||
|
const dim = String(r.dimension || '').trim() || 'Unknown';
|
||||||
|
const s = String(r.series || '').trim() || 'Unknown';
|
||||||
|
sKeys.add(s);
|
||||||
|
|
||||||
|
if (!grouped.has(dim)) {
|
||||||
|
grouped.set(dim, { dimension: dim });
|
||||||
|
}
|
||||||
|
const entry = grouped.get(dim);
|
||||||
|
entry[s] = Number(r.value || 0);
|
||||||
|
});
|
||||||
|
finalData = Array.from(grouped.values());
|
||||||
|
seriesKeys = Array.from(sKeys).sort(); // Sort series keys for consistency
|
||||||
|
} else {
|
||||||
|
finalData = (chart.rows || []).map(r => ({
|
||||||
|
...r,
|
||||||
|
dimension: String(r.dimension || '').trim() || 'Unknown',
|
||||||
|
value: Number(r.value || 0)
|
||||||
|
}));
|
||||||
|
seriesKeys = ['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalData.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={chart.chart_uid || idx} title={title} className="shadow-sm border-t-4 border-t-indigo-500">
|
<Card key={chart.chart_uid || idx} title={title} className="shadow-sm border-t-4 border-t-indigo-500">
|
||||||
<div className="h-[320px] w-full mt-4">
|
<div className="h-[320px] w-full mt-4">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={formattedRows} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
<BarChart data={finalData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E2E8F0" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E2E8F0" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="dimension"
|
dataKey="dimension"
|
||||||
@ -69,13 +94,17 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
|
|||||||
contentStyle={{ borderRadius: '8px', border: '1px solid #E2E8F0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', fontSize: '14px', fontFamily: 'inherit' }}
|
contentStyle={{ borderRadius: '8px', border: '1px solid #E2E8F0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', fontSize: '14px', fontFamily: 'inherit' }}
|
||||||
labelStyle={{ fontWeight: 'bold', color: '#0F172A', marginBottom: '4px' }}
|
labelStyle={{ fontWeight: 'bold', color: '#0F172A', marginBottom: '4px' }}
|
||||||
/>
|
/>
|
||||||
|
{hasSeries && <Legend wrapperStyle={{ paddingTop: '20px' }} />}
|
||||||
|
{seriesKeys.map((key, i) => (
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="value"
|
key={key}
|
||||||
name="Value"
|
dataKey={key}
|
||||||
fill={colors[idx % colors.length]}
|
name={hasSeries ? key : "Value"}
|
||||||
|
fill={colors[i % colors.length]}
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
maxBarSize={40}
|
maxBarSize={40}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -40,15 +40,15 @@ export function Card({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-card rounded-lg border border-border-subtle overflow-hidden font-sans',
|
'bg-[var(--z-block-bg)] rounded-lg border border-[var(--z-block-border)] overflow-hidden font-sans',
|
||||||
SHADOW[elevation],
|
SHADOW[elevation],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
|
<header className="flex items-center justify-between px-5 py-4 border-b border-[var(--z-block-border)]">
|
||||||
<h3 className="m-0 text-md font-semibold text-strong">{title}</h3>
|
<h3 className="m-0 text-md font-semibold text-[var(--z-text-default)]">{title}</h3>
|
||||||
{action}
|
{action}
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -5,12 +5,6 @@ import { cn } from '../../lib/cn';
|
|||||||
|
|
||||||
export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl';
|
export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
const WIDTH: Record<ModalWidth, string> = {
|
|
||||||
sm: 'max-w-[480px]',
|
|
||||||
md: 'max-w-[640px]',
|
|
||||||
lg: 'max-w-[820px]',
|
|
||||||
xl: 'max-w-[1000px]',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ModalProps {
|
export interface ModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -23,6 +17,13 @@ export interface ModalProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WIDTH_CLASSES: Record<ModalWidth, string> = {
|
||||||
|
sm: 'sm:w-[30vw]',
|
||||||
|
md: 'sm:w-[50vw]',
|
||||||
|
lg: 'sm:w-[70vw]',
|
||||||
|
xl: 'sm:w-[90vw]',
|
||||||
|
};
|
||||||
|
|
||||||
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
|
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
|
||||||
export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
|
export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -43,21 +44,30 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-start justify-center overflow-y-auto bg-black/40 p-4 sm:p-8"
|
className="fixed inset-0 z-[10000] flex items-start justify-end overflow-hidden bg-black/10 transition-opacity"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto',
|
'w-full bg-[var(--z-block-bg)] shadow-[auto_0_30px_rgba(0,0,0,0.1)] my-0',
|
||||||
'flex flex-col max-h-[85vh]',
|
WIDTH_CLASSES[width],
|
||||||
WIDTH[width],
|
'flex flex-col h-[100dvh] rounded-l-2xl rounded-r-none animate-slide-in-right border-l border-[var(--z-border-default)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle">
|
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--z-border-default)]">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
|
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
|
||||||
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
|
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { Button } from '../buttons/Button';
|
import { Button } from '../buttons/Button';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export interface PaginationProps {
|
export interface PaginationProps {
|
||||||
/** 1-based current page. */
|
/** 1-based current page. */
|
||||||
@ -8,21 +9,70 @@ export interface PaginationProps {
|
|||||||
/** Total rows across all pages. */
|
/** Total rows across all pages. */
|
||||||
total: number;
|
total: number;
|
||||||
onPage: (page: number) => void;
|
onPage: (page: number) => void;
|
||||||
|
onPageSizeChange?: (size: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pager: "Showing a–b of N" + Prev/Next. Renders nothing when the set fits on
|
/** Pager: "Showing a–b of N" + Rows per page + Jump to + Prev/Next.
|
||||||
* one page. Shared by the record-view tables. */
|
* Shared by the record-view tables. */
|
||||||
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
|
export function Pagination({ page, pageSize, total, onPage, onPageSizeChange }: PaginationProps) {
|
||||||
|
const [jumpPage, setJumpPage] = useState(String(page));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setJumpPage(String(page));
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
if (total <= 0) return null;
|
if (total <= 0) return null;
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const safePage = Math.min(Math.max(page, 1), totalPages);
|
const safePage = Math.min(Math.max(page, 1), totalPages);
|
||||||
const start = (safePage - 1) * pageSize;
|
const start = (safePage - 1) * pageSize;
|
||||||
|
|
||||||
|
const handleJump = () => {
|
||||||
|
const p = parseInt(jumpPage, 10);
|
||||||
|
if (!isNaN(p) && p >= 1 && p <= totalPages) {
|
||||||
|
onPage(p);
|
||||||
|
} else {
|
||||||
|
setJumpPage(String(safePage));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border-subtle px-[18px] py-3">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border-subtle px-[18px] py-3">
|
||||||
<span className="text-xs text-faint">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<span className="text-xs text-faint whitespace-nowrap">
|
||||||
Showing {start + 1}–{Math.min(start + pageSize, total)} of {total}
|
Showing {start + 1}–{Math.min(start + pageSize, total)} of {total}
|
||||||
</span>
|
</span>
|
||||||
|
{onPageSizeChange && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted">Rows per page:</span>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="h-7 rounded border border-border-default bg-card px-1 py-0 text-xs text-strong outline-none focus-ring cursor-pointer"
|
||||||
|
>
|
||||||
|
{[10, 25, 50, 100].map(sz => (
|
||||||
|
<option key={sz} value={sz}>{sz}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted">Jump to:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
value={jumpPage}
|
||||||
|
onChange={e => setJumpPage(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleJump()}
|
||||||
|
onBlur={handleJump}
|
||||||
|
className="w-14 h-7 rounded border border-border-default bg-card px-2 py-1 text-xs text-strong text-center outline-none focus-ring"
|
||||||
|
style={{ MozAppearance: 'textfield' }} // best effort hide spinner on FF
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="secondary" size="sm" disabled={safePage <= 1} onClick={() => onPage(safePage - 1)}>
|
<Button variant="secondary" size="sm" disabled={safePage <= 1} onClick={() => onPage(safePage - 1)}>
|
||||||
<ChevronLeft size={15} />
|
<ChevronLeft size={15} />
|
||||||
@ -37,5 +87,6 @@ export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,10 @@
|
|||||||
import { Card } from "./Card";
|
import { Phone, TrendingUp, Activity, BarChart2, ShoppingCart, ShoppingBag, Scale } from 'lucide-react';
|
||||||
import type { TileItem } from "../../api/types";
|
import type { TileItem } from "../../api/types";
|
||||||
|
|
||||||
export interface StatsTilesProps {
|
export interface StatsTilesProps {
|
||||||
tiles?: TileItem[];
|
tiles?: TileItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = [
|
|
||||||
"from-blue-500 to-cyan-500",
|
|
||||||
"from-emerald-500 to-green-500",
|
|
||||||
"from-orange-500 to-amber-500",
|
|
||||||
"from-violet-500 to-fuchsia-500",
|
|
||||||
"from-pink-500 to-rose-500",
|
|
||||||
"from-indigo-500 to-blue-500",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function StatsTiles({ tiles }: StatsTilesProps) {
|
export function StatsTiles({ tiles }: StatsTilesProps) {
|
||||||
if (!tiles?.length) return null;
|
if (!tiles?.length) return null;
|
||||||
|
|
||||||
@ -24,35 +15,41 @@ export function StatsTiles({ tiles }: StatsTilesProps) {
|
|||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
const gradient = colors[idx % colors.length];
|
const lowerKey = String(tile.key).toLowerCase();
|
||||||
|
let Icon = BarChart2;
|
||||||
|
if (lowerKey.includes('call') || lowerKey.includes('total')) Icon = Phone;
|
||||||
|
if (lowerKey.includes('productive')) Icon = TrendingUp;
|
||||||
|
if (lowerKey.includes('order') || lowerKey.includes('cart')) Icon = ShoppingCart;
|
||||||
|
if (lowerKey.includes('bag')) Icon = ShoppingBag;
|
||||||
|
if (lowerKey.includes('kg') || lowerKey.includes('weight')) Icon = Scale;
|
||||||
|
if (lowerKey.includes('active')) Icon = Activity;
|
||||||
|
|
||||||
|
const isDanger = lowerKey.includes('no_order');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
key={tile.tile_uid || idx}
|
key={tile.tile_uid || idx}
|
||||||
className="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
className={`flex flex-col justify-between w-full h-[100px] p-4 rounded-lg bg-[var(--z-bg-neutral-100)] border border-[var(--z-border-neutral-300)] border-t-4 shadow-sm transition-transform duration-200 hover:-translate-y-1 ${
|
||||||
|
isDanger ? 'border-t-[var(--z-text-danger-400)]' : 'border-t-[var(--z-text-primary)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Top Gradient */}
|
<div className="flex justify-between items-center">
|
||||||
<div
|
<span className={`text-[10px] font-bold uppercase tracking-wider whitespace-nowrap overflow-hidden text-ellipsis max-w-[80%] ${
|
||||||
className={`absolute left-0 top-0 h-1.5 w-full bg-gradient-to-r ${gradient}`}
|
isDanger ? 'text-[var(--z-text-danger-400)]' : 'text-[var(--z-text-neutral-500)]'
|
||||||
/>
|
}`}>
|
||||||
|
|
||||||
{/* Background Decoration */}
|
|
||||||
<div
|
|
||||||
className={`absolute -right-6 -top-6 h-24 w-24 rounded-full bg-gradient-to-br ${gradient} opacity-10 transition-all duration-300 group-hover:scale-125`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative flex flex-col gap-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.15em] text-gray-500">
|
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
</p>
|
</span>
|
||||||
|
<Icon size={16} className={`${
|
||||||
<h2 className="text-4xl font-bold tracking-tight text-gray-900">
|
isDanger ? 'text-[var(--z-text-danger-400)]' : 'text-[var(--z-text-success-400)]'
|
||||||
{(tile.value as React.ReactNode) ?? "-"}
|
}`} />
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
<div className="h-1 w-12 rounded-full bg-gray-200 transition-all duration-300 group-hover:w-20 group-hover:bg-blue-500" />
|
<p className={`text-[30px] font-extrabold leading-tight tabular-nums ${
|
||||||
|
isDanger ? 'text-[var(--z-text-danger-400)]' : 'text-[var(--z-text-primary)]'
|
||||||
|
}`}>
|
||||||
|
{(tile.value as React.ReactNode) ?? "-"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { RecordView } from './RecordView';
|
|||||||
import type { WiredRecordViewProps } from './OrdersView';
|
import type { WiredRecordViewProps } from './OrdersView';
|
||||||
|
|
||||||
/** Daily Logs record view (Daily Reports workflow). */
|
/** Daily Logs record view (Daily Reports workflow). */
|
||||||
export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, presetAlias }: WiredRecordViewProps) {
|
||||||
return (
|
return (
|
||||||
<RecordView
|
<RecordView
|
||||||
client={dailyReportsClient}
|
client={dailyReportsClient}
|
||||||
@ -15,6 +15,7 @@ export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions,
|
|||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
rowActions={rowActions}
|
rowActions={rowActions}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
presetAlias={presetAlias}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,10 +9,11 @@ export interface WiredRecordViewProps {
|
|||||||
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
|
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
initialFilters?: Record<string, string>;
|
initialFilters?: Record<string, string>;
|
||||||
|
presetAlias?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Orders record view (Order Booking workflow). */
|
/** Orders record view (Order Booking workflow). */
|
||||||
export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, initialFilters }: WiredRecordViewProps) {
|
export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, initialFilters, presetAlias }: WiredRecordViewProps) {
|
||||||
return (
|
return (
|
||||||
<RecordView
|
<RecordView
|
||||||
client={orderBookingClient}
|
client={orderBookingClient}
|
||||||
@ -27,6 +28,7 @@ export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, re
|
|||||||
sortDir="desc"
|
sortDir="desc"
|
||||||
omitColumns={['created_at', 'instance_id']}
|
omitColumns={['created_at', 'instance_id']}
|
||||||
initialFilters={initialFilters}
|
initialFilters={initialFilters}
|
||||||
|
presetAlias={presetAlias}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,8 @@ export interface RecordViewProps {
|
|||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
/** Default filters to apply initially. */
|
/** Default filters to apply initially. */
|
||||||
initialFilters?: Record<string, string>;
|
initialFilters?: Record<string, string>;
|
||||||
|
/** Custom preset alias to send with the request (e.g. preset_alias: my_orders) */
|
||||||
|
presetAlias?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,7 +55,7 @@ export function RecordView({
|
|||||||
rvUid,
|
rvUid,
|
||||||
title = 'Records',
|
title = 'Records',
|
||||||
columns,
|
columns,
|
||||||
pageSize = 50,
|
pageSize = 25,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
rowKey,
|
rowKey,
|
||||||
headerActions,
|
headerActions,
|
||||||
@ -63,7 +65,9 @@ export function RecordView({
|
|||||||
sortDir,
|
sortDir,
|
||||||
omitColumns,
|
omitColumns,
|
||||||
initialFilters = {},
|
initialFilters = {},
|
||||||
|
presetAlias,
|
||||||
}: RecordViewProps) {
|
}: RecordViewProps) {
|
||||||
|
const [internalPageSize, setInternalPageSize] = useState(pageSize);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
@ -103,11 +107,12 @@ export function RecordView({
|
|||||||
try {
|
try {
|
||||||
const r = await client.recordView(rvUid, {
|
const r = await client.recordView(rvUid, {
|
||||||
page,
|
page,
|
||||||
limit: pageSize,
|
limit: internalPageSize,
|
||||||
search: debounced,
|
search: debounced,
|
||||||
filters: filtersParam,
|
filters: filtersParam,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDir
|
sortDir,
|
||||||
|
presetAlias
|
||||||
});
|
});
|
||||||
console.log("RECORD VIEW RESP:", r);
|
console.log("RECORD VIEW RESP:", r);
|
||||||
if (live) setResp(r);
|
if (live) setResp(r);
|
||||||
@ -121,7 +126,7 @@ export function RecordView({
|
|||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey, sortBy, sortDir]);
|
}, [client, rvUid, page, internalPageSize, debounced, filtersParam, refreshKey, sortBy, sortDir]);
|
||||||
|
|
||||||
const fields: RecordViewField[] = useMemo(() => {
|
const fields: RecordViewField[] = useMemo(() => {
|
||||||
const all = resp?.config.fields ?? [];
|
const all = resp?.config.fields ?? [];
|
||||||
@ -157,7 +162,7 @@ export function RecordView({
|
|||||||
action={headerActions}
|
action={headerActions}
|
||||||
pad={false}
|
pad={false}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 border-b border-border-subtle bg-slate-50/50">
|
<div className="flex flex-wrap items-center justify-between gap-4 p-4 border-b border-[var(--z-block-border)] bg-[var(--z-rv-table-header)]">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!resp?.config) return null;
|
if (!resp?.config) return null;
|
||||||
@ -232,7 +237,7 @@ export function RecordView({
|
|||||||
<div className="overflow-x-auto scrollbar-slim">
|
<div className="overflow-x-auto scrollbar-slim">
|
||||||
<table className="w-full border-collapse min-w-[640px]">
|
<table className="w-full border-collapse min-w-[640px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-50 border-b border-border-subtle">
|
<tr className="bg-[var(--z-rv-table-header)] border-b border-[var(--z-rv-table-border)]">
|
||||||
{fields.map((f) => (
|
{fields.map((f) => (
|
||||||
<th
|
<th
|
||||||
key={f.field_key}
|
key={f.field_key}
|
||||||
@ -254,15 +259,69 @@ export function RecordView({
|
|||||||
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
|
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
|
||||||
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
|
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-surface border-b border-border-subtle transition-colors duration-150',
|
'bg-[var(--z-rv-table-bg)] border-b border-[var(--z-rv-table-border)] transition-colors duration-150',
|
||||||
onRowClick && 'cursor-pointer hover:bg-slate-50',
|
onRowClick && 'cursor-pointer hover:bg-[var(--table-row-bg-select)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{fields.map((f) => (
|
{fields.map((f) => {
|
||||||
|
const lowerKey = f.field_key.toLowerCase();
|
||||||
|
const isSku = lowerKey === 'sku_code';
|
||||||
|
const isStatus = lowerKey.includes('status') || lowerKey.includes('state');
|
||||||
|
const valStr = formatValue(row[f.field_key], f.field_key);
|
||||||
|
|
||||||
|
let content = <>{valStr}</>;
|
||||||
|
|
||||||
|
if (valStr !== '—' && valStr !== '') {
|
||||||
|
if (isSku || isStatus) {
|
||||||
|
const palettes = [
|
||||||
|
"bg-blue-50 text-blue-700 border-blue-200",
|
||||||
|
"bg-purple-50 text-purple-700 border-purple-200",
|
||||||
|
"bg-pink-50 text-pink-700 border-pink-200",
|
||||||
|
"bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||||
|
"bg-cyan-50 text-cyan-700 border-cyan-200",
|
||||||
|
"bg-rose-50 text-rose-700 border-rose-200",
|
||||||
|
"bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200",
|
||||||
|
"bg-teal-50 text-teal-700 border-teal-200",
|
||||||
|
];
|
||||||
|
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < valStr.length; i++) {
|
||||||
|
hash = valStr.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const colorIndex = Math.abs(hash) % palettes.length;
|
||||||
|
let colorClass = palettes[colorIndex];
|
||||||
|
|
||||||
|
if (isSku) {
|
||||||
|
content = (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[12px] font-mono font-medium border shadow-sm ${colorClass}`}>
|
||||||
|
{valStr}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (isStatus) {
|
||||||
|
const valLower = valStr.toLowerCase();
|
||||||
|
const isSuccess = valLower.includes('approve') || valLower.includes('complete') || valLower.includes('success') || valLower.includes('active');
|
||||||
|
const isWarning = valLower.includes('pending') || valLower.includes('draft') || valLower.includes('hold');
|
||||||
|
const isDanger = valLower.includes('reject') || valLower.includes('fail') || valLower.includes('cancel');
|
||||||
|
|
||||||
|
if (isSuccess) colorClass = "bg-emerald-50 text-emerald-700 border-emerald-200";
|
||||||
|
else if (isWarning) colorClass = "bg-amber-50 text-amber-700 border-amber-200";
|
||||||
|
else if (isDanger) colorClass = "bg-red-50 text-red-700 border-red-200";
|
||||||
|
|
||||||
|
content = (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-bold uppercase tracking-wider border shadow-sm ${colorClass}`}>
|
||||||
|
{valStr}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<td key={f.field_key} className="px-[18px] py-3 text-sm text-body whitespace-nowrap">
|
<td key={f.field_key} className="px-[18px] py-3 text-sm text-body whitespace-nowrap">
|
||||||
{formatValue(row[f.field_key], f.field_key)}
|
{content}
|
||||||
</td>
|
</td>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{rowActions && (
|
{rowActions && (
|
||||||
<td className="sticky right-0 bg-inherit px-4 py-3 whitespace-nowrap border-b border-border-subtle z-10">
|
<td className="sticky right-0 bg-inherit px-4 py-3 whitespace-nowrap border-b border-border-subtle z-10">
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
@ -276,7 +335,16 @@ export function RecordView({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageSize={internalPageSize}
|
||||||
|
total={total}
|
||||||
|
onPage={setPage}
|
||||||
|
onPageSizeChange={(sz) => {
|
||||||
|
setInternalPageSize(sz);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -117,44 +117,50 @@ export function CallsPage() {
|
|||||||
onClose={() => navigate(`/calls`)}
|
onClose={() => navigate(`/calls`)}
|
||||||
title={instanceId != null ? `Call #${instanceId}` : undefined}
|
title={instanceId != null ? `Call #${instanceId}` : undefined}
|
||||||
width="lg"
|
width="lg"
|
||||||
actions={
|
>
|
||||||
<>
|
{instanceId != null && selectedRow && (
|
||||||
<Button size="sm" variant="secondary" onClick={handlePotentialMiningClick} disabled={miningLoading}>
|
<div className="pb-4">
|
||||||
|
<CallDetail
|
||||||
|
instanceId={instanceId}
|
||||||
|
selectedRow={selectedRow}
|
||||||
|
potentialMiningAction={
|
||||||
|
<Button size="sm" variant="secondary" className="w-48 max-w-full" onClick={handlePotentialMiningClick} disabled={miningLoading}>
|
||||||
{miningLoading ? 'Loading...' : 'Potential Mining'}
|
{miningLoading ? 'Loading...' : 'Potential Mining'}
|
||||||
</Button>
|
</Button>
|
||||||
{(() => {
|
}
|
||||||
const stateName = String(selectedRow?.current_state_name || selectedRow?.current_state_name_ || selectedRow?.current_state || selectedRow?.status || '').toLowerCase();
|
placeOrderAction={
|
||||||
|
(() => {
|
||||||
|
const stateName = String(selectedRow.current_state_name || selectedRow.current_state_name_ || selectedRow.current_state || selectedRow.status || '').toLowerCase();
|
||||||
if (stateName.includes('ordered') || stateName === 'ordered' || stateName.includes('order')) {
|
if (stateName.includes('ordered') || stateName === 'ordered' || stateName.includes('order')) {
|
||||||
return (
|
return (
|
||||||
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.EDIT_ORDER.uid, name: 'Edit Order' })}>
|
<Button onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.EDIT_ORDER.uid, name: 'Edit Order' })}>
|
||||||
Edit Order
|
Edit Order
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
} else if (stateName.includes('productive')) {
|
} else if (stateName.includes('productive')) {
|
||||||
return (
|
return (
|
||||||
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
|
<Button onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
|
||||||
Place Order
|
Place Order
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
|
<Button onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
|
||||||
Place Order
|
Place Order
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})()}
|
})()
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
>
|
</div>
|
||||||
{instanceId != null && <CallDetail instanceId={instanceId} />}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={activeActivity != null}
|
open={activeActivity != null}
|
||||||
onClose={() => setActiveActivity(null)}
|
onClose={() => setActiveActivity(null)}
|
||||||
title={activeActivity?.name}
|
title={activeActivity?.name}
|
||||||
width="md"
|
width={activeActivity?.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? 'lg' : 'md'}
|
||||||
>
|
>
|
||||||
{activeActivity && instanceId != null && (
|
{activeActivity && instanceId != null && (
|
||||||
<DynamicForm
|
<DynamicForm
|
||||||
|
|||||||
@ -1,19 +1,23 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { LogOut } from 'lucide-react';
|
import { LogOut, FileText, ChevronDown } from 'lucide-react';
|
||||||
import { cn } from '../lib/cn';
|
import { cn } from '../lib/cn';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||||
import { APP_ID } from '../api/config';
|
|
||||||
import { Button } from '../components/buttons';
|
import { Button } from '../components/buttons';
|
||||||
import { SCREENS } from './tabs';
|
import { SCREENS } from './tabs';
|
||||||
|
import { REPORT_MAP } from './ReportPage';
|
||||||
|
|
||||||
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
||||||
export function ConsoleLayout() {
|
export function ConsoleLayout() {
|
||||||
const { authed, logout } = useAuth();
|
const { authed, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const user = orderBookingClient.currentUser();
|
const user = orderBookingClient.currentUser();
|
||||||
|
|
||||||
|
const isReportsActive = location.pathname.startsWith('/reports');
|
||||||
|
|
||||||
// A 401 from any workflow client bounces back to login.
|
// A 401 from any workflow client bounces back to login.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onAuthErrorAll(() => {
|
onAuthErrorAll(() => {
|
||||||
@ -26,10 +30,9 @@ export function ConsoleLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-app flex flex-col">
|
<div className="h-screen bg-app flex flex-col">
|
||||||
<header className="sticky top-0 z-10 shrink-0 flex items-center justify-between gap-3 px-6 h-[60px] bg-navy-grad border-b border-border-navy">
|
<header className="sticky top-0 z-10 shrink-0 flex items-center justify-between gap-3 px-6 h-[60px] bg-[var(--z-bg-primary)] border-b border-[var(--z-bg-primary)] shadow-sm">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-md font-extrabold text-on-navy tracking-[-0.01em] leading-none">Krishna Sales</span>
|
<span className="text-md font-extrabold text-white tracking-[-0.01em] leading-none">Krishna Sales</span>
|
||||||
<span className="text-2xs text-on-navy-muted">Field Sales · Sandbox {APP_ID}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-1 h-full">
|
<nav className="flex items-center gap-1 h-full">
|
||||||
{SCREENS.map((t) => {
|
{SCREENS.map((t) => {
|
||||||
@ -41,7 +44,7 @@ export function ConsoleLayout() {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-1.5 no-underline font-sans text-sm font-medium px-3 py-1.5 rounded-md transition-all duration-150',
|
'flex items-center gap-1.5 no-underline font-sans text-sm font-medium px-3 py-1.5 rounded-md transition-all duration-150',
|
||||||
isActive ? 'bg-white/10 text-white' : 'text-on-navy-muted hover:text-white hover:bg-white/5',
|
isActive ? 'bg-white text-[var(--z-text-primary)]' : 'text-white hover:text-[var(--z-text-primary)] hover:bg-white',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -50,9 +53,37 @@ export function ConsoleLayout() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<div className="relative group flex items-center h-full">
|
||||||
|
<button className={cn(
|
||||||
|
"flex items-center gap-1.5 no-underline font-sans text-sm font-medium px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
|
||||||
|
isReportsActive ? "bg-white text-[var(--z-text-primary)]" : "text-white hover:text-[var(--z-text-primary)] hover:bg-white"
|
||||||
|
)}>
|
||||||
|
<FileText size={16} />
|
||||||
|
Reports
|
||||||
|
<ChevronDown size={14} className="ml-0.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="absolute top-[80%] left-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
|
||||||
|
{Object.entries(REPORT_MAP).map(([key, report]) => (
|
||||||
|
<NavLink
|
||||||
|
key={key}
|
||||||
|
to={`/reports/${key}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
|
||||||
|
isActive && "bg-gray-100 font-semibold"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{report.title}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{user?.name && <span className="text-sm text-on-navy hidden sm:inline">{user.name}</span>}
|
{user?.name && <span className="text-sm text-white hidden sm:inline">{user.name}</span>}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export function DailyLogsPage() {
|
|||||||
<>
|
<>
|
||||||
<DailyLogsView
|
<DailyLogsView
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
presetAlias="my_logs"
|
||||||
onRowClick={(row) => {
|
onRowClick={(row) => {
|
||||||
const id = row.instance_id as number | string | undefined;
|
const id = row.instance_id as number | string | undefined;
|
||||||
if (id != null) navigate(`/daily/${id}`);
|
if (id != null) navigate(`/daily/${id}`);
|
||||||
@ -30,11 +31,17 @@ export function DailyLogsPage() {
|
|||||||
Punch In
|
Punch In
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
rowActions={(row) => (
|
rowActions={(row) => {
|
||||||
|
const state = String(row.current_state_name || row.current_state_name_ || row.current_state || row.status || '').toLowerCase();
|
||||||
|
// Show only for Punched In, hide for Punched Out or others
|
||||||
|
if (state.includes('out') || !state.includes('in')) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
<Button size="sm" variant="secondary" onClick={() => setPunchOutInstanceId(row.instance_id as string | number)}>
|
<Button size="sm" variant="secondary" onClick={() => setPunchOutInstanceId(row.instance_id as string | number)}>
|
||||||
Punch Out
|
Punch Out
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@ -80,7 +87,7 @@ export function DailyLogsPage() {
|
|||||||
title={instanceId != null ? `Daily Log #${instanceId}` : undefined}
|
title={instanceId != null ? `Daily Log #${instanceId}` : undefined}
|
||||||
width="lg"
|
width="lg"
|
||||||
>
|
>
|
||||||
{instanceId != null && <DailyLogDetail instanceId={instanceId} />}
|
{instanceId != null && <DailyLogDetail instanceId={instanceId} onPunchOut={() => setPunchOutInstanceId(instanceId)} />}
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -97,22 +97,6 @@ export function DailySalesReportPage() {
|
|||||||
|
|
||||||
const blob = doc.output('blob');
|
const blob = doc.output('blob');
|
||||||
|
|
||||||
// Attempt mobile native share (which includes OS-level Print)
|
|
||||||
if (navigator.share && navigator.canShare) {
|
|
||||||
const file = new File([blob], `Daily_Sales_Report_${date || 'Draft'}.pdf`, { type: 'application/pdf' });
|
|
||||||
try {
|
|
||||||
if (navigator.canShare({ files: [file] })) {
|
|
||||||
await navigator.share({
|
|
||||||
files: [file],
|
|
||||||
title: 'Daily Sales Report',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Share failed or was cancelled', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for PC / unsupported browsers
|
// Fallback for PC / unsupported browsers
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export function MyOrdersPage() {
|
|||||||
<>
|
<>
|
||||||
<OrdersView
|
<OrdersView
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
presetAlias="my_orders"
|
||||||
initialFilters={userEmail ? { performed_by_email: userEmail } : undefined}
|
initialFilters={userEmail ? { performed_by_email: userEmail } : undefined}
|
||||||
onRowClick={(row) => {
|
onRowClick={(row) => {
|
||||||
const id = row.instance_id as number | string | undefined;
|
const id = row.instance_id as number | string | undefined;
|
||||||
|
|||||||
33
src/screens/ReportPage.tsx
Normal file
33
src/screens/ReportPage.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { RecordView } from '../components/rv/RecordView';
|
||||||
|
import { ORDER_BOOKING } from '../api/config';
|
||||||
|
import { orderBookingClient } from '../api/clients';
|
||||||
|
|
||||||
|
export const REPORT_MAP: Record<string, { uid: string; title: string }> = {
|
||||||
|
'product-wise-orders': { uid: ORDER_BOOKING.recordViews.PRODUCT_WISE_ORDERS, title: 'Product Wise Orders' },
|
||||||
|
'route-wise-orders': { uid: ORDER_BOOKING.recordViews.ROUTE_WISE_ORDERS, title: 'Route Wise Orders' },
|
||||||
|
'so-wise-orders': { uid: ORDER_BOOKING.recordViews.SO_WISE_ORDERS, title: 'SO Wise Orders' },
|
||||||
|
'so-wise-visits': { uid: ORDER_BOOKING.recordViews.SO_WISE_VISITS, title: 'SO Wise Visits' },
|
||||||
|
'yearly-orders': { uid: ORDER_BOOKING.recordViews.YEARLY_ORDERS, title: 'Yearly Orders' },
|
||||||
|
'monthly-orders': { uid: ORDER_BOOKING.recordViews.MONTHLY_ORDERS, title: 'Monthly Orders' },
|
||||||
|
'weekly-orders': { uid: ORDER_BOOKING.recordViews.WEEKLY_ORDERS, title: 'Weekly Orders' },
|
||||||
|
'store-wise-products': { uid: ORDER_BOOKING.recordViews.STORE_WISE_PRODUCTS, title: 'Store Wise Products' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportPage() {
|
||||||
|
const { reportType } = useParams<{ reportType: string }>();
|
||||||
|
const report = reportType ? REPORT_MAP[reportType] : null;
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return <div className="p-4">Report not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecordView
|
||||||
|
key={report.uid}
|
||||||
|
client={orderBookingClient}
|
||||||
|
rvUid={report.uid}
|
||||||
|
title={report.title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -65,19 +65,19 @@
|
|||||||
--color-slate-50: var(--slate-50);
|
--color-slate-50: var(--slate-50);
|
||||||
|
|
||||||
/* ---- Semantic aliases (short, readable utility names) ---- */
|
/* ---- Semantic aliases (short, readable utility names) ---- */
|
||||||
--color-app: var(--surface-app);
|
--color-app: var(--z-body-bg);
|
||||||
--color-card: var(--surface-card);
|
--color-card: var(--z-block-bg);
|
||||||
--color-sunk: var(--surface-sunk);
|
--color-sunk: var(--z-bg-plain);
|
||||||
--color-strong: var(--text-strong);
|
--color-strong: var(--z-text-default);
|
||||||
--color-body: var(--text-body);
|
--color-body: var(--z-text-default);
|
||||||
--color-muted: var(--text-muted);
|
--color-muted: var(--z-text-muted);
|
||||||
--color-faint: var(--text-faint);
|
--color-faint: var(--z-text-secondary);
|
||||||
--color-on-navy: var(--text-on-navy);
|
--color-on-navy: var(--z-text-inverse);
|
||||||
--color-on-navy-muted: var(--text-on-navy-muted);
|
--color-on-navy-muted: var(--z-text-secondary);
|
||||||
--color-accent: var(--text-accent);
|
--color-accent: var(--z-text-primary);
|
||||||
--color-link: var(--text-link);
|
--color-link: var(--z-text-primary);
|
||||||
--color-border-subtle: var(--border-subtle);
|
--color-border-subtle: var(--z-border-default);
|
||||||
--color-border-default: var(--border-default);
|
--color-border-default: var(--z-border-secondary);
|
||||||
|
|
||||||
/* ---- Radii ---- */
|
/* ---- Radii ---- */
|
||||||
--radius-xs: var(--radius-xs);
|
--radius-xs: var(--radius-xs);
|
||||||
@ -148,9 +148,9 @@
|
|||||||
.nums {
|
.nums {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
/* Sunrise focus ring on inputs/selects. */
|
/* Primary focus ring on inputs/selects. */
|
||||||
.focus-ring:focus-within {
|
.focus-ring:focus-within {
|
||||||
border-color: var(--sunrise-500);
|
border-color: var(--z-btn-primary-bg);
|
||||||
box-shadow: var(--shadow-focus);
|
box-shadow: var(--shadow-focus);
|
||||||
}
|
}
|
||||||
/* Slim, theme-tinted scrollbar for in-panel scroll areas. */
|
/* Slim, theme-tinted scrollbar for in-panel scroll areas. */
|
||||||
@ -175,3 +175,321 @@
|
|||||||
background-color: var(--text-faint);
|
background-color: var(--text-faint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* root variable */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #1d4ed8;
|
||||||
|
--secondary-color: #f9fafb;
|
||||||
|
--font-color: #10182b;
|
||||||
|
--font-family: "IBM Plex Sans", serif;
|
||||||
|
--light-font-color: #667085;
|
||||||
|
--body-bg: #f0f4f8;
|
||||||
|
--bg-plain: #fff;
|
||||||
|
--bg-active: #00000014;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--shadow: 0px 4px 8px 0px rgba(228, 231, 236, 0.3);
|
||||||
|
--hover: #e4e4e4;
|
||||||
|
--hover-font: #fff;
|
||||||
|
--hover-bg: #f9fafb;
|
||||||
|
--hover-border: #667085;
|
||||||
|
--menu-item-bg: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
|
||||||
|
/* Navbar background color */
|
||||||
|
--nav-bg-color: #fff;
|
||||||
|
--nav-item-color: #10182b;
|
||||||
|
--nav-item-active: #1b84ff;
|
||||||
|
--nav-item-active-bg: #c0d4ed45;
|
||||||
|
--nav-item-border-bottom: transparent;
|
||||||
|
--nav-dropdown-bg: #fff;
|
||||||
|
|
||||||
|
/* Matching the navbar color */
|
||||||
|
--nav-dropdown-color: #4B5675;
|
||||||
|
--nav-dropdown-active: #2e56e1;
|
||||||
|
--nav-dropdown-active-bg: #F9F9F9;
|
||||||
|
--nav-dropdown-border: none;
|
||||||
|
--nav-dropdown-radius: 4px;
|
||||||
|
--nav-dropdown-padding: 10px;
|
||||||
|
--menu-item-radius: 4px;
|
||||||
|
--p-8: 0px;
|
||||||
|
--nav-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--nav-left-separator-line: #484848;
|
||||||
|
|
||||||
|
/* tiles card */
|
||||||
|
--tiles-card-bg: #fff;
|
||||||
|
--tiles-card-border: #e5e7eb;
|
||||||
|
--tiles-font-color: #10182b;
|
||||||
|
--tiles-font-size: 16px;
|
||||||
|
--tiles-count-size: 20px;
|
||||||
|
|
||||||
|
/* button */
|
||||||
|
--z-btn-primary-bg: #0058be;
|
||||||
|
--z-btn-primary-color: #fff;
|
||||||
|
--primary-btn-active: #196ed0;
|
||||||
|
--primary-btn-activeClr: #fff;
|
||||||
|
--z-btn-secondary-bg: #f4f4f4;
|
||||||
|
--z-btn-secondary-color: #10182b;
|
||||||
|
--secondary-btn-active-bg: #eaeaea;
|
||||||
|
--z-btn-outline-color: #0058be;
|
||||||
|
--z-btn-outline-border: #0058be;
|
||||||
|
|
||||||
|
--button-height: 36px;
|
||||||
|
--z-btn-sm-height: 32px;
|
||||||
|
--z-btn-md-height: 40px;
|
||||||
|
--z-btn-lg-height: 48px;
|
||||||
|
|
||||||
|
/* blocks */
|
||||||
|
--block-bg: #fff;
|
||||||
|
--block-padding: 20px;
|
||||||
|
--block-radius: 20px;
|
||||||
|
--block-border: #e5e7eb;
|
||||||
|
/* --block-shadow: 0px 4px 8px 0px rgba(228, 231, 236, 0.3); */
|
||||||
|
--block-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);
|
||||||
|
|
||||||
|
/* button or input */
|
||||||
|
--button-radius: 8px;
|
||||||
|
--input-shadow: 0px 4px 8px 0px rgba(228, 231, 236, 0.3);
|
||||||
|
--input-border: #e5e7eb;
|
||||||
|
|
||||||
|
/* font-size */
|
||||||
|
--small-font: 14px;
|
||||||
|
--medium-font: 16px;
|
||||||
|
--large-font: 20px;
|
||||||
|
--z-btn-font-sm: 12px;
|
||||||
|
--z-btn-font-md: 14px;
|
||||||
|
--z-btn-font-lg: 16px;
|
||||||
|
|
||||||
|
--small-icon: 24px;
|
||||||
|
|
||||||
|
/* record view table color */
|
||||||
|
--rv-table-bg: #fff;
|
||||||
|
--rv-table-header: #f9fafb;
|
||||||
|
--table-row-bg-select: #fafafa;
|
||||||
|
--table-tr-active: #f9fafb;
|
||||||
|
--rv-table-height: 40px;
|
||||||
|
--rv-table-btn-height: 36px;
|
||||||
|
--table-td-padding: 8px 20px;
|
||||||
|
|
||||||
|
/* form input tags */
|
||||||
|
--form-input-height: 40px;
|
||||||
|
--form-bg: #fff;
|
||||||
|
--form-input-bg: #fff;
|
||||||
|
--inputBox-border: ##e5e7eb;
|
||||||
|
--inputBox-border-active: #a2acbf;
|
||||||
|
--disable-bg: #fafafa;
|
||||||
|
--disable-color: #98a2b3;
|
||||||
|
|
||||||
|
--toolTip-color: #2185d0;
|
||||||
|
--toolTip-active-clr: #208fe3;
|
||||||
|
|
||||||
|
/* new template style variables */
|
||||||
|
--z-body-bg: #f0f4f8;
|
||||||
|
--z-bg-plain: #fff;
|
||||||
|
|
||||||
|
--z-bg-primary: #0058be;
|
||||||
|
--z-bg-secondary: #f4f4f4;
|
||||||
|
--z-bg-success: #1fc16b;
|
||||||
|
--z-bg-error: #fa3748;
|
||||||
|
--z-bg-light: #ffffff;
|
||||||
|
--z-bg-dark: #1e2227;
|
||||||
|
|
||||||
|
--z-text-default: #10182b;
|
||||||
|
--z-text-primary: #0058be;
|
||||||
|
--z-text-secondary: #6c757d;
|
||||||
|
--z-text-success: #fff;
|
||||||
|
--z-text-error: #fff;
|
||||||
|
--z-text-muted: #6c757d;
|
||||||
|
--z-text-inverse: #ffffff;
|
||||||
|
--z-text-plain: #fff;
|
||||||
|
|
||||||
|
|
||||||
|
--z-bg-active: #3d94fb;
|
||||||
|
|
||||||
|
--z-border-primary: #0058be;
|
||||||
|
--z-border-secondary: #ced4da;
|
||||||
|
--z-border-success: #1fc16b;
|
||||||
|
--z-border-error: #fa3748;
|
||||||
|
|
||||||
|
--z-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--z-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--z-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
--z-font-size-xs: 10px;
|
||||||
|
--z-font-size-sm: 12px;
|
||||||
|
--z-font-size-md: 14px;
|
||||||
|
--z-font-size-lg: 16px;
|
||||||
|
--z-font-size-xl: 20px;
|
||||||
|
|
||||||
|
--z-border-size-sm: 1px;
|
||||||
|
--z-border-size-md: 2px;
|
||||||
|
--z-border-size-lg: 4px;
|
||||||
|
|
||||||
|
--z-border-radius-sm: 4px;
|
||||||
|
--z-border-radius-md: 8px;
|
||||||
|
--z-border-radius-lg: 16px;
|
||||||
|
--z-border-radius-full: 50%;
|
||||||
|
--z-border-radius-pill: 30px;
|
||||||
|
|
||||||
|
/* app default text */
|
||||||
|
--z-font-sm: 14px;
|
||||||
|
--z-font-md: 16px;
|
||||||
|
--z-font-lg: 20px;
|
||||||
|
|
||||||
|
/* button style */
|
||||||
|
--z-btn-radius: 8px;
|
||||||
|
--z-btn-height: 44px;
|
||||||
|
|
||||||
|
/* block and section styles */
|
||||||
|
--z-block-bg: #fff;
|
||||||
|
--z-block-radius: 8px;
|
||||||
|
--z-block-border: #e5e7eb;
|
||||||
|
--z-block-shadow: box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
/* record view table */
|
||||||
|
--z-rv-table-header: #fff;
|
||||||
|
--z-rv-td-padding: 8px 20px;
|
||||||
|
--z-rv-table-border: #e5e7eb;
|
||||||
|
--z-rv-td-height: 40px;
|
||||||
|
|
||||||
|
--z-tile-bg: #fff;
|
||||||
|
--z-tile-border: #e5e7eb;
|
||||||
|
|
||||||
|
/*-----------------New BG Color Palatte----------------*/
|
||||||
|
|
||||||
|
/* Primary Colors */
|
||||||
|
--z-bg-primary-100: #c0d4ed;
|
||||||
|
--z-bg-primary-200: #83b6f4;
|
||||||
|
--z-bg-primary-300: #5aa2f8;
|
||||||
|
--z-bg-primary-400: #0058be;
|
||||||
|
|
||||||
|
/* Secondary Colors */
|
||||||
|
--z-bg-secondary-100: #ffce8d;
|
||||||
|
--z-bg-secondary-200: #ffbb62;
|
||||||
|
--z-bg-secondary-300: #faa73b;
|
||||||
|
--z-bg-secondary-400: #f5f6f7;
|
||||||
|
|
||||||
|
/* Gradient Colors */
|
||||||
|
--z-bg-gradient-1: linear-gradient(90deg, #0058be 0%, #104F99 100%);
|
||||||
|
--z-bg-gradient-2: linear-gradient(90deg, #1C38A7 0%, #2E55DF 100%);
|
||||||
|
--z-bg-gradient-3: linear-gradient(94.13deg, #83B6F4 2.64%, #2B6BBA 97.36%);
|
||||||
|
--z-bg-gradient-4: linear-gradient(99.74deg, #A0C5F4 4.16%, #EFF3F8 97.03%);
|
||||||
|
|
||||||
|
/* Neutral Colors */
|
||||||
|
--z-bg-neutral-100: #ffffff;
|
||||||
|
--z-bg-neutral-200: #f9fafb;
|
||||||
|
--z-bg-neutral-300: #e5e7eb;
|
||||||
|
--z-bg-neutral-400: #d0d5dd;
|
||||||
|
--z-bg-neutral-500: #98a2b3;
|
||||||
|
--z-bg-neutral-600: #667085;
|
||||||
|
--z-bg-neutral-700: #475467;
|
||||||
|
--z-bg-neutral-800: #1d2939;
|
||||||
|
--z-bg-neutral-900: #10182b;
|
||||||
|
--z-bg-neutral-1000: #0c1323;
|
||||||
|
|
||||||
|
/* Danger Colors */
|
||||||
|
--z-bg-danger-100: #fecdca;
|
||||||
|
--z-bg-danger-200: #fda29b;
|
||||||
|
--z-bg-danger-300: #f97066;
|
||||||
|
--z-bg-danger-400: #f04438;
|
||||||
|
|
||||||
|
/* Warning Colors */
|
||||||
|
--z-bg-warning-100: #ffdf89;
|
||||||
|
--z-bg-warning-200: #fec84b;
|
||||||
|
--z-bg-warning-300: #ffdb43;
|
||||||
|
--z-bg-warning-400: #dfb400;
|
||||||
|
|
||||||
|
/* Success Colors */
|
||||||
|
--z-bg-success-100: oklch(97.9% .021 166.113);
|
||||||
|
--z-bg-success-200: #6ce9a6;
|
||||||
|
--z-bg-success-300: #32d584;
|
||||||
|
--z-bg-success-400: #12b76a;
|
||||||
|
|
||||||
|
/*-----------------New Text Color Palatte----------------*/
|
||||||
|
|
||||||
|
/* Primary Colors */
|
||||||
|
--z-text-primary-100: #c0d4ed;
|
||||||
|
--z-text-primary-200: #83b6f4;
|
||||||
|
--z-text-primary-300: #5aa2f8;
|
||||||
|
--z-text-primary-400: #0058be;
|
||||||
|
|
||||||
|
/* Secondary Colors */
|
||||||
|
--z-text-secondary-100: #ffce8d;
|
||||||
|
--z-text-secondary-200: #ffbb62;
|
||||||
|
--z-text-secondary-300: #faa73b;
|
||||||
|
--z-text-secondary-400: #f79009;
|
||||||
|
|
||||||
|
/* Neutral Colors */
|
||||||
|
--z-text-neutral-100: #ffffff;
|
||||||
|
--z-text-neutral-200: #f9fafb;
|
||||||
|
--z-text-neutral-300: #e5e7eb;
|
||||||
|
--z-text-neutral-400: #d0d5dd;
|
||||||
|
--z-text-neutral-500: #98a2b3;
|
||||||
|
--z-text-neutral-600: #667085;
|
||||||
|
--z-text-neutral-700: #475467;
|
||||||
|
--z-text-neutral-800: #1d2939;
|
||||||
|
--z-text-neutral-900: #10182b;
|
||||||
|
--z-text-neutral-1000: #0c1323;
|
||||||
|
|
||||||
|
/* Danger Colors */
|
||||||
|
--z-text-danger-100: #fecdca;
|
||||||
|
--z-text-danger-200: #fda29b;
|
||||||
|
--z-text-danger-300: #f97066;
|
||||||
|
--z-text-danger-400: #f04438;
|
||||||
|
|
||||||
|
/* Warning Colors */
|
||||||
|
--z-text-warning-100: #ffdf89;
|
||||||
|
--z-text-warning-200: #fec84b;
|
||||||
|
--z-text-warning-300: #ffdb43;
|
||||||
|
--z-text-warning-400: #dfb400;
|
||||||
|
|
||||||
|
/* Success Colors */
|
||||||
|
--z-text-success-100: #a6f4c5;
|
||||||
|
--z-text-success-200: #6ce9a6;
|
||||||
|
--z-text-success-300: #32d584;
|
||||||
|
--z-text-success-400: #12b76a;
|
||||||
|
|
||||||
|
/*-----------------New Border Color Palatte----------------*/
|
||||||
|
|
||||||
|
/* Primary Colors */
|
||||||
|
--z-border-default: #e5e7eb;
|
||||||
|
--z-border-primary-100: #c0d4ed;
|
||||||
|
--z-border-primary-200: #83b6f4;
|
||||||
|
--z-border-primary-300: #5aa2f8;
|
||||||
|
--z-border-primary-400: #0058be;
|
||||||
|
|
||||||
|
/* Secondary Colors */
|
||||||
|
--z-border-secondary-100: #ffce8d;
|
||||||
|
--z-border-secondary-200: #f3cfc4;
|
||||||
|
--z-border-secondary-300: #ebaf9d;
|
||||||
|
--z-border-secondary-400: #f79009;
|
||||||
|
|
||||||
|
/* Neutral Colors */
|
||||||
|
--z-border-neutral-100: #ffffff;
|
||||||
|
--z-border-neutral-200: #f9fafb;
|
||||||
|
--z-border-neutral-300: #e5e7eb;
|
||||||
|
--z-border-neutral-400: #d0d5dd;
|
||||||
|
--z-border-neutral-500: #98a2b3;
|
||||||
|
--z-border-neutral-600: #667085;
|
||||||
|
--z-border-neutral-700: #475467;
|
||||||
|
--z-border-neutral-800: #1d2939;
|
||||||
|
--z-border-neutral-900: #10182b;
|
||||||
|
--z-border-neutral-1000: #0c1323;
|
||||||
|
|
||||||
|
/* Danger Colors */
|
||||||
|
--z-border-danger-100: #fecdca;
|
||||||
|
--z-border-danger-200: #fda29b;
|
||||||
|
--z-border-danger-300: #f97066;
|
||||||
|
--z-border-danger-400: #f04438;
|
||||||
|
|
||||||
|
/* Warning Colors */
|
||||||
|
--z-border-warning-100: #ffdf89;
|
||||||
|
--z-border-warning-200: #fec84b;
|
||||||
|
--z-border-warning-300: #ffdb43;
|
||||||
|
--z-border-warning-400: #dfb400;
|
||||||
|
|
||||||
|
/* Success Colors */
|
||||||
|
--z-border-success-100: #a6f4c5;
|
||||||
|
--z-border-success-200: #6ce9a6;
|
||||||
|
--z-border-success-300: #32d584;
|
||||||
|
--z-border-success-400: #12b76a;
|
||||||
|
}
|
||||||
|
|||||||
@ -97,7 +97,7 @@
|
|||||||
--action-primary-hover: var(--sunrise-600);
|
--action-primary-hover: var(--sunrise-600);
|
||||||
--action-navy: var(--navy-900);
|
--action-navy: var(--navy-900);
|
||||||
--action-navy-hover: var(--navy-800);
|
--action-navy-hover: var(--navy-800);
|
||||||
--focus-ring: rgba(242,107,58,0.45);
|
--focus-ring: rgba(0, 88, 190, 0.45);
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
--status-success: var(--emerald-600);
|
--status-success: var(--emerald-600);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user