Merge pull request 'feat: Admin UI cleanup and dispensary schedule page' (#28) from fix/worker-proxy-wait into master
Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/28
This commit is contained in:
@@ -291,6 +291,107 @@ router.get('/stores/:id/summary', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/markets/stores/:id/crawl-history
|
||||||
|
* Get crawl history for a specific store
|
||||||
|
*/
|
||||||
|
router.get('/stores/:id/crawl-history', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { limit = '50' } = req.query;
|
||||||
|
const dispensaryId = parseInt(id, 10);
|
||||||
|
const limitNum = Math.min(parseInt(limit as string, 10), 100);
|
||||||
|
|
||||||
|
// Get crawl history from crawl_orchestration_traces
|
||||||
|
const { rows: historyRows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
profile_key,
|
||||||
|
crawler_module,
|
||||||
|
state_at_start,
|
||||||
|
state_at_end,
|
||||||
|
total_steps,
|
||||||
|
duration_ms,
|
||||||
|
success,
|
||||||
|
error_message,
|
||||||
|
products_found,
|
||||||
|
started_at,
|
||||||
|
completed_at
|
||||||
|
FROM crawl_orchestration_traces
|
||||||
|
WHERE dispensary_id = $1
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`, [dispensaryId, limitNum]);
|
||||||
|
|
||||||
|
// Get next scheduled crawl if available
|
||||||
|
const { rows: scheduleRows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
js.id as schedule_id,
|
||||||
|
js.job_name,
|
||||||
|
js.enabled,
|
||||||
|
js.base_interval_minutes,
|
||||||
|
js.jitter_minutes,
|
||||||
|
js.next_run_at,
|
||||||
|
js.last_run_at,
|
||||||
|
js.last_status
|
||||||
|
FROM job_schedules js
|
||||||
|
WHERE js.enabled = true
|
||||||
|
AND js.job_config->>'dispensaryId' = $1::text
|
||||||
|
ORDER BY js.next_run_at
|
||||||
|
LIMIT 1
|
||||||
|
`, [dispensaryId.toString()]);
|
||||||
|
|
||||||
|
// Get dispensary info for slug
|
||||||
|
const { rows: dispRows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
dba_name,
|
||||||
|
slug,
|
||||||
|
state,
|
||||||
|
city,
|
||||||
|
menu_type,
|
||||||
|
platform_dispensary_id,
|
||||||
|
last_menu_scrape
|
||||||
|
FROM dispensaries
|
||||||
|
WHERE id = $1
|
||||||
|
`, [dispensaryId]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
dispensary: dispRows[0] || null,
|
||||||
|
history: historyRows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
runId: row.run_id,
|
||||||
|
profileKey: row.profile_key,
|
||||||
|
crawlerModule: row.crawler_module,
|
||||||
|
stateAtStart: row.state_at_start,
|
||||||
|
stateAtEnd: row.state_at_end,
|
||||||
|
totalSteps: row.total_steps,
|
||||||
|
durationMs: row.duration_ms,
|
||||||
|
success: row.success,
|
||||||
|
errorMessage: row.error_message,
|
||||||
|
productsFound: row.products_found,
|
||||||
|
startedAt: row.started_at?.toISOString() || null,
|
||||||
|
completedAt: row.completed_at?.toISOString() || null,
|
||||||
|
})),
|
||||||
|
nextSchedule: scheduleRows[0] ? {
|
||||||
|
scheduleId: scheduleRows[0].schedule_id,
|
||||||
|
jobName: scheduleRows[0].job_name,
|
||||||
|
enabled: scheduleRows[0].enabled,
|
||||||
|
baseIntervalMinutes: scheduleRows[0].base_interval_minutes,
|
||||||
|
jitterMinutes: scheduleRows[0].jitter_minutes,
|
||||||
|
nextRunAt: scheduleRows[0].next_run_at?.toISOString() || null,
|
||||||
|
lastRunAt: scheduleRows[0].last_run_at?.toISOString() || null,
|
||||||
|
lastStatus: scheduleRows[0].last_status,
|
||||||
|
} : null,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Markets] Error fetching crawl history:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/markets/stores/:id/products
|
* GET /api/markets/stores/:id/products
|
||||||
* Get products for a store with filtering and pagination
|
* Get products for a store with filtering and pagination
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function handleStoreDiscovery(ctx: TaskContext): Promise<TaskResult
|
|||||||
try {
|
try {
|
||||||
// Get states to discover
|
// Get states to discover
|
||||||
const statesResult = await pool.query(`
|
const statesResult = await pool.query(`
|
||||||
SELECT code FROM states WHERE active = true ORDER BY code
|
SELECT code FROM states WHERE is_active = true ORDER BY code
|
||||||
`);
|
`);
|
||||||
const stateCodes = statesResult.rows.map(r => r.code);
|
const stateCodes = statesResult.rows.map(r => r.code);
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,6 @@ class TaskService {
|
|||||||
WHERE id = (
|
WHERE id = (
|
||||||
SELECT id FROM worker_tasks
|
SELECT id FROM worker_tasks
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
AND active = true
|
|
||||||
AND (scheduled_for IS NULL OR scheduled_for <= NOW())
|
AND (scheduled_for IS NULL OR scheduled_for <= NOW())
|
||||||
-- Exclude stores that already have an active task
|
-- Exclude stores that already have an active task
|
||||||
AND (dispensary_id IS NULL OR dispensary_id NOT IN (
|
AND (dispensary_id IS NULL OR dispensary_id NOT IN (
|
||||||
|
|||||||
4
cannaiq/dist/index.html
vendored
4
cannaiq/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
|||||||
<title>CannaIQ - Cannabis Menu Intelligence Platform</title>
|
<title>CannaIQ - Cannabis Menu Intelligence Platform</title>
|
||||||
<meta name="description" content="CannaIQ provides real-time cannabis dispensary menu data, product tracking, and analytics for dispensaries across Arizona." />
|
<meta name="description" content="CannaIQ provides real-time cannabis dispensary menu data, product tracking, and analytics for dispensaries across Arizona." />
|
||||||
<meta name="keywords" content="cannabis, dispensary, menu, products, analytics, Arizona" />
|
<meta name="keywords" content="cannabis, dispensary, menu, products, analytics, Arizona" />
|
||||||
<script type="module" crossorigin src="/assets/index-BXmp5CSY.js"></script>
|
<script type="module" crossorigin src="/assets/index-Dq9S0rVi.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-4959QN4j.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DhM09B-d.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ProductDetail } from './pages/ProductDetail';
|
|||||||
import { Stores } from './pages/Stores';
|
import { Stores } from './pages/Stores';
|
||||||
import { Dispensaries } from './pages/Dispensaries';
|
import { Dispensaries } from './pages/Dispensaries';
|
||||||
import { DispensaryDetail } from './pages/DispensaryDetail';
|
import { DispensaryDetail } from './pages/DispensaryDetail';
|
||||||
|
import { DispensarySchedule } from './pages/DispensarySchedule';
|
||||||
import { StoreDetail } from './pages/StoreDetail';
|
import { StoreDetail } from './pages/StoreDetail';
|
||||||
import { StoreBrands } from './pages/StoreBrands';
|
import { StoreBrands } from './pages/StoreBrands';
|
||||||
import { StoreSpecials } from './pages/StoreSpecials';
|
import { StoreSpecials } from './pages/StoreSpecials';
|
||||||
@@ -66,6 +67,7 @@ export default function App() {
|
|||||||
<Route path="/stores" element={<PrivateRoute><Stores /></PrivateRoute>} />
|
<Route path="/stores" element={<PrivateRoute><Stores /></PrivateRoute>} />
|
||||||
<Route path="/dispensaries" element={<PrivateRoute><Dispensaries /></PrivateRoute>} />
|
<Route path="/dispensaries" element={<PrivateRoute><Dispensaries /></PrivateRoute>} />
|
||||||
<Route path="/dispensaries/:state/:city/:slug" element={<PrivateRoute><DispensaryDetail /></PrivateRoute>} />
|
<Route path="/dispensaries/:state/:city/:slug" element={<PrivateRoute><DispensaryDetail /></PrivateRoute>} />
|
||||||
|
<Route path="/dispensaries/:state/:city/:slug/schedule" element={<PrivateRoute><DispensarySchedule /></PrivateRoute>} />
|
||||||
<Route path="/stores/:state/:storeName/:slug/brands" element={<PrivateRoute><StoreBrands /></PrivateRoute>} />
|
<Route path="/stores/:state/:storeName/:slug/brands" element={<PrivateRoute><StoreBrands /></PrivateRoute>} />
|
||||||
<Route path="/stores/:state/:storeName/:slug/specials" element={<PrivateRoute><StoreSpecials /></PrivateRoute>} />
|
<Route path="/stores/:state/:storeName/:slug/specials" element={<PrivateRoute><StoreSpecials /></PrivateRoute>} />
|
||||||
<Route path="/stores/:state/:storeName/:slug" element={<PrivateRoute><StoreDetail /></PrivateRoute>} />
|
<Route path="/stores/:state/:storeName/:slug" element={<PrivateRoute><StoreDetail /></PrivateRoute>} />
|
||||||
|
|||||||
@@ -983,6 +983,47 @@ class ApiClient {
|
|||||||
}>(`/api/markets/stores/${id}/categories`);
|
}>(`/api/markets/stores/${id}/categories`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStoreCrawlHistory(id: number, limit = 50) {
|
||||||
|
return this.request<{
|
||||||
|
dispensary: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
dba_name: string | null;
|
||||||
|
slug: string;
|
||||||
|
state: string;
|
||||||
|
city: string;
|
||||||
|
menu_type: string | null;
|
||||||
|
platform_dispensary_id: string | null;
|
||||||
|
last_menu_scrape: string | null;
|
||||||
|
} | null;
|
||||||
|
history: Array<{
|
||||||
|
id: number;
|
||||||
|
runId: string | null;
|
||||||
|
profileKey: string | null;
|
||||||
|
crawlerModule: string | null;
|
||||||
|
stateAtStart: string | null;
|
||||||
|
stateAtEnd: string | null;
|
||||||
|
totalSteps: number;
|
||||||
|
durationMs: number | null;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
productsFound: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
}>;
|
||||||
|
nextSchedule: {
|
||||||
|
scheduleId: number;
|
||||||
|
jobName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
baseIntervalMinutes: number;
|
||||||
|
jitterMinutes: number;
|
||||||
|
nextRunAt: string | null;
|
||||||
|
lastRunAt: string | null;
|
||||||
|
lastStatus: string | null;
|
||||||
|
} | null;
|
||||||
|
}>(`/api/markets/stores/${id}/crawl-history?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Global Brands/Categories (from v_brands/v_categories views)
|
// Global Brands/Categories (from v_brands/v_categories views)
|
||||||
async getMarketBrands(params?: { limit?: number; offset?: number }) {
|
async getMarketBrands(params?: { limit?: number; offset?: number }) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Layout } from '../components/Layout';
|
import { Layout } from '../components/Layout';
|
||||||
import { HealthPanel } from '../components/HealthPanel';
|
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +41,6 @@ export function Dashboard() {
|
|||||||
const [activity, setActivity] = useState<any>(null);
|
const [activity, setActivity] = useState<any>(null);
|
||||||
const [nationalStats, setNationalStats] = useState<any>(null);
|
const [nationalStats, setNationalStats] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [pendingChangesCount, setPendingChangesCount] = useState(0);
|
const [pendingChangesCount, setPendingChangesCount] = useState(0);
|
||||||
const [showNotification, setShowNotification] = useState(false);
|
const [showNotification, setShowNotification] = useState(false);
|
||||||
const [taskCounts, setTaskCounts] = useState<Record<string, number> | null>(null);
|
const [taskCounts, setTaskCounts] = useState<Record<string, number> | null>(null);
|
||||||
@@ -93,10 +91,7 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadData = async (isRefresh = false) => {
|
const loadData = async () => {
|
||||||
if (isRefresh) {
|
|
||||||
setRefreshing(true);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
// Fetch dashboard data (primary data source)
|
// Fetch dashboard data (primary data source)
|
||||||
const dashboard = await api.getMarketDashboard();
|
const dashboard = await api.getMarketDashboard();
|
||||||
@@ -158,7 +153,6 @@ export function Dashboard() {
|
|||||||
console.error('Failed to load dashboard:', error);
|
console.error('Failed to load dashboard:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,23 +265,10 @@ export function Dashboard() {
|
|||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Dashboard</h1>
|
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Dashboard</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Monitor your dispensary data aggregation</p>
|
<p className="text-sm text-gray-500 mt-1">Monitor your dispensary data aggregation</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => loadData(true)}
|
|
||||||
disabled={refreshing}
|
|
||||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium text-gray-700 self-start sm:self-auto disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
||||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Health */}
|
|
||||||
<HealthPanel showQueues={false} refreshInterval={60000} />
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-6">
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-6">
|
||||||
|
|||||||
@@ -161,23 +161,6 @@ export function Dispensaries() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Filter by Status
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={filterStatus}
|
|
||||||
onChange={(e) => handleStatusFilter(e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
|
||||||
filterStatus === 'dropped' ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<option value="">All Statuses</option>
|
|
||||||
<option value="open">Open</option>
|
|
||||||
<option value="dropped">Dropped (Needs Review)</option>
|
|
||||||
<option value="closed">Closed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -204,47 +204,6 @@ export function DispensaryDetail() {
|
|||||||
Back to Dispensaries
|
Back to Dispensaries
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Update Dropdown */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowUpdateDropdown(!showUpdateDropdown)}
|
|
||||||
disabled={isUpdating}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${isUpdating ? 'animate-spin' : ''}`} />
|
|
||||||
{isUpdating ? 'Updating...' : 'Update'}
|
|
||||||
{!isUpdating && <ChevronDown className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showUpdateDropdown && !isUpdating && (
|
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdate('products')}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-t-lg"
|
|
||||||
>
|
|
||||||
Products
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdate('brands')}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
Brands
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdate('specials')}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
Specials
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdate('all')}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-b-lg border-t border-gray-200"
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dispensary Header */}
|
{/* Dispensary Header */}
|
||||||
@@ -266,7 +225,7 @@ export function DispensaryDetail() {
|
|||||||
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Last Crawl Date:</span>
|
<span className="font-medium">Last Updated:</span>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{dispensary.last_menu_scrape
|
{dispensary.last_menu_scrape
|
||||||
? new Date(dispensary.last_menu_scrape).toLocaleDateString('en-US', {
|
? new Date(dispensary.last_menu_scrape).toLocaleDateString('en-US', {
|
||||||
@@ -331,7 +290,7 @@ export function DispensaryDetail() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
to="/schedule"
|
to={`/dispensaries/${state}/${city}/${slug}/schedule`}
|
||||||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
|
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
|
||||||
>
|
>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
@@ -533,57 +492,31 @@ export function DispensaryDetail() {
|
|||||||
`$${product.regular_price}`
|
`$${product.regular_price}`
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap">
|
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||||
{product.quantity != null ? (
|
{product.quantity != null ? product.quantity : '-'}
|
||||||
<span className={`badge badge-sm ${product.quantity > 0 ? 'badge-info' : 'badge-error'}`}>
|
|
||||||
{product.quantity}
|
|
||||||
</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap">
|
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||||
{product.thc_percentage ? (
|
{product.thc_percentage ? `${product.thc_percentage}%` : '-'}
|
||||||
<span className="badge badge-success badge-sm">{product.thc_percentage}%</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap">
|
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||||
{product.cbd_percentage ? (
|
{product.cbd_percentage ? `${product.cbd_percentage}%` : '-'}
|
||||||
<span className="badge badge-info badge-sm">{product.cbd_percentage}%</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap">
|
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||||
{product.strain_type ? (
|
{product.strain_type || '-'}
|
||||||
<span className="badge badge-ghost badge-sm">{product.strain_type}</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap">
|
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||||
{product.in_stock ? (
|
{product.in_stock ? 'Yes' : product.in_stock === false ? 'No' : '-'}
|
||||||
<span className="badge badge-success badge-sm">Yes</span>
|
|
||||||
) : product.in_stock === false ? (
|
|
||||||
<span className="badge badge-error badge-sm">No</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap text-xs text-gray-500">
|
<td className="whitespace-nowrap text-xs text-gray-500">
|
||||||
{product.updated_at ? formatDate(product.updated_at) : '-'}
|
{product.updated_at ? formatDate(product.updated_at) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex gap-1">
|
|
||||||
{product.dutchie_url && (
|
|
||||||
<a
|
|
||||||
href={product.dutchie_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn btn-xs btn-outline"
|
|
||||||
>
|
|
||||||
Dutchie
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/products/${product.id}`)}
|
onClick={() => navigate(`/products/${product.id}`)}
|
||||||
className="btn btn-xs btn-primary"
|
className="btn btn-xs btn-ghost text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
378
cannaiq/src/pages/DispensarySchedule.tsx
Normal file
378
cannaiq/src/pages/DispensarySchedule.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { Layout } from '../components/Layout';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Package,
|
||||||
|
Timer,
|
||||||
|
Building2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface CrawlHistoryItem {
|
||||||
|
id: number;
|
||||||
|
runId: string | null;
|
||||||
|
profileKey: string | null;
|
||||||
|
crawlerModule: string | null;
|
||||||
|
stateAtStart: string | null;
|
||||||
|
stateAtEnd: string | null;
|
||||||
|
totalSteps: number;
|
||||||
|
durationMs: number | null;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
productsFound: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NextSchedule {
|
||||||
|
scheduleId: number;
|
||||||
|
jobName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
baseIntervalMinutes: number;
|
||||||
|
jitterMinutes: number;
|
||||||
|
nextRunAt: string | null;
|
||||||
|
lastRunAt: string | null;
|
||||||
|
lastStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dispensary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
dba_name: string | null;
|
||||||
|
slug: string;
|
||||||
|
state: string;
|
||||||
|
city: string;
|
||||||
|
menu_type: string | null;
|
||||||
|
platform_dispensary_id: string | null;
|
||||||
|
last_menu_scrape: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DispensarySchedule() {
|
||||||
|
const { state, city, slug } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [dispensary, setDispensary] = useState<Dispensary | null>(null);
|
||||||
|
const [history, setHistory] = useState<CrawlHistoryItem[]>([]);
|
||||||
|
const [nextSchedule, setNextSchedule] = useState<NextSchedule | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadScheduleData();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
const loadScheduleData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// First get the dispensary to get the ID
|
||||||
|
const dispData = await api.getDispensary(slug!);
|
||||||
|
if (dispData?.id) {
|
||||||
|
const data = await api.getStoreCrawlHistory(dispData.id);
|
||||||
|
setDispensary(data.dispensary);
|
||||||
|
setHistory(data.history || []);
|
||||||
|
setNextSchedule(data.nextSchedule);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load schedule data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeAgo = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffMinutes < 1) return 'Just now';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
if (diffDays < 7) return `${diffDays} days ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeUntil = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return 'Not scheduled';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = date.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (diffMs < 0) return 'Overdue';
|
||||||
|
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
|
||||||
|
if (diffMinutes < 60) return `in ${diffMinutes}m`;
|
||||||
|
return `in ${diffHours}h ${diffMinutes % 60}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number | null) => {
|
||||||
|
if (!ms) return '-';
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 1) return `${seconds}s`;
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatInterval = (baseMinutes: number, jitterMinutes: number) => {
|
||||||
|
const hours = Math.floor(baseMinutes / 60);
|
||||||
|
const mins = baseMinutes % 60;
|
||||||
|
let base = hours > 0 ? `${hours}h` : '';
|
||||||
|
if (mins > 0) base += `${mins}m`;
|
||||||
|
return `Every ${base} (+/- ${jitterMinutes}m jitter)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-400 border-t-transparent"></div>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">Loading schedule...</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dispensary) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-600">Dispensary not found</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats from history
|
||||||
|
const successCount = history.filter(h => h.success).length;
|
||||||
|
const failureCount = history.filter(h => !h.success).length;
|
||||||
|
const lastSuccess = history.find(h => h.success);
|
||||||
|
const avgDuration = history.length > 0
|
||||||
|
? Math.round(history.reduce((sum, h) => sum + (h.durationMs || 0), 0) / history.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/dispensaries/${state}/${city}/${slug}`)}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to {dispensary.dba_name || dispensary.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dispensary Info */}
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg">
|
||||||
|
<Building2 className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{dispensary.dba_name || dispensary.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{dispensary.city}, {dispensary.state} - Crawl Schedule & History
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||||
|
<span>Slug: {dispensary.slug}</span>
|
||||||
|
{dispensary.menu_type && (
|
||||||
|
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs">
|
||||||
|
{dispensary.menu_type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Scheduled Crawl */}
|
||||||
|
{nextSchedule && (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5 text-blue-500" />
|
||||||
|
Upcoming Schedule
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Next Run</p>
|
||||||
|
<p className="text-xl font-semibold text-blue-600">
|
||||||
|
{formatTimeUntil(nextSchedule.nextRunAt)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{formatDate(nextSchedule.nextRunAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Interval</p>
|
||||||
|
<p className="text-lg font-medium">
|
||||||
|
{formatInterval(nextSchedule.baseIntervalMinutes, nextSchedule.jitterMinutes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Last Run</p>
|
||||||
|
<p className="text-lg font-medium">
|
||||||
|
{formatTimeAgo(nextSchedule.lastRunAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Last Status</p>
|
||||||
|
<p className={`text-lg font-medium ${
|
||||||
|
nextSchedule.lastStatus === 'success' ? 'text-green-600' :
|
||||||
|
nextSchedule.lastStatus === 'error' ? 'text-red-600' : 'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{nextSchedule.lastStatus || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Summary */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Successful Runs</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{successCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<XCircle className="w-8 h-8 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Failed Runs</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{failureCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Timer className="w-8 h-8 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Avg Duration</p>
|
||||||
|
<p className="text-2xl font-bold">{formatDuration(avgDuration)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="w-8 h-8 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Last Products Found</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{lastSuccess?.productsFound?.toLocaleString() || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crawl History Table */}
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200">
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-gray-500" />
|
||||||
|
Crawl History
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-sm w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th className="text-right">Products</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-8 text-gray-500">
|
||||||
|
No crawl history available
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
history.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
|
<td>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
item.success
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{item.success ? (
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
{item.success ? 'Success' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="text-sm">{formatDate(item.startedAt)}</div>
|
||||||
|
<div className="text-xs text-gray-400">{formatTimeAgo(item.startedAt)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="font-mono text-sm">
|
||||||
|
{formatDuration(item.durationMs)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right font-mono text-sm">
|
||||||
|
{item.productsFound?.toLocaleString() || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="text-sm text-gray-600">
|
||||||
|
{item.stateAtEnd || item.stateAtStart || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[200px]">
|
||||||
|
{item.errorMessage ? (
|
||||||
|
<span
|
||||||
|
className="text-xs text-red-600 truncate block cursor-help"
|
||||||
|
title={item.errorMessage}
|
||||||
|
>
|
||||||
|
{item.errorMessage.substring(0, 50)}...
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DispensarySchedule;
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
RefreshCw,
|
|
||||||
Search,
|
Search,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -32,7 +31,7 @@ export function IntelligenceBrands() {
|
|||||||
const [brands, setBrands] = useState<BrandData[]>([]);
|
const [brands, setBrands] = useState<BrandData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name'>('stores');
|
const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name' | 'states'>('stores');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBrands();
|
loadBrands();
|
||||||
@@ -69,6 +68,8 @@ export function IntelligenceBrands() {
|
|||||||
return b.skuCount - a.skuCount;
|
return b.skuCount - a.skuCount;
|
||||||
case 'name':
|
case 'name':
|
||||||
return a.brandName.localeCompare(b.brandName);
|
return a.brandName.localeCompare(b.brandName);
|
||||||
|
case 'states':
|
||||||
|
return b.states.length - a.states.length;
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -100,37 +101,62 @@ export function IntelligenceBrands() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Brands Intelligence</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Brands Intelligence</h1>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Brand penetration and pricing analytics across markets
|
Brand penetration and pricing analytics across markets
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
{/* State Selector */}
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
||||||
|
{stateLabel}
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
|
||||||
|
<li>
|
||||||
|
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
||||||
|
All States
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<div className="divider my-1"></div>
|
||||||
|
{availableStates.map((state) => (
|
||||||
|
<li key={state}>
|
||||||
|
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
||||||
|
{state}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page Navigation */}
|
||||||
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/intelligence/pricing')}
|
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
|
||||||
className="btn btn-sm btn-outline gap-1"
|
|
||||||
>
|
>
|
||||||
<DollarSign className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
Pricing
|
<span>Brands</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/intelligence/stores')}
|
onClick={() => navigate('/admin/intelligence/stores')}
|
||||||
className="btn btn-sm btn-outline gap-1"
|
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<MapPin className="w-4 h-4" />
|
<MapPin className="w-4 h-4" />
|
||||||
Stores
|
<span>Stores</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={loadBrands}
|
onClick={() => navigate('/admin/intelligence/pricing')}
|
||||||
className="btn btn-sm btn-outline gap-2"
|
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<DollarSign className="w-4 h-4" />
|
||||||
Refresh
|
<span>Pricing</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
@@ -180,51 +206,32 @@ export function IntelligenceBrands() {
|
|||||||
|
|
||||||
{/* Top Brands Chart */}
|
{/* Top Brands Chart */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2 mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
<BarChart3 className="w-5 h-5 text-emerald-500" />
|
||||||
<BarChart3 className="w-5 h-5 text-blue-500" />
|
|
||||||
Top 10 Brands by Store Count
|
Top 10 Brands by Store Count
|
||||||
</h3>
|
</h3>
|
||||||
<div className="dropdown dropdown-end">
|
|
||||||
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
|
||||||
{stateLabel}
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
|
|
||||||
<li>
|
|
||||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
|
||||||
All States
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="divider"></li>
|
|
||||||
{availableStates.map((state) => (
|
|
||||||
<li key={state}>
|
|
||||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
|
||||||
{state}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{topBrands.map((brand, idx) => (
|
{topBrands.map((brand) => {
|
||||||
|
const barWidth = Math.min((brand.storeCount / maxStoreCount) * 100, 100);
|
||||||
|
return (
|
||||||
<div key={brand.brandName} className="flex items-center gap-3">
|
<div key={brand.brandName} className="flex items-center gap-3">
|
||||||
<span className="text-sm text-gray-500 w-6">{idx + 1}.</span>
|
<span className="text-sm font-medium w-28 truncate shrink-0" title={brand.brandName}>
|
||||||
<span className="text-sm font-medium w-40 truncate" title={brand.brandName}>
|
|
||||||
{brand.brandName}
|
{brand.brandName}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 bg-gray-100 rounded-full h-4 relative">
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="bg-gray-100 rounded h-5 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 rounded-full h-4"
|
className="bg-gradient-to-r from-emerald-400 to-emerald-500 h-5 rounded transition-all"
|
||||||
style={{ width: `${(brand.storeCount / maxStoreCount) * 100}%` }}
|
style={{ width: `${barWidth}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-600 w-16 text-right">
|
</div>
|
||||||
{brand.storeCount} stores
|
<span className="text-sm font-mono font-semibold text-emerald-600 w-16 text-right shrink-0">
|
||||||
|
{brand.storeCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -247,6 +254,7 @@ export function IntelligenceBrands() {
|
|||||||
>
|
>
|
||||||
<option value="stores">Sort by Stores</option>
|
<option value="stores">Sort by Stores</option>
|
||||||
<option value="skus">Sort by SKUs</option>
|
<option value="skus">Sort by SKUs</option>
|
||||||
|
<option value="states">Sort by States</option>
|
||||||
<option value="name">Sort by Name</option>
|
<option value="name">Sort by Name</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
RefreshCw,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -87,58 +86,62 @@ export function IntelligencePricing() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Pricing Intelligence</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Pricing Intelligence</h1>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Price distribution and trends by category
|
Price distribution and trends by category
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
{/* State Selector */}
|
||||||
<div className="dropdown dropdown-end">
|
<div className="dropdown dropdown-end">
|
||||||
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
||||||
{stateLabel}
|
{stateLabel}
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
|
<ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
|
||||||
<li>
|
<li>
|
||||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
||||||
All States
|
All States
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li className="divider"></li>
|
<div className="divider my-1"></div>
|
||||||
{availableStates.map((state) => (
|
{availableStates.map((state) => (
|
||||||
<li key={state}>
|
<li key={state}>
|
||||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
||||||
{state}
|
{state}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Page Navigation */}
|
||||||
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/intelligence/brands')}
|
onClick={() => navigate('/admin/intelligence/brands')}
|
||||||
className="btn btn-sm btn-outline gap-1"
|
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<Building2 className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
Brands
|
<span>Brands</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/intelligence/stores')}
|
onClick={() => navigate('/admin/intelligence/stores')}
|
||||||
className="btn btn-sm btn-outline gap-1"
|
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<MapPin className="w-4 h-4" />
|
<MapPin className="w-4 h-4" />
|
||||||
Stores
|
<span>Stores</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={loadPricing}
|
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
|
||||||
className="btn btn-sm btn-outline gap-2"
|
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<DollarSign className="w-4 h-4" />
|
||||||
Refresh
|
<span>Pricing</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Overall Stats */}
|
{/* Overall Stats */}
|
||||||
{overall && (
|
{overall && (
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Package,
|
Package,
|
||||||
RefreshCw,
|
|
||||||
Search,
|
Search,
|
||||||
Clock,
|
Clock,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -34,12 +33,19 @@ export function IntelligenceStores() {
|
|||||||
const [stores, setStores] = useState<StoreActivity[]>([]);
|
const [stores, setStores] = useState<StoreActivity[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [localStates, setLocalStates] = useState<string[]>([]);
|
const [availableStates, setAvailableStates] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStores();
|
loadStores();
|
||||||
}, [selectedState]);
|
}, [selectedState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load available states from orchestrator API
|
||||||
|
api.getOrchestratorStates().then(data => {
|
||||||
|
setAvailableStates(data.states?.map((s: any) => s.state) || []);
|
||||||
|
}).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadStores = async () => {
|
const loadStores = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -47,12 +53,7 @@ export function IntelligenceStores() {
|
|||||||
state: stateParam,
|
state: stateParam,
|
||||||
limit: 500,
|
limit: 500,
|
||||||
});
|
});
|
||||||
const storeList = data.stores || [];
|
setStores(data.stores || []);
|
||||||
setStores(storeList);
|
|
||||||
|
|
||||||
// Extract unique states from response for dropdown counts
|
|
||||||
const uniqueStates = [...new Set(storeList.map((s: StoreActivity) => s.state))].filter(Boolean).sort() as string[];
|
|
||||||
setLocalStates(uniqueStates);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load stores:', error);
|
console.error('Failed to load stores:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,37 +111,62 @@ export function IntelligenceStores() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Store Activity</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Store Activity</h1>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Per-store SKU counts, snapshots, and crawl frequency
|
Per-store SKU counts, snapshots, and crawl frequency
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
{/* State Selector */}
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
||||||
|
{stateLabel}
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
|
||||||
|
<li>
|
||||||
|
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
||||||
|
All States
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<div className="divider my-1"></div>
|
||||||
|
{availableStates.map((state) => (
|
||||||
|
<li key={state}>
|
||||||
|
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
||||||
|
{state}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page Navigation */}
|
||||||
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/intelligence/brands')}
|
onClick={() => navigate('/admin/intelligence/brands')}
|
||||||
className="btn btn-sm btn-outline gap-1"
|
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<Building2 className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
Brands
|
<span>Brands</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
|
||||||
|
>
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>Stores</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/intelligence/pricing')}
|
onClick={() => navigate('/admin/intelligence/pricing')}
|
||||||
className="btn btn-sm btn-outline gap-1"
|
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<DollarSign className="w-4 h-4" />
|
<DollarSign className="w-4 h-4" />
|
||||||
Pricing
|
<span>Pricing</span>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={loadStores}
|
|
||||||
className="btn btn-sm btn-outline gap-2"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
Refresh
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards - Responsive: 2→4 columns */}
|
{/* Summary Cards - Responsive: 2→4 columns */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
@@ -194,26 +220,6 @@ export function IntelligenceStores() {
|
|||||||
className="input input-bordered input-sm w-full pl-10"
|
className="input input-bordered input-sm w-full pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="dropdown">
|
|
||||||
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
|
||||||
{stateLabel}
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
|
|
||||||
<li>
|
|
||||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
|
||||||
All States
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{localStates.map(state => (
|
|
||||||
<li key={state}>
|
|
||||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
|
||||||
{state}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
Showing {filteredStores.length} of {stores.length} stores
|
Showing {filteredStores.length} of {stores.length} stores
|
||||||
</span>
|
</span>
|
||||||
@@ -247,7 +253,7 @@ export function IntelligenceStores() {
|
|||||||
<tr
|
<tr
|
||||||
key={store.id}
|
key={store.id}
|
||||||
className="hover:bg-gray-50 cursor-pointer"
|
className="hover:bg-gray-50 cursor-pointer"
|
||||||
onClick={() => navigate(`/admin/orchestrator/stores?storeId=${store.id}`)}
|
onClick={() => navigate(`/stores/list/${store.id}`)}
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<span className="font-medium">{store.name}</span>
|
<span className="font-medium">{store.name}</span>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
MapPin,
|
MapPin,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
RefreshCw,
|
|
||||||
AlertCircle
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -204,7 +203,6 @@ export default function NationalDashboard() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [summary, setSummary] = useState<NationalSummary | null>(null);
|
const [summary, setSummary] = useState<NationalSummary | null>(null);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -229,18 +227,6 @@ export default function NationalDashboard() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRefreshMetrics = async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
|
||||||
await api.post('/api/admin/states/refresh-metrics');
|
|
||||||
await fetchData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to refresh metrics:', err);
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStateClick = (stateCode: string) => {
|
const handleStateClick = (stateCode: string) => {
|
||||||
setSelectedState(stateCode);
|
setSelectedState(stateCode);
|
||||||
navigate(`/national/state/${stateCode}`);
|
navigate(`/national/state/${stateCode}`);
|
||||||
@@ -277,24 +263,12 @@ export default function NationalDashboard() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">National Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900">National Dashboard</h1>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 mt-1">
|
||||||
Multi-state cannabis market intelligence
|
Multi-state cannabis market intelligence
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleRefreshMetrics}
|
|
||||||
disabled={refreshing}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
||||||
Refresh Metrics
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
{summary && (
|
{summary && (
|
||||||
|
|||||||
@@ -153,29 +153,6 @@ export function StoreDetailPage() {
|
|||||||
Back to Stores
|
Back to Stores
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Update Button */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowUpdateDropdown(!showUpdateDropdown)}
|
|
||||||
disabled={isUpdating}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${isUpdating ? 'animate-spin' : ''}`} />
|
|
||||||
{isUpdating ? 'Crawling...' : 'Crawl Now'}
|
|
||||||
{!isUpdating && <ChevronDown className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showUpdateDropdown && !isUpdating && (
|
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
|
||||||
<button
|
|
||||||
onClick={handleCrawl}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg"
|
|
||||||
>
|
|
||||||
Start Full Crawl
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Store Header */}
|
{/* Store Header */}
|
||||||
@@ -200,7 +177,7 @@ export function StoreDetailPage() {
|
|||||||
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Last Crawl:</span>
|
<span className="font-medium">Last Updated:</span>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{lastCrawl?.completed_at
|
{lastCrawl?.completed_at
|
||||||
? new Date(lastCrawl.completed_at).toLocaleDateString('en-US', {
|
? new Date(lastCrawl.completed_at).toLocaleDateString('en-US', {
|
||||||
@@ -212,15 +189,6 @@ export function StoreDetailPage() {
|
|||||||
})
|
})
|
||||||
: 'Never'}
|
: 'Never'}
|
||||||
</span>
|
</span>
|
||||||
{lastCrawl?.status && (
|
|
||||||
<span className={`ml-2 px-2 py-0.5 rounded text-xs ${
|
|
||||||
lastCrawl.status === 'completed' ? 'bg-green-100 text-green-800' :
|
|
||||||
lastCrawl.status === 'failed' ? 'bg-red-100 text-red-800' :
|
|
||||||
'bg-yellow-100 text-yellow-800'
|
|
||||||
}`}>
|
|
||||||
{lastCrawl.status}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,8 +250,8 @@ export function StoreDetailPage() {
|
|||||||
setStockFilter('in_stock');
|
setStockFilter('in_stock');
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
}}
|
}}
|
||||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||||
stockFilter === 'in_stock' ? 'border-blue-500' : 'border-gray-200'
|
stockFilter === 'in_stock' ? 'border-gray-400' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -303,8 +271,8 @@ export function StoreDetailPage() {
|
|||||||
setStockFilter('out_of_stock');
|
setStockFilter('out_of_stock');
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
}}
|
}}
|
||||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||||
stockFilter === 'out_of_stock' ? 'border-blue-500' : 'border-gray-200'
|
stockFilter === 'out_of_stock' ? 'border-gray-400' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -320,8 +288,8 @@ export function StoreDetailPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('brands')}
|
onClick={() => setActiveTab('brands')}
|
||||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||||
activeTab === 'brands' ? 'border-blue-500' : 'border-gray-200'
|
activeTab === 'brands' ? 'border-gray-400' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -337,8 +305,8 @@ export function StoreDetailPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('categories')}
|
onClick={() => setActiveTab('categories')}
|
||||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||||
activeTab === 'categories' ? 'border-blue-500' : 'border-gray-200'
|
activeTab === 'categories' ? 'border-gray-400' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -364,7 +332,7 @@ export function StoreDetailPage() {
|
|||||||
}}
|
}}
|
||||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||||
activeTab === 'products'
|
activeTab === 'products'
|
||||||
? 'border-blue-600 text-blue-600'
|
? 'border-gray-800 text-gray-900'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -374,7 +342,7 @@ export function StoreDetailPage() {
|
|||||||
onClick={() => setActiveTab('brands')}
|
onClick={() => setActiveTab('brands')}
|
||||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||||
activeTab === 'brands'
|
activeTab === 'brands'
|
||||||
? 'border-blue-600 text-blue-600'
|
? 'border-gray-800 text-gray-900'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -384,7 +352,7 @@ export function StoreDetailPage() {
|
|||||||
onClick={() => setActiveTab('categories')}
|
onClick={() => setActiveTab('categories')}
|
||||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||||
activeTab === 'categories'
|
activeTab === 'categories'
|
||||||
? 'border-blue-600 text-blue-600'
|
? 'border-gray-800 text-gray-900'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -433,7 +401,7 @@ export function StoreDetailPage() {
|
|||||||
|
|
||||||
{productsLoading ? (
|
{productsLoading ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-4 border-blue-500 border-t-transparent"></div>
|
<div className="inline-block animate-spin rounded-full h-6 w-6 border-4 border-gray-400 border-t-transparent"></div>
|
||||||
<p className="mt-2 text-sm text-gray-600">Loading products...</p>
|
<p className="mt-2 text-sm text-gray-600">Loading products...</p>
|
||||||
</div>
|
</div>
|
||||||
) : products.length === 0 ? (
|
) : products.length === 0 ? (
|
||||||
@@ -485,9 +453,9 @@ export function StoreDetailPage() {
|
|||||||
<div className="line-clamp-2" title={product.brand || '-'}>{product.brand || '-'}</div>
|
<div className="line-clamp-2" title={product.brand || '-'}>{product.brand || '-'}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap">
|
<td className="whitespace-nowrap">
|
||||||
<span className="badge badge-ghost badge-sm">{product.type || '-'}</span>
|
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">{product.type || '-'}</span>
|
||||||
{product.subcategory && (
|
{product.subcategory && (
|
||||||
<span className="badge badge-ghost badge-sm ml-1">{product.subcategory}</span>
|
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded ml-1">{product.subcategory}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right font-semibold whitespace-nowrap">
|
<td className="text-right font-semibold whitespace-nowrap">
|
||||||
@@ -500,21 +468,14 @@ export function StoreDetailPage() {
|
|||||||
`$${product.regular_price}`
|
`$${product.regular_price}`
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap">
|
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||||
{product.thc_percentage ? (
|
{product.thc_percentage ? `${product.thc_percentage}%` : '-'}
|
||||||
<span className="badge badge-success badge-sm">{product.thc_percentage}%</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap">
|
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||||
{product.stock_status === 'in_stock' ? (
|
{product.stock_status === 'in_stock' ? 'In Stock' :
|
||||||
<span className="badge badge-success badge-sm">In Stock</span>
|
product.stock_status === 'out_of_stock' ? 'Out' : '-'}
|
||||||
) : product.stock_status === 'out_of_stock' ? (
|
|
||||||
<span className="badge badge-error badge-sm">Out</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge badge-warning badge-sm">Unknown</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap">
|
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||||
{product.total_quantity != null ? product.total_quantity : '-'}
|
{product.total_quantity != null ? product.total_quantity : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap text-xs text-gray-500">
|
<td className="whitespace-nowrap text-xs text-gray-500">
|
||||||
|
|||||||
Reference in New Issue
Block a user