920 lines
36 KiB
Python
920 lines
36 KiB
Python
"""
|
|
HR app forms with healthcare-focused validation and user experience.
|
|
"""
|
|
|
|
from django import forms
|
|
from django.core.exceptions import ValidationError
|
|
from django.utils import timezone
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
|
|
from .models import (
|
|
Employee, Department, Schedule, ScheduleAssignment,
|
|
TimeEntry, PerformanceReview, TrainingRecord
|
|
)
|
|
|
|
|
|
class EmployeeForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating employees.
|
|
"""
|
|
|
|
class Meta:
|
|
model = Employee
|
|
fields = [
|
|
'employee_number', 'first_name', 'last_name', 'middle_name', 'preferred_name',
|
|
'email', 'phone', 'mobile_phone', 'address_line_1', 'address_line_2',
|
|
'city', 'state', 'postal_code', 'country', 'date_of_birth',
|
|
'gender', 'marital_status', 'department', 'job_title',
|
|
'employment_type', 'employment_status', 'hire_date', 'termination_date',
|
|
'supervisor', 'standard_hours_per_week', 'fte_percentage',
|
|
'hourly_rate', 'annual_salary', 'license_number', 'license_expiry_date',
|
|
'certifications', 'emergency_contact_name', 'emergency_contact_relationship',
|
|
'emergency_contact_phone', 'notes'
|
|
]
|
|
widgets = {
|
|
'employee_number': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Unique employee ID'
|
|
}),
|
|
'first_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'First name'
|
|
}),
|
|
'last_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Last name'
|
|
}),
|
|
'middle_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Middle name'
|
|
}),
|
|
'preferred_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Preferred name'
|
|
}),
|
|
'email': forms.EmailInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'employee@hospital.com'
|
|
}),
|
|
'phone': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': '+1-555-123-4567'
|
|
}),
|
|
'mobile_phone': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': '+1-555-123-4567'
|
|
}),
|
|
'address_line_1': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Street address'
|
|
}),
|
|
'address_line_2': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Apt, Suite, etc.'
|
|
}),
|
|
'city': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'City'
|
|
}),
|
|
'state': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'State/Province'
|
|
}),
|
|
'postal_code': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Postal/ZIP code'
|
|
}),
|
|
'country': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Country'
|
|
}),
|
|
'department': forms.Select(attrs={'class': 'form-control'}),
|
|
'job_title': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Job title/position'
|
|
}),
|
|
'employment_status': forms.Select(attrs={'class': 'form-control'}),
|
|
'employment_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'hire_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'termination_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'annual_salary': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0'
|
|
}),
|
|
'hourly_rate': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0'
|
|
}),
|
|
'supervisor': forms.Select(attrs={'class': 'form-control'}),
|
|
'emergency_contact_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Emergency contact name'
|
|
}),
|
|
'emergency_contact_relationship': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Relationship to employee'
|
|
}),
|
|
'emergency_contact_phone': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Emergency contact phone'
|
|
}),
|
|
'date_of_birth': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'gender': forms.Select(attrs={'class': 'form-control'}),
|
|
'marital_status': forms.Select(attrs={'class': 'form-control'}),
|
|
'standard_hours_per_week': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.5',
|
|
'min': '0',
|
|
'max': '168'
|
|
}),
|
|
'fte_percentage': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '1',
|
|
'min': '0',
|
|
'max': '100'
|
|
}),
|
|
'license_number': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Professional license number'
|
|
}),
|
|
'license_expiry_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'certifications': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'List of certifications'
|
|
}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Additional notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'employee_number': 'Unique identifier for this employee',
|
|
'employment_status': 'Current employment status',
|
|
'employment_type': 'Full-time, part-time, contract, etc.',
|
|
'supervisor': 'Direct supervisor (if any)',
|
|
'annual_salary': 'Annual salary (for salaried employees)',
|
|
'hourly_rate': 'Hourly rate (for hourly employees)',
|
|
'fte_percentage': 'Full-time equivalent percentage (0-100)',
|
|
'license_number': 'Professional license number (if applicable)',
|
|
'license_expiry_date': 'License expiration date (if applicable)',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter department and supervisor by tenant
|
|
self.fields['department'].queryset = Department.objects.filter(
|
|
tenant=user.tenant
|
|
).order_by('name')
|
|
|
|
self.fields['supervisor'].queryset = Employee.objects.filter(
|
|
tenant=user.tenant,
|
|
employment_status='ACTIVE'
|
|
).order_by('last_name', 'first_name')
|
|
|
|
# Exclude current instance from supervisor choices
|
|
if self.instance.pk:
|
|
self.fields['supervisor'].queryset = self.fields['supervisor'].queryset.exclude(
|
|
pk=self.instance.pk
|
|
)
|
|
|
|
def clean_employee_number(self):
|
|
employee_number = self.cleaned_data.get('employee_number')
|
|
if employee_number:
|
|
# Check for uniqueness within tenant (excluding current instance)
|
|
queryset = Employee.objects.filter(employee_number=employee_number)
|
|
if self.instance.pk:
|
|
queryset = queryset.exclude(pk=self.instance.pk)
|
|
|
|
if queryset.exists():
|
|
raise ValidationError('Employee ID must be unique.')
|
|
|
|
return employee_number
|
|
|
|
def clean_hire_date(self):
|
|
hire_date = self.cleaned_data.get('hire_date')
|
|
if hire_date and hire_date > timezone.now().date():
|
|
raise ValidationError('Hire date cannot be in the future.')
|
|
return hire_date
|
|
|
|
def clean_termination_date(self):
|
|
termination_date = self.cleaned_data.get('termination_date')
|
|
hire_date = self.cleaned_data.get('hire_date')
|
|
|
|
if termination_date:
|
|
if hire_date and termination_date < hire_date:
|
|
raise ValidationError('Termination date cannot be before hire date.')
|
|
|
|
return termination_date
|
|
|
|
def clean_date_of_birth(self):
|
|
date_of_birth = self.cleaned_data.get('date_of_birth')
|
|
if date_of_birth:
|
|
# Check minimum age (typically 16 for employment)
|
|
min_age_date = timezone.now().date() - timedelta(days=16*365)
|
|
if date_of_birth > min_age_date:
|
|
raise ValidationError('Employee must be at least 16 years old.')
|
|
|
|
# Check maximum age (reasonable limit)
|
|
max_age_date = timezone.now().date() - timedelta(days=100*365)
|
|
if date_of_birth < max_age_date:
|
|
raise ValidationError('Please verify the date of birth.')
|
|
|
|
return date_of_birth
|
|
|
|
def clean_license_expiry_date(self):
|
|
license_expiry_date = self.cleaned_data.get('license_expiry_date')
|
|
license_number = self.cleaned_data.get('license_number')
|
|
|
|
if license_number and not license_expiry_date:
|
|
raise ValidationError('License expiry date is required when license number is provided.')
|
|
|
|
return license_expiry_date
|
|
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
employment_type = cleaned_data.get('employment_type')
|
|
annual_salary = cleaned_data.get('annual_salary')
|
|
hourly_rate = cleaned_data.get('hourly_rate')
|
|
|
|
# Validate salary/hourly rate based on employment type
|
|
if employment_type == 'FULL_TIME' and not annual_salary:
|
|
self.add_error('annual_salary', 'Annual salary is required for full-time employees.')
|
|
|
|
if employment_type == 'PART_TIME' and not hourly_rate:
|
|
self.add_error('hourly_rate', 'Hourly rate is required for part-time employees.')
|
|
|
|
return cleaned_data
|
|
|
|
|
|
class DepartmentForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating departments.
|
|
"""
|
|
|
|
class Meta:
|
|
model = Department
|
|
fields = [
|
|
'name', 'code', 'description', 'department_type',
|
|
'parent_department', 'department_head', 'annual_budget',
|
|
'cost_center', 'location', 'is_active', 'notes'
|
|
]
|
|
widgets = {
|
|
'name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Department name'
|
|
}),
|
|
'department_code': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Unique department code'
|
|
}),
|
|
'description': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Department description'
|
|
}),
|
|
'department_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'parent_department': forms.Select(attrs={'class': 'form-control'}),
|
|
'department_head': forms.Select(attrs={'class': 'form-control'}),
|
|
'annual_budget': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0'
|
|
}),
|
|
'cost_center': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Cost center code'
|
|
}),
|
|
'location': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Department location'
|
|
}),
|
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Additional notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'department_code': 'Unique code to identify this department',
|
|
'department_head': 'Department manager (optional)',
|
|
'annual_budget': 'Annual budget for this department',
|
|
'parent_department': 'Parent department (if any)',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter parent department and department head by tenant
|
|
self.fields['parent_department'].queryset = Department.objects.filter(
|
|
tenant=user.tenant
|
|
).order_by('name')
|
|
|
|
self.fields['department_head'].queryset = Employee.objects.filter(
|
|
tenant=user.tenant,
|
|
employment_status='ACTIVE'
|
|
).order_by('last_name', 'first_name')
|
|
|
|
# Exclude current instance from parent department choices
|
|
if self.instance.pk:
|
|
self.fields['parent_department'].queryset = self.fields['parent_department'].queryset.exclude(
|
|
pk=self.instance.pk
|
|
)
|
|
|
|
def clean_department_code(self):
|
|
department_code = self.cleaned_data.get('department_code')
|
|
if department_code:
|
|
# Check for uniqueness within tenant (excluding current instance)
|
|
queryset = Department.objects.filter(department_code=department_code)
|
|
if self.instance.pk:
|
|
queryset = queryset.exclude(pk=self.instance.pk)
|
|
|
|
if queryset.exists():
|
|
raise ValidationError('Department code must be unique.')
|
|
|
|
return department_code
|
|
|
|
|
|
class ScheduleForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating schedules.
|
|
"""
|
|
|
|
class Meta:
|
|
model = Schedule
|
|
fields = [
|
|
'employee', 'name', 'description', 'schedule_type',
|
|
'effective_date', 'end_date', 'schedule_pattern',
|
|
'is_active', 'notes'
|
|
]
|
|
widgets = {
|
|
'employee': forms.Select(attrs={'class': 'form-control'}),
|
|
'name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Schedule name'
|
|
}),
|
|
'description': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Schedule description'
|
|
}),
|
|
'schedule_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'effective_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'end_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'schedule_pattern': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 5,
|
|
'placeholder': '{"monday": {"start": "09:00", "end": "17:00"}, ...}'
|
|
}),
|
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Schedule notes or instructions'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'schedule_type': 'Type of schedule (regular, rotating, etc.)',
|
|
'effective_date': 'Date when this schedule becomes effective',
|
|
'end_date': 'Date when this schedule ends (leave blank for indefinite)',
|
|
'schedule_pattern': 'JSON configuration for schedule pattern',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter employee by tenant
|
|
self.fields['employee'].queryset = Employee.objects.filter(
|
|
tenant=user.tenant,
|
|
employment_status='ACTIVE'
|
|
).order_by('last_name', 'first_name')
|
|
|
|
def clean_end_date(self):
|
|
end_date = self.cleaned_data.get('end_date')
|
|
effective_date = self.cleaned_data.get('effective_date')
|
|
|
|
if end_date and effective_date:
|
|
if end_date < effective_date:
|
|
raise ValidationError('End date cannot be before effective date.')
|
|
|
|
return end_date
|
|
|
|
|
|
class ScheduleAssignmentForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating schedule assignments.
|
|
"""
|
|
|
|
class Meta:
|
|
model = ScheduleAssignment
|
|
fields = [
|
|
'schedule', 'assignment_date', 'start_time', 'end_time',
|
|
'shift_type', 'department', 'location', 'status',
|
|
'break_minutes', 'lunch_minutes', 'notes'
|
|
]
|
|
widgets = {
|
|
'schedule': forms.Select(attrs={'class': 'form-control'}),
|
|
'assignment_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'start_time': forms.TimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'time'
|
|
}),
|
|
'end_time': forms.TimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'time'
|
|
}),
|
|
'shift_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'department': forms.Select(attrs={'class': 'form-control'}),
|
|
'location': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Specific location'
|
|
}),
|
|
'status': forms.Select(attrs={'class': 'form-control'}),
|
|
'break_minutes': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '0',
|
|
'max': '480' # 8 hours max
|
|
}),
|
|
'lunch_minutes': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '0',
|
|
'max': '480' # 8 hours max
|
|
}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 2,
|
|
'placeholder': 'Assignment notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'break_minutes': 'Break duration in minutes',
|
|
'lunch_minutes': 'Lunch duration in minutes',
|
|
'shift_type': 'Type of shift',
|
|
'status': 'Current status of the assignment',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter schedule and department by tenant
|
|
self.fields['schedule'].queryset = Schedule.objects.filter(
|
|
employee__tenant=user.tenant,
|
|
is_active=True
|
|
).order_by('-effective_date')
|
|
|
|
self.fields['department'].queryset = Department.objects.filter(
|
|
tenant=user.tenant,
|
|
is_active=True
|
|
).order_by('name')
|
|
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
start_time = cleaned_data.get('start_time')
|
|
end_time = cleaned_data.get('end_time')
|
|
|
|
if start_time and end_time:
|
|
# Handle overnight shifts
|
|
if end_time < start_time:
|
|
# This is valid for overnight shifts
|
|
pass
|
|
elif end_time == start_time:
|
|
self.add_error('end_time', 'End time cannot be the same as start time.')
|
|
|
|
return cleaned_data
|
|
|
|
|
|
class TimeEntryForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating time entries.
|
|
"""
|
|
|
|
class Meta:
|
|
model = TimeEntry
|
|
fields = [
|
|
'employee', 'work_date', 'clock_in_time', 'clock_out_time',
|
|
'break_start_time', 'break_end_time', 'lunch_start_time',
|
|
'lunch_end_time', 'entry_type', 'department', 'location',
|
|
'status', 'notes'
|
|
]
|
|
widgets = {
|
|
'employee': forms.Select(attrs={'class': 'form-control'}),
|
|
'work_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'clock_in_time': forms.DateTimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'datetime-local'
|
|
}),
|
|
'clock_out_time': forms.DateTimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'datetime-local'
|
|
}),
|
|
'break_start_time': forms.DateTimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'datetime-local'
|
|
}),
|
|
'break_end_time': forms.DateTimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'datetime-local'
|
|
}),
|
|
'lunch_start_time': forms.DateTimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'datetime-local'
|
|
}),
|
|
'lunch_end_time': forms.DateTimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'datetime-local'
|
|
}),
|
|
'entry_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'department': forms.Select(attrs={'class': 'form-control'}),
|
|
'location': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Work location'
|
|
}),
|
|
'status': forms.Select(attrs={'class': 'form-control'}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 2,
|
|
'placeholder': 'Time entry notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'work_date': 'Date of work',
|
|
'clock_in_time': 'Clock in date and time',
|
|
'clock_out_time': 'Clock out date and time',
|
|
'entry_type': 'Type of time entry',
|
|
'status': 'Current status of the time entry',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter employee and department by tenant
|
|
self.fields['employee'].queryset = Employee.objects.filter(
|
|
tenant=user.tenant,
|
|
employment_status='ACTIVE'
|
|
).order_by('last_name', 'first_name')
|
|
|
|
self.fields['department'].queryset = Department.objects.filter(
|
|
tenant=user.tenant,
|
|
is_active=True
|
|
).order_by('name')
|
|
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
clock_in_time = cleaned_data.get('clock_in_time')
|
|
clock_out_time = cleaned_data.get('clock_out_time')
|
|
break_start_time = cleaned_data.get('break_start_time')
|
|
break_end_time = cleaned_data.get('break_end_time')
|
|
lunch_start_time = cleaned_data.get('lunch_start_time')
|
|
lunch_end_time = cleaned_data.get('lunch_end_time')
|
|
|
|
if clock_in_time and clock_out_time:
|
|
if clock_out_time <= clock_in_time:
|
|
self.add_error('clock_out_time', 'Clock out time must be after clock in time.')
|
|
|
|
# Check for reasonable shift length (max 24 hours)
|
|
duration = clock_out_time - clock_in_time
|
|
if duration.total_seconds() > 24 * 3600:
|
|
self.add_error('clock_out_time', 'Shift duration cannot exceed 24 hours.')
|
|
|
|
# Validate break times
|
|
if break_start_time and not break_end_time:
|
|
self.add_error('break_end_time', 'Break end time is required if break start time is provided.')
|
|
|
|
if break_end_time and not break_start_time:
|
|
self.add_error('break_start_time', 'Break start time is required if break end time is provided.')
|
|
|
|
if break_start_time and break_end_time:
|
|
if break_end_time <= break_start_time:
|
|
self.add_error('break_end_time', 'Break end time must be after break start time.')
|
|
|
|
if clock_in_time and break_start_time < clock_in_time:
|
|
self.add_error('break_start_time', 'Break start time must be after clock in time.')
|
|
|
|
if clock_out_time and break_end_time > clock_out_time:
|
|
self.add_error('break_end_time', 'Break end time must be before clock out time.')
|
|
|
|
# Validate lunch times
|
|
if lunch_start_time and not lunch_end_time:
|
|
self.add_error('lunch_end_time', 'Lunch end time is required if lunch start time is provided.')
|
|
|
|
if lunch_end_time and not lunch_start_time:
|
|
self.add_error('lunch_start_time', 'Lunch start time is required if lunch end time is provided.')
|
|
|
|
if lunch_start_time and lunch_end_time:
|
|
if lunch_end_time <= lunch_start_time:
|
|
self.add_error('lunch_end_time', 'Lunch end time must be after lunch start time.')
|
|
|
|
if clock_in_time and lunch_start_time < clock_in_time:
|
|
self.add_error('lunch_start_time', 'Lunch start time must be after clock in time.')
|
|
|
|
if clock_out_time and lunch_end_time > clock_out_time:
|
|
self.add_error('lunch_end_time', 'Lunch end time must be before clock out time.')
|
|
|
|
return cleaned_data
|
|
|
|
|
|
class PerformanceReviewForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating performance reviews.
|
|
"""
|
|
|
|
class Meta:
|
|
model = PerformanceReview
|
|
fields = [
|
|
'employee', 'review_period_start', 'review_period_end',
|
|
'review_date', 'review_type', 'overall_rating',
|
|
'competency_ratings', 'goals_achieved', 'goals_not_achieved',
|
|
'future_goals', 'strengths', 'areas_for_improvement',
|
|
'development_plan', 'training_recommendations',
|
|
'employee_comments', 'employee_signature_date',
|
|
'status', 'notes'
|
|
]
|
|
widgets = {
|
|
'employee': forms.Select(attrs={'class': 'form-control'}),
|
|
'review_period_start': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'review_period_end': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'review_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'review_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'overall_rating': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.1',
|
|
'min': '1',
|
|
'max': '5'
|
|
}),
|
|
'competency_ratings': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 5,
|
|
'placeholder': '{"communication": 4, "teamwork": 5, ...}'
|
|
}),
|
|
'goals_achieved': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Goals achieved during review period'
|
|
}),
|
|
'goals_not_achieved': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Goals not achieved during review period'
|
|
}),
|
|
'future_goals': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Goals for next review period'
|
|
}),
|
|
'strengths': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Employee strengths'
|
|
}),
|
|
'areas_for_improvement': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Areas for improvement'
|
|
}),
|
|
'development_plan': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Professional development plan'
|
|
}),
|
|
'training_recommendations': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Training recommendations'
|
|
}),
|
|
'employee_comments': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Employee comments'
|
|
}),
|
|
'employee_signature_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'status': forms.Select(attrs={'class': 'form-control'}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Additional notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'review_type': 'Type of performance review',
|
|
'overall_rating': 'Overall rating (1-5)',
|
|
'competency_ratings': 'Individual competency ratings as JSON',
|
|
'status': 'Current status of the review',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter employee by tenant
|
|
self.fields['employee'].queryset = Employee.objects.filter(
|
|
tenant=user.tenant,
|
|
employment_status='ACTIVE'
|
|
).order_by('last_name', 'first_name')
|
|
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
review_period_start = cleaned_data.get('review_period_start')
|
|
review_period_end = cleaned_data.get('review_period_end')
|
|
review_date = cleaned_data.get('review_date')
|
|
status = cleaned_data.get('status')
|
|
employee_signature_date = cleaned_data.get('employee_signature_date')
|
|
|
|
if review_period_start and review_period_end:
|
|
if review_period_end < review_period_start:
|
|
self.add_error('review_period_end', 'Review period end date must be after start date.')
|
|
|
|
if review_date and review_period_end:
|
|
if review_date < review_period_end:
|
|
self.add_error('review_date', 'Review date should be on or after the review period end date.')
|
|
|
|
if status == 'ACKNOWLEDGED' and not employee_signature_date:
|
|
self.add_error('employee_signature_date', 'Employee signature date is required for acknowledged reviews.')
|
|
|
|
return cleaned_data
|
|
|
|
|
|
class TrainingRecordForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating training records.
|
|
"""
|
|
|
|
class Meta:
|
|
model = TrainingRecord
|
|
fields = [
|
|
'employee', 'training_name', 'training_description',
|
|
'training_type', 'training_provider', 'instructor',
|
|
'training_date', 'completion_date', 'expiry_date',
|
|
'duration_hours', 'credits_earned', 'status',
|
|
'score', 'passed', 'certificate_number',
|
|
'certification_body', 'training_cost', 'notes'
|
|
]
|
|
widgets = {
|
|
'employee': forms.Select(attrs={'class': 'form-control'}),
|
|
'training_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Training program name'
|
|
}),
|
|
'training_description': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Training description'
|
|
}),
|
|
'training_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'training_provider': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Training provider/organization'
|
|
}),
|
|
'instructor': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Instructor name'
|
|
}),
|
|
'training_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'completion_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'expiry_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'duration_hours': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.5',
|
|
'min': '0'
|
|
}),
|
|
'credits_earned': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.1',
|
|
'min': '0'
|
|
}),
|
|
'status': forms.Select(attrs={'class': 'form-control'}),
|
|
'score': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0',
|
|
'max': '100'
|
|
}),
|
|
'passed': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'certificate_number': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Certificate or credential number'
|
|
}),
|
|
'certification_body': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Certification organization'
|
|
}),
|
|
'training_cost': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0'
|
|
}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Training notes and comments'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'training_type': 'Type of training (mandatory, optional, certification, etc.)',
|
|
'duration_hours': 'Training duration in hours',
|
|
'credits_earned': 'Continuing education credits earned',
|
|
'training_cost': 'Training cost',
|
|
'expiry_date': 'Certification expiry date (if applicable)',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter employee by tenant
|
|
self.fields['employee'].queryset = Employee.objects.filter(
|
|
tenant=user.tenant,
|
|
employment_status='ACTIVE'
|
|
).order_by('last_name', 'first_name')
|
|
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
training_date = cleaned_data.get('training_date')
|
|
completion_date = cleaned_data.get('completion_date')
|
|
expiry_date = cleaned_data.get('expiry_date')
|
|
status = cleaned_data.get('status')
|
|
passed = cleaned_data.get('passed')
|
|
certificate_number = cleaned_data.get('certificate_number')
|
|
|
|
if completion_date and training_date:
|
|
if completion_date < training_date:
|
|
self.add_error('completion_date', 'Completion date cannot be before training date.')
|
|
|
|
if expiry_date and completion_date:
|
|
if expiry_date <= completion_date:
|
|
self.add_error('expiry_date', 'Expiry date must be after completion date.')
|
|
|
|
if status == 'COMPLETED' and not completion_date:
|
|
self.add_error('completion_date', 'Completion date is required for completed training.')
|
|
|
|
if passed and status not in ['COMPLETED', 'PASSED']:
|
|
self.add_error('passed', 'Training must be completed or passed to mark as passed.')
|
|
|
|
if certificate_number and not passed:
|
|
self.add_error('certificate_number', 'Certificate number can only be provided for passed training.')
|
|
|
|
return cleaned_data
|
|
|