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:
37
frontend/src/lib/api.ts
Normal file
37
frontend/src/lib/api.ts
Normal 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;
|
||||
33
frontend/src/lib/layoutAlgorithm.ts
Normal file
33
frontend/src/lib/layoutAlgorithm.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
38
frontend/src/lib/timelineUtils.ts
Normal file
38
frontend/src/lib/timelineUtils.ts
Normal 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;
|
||||
}
|
||||
67
frontend/src/lib/uiAdapter.ts
Normal file
67
frontend/src/lib/uiAdapter.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user