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:
@@ -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 */}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -528,7 +528,7 @@ export function DispensaryDetail() {
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-xs btn-outline"
|
||||
>
|
||||
Dutchie
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -502,7 +502,7 @@ function ProductCard({ product, onViewDetails }: { product: any; onViewDetails:
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Dutchie
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user