Add Dutchie AZ data pipeline and public API v1

- Add dutchie-az module with GraphQL product crawler, scheduler, and admin UI
- Add public API v1 endpoints (/api/v1/products, /categories, /brands, /specials, /menu)
- API key auth maps dispensary to dutchie_az store for per-dispensary data access
- Add frontend pages for Dutchie AZ stores, store details, and schedule management
- Update Layout with Dutchie AZ navigation section

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-02 09:43:26 -07:00
parent 511629b4e6
commit 917e91297e
22 changed files with 8201 additions and 45 deletions

View File

@@ -20,6 +20,9 @@ import { ScraperSchedule } from './pages/ScraperSchedule';
import { ScraperTools } from './pages/ScraperTools';
import { ChangeApproval } from './pages/ChangeApproval';
import { ApiPermissions } from './pages/ApiPermissions';
import { DutchieAZSchedule } from './pages/DutchieAZSchedule';
import { DutchieAZStores } from './pages/DutchieAZStores';
import { DutchieAZStoreDetail } from './pages/DutchieAZStoreDetail';
import { PrivateRoute } from './components/PrivateRoute';
export default function App() {
@@ -46,6 +49,9 @@ export default function App() {
<Route path="/scraper-tools" element={<PrivateRoute><ScraperTools /></PrivateRoute>} />
<Route path="/scraper-monitor" element={<PrivateRoute><ScraperMonitor /></PrivateRoute>} />
<Route path="/scraper-schedule" element={<PrivateRoute><ScraperSchedule /></PrivateRoute>} />
<Route path="/dutchie-az-schedule" element={<PrivateRoute><DutchieAZSchedule /></PrivateRoute>} />
<Route path="/dutchie-az" element={<PrivateRoute><DutchieAZStores /></PrivateRoute>} />
<Route path="/dutchie-az/stores/:id" element={<PrivateRoute><DutchieAZStoreDetail /></PrivateRoute>} />
<Route path="/api-permissions" element={<PrivateRoute><ApiPermissions /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -13,6 +13,7 @@ import {
Wrench,
Activity,
Clock,
Calendar,
Shield,
FileText,
Settings,
@@ -162,6 +163,21 @@ export function Layout({ children }: LayoutProps) {
/>
</NavSection>
<NavSection title="Dutchie AZ">
<NavLink
to="/dutchie-az"
icon={<Store className="w-4 h-4" />}
label="AZ Stores"
isActive={isActive('/dutchie-az', false)}
/>
<NavLink
to="/dutchie-az-schedule"
icon={<Calendar className="w-4 h-4" />}
label="AZ Schedule"
isActive={isActive('/dutchie-az-schedule')}
/>
</NavSection>
<NavSection title="Scraper">
<NavLink
to="/scraper-tools"

View File

@@ -398,11 +398,11 @@ class ApiClient {
return this.request<{ permissions: any[] }>('/api/api-permissions');
}
async getApiPermissionStores() {
return this.request<{ stores: Array<{ id: number; name: string }> }>('/api/api-permissions/stores');
async getApiPermissionDispensaries() {
return this.request<{ dispensaries: Array<{ id: number; name: string }> }>('/api/api-permissions/dispensaries');
}
async createApiPermission(data: { user_name: string; store_id: number; allowed_ips?: string; allowed_domains?: string }) {
async createApiPermission(data: { user_name: string; dispensary_id: number; allowed_ips?: string; allowed_domains?: string }) {
return this.request<{ permission: any; message: string }>('/api/api-permissions', {
method: 'POST',
body: JSON.stringify(data),
@@ -525,6 +525,313 @@ class ApiClient {
image_tag: string;
}>('/api/version');
}
// ============================================================
// DUTCHIE AZ API
// ============================================================
// Dutchie AZ Dashboard
async getDutchieAZDashboard() {
return this.request<{
dispensaryCount: number;
productCount: number;
snapshotCount24h: number;
lastCrawlTime: string | null;
failedJobCount: number;
brandCount: number;
categoryCount: number;
}>('/api/dutchie-az/dashboard');
}
// Dutchie AZ Schedules (CRUD)
async getDutchieAZSchedules() {
return this.request<{
schedules: Array<{
id: number;
jobName: string;
description: string | null;
enabled: boolean;
baseIntervalMinutes: number;
jitterMinutes: number;
lastRunAt: string | null;
lastStatus: string | null;
lastErrorMessage: string | null;
lastDurationMs: number | null;
nextRunAt: string | null;
jobConfig: Record<string, any> | null;
createdAt: string;
updatedAt: string;
}>;
}>('/api/dutchie-az/admin/schedules');
}
async getDutchieAZSchedule(id: number) {
return this.request<{
id: number;
jobName: string;
description: string | null;
enabled: boolean;
baseIntervalMinutes: number;
jitterMinutes: number;
lastRunAt: string | null;
lastStatus: string | null;
lastErrorMessage: string | null;
lastDurationMs: number | null;
nextRunAt: string | null;
jobConfig: Record<string, any> | null;
createdAt: string;
updatedAt: string;
}>(`/api/dutchie-az/admin/schedules/${id}`);
}
async createDutchieAZSchedule(data: {
jobName: string;
description?: string;
enabled?: boolean;
baseIntervalMinutes: number;
jitterMinutes: number;
jobConfig?: Record<string, any>;
startImmediately?: boolean;
}) {
return this.request<any>('/api/dutchie-az/admin/schedules', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateDutchieAZSchedule(id: number, data: {
description?: string;
enabled?: boolean;
baseIntervalMinutes?: number;
jitterMinutes?: number;
jobConfig?: Record<string, any>;
}) {
return this.request<any>(`/api/dutchie-az/admin/schedules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteDutchieAZSchedule(id: number) {
return this.request<{ success: boolean; message: string }>(`/api/dutchie-az/admin/schedules/${id}`, {
method: 'DELETE',
});
}
async triggerDutchieAZSchedule(id: number) {
return this.request<{ success: boolean; message: string }>(`/api/dutchie-az/admin/schedules/${id}/trigger`, {
method: 'POST',
});
}
async initDutchieAZSchedules() {
return this.request<{ success: boolean; schedules: any[] }>('/api/dutchie-az/admin/schedules/init', {
method: 'POST',
});
}
// Dutchie AZ Run Logs
async getDutchieAZScheduleLogs(scheduleId: number, limit?: number, offset?: number) {
const params = new URLSearchParams();
if (limit) params.append('limit', limit.toString());
if (offset) params.append('offset', offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return this.request<{ logs: any[]; total: number }>(`/api/dutchie-az/admin/schedules/${scheduleId}/logs${queryString}`);
}
async getDutchieAZRunLogs(options?: { scheduleId?: number; jobName?: string; limit?: number; offset?: number }) {
const params = new URLSearchParams();
if (options?.scheduleId) params.append('scheduleId', options.scheduleId.toString());
if (options?.jobName) params.append('jobName', options.jobName);
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return this.request<{ logs: any[]; total: number }>(`/api/dutchie-az/admin/run-logs${queryString}`);
}
// Dutchie AZ Scheduler Control
async getDutchieAZSchedulerStatus() {
return this.request<{ running: boolean; pollIntervalMs: number }>('/api/dutchie-az/admin/scheduler/status');
}
async startDutchieAZScheduler() {
return this.request<{ success: boolean; message: string }>('/api/dutchie-az/admin/scheduler/start', {
method: 'POST',
});
}
async stopDutchieAZScheduler() {
return this.request<{ success: boolean; message: string }>('/api/dutchie-az/admin/scheduler/stop', {
method: 'POST',
});
}
async triggerDutchieAZImmediateCrawl() {
return this.request<{ success: boolean; message: string }>('/api/dutchie-az/admin/scheduler/trigger', {
method: 'POST',
});
}
// Dutchie AZ Stores
async getDutchieAZStores(params?: { city?: string; hasPlatformId?: boolean; limit?: number; offset?: number }) {
const searchParams = new URLSearchParams();
if (params?.city) searchParams.append('city', params.city);
if (params?.hasPlatformId !== undefined) searchParams.append('hasPlatformId', String(params.hasPlatformId));
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{ stores: any[]; total: number }>(`/api/dutchie-az/stores${queryString}`);
}
async getDutchieAZStore(id: number) {
return this.request<any>(`/api/dutchie-az/stores/${id}`);
}
async getDutchieAZStoreSummary(id: number) {
return this.request<{
dispensary: any;
totalProducts: number;
inStockCount: number;
outOfStockCount: number;
unknownStockCount: number;
missingFromFeedCount: number;
categories: Array<{ type: string; subcategory: string; product_count: number }>;
brands: Array<{ brand_name: string; product_count: number }>;
brandCount: number;
categoryCount: number;
lastCrawl: any | null;
}>(`/api/dutchie-az/stores/${id}/summary`);
}
async getDutchieAZStoreProducts(id: number, params?: {
stockStatus?: string;
type?: string;
subcategory?: string;
brandName?: string;
search?: string;
limit?: number;
offset?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.stockStatus) searchParams.append('stockStatus', params.stockStatus);
if (params?.type) searchParams.append('type', params.type);
if (params?.subcategory) searchParams.append('subcategory', params.subcategory);
if (params?.brandName) searchParams.append('brandName', params.brandName);
if (params?.search) searchParams.append('search', params.search);
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
products: Array<{
id: number;
external_id: string;
name: string;
slug: string;
brand: string;
type: string;
subcategory: string;
strain_type: string;
stock_status: string;
in_stock: boolean;
missing_from_feed: boolean;
regular_price: number | null;
sale_price: number | null;
med_price: number | null;
med_sale_price: number | null;
thc_percentage: number | null;
cbd_percentage: number | null;
image_url: string | null;
description: string | null;
options: any | null;
total_quantity: number | null;
first_seen_at: string;
last_seen_at: string;
updated_at: string;
snapshot_at: string | null;
}>;
total: number;
limit: number;
offset: number;
}>(`/api/dutchie-az/stores/${id}/products${queryString}`);
}
async getDutchieAZStoreBrands(id: number) {
return this.request<{
brands: Array<{ brand: string; product_count: number }>;
}>(`/api/dutchie-az/stores/${id}/brands`);
}
async getDutchieAZStoreCategories(id: number) {
return this.request<{
categories: Array<{ type: string; subcategory: string; product_count: number }>;
}>(`/api/dutchie-az/stores/${id}/categories`);
}
// Dutchie AZ Debug
async getDutchieAZDebugSummary() {
return this.request<{
tableCounts: {
dispensary_count: string;
dispensaries_with_platform_id: string;
product_count: string;
snapshot_count: string;
job_count: string;
completed_jobs: string;
failed_jobs: string;
};
stockDistribution: Array<{ stock_status: string; count: string }>;
productsByDispensary: Array<{
id: number;
name: string;
slug: string;
platform_dispensary_id: string;
product_count: string;
last_product_update: string;
}>;
recentSnapshots: Array<{
id: number;
dutchie_product_id: number;
product_name: string;
dispensary_name: string;
crawled_at: string;
}>;
}>('/api/dutchie-az/debug/summary');
}
async getDutchieAZDebugStore(id: number) {
return this.request<{
dispensary: any;
productStats: {
total_products: string;
in_stock: string;
out_of_stock: string;
unknown: string;
missing_from_feed: string;
earliest_product: string;
latest_product: string;
last_update: string;
};
snapshotStats: {
total_snapshots: string;
earliest_snapshot: string;
latest_snapshot: string;
products_with_snapshots: string;
};
recentJobs: any[];
sampleProducts: {
inStock: any[];
outOfStock: any[];
};
categories: Array<{ type: string; subcategory: string; count: string }>;
}>(`/api/dutchie-az/debug/store/${id}`);
}
async triggerDutchieAZCrawl(id: number, options?: { pricingType?: string; useBothModes?: boolean }) {
return this.request<any>(`/api/dutchie-az/admin/crawl/${id}`, {
method: 'POST',
body: JSON.stringify(options || {}),
});
}
}
export const api = new ApiClient(API_URL);

View File

@@ -12,23 +12,23 @@ interface ApiPermission {
is_active: number;
created_at: string;
last_used_at: string | null;
store_id: number | null;
store_name: string | null;
dispensary_id: number | null;
dispensary_name: string | null;
}
interface Store {
interface Dispensary {
id: number;
name: string;
}
export function ApiPermissions() {
const [permissions, setPermissions] = useState<ApiPermission[]>([]);
const [stores, setStores] = useState<Store[]>([]);
const [dispensaries, setDispensaries] = useState<Dispensary[]>([]);
const [loading, setLoading] = useState(true);
const [showAddForm, setShowAddForm] = useState(false);
const [newPermission, setNewPermission] = useState({
user_name: '',
store_id: '',
dispensary_id: '',
allowed_ips: '',
allowed_domains: '',
});
@@ -36,15 +36,15 @@ export function ApiPermissions() {
useEffect(() => {
loadPermissions();
loadStores();
loadDispensaries();
}, []);
const loadStores = async () => {
const loadDispensaries = async () => {
try {
const data = await api.getApiPermissionStores();
setStores(data.stores);
const data = await api.getApiPermissionDispensaries();
setDispensaries(data.dispensaries);
} catch (error: any) {
console.error('Failed to load stores:', error);
console.error('Failed to load dispensaries:', error);
}
};
@@ -68,18 +68,18 @@ export function ApiPermissions() {
return;
}
if (!newPermission.store_id) {
setNotification({ message: 'Store is required', type: 'error' });
if (!newPermission.dispensary_id) {
setNotification({ message: 'Dispensary is required', type: 'error' });
return;
}
try {
const result = await api.createApiPermission({
...newPermission,
store_id: parseInt(newPermission.store_id),
dispensary_id: parseInt(newPermission.dispensary_id),
});
setNotification({ message: result.message, type: 'success' });
setNewPermission({ user_name: '', store_id: '', allowed_ips: '', allowed_domains: '' });
setNewPermission({ user_name: '', dispensary_id: '', allowed_ips: '', allowed_domains: '' });
setShowAddForm(false);
loadPermissions();
} catch (error: any) {
@@ -182,22 +182,22 @@ export function ApiPermissions() {
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Store *
Dispensary *
</label>
<select
value={newPermission.store_id}
onChange={(e) => setNewPermission({ ...newPermission, store_id: e.target.value })}
value={newPermission.dispensary_id}
onChange={(e) => setNewPermission({ ...newPermission, dispensary_id: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select a store...</option>
{stores.map((store) => (
<option key={store.id} value={store.id}>
{store.name}
<option value="">Select a dispensary...</option>
{dispensaries.map((dispensary) => (
<option key={dispensary.id} value={dispensary.id}>
{dispensary.name}
</option>
))}
</select>
<p className="text-sm text-gray-600 mt-1">The store this API token can access</p>
<p className="text-sm text-gray-600 mt-1">The dispensary this API token can access</p>
</div>
<div className="mb-4">
@@ -261,7 +261,7 @@ export function ApiPermissions() {
User Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Store
Dispensary
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
API Key
@@ -290,7 +290,7 @@ export function ApiPermissions() {
<div className="font-medium text-gray-900">{perm.user_name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{perm.store_name || <span className="text-gray-400 italic">No store</span>}</div>
<div className="text-sm text-gray-900">{perm.dispensary_name || <span className="text-gray-400 italic">No dispensary</span>}</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center space-x-2">

View File

@@ -0,0 +1,697 @@
import { useEffect, useState } from 'react';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
interface JobSchedule {
id: number;
jobName: string;
description: string | null;
enabled: boolean;
baseIntervalMinutes: number;
jitterMinutes: number;
lastRunAt: string | null;
lastStatus: string | null;
lastErrorMessage: string | null;
lastDurationMs: number | null;
nextRunAt: string | null;
jobConfig: Record<string, any> | null;
createdAt: string;
updatedAt: string;
}
interface RunLog {
id: number;
schedule_id: number;
job_name: string;
status: string;
started_at: string | null;
completed_at: string | null;
duration_ms: number | null;
error_message: string | null;
items_processed: number | null;
items_succeeded: number | null;
items_failed: number | null;
metadata: any;
created_at: string;
}
export function DutchieAZSchedule() {
const [schedules, setSchedules] = useState<JobSchedule[]>([]);
const [runLogs, setRunLogs] = useState<RunLog[]>([]);
const [schedulerStatus, setSchedulerStatus] = useState<{ running: boolean; pollIntervalMs: number } | null>(null);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<'schedules' | 'logs'>('schedules');
const [editingSchedule, setEditingSchedule] = useState<JobSchedule | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
useEffect(() => {
loadData();
if (autoRefresh) {
const interval = setInterval(loadData, 10000);
return () => clearInterval(interval);
}
}, [autoRefresh]);
const loadData = async () => {
try {
const [schedulesData, logsData, statusData] = await Promise.all([
api.getDutchieAZSchedules(),
api.getDutchieAZRunLogs({ limit: 50 }),
api.getDutchieAZSchedulerStatus(),
]);
setSchedules(schedulesData.schedules || []);
setRunLogs(logsData.logs || []);
setSchedulerStatus(statusData);
} catch (error) {
console.error('Failed to load schedule data:', error);
} finally {
setLoading(false);
}
};
const handleToggleScheduler = async () => {
try {
if (schedulerStatus?.running) {
await api.stopDutchieAZScheduler();
} else {
await api.startDutchieAZScheduler();
}
await loadData();
} catch (error) {
console.error('Failed to toggle scheduler:', error);
}
};
const handleInitSchedules = async () => {
try {
await api.initDutchieAZSchedules();
await loadData();
} catch (error) {
console.error('Failed to initialize schedules:', error);
}
};
const handleTriggerSchedule = async (id: number) => {
try {
await api.triggerDutchieAZSchedule(id);
await loadData();
} catch (error) {
console.error('Failed to trigger schedule:', error);
}
};
const handleToggleEnabled = async (schedule: JobSchedule) => {
try {
await api.updateDutchieAZSchedule(schedule.id, { enabled: !schedule.enabled });
await loadData();
} catch (error) {
console.error('Failed to toggle schedule:', error);
}
};
const handleUpdateSchedule = async (id: number, updates: Partial<JobSchedule>) => {
try {
await api.updateDutchieAZSchedule(id, updates);
setEditingSchedule(null);
await loadData();
} catch (error) {
console.error('Failed to update schedule:', error);
}
};
const handleDeleteSchedule = async (id: number) => {
if (!confirm('Are you sure you want to delete this schedule?')) return;
try {
await api.deleteDutchieAZSchedule(id);
await loadData();
} catch (error) {
console.error('Failed to delete schedule:', error);
}
};
const formatTimeAgo = (dateString: string | null) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};
const formatTimeUntil = (dateString: string | null) => {
if (!dateString) return 'Not scheduled';
const date = new Date(dateString);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
if (diffMs < 0) return 'Overdue';
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
if (diffMins < 60) return `${diffMins}m`;
return `${diffHours}h ${diffMins % 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;
const jitterHours = Math.floor(jitterMinutes / 60);
const jitterMins = jitterMinutes % 60;
let base = hours > 0 ? `${hours}h` : '';
if (mins > 0) base += `${mins}m`;
let jitter = jitterHours > 0 ? `${jitterHours}h` : '';
if (jitterMins > 0) jitter += `${jitterMins}m`;
return `${base} +/- ${jitter}`;
};
const getStatusColor = (status: string | null) => {
switch (status) {
case 'success': return { bg: '#d1fae5', color: '#065f46' };
case 'running': return { bg: '#dbeafe', color: '#1e40af' };
case 'error': return { bg: '#fee2e2', color: '#991b1b' };
case 'partial': return { bg: '#fef3c7', color: '#92400e' };
default: return { bg: '#f3f4f6', color: '#374151' };
}
};
return (
<Layout>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '30px' }}>
<div>
<h1 style={{ fontSize: '32px', margin: 0 }}>Dutchie AZ Schedule</h1>
<p style={{ color: '#666', margin: '8px 0 0 0' }}>
Jittered scheduling for Arizona Dutchie product crawls
</p>
</div>
<div style={{ display: 'flex', gap: '15px', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>Auto-refresh (10s)</span>
</label>
</div>
</div>
{/* Scheduler Status Card */}
<div style={{
background: 'white',
padding: '20px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
marginBottom: '30px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Scheduler Status</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: schedulerStatus?.running ? '#10b981' : '#ef4444',
display: 'inline-block'
}} />
<span style={{ fontWeight: '600', fontSize: '18px' }}>
{schedulerStatus?.running ? 'Running' : 'Stopped'}
</span>
</div>
</div>
<div style={{ borderLeft: '1px solid #eee', paddingLeft: '20px' }}>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Poll Interval</div>
<div style={{ fontWeight: '600' }}>
{schedulerStatus ? `${schedulerStatus.pollIntervalMs / 1000}s` : '-'}
</div>
</div>
<div style={{ borderLeft: '1px solid #eee', paddingLeft: '20px' }}>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Active Schedules</div>
<div style={{ fontWeight: '600' }}>
{schedules.filter(s => s.enabled).length} / {schedules.length}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handleToggleScheduler}
style={{
padding: '10px 20px',
background: schedulerStatus?.running ? '#ef4444' : '#10b981',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: '600'
}}
>
{schedulerStatus?.running ? 'Stop Scheduler' : 'Start Scheduler'}
</button>
{schedules.length === 0 && (
<button
onClick={handleInitSchedules}
style={{
padding: '10px 20px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: '600'
}}
>
Initialize Default Schedules
</button>
)}
</div>
</div>
{/* Tabs */}
<div style={{ marginBottom: '30px', display: 'flex', gap: '10px', borderBottom: '2px solid #eee' }}>
<button
onClick={() => setActiveTab('schedules')}
style={{
padding: '12px 24px',
background: activeTab === 'schedules' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'schedules' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'schedules' ? '600' : '400',
color: activeTab === 'schedules' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Schedule Configs ({schedules.length})
</button>
<button
onClick={() => setActiveTab('logs')}
style={{
padding: '12px 24px',
background: activeTab === 'logs' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'logs' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'logs' ? '600' : '400',
color: activeTab === 'logs' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Run Logs ({runLogs.length})
</button>
</div>
{activeTab === 'schedules' && (
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
{schedules.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
No schedules configured. Click "Initialize Default Schedules" to create the default crawl schedule.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Job Name</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Enabled</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Interval (Jitter)</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Last Run</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Next Run</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Last Status</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Actions</th>
</tr>
</thead>
<tbody>
{schedules.map((schedule) => (
<tr key={schedule.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600' }}>{schedule.jobName}</div>
{schedule.description && (
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
{schedule.description}
</div>
)}
{schedule.jobConfig && (
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
Config: {JSON.stringify(schedule.jobConfig)}
</div>
)}
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<button
onClick={() => handleToggleEnabled(schedule)}
style={{
padding: '4px 12px',
borderRadius: '12px',
border: 'none',
cursor: 'pointer',
fontWeight: '600',
fontSize: '12px',
background: schedule.enabled ? '#d1fae5' : '#fee2e2',
color: schedule.enabled ? '#065f46' : '#991b1b'
}}
>
{schedule.enabled ? 'ON' : 'OFF'}
</button>
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<div style={{ fontWeight: '600' }}>
{formatInterval(schedule.baseIntervalMinutes, schedule.jitterMinutes)}
</div>
</td>
<td style={{ padding: '15px' }}>
<div>{formatTimeAgo(schedule.lastRunAt)}</div>
{schedule.lastDurationMs && (
<div style={{ fontSize: '12px', color: '#666' }}>
Duration: {formatDuration(schedule.lastDurationMs)}
</div>
)}
</td>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600', color: '#2563eb' }}>
{formatTimeUntil(schedule.nextRunAt)}
</div>
{schedule.nextRunAt && (
<div style={{ fontSize: '12px', color: '#999' }}>
{new Date(schedule.nextRunAt).toLocaleString()}
</div>
)}
</td>
<td style={{ padding: '15px' }}>
{schedule.lastStatus ? (
<div>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
...getStatusColor(schedule.lastStatus)
}}>
{schedule.lastStatus}
</span>
{schedule.lastErrorMessage && (
<button
onClick={() => alert(schedule.lastErrorMessage)}
style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fee2e2',
color: '#991b1b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '10px'
}}
>
Error
</button>
)}
</div>
) : (
<span style={{ color: '#999' }}>Never run</span>
)}
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<button
onClick={() => handleTriggerSchedule(schedule.id)}
disabled={schedule.lastStatus === 'running'}
style={{
padding: '6px 12px',
background: schedule.lastStatus === 'running' ? '#94a3b8' : '#2563eb',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: schedule.lastStatus === 'running' ? 'not-allowed' : 'pointer',
fontSize: '13px'
}}
>
Run Now
</button>
<button
onClick={() => setEditingSchedule(schedule)}
style={{
padding: '6px 12px',
background: '#f3f4f6',
color: '#374151',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px'
}}
>
Edit
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{activeTab === 'logs' && (
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
{runLogs.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
No run logs yet. Logs will appear here after jobs execute.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Job</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Status</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Started</th>
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Duration</th>
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Processed</th>
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Succeeded</th>
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Failed</th>
</tr>
</thead>
<tbody>
{runLogs.map((log) => (
<tr key={log.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600' }}>{log.job_name}</div>
<div style={{ fontSize: '12px', color: '#999' }}>Run #{log.id}</div>
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
...getStatusColor(log.status)
}}>
{log.status}
</span>
{log.error_message && (
<button
onClick={() => alert(log.error_message)}
style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fee2e2',
color: '#991b1b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '10px'
}}
>
Error
</button>
)}
</td>
<td style={{ padding: '15px' }}>
<div>{log.started_at ? new Date(log.started_at).toLocaleString() : '-'}</div>
<div style={{ fontSize: '12px', color: '#999' }}>{formatTimeAgo(log.started_at)}</div>
</td>
<td style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>
{formatDuration(log.duration_ms)}
</td>
<td style={{ padding: '15px', textAlign: 'right' }}>
{log.items_processed ?? '-'}
</td>
<td style={{ padding: '15px', textAlign: 'right', color: '#10b981' }}>
{log.items_succeeded ?? '-'}
</td>
<td style={{ padding: '15px', textAlign: 'right', color: log.items_failed ? '#ef4444' : 'inherit' }}>
{log.items_failed ?? '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Edit Modal */}
{editingSchedule && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
padding: '30px',
borderRadius: '12px',
width: '500px',
maxWidth: '90vw'
}}>
<h2 style={{ margin: '0 0 20px 0' }}>Edit Schedule: {editingSchedule.jobName}</h2>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Description</label>
<input
type="text"
value={editingSchedule.description || ''}
onChange={(e) => setEditingSchedule({ ...editingSchedule, description: e.target.value })}
style={{
width: '100%',
padding: '10px',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '14px'
}}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginBottom: '20px' }}>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>
Base Interval (minutes)
</label>
<input
type="number"
value={editingSchedule.baseIntervalMinutes}
onChange={(e) => setEditingSchedule({ ...editingSchedule, baseIntervalMinutes: parseInt(e.target.value) || 240 })}
style={{
width: '100%',
padding: '10px',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '14px'
}}
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
= {Math.floor(editingSchedule.baseIntervalMinutes / 60)}h {editingSchedule.baseIntervalMinutes % 60}m
</div>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>
Jitter (minutes)
</label>
<input
type="number"
value={editingSchedule.jitterMinutes}
onChange={(e) => setEditingSchedule({ ...editingSchedule, jitterMinutes: parseInt(e.target.value) || 30 })}
style={{
width: '100%',
padding: '10px',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '14px'
}}
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
+/- {editingSchedule.jitterMinutes}m random offset
</div>
</div>
</div>
<div style={{ fontSize: '13px', color: '#666', marginBottom: '20px', padding: '15px', background: '#f8f8f8', borderRadius: '6px' }}>
<strong>Effective range:</strong> {Math.floor((editingSchedule.baseIntervalMinutes - editingSchedule.jitterMinutes) / 60)}h {(editingSchedule.baseIntervalMinutes - editingSchedule.jitterMinutes) % 60}m
{' to '}
{Math.floor((editingSchedule.baseIntervalMinutes + editingSchedule.jitterMinutes) / 60)}h {(editingSchedule.baseIntervalMinutes + editingSchedule.jitterMinutes) % 60}m
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
<button
onClick={() => setEditingSchedule(null)}
style={{
padding: '10px 20px',
background: '#f3f4f6',
color: '#374151',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
onClick={() => handleUpdateSchedule(editingSchedule.id, {
description: editingSchedule.description,
baseIntervalMinutes: editingSchedule.baseIntervalMinutes,
jitterMinutes: editingSchedule.jitterMinutes,
})}
style={{
padding: '10px 20px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: '600'
}}
>
Save Changes
</button>
</div>
</div>
</div>
)}
</div>
</Layout>
);
}

View File

@@ -0,0 +1,620 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import {
Building2,
Phone,
MapPin,
ExternalLink,
ArrowLeft,
Package,
Tag,
RefreshCw,
ChevronDown,
Clock,
CheckCircle,
XCircle,
AlertCircle
} from 'lucide-react';
export function DutchieAZStoreDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [summary, setSummary] = useState<any>(null);
const [products, setProducts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [productsLoading, setProductsLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'products' | 'brands' | 'categories'>('products');
const [showUpdateDropdown, setShowUpdateDropdown] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalProducts, setTotalProducts] = useState(0);
const [itemsPerPage] = useState(25);
const [stockFilter, setStockFilter] = useState<string>('');
const formatDate = (dateStr: string) => {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
};
useEffect(() => {
if (id) {
loadStoreSummary();
}
}, [id]);
useEffect(() => {
if (id && activeTab === 'products') {
loadProducts();
}
}, [id, currentPage, searchQuery, stockFilter, activeTab]);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, stockFilter]);
const loadStoreSummary = async () => {
setLoading(true);
try {
const data = await api.getDutchieAZStoreSummary(parseInt(id!, 10));
setSummary(data);
} catch (error) {
console.error('Failed to load store summary:', error);
} finally {
setLoading(false);
}
};
const loadProducts = async () => {
if (!id) return;
setProductsLoading(true);
try {
const data = await api.getDutchieAZStoreProducts(parseInt(id, 10), {
search: searchQuery || undefined,
stockStatus: stockFilter || undefined,
limit: itemsPerPage,
offset: (currentPage - 1) * itemsPerPage,
});
setProducts(data.products);
setTotalProducts(data.total);
} catch (error) {
console.error('Failed to load products:', error);
} finally {
setProductsLoading(false);
}
};
const handleCrawl = async () => {
setShowUpdateDropdown(false);
setIsUpdating(true);
try {
await api.triggerDutchieAZCrawl(parseInt(id!, 10));
alert('Crawl started! Refresh the page in a few minutes to see updated data.');
} catch (error) {
console.error('Failed to trigger crawl:', error);
alert('Failed to start crawl. Please try again.');
} finally {
setIsUpdating(false);
}
};
const totalPages = Math.ceil(totalProducts / itemsPerPage);
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-blue-500 border-t-transparent"></div>
<p className="mt-2 text-sm text-gray-600">Loading store...</p>
</div>
</Layout>
);
}
if (!summary) {
return (
<Layout>
<div className="text-center py-12">
<p className="text-gray-600">Store not found</p>
</div>
</Layout>
);
}
const { dispensary, brands, categories, lastCrawl } = summary;
return (
<Layout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<button
onClick={() => navigate('/dutchie-az')}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4" />
Back to Dutchie AZ Stores
</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>
{/* Store Header */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-start justify-between gap-4 mb-4">
<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>
{dispensary.company_name && (
<p className="text-sm text-gray-600 mt-1">{dispensary.company_name}</p>
)}
<p className="text-xs text-gray-500 mt-1">
Platform ID: {dispensary.platform_dispensary_id || 'Not resolved'}
</p>
</div>
</div>
<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" />
<div>
<span className="font-medium">Last Crawl:</span>
<span className="ml-2">
{lastCrawl?.completed_at
? new Date(lastCrawl.completed_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: 'Never'}
</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 className="flex flex-wrap gap-4">
{dispensary.address && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<MapPin className="w-4 h-4" />
<span>
{dispensary.address}, {dispensary.city}, {dispensary.state} {dispensary.zip}
</span>
</div>
)}
{dispensary.phone && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="w-4 h-4" />
<span>{dispensary.phone}</span>
</div>
)}
{dispensary.website && (
<a
href={dispensary.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
>
<ExternalLink className="w-4 h-4" />
Website
</a>
)}
</div>
</div>
{/* Dashboard Metrics */}
<div className="grid grid-cols-5 gap-4">
<button
onClick={() => {
setActiveTab('products');
setStockFilter('');
setSearchQuery('');
}}
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
activeTab === 'products' && !stockFilter ? 'border-blue-500' : 'border-gray-200'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-green-50 rounded-lg">
<Package className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-sm text-gray-600">Total Products</p>
<p className="text-xl font-bold text-gray-900">{summary.totalProducts}</p>
</div>
</div>
</button>
<button
onClick={() => {
setActiveTab('products');
setStockFilter('in_stock');
setSearchQuery('');
}}
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
stockFilter === 'in_stock' ? 'border-blue-500' : 'border-gray-200'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-50 rounded-lg">
<CheckCircle className="w-5 h-5 text-emerald-600" />
</div>
<div>
<p className="text-sm text-gray-600">In Stock</p>
<p className="text-xl font-bold text-gray-900">{summary.inStockCount}</p>
</div>
</div>
</button>
<button
onClick={() => {
setActiveTab('products');
setStockFilter('out_of_stock');
setSearchQuery('');
}}
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
stockFilter === 'out_of_stock' ? 'border-blue-500' : 'border-gray-200'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-red-50 rounded-lg">
<XCircle className="w-5 h-5 text-red-600" />
</div>
<div>
<p className="text-sm text-gray-600">Out of Stock</p>
<p className="text-xl font-bold text-gray-900">{summary.outOfStockCount}</p>
</div>
</div>
</button>
<button
onClick={() => setActiveTab('brands')}
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
activeTab === 'brands' ? 'border-blue-500' : 'border-gray-200'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 rounded-lg">
<Tag className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-sm text-gray-600">Brands</p>
<p className="text-xl font-bold text-gray-900">{summary.brandCount}</p>
</div>
</div>
</button>
<button
onClick={() => setActiveTab('categories')}
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
activeTab === 'categories' ? 'border-blue-500' : 'border-gray-200'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-50 rounded-lg">
<AlertCircle className="w-5 h-5 text-orange-600" />
</div>
<div>
<p className="text-sm text-gray-600">Categories</p>
<p className="text-xl font-bold text-gray-900">{summary.categoryCount}</p>
</div>
</div>
</button>
</div>
{/* Content Tabs */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="border-b border-gray-200">
<div className="flex gap-4 px-6">
<button
onClick={() => {
setActiveTab('products');
setStockFilter('');
}}
className={`py-4 px-2 text-sm font-medium border-b-2 ${
activeTab === 'products'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
Products ({summary.totalProducts})
</button>
<button
onClick={() => setActiveTab('brands')}
className={`py-4 px-2 text-sm font-medium border-b-2 ${
activeTab === 'brands'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
Brands ({summary.brandCount})
</button>
<button
onClick={() => setActiveTab('categories')}
className={`py-4 px-2 text-sm font-medium border-b-2 ${
activeTab === 'categories'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
Categories ({summary.categoryCount})
</button>
</div>
</div>
<div className="p-6">
{activeTab === 'products' && (
<div className="space-y-4">
{/* Search and Filter */}
<div className="flex items-center gap-4 mb-4">
<input
type="text"
placeholder="Search products by name or brand..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input input-bordered input-sm flex-1"
/>
<select
value={stockFilter}
onChange={(e) => setStockFilter(e.target.value)}
className="select select-bordered select-sm"
>
<option value="">All Stock</option>
<option value="in_stock">In Stock</option>
<option value="out_of_stock">Out of Stock</option>
<option value="unknown">Unknown</option>
</select>
{(searchQuery || stockFilter) && (
<button
onClick={() => {
setSearchQuery('');
setStockFilter('');
}}
className="btn btn-sm btn-ghost"
>
Clear
</button>
)}
<div className="text-sm text-gray-600">
{totalProducts} products
</div>
</div>
{productsLoading ? (
<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>
<p className="mt-2 text-sm text-gray-600">Loading products...</p>
</div>
) : products.length === 0 ? (
<p className="text-center py-8 text-gray-500">No products found</p>
) : (
<>
<div className="overflow-x-auto -mx-6 px-6">
<table className="table table-xs table-zebra table-pin-rows w-full">
<thead>
<tr>
<th>Image</th>
<th>Product Name</th>
<th>Brand</th>
<th>Type</th>
<th className="text-right">Price</th>
<th className="text-center">THC %</th>
<th className="text-center">Stock</th>
<th className="text-center">Qty</th>
<th>Last Updated</th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id}>
<td className="whitespace-nowrap">
{product.image_url ? (
<img
src={product.image_url}
alt={product.name}
className="w-12 h-12 object-cover rounded"
onError={(e) => e.currentTarget.style.display = 'none'}
/>
) : '-'}
</td>
<td className="font-medium max-w-[200px]">
<div className="line-clamp-2" title={product.name}>{product.name}</div>
</td>
<td className="max-w-[120px]">
<div className="line-clamp-2" title={product.brand || '-'}>{product.brand || '-'}</div>
</td>
<td className="whitespace-nowrap">
<span className="badge badge-ghost badge-sm">{product.type || '-'}</span>
{product.subcategory && (
<span className="badge badge-ghost badge-sm ml-1">{product.subcategory}</span>
)}
</td>
<td className="text-right font-semibold whitespace-nowrap">
{product.sale_price ? (
<div className="flex flex-col items-end">
<span className="text-error">${product.sale_price}</span>
<span className="text-gray-400 line-through text-xs">${product.regular_price}</span>
</div>
) : product.regular_price ? (
`$${product.regular_price}`
) : '-'}
</td>
<td className="text-center whitespace-nowrap">
{product.thc_percentage ? (
<span className="badge badge-success badge-sm">{product.thc_percentage}%</span>
) : '-'}
</td>
<td className="text-center whitespace-nowrap">
{product.stock_status === 'in_stock' ? (
<span className="badge badge-success badge-sm">In Stock</span>
) : product.stock_status === 'out_of_stock' ? (
<span className="badge badge-error badge-sm">Out</span>
) : (
<span className="badge badge-warning badge-sm">Unknown</span>
)}
</td>
<td className="text-center whitespace-nowrap">
{product.total_quantity != null ? product.total_quantity : '-'}
</td>
<td className="whitespace-nowrap text-xs text-gray-500">
{product.updated_at ? formatDate(product.updated_at) : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-4">
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="btn btn-sm btn-outline"
>
Previous
</button>
<div className="flex gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let page: number;
if (totalPages <= 5) {
page = i + 1;
} else if (currentPage <= 3) {
page = i + 1;
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i;
} else {
page = currentPage - 2 + i;
}
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`btn btn-sm ${
currentPage === page ? 'btn-primary' : 'btn-outline'
}`}
>
{page}
</button>
);
})}
</div>
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="btn btn-sm btn-outline"
>
Next
</button>
</div>
)}
</>
)}
</div>
)}
{activeTab === 'brands' && (
<div className="space-y-4">
{brands.length === 0 ? (
<p className="text-center py-8 text-gray-500">No brands found</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{brands.map((brand: any) => (
<button
key={brand.brand_name}
onClick={() => {
setActiveTab('products');
setSearchQuery(brand.brand_name);
setStockFilter('');
}}
className="border border-gray-200 rounded-lg p-4 text-center hover:border-blue-300 hover:shadow-md transition-all cursor-pointer"
>
<p className="font-medium text-gray-900 line-clamp-2">{brand.brand_name}</p>
<p className="text-sm text-gray-600 mt-1">
{brand.product_count} product{brand.product_count !== 1 ? 's' : ''}
</p>
</button>
))}
</div>
)}
</div>
)}
{activeTab === 'categories' && (
<div className="space-y-4">
{categories.length === 0 ? (
<p className="text-center py-8 text-gray-500">No categories found</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{categories.map((cat: any, idx: number) => (
<div
key={idx}
className="border border-gray-200 rounded-lg p-4 text-center"
>
<p className="font-medium text-gray-900">{cat.type}</p>
{cat.subcategory && (
<p className="text-sm text-gray-600">{cat.subcategory}</p>
)}
<p className="text-sm text-gray-500 mt-1">
{cat.product_count} product{cat.product_count !== 1 ? 's' : ''}
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import {
Building2,
MapPin,
Package,
RefreshCw,
CheckCircle,
XCircle
} from 'lucide-react';
export function DutchieAZStores() {
const navigate = useNavigate();
const [stores, setStores] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [dashboard, setDashboard] = useState<any>(null);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [storesData, dashboardData] = await Promise.all([
api.getDutchieAZStores({ limit: 100 }),
api.getDutchieAZDashboard(),
]);
setStores(storesData.stores);
setDashboard(dashboardData);
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
}
};
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-blue-500 border-t-transparent"></div>
<p className="mt-2 text-sm text-gray-600">Loading stores...</p>
</div>
</Layout>
);
}
return (
<Layout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dutchie AZ Stores</h1>
<p className="text-sm text-gray-600 mt-1">
Arizona dispensaries using the Dutchie platform - data from the new pipeline
</p>
</div>
<button
onClick={loadData}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
{/* Dashboard Stats */}
{dashboard && (
<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">
<div className="p-2 bg-blue-50 rounded-lg">
<Building2 className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-gray-600">Dispensaries</p>
<p className="text-xl font-bold text-gray-900">{dashboard.dispensaryCount}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-50 rounded-lg">
<Package className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-sm text-gray-600">Total Products</p>
<p className="text-xl font-bold text-gray-900">{dashboard.productCount.toLocaleString()}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 rounded-lg">
<CheckCircle className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-sm text-gray-600">Brands</p>
<p className="text-xl font-bold text-gray-900">{dashboard.brandCount}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-50 rounded-lg">
<XCircle className="w-5 h-5 text-orange-600" />
</div>
<div>
<p className="text-sm text-gray-600">Failed Jobs (24h)</p>
<p className="text-xl font-bold text-gray-900">{dashboard.failedJobCount}</p>
</div>
</div>
</div>
</div>
)}
{/* Stores List */}
<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">All Stores ({stores.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr>
<th>Name</th>
<th>City</th>
<th>Platform ID</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{stores.map((store) => (
<tr key={store.id}>
<td>
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<Building2 className="w-4 h-4 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{store.dba_name || store.name}</p>
{store.company_name && store.company_name !== store.name && (
<p className="text-xs text-gray-500">{store.company_name}</p>
)}
</div>
</div>
</td>
<td>
<div className="flex items-center gap-2 text-sm text-gray-600">
<MapPin className="w-4 h-4" />
{store.city}, {store.state}
</div>
</td>
<td>
{store.platform_dispensary_id ? (
<span className="text-xs font-mono text-gray-600">{store.platform_dispensary_id}</span>
) : (
<span className="badge badge-warning badge-sm">Not Resolved</span>
)}
</td>
<td>
{store.platform_dispensary_id ? (
<span className="badge badge-success badge-sm">Ready</span>
) : (
<span className="badge badge-warning badge-sm">Pending</span>
)}
</td>
<td>
<button
onClick={() => navigate(`/dutchie-az/stores/${store.id}`)}
className="btn btn-sm btn-primary"
disabled={!store.platform_dispensary_id}
>
View Products
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</Layout>
);
}