feat: Add Findagram and FindADispo consumer frontends

- Add findagram.co React frontend with product search, brands, categories
- Add findadispo.com React frontend with dispensary locator
- Wire findagram to backend /api/az/* endpoints
- Update category/brand links to route to /products with filters
- Add k8s manifests for both frontends
- Add multi-domain user support migrations

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-05 16:10:15 -07:00
parent d120a07ed7
commit a0f8d3911c
179 changed files with 140234 additions and 600 deletions

View File

@@ -119,8 +119,13 @@ export function Layout({ children }: LayoutProps) {
>
{/* Logo/Brand */}
<div className="px-6 py-5 border-b border-gray-200">
<h1 className="text-lg font-semibold text-gray-900">Dutchie Analytics</h1>
<h1 className="text-lg font-semibold text-gray-900">CannaIQ</h1>
<p className="text-xs text-gray-500 mt-0.5">{user?.email}</p>
{versionInfo && (
<p className="text-xs text-gray-400 mt-1">
{versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)})
</p>
)}
</div>
{/* Navigation */}

View File

@@ -1037,23 +1037,24 @@ class ApiClient {
}
// Users Management
async getUsers() {
return this.request<{ users: Array<{ id: number; email: string; role: string; created_at: string; updated_at: string }> }>('/api/users');
async getUsers(queryParams?: string) {
const url = queryParams ? `/api/users?${queryParams}` : '/api/users';
return this.request<{ users: Array<{ id: number; email: string; role: string; first_name: string | null; last_name: string | null; phone: string | null; domain: string; created_at: string; updated_at: string }> }>(url);
}
async getUser(id: number) {
return this.request<{ user: { id: number; email: string; role: string; created_at: string; updated_at: string } }>(`/api/users/${id}`);
return this.request<{ user: { id: number; email: string; role: string; first_name: string | null; last_name: string | null; phone: string | null; domain: string; created_at: string; updated_at: string } }>(`/api/users/${id}`);
}
async createUser(data: { email: string; password: string; role?: string }) {
return this.request<{ user: { id: number; email: string; role: string; created_at: string; updated_at: string } }>('/api/users', {
async createUser(data: { email: string; password: string; role?: string; first_name?: string; last_name?: string; phone?: string; domain?: string }) {
return this.request<{ user: { id: number; email: string; role: string; first_name: string | null; last_name: string | null; phone: string | null; domain: string; created_at: string; updated_at: string } }>('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateUser(id: number, data: { email?: string; password?: string; role?: string }) {
return this.request<{ user: { id: number; email: string; role: string; created_at: string; updated_at: string } }>(`/api/users/${id}`, {
async updateUser(id: number, data: { email?: string; password?: string; role?: string; first_name?: string; last_name?: string; phone?: string; domain?: string }) {
return this.request<{ user: { id: number; email: string; role: string; first_name: string | null; last_name: string | null; phone: string | null; domain: string; created_at: string; updated_at: string } }>(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});

View File

@@ -528,7 +528,7 @@ export function DispensaryDetail() {
rel="noopener noreferrer"
className="btn btn-xs btn-outline"
>
Dutchie
Source
</a>
)}
<button

View File

@@ -168,7 +168,7 @@ export function DutchieAZSchedule() {
};
const handleDetectMissingIds = async () => {
if (!confirm('Resolve platform IDs for all Dutchie dispensaries missing them?')) return;
if (!confirm('Resolve platform IDs for all dispensaries missing them?')) return;
setDetectingAll(true);
setDetectionResults(null);
try {
@@ -251,9 +251,9 @@ export function DutchieAZSchedule() {
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '30px' }}>
<div>
<h1 style={{ fontSize: '32px', margin: 0 }}>Dutchie AZ Schedule</h1>
<h1 style={{ fontSize: '32px', margin: 0 }}>AZ Schedule</h1>
<p style={{ color: '#666', margin: '8px 0 0 0' }}>
Jittered scheduling for Arizona Dutchie product crawls
Jittered scheduling for Arizona product crawls
</p>
</div>
<div style={{ display: 'flex', gap: '15px', alignItems: 'center' }}>

View File

@@ -56,9 +56,9 @@ export function DutchieAZStores() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dutchie AZ Stores</h1>
<h1 className="text-2xl font-bold text-gray-900">AZ Stores</h1>
<p className="text-sm text-gray-600 mt-1">
Arizona dispensaries using the Dutchie platform - data from the new pipeline
Arizona dispensaries - data from the CannaIQ pipeline
</p>
</div>
<button

View File

@@ -248,7 +248,7 @@ export function ProductDetail() {
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-700"
>
View on Dutchie
View Source
<ExternalLink className="w-4 h-4" />
</a>
</div>

View File

@@ -502,7 +502,7 @@ function ProductCard({ product, onViewDetails }: { product: any; onViewDetails:
}}
onClick={(e) => e.stopPropagation()}
>
Dutchie
Source
</a>
)}
<button

View File

@@ -542,7 +542,7 @@ export function ScraperSchedule() {
onChange={(e) => setFilterDutchieOnly(e.target.checked)}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<span>Dutchie only</span>
<span>CannaIQ only</span>
</label>
{/* Results Count */}

View File

@@ -531,7 +531,7 @@ export function StoreDetail() {
rel="noopener noreferrer"
className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors text-center border border-gray-200"
>
Dutchie
Source
</a>
)}
<button

View File

@@ -206,7 +206,7 @@ export function Stores() {
</div>
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded">Dutchie</span>
<span className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded">CannaIQ</span>
</td>
<td className="px-6 py-4">
<a
@@ -323,7 +323,7 @@ export function Stores() {
</div>
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded">Dutchie</span>
<span className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded">CannaIQ</span>
</td>
<td className="px-6 py-4">
<a

View File

@@ -2,12 +2,16 @@ import { useState, useEffect } from 'react';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { useAuthStore } from '../store/authStore';
import { Users as UsersIcon, Plus, Pencil, Trash2, X, Check, AlertCircle } from 'lucide-react';
import { Users as UsersIcon, Plus, Pencil, Trash2, X, Check, AlertCircle, Search, Globe, Phone, Mail } from 'lucide-react';
interface User {
id: number;
email: string;
role: string;
first_name: string | null;
last_name: string | null;
phone: string | null;
domain: string;
created_at: string;
updated_at: string;
}
@@ -16,8 +20,18 @@ interface UserFormData {
email: string;
password: string;
role: string;
first_name: string;
last_name: string;
phone: string;
domain: string;
}
const DOMAINS = [
{ value: 'cannaiq.co', label: 'CannaIQ' },
{ value: 'findagram.co', label: 'Find a Gram' },
{ value: 'findadispo.com', label: 'Find a Dispo' },
];
export function Users() {
const { user: currentUser } = useAuthStore();
const [users, setUsers] = useState<User[]>([]);
@@ -25,14 +39,30 @@ export function Users() {
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState<UserFormData>({ email: '', password: '', role: 'viewer' });
const [formData, setFormData] = useState<UserFormData>({
email: '',
password: '',
role: 'viewer',
first_name: '',
last_name: '',
phone: '',
domain: 'cannaiq.co'
});
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Search and filter states
const [searchQuery, setSearchQuery] = useState('');
const [domainFilter, setDomainFilter] = useState('');
const fetchUsers = async () => {
try {
setLoading(true);
const response = await api.getUsers();
const params = new URLSearchParams();
if (searchQuery) params.set('search', searchQuery);
if (domainFilter) params.set('domain', domainFilter);
const response = await api.getUsers(params.toString());
setUsers(response.users);
setError(null);
} catch (err: any) {
@@ -44,7 +74,7 @@ export function Users() {
useEffect(() => {
fetchUsers();
}, []);
}, [searchQuery, domainFilter]);
const handleCreate = async () => {
if (!formData.email || !formData.password) {
@@ -55,9 +85,17 @@ export function Users() {
try {
setSaving(true);
setFormError(null);
await api.createUser(formData);
await api.createUser({
email: formData.email,
password: formData.password,
role: formData.role,
first_name: formData.first_name || undefined,
last_name: formData.last_name || undefined,
phone: formData.phone || undefined,
domain: formData.domain
});
setShowCreateModal(false);
setFormData({ email: '', password: '', role: 'viewer' });
resetForm();
fetchUsers();
} catch (err: any) {
setFormError(err.message || 'Failed to create user');
@@ -69,7 +107,7 @@ export function Users() {
const handleUpdate = async () => {
if (!editingUser) return;
const updateData: Partial<UserFormData> = {};
const updateData: any = {};
if (formData.email && formData.email !== editingUser.email) {
updateData.email = formData.email;
}
@@ -79,6 +117,18 @@ export function Users() {
if (formData.role && formData.role !== editingUser.role) {
updateData.role = formData.role;
}
if (formData.first_name !== (editingUser.first_name || '')) {
updateData.first_name = formData.first_name || null;
}
if (formData.last_name !== (editingUser.last_name || '')) {
updateData.last_name = formData.last_name || null;
}
if (formData.phone !== (editingUser.phone || '')) {
updateData.phone = formData.phone || null;
}
if (formData.domain !== editingUser.domain) {
updateData.domain = formData.domain;
}
if (Object.keys(updateData).length === 0) {
setEditingUser(null);
@@ -90,7 +140,7 @@ export function Users() {
setFormError(null);
await api.updateUser(editingUser.id, updateData);
setEditingUser(null);
setFormData({ email: '', password: '', role: 'viewer' });
resetForm();
fetchUsers();
} catch (err: any) {
setFormError(err.message || 'Failed to update user');
@@ -114,14 +164,34 @@ export function Users() {
const openEditModal = (user: User) => {
setEditingUser(user);
setFormData({ email: user.email, password: '', role: user.role });
setFormData({
email: user.email,
password: '',
role: user.role,
first_name: user.first_name || '',
last_name: user.last_name || '',
phone: user.phone || '',
domain: user.domain
});
setFormError(null);
};
const resetForm = () => {
setFormData({
email: '',
password: '',
role: 'viewer',
first_name: '',
last_name: '',
phone: '',
domain: 'cannaiq.co'
});
};
const closeModal = () => {
setShowCreateModal(false);
setEditingUser(null);
setFormData({ email: '', password: '', role: 'viewer' });
resetForm();
setFormError(null);
};
@@ -140,10 +210,21 @@ export function Users() {
}
};
const getDomainBadgeColor = (domain: string) => {
switch (domain) {
case 'cannaiq.co':
return 'bg-indigo-100 text-indigo-800';
case 'findagram.co':
return 'bg-emerald-100 text-emerald-800';
case 'findadispo.com':
return 'bg-amber-100 text-amber-800';
default:
return 'bg-gray-100 text-gray-700';
}
};
const canModifyUser = (user: User) => {
// Can't modify yourself
if (currentUser?.id === user.id) return false;
// Only superadmin can modify superadmin users
if (user.role === 'superadmin' && currentUser?.role !== 'superadmin') return false;
return true;
};
@@ -157,7 +238,7 @@ export function Users() {
<UsersIcon className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500">Manage system users and their roles</p>
<p className="text-sm text-gray-500">Manage system users across all domains</p>
</div>
</div>
<button
@@ -172,6 +253,34 @@ export function Users() {
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search by email or name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="sm:w-48">
<select
value={domainFilter}
onChange={(e) => setDomainFilter(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All Domains</option>
{DOMAINS.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
@@ -192,106 +301,121 @@ export function Users() {
No users found
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-sm font-medium text-gray-600">
{user.email.charAt(0).toUpperCase()}
</span>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">{user.email}</p>
{currentUser?.id === user.id && (
<p className="text-xs text-gray-500">(you)</p>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleBadgeColor(user.role)}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{canModifyUser(user) ? (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(user)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="Edit user"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(user)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Domain
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Contact
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<span className="text-sm font-bold text-white">
{(user.first_name?.[0] || user.email[0]).toUpperCase()}
</span>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">
{user.first_name || user.last_name
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
: user.email.split('@')[0]}
</p>
<p className="text-xs text-gray-500">{user.email}</p>
{currentUser?.id === user.id && (
<span className="text-xs text-blue-500">(you)</span>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full ${getDomainBadgeColor(user.domain)}`}>
<Globe className="w-3 h-3" />
{DOMAINS.find(d => d.value === user.domain)?.label || user.domain}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleBadgeColor(user.role)}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm">
{user.phone && (
<div className="flex items-center gap-1 text-gray-500">
<Phone className="w-3 h-3" />
<span>{user.phone}</span>
</div>
)}
{!user.phone && <span className="text-gray-400">-</span>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{canModifyUser(user) ? (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(user)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="Edit user"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(user)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Role Legend */}
{/* Domain Legend */}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Role Permissions</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleBadgeColor('superadmin')}`}>
superadmin
</span>
<p className="mt-1 text-gray-500">Full system access</p>
</div>
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleBadgeColor('admin')}`}>
admin
</span>
<p className="mt-1 text-gray-500">Manage users & settings</p>
</div>
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleBadgeColor('analyst')}`}>
analyst
</span>
<p className="mt-1 text-gray-500">View & analyze data</p>
</div>
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleBadgeColor('viewer')}`}>
viewer
</span>
<p className="mt-1 text-gray-500">Read-only access</p>
</div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Domains</h3>
<div className="flex flex-wrap gap-4">
{DOMAINS.map((domain) => (
<div key={domain.value} className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full ${getDomainBadgeColor(domain.value)}`}>
<Globe className="w-3 h-3" />
{domain.label}
</span>
<span className="text-xs text-gray-500">{domain.value}</span>
</div>
))}
</div>
</div>
</div>
@@ -299,7 +423,7 @@ export function Users() {
{/* Create/Edit Modal */}
{(showCreateModal || editingUser) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
{editingUser ? 'Edit User' : 'Create New User'}
@@ -320,8 +444,37 @@ export function Users() {
</div>
)}
{/* Profile Section */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
First Name
</label>
<input
type="text"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="John"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Last Name
</label>
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Doe"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Mail className="w-4 h-4 inline mr-1" />
Email
</label>
<input
@@ -342,26 +495,57 @@ export function Users() {
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder={editingUser ? '••••••••' : 'Enter password'}
placeholder={editingUser ? '********' : 'Enter password'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role
<Phone className="w-4 h-4 inline mr-1" />
Phone
</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="viewer">Viewer</option>
<option value="analyst">Analyst</option>
<option value="admin">Admin</option>
{currentUser?.role === 'superadmin' && (
<option value="superadmin">Superadmin</option>
)}
</select>
placeholder="+1 555 123 4567"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Globe className="w-4 h-4 inline mr-1" />
Domain
</label>
<select
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{DOMAINS.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="viewer">Viewer</option>
<option value="analyst">Analyst</option>
<option value="admin">Admin</option>
{currentUser?.role === 'superadmin' && (
<option value="superadmin">Superadmin</option>
)}
</select>
</div>
</div>
</div>

View File

@@ -121,7 +121,7 @@ export function WholesaleAnalytics() {
<div>
<h1 className="text-2xl font-bold text-gray-900">Wholesale & Inventory Analytics</h1>
<p className="text-sm text-gray-600 mt-1">
Arizona Dutchie dispensaries data overview
Arizona dispensaries data overview
</p>
</div>
<button