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
15 changed files with 739 additions and 369 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

@@ -25,7 +25,7 @@ export async function handleStoreDiscovery(ctx: TaskContext): Promise<TaskResult
try { try {
// Get states to discover // Get states to discover
const statesResult = await pool.query(` const statesResult = await pool.query(`
SELECT code FROM states WHERE active = true ORDER BY code SELECT code FROM states WHERE is_active = true ORDER BY code
`); `);
const stateCodes = statesResult.rows.map(r => r.code); const stateCodes = statesResult.rows.map(r => r.code);

View File

@@ -170,7 +170,6 @@ class TaskService {
WHERE id = ( WHERE id = (
SELECT id FROM worker_tasks SELECT id FROM worker_tasks
WHERE status = 'pending' WHERE status = 'pending'
AND active = true
AND (scheduled_for IS NULL OR scheduled_for <= NOW()) AND (scheduled_for IS NULL OR scheduled_for <= NOW())
-- Exclude stores that already have an active task -- Exclude stores that already have an active task
AND (dispensary_id IS NULL OR dispensary_id NOT IN ( AND (dispensary_id IS NULL OR dispensary_id NOT IN (

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();

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,23 +265,10 @@ export function Dashboard() {
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div> <div>
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Dashboard</h1> <h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500 mt-1">Monitor your dispensary data aggregation</p> <p className="text-sm text-gray-500 mt-1">Monitor your dispensary data aggregation</p>
</div> </div>
<button
onClick={() => loadData(true)}
disabled={refreshing}
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium text-gray-700 self-start sm:self-auto disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{/* System Health */}
<HealthPanel showQueues={false} refreshInterval={60000} />
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-6"> <div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-6">

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

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,37 +101,62 @@ export function IntelligenceBrands() {
<Layout> <Layout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Brands Intelligence</h1> <h1 className="text-2xl font-bold text-gray-900">Brands Intelligence</h1>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Brand penetration and pricing analytics across markets Brand penetration and pricing analytics across markets
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2 items-center">
{/* State Selector */}
<div className="dropdown dropdown-end">
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
{stateLabel}
<ChevronDown className="w-4 h-4" />
</button>
<ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
<li>
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
All States
</a>
</li>
<div className="divider my-1"></div>
{availableStates.map((state) => (
<li key={state}>
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
{state}
</a>
</li>
))}
</ul>
</div>
{/* Page Navigation */}
<div className="flex gap-1">
<button <button
onClick={() => navigate('/admin/intelligence/pricing')} className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
className="btn btn-sm btn-outline gap-1"
> >
<DollarSign className="w-4 h-4" /> <Building2 className="w-4 h-4" />
Pricing <span>Brands</span>
</button> </button>
<button <button
onClick={() => navigate('/admin/intelligence/stores')} onClick={() => navigate('/admin/intelligence/stores')}
className="btn btn-sm btn-outline gap-1" className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
> >
<MapPin className="w-4 h-4" /> <MapPin className="w-4 h-4" />
Stores <span>Stores</span>
</button> </button>
<button <button
onClick={loadBrands} onClick={() => navigate('/admin/intelligence/pricing')}
className="btn btn-sm btn-outline gap-2" className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
> >
<RefreshCw className="w-4 h-4" /> <DollarSign className="w-4 h-4" />
Refresh <span>Pricing</span>
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
@@ -180,51 +206,32 @@ export function IntelligenceBrands() {
{/* Top Brands Chart */} {/* Top Brands Chart */}
<div className="bg-white rounded-lg border border-gray-200 p-4"> <div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-4"> <h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2 mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2"> <BarChart3 className="w-5 h-5 text-emerald-500" />
<BarChart3 className="w-5 h-5 text-blue-500" />
Top 10 Brands by Store Count Top 10 Brands by Store Count
</h3> </h3>
<div className="dropdown dropdown-end">
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
{stateLabel}
<ChevronDown className="w-4 h-4" />
</button>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
<li>
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
All States
</a>
</li>
<li className="divider"></li>
{availableStates.map((state) => (
<li key={state}>
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
{state}
</a>
</li>
))}
</ul>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
{topBrands.map((brand, idx) => ( {topBrands.map((brand) => {
const barWidth = Math.min((brand.storeCount / maxStoreCount) * 100, 100);
return (
<div key={brand.brandName} className="flex items-center gap-3"> <div key={brand.brandName} className="flex items-center gap-3">
<span className="text-sm text-gray-500 w-6">{idx + 1}.</span> <span className="text-sm font-medium w-28 truncate shrink-0" title={brand.brandName}>
<span className="text-sm font-medium w-40 truncate" title={brand.brandName}>
{brand.brandName} {brand.brandName}
</span> </span>
<div className="flex-1 bg-gray-100 rounded-full h-4 relative"> <div className="flex-1 min-w-0">
<div className="bg-gray-100 rounded h-5 overflow-hidden">
<div <div
className="bg-blue-500 rounded-full h-4" className="bg-gradient-to-r from-emerald-400 to-emerald-500 h-5 rounded transition-all"
style={{ width: `${(brand.storeCount / maxStoreCount) * 100}%` }} style={{ width: `${barWidth}%` }}
/> />
</div> </div>
<span className="text-sm text-gray-600 w-16 text-right"> </div>
{brand.storeCount} stores <span className="text-sm font-mono font-semibold text-emerald-600 w-16 text-right shrink-0">
{brand.storeCount}
</span> </span>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
@@ -247,6 +254,7 @@ export function IntelligenceBrands() {
> >
<option value="stores">Sort by Stores</option> <option value="stores">Sort by Stores</option>
<option value="skus">Sort by SKUs</option> <option value="skus">Sort by SKUs</option>
<option value="states">Sort by States</option>
<option value="name">Sort by Name</option> <option value="name">Sort by Name</option>
</select> </select>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">

View File

@@ -8,7 +8,6 @@ import {
Building2, Building2,
MapPin, MapPin,
Package, Package,
RefreshCw,
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
BarChart3, BarChart3,
@@ -87,58 +86,62 @@ export function IntelligencePricing() {
<Layout> <Layout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Pricing Intelligence</h1> <h1 className="text-2xl font-bold text-gray-900">Pricing Intelligence</h1>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Price distribution and trends by category Price distribution and trends by category
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2 items-center">
{/* State Selector */}
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<button tabIndex={0} className="btn btn-sm btn-outline gap-2"> <button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
{stateLabel} {stateLabel}
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
</button> </button>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto"> <ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
<li> <li>
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}> <a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
All States All States
</a> </a>
</li> </li>
<li className="divider"></li> <div className="divider my-1"></div>
{availableStates.map((state) => ( {availableStates.map((state) => (
<li key={state}> <li key={state}>
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}> <a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
{state} {state}
</a> </a>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
{/* Page Navigation */}
<div className="flex gap-1">
<button <button
onClick={() => navigate('/admin/intelligence/brands')} onClick={() => navigate('/admin/intelligence/brands')}
className="btn btn-sm btn-outline gap-1" className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
> >
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
Brands <span>Brands</span>
</button> </button>
<button <button
onClick={() => navigate('/admin/intelligence/stores')} onClick={() => navigate('/admin/intelligence/stores')}
className="btn btn-sm btn-outline gap-1" className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
> >
<MapPin className="w-4 h-4" /> <MapPin className="w-4 h-4" />
Stores <span>Stores</span>
</button> </button>
<button <button
onClick={loadPricing} className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
className="btn btn-sm btn-outline gap-2"
> >
<RefreshCw className="w-4 h-4" /> <DollarSign className="w-4 h-4" />
Refresh <span>Pricing</span>
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Overall Stats */} {/* Overall Stats */}
{overall && ( {overall && (

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,37 +111,62 @@ export function IntelligenceStores() {
<Layout> <Layout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Store Activity</h1> <h1 className="text-2xl font-bold text-gray-900">Store Activity</h1>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Per-store SKU counts, snapshots, and crawl frequency Per-store SKU counts, snapshots, and crawl frequency
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2 items-center">
{/* State Selector */}
<div className="dropdown dropdown-end">
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
{stateLabel}
<ChevronDown className="w-4 h-4" />
</button>
<ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
<li>
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
All States
</a>
</li>
<div className="divider my-1"></div>
{availableStates.map((state) => (
<li key={state}>
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
{state}
</a>
</li>
))}
</ul>
</div>
{/* Page Navigation */}
<div className="flex gap-1">
<button <button
onClick={() => navigate('/admin/intelligence/brands')} onClick={() => navigate('/admin/intelligence/brands')}
className="btn btn-sm btn-outline gap-1" className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
> >
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
Brands <span>Brands</span>
</button>
<button
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
>
<MapPin className="w-4 h-4" />
<span>Stores</span>
</button> </button>
<button <button
onClick={() => navigate('/admin/intelligence/pricing')} onClick={() => navigate('/admin/intelligence/pricing')}
className="btn btn-sm btn-outline gap-1" className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
> >
<DollarSign className="w-4 h-4" /> <DollarSign className="w-4 h-4" />
Pricing <span>Pricing</span>
</button>
<button
onClick={loadStores}
className="btn btn-sm btn-outline gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Summary Cards - Responsive: 2→4 columns */} {/* Summary Cards - Responsive: 2→4 columns */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
@@ -194,26 +220,6 @@ export function IntelligenceStores() {
className="input input-bordered input-sm w-full pl-10" className="input input-bordered input-sm w-full pl-10"
/> />
</div> </div>
<div className="dropdown">
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
{stateLabel}
<ChevronDown className="w-4 h-4" />
</button>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
<li>
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
All States
</a>
</li>
{localStates.map(state => (
<li key={state}>
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
{state}
</a>
</li>
))}
</ul>
</div>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
Showing {filteredStores.length} of {stores.length} stores Showing {filteredStores.length} of {stores.length} stores
</span> </span>
@@ -247,7 +253,7 @@ export function IntelligenceStores() {
<tr <tr
key={store.id} key={store.id}
className="hover:bg-gray-50 cursor-pointer" className="hover:bg-gray-50 cursor-pointer"
onClick={() => navigate(`/admin/orchestrator/stores?storeId=${store.id}`)} onClick={() => navigate(`/stores/list/${store.id}`)}
> >
<td> <td>
<span className="font-medium">{store.name}</span> <span className="font-medium">{store.name}</span>

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,24 +263,12 @@ export default function NationalDashboard() {
<Layout> <Layout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">National Dashboard</h1> <h1 className="text-2xl font-bold text-gray-900">National Dashboard</h1>
<p className="text-gray-500 mt-1"> <p className="text-gray-500 mt-1">
Multi-state cannabis market intelligence Multi-state cannabis market intelligence
</p> </p>
</div> </div>
<div className="flex items-center gap-3">
<button
onClick={handleRefreshMetrics}
disabled={refreshing}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh Metrics
</button>
</div>
</div>
{/* Summary Cards */} {/* Summary Cards */}
{summary && ( {summary && (

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