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:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

View 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;