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

37
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,37 @@
import axios from 'axios';
// Tenant Extraction Logic
export const getTenantFromSubdomain = (): string => {
const hostname = window.location.hostname;
// Localhost or IP address -> 'public' (or dev tenant)
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'public';
}
// Extract subdomain
// e.g., plumbing.chronoflow.com -> plumbing
const parts = hostname.split('.');
if (parts.length > 2) {
return parts[0];
}
return 'public'; // Fallback
};
// Axios Instance
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api',
withCredentials: true, // Important for cookies/sessions
});
// Request Interceptor to inject Tenant ID
api.interceptors.request.use((config) => {
const tenant = getTenantFromSubdomain();
if (tenant) {
config.headers['X-Tenant-ID'] = tenant;
}
return config;
});
export default api;

View File

@@ -0,0 +1,33 @@
export interface Event {
id: number;
resourceId: number;
title: string;
serviceName?: string;
start: Date;
end: Date;
laneIndex?: number;
status?: 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
isPaid?: boolean;
}
export function calculateLayout(events: Event[]): Event[] {
const sortedEvents = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const laneEndTimes: number[] = [];
return sortedEvents.map((event) => {
let laneIndex = 0;
let placed = false;
while (!placed) {
const laneEndTime = laneEndTimes[laneIndex] || 0;
if (laneEndTime <= event.start.getTime()) {
laneEndTimes[laneIndex] = event.end.getTime();
placed = true;
} else {
laneIndex++;
}
}
return { ...event, laneIndex };
});
}

View File

@@ -0,0 +1,38 @@
import { addMinutes, differenceInMinutes, roundToNearestMinutes } from 'date-fns';
export const SNAP_MINUTES = 15;
export const DEFAULT_PIXELS_PER_HOUR = 100;
/**
* Snap a date to the nearest 15 minutes
*/
export function snapDate(date: Date): Date {
return roundToNearestMinutes(date, { nearestTo: SNAP_MINUTES });
}
/**
* Calculate pixel position from date relative to start time
*/
export function getPosition(date: Date, startTime: Date, pixelsPerHour: number): number {
const diffMinutes = differenceInMinutes(date, startTime);
const pixelsPerMinute = pixelsPerHour / 60;
return diffMinutes * pixelsPerMinute;
}
/**
* Calculate date from pixel position relative to start time
*/
export function getDateFromPosition(pixels: number, startTime: Date, pixelsPerHour: number): Date {
const pixelsPerMinute = pixelsPerHour / 60;
const minutes = pixels / pixelsPerMinute;
return addMinutes(startTime, minutes);
}
/**
* Snap pixel value to nearest grid line (15 min)
*/
export function snapPixels(pixels: number, pixelsPerHour: number): number {
const pixelsPerMinute = pixelsPerHour / 60;
const snapPixels = SNAP_MINUTES * pixelsPerMinute;
return Math.round(pixels / snapPixels) * snapPixels;
}

View File

@@ -0,0 +1,67 @@
import { Event } from './layoutAlgorithm';
import { ResourceLayout, PendingAppointment } from '../components/Schedule/Sidebar';
// Backend Types (Django)
export interface BackendResource {
id: number;
name: string;
description?: string;
// Add other backend fields if necessary
}
export interface BackendAppointment {
id: number;
resource?: number; // Foreign Key ID
customer: number; // User ID
service: number; // Service ID
start_time: string; // ISO 8601
end_time: string; // ISO 8601
status: 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
is_paid: boolean;
// We might need to fetch related data (customer name, service name)
// or expect the serializer to expand them.
// For now, assuming expanded or separate lookup.
customer_name?: string;
service_name?: string;
}
// Adapter Functions
export const adaptResources = (resources: BackendResource[]): { id: number; name: string }[] => {
return resources.map(r => ({
id: r.id,
name: r.name,
}));
};
export const adaptEvents = (appointments: BackendAppointment[]): Event[] => {
return appointments
.filter(apt => apt.resource !== null && apt.resource !== undefined) // Only scheduled events
.map(apt => ({
id: apt.id,
resourceId: apt.resource!,
title: apt.customer_name || `Customer ${apt.customer}`,
serviceName: apt.service_name || `Service ${apt.service}`,
start: new Date(apt.start_time),
end: new Date(apt.end_time),
status: apt.status,
isPaid: apt.is_paid,
}));
};
export const adaptPending = (appointments: BackendAppointment[]): PendingAppointment[] => {
return appointments
.filter(apt => apt.resource === null || apt.resource === undefined) // Only unscheduled
.map(apt => {
const start = new Date(apt.start_time);
const end = new Date(apt.end_time);
const durationMinutes = (end.getTime() - start.getTime()) / 60000;
return {
id: apt.id,
customerName: apt.customer_name || `Customer ${apt.customer}`,
serviceName: apt.service_name || `Service ${apt.service}`,
durationMinutes: Math.round(durationMinutes),
};
});
};