3e969e29dc
Модель и сервис событий календаря, вью и страница календаря. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
366 lines
13 KiB
Python
366 lines
13 KiB
Python
from django import forms
|
|
from django.contrib import admin
|
|
from django.utils.html import format_html
|
|
from django.utils.safestring import mark_safe
|
|
from django.utils.translation import gettext_lazy as _
|
|
from unfold.admin import ModelAdmin, TabularInline
|
|
|
|
from .models import (
|
|
Shift,
|
|
Holiday,
|
|
CalendarEvent,
|
|
ScheduleTemplate, SchedulePattern, ScheduleException,
|
|
WorkSchedule,
|
|
AttendanceLog, WorkShift,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shifts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@admin.register(Shift)
|
|
class ShiftAdmin(ModelAdmin):
|
|
list_display = ['name', 'shift_type', 'time_display', 'duration_hours',
|
|
'is_night_shift', 'has_lunch_break', 'is_active', 'color_display']
|
|
list_filter = ['shift_type', 'is_day_off', 'is_night_shift', 'is_flexible', 'is_active']
|
|
search_fields = ['name', 'description']
|
|
ordering = ['shift_type', 'start_time']
|
|
|
|
fieldsets = (
|
|
(_('Основне'), {
|
|
'fields': ('name', 'shift_type', 'is_active', 'color', 'description'),
|
|
}),
|
|
(_('Час роботи'), {
|
|
'fields': ('start_time', 'end_time', 'duration_hours',
|
|
'is_day_off', 'is_night_shift', 'is_flexible'),
|
|
}),
|
|
(_('Обідня перерва'), {
|
|
'fields': ('has_lunch_break', 'lunch_break_start', 'lunch_break_duration'),
|
|
}),
|
|
(_('Понаднормові'), {
|
|
'fields': ('min_hours', 'max_hours', 'overtime_threshold'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
)
|
|
readonly_fields = ['duration_hours']
|
|
|
|
def time_display(self, obj):
|
|
if obj.is_day_off:
|
|
return '—'
|
|
if obj.start_time and obj.end_time:
|
|
return f"{obj.start_time:%H:%M} — {obj.end_time:%H:%M}"
|
|
return '—'
|
|
time_display.short_description = _('Час')
|
|
|
|
def color_display(self, obj):
|
|
return format_html(
|
|
'<span style="display:inline-block;width:16px;height:16px;'
|
|
'border-radius:3px;background:{};vertical-align:middle;"></span> {}',
|
|
obj.color, obj.color
|
|
)
|
|
color_display.short_description = _('Колір')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar events
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@admin.register(CalendarEvent)
|
|
class CalendarEventAdmin(ModelAdmin):
|
|
list_display = [
|
|
'title', 'event_type', 'date_from', 'date_to',
|
|
'visibility', 'created_by',
|
|
]
|
|
list_filter = ['event_type', 'visibility', 'date_from']
|
|
search_fields = ['title', 'notes', 'location', 'created_by__last_name']
|
|
list_select_related = ['created_by']
|
|
ordering = ['-date_from']
|
|
date_hierarchy = 'date_from'
|
|
autocomplete_fields = ['created_by']
|
|
|
|
fieldsets = (
|
|
(_('Событие'), {
|
|
'fields': ('title', 'event_type', 'visibility', 'created_by'),
|
|
}),
|
|
(_('Сроки'), {
|
|
'fields': ('date_from', 'date_to', 'time_from', 'time_to'),
|
|
}),
|
|
(_('Детали'), {
|
|
'fields': ('location', 'notes'),
|
|
}),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Holidays
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@admin.register(Holiday)
|
|
class HolidayAdmin(ModelAdmin):
|
|
list_display = ['name', 'date', 'holiday_type', 'is_working_day',
|
|
'is_national', 'is_recurring', 'days_until_display']
|
|
list_filter = ['holiday_type', 'is_working_day', 'is_national', 'is_recurring']
|
|
search_fields = ['name', 'description']
|
|
ordering = ['date']
|
|
date_hierarchy = 'date'
|
|
|
|
fieldsets = (
|
|
(_('Основне'), {
|
|
'fields': ('name', 'date', 'holiday_type'),
|
|
}),
|
|
(_('Характеристики'), {
|
|
'fields': ('is_working_day', 'is_national', 'is_recurring',
|
|
'affects_schedule', 'overtime_rate'),
|
|
}),
|
|
(_('Відображення'), {
|
|
'fields': ('color', 'icon'),
|
|
}),
|
|
(_('Опис'), {
|
|
'fields': ('description', 'traditions'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
(_('Регіони'), {
|
|
'fields': ('countries', 'regions'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
)
|
|
|
|
def days_until_display(self, obj):
|
|
days = obj.days_until
|
|
if days is None:
|
|
return mark_safe('<span style="color:#9ca3af;">Пройшло</span>')
|
|
if days == 0:
|
|
return mark_safe('<span style="color:#16a34a;font-weight:bold;">Сьогодні</span>')
|
|
if days <= 7:
|
|
return format_html('<span style="color:#ca8a04;">{} дн.</span>', days)
|
|
return f"{days} дн."
|
|
days_until_display.short_description = _('Залишилось')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schedule templates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SchedulePatternInline(TabularInline):
|
|
model = SchedulePattern
|
|
extra = 0
|
|
fields = ('shift', 'weekday', 'cycle_day', 'is_override', 'notes')
|
|
|
|
|
|
class ScheduleExceptionInline(TabularInline):
|
|
model = ScheduleException
|
|
extra = 0
|
|
fields = ('employee', 'date', 'exception_type', 'shift', 'reason', 'is_approved', 'approved_by')
|
|
readonly_fields = ['approved_by']
|
|
|
|
|
|
@admin.register(ScheduleTemplate)
|
|
class ScheduleTemplateAdmin(ModelAdmin):
|
|
list_display = ['name', 'template_type', 'work_days_per_week',
|
|
'total_hours_per_week', 'is_default', 'is_active']
|
|
list_filter = ['template_type', 'is_active', 'is_default', 'allow_overtime']
|
|
search_fields = ['name', 'description']
|
|
ordering = ['name']
|
|
filter_horizontal = ['suitable_departments']
|
|
|
|
fieldsets = (
|
|
(_('Основне'), {
|
|
'fields': ('name', 'template_type', 'description', 'is_active', 'is_default'),
|
|
}),
|
|
(_('Час'), {
|
|
'fields': ('default_start_time', 'default_end_time',
|
|
'min_daily_hours', 'max_daily_hours'),
|
|
}),
|
|
(_('Тиждень'), {
|
|
'fields': ('work_days_per_week', 'total_hours_per_week'),
|
|
}),
|
|
(_('Цикл (для змінного графіку)'), {
|
|
'fields': ('cycle_length_days', 'cycle_start_day'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
(_('Налаштування'), {
|
|
'fields': ('allow_overtime', 'auto_lunch_break', 'lunch_break_duration'),
|
|
}),
|
|
(_('Придатність'), {
|
|
'fields': ('suitable_location_types', 'suitable_departments'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
)
|
|
|
|
inlines = [SchedulePatternInline, ScheduleExceptionInline]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Work schedules
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@admin.register(WorkSchedule)
|
|
class WorkScheduleAdmin(ModelAdmin):
|
|
list_display = ['employee', 'schedule_template', 'location',
|
|
'start_date', 'end_date', 'is_primary', 'is_active', 'is_current']
|
|
list_filter = ['is_active', 'is_primary', 'schedule_template']
|
|
search_fields = ['employee__last_name', 'employee__first_name', 'notes']
|
|
list_select_related = ['employee', 'schedule_template', 'location']
|
|
ordering = ['-start_date']
|
|
date_hierarchy = 'start_date'
|
|
|
|
fieldsets = (
|
|
(_('Співробітник та шаблон'), {
|
|
'fields': ('employee', 'schedule_template', 'location'),
|
|
}),
|
|
(_('Період'), {
|
|
'fields': ('start_date', 'end_date', 'is_active', 'is_primary'),
|
|
}),
|
|
(_('Налаштування відміток'), {
|
|
'fields': ('allow_early_checkin', 'early_checkin_minutes',
|
|
'allow_late_checkout', 'late_checkout_minutes'),
|
|
}),
|
|
(_('Примітки'), {
|
|
'fields': ('notes',),
|
|
'classes': ('collapse',),
|
|
}),
|
|
)
|
|
readonly_fields = ['created_by']
|
|
|
|
def is_current(self, obj):
|
|
if obj.is_current:
|
|
return mark_safe('<span style="color:#16a34a;">●</span> Активний')
|
|
return mark_safe('<span style="color:#9ca3af;">●</span> Неактивний')
|
|
is_current.short_description = _('Статус')
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not obj.pk:
|
|
obj.created_by = request.user
|
|
super().save_model(request, obj, form, change)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Attendance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LocationChoiceField(forms.ModelChoiceField):
|
|
def label_from_instance(self, obj):
|
|
type_label = obj.get_location_type_display()
|
|
addr = obj.get_full_address() or obj.address or ''
|
|
if obj.location_type == 'pharmacy':
|
|
try:
|
|
num = obj.pharmacy.pharmacy_number
|
|
return f"Аптека №{num} — {addr}"
|
|
except Exception:
|
|
pass
|
|
return f"{type_label} — {addr}" if addr else type_label
|
|
|
|
|
|
class AttendanceLogForm(forms.ModelForm):
|
|
location = LocationChoiceField(
|
|
queryset=None,
|
|
label=_('Локация'),
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
from apps.locations.models import Location
|
|
self.fields['location'].queryset = (
|
|
Location.objects
|
|
.filter(is_active=True)
|
|
.select_related('location_region')
|
|
.prefetch_related('pharmacy')
|
|
.order_by('location_type', 'address')
|
|
)
|
|
|
|
class Meta:
|
|
model = AttendanceLog
|
|
fields = '__all__'
|
|
|
|
|
|
@admin.register(AttendanceLog)
|
|
class AttendanceLogAdmin(ModelAdmin):
|
|
form = AttendanceLogForm
|
|
list_display = ['employee', 'location', 'direction_display',
|
|
'timestamp', 'location_type_display', 'has_photo', 'synced']
|
|
list_filter = ['direction', 'synced', 'timestamp', 'location__location_type']
|
|
search_fields = ['employee__last_name', 'employee__first_name', 'device_id']
|
|
list_select_related = ['employee', 'location']
|
|
ordering = ['-timestamp']
|
|
date_hierarchy = 'timestamp'
|
|
readonly_fields = ['created_at', 'photo_preview']
|
|
|
|
fieldsets = (
|
|
(_('Основне'), {
|
|
'fields': ('employee', 'location', 'direction', 'timestamp'),
|
|
}),
|
|
(_('Фото'), {
|
|
'fields': ('photo', 'photo_preview'),
|
|
}),
|
|
(_('Технічне'), {
|
|
'fields': ('device_id', 'ip_address', 'synced', 'created_at'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
)
|
|
|
|
def direction_display(self, obj):
|
|
color = '#16a34a' if obj.direction == 'in' else '#dc2626'
|
|
return format_html(
|
|
'<span style="color:{}; font-weight:bold;">{}</span>',
|
|
color, obj.get_direction_display()
|
|
)
|
|
direction_display.short_description = _('Направлення')
|
|
direction_display.admin_order_field = 'direction'
|
|
|
|
def location_type_display(self, obj):
|
|
icons = {'pharmacy': '🏥', 'warehouse': '🏭', 'office': '🏢'}
|
|
lt = obj.location.location_type
|
|
return f"{icons.get(lt, '')} {obj.location.get_location_type_display()}"
|
|
location_type_display.short_description = _('Тип локації')
|
|
|
|
def has_photo(self, obj):
|
|
return bool(obj.photo)
|
|
has_photo.boolean = True
|
|
has_photo.short_description = _('Фото')
|
|
|
|
def photo_preview(self, obj):
|
|
b64 = obj.photo_thumb_b64
|
|
if b64:
|
|
return format_html('<img src="{}" style="width:64px;height:64px;object-fit:cover;">', b64)
|
|
return '—'
|
|
photo_preview.short_description = _('Превью')
|
|
|
|
|
|
@admin.register(WorkShift)
|
|
class WorkShiftAdmin(ModelAdmin):
|
|
list_display = ['employee', 'location', 'date', 'arrived_at',
|
|
'left_at', 'work_hours_display', 'is_complete_display']
|
|
list_filter = ['date', 'location']
|
|
search_fields = ['employee__last_name', 'employee__first_name']
|
|
list_select_related = ['employee', 'location']
|
|
ordering = ['-date']
|
|
date_hierarchy = 'date'
|
|
readonly_fields = ['arrived_at', 'left_at', 'duration', 'log_in', 'log_out']
|
|
|
|
fieldsets = (
|
|
(_('Основне'), {
|
|
'fields': ('employee', 'location', 'date'),
|
|
}),
|
|
(_('Час'), {
|
|
'fields': ('arrived_at', 'left_at', 'duration'),
|
|
}),
|
|
(_('Записи'), {
|
|
'fields': ('log_in', 'log_out'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
)
|
|
|
|
def work_hours_display(self, obj):
|
|
h = obj.work_hours
|
|
if h is None:
|
|
return '—'
|
|
color = '#dc2626' if h > 9 else '#16a34a'
|
|
return format_html('<span style="color:{};">{:.1f} год.</span>', color, h)
|
|
work_hours_display.short_description = _('Відпрацьовано')
|
|
|
|
def is_complete_display(self, obj):
|
|
return obj.is_complete
|
|
is_complete_display.boolean = True
|
|
is_complete_display.short_description = _('Повна зміна')
|