Compare commits
4 Commits
fix/worker
...
feat/task-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a09691e91 | ||
|
|
01810c40a1 | ||
|
|
61e915968f | ||
|
|
a4338669a9 |
@@ -291,107 +291,6 @@ 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
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import {
|
|||||||
TaskFilter,
|
TaskFilter,
|
||||||
} from '../tasks/task-service';
|
} from '../tasks/task-service';
|
||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
|
import {
|
||||||
|
isTaskPoolPaused,
|
||||||
|
pauseTaskPool,
|
||||||
|
resumeTaskPool,
|
||||||
|
getTaskPoolStatus,
|
||||||
|
} from '../tasks/task-pool-state';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -592,4 +598,42 @@ router.post('/migration/full-migrate', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tasks/pool/status
|
||||||
|
* Check if task pool is paused
|
||||||
|
*/
|
||||||
|
router.get('/pool/status', async (_req: Request, res: Response) => {
|
||||||
|
const status = getTaskPoolStatus();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
...status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tasks/pool/pause
|
||||||
|
* Pause the task pool - workers won't pick up new tasks
|
||||||
|
*/
|
||||||
|
router.post('/pool/pause', async (_req: Request, res: Response) => {
|
||||||
|
pauseTaskPool();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
paused: true,
|
||||||
|
message: 'Task pool paused - workers will not pick up new tasks',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tasks/pool/resume
|
||||||
|
* Resume the task pool - workers will pick up tasks again
|
||||||
|
*/
|
||||||
|
router.post('/pool/resume', async (_req: Request, res: Response) => {
|
||||||
|
resumeTaskPool();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
paused: false,
|
||||||
|
message: 'Task pool resumed - workers will pick up new tasks',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
35
backend/src/tasks/task-pool-state.ts
Normal file
35
backend/src/tasks/task-pool-state.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Task Pool State
|
||||||
|
*
|
||||||
|
* Shared state for task pool pause/resume functionality.
|
||||||
|
* This is kept separate to avoid circular dependencies between
|
||||||
|
* task-service.ts and routes/tasks.ts.
|
||||||
|
*
|
||||||
|
* State is in-memory and resets on server restart.
|
||||||
|
* By default, the pool is OPEN (not paused).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let taskPoolPaused = false;
|
||||||
|
|
||||||
|
export function isTaskPoolPaused(): boolean {
|
||||||
|
return taskPoolPaused;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseTaskPool(): void {
|
||||||
|
taskPoolPaused = true;
|
||||||
|
console.log('[TaskPool] Task pool PAUSED - workers will not pick up new tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeTaskPool(): void {
|
||||||
|
taskPoolPaused = false;
|
||||||
|
console.log('[TaskPool] Task pool RESUMED - workers can pick up tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskPoolStatus(): { paused: boolean; message: string } {
|
||||||
|
return {
|
||||||
|
paused: taskPoolPaused,
|
||||||
|
message: taskPoolPaused
|
||||||
|
? 'Task pool is paused - workers will not pick up new tasks'
|
||||||
|
: 'Task pool is open - workers are picking up tasks',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
|
import { isTaskPoolPaused } from './task-pool-state';
|
||||||
|
|
||||||
// Helper to check if a table exists
|
// Helper to check if a table exists
|
||||||
async function tableExists(tableName: string): Promise<boolean> {
|
async function tableExists(tableName: string): Promise<boolean> {
|
||||||
@@ -149,8 +150,14 @@ class TaskService {
|
|||||||
/**
|
/**
|
||||||
* Claim a task atomically for a worker
|
* Claim a task atomically for a worker
|
||||||
* If role is null, claims ANY available task (role-agnostic worker)
|
* If role is null, claims ANY available task (role-agnostic worker)
|
||||||
|
* Returns null if task pool is paused.
|
||||||
*/
|
*/
|
||||||
async claimTask(role: TaskRole | null, workerId: string): Promise<WorkerTask | null> {
|
async claimTask(role: TaskRole | null, workerId: string): Promise<WorkerTask | null> {
|
||||||
|
// Check if task pool is paused - don't claim any tasks
|
||||||
|
if (isTaskPoolPaused()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (role) {
|
if (role) {
|
||||||
// Role-specific claiming - use the SQL function
|
// Role-specific claiming - use the SQL function
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|||||||
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-Dq9S0rVi.js"></script>
|
<script type="module" crossorigin src="/assets/index-BXmp5CSY.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DhM09B-d.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-4959QN4j.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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';
|
||||||
@@ -67,7 +66,6 @@ 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,47 +983,6 @@ 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();
|
||||||
@@ -2929,6 +2888,27 @@ class ApiClient {
|
|||||||
`/api/tasks/store/${dispensaryId}/active`
|
`/api/tasks/store/${dispensaryId}/active`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task Pool Control
|
||||||
|
async getTaskPoolStatus() {
|
||||||
|
return this.request<{ success: boolean; paused: boolean; message: string }>(
|
||||||
|
'/api/tasks/pool/status'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pauseTaskPool() {
|
||||||
|
return this.request<{ success: boolean; paused: boolean; message: string }>(
|
||||||
|
'/api/tasks/pool/pause',
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeTaskPool() {
|
||||||
|
return this.request<{ success: boolean; paused: boolean; message: string }>(
|
||||||
|
'/api/tasks/pool/resume',
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient(API_URL);
|
export const api = new ApiClient(API_URL);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -41,6 +42,7 @@ 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);
|
||||||
@@ -91,7 +93,10 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async (isRefresh = false) => {
|
||||||
|
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();
|
||||||
@@ -153,6 +158,7 @@ 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,11 +271,24 @@ export function Dashboard() {
|
|||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||||
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Dashboard</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mt-1">Monitor your dispensary data aggregation</p>
|
<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>
|
||||||
|
</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>
|
</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">
|
||||||
{/* Products */}
|
{/* Products */}
|
||||||
|
|||||||
@@ -161,6 +161,23 @@ 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,6 +204,47 @@ 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 */}
|
||||||
@@ -225,7 +266,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 Updated:</span>
|
<span className="font-medium">Last Crawl Date:</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', {
|
||||||
@@ -290,7 +331,7 @@ export function DispensaryDetail() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
to={`/dispensaries/${state}/${city}/${slug}/schedule`}
|
to="/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" />
|
||||||
@@ -492,31 +533,57 @@ export function DispensaryDetail() {
|
|||||||
`$${product.regular_price}`
|
`$${product.regular_price}`
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
<td className="text-center whitespace-nowrap">
|
||||||
{product.quantity != null ? product.quantity : '-'}
|
{product.quantity != null ? (
|
||||||
|
<span className={`badge badge-sm ${product.quantity > 0 ? 'badge-info' : 'badge-error'}`}>
|
||||||
|
{product.quantity}
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
<td className="text-center whitespace-nowrap">
|
||||||
{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 text-sm text-gray-700">
|
<td className="text-center whitespace-nowrap">
|
||||||
{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 text-sm text-gray-700">
|
<td className="text-center whitespace-nowrap">
|
||||||
{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 text-sm text-gray-700">
|
<td className="text-center whitespace-nowrap">
|
||||||
{product.in_stock ? 'Yes' : product.in_stock === false ? 'No' : '-'}
|
{product.in_stock ? (
|
||||||
|
<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>
|
||||||
<button
|
<div className="flex gap-1">
|
||||||
onClick={() => navigate(`/products/${product.id}`)}
|
{product.dutchie_url && (
|
||||||
className="btn btn-xs btn-ghost text-gray-500 hover:text-gray-700"
|
<a
|
||||||
>
|
href={product.dutchie_url}
|
||||||
Details
|
target="_blank"
|
||||||
</button>
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-xs btn-outline"
|
||||||
|
>
|
||||||
|
Dutchie
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/products/${product.id}`)}
|
||||||
|
className="btn btn-xs btn-primary"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
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,6 +9,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -31,7 +32,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' | 'states'>('stores');
|
const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name'>('stores');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBrands();
|
loadBrands();
|
||||||
@@ -68,8 +69,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -101,60 +100,35 @@ export function IntelligenceBrands() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center 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 flex-wrap gap-2 items-center">
|
<div className="flex gap-2">
|
||||||
{/* State Selector */}
|
<button
|
||||||
<div className="dropdown dropdown-end">
|
onClick={() => navigate('/admin/intelligence/pricing')}
|
||||||
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
className="btn btn-sm btn-outline gap-1"
|
||||||
{stateLabel}
|
>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<DollarSign className="w-4 h-4" />
|
||||||
</button>
|
Pricing
|
||||||
<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">
|
</button>
|
||||||
<li>
|
<button
|
||||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
onClick={() => navigate('/admin/intelligence/stores')}
|
||||||
All States
|
className="btn btn-sm btn-outline gap-1"
|
||||||
</a>
|
>
|
||||||
</li>
|
<MapPin className="w-4 h-4" />
|
||||||
<div className="divider my-1"></div>
|
Stores
|
||||||
{availableStates.map((state) => (
|
</button>
|
||||||
<li key={state}>
|
<button
|
||||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
onClick={loadBrands}
|
||||||
{state}
|
className="btn btn-sm btn-outline gap-2"
|
||||||
</a>
|
>
|
||||||
</li>
|
<RefreshCw className="w-4 h-4" />
|
||||||
))}
|
Refresh
|
||||||
</ul>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Navigation */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
|
|
||||||
>
|
|
||||||
<Building2 className="w-4 h-4" />
|
|
||||||
<span>Brands</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin/intelligence/stores')}
|
|
||||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
<span>Stores</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin/intelligence/pricing')}
|
|
||||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
<span>Pricing</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,32 +180,51 @@ 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">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2 mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<BarChart3 className="w-5 h-5 text-emerald-500" />
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
Top 10 Brands by Store Count
|
<BarChart3 className="w-5 h-5 text-blue-500" />
|
||||||
</h3>
|
Top 10 Brands by Store Count
|
||||||
|
</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) => {
|
{topBrands.map((brand, idx) => (
|
||||||
const barWidth = Math.min((brand.storeCount / maxStoreCount) * 100, 100);
|
<div key={brand.brandName} className="flex items-center gap-3">
|
||||||
return (
|
<span className="text-sm text-gray-500 w-6">{idx + 1}.</span>
|
||||||
<div key={brand.brandName} className="flex items-center gap-3">
|
<span className="text-sm font-medium w-40 truncate" title={brand.brandName}>
|
||||||
<span className="text-sm font-medium w-28 truncate shrink-0" 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
|
||||||
<div className="bg-gray-100 rounded h-5 overflow-hidden">
|
className="bg-blue-500 rounded-full h-4"
|
||||||
<div
|
style={{ width: `${(brand.storeCount / maxStoreCount) * 100}%` }}
|
||||||
className="bg-gradient-to-r from-emerald-400 to-emerald-500 h-5 rounded transition-all"
|
/>
|
||||||
style={{ width: `${barWidth}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-mono font-semibold text-emerald-600 w-16 text-right shrink-0">
|
|
||||||
{brand.storeCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<span className="text-sm text-gray-600 w-16 text-right">
|
||||||
})}
|
{brand.storeCount} stores
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -254,7 +247,6 @@ 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,6 +8,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
|
RefreshCw,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -86,60 +87,56 @@ export function IntelligencePricing() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center 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 flex-wrap gap-2 items-center">
|
<div className="flex gap-2">
|
||||||
{/* State Selector */}
|
|
||||||
<div className="dropdown dropdown-end">
|
<div className="dropdown dropdown-end">
|
||||||
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
||||||
{stateLabel}
|
{stateLabel}
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</button>
|
</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">
|
<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>
|
<li>
|
||||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
||||||
All States
|
All States
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<div className="divider my-1"></div>
|
<li className="divider"></li>
|
||||||
{availableStates.map((state) => (
|
{availableStates.map((state) => (
|
||||||
<li key={state}>
|
<li key={state}>
|
||||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
||||||
{state}
|
{state}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{/* Page Navigation */}
|
onClick={() => navigate('/admin/intelligence/brands')}
|
||||||
<div className="flex gap-1">
|
className="btn btn-sm btn-outline gap-1"
|
||||||
<button
|
>
|
||||||
onClick={() => navigate('/admin/intelligence/brands')}
|
<Building2 className="w-4 h-4" />
|
||||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
Brands
|
||||||
>
|
</button>
|
||||||
<Building2 className="w-4 h-4" />
|
<button
|
||||||
<span>Brands</span>
|
onClick={() => navigate('/admin/intelligence/stores')}
|
||||||
</button>
|
className="btn btn-sm btn-outline gap-1"
|
||||||
<button
|
>
|
||||||
onClick={() => navigate('/admin/intelligence/stores')}
|
<MapPin className="w-4 h-4" />
|
||||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
Stores
|
||||||
>
|
</button>
|
||||||
<MapPin className="w-4 h-4" />
|
<button
|
||||||
<span>Stores</span>
|
onClick={loadPricing}
|
||||||
</button>
|
className="btn btn-sm btn-outline gap-2"
|
||||||
<button
|
>
|
||||||
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
|
<RefreshCw className="w-4 h-4" />
|
||||||
>
|
Refresh
|
||||||
<DollarSign className="w-4 h-4" />
|
</button>
|
||||||
<span>Pricing</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Package,
|
Package,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Clock,
|
Clock,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -33,19 +34,12 @@ 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 [availableStates, setAvailableStates] = useState<string[]>([]);
|
const [localStates, setLocalStates] = 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);
|
||||||
@@ -53,7 +47,12 @@ export function IntelligenceStores() {
|
|||||||
state: stateParam,
|
state: stateParam,
|
||||||
limit: 500,
|
limit: 500,
|
||||||
});
|
});
|
||||||
setStores(data.stores || []);
|
const storeList = 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 {
|
||||||
@@ -111,60 +110,35 @@ export function IntelligenceStores() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center 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 flex-wrap gap-2 items-center">
|
<div className="flex gap-2">
|
||||||
{/* State Selector */}
|
<button
|
||||||
<div className="dropdown dropdown-end">
|
onClick={() => navigate('/admin/intelligence/brands')}
|
||||||
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
className="btn btn-sm btn-outline gap-1"
|
||||||
{stateLabel}
|
>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
</button>
|
Brands
|
||||||
<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">
|
</button>
|
||||||
<li>
|
<button
|
||||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
onClick={() => navigate('/admin/intelligence/pricing')}
|
||||||
All States
|
className="btn btn-sm btn-outline gap-1"
|
||||||
</a>
|
>
|
||||||
</li>
|
<DollarSign className="w-4 h-4" />
|
||||||
<div className="divider my-1"></div>
|
Pricing
|
||||||
{availableStates.map((state) => (
|
</button>
|
||||||
<li key={state}>
|
<button
|
||||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
onClick={loadStores}
|
||||||
{state}
|
className="btn btn-sm btn-outline gap-2"
|
||||||
</a>
|
>
|
||||||
</li>
|
<RefreshCw className="w-4 h-4" />
|
||||||
))}
|
Refresh
|
||||||
</ul>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Navigation */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin/intelligence/brands')}
|
|
||||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<Building2 className="w-4 h-4" />
|
|
||||||
<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
|
|
||||||
onClick={() => navigate('/admin/intelligence/pricing')}
|
|
||||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
<span>Pricing</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -220,6 +194,26 @@ 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>
|
||||||
@@ -253,7 +247,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(`/stores/list/${store.id}`)}
|
onClick={() => navigate(`/admin/orchestrator/stores?storeId=${store.id}`)}
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<span className="font-medium">{store.name}</span>
|
<span className="font-medium">{store.name}</span>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
MapPin,
|
MapPin,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
RefreshCw,
|
||||||
AlertCircle
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -203,6 +204,7 @@ 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);
|
||||||
@@ -227,6 +229,18 @@ 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}`);
|
||||||
@@ -263,11 +277,23 @@ export default function NationalDashboard() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">National Dashboard</h1>
|
<div>
|
||||||
<p className="text-gray-500 mt-1">
|
<h1 className="text-2xl font-bold text-gray-900">National Dashboard</h1>
|
||||||
Multi-state cannabis market intelligence
|
<p className="text-gray-500 mt-1">
|
||||||
</p>
|
Multi-state cannabis market intelligence
|
||||||
|
</p>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
|
|||||||
@@ -153,6 +153,29 @@ 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 */}
|
||||||
@@ -177,7 +200,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 Updated:</span>
|
<span className="font-medium">Last Crawl:</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', {
|
||||||
@@ -189,6 +212,15 @@ 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>
|
||||||
@@ -250,8 +282,8 @@ export function StoreDetailPage() {
|
|||||||
setStockFilter('in_stock');
|
setStockFilter('in_stock');
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
}}
|
}}
|
||||||
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||||
stockFilter === 'in_stock' ? 'border-gray-400' : 'border-gray-200'
|
stockFilter === 'in_stock' ? 'border-blue-500' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -271,8 +303,8 @@ export function StoreDetailPage() {
|
|||||||
setStockFilter('out_of_stock');
|
setStockFilter('out_of_stock');
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
}}
|
}}
|
||||||
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||||
stockFilter === 'out_of_stock' ? 'border-gray-400' : 'border-gray-200'
|
stockFilter === 'out_of_stock' ? 'border-blue-500' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -288,8 +320,8 @@ export function StoreDetailPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('brands')}
|
onClick={() => setActiveTab('brands')}
|
||||||
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||||
activeTab === 'brands' ? 'border-gray-400' : 'border-gray-200'
|
activeTab === 'brands' ? 'border-blue-500' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -305,8 +337,8 @@ export function StoreDetailPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('categories')}
|
onClick={() => setActiveTab('categories')}
|
||||||
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||||
activeTab === 'categories' ? 'border-gray-400' : 'border-gray-200'
|
activeTab === 'categories' ? 'border-blue-500' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -332,7 +364,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-gray-800 text-gray-900'
|
? 'border-blue-600 text-blue-600'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -342,7 +374,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-gray-800 text-gray-900'
|
? 'border-blue-600 text-blue-600'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -352,7 +384,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-gray-800 text-gray-900'
|
? 'border-blue-600 text-blue-600'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -401,7 +433,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-gray-400 border-t-transparent"></div>
|
<div className="inline-block animate-spin rounded-full h-6 w-6 border-4 border-blue-500 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 ? (
|
||||||
@@ -453,9 +485,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="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">{product.type || '-'}</span>
|
<span className="badge badge-ghost badge-sm">{product.type || '-'}</span>
|
||||||
{product.subcategory && (
|
{product.subcategory && (
|
||||||
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded ml-1">{product.subcategory}</span>
|
<span className="badge badge-ghost badge-sm ml-1">{product.subcategory}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right font-semibold whitespace-nowrap">
|
<td className="text-right font-semibold whitespace-nowrap">
|
||||||
@@ -468,14 +500,21 @@ export function StoreDetailPage() {
|
|||||||
`$${product.regular_price}`
|
`$${product.regular_price}`
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
<td className="text-center whitespace-nowrap">
|
||||||
{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 text-sm text-gray-700">
|
<td className="text-center whitespace-nowrap">
|
||||||
{product.stock_status === 'in_stock' ? 'In Stock' :
|
{product.stock_status === 'in_stock' ? (
|
||||||
product.stock_status === 'out_of_stock' ? 'Out' : '-'}
|
<span className="badge badge-success badge-sm">In Stock</span>
|
||||||
|
) : 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 text-sm text-gray-700">
|
<td className="text-center whitespace-nowrap">
|
||||||
{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">
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
Gauge,
|
Gauge,
|
||||||
Users,
|
Users,
|
||||||
Calendar,
|
Power,
|
||||||
Zap,
|
Play,
|
||||||
|
Square,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
@@ -82,6 +83,27 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
stale: 'bg-gray-100 text-gray-800',
|
stale: 'bg-gray-100 text-gray-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string, poolPaused: boolean): React.ReactNode => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="w-4 h-4" />;
|
||||||
|
case 'claimed':
|
||||||
|
return <PlayCircle className="w-4 h-4" />;
|
||||||
|
case 'running':
|
||||||
|
// Don't spin when pool is paused
|
||||||
|
return <RefreshCw className={`w-4 h-4 ${!poolPaused ? 'animate-spin' : ''}`} />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle2 className="w-4 h-4" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="w-4 h-4" />;
|
||||||
|
case 'stale':
|
||||||
|
return <AlertTriangle className="w-4 h-4" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static version for summary cards (always shows animation)
|
||||||
const STATUS_ICONS: Record<string, React.ReactNode> = {
|
const STATUS_ICONS: Record<string, React.ReactNode> = {
|
||||||
pending: <Clock className="w-4 h-4" />,
|
pending: <Clock className="w-4 h-4" />,
|
||||||
claimed: <PlayCircle className="w-4 h-4" />,
|
claimed: <PlayCircle className="w-4 h-4" />,
|
||||||
@@ -116,6 +138,8 @@ export default function TasksDashboard() {
|
|||||||
const [capacity, setCapacity] = useState<CapacityMetric[]>([]);
|
const [capacity, setCapacity] = useState<CapacityMetric[]>([]);
|
||||||
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 [poolPaused, setPoolPaused] = useState(false);
|
||||||
|
const [poolLoading, setPoolLoading] = useState(false);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [roleFilter, setRoleFilter] = useState<string>('');
|
const [roleFilter, setRoleFilter] = useState<string>('');
|
||||||
@@ -123,13 +147,10 @@ export default function TasksDashboard() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showCapacity, setShowCapacity] = useState(true);
|
const [showCapacity, setShowCapacity] = useState(true);
|
||||||
|
|
||||||
// Actions
|
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
|
||||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [tasksRes, countsRes, capacityRes] = await Promise.all([
|
const [tasksRes, countsRes, capacityRes, poolStatus] = await Promise.all([
|
||||||
api.getTasks({
|
api.getTasks({
|
||||||
role: roleFilter || undefined,
|
role: roleFilter || undefined,
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
@@ -137,11 +158,13 @@ export default function TasksDashboard() {
|
|||||||
}),
|
}),
|
||||||
api.getTaskCounts(),
|
api.getTaskCounts(),
|
||||||
api.getTaskCapacity(),
|
api.getTaskCapacity(),
|
||||||
|
api.getTaskPoolStatus(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setTasks(tasksRes.tasks || []);
|
setTasks(tasksRes.tasks || []);
|
||||||
setCounts(countsRes);
|
setCounts(countsRes);
|
||||||
setCapacity(capacityRes.metrics || []);
|
setCapacity(capacityRes.metrics || []);
|
||||||
|
setPoolPaused(poolStatus.paused);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load tasks');
|
setError(err.message || 'Failed to load tasks');
|
||||||
@@ -150,40 +173,29 @@ export default function TasksDashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePool = async () => {
|
||||||
|
setPoolLoading(true);
|
||||||
|
try {
|
||||||
|
if (poolPaused) {
|
||||||
|
await api.resumeTaskPool();
|
||||||
|
setPoolPaused(false);
|
||||||
|
} else {
|
||||||
|
await api.pauseTaskPool();
|
||||||
|
setPoolPaused(true);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to toggle pool');
|
||||||
|
} finally {
|
||||||
|
setPoolLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
const interval = setInterval(fetchData, 10000); // Refresh every 10 seconds
|
const interval = setInterval(fetchData, 15000); // Auto-refresh every 15 seconds
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [roleFilter, statusFilter]);
|
}, [roleFilter, statusFilter]);
|
||||||
|
|
||||||
const handleGenerateResync = async () => {
|
|
||||||
setActionLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await api.generateResyncTasks();
|
|
||||||
setActionMessage(`Generated ${result.tasks_created} resync tasks`);
|
|
||||||
fetchData();
|
|
||||||
} catch (err: any) {
|
|
||||||
setActionMessage(`Error: ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setActionLoading(false);
|
|
||||||
setTimeout(() => setActionMessage(null), 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecoverStale = async () => {
|
|
||||||
setActionLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await api.recoverStaleTasks();
|
|
||||||
setActionMessage(`Recovered ${result.tasks_recovered} stale tasks`);
|
|
||||||
fetchData();
|
|
||||||
} catch (err: any) {
|
|
||||||
setActionMessage(`Error: ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setActionLoading(false);
|
|
||||||
setTimeout(() => setActionMessage(null), 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredTasks = tasks.filter((task) => {
|
const filteredTasks = tasks.filter((task) => {
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
@@ -225,46 +237,33 @@ export default function TasksDashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Pool Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateResync}
|
onClick={togglePool}
|
||||||
disabled={actionLoading}
|
disabled={poolLoading}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
poolPaused
|
||||||
|
? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
|
||||||
|
: 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Calendar className="w-4 h-4" />
|
{poolPaused ? (
|
||||||
Generate Resync
|
<>
|
||||||
</button>
|
<Play className={`w-5 h-5 ${poolLoading ? 'animate-pulse' : ''}`} />
|
||||||
<button
|
Start Pool
|
||||||
onClick={handleRecoverStale}
|
</>
|
||||||
disabled={actionLoading}
|
) : (
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50"
|
<>
|
||||||
>
|
<Square className={`w-5 h-5 ${poolLoading ? 'animate-pulse' : ''}`} />
|
||||||
<Zap className="w-4 h-4" />
|
Stop Pool
|
||||||
Recover Stale
|
</>
|
||||||
</button>
|
)}
|
||||||
<button
|
|
||||||
onClick={fetchData}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
Refresh
|
|
||||||
</button>
|
</button>
|
||||||
|
<span className="text-sm text-gray-400">Auto-refreshes every 15s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Message */}
|
|
||||||
{actionMessage && (
|
|
||||||
<div
|
|
||||||
className={`p-4 rounded-lg ${
|
|
||||||
actionMessage.startsWith('Error')
|
|
||||||
? 'bg-red-50 text-red-700'
|
|
||||||
: 'bg-green-50 text-green-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{actionMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-50 text-red-700 rounded-lg">{error}</div>
|
<div className="p-4 bg-red-50 text-red-700 rounded-lg">{error}</div>
|
||||||
)}
|
)}
|
||||||
@@ -496,7 +495,7 @@ export default function TasksDashboard() {
|
|||||||
STATUS_COLORS[task.status]
|
STATUS_COLORS[task.status]
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{STATUS_ICONS[task.status]}
|
{getStatusIcon(task.status, poolPaused)}
|
||||||
{task.status}
|
{task.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user