admin page added
This commit is contained in:
parent
dd06df1fb1
commit
f8abf9b553
@ -9,6 +9,8 @@ import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||
import { AllDailyLogsPage } from './screens/AllDailyLogsPage'
|
||||
import { AnalyticsPage } from './screens/AnalyticsPage'
|
||||
import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
||||
import { DatasetsPage } from './screens/admin/DatasetsPage'
|
||||
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
@ -35,6 +37,10 @@ function App() {
|
||||
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/sales-report" element={<DailySalesReportPage />} />
|
||||
|
||||
<Route path="/admin/datasets" element={<DatasetsPage />}>
|
||||
<Route path=":id" element={<DatasetItemsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/daily" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -3,6 +3,7 @@ import type {
|
||||
AiDecision,
|
||||
ApiError,
|
||||
AuditEntry,
|
||||
Dataset,
|
||||
FormScreenResponse,
|
||||
LoginResponse,
|
||||
RecordViewParams,
|
||||
@ -310,6 +311,24 @@ export class ZinoClient {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Datasets ---
|
||||
|
||||
getDatasets(): Promise<Dataset[]> {
|
||||
return this.request<Dataset[]>('GET', `/app/${APP_ID}/datasets`);
|
||||
}
|
||||
|
||||
getDataset(id: number | string): Promise<Dataset> {
|
||||
return this.request<Dataset>('GET', `/app/${APP_ID}/datasets/${id}`);
|
||||
}
|
||||
|
||||
createDataset(payload: { name: string; data_type: 'string' | 'object'; keys: string[]; description?: string }): Promise<Dataset> {
|
||||
return this.request<Dataset>('POST', `/app/${APP_ID}/datasets`, payload);
|
||||
}
|
||||
|
||||
updateDataset(id: number | string, payload: Partial<{ name: string; data_type: 'string' | 'object'; keys: string[]; description: string; items: any[] }>): Promise<Dataset> {
|
||||
return this.request<Dataset>('PUT', `/app/${APP_ID}/datasets/${id}`, payload);
|
||||
}
|
||||
|
||||
// --- File upload + OCR (multipart, field-scoped) ---
|
||||
|
||||
private fieldForm(file: File, ctx: FieldContext): FormData {
|
||||
|
||||
@ -19,6 +19,20 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// --- Datasets ---
|
||||
|
||||
export interface Dataset {
|
||||
id: number;
|
||||
uuid?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
data_type: 'string' | 'object';
|
||||
keys: string[];
|
||||
item_count?: number;
|
||||
items?: any[];
|
||||
}
|
||||
|
||||
|
||||
// --- Record view (POST /app/{appId}/view/recordview) ---
|
||||
|
||||
export interface RecordViewField {
|
||||
|
||||
@ -21,25 +21,29 @@ export function DailyLogCard({ row, onPunchOut, isDetailView = false }: { row: R
|
||||
const nonProdCalls = formatValue(row.total_non_productive_calls || 0);
|
||||
const eodNotes = formatValue(row.eod_notes_remarks || row.notes || '—');
|
||||
|
||||
// Date
|
||||
let dateStr = '—';
|
||||
if (row.date) dateStr = String(row.date).split('T')[0];
|
||||
else if (row.created_at) dateStr = String(row.created_at).split('T')[0];
|
||||
|
||||
try {
|
||||
if (dateStr !== '—') {
|
||||
const d = new Date(dateStr);
|
||||
const formatDateStr = (raw: string | undefined | null) => {
|
||||
if (!raw || raw === '—') return '—';
|
||||
try {
|
||||
const d = new Date(raw);
|
||||
if (!isNaN(d.getTime())) {
|
||||
dateStr = d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).replace(/ /g, '-');
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).replace(/ /g, '-');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {}
|
||||
const part = String(raw).split('T')[0];
|
||||
return part || '—';
|
||||
};
|
||||
|
||||
const formatTimeStr = (t: string | undefined | null) => {
|
||||
if (!t) return '—';
|
||||
const str = String(t);
|
||||
if (str === '—') return str;
|
||||
try {
|
||||
if (str.includes('T') || (str.includes('-') && str.includes(':'))) {
|
||||
const d = new Date(str);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
}
|
||||
}
|
||||
let timeParts = str.split(':');
|
||||
if (str.includes('T')) {
|
||||
timeParts = str.split('T')[1].replace('Z', '').split('.')[0].split(':');
|
||||
@ -59,16 +63,29 @@ export function DailyLogCard({ row, onPunchOut, isDetailView = false }: { row: R
|
||||
}
|
||||
};
|
||||
|
||||
// Checkin time
|
||||
let checkInTimeRaw = row.time || row.created_at || row.punch_in_time;
|
||||
// Checkin (Punch In) time & date
|
||||
let checkInTimeRaw =
|
||||
row['ac04a444-9b96-463d-bc91-447fa80b028f__created_at'] ||
|
||||
row['ac04a444_9b96_463d_bc91_447fa80b028f__created_at'] ||
|
||||
row['ac04a444-9b96-463d-bc91-447fa80b028f_created_at'] ||
|
||||
row['ac04a444_9b96_463d_bc91_447fa80b028f_created_at'] ||
|
||||
row.time || row.created_at || row.punch_in_time;
|
||||
let checkInTime = formatTimeStr(checkInTimeRaw);
|
||||
let punchInDateStr = formatDateStr(checkInTimeRaw || row.date || row.created_at);
|
||||
|
||||
// Check out time
|
||||
let checkOutTimeRaw = row.check_out_time || row.punch_out_time;
|
||||
// Check out (Punch Out) time & date
|
||||
let checkOutTimeRaw =
|
||||
row['37c218fc-fd91-41d1-9964-fcaeade54e74__created_at'] ||
|
||||
row['37c218fc_fd91-41d1-9964-fcaeade54e74__created_at'] ||
|
||||
row['37c218fc-fd91-41d1-9964-fcaeade54e74_created_at'] ||
|
||||
row['37c218fc_fd91-41d1-9964-fcaeade54e74_created_at'] ||
|
||||
row.check_out_time || row.punch_out_time;
|
||||
if (!checkOutTimeRaw && stateName.toLowerCase().includes('out') && row.time) {
|
||||
checkOutTimeRaw = row.time;
|
||||
}
|
||||
let checkOutTime = checkOutTimeRaw ? formatTimeStr(checkOutTimeRaw) : null;
|
||||
let punchOutDateStr = checkOutTimeRaw ? formatDateStr(checkOutTimeRaw) : '—';
|
||||
if (punchOutDateStr === '—') punchOutDateStr = punchInDateStr;
|
||||
|
||||
const isPunchedIn = stateName.toLowerCase().includes('punched in') || stateName.toLowerCase().includes('active') || (stateName.toLowerCase().includes('in') && !stateName.toLowerCase().includes('out'));
|
||||
|
||||
@ -112,7 +129,7 @@ export function DailyLogCard({ row, onPunchOut, isDetailView = false }: { row: R
|
||||
<div className="z-card-footer-label" style={{ whiteSpace: 'nowrap' }}><Clock size={14} /> PUNCH-IN TIME</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', whiteSpace: 'nowrap' }}>
|
||||
<span className="z-card-footer-value" style={{ display: 'flex', gap: '6px', fontSize: '13px' }}>
|
||||
<span>{dateStr}</span>
|
||||
<span>{punchInDateStr}</span>
|
||||
<span>{checkInTime}</span>
|
||||
</span>
|
||||
</div>
|
||||
@ -122,7 +139,7 @@ export function DailyLogCard({ row, onPunchOut, isDetailView = false }: { row: R
|
||||
<div className="z-card-footer-label" style={{ whiteSpace: 'nowrap' }}><Clock size={14} /> PUNCH-OUT TIME</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', whiteSpace: 'nowrap' }}>
|
||||
<span className="z-card-footer-value" style={{ display: 'flex', gap: '6px', fontSize: '13px' }}>
|
||||
<span>{dateStr}</span>
|
||||
<span>{punchOutDateStr}</span>
|
||||
<span>{checkOutTime}</span>
|
||||
</span>
|
||||
</div>
|
||||
@ -148,12 +165,12 @@ export function DailyLogCard({ row, onPunchOut, isDetailView = false }: { row: R
|
||||
<div className="z-card-route mt-4" style={{ paddingTop: '16px', gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||
<div>
|
||||
<p className="z-card-grid-item-label flex items-center gap-1"><Clock size={12} /> PUNCH-IN</p>
|
||||
<p className="z-card-route-val" style={{ fontSize: '13px' }}>{dateStr} {checkInTime}</p>
|
||||
<p className="z-card-route-val" style={{ fontSize: '13px' }}>{punchInDateStr} {checkInTime}</p>
|
||||
</div>
|
||||
{checkOutTime && (
|
||||
<div>
|
||||
<p className="z-card-grid-item-label flex items-center gap-1"><Clock size={12} /> PUNCH-OUT</p>
|
||||
<p className="z-card-route-val" style={{ fontSize: '13px' }}>{dateStr} {checkOutTime}</p>
|
||||
<p className="z-card-route-val" style={{ fontSize: '13px' }}>{punchOutDateStr} {checkOutTime}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -51,25 +51,29 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
|
||||
const nonProdCalls = formatValue(row.total_non_productive_calls || 0);
|
||||
const eodNotes = formatValue(row.eod_notes_remarks || row.notes || '—');
|
||||
|
||||
// Format Date & Time
|
||||
let dateStr = '—';
|
||||
if (row.date) dateStr = String(row.date).split('T')[0];
|
||||
else if (row.created_at) dateStr = String(row.created_at).split('T')[0];
|
||||
|
||||
try {
|
||||
if (dateStr !== '—') {
|
||||
const d = new Date(dateStr);
|
||||
const formatDateStr = (raw: string | undefined | null) => {
|
||||
if (!raw || raw === '—') return '—';
|
||||
try {
|
||||
const d = new Date(raw);
|
||||
if (!isNaN(d.getTime())) {
|
||||
dateStr = d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).replace(/ /g, '-');
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).replace(/ /g, '-');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {}
|
||||
const part = String(raw).split('T')[0];
|
||||
return part || '—';
|
||||
};
|
||||
|
||||
const formatTimeStr = (t: string | undefined | null) => {
|
||||
if (!t) return '—';
|
||||
const str = String(t);
|
||||
if (str === '—') return str;
|
||||
try {
|
||||
if (str.includes('T') || (str.includes('-') && str.includes(':'))) {
|
||||
const d = new Date(str);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
}
|
||||
}
|
||||
let timeParts = str.split(':');
|
||||
if (str.includes('T')) {
|
||||
timeParts = str.split('T')[1].replace('Z', '').split('.')[0].split(':');
|
||||
@ -89,14 +93,27 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const checkInTimeRaw = row.time || row.created_at || row.punch_in_time;
|
||||
const checkInTimeRaw =
|
||||
row['ac04a444-9b96-463d-bc91-447fa80b028f__created_at'] ||
|
||||
row['ac04a444_9b96_463d_bc91_447fa80b028f__created_at'] ||
|
||||
row['ac04a444-9b96-463d-bc91-447fa80b028f_created_at'] ||
|
||||
row['ac04a444_9b96_463d_bc91_447fa80b028f_created_at'] ||
|
||||
row.time || row.created_at || row.punch_in_time;
|
||||
const checkInTime = formatTimeStr(checkInTimeRaw);
|
||||
const punchInDateStr = formatDateStr(checkInTimeRaw || row.date || row.created_at);
|
||||
|
||||
let checkOutTimeRaw = row.check_out_time || row.punch_out_time;
|
||||
let checkOutTimeRaw =
|
||||
row['37c218fc-fd91-41d1-9964-fcaeade54e74__created_at'] ||
|
||||
row['37c218fc_fd91-41d1-9964-fcaeade54e74__created_at'] ||
|
||||
row['37c218fc-fd91-41d1-9964-fcaeade54e74_created_at'] ||
|
||||
row['37c218fc_fd91-41d1-9964-fcaeade54e74_created_at'] ||
|
||||
row.check_out_time || row.punch_out_time;
|
||||
if (!checkOutTimeRaw && stateName.toLowerCase().includes('out') && row.time) {
|
||||
checkOutTimeRaw = row.time;
|
||||
}
|
||||
const checkOutTime = checkOutTimeRaw ? formatTimeStr(checkOutTimeRaw) : null;
|
||||
let punchOutDateStr = checkOutTimeRaw ? formatDateStr(checkOutTimeRaw) : '—';
|
||||
if (punchOutDateStr === '—') punchOutDateStr = punchInDateStr;
|
||||
|
||||
const isPunchedIn = stateName.toLowerCase().includes('punched in') || stateName.toLowerCase().includes('active') || (stateName.toLowerCase().includes('in') && !stateName.toLowerCase().includes('out'));
|
||||
|
||||
@ -164,7 +181,7 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
|
||||
<div className="text-xs">
|
||||
<div className="font-bold text-slate-800 uppercase tracking-wider text-[10px] mb-0.5">PUNCH IN</div>
|
||||
<div className="text-slate-800 font-medium text-[12px] leading-tight">
|
||||
{dateStr} • {checkInTime}
|
||||
{punchInDateStr} • {checkInTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -177,7 +194,7 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
|
||||
<div className="text-xs">
|
||||
<div className="font-bold text-slate-800 uppercase tracking-wider text-[10px] mb-0.5">PUNCH OUT</div>
|
||||
<div className="text-slate-800 font-medium text-[12px] leading-tight">
|
||||
{dateStr} • {checkOutTime}
|
||||
{punchOutDateStr} • {checkOutTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal } from '../../reusable/Modal';
|
||||
import { Button } from '../../buttons/Button';
|
||||
import { useJsApiLoader, GoogleMap, Marker } from '@react-google-maps/api';
|
||||
import { X } from "lucide-react";
|
||||
import { MapPin, Crosshair, Map as MapIcon, Loader2, X } from 'lucide-react';
|
||||
|
||||
const mapContainerStyle = {
|
||||
width: '100%',
|
||||
@ -10,11 +10,6 @@ const mapContainerStyle = {
|
||||
borderRadius: '8px'
|
||||
};
|
||||
|
||||
const defaultCenter = {
|
||||
lat: 20.5937,
|
||||
lng: 78.9629
|
||||
};
|
||||
|
||||
export function GeolocationInput({
|
||||
label,
|
||||
required,
|
||||
@ -23,14 +18,14 @@ export function GeolocationInput({
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: { latitude: number; longitude: number; accuracy?: number } | null;
|
||||
value: { latitude?: number; longitude?: number; lat?: number; lng?: number; accuracy?: number } | null;
|
||||
onChange: (val: { latitude: number; longitude: number; accuracy?: number } | null) => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [isMapModalOpen, setIsMapModalOpen] = useState(false);
|
||||
const [mapMarkerPos, setMapMarkerPos] = useState<{lat: number, lng: number} | null>(null);
|
||||
const [mapMarkerPos, setMapMarkerPos] = useState<{ lat: number; lng: number } | null>(null);
|
||||
|
||||
const isLocalhost = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
||||
|
||||
@ -39,41 +34,46 @@ export function GeolocationInput({
|
||||
googleMapsApiKey: isLocalhost ? '' : (import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '')
|
||||
});
|
||||
|
||||
// Auto-fetch location on first mount if no value is present
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
fetchLocation();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// Extract lat, lng & accuracy from value (supporting both latitude/longitude and lat/lng)
|
||||
const lat = value ? (value.latitude ?? value.lat ?? null) : null;
|
||||
const lng = value ? (value.longitude ?? value.lng ?? null) : null;
|
||||
const accuracy = value?.accuracy;
|
||||
|
||||
const fetchLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError('Geolocation is not supported by your browser.');
|
||||
const fetchLocation = useCallback(() => {
|
||||
setError(null);
|
||||
if (!("geolocation" in navigator)) {
|
||||
setError("Geolocation not supported on this device");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
(pos) => {
|
||||
onChange({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy,
|
||||
});
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setError(err.message);
|
||||
setError(err.message || "Unable to get location");
|
||||
setLoading(false);
|
||||
},
|
||||
{ enableHighAccuracy: true }
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
};
|
||||
}, [onChange]);
|
||||
|
||||
// Auto-fetch location on first mount if no value is present
|
||||
useEffect(() => {
|
||||
if (!value || (lat == null && lng == null)) {
|
||||
fetchLocation();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleOpenMap = () => {
|
||||
if (value) {
|
||||
setMapMarkerPos({ lat: value.latitude, lng: value.longitude });
|
||||
if (lat != null && lng != null) {
|
||||
setMapMarkerPos({ lat, lng });
|
||||
} else {
|
||||
setMapMarkerPos(null);
|
||||
}
|
||||
@ -93,72 +93,89 @@ export function GeolocationInput({
|
||||
if (mapMarkerPos) {
|
||||
onChange({
|
||||
latitude: mapMarkerPos.lat,
|
||||
longitude: mapMarkerPos.lng
|
||||
longitude: mapMarkerPos.lng,
|
||||
accuracy: 10,
|
||||
});
|
||||
setIsMapModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => onChange(null);
|
||||
const hasValue = !!value;
|
||||
const hasValidCoords = lat != null && lng != null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 font-sans w-full">
|
||||
<span className="text-sm font-medium text-muted">
|
||||
<div className="space-y-2.5 font-sans w-full">
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-slate-700">
|
||||
<MapPin className="h-3.5 w-3.5 text-slate-500" />
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</span>
|
||||
{required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center w-full border border-slate-200 bg-white rounded-lg p-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={fetchLocation} disabled={loading}>
|
||||
{loading ? 'Locating...' : 'Get Location'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleOpenMap}>
|
||||
Select Map
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasValue ? (
|
||||
<div className="flex items-center gap-2 sm:ml-auto text-sm text-slate-700 font-medium bg-slate-50 px-3 py-1.5 rounded-md border border-slate-100">
|
||||
<span className="flex h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
{value.latitude.toFixed(5)}, {value.longitude.toFixed(5)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="ml-1 text-slate-400 hover:text-ruby-500 transition-colors"
|
||||
aria-label="Clear location"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400 sm:ml-auto px-2 py-1">
|
||||
No location set
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchLocation}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800 transition-colors hover:bg-emerald-100 disabled:opacity-60 cursor-pointer"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Crosshair className="h-3.5 w-3.5" />}
|
||||
{loading ? "Locating…" : "Get Location"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenMap}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition-colors hover:border-emerald-300 hover:bg-emerald-50 cursor-pointer"
|
||||
>
|
||||
<MapIcon className="h-3.5 w-3.5" />
|
||||
Select from Map
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <span className="text-xs text-ruby-600 px-1">{error}</span>}
|
||||
{required && !value && <input type="text" className="sr-only" required />}
|
||||
{hasValidCoords ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-emerald-500/40 bg-emerald-50/50 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-sm font-semibold tabular-nums text-slate-800 truncate">
|
||||
{lat.toFixed(5)}, {lng.toFixed(5)}
|
||||
</div>
|
||||
{typeof accuracy === "number" && (
|
||||
<div className="text-[10px] text-slate-500">± {Math.round(accuracy)} m accuracy</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="shrink-0 grid h-6 w-6 place-items-center rounded-full text-slate-400 transition-colors hover:text-red-500 cursor-pointer"
|
||||
aria-label="Clear location"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400">No location captured yet.</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-500">{error}</div>}
|
||||
{required && !hasValidCoords && <input type="text" className="sr-only" required tabIndex={-1} />}
|
||||
|
||||
{isMapModalOpen && (
|
||||
<Modal
|
||||
open={isMapModalOpen}
|
||||
onClose={() => setIsMapModalOpen(false)}
|
||||
title="Select Location"
|
||||
title="Select Location on Map"
|
||||
width="lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-slate-600">Click on the map to place a pin at your desired location.</p>
|
||||
<p className="text-sm text-slate-600">Click on the map to place a pin at the desired store location.</p>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden relative min-h-[400px] bg-slate-100 flex items-center justify-center">
|
||||
{!isLoaded ? (
|
||||
<span className="text-slate-500 font-medium">Loading Map...</span>
|
||||
<span className="text-slate-500 font-medium flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading Map...
|
||||
</span>
|
||||
) : (
|
||||
<GoogleMap
|
||||
mapContainerStyle={mapContainerStyle}
|
||||
center={mapMarkerPos || defaultCenter}
|
||||
center={mapMarkerPos || { lat: 20.5937, lng: 78.9629 }}
|
||||
zoom={mapMarkerPos ? 15 : 5}
|
||||
onClick={handleMapClick}
|
||||
options={{
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { LogOut, Menu, X } from 'lucide-react';
|
||||
import { LogOut, Menu, X, Database } from 'lucide-react';
|
||||
import { cn } from '../lib/cn';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||
@ -129,6 +129,20 @@ export function ConsoleLayout() {
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="my-2 border-t border-border-subtle" />
|
||||
|
||||
<NavLink
|
||||
to="/admin/datasets"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className={({ isActive }) => cn(
|
||||
"flex items-center gap-3 px-4 py-3 mx-2 rounded-lg no-underline transition-colors duration-150 sidebar-item",
|
||||
isActive ? "active" : ""
|
||||
)}
|
||||
>
|
||||
<Database size={20} className="shrink-0" />
|
||||
<span>Manage Datasets</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border-subtle shrink-0 flex flex-col gap-3 mb-[env(safe-area-inset-bottom)]">
|
||||
|
||||
459
src/screens/admin/DatasetItemsPage.tsx
Normal file
459
src/screens/admin/DatasetItemsPage.tsx
Normal file
@ -0,0 +1,459 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import type { Dataset } from '../../api/types';
|
||||
import { Button } from '../../components/buttons/Button';
|
||||
import { Spinner } from '../../components/reusable/Spinner';
|
||||
import { Save, Trash2, Plus, Database, Upload, Search } from 'lucide-react';
|
||||
|
||||
export function DatasetItemsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const [dataset, setDataset] = useState<Dataset | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [invalidCells, setInvalidCells] = useState<Set<string>>(new Set());
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
|
||||
// Local state for items editing
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) fetchDataset(id);
|
||||
}, [id]);
|
||||
|
||||
const fetchDataset = async (datasetId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await orderBookingClient.getDataset(datasetId);
|
||||
setDataset(res);
|
||||
setItems(res.items || []);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch dataset details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!dataset || !id) return;
|
||||
setInvalidCells(new Set());
|
||||
|
||||
let hasError = false;
|
||||
const newInvalidCells = new Set<string>();
|
||||
|
||||
if (dataset.data_type === 'object') {
|
||||
items.forEach((obj, idx) => {
|
||||
const hasSomeValue = dataset.keys.some(k => (obj[k] || '').trim() !== '');
|
||||
const hasMissingValue = dataset.keys.some(k => !(obj[k] || '').trim());
|
||||
|
||||
if (hasSomeValue && hasMissingValue) {
|
||||
hasError = true;
|
||||
dataset.keys.forEach(k => {
|
||||
if (!(obj[k] || '').trim()) {
|
||||
newInvalidCells.add(`${idx}-${k}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
setInvalidCells(newInvalidCells);
|
||||
return;
|
||||
}
|
||||
|
||||
let cleanedItems: any[] = [];
|
||||
if (dataset.data_type === 'string') {
|
||||
cleanedItems = items.filter(val => typeof val === 'string' && val.trim() !== '');
|
||||
} else {
|
||||
cleanedItems = items.filter(obj => dataset.keys.some(k => (obj[k] || '').trim() !== ''));
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
items: cleanedItems
|
||||
};
|
||||
const res = await orderBookingClient.updateDataset(id, payload);
|
||||
setDataset(res);
|
||||
setItems(res.items || []);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to save dataset items');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// String Array handlers
|
||||
const handleStringChange = (index: number, val: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = val;
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleAddString = () => {
|
||||
if (items.length > 0 && typeof items[items.length - 1] === 'string' && items[items.length - 1].trim() === '') {
|
||||
return;
|
||||
}
|
||||
setItems([...items, '']);
|
||||
};
|
||||
|
||||
// Object Array handlers
|
||||
const handleObjectChange = (index: number, key: string, val: string) => {
|
||||
const newItems = [...items];
|
||||
if (!newItems[index]) newItems[index] = {};
|
||||
newItems[index][key] = val;
|
||||
setItems(newItems);
|
||||
|
||||
if (invalidCells.has(`${index}-${key}`)) {
|
||||
const newInvalid = new Set(invalidCells);
|
||||
newInvalid.delete(`${index}-${key}`);
|
||||
setInvalidCells(newInvalid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddObject = () => {
|
||||
if (items.length > 0) {
|
||||
const lastObj = items[items.length - 1];
|
||||
const hasAnyValue = dataset?.keys?.some(k => (lastObj[k] || '').trim() !== '');
|
||||
if (!hasAnyValue && dataset?.keys?.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newObj: any = {};
|
||||
if (dataset?.keys) {
|
||||
dataset.keys.forEach(k => { newObj[k] = ''; });
|
||||
}
|
||||
setItems([...items, newObj]);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
setItems(newItems);
|
||||
setSelectedRows(new Set());
|
||||
setInvalidCells(new Set());
|
||||
};
|
||||
|
||||
const toggleRowSelection = (index: number) => {
|
||||
const newSet = new Set(selectedRows);
|
||||
if (newSet.has(index)) newSet.delete(index);
|
||||
else newSet.add(index);
|
||||
setSelectedRows(newSet);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedRows.size === items.length && items.length > 0) {
|
||||
setSelectedRows(new Set());
|
||||
} else {
|
||||
setSelectedRows(new Set(items.map((_, i) => i)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMultiDelete = () => {
|
||||
if (selectedRows.size === 0) return;
|
||||
const newItems = items.filter((_, idx) => !selectedRows.has(idx));
|
||||
setItems(newItems);
|
||||
setSelectedRows(new Set());
|
||||
setInvalidCells(new Set());
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (dataset) {
|
||||
setItems(dataset.items || []);
|
||||
setSelectedRows(new Set());
|
||||
setInvalidCells(new Set());
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
if (!text) return;
|
||||
|
||||
const rows = text.split(/\r?\n/).map(row => row.trim()).filter(row => row);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
if (dataset?.data_type === 'string') {
|
||||
const parsed = rows.map(r => r.split(',')[0].replace(/^"|"$/g, '').trim());
|
||||
setItems([...items, ...parsed]);
|
||||
} else {
|
||||
const headers = rows[0].split(',').map(h => h.replace(/^"|"$/g, '').trim());
|
||||
const newItems = rows.slice(1).map(row => {
|
||||
const values = row.split(',').map(v => v.replace(/^"|"$/g, '').trim());
|
||||
const obj: any = {};
|
||||
headers.forEach((h, i) => {
|
||||
if (dataset?.keys?.includes(h)) {
|
||||
obj[h] = values[i] || '';
|
||||
}
|
||||
});
|
||||
dataset?.keys?.forEach(k => {
|
||||
if (!(k in obj)) obj[k] = '';
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
setItems([...items, ...newItems]);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-10 flex justify-center"><Spinner /></div>;
|
||||
}
|
||||
|
||||
if (!dataset) {
|
||||
return <div className="p-10 text-center text-red-600 font-medium">{error || 'Dataset not found.'}</div>;
|
||||
}
|
||||
|
||||
const isString = dataset.data_type === 'string';
|
||||
const hasChanges = JSON.stringify(items) !== JSON.stringify(dataset.items || []);
|
||||
|
||||
const filteredIndices = items
|
||||
.map((item, idx) => ({ item, idx }))
|
||||
.filter(({ item }) => {
|
||||
if (!searchTerm) return true;
|
||||
const lowerSearch = searchTerm.toLowerCase();
|
||||
if (isString) {
|
||||
return (typeof item === 'string') && item.toLowerCase().includes(lowerSearch);
|
||||
} else {
|
||||
return dataset?.keys?.some(k =>
|
||||
item[k] && typeof item[k] === 'string' && item[k].toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
})
|
||||
.map(({ idx }) => idx);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-transparent">
|
||||
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 bg-card border-b border-border-subtle">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-base sm:text-lg font-bold text-foreground m-0 flex items-center gap-2">
|
||||
<Database size={20} className="shrink-0 text-navy-600" />
|
||||
<span className="truncate">{dataset.name}</span>
|
||||
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded flex items-center gap-1 shrink-0 ${
|
||||
!isString ? 'bg-purple-100 text-purple-700' : 'bg-emerald-100 text-emerald-700'
|
||||
}`}>
|
||||
{!isString ? 'Table' : 'List'}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm font-normal text-muted shrink-0">
|
||||
{items.length} items {dataset.keys?.length ? `- ${dataset.keys.length} cols` : ''}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative">
|
||||
<input type="file" accept=".csv" onChange={handleFileUpload} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" title="Import CSV" />
|
||||
<Button variant="outline" size="sm" className="rounded font-medium flex items-center gap-1.5"><Upload size={14} /> Import</Button>
|
||||
</div>
|
||||
{selectedRows.size > 0 && (
|
||||
<Button variant="danger" size="sm" onClick={handleMultiDelete} className="rounded font-semibold px-3 flex items-center gap-1.5">
|
||||
<Trash2 size={14} /> Delete ({selectedRows.size})
|
||||
</Button>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Button variant="secondary" size="sm" onClick={handleReset} disabled={saving} className="rounded font-semibold">
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges} className="flex items-center gap-1.5 rounded font-semibold">
|
||||
<Save size={16} /> {saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="m-4 p-4 bg-red-50 text-red-600 rounded-md border border-red-100 font-medium">{error}</div>}
|
||||
|
||||
<div className="flex-1 p-3 sm:p-6 overflow-y-auto">
|
||||
{isString ? (
|
||||
<div className="bg-card border border-border-subtle rounded-lg shadow-sm">
|
||||
<div className="p-3 sm:p-4 border-b border-border-subtle flex items-center justify-between gap-3">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search rows..."
|
||||
className="w-full text-sm pl-8 pr-4 py-2 border border-border-subtle rounded-md focus:outline-none focus:ring-1 focus:ring-navy-600 bg-background text-foreground"
|
||||
/>
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-muted shrink-0">{items.length} rows</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full table-auto text-left text-sm whitespace-nowrap bg-card">
|
||||
<thead className="bg-black/5 border-b border-border-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold text-muted w-10 text-center border-r border-border-subtle">
|
||||
<input type="checkbox" checked={selectedRows.size === items.length && items.length > 0} onChange={toggleSelectAll} className="rounded border-border-subtle text-navy-600 focus:ring-navy-600 cursor-pointer" />
|
||||
</th>
|
||||
<th className="px-4 py-3 font-bold text-xs uppercase text-muted w-10 text-center border-r border-border-subtle">#</th>
|
||||
<th className="px-4 py-3 font-bold text-xs uppercase text-muted border-r border-border-subtle">VALUE</th>
|
||||
<th className="px-4 py-3 font-bold text-xs uppercase text-muted text-center w-16">ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-subtle">
|
||||
{filteredIndices.map((idx) => {
|
||||
const val = items[idx];
|
||||
return (
|
||||
<tr key={idx} className={`bg-card hover:bg-black/5 transition-colors ${selectedRows.has(idx) ? '!bg-navy-50/10' : ''}`}>
|
||||
<td className="px-4 py-2 text-center border-r border-border-subtle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(idx)}
|
||||
onChange={() => toggleRowSelection(idx)}
|
||||
className="rounded border-border-subtle text-navy-600 focus:ring-navy-600 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted font-mono text-center text-xs border-r border-border-subtle">{idx + 1}</td>
|
||||
<td className="px-0 py-0 relative border-r border-border-subtle min-w-[150px]">
|
||||
<div className="invisible px-4 py-3 text-sm whitespace-pre" aria-hidden="true">
|
||||
{val || 'Enter value'}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={(e) => handleStringChange(idx, e.target.value)}
|
||||
className="absolute inset-0 w-full h-full px-4 py-2 border-none bg-transparent text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-inset focus:ring-navy-600 transition-colors"
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(idx)}
|
||||
className="p-1.5 text-muted hover:text-red-500 transition-all cursor-pointer"
|
||||
title="Remove Row"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-5 py-10 text-center text-muted italic font-medium">
|
||||
No string items exist.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="p-4 border-t border-border-subtle bg-card flex justify-between items-center text-xs text-muted rounded-b-lg">
|
||||
<Button variant="primary" size="sm" onClick={handleAddString} className="font-semibold px-4 flex items-center gap-1.5">
|
||||
<Plus size={16} /> Add Row
|
||||
</Button>
|
||||
<div>{items.length} rows</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card border border-border-subtle rounded-lg shadow-sm">
|
||||
<div className="p-3 sm:p-4 border-b border-border-subtle flex items-center justify-between gap-3">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search rows..."
|
||||
className="w-full text-sm pl-8 pr-4 py-2 border border-border-subtle rounded-md focus:outline-none focus:ring-1 focus:ring-navy-600 bg-background text-foreground"
|
||||
/>
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-muted shrink-0">{items.length} rows</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full table-auto text-left text-sm whitespace-nowrap bg-card">
|
||||
<thead className="bg-black/5 border-b border-border-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold text-muted w-10 text-center border-r border-border-subtle">
|
||||
<input type="checkbox" checked={selectedRows.size === items.length && items.length > 0} onChange={toggleSelectAll} className="rounded border-border-subtle text-navy-600 focus:ring-navy-600 cursor-pointer" />
|
||||
</th>
|
||||
<th className="px-4 py-3 font-bold text-xs uppercase text-muted w-10 text-center border-r border-border-subtle">#</th>
|
||||
{dataset.keys?.map(k => (
|
||||
<th key={k} className="px-4 py-3 font-bold text-xs uppercase text-muted border-r border-border-subtle">{k}</th>
|
||||
))}
|
||||
<th className="px-4 py-3 font-bold text-xs uppercase text-muted text-center w-16">ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-subtle">
|
||||
{filteredIndices.map((idx) => {
|
||||
const rowObj = items[idx];
|
||||
return (
|
||||
<tr key={idx} className={`bg-card hover:bg-black/5 transition-colors ${selectedRows.has(idx) ? '!bg-navy-50/10' : ''}`}>
|
||||
<td className="px-4 py-2 text-center border-r border-border-subtle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(idx)}
|
||||
onChange={() => toggleRowSelection(idx)}
|
||||
className="rounded border-border-subtle text-navy-600 focus:ring-navy-600 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted font-mono text-center text-xs border-r border-border-subtle">{idx + 1}</td>
|
||||
{dataset.keys?.map(k => {
|
||||
const isInvalid = invalidCells.has(`${idx}-${k}`);
|
||||
return (
|
||||
<td key={k} className="px-0 py-0 relative border-r border-border-subtle min-w-[150px]">
|
||||
<div className="invisible px-4 py-3 text-sm whitespace-pre" aria-hidden="true">
|
||||
{rowObj[k] || `Enter ${k}`}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={rowObj[k] || ''}
|
||||
onChange={(e) => handleObjectChange(idx, k, e.target.value)}
|
||||
className={`absolute inset-0 w-full h-full px-4 py-2 border-none bg-transparent text-sm text-foreground transition-colors ${isInvalid
|
||||
? 'ring-2 ring-inset ring-red-400 bg-red-50/50 focus:ring-red-500 focus:outline-none'
|
||||
: 'focus:outline-none focus:ring-2 focus:ring-inset focus:ring-navy-600'
|
||||
}`}
|
||||
placeholder={`Enter ${k}`}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(idx)}
|
||||
className="p-1.5 text-muted hover:text-red-500 transition-all cursor-pointer"
|
||||
title="Remove Row"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={(dataset.keys?.length || 0) + 3} className="px-5 py-10 text-center text-muted italic font-medium">
|
||||
No data rows available.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="p-4 border-t border-border-subtle bg-card flex justify-between items-center text-xs text-muted rounded-b-lg">
|
||||
<Button variant="primary" size="sm" onClick={handleAddObject} className="font-semibold px-4 flex items-center gap-1.5">
|
||||
<Plus size={16} /> Add Row
|
||||
</Button>
|
||||
<div>{items.length} rows - {dataset.keys?.length || 0} cols</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/screens/admin/DatasetsPage.tsx
Normal file
105
src/screens/admin/DatasetsPage.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import type { Dataset } from '../../api/types';
|
||||
import { Spinner } from '../../components/reusable/Spinner';
|
||||
import { Database } from 'lucide-react';
|
||||
import { useNavigate, useLocation, Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
export function DatasetsPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDatasets();
|
||||
}, []);
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await orderBookingClient.getDatasets();
|
||||
setDatasets(res);
|
||||
setError('');
|
||||
|
||||
// Auto-select first dataset if none selected and on base path
|
||||
if (res.length > 0 && (location.pathname.endsWith('/admin/datasets') || location.pathname.endsWith('/admin/datasets/'))) {
|
||||
navigate(`/admin/datasets/${res[0].id}`, { replace: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch datasets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-10 flex justify-center"><Spinner /></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4 bg-red-50 text-red-600 rounded-md border border-red-100 font-medium">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row min-h-[calc(100vh-140px)] bg-card border border-border-subtle rounded-lg overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="w-full md:w-64 border-b md:border-b-0 md:border-r border-border-subtle flex flex-col bg-card shrink-0">
|
||||
<div className="p-4 border-b border-border-subtle flex items-center gap-2">
|
||||
<Database size={18} className="text-muted" />
|
||||
<h2 className="text-sm font-bold text-foreground tracking-wide">DATASETS</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto max-h-48 md:max-h-none">
|
||||
<ul className="flex flex-col py-2">
|
||||
{datasets.map((ds) => {
|
||||
const isActive = id === String(ds.id);
|
||||
const isTable = ds.data_type === 'object';
|
||||
return (
|
||||
<li key={ds.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/datasets/${ds.id}`)}
|
||||
className={`w-full text-left px-4 py-3 flex flex-col gap-1 border-l-4 transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? 'border-navy-600 bg-navy-50/10 text-foreground font-semibold'
|
||||
: 'border-transparent hover:bg-black/5 text-muted'
|
||||
}`}
|
||||
>
|
||||
<div className={`font-medium text-sm truncate ${isActive ? 'text-foreground font-bold' : 'text-foreground/80'}`}>
|
||||
{ds.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded flex items-center gap-1 ${
|
||||
isTable ? 'bg-purple-100 text-purple-700' : 'bg-emerald-100 text-emerald-700'
|
||||
}`}>
|
||||
{isTable ? 'Table' : 'List'}
|
||||
</span>
|
||||
<span className="text-xs text-muted font-medium">
|
||||
{ds.item_count || 0} items
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="h-px bg-border-subtle mx-4" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 bg-card overflow-y-auto min-w-0">
|
||||
{id ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<div className="h-full min-h-[300px] flex items-center justify-center text-muted p-6 text-center">
|
||||
Select a dataset to view its contents
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user