This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
183 lines
10 KiB
TypeScript
183 lines
10 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { User } from '../types';
|
|
import { useBusinessUsers, useCreateResource, useResources } from '../hooks/useBusiness';
|
|
import {
|
|
Plus,
|
|
MoreHorizontal,
|
|
User as UserIcon,
|
|
Shield,
|
|
Briefcase,
|
|
Calendar
|
|
} from 'lucide-react';
|
|
import Portal from '../components/Portal';
|
|
|
|
interface StaffProps {
|
|
onMasquerade: (user: User) => void;
|
|
effectiveUser: User;
|
|
}
|
|
|
|
const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|
const { t } = useTranslation();
|
|
const { data: users = [], isLoading, error } = useBusinessUsers();
|
|
const { data: resources = [] } = useResources();
|
|
const createResourceMutation = useCreateResource();
|
|
|
|
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
|
|
|
// Helper to check if a user is already linked to a resource
|
|
const getLinkedResource = (userId: string) => {
|
|
return resources.find((r: any) => r.user_id === parseInt(userId));
|
|
};
|
|
|
|
const handleMakeBookable = (user: any) => {
|
|
if (confirm(`Create a bookable resource for ${user.name || user.username}?`)) {
|
|
createResourceMutation.mutate({
|
|
name: user.name || user.username,
|
|
type: 'STAFF',
|
|
user_id: user.id
|
|
});
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-8 max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-8 max-w-7xl mx-auto">
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<p className="text-red-800 dark:text-red-300">{t('staff.errorLoading')}: {(error as Error).message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Filter for staff/management roles
|
|
const staffUsers = users.filter((u: any) => ['owner', 'manager', 'staff'].includes(u.role));
|
|
|
|
return (
|
|
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('staff.title')}</h2>
|
|
<p className="text-gray-500 dark:text-gray-400">{t('staff.description')}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsInviteModalOpen(true)}
|
|
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
|
|
>
|
|
<Plus size={18} />
|
|
{t('staff.inviteStaff')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden transition-colors duration-200">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
|
<tr>
|
|
<th className="px-6 py-4 font-medium">{t('staff.name')}</th>
|
|
<th className="px-6 py-4 font-medium">{t('staff.role')}</th>
|
|
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
|
|
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
|
{staffUsers.map((user: any) => {
|
|
const linkedResource = getLinkedResource(user.id);
|
|
|
|
// Owners/Managers can log in as anyone.
|
|
const canMasquerade = ['owner', 'manager'].includes(effectiveUser.role) && user.id !== effectiveUser.id;
|
|
|
|
return (
|
|
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400 font-medium">
|
|
{user.name ? user.name.charAt(0).toUpperCase() : user.username.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-gray-900 dark:text-white">{user.name || user.username}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">{user.email}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${user.role === 'owner' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
|
|
user.role === 'manager' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' :
|
|
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
|
}`}>
|
|
{user.role === 'owner' && <Shield size={12} />}
|
|
{user.role === 'manager' && <Briefcase size={12} />}
|
|
{user.role}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{linkedResource ? (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
|
|
<Calendar size={12} />
|
|
{t('staff.yes')} ({linkedResource.name})
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={() => handleMakeBookable(user)}
|
|
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 hover:underline"
|
|
>
|
|
{t('staff.makeBookable')}
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{canMasquerade && (
|
|
<button
|
|
onClick={() => onMasquerade(user)}
|
|
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
|
|
>
|
|
{t('common.masquerade')}
|
|
</button>
|
|
)}
|
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
|
<MoreHorizontal size={18} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Invite Modal Placeholder */}
|
|
{isInviteModalOpen && (
|
|
<Portal>
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md p-6">
|
|
<h3 className="text-lg font-semibold mb-4 dark:text-white">{t('staff.inviteModalTitle')}</h3>
|
|
<p className="text-gray-500 dark:text-gray-400 mb-6">{t('staff.inviteModalDescription')}</p>
|
|
<div className="flex justify-end">
|
|
<button onClick={() => setIsInviteModalOpen(false)} className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-700 dark:text-gray-300">{t('common.close')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Staff;
|