Compare commits

..

6 Commits

Author SHA1 Message Date
Kelly
459ad7d9c9 fix(tasks): Fix missing column errors in task queries
- Change 'active' to 'is_active' in states table query (store-discovery.ts)
- Remove non-existent 'active' column check from worker_tasks query (task-service.ts)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 23:54:28 -07:00
Kelly
d102d27731 feat(admin): Dispensary schedule page and UI cleanup
- Add DispensarySchedule page showing crawl history and upcoming schedule
- Add /dispensaries/:state/:city/:slug/schedule route
- Add API endpoint for store crawl history
- Update View Schedule link to use dispensary-specific route
- Remove colored badges from DispensaryDetail product table (plain text)
- Make Details button ghost style in product table
- Add "Sort by States" option to IntelligenceBrands
- Remove status filter dropdown from Dispensaries page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 23:50:47 -07:00
Kelly
b7d33e1cbf fix(admin): Clean up store detail and intelligence pages
- Remove Update dropdown from DispensaryDetail page
- Remove Crawl Now button from StoreDetailPage
- Change "Last Crawl" to "Last Updated" on both detail pages
- Tone down emerald colors on StoreDetailPage (use gray borders/tabs)
- Simplify THC/CBD/Stock badges to plain text
- Remove duplicate state dropdown from IntelligenceStores filters
- Make store rows clickable in IntelligenceStores

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 23:42:50 -07:00
Kelly
5b34b5a78c fix(admin): Consistent navigation across Intelligence pages
- Add state selector dropdown to all three Intelligence pages (Brands, Stores, Pricing)
- Use consistent emerald-styled page navigation badges with current page highlighted
- Remove Refresh buttons from all Intelligence pages
- Update chart styling to use emerald gradient bars (matching Pricing page)
- Load all available states from orchestrator API instead of extracting from local data
- Fix z-index and styling on state dropdown for better visibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 23:32:57 -07:00
Kelly
c091d2316b fix(dashboard): Remove refresh button and HealthPanel
- Removed refresh button and refreshing state from Dashboard
- Removed HealthPanel component (deploy status auto-refresh)
- Simplified header layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 23:28:18 -07:00
Kelly
e8862b8a8b fix(national): Remove Refresh Metrics button
Removed unused refresh button and related state/handlers from
National Dashboard.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 23:27:01 -07:00
17 changed files with 806 additions and 541 deletions

View File

@@ -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

View File

@@ -13,12 +13,6 @@ 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();
@@ -598,42 +592,4 @@ 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;

View File

@@ -1,35 +0,0 @@
/**
* 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',
};
}

View File

@@ -9,7 +9,6 @@
*/ */
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> {
@@ -150,14 +149,8 @@ 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(

View File

@@ -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>

View File

@@ -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>} />

View File

@@ -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();
@@ -2888,27 +2929,6 @@ 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);

View File

@@ -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,24 +265,11 @@ 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>
<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 */}

View File

@@ -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>

View File

@@ -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"> <button
{product.dutchie_url && ( onClick={() => navigate(`/products/${product.id}`)}
<a className="btn btn-xs btn-ghost text-gray-500 hover:text-gray-700"
href={product.dutchie_url} >
target="_blank" Details
rel="noopener noreferrer" </button>
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>
))} ))}

View 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;

View File

@@ -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,35 +101,60 @@ 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">
<button {/* State Selector */}
onClick={() => navigate('/admin/intelligence/pricing')} <div className="dropdown dropdown-end">
className="btn btn-sm btn-outline gap-1" <button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
> {stateLabel}
<DollarSign className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
Pricing </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">
<button <li>
onClick={() => navigate('/admin/intelligence/stores')} <a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
className="btn btn-sm btn-outline gap-1" All States
> </a>
<MapPin className="w-4 h-4" /> </li>
Stores <div className="divider my-1"></div>
</button> {availableStates.map((state) => (
<button <li key={state}>
onClick={loadBrands} <a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
className="btn btn-sm btn-outline gap-2" {state}
> </a>
<RefreshCw className="w-4 h-4" /> </li>
Refresh ))}
</button> </ul>
</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>
@@ -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) => {
<div key={brand.brandName} className="flex items-center gap-3"> const barWidth = Math.min((brand.storeCount / maxStoreCount) * 100, 100);
<span className="text-sm text-gray-500 w-6">{idx + 1}.</span> return (
<span className="text-sm font-medium w-40 truncate" title={brand.brandName}> <div key={brand.brandName} className="flex items-center gap-3">
{brand.brandName} <span className="text-sm font-medium w-28 truncate shrink-0" title={brand.brandName}>
</span> {brand.brandName}
<div className="flex-1 bg-gray-100 rounded-full h-4 relative"> </span>
<div <div className="flex-1 min-w-0">
className="bg-blue-500 rounded-full h-4" <div className="bg-gray-100 rounded h-5 overflow-hidden">
style={{ width: `${(brand.storeCount / maxStoreCount) * 100}%` }} <div
/> 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>
@@ -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">

View File

@@ -8,7 +8,6 @@ import {
Building2, Building2,
MapPin, MapPin,
Package, Package,
RefreshCw,
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
BarChart3, BarChart3,
@@ -87,56 +86,60 @@ 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>
<button
onClick={() => navigate('/admin/intelligence/brands')} {/* Page Navigation */}
className="btn btn-sm btn-outline gap-1" <div className="flex gap-1">
> <button
<Building2 className="w-4 h-4" /> onClick={() => navigate('/admin/intelligence/brands')}
Brands className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
</button> >
<button <Building2 className="w-4 h-4" />
onClick={() => navigate('/admin/intelligence/stores')} <span>Brands</span>
className="btn btn-sm btn-outline gap-1" </button>
> <button
<MapPin className="w-4 h-4" /> onClick={() => navigate('/admin/intelligence/stores')}
Stores className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
</button> >
<button <MapPin className="w-4 h-4" />
onClick={loadPricing} <span>Stores</span>
className="btn btn-sm btn-outline gap-2" </button>
> <button
<RefreshCw className="w-4 h-4" /> className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
Refresh >
</button> <DollarSign className="w-4 h-4" />
<span>Pricing</span>
</button>
</div>
</div> </div>
</div> </div>

View File

@@ -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,35 +111,60 @@ 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">
<button {/* State Selector */}
onClick={() => navigate('/admin/intelligence/brands')} <div className="dropdown dropdown-end">
className="btn btn-sm btn-outline gap-1" <button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
> {stateLabel}
<Building2 className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
Brands </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">
<button <li>
onClick={() => navigate('/admin/intelligence/pricing')} <a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
className="btn btn-sm btn-outline gap-1" All States
> </a>
<DollarSign className="w-4 h-4" /> </li>
Pricing <div className="divider my-1"></div>
</button> {availableStates.map((state) => (
<button <li key={state}>
onClick={loadStores} <a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
className="btn btn-sm btn-outline gap-2" {state}
> </a>
<RefreshCw className="w-4 h-4" /> </li>
Refresh ))}
</button> </ul>
</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>
@@ -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>

View File

@@ -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,23 +263,11 @@ 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 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 */}

View File

@@ -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">

View File

@@ -14,9 +14,8 @@ import {
ChevronUp, ChevronUp,
Gauge, Gauge,
Users, Users,
Power, Calendar,
Play, Zap,
Square,
} from 'lucide-react'; } from 'lucide-react';
interface Task { interface Task {
@@ -83,27 +82,6 @@ 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" />,
@@ -138,8 +116,6 @@ 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>('');
@@ -147,10 +123,13 @@ 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, poolStatus] = await Promise.all([ const [tasksRes, countsRes, capacityRes] = await Promise.all([
api.getTasks({ api.getTasks({
role: roleFilter || undefined, role: roleFilter || undefined,
status: statusFilter || undefined, status: statusFilter || undefined,
@@ -158,13 +137,11 @@ 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');
@@ -173,28 +150,39 @@ export default function TasksDashboard() {
} }
}; };
const togglePool = async () => { useEffect(() => {
setPoolLoading(true); fetchData();
const interval = setInterval(fetchData, 10000); // Refresh every 10 seconds
return () => clearInterval(interval);
}, [roleFilter, statusFilter]);
const handleGenerateResync = async () => {
setActionLoading(true);
try { try {
if (poolPaused) { const result = await api.generateResyncTasks();
await api.resumeTaskPool(); setActionMessage(`Generated ${result.tasks_created} resync tasks`);
setPoolPaused(false); fetchData();
} else {
await api.pauseTaskPool();
setPoolPaused(true);
}
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to toggle pool'); setActionMessage(`Error: ${err.message}`);
} finally { } finally {
setPoolLoading(false); setActionLoading(false);
setTimeout(() => setActionMessage(null), 5000);
} }
}; };
useEffect(() => { const handleRecoverStale = async () => {
fetchData(); setActionLoading(true);
const interval = setInterval(fetchData, 15000); // Auto-refresh every 15 seconds try {
return () => clearInterval(interval); const result = await api.recoverStaleTasks();
}, [roleFilter, statusFilter]); 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) {
@@ -237,33 +225,46 @@ export default function TasksDashboard() {
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex gap-2">
{/* Pool Toggle */}
<button <button
onClick={togglePool} onClick={handleGenerateResync}
disabled={poolLoading} disabled={actionLoading}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${ className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
poolPaused
? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
: 'bg-red-100 text-red-700 hover:bg-red-200'
}`}
> >
{poolPaused ? ( <Calendar className="w-4 h-4" />
<> Generate Resync
<Play className={`w-5 h-5 ${poolLoading ? 'animate-pulse' : ''}`} /> </button>
Start Pool <button
</> 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' : ''}`} /> >
Stop Pool <Zap className="w-4 h-4" />
</> 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>
)} )}
@@ -495,7 +496,7 @@ export default function TasksDashboard() {
STATUS_COLORS[task.status] STATUS_COLORS[task.status]
}`} }`}
> >
{getStatusIcon(task.status, poolPaused)} {STATUS_ICONS[task.status]}
{task.status} {task.status}
</span> </span>
</td> </td>