Initial commit: SmoothSchedule multi-tenant scheduling platform
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>
This commit is contained in:
182
frontend/src/pages/Staff.tsx
Normal file
182
frontend/src/pages/Staff.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user