From d4edea855ce5a672aee6dedfaf4763aef8b3877d Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 19 Aug 2026 14:05:02 +0300 Subject: [PATCH] Refactor application workflows and update UI components --- .../schedule_serializers/calendars.py | 12 +- backend/api/v1/schedule/viewsets/calendars.py | 38 ++ backend/api/v1/schedule/viewsets/common.py | 13 +- .../0003_calendarbinding_access_scope.py | 24 + backend/scheduling/models.py | 11 + backend/scheduling/realtime.py | 8 +- backend/scheduling/services.py | 15 + backend/tests/test_schedule_permissions.py | 60 +++ frontend/src/app/equipment/page.tsx | 49 +- frontend/src/app/finances/page.tsx | 10 +- frontend/src/app/globals.css | 25 +- frontend/src/app/guests/page.tsx | 30 +- frontend/src/app/notifications/page.tsx | 37 +- frontend/src/app/procurement/page.tsx | 54 +-- frontend/src/components/BigCalendar.tsx | 55 +-- .../components/CalendarParticipantsModal.tsx | 448 ++++++++---------- .../finance/PayrollDailyWorkSection.tsx | 53 +-- .../finance/admin/PayrollAdminModals.tsx | 22 +- .../finance/admin/PayrollAdminTables.tsx | 37 +- .../finance/admin/PayrollAdminWorkspace.tsx | 114 +++-- .../admin/PayrollAttendanceWorkModal.tsx | 10 +- .../finance/admin/PayrollPeriodMenu.tsx | 36 ++ .../finance/admin/PayrollPeriodTable.tsx | 80 +++- .../admin/PayrollPointBreakdownModal.tsx | 21 +- .../admin/PayrollPointSourceDetailModals.tsx | 7 +- .../admin/PayrollWorkbookImportModal.tsx | 4 +- .../src/components/layout/CalendarModals.tsx | 1 + .../messages/MessagesChatListControls.tsx | 27 +- .../requests/RequestListControls.tsx | 80 ++-- .../components/tasks/TaskBoardNavigator.tsx | 37 +- .../src/components/ui/CompactFilterMenu.tsx | 2 + .../components/ui/HorizontalChipScroller.tsx | 39 +- .../src/components/ui/ProjectSelectMenu.tsx | 230 +++++++++ .../src/hooks/usePayrollDesktopWideMode.ts | 21 +- frontend/src/lib/api/calendar.ts | 4 +- frontend/src/lib/calendar/ui.ts | 2 + frontend/src/lib/payroll-attendance.test.js | 12 +- frontend/src/lib/payroll-attendance.ts | 3 +- frontend/src/lib/payroll.ts | 24 + frontend/src/types/api.ts | 1 + 40 files changed, 1135 insertions(+), 621 deletions(-) create mode 100644 backend/scheduling/migrations/0003_calendarbinding_access_scope.py create mode 100644 frontend/src/components/finance/admin/PayrollPeriodMenu.tsx create mode 100644 frontend/src/components/ui/ProjectSelectMenu.tsx diff --git a/backend/api/v1/schedule/schedule_serializers/calendars.py b/backend/api/v1/schedule/schedule_serializers/calendars.py index cd20a72f..887c3912 100644 --- a/backend/api/v1/schedule/schedule_serializers/calendars.py +++ b/backend/api/v1/schedule/schedule_serializers/calendars.py @@ -14,6 +14,7 @@ from schedule.models import ( from scheduling.services import ( get_calendar_binding, + is_company_wide_calendar, user_can_create_event, user_can_edit_calendar, user_can_manage_participants, @@ -111,6 +112,7 @@ class CalendarSerializer(serializers.ModelSerializer): context_type = serializers.SerializerMethodField() context_object_id = serializers.SerializerMethodField() flags = serializers.SerializerMethodField() + access_scope = serializers.SerializerMethodField() can_create_events = serializers.SerializerMethodField() can_edit_calendar = serializers.SerializerMethodField() can_manage_participants = serializers.SerializerMethodField() @@ -127,6 +129,7 @@ class CalendarSerializer(serializers.ModelSerializer): "context_type", "context_object_id", "flags", + "access_scope", "owner", "participants", "user_role", @@ -177,6 +180,11 @@ class CalendarSerializer(serializers.ModelSerializer): binding = get_calendar_binding(obj) return binding.flags if binding else {} + @extend_schema_field(serializers.CharField()) + def get_access_scope(self, obj): + binding = get_calendar_binding(obj) + return binding.access_scope if binding else "restricted" + @extend_schema_field(RelatedUserSummarySerializer(allow_null=True)) def get_owner(self, obj): """Получить владельца календаря.""" @@ -247,7 +255,9 @@ class CalendarSerializer(serializers.ModelSerializer): calendar=obj, content_type=ct, object_id=request.user.id ).first() - return relation.distinction if relation else None + if relation: + return relation.distinction + return "viewer" if is_company_wide_calendar(obj) else None @extend_schema_field(serializers.BooleanField()) def get_can_create_events(self, obj): diff --git a/backend/api/v1/schedule/viewsets/calendars.py b/backend/api/v1/schedule/viewsets/calendars.py index f662bbed..147ee144 100644 --- a/backend/api/v1/schedule/viewsets/calendars.py +++ b/backend/api/v1/schedule/viewsets/calendars.py @@ -114,6 +114,44 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet): defaults={"type": CalendarBinding.BindingType.DEFAULT}, ) + @action(detail=True, methods=["patch"], url_path="access-scope") + def access_scope(self, request, pk=None): + """Изменить общий режим доступа к календарю.""" + calendar = self.get_object() + binding, _ = CalendarBinding.objects.get_or_create( + calendar=calendar, + defaults={"type": CalendarBinding.BindingType.DEFAULT}, + ) + + if binding.type == CalendarBinding.BindingType.DEPARTMENT: + return Response( + {"detail": "Доступ к календарю отдела управляется автоматически."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not user_can_manage_participants(request.user, calendar): + return Response( + {"detail": "Только владелец может изменять доступ к календарю."}, + status=status.HTTP_403_FORBIDDEN, + ) + + access_scope = request.data.get("access_scope") + allowed_scopes = { + CalendarBinding.AccessScope.ALL, + CalendarBinding.AccessScope.RESTRICTED, + } + if access_scope not in allowed_scopes: + return Response( + {"access_scope": "Выберите доступ для всей компании или участников."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if binding.access_scope != access_scope: + binding.access_scope = access_scope + binding.save(update_fields=["access_scope", "updated_at"]) + + calendar.refresh_from_db() + return Response(self.get_serializer(calendar).data) + @action(detail=True, methods=["post"], url_path="add-participant") def add_participant(self, request, pk=None): """Добавить участника к календарю. diff --git a/backend/api/v1/schedule/viewsets/common.py b/backend/api/v1/schedule/viewsets/common.py index 3d6f5456..06d1ed9a 100644 --- a/backend/api/v1/schedule/viewsets/common.py +++ b/backend/api/v1/schedule/viewsets/common.py @@ -5,6 +5,7 @@ ViewSets для django-scheduler (чистый, проверенный код). """ from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from schedule.models import ( CalendarRelation, ) @@ -19,10 +20,20 @@ def _admin_requested_all_scope(request) -> bool: def _user_accessible_calendar_ids(user): from django.contrib.auth import get_user_model + from schedule.models import Calendar + + from scheduling.models import CalendarBinding User = get_user_model() ct = ContentType.objects.get_for_model(User) - return CalendarRelation.objects.filter( + relation_ids = CalendarRelation.objects.filter( content_type=ct, object_id=user.id, ).values_list("calendar_id", flat=True) + return Calendar.objects.filter( + Q(id__in=relation_ids) + | Q( + binding__type=CalendarBinding.BindingType.DEFAULT, + binding__access_scope=CalendarBinding.AccessScope.ALL, + ) + ).values_list("id", flat=True) diff --git a/backend/scheduling/migrations/0003_calendarbinding_access_scope.py b/backend/scheduling/migrations/0003_calendarbinding_access_scope.py new file mode 100644 index 00000000..19ec6c50 --- /dev/null +++ b/backend/scheduling/migrations/0003_calendarbinding_access_scope.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("scheduling", "0002_calendarbinding"), + ] + + operations = [ + migrations.AddField( + model_name="calendarbinding", + name="access_scope", + field=models.CharField( + choices=[ + ("restricted", "Только участники"), + ("all", "Вся компания"), + ], + db_index=True, + default="restricted", + max_length=16, + verbose_name="Доступ", + ), + ), + ] diff --git a/backend/scheduling/models.py b/backend/scheduling/models.py index bd22c1e2..5ad103a8 100644 --- a/backend/scheduling/models.py +++ b/backend/scheduling/models.py @@ -9,6 +9,10 @@ class CalendarBinding(models.Model): DEFAULT = "default", "Обычный" DEPARTMENT = "department", "Отдел" + class AccessScope(models.TextChoices): + RESTRICTED = "restricted", "Только участники" + ALL = "all", "Вся компания" + calendar = models.OneToOneField( "schedule.Calendar", on_delete=models.CASCADE, @@ -22,6 +26,13 @@ class CalendarBinding(models.Model): db_index=True, verbose_name="Тип привязки", ) + access_scope = models.CharField( + max_length=16, + choices=AccessScope.choices, + default=AccessScope.RESTRICTED, + db_index=True, + verbose_name="Доступ", + ) context_content_type = models.ForeignKey( ContentType, null=True, diff --git a/backend/scheduling/realtime.py b/backend/scheduling/realtime.py index 75040463..6cd78805 100644 --- a/backend/scheduling/realtime.py +++ b/backend/scheduling/realtime.py @@ -14,6 +14,7 @@ from django.utils.dateparse import parse_date, parse_datetime from schedule.models import Calendar, CalendarRelation, Event, EventRelation from schedule.periods import Period +from scheduling.models import CalendarBinding from scheduling.services import is_department_calendar logger = logging.getLogger(__name__) @@ -110,7 +111,12 @@ def _visible_event_queryset(*, user, subscription: CalendarSubscription): object_id=user.id, ).values_list("event_id", flat=True) queryset = queryset.filter( - Q(calendar_id__in=calendar_ids) | Q(id__in=participant_event_ids) + Q(calendar_id__in=calendar_ids) + | Q(id__in=participant_event_ids) + | Q( + calendar__binding__type=CalendarBinding.BindingType.DEFAULT, + calendar__binding__access_scope=CalendarBinding.AccessScope.ALL, + ) ) if subscription.calendar_id is not None: diff --git a/backend/scheduling/services.py b/backend/scheduling/services.py index eda0a567..88a26429 100644 --- a/backend/scheduling/services.py +++ b/backend/scheduling/services.py @@ -33,6 +33,15 @@ def is_department_calendar(calendar: Calendar | None) -> bool: ) +def is_company_wide_calendar(calendar: Calendar | None) -> bool: + binding = get_calendar_binding(calendar) + return bool( + binding + and binding.type == CalendarBinding.BindingType.DEFAULT + and binding.access_scope == CalendarBinding.AccessScope.ALL + ) + + def get_department_calendar_binding( department: Department, ) -> CalendarBinding | None: @@ -323,6 +332,8 @@ def user_can_view_calendar(user, calendar: Calendar) -> bool: return True if is_department_calendar(calendar): return True + if is_company_wide_calendar(calendar): + return True return user_relation_distinction(calendar, user) is not None @@ -351,6 +362,10 @@ def filter_events_for_user( Q(calendar_id__in=calendar_ids) | Q(id__in=participant_event_ids) | Q(calendar__binding__type=CalendarBinding.BindingType.DEPARTMENT) + | Q( + calendar__binding__type=CalendarBinding.BindingType.DEFAULT, + calendar__binding__access_scope=CalendarBinding.AccessScope.ALL, + ) ).distinct() diff --git a/backend/tests/test_schedule_permissions.py b/backend/tests/test_schedule_permissions.py index 66a9083e..9ed6048b 100644 --- a/backend/tests/test_schedule_permissions.py +++ b/backend/tests/test_schedule_permissions.py @@ -141,6 +141,66 @@ class TestCalendarPermissions: assert len(calendars) == 1 assert calendars[0]['name'] == 'User1 Calendar' + + def test_owner_can_make_calendar_available_to_entire_company( + self, api_client, user1, user2 + ): + api_client.force_authenticate(user=user1) + create_response = api_client.post( + '/api/v1/schedule/calendars/', + {'name': 'Company Calendar'}, + ) + calendar_id = create_response.data['id'] + + api_client.force_authenticate(user=user2) + assert api_client.get('/api/v1/schedule/calendars/').data == [] + + api_client.force_authenticate(user=user1) + access_response = api_client.patch( + f'/api/v1/schedule/calendars/{calendar_id}/access-scope/', + {'access_scope': 'all'}, + ) + assert access_response.status_code == status.HTTP_200_OK + assert access_response.data['access_scope'] == 'all' + + event_response = api_client.post( + '/api/v1/schedule/events/', + { + 'title': 'Company event', + 'calendar': calendar_id, + 'start': timezone.now().isoformat(), + 'end': (timezone.now() + timedelta(hours=1)).isoformat(), + }, + ) + assert event_response.status_code == status.HTTP_201_CREATED + + api_client.force_authenticate(user=user2) + list_response = api_client.get('/api/v1/schedule/calendars/') + assert [calendar['id'] for calendar in list_response.data] == [calendar_id] + assert list_response.data[0]['user_role'] == 'viewer' + assert list_response.data[0]['can_create_events'] is False + events_response = api_client.get( + f'/api/v1/schedule/events/?calendar={calendar_id}' + ) + assert [event['id'] for event in events_response.data['results']] == [ + event_response.data['id'] + ] + + forbidden_response = api_client.patch( + f'/api/v1/schedule/calendars/{calendar_id}/access-scope/', + {'access_scope': 'restricted'}, + ) + assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=user1) + restricted_response = api_client.patch( + f'/api/v1/schedule/calendars/{calendar_id}/access-scope/', + {'access_scope': 'restricted'}, + ) + assert restricted_response.status_code == status.HTTP_200_OK + + api_client.force_authenticate(user=user2) + assert api_client.get('/api/v1/schedule/calendars/').data == [] def test_add_participant_to_calendar(self, api_client, user1, user2): """Owner может добавить участника к календарю.""" diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index 015ed0fe..20fcf382 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -18,6 +18,7 @@ import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu"; import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction"; import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; import type { Equipment, EquipmentTransferHistoryEntry, MaintenanceRecord } from "@/types/api"; const listModeMeta = [ @@ -551,30 +552,30 @@ function EquipmentPageContent() { setFiltersOpen(false)} ariaLabel="Фильтры оборудования">
- - - - + ({ value, label: meta.label }))]} + /> + ({ value: String(category.id), label: category.name }))]} + /> + ({ value: String(department.id), label: department.name }))]} + /> + ({ value: String(employee.id), label: displayUserName(employee) }))]} + />
Дата покупки: diff --git a/frontend/src/app/finances/page.tsx b/frontend/src/app/finances/page.tsx index bd9ac8a5..60750f60 100644 --- a/frontend/src/app/finances/page.tsx +++ b/frontend/src/app/finances/page.tsx @@ -84,7 +84,7 @@ export default function FinancesPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reloadKey, setReloadKey] = useState(0); - const [desktopWideMode, changeDesktopWideMode] = usePayrollDesktopWideMode(user?.id); + const [, changeDesktopWideMode, effectiveDesktopWideMode] = usePayrollDesktopWideMode(user?.id); const loadStatements = useCallback(async () => { setLoading(true); @@ -135,17 +135,17 @@ export default function FinancesPage() { return ( -
+

Финансы

{activeTab === "management" && showPayrollAdmin ? (
) : activeTab === "work" ? ( -
+
) : null}
@@ -199,7 +199,7 @@ export default function FinancesPage() { accessTargetId="finance-management-access" embedded actionsTargetId="finance-management-actions" - desktopWideMode={desktopWideMode} + desktopWideMode={effectiveDesktopWideMode} headerTargetId="finance-management-header" onDesktopWideModeChange={changeDesktopWideMode} /> diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ccf73a5e..2b514bb6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -812,6 +812,26 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { container-type: inline-size; } +.payroll-readiness-responsive { + container-type: inline-size; +} + +.payroll-readiness-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@container (min-width: 38rem) { + .payroll-readiness-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@container (min-width: 52rem) { + .payroll-readiness-layout { + grid-template-columns: minmax(0, 1fr) minmax(16rem, 20rem); + } +} + .payroll-admin-toolbar-layout { display: flex; min-width: 0; @@ -820,7 +840,6 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { } .payroll-admin-toolbar-main, -.payroll-admin-toolbar-status, .payroll-admin-toolbar-actions { width: 100%; } @@ -839,10 +858,6 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { flex: 1 1 0%; } - .payroll-admin-toolbar-status { - width: 11rem; - } - .payroll-admin-toolbar-actions { width: auto; flex-shrink: 0; diff --git a/frontend/src/app/guests/page.tsx b/frontend/src/app/guests/page.tsx index 57104ba2..7f5fc0d3 100644 --- a/frontend/src/app/guests/page.tsx +++ b/frontend/src/app/guests/page.tsx @@ -50,6 +50,7 @@ import { CompactFilterMenu } from "@/components/ui/CompactFilterMenu"; import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction"; import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; import { canManageGuestAccounts, canViewAllGuestVisits } from "@/lib/permissions"; @@ -1687,15 +1688,12 @@ function GuestVisitControls({
@@ -3498,15 +3492,11 @@ function GuestRegistryControls({
diff --git a/frontend/src/app/notifications/page.tsx b/frontend/src/app/notifications/page.tsx index d7fc50da..76f31735 100644 --- a/frontend/src/app/notifications/page.tsx +++ b/frontend/src/app/notifications/page.tsx @@ -7,6 +7,7 @@ import { HorizontalChipScroller } from '@/components/ui/HorizontalChipScroller'; import { ModulePrimaryAction } from '@/components/ui/ModulePrimaryAction'; import { AutoRevealStickyHeader } from '@/components/ui/AutoRevealStickyHeader'; import { CompactFilterPanel } from '@/components/ui/CompactFilterMenu'; +import { ProjectSelectMenu } from '@/components/ui/ProjectSelectMenu'; import { apiClient } from '@/lib/api'; import { Banknote, @@ -323,28 +324,26 @@ export default function NotificationsPage() { ariaLabel="Фильтры уведомлений" >
- + onChange={setFilterRead} + ariaLabel="Фильтр по прочтению" + options={[ + { value: 'all', label: 'Все уведомления' }, + { value: 'unread', label: 'Только непрочитанные' }, + { value: 'read', label: 'Только прочитанные' }, + ] satisfies { value: NotificationReadFilter; label: string }[]} + /> - + onChange={setFilterCategory} + ariaLabel="Категория уведомлений" + options={[ + { value: '', label: 'Все категории' }, + ...categories.map((category) => ({ value: category, label: category })), + ]} + /> {(filterRead !== 'all' || filterCategory) && (
); }; @@ -623,24 +620,17 @@ export function BigCalendar() {
-
- - -
+ setSelectedCalendarId(value === "" ? null : Number(value))} + ariaLabel="Календарь" + triggerIcon={} + options={[ + { value: "", label: "Все события" }, + ...calendarOptions.map((calendar) => ({ value: calendar.id, label: calendar.name })), + ]} + />
@@ -779,6 +769,7 @@ export function BigCalendar() { canManageParticipants={selectedCalendar.can_manage_participants} calendarType={selectedCalendar.type} contextType={selectedCalendar.context_type} + initialAccessScope={selectedCalendar.access_scope} /> )} diff --git a/frontend/src/components/CalendarParticipantsModal.tsx b/frontend/src/components/CalendarParticipantsModal.tsx index 31326808..05af502c 100644 --- a/frontend/src/components/CalendarParticipantsModal.tsx +++ b/frontend/src/components/CalendarParticipantsModal.tsx @@ -1,12 +1,23 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { X, UserPlus, Trash2 } from 'lucide-react'; +import { + Building2, + CalendarDays, + Check, + Search, + Trash2, + UserPlus, + UserRoundCheck, + Users, +} from 'lucide-react'; import api from '@/lib/api'; import { Modal } from '@/components/ui'; +import { ProjectSelectMenu } from '@/components/ui/ProjectSelectMenu'; import { loadAllPages } from '@/lib/shared'; import { resolveMediaUrl } from '@/lib/url'; import type { CalendarParticipant, Department, User } from '@/types/api'; +import Image from 'next/image'; interface CalendarParticipantsModalProps { isOpen: boolean; @@ -16,6 +27,7 @@ interface CalendarParticipantsModalProps { canManageParticipants?: boolean; calendarType?: string | null; contextType?: string | null; + initialAccessScope?: 'all' | 'restricted'; } export default function CalendarParticipantsModal({ @@ -26,6 +38,7 @@ export default function CalendarParticipantsModal({ canManageParticipants = false, calendarType, contextType, + initialAccessScope = 'restricted', }: CalendarParticipantsModalProps) { const [participants, setParticipants] = useState([]); const [employees, setEmployees] = useState([]); @@ -34,6 +47,9 @@ export default function CalendarParticipantsModal({ const [selectedRole, setSelectedRole] = useState('viewer'); const [selectedDepartment, setSelectedDepartment] = useState(''); const [loading, setLoading] = useState(false); + const [accessUpdating, setAccessUpdating] = useState(false); + const [accessScope, setAccessScope] = useState<'all' | 'restricted'>(initialAccessScope); + const [notice, setNotice] = useState<{ tone: 'success' | 'danger'; text: string } | null>(null); const [searchQuery, setSearchQuery] = useState(''); const isDepartmentCalendar = @@ -72,13 +88,50 @@ export default function CalendarParticipantsModal({ return; } - void loadParticipants(); + const loadTimer = window.setTimeout(() => { + void loadParticipants(); + void api.getCalendar(calendarId).then((calendar) => { + setAccessScope(calendar.access_scope === 'all' ? 'all' : 'restricted'); + }).catch((error) => { + console.error('Failed to load calendar access:', error); + }); - if (canManage) { - void loadEmployees(); - void loadDepartments(); + if (canManage) { + void loadEmployees(); + void loadDepartments(); + } + }, 0); + return () => window.clearTimeout(loadTimer); + }, [calendarId, canManage, isOpen, loadDepartments, loadEmployees, loadParticipants]); + + const handleAccessScopeChange = async (nextScope: 'all' | 'restricted') => { + if (nextScope === accessScope || accessUpdating) return; + setAccessUpdating(true); + setNotice(null); + try { + const calendar = await api.updateCalendarAccessScope(calendarId, nextScope); + setAccessScope(calendar.access_scope === 'all' ? 'all' : 'restricted'); + setSelectedUserIds([]); + setNotice({ + tone: 'success', + text: nextScope === 'all' + ? 'Календарь доступен всей компании.' + : 'Календарь доступен только выбранным участникам.', + }); + } catch (error) { + console.error('Failed to update calendar access:', error); + setNotice({ tone: 'danger', text: 'Не удалось изменить доступ к календарю.' }); + } finally { + setAccessUpdating(false); } - }, [canManage, isOpen, loadDepartments, loadEmployees, loadParticipants]); + }; + + const handleClose = () => { + setNotice(null); + setSelectedUserIds([]); + setSearchQuery(''); + onClose(); + }; const handleAddParticipants = async () => { if (selectedUserIds.length === 0) { @@ -88,19 +141,18 @@ export default function CalendarParticipantsModal({ setLoading(true); try { - // Добавляем всех выбранных участников последовательно - for (const userId of selectedUserIds) { - await api.addCalendarParticipant(calendarId, userId, selectedRole); - } + await Promise.all(selectedUserIds.map((userId) => + api.addCalendarParticipant(calendarId, userId, selectedRole) + )); await loadParticipants(); setSelectedUserIds([]); setSearchQuery(''); setSelectedRole('viewer'); - alert(`Успешно добавлено участников: ${selectedUserIds.length}`); + setNotice({ tone: 'success', text: `Добавлено участников: ${selectedUserIds.length}.` }); } catch (error) { console.error('Failed to add participants:', error); - alert('Не удалось добавить участников'); + setNotice({ tone: 'danger', text: 'Не удалось добавить участников.' }); } finally { setLoading(false); } @@ -139,9 +191,10 @@ export default function CalendarParticipantsModal({ try { await api.removeCalendarParticipant(calendarId, userId); await loadParticipants(); + setNotice({ tone: 'success', text: 'Участник удалён из календаря.' }); } catch (error) { console.error('Failed to remove participant:', error); - alert('Не удалось удалить участника'); + setNotice({ tone: 'danger', text: 'Не удалось удалить участника.' }); } finally { setLoading(false); } @@ -166,259 +219,136 @@ export default function CalendarParticipantsModal({ !participants.find(p => p.user?.id === emp.id) ); + const visibleParticipants = accessScope === 'all' + ? participants.filter((participant) => participant.distinction !== 'viewer') + : participants; + if (!isOpen) return null; return ( - -
-
-
-

Участники календаря

-

{calendarName}

+
} + > +
+
+
+
- -
- -
- {isDepartmentCalendar && ( -
-

- Состав участников календаря отдела синхронизируется автоматически - из состава отдела и назначенных ролей. Управление через эту - модалку отключено для всех пользователей, включая администраторов. -

-
- )} - - {/* Add Participant Section */} - {canManage && ( -
-
-

- Добавить участников - {selectedUserIds.length > 0 && ( - - (выбрано: {selectedUserIds.length}) - - )} -

-
- - {selectedDepartment && ( - - )} - -
-
- -
- setSearchQuery(e.target.value)} - className="app-input flex-1 rounded-lg px-3 py-2 text-sm" - /> - - -
- - {/* Employee list with checkboxes */} - {availableEmployees.length > 0 && ( -
- {availableEmployees.map(emp => ( - - ))} -
- )} - - {availableEmployees.length === 0 && ( -

- {searchQuery ? 'Сотрудники не найдены' : 'Все сотрудники уже добавлены'} -

- )} - -
- - - -
-
- )} - - {/* Participants List */} -
-

- Текущие участники ({participants.length}) -

- -
- {participants.length === 0 ? ( -

Нет участников

- ) : ( - participants.map((participant) => { - const user = participant.user; - const firstName = user?.first_name || 'N'; - const lastName = user?.last_name || 'A'; - const username = typeof user?.username === 'string' ? user.username.trim() : ''; - const secondaryLabel = user?.email || (username ? `@${username}` : ''); - - return ( -
-
- {user?.avatar ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${firstName} - ) : ( -
- - {firstName[0]}{lastName[0]} - -
- )} -
-
- {firstName} {lastName} -
- {secondaryLabel ? ( -
{secondaryLabel}
- ) : null} -
-
- -
- - {participant.distinction === 'owner' ? 'Владелец' : - participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'} - - - {canManage && participant.distinction !== 'owner' && ( - - )} -
-
- ); - }) - )} -
+
+

{calendarName}

+

+ {isDepartmentCalendar ? 'Состав определяется отделом' : accessScope === 'all' ? 'Доступ для всей компании' : 'Доступ выбранным участникам'} +

-
- -
+ {notice ?
{notice.text}
: null} + + {isDepartmentCalendar ? ( +
+ +

Участники синхронизируются автоматически из состава отдела и назначенных ролей.

+
+ ) : canManage ? ( +
+

Доступ

+
+ + +
+
+ ) : null} + + {canManage && accessScope === 'restricted' ? ( +
+
+
+

Добавить участников

+

Выбрано: {selectedUserIds.length}

+
+
+ + {selectedDepartment ? : null} + +
+
+ +
+ + { setSelectedDepartment(value); setSelectedUserIds([]); }} + ariaLabel="Отдел участников" + options={[{ value: '', label: 'Все отделы' }, ...departments.map((department) => ({ value: String(department.id), label: department.name }))]} + /> +
+ +
+ {availableEmployees.length ? availableEmployees.map((employee) => { + const selected = selectedUserIds.includes(employee.id); + const fullName = `${employee.first_name || ''} ${employee.last_name || ''}`.trim() || employee.username || 'Сотрудник'; + return ; + }) :

{searchQuery ? 'Сотрудники не найдены' : 'Все сотрудники уже добавлены'}

} +
+ +
+ + +
+
+ ) : null} + + {accessScope === 'all' && !isDepartmentCalendar ? ( +
+ +

Вся компания

Все действующие и новые сотрудники получают просмотр календаря автоматически.

+
+ ) : null} + +
+
+

{accessScope === 'all' ? 'Индивидуальные роли' : 'Текущие участники'}

+ {visibleParticipants.length} +
+
+ {visibleParticipants.length ? visibleParticipants.map((participant) => { + const user = participant.user; + const fullName = `${user?.first_name || ''} ${user?.last_name || ''}`.trim() || user?.username || 'Сотрудник'; + const secondaryLabel = user?.email || (user?.username ? `@${user.username}` : ''); + return
+ {user?.avatar ? {fullName} :
{user?.first_name?.[0] || user?.last_name?.[0] || '?'}
} +

{fullName}

{secondaryLabel ?

{secondaryLabel}

: null}
+ {participant.distinction === 'owner' ? 'Владелец' : participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'} + {canManage && participant.distinction !== 'owner' ? : null} +
; + }) :

Индивидуальные участники не добавлены

} +
+
); diff --git a/frontend/src/components/finance/PayrollDailyWorkSection.tsx b/frontend/src/components/finance/PayrollDailyWorkSection.tsx index 1fcc8b92..17757064 100644 --- a/frontend/src/components/finance/PayrollDailyWorkSection.tsx +++ b/frontend/src/components/finance/PayrollDailyWorkSection.tsx @@ -11,6 +11,7 @@ import { import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; import { createPortal } from "react-dom"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; import { apiClient } from "@/lib/api"; import type { PayrollOwnDailyWorkEntry, @@ -18,7 +19,7 @@ import type { PayrollOwnWorkPeriod, } from "@/lib/api/finance"; import { approvalStatusMeta, getPayrollAdminError, periodStatusMeta } from "@/lib/payroll-admin"; -import { formatPayrollDate, getPayrollPeriodRange } from "@/lib/payroll"; +import { formatPayrollDate, formatPayrollPoints, getPayrollPeriodRange, normalizePayrollPoints } from "@/lib/payroll"; function localDateInput(): string { const now = new Date(); @@ -35,13 +36,6 @@ function defaultWorkDate(period: PayrollOwnWorkPeriod): string { return today; } -function formatPoints(value: string | null | undefined): string { - if (value == null || value === "") return "0"; - const number = Number(value); - if (!Number.isFinite(number)) return value; - return number.toLocaleString("ru-RU", { maximumFractionDigits: 4 }); -} - function currentEntryForDate( entries: PayrollOwnDailyWorkEntry[], workDate: string, @@ -80,7 +74,7 @@ export function PayrollDailyWorkSection({ : defaultWorkDate(period); const entry = currentEntryForDate(payload.entries, nextDate); setWorkDate(nextDate); - setActualPoints(entry?.actual_points || ""); + setActualPoints(normalizePayrollPoints(entry?.actual_points)); setNote(entry?.note || ""); }, []); @@ -130,7 +124,7 @@ export function PayrollDailyWorkSection({ const selectWorkDate = (date: string) => { setWorkDate(date); const entry = currentEntryForDate(workspace?.entries || [], date); - setActualPoints(entry?.actual_points || ""); + setActualPoints(normalizePayrollPoints(entry?.actual_points)); setNote(entry?.note || ""); setError(null); setNotice(null); @@ -146,7 +140,7 @@ export function PayrollDailyWorkSection({ const result = await apiClient.saveMyPayrollDailyWorkEntry({ period_id: selectedPeriod.id, work_date: workDate, - actual_points: actualPoints, + actual_points: normalizePayrollPoints(actualPoints), note: note.trim(), ...(selectedEntry ? { expected_lock_version: selectedEntry.lock_version } : {}), }); @@ -183,16 +177,18 @@ export function PayrollDailyWorkSection({ const aggregateStatus = aggregate ? approvalStatusMeta[aggregate.status] : null; const periodStatus = selectedPeriod ? periodStatusMeta[selectedPeriod.status] : null; const periodSelector = ( - + ({ + value: period.id, + label: period.name || period.code, + icon: , + }))} + onChange={(periodId) => void loadWorkspace(periodId)} + ariaLabel="Расчётный период выработки" + variant="field" + className="w-52 max-w-full" + /> ); const periodSelectorTarget = periodSelectorTargetId && typeof document !== "undefined" ? document.getElementById(periodSelectorTargetId) @@ -209,7 +205,7 @@ export function PayrollDailyWorkSection({ {selectedEntry ? "Изменить запись за день" : "Добавить запись за день"}
-
+
@@ -294,7 +291,7 @@ export function PayrollDailyWorkSection({

Норма за период

-

{formatPoints(summary?.target_points)}

+

{formatPayrollPoints(summary?.target_points, "0")}

{summary?.target_source === "saved_record" ? "Из сохранённой записи" : `По графику: ${summary?.workdays_count || 0} дн.`}

@@ -304,7 +301,7 @@ export function PayrollDailyWorkSection({

Фактически за период

- {formatPoints(summary?.actual_points)} / {formatPoints(summary?.target_points)} + {formatPayrollPoints(summary?.actual_points, "0")} / {formatPayrollPoints(summary?.target_points, "0")}

{aggregateStatus ? {aggregateStatus.label} : null} @@ -334,8 +331,8 @@ export function PayrollDailyWorkSection({ - {formatPoints(entry.actual_points)} - из {formatPoints(entry.target_points)} + {formatPayrollPoints(entry.actual_points, "0")} + из {formatPayrollPoints(entry.target_points, "0")} {selectedPeriod?.editable ? : null} diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx index 7d99db74..ed2cd718 100644 --- a/frontend/src/components/finance/admin/PayrollAdminModals.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx @@ -29,6 +29,7 @@ import { getPayrollAdminError, isPayrollAdminStaleConflict, } from "@/lib/payroll-admin"; +import { formatPayrollPoints, normalizePayrollPoints } from "@/lib/payroll"; const inputClass = "app-input w-full rounded-lg px-3 py-2.5 text-sm"; const selectClass = "app-select w-full rounded-lg px-3 py-2.5 text-sm"; @@ -578,7 +579,7 @@ export function PayrollBulkTargetPointsModal({ result = await onSubmit({ employee_ids: Array.from(selectedEmployeeIds), mode, - target_points: mode === "fixed" ? targetPoints.trim() : null, + target_points: mode === "fixed" ? normalizePayrollPoints(targetPoints) : null, reason: reason.trim(), }); }, { @@ -633,13 +634,14 @@ export function PayrollBulkTargetPointsModal({ type="number" className={inputClass} inputMode="decimal" - min="0.0001" - step="0.0001" + min="0.01" + step="0.01" value={targetPoints} required={mode === "fixed"} disabled={mode === "automatic"} placeholder={mode === "automatic" ? "По рабочему графику" : "115"} onChange={(event) => setTargetPoints(event.target.value)} + onBlur={() => setTargetPoints((value) => normalizePayrollPoints(value))} />
diff --git a/frontend/src/components/finance/admin/PayrollAdminTables.tsx b/frontend/src/components/finance/admin/PayrollAdminTables.tsx index 50b68ea7..293aca16 100644 --- a/frontend/src/components/finance/admin/PayrollAdminTables.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminTables.tsx @@ -10,7 +10,7 @@ import type { PayrollAdminWorkRecord, PayrollApprovalStatus, } from "@/lib/api/finance"; -import { formatPayrollDate, formatPayrollDateTime, formatPayrollMoney } from "@/lib/payroll"; +import { formatPayrollDate, formatPayrollDateTime, formatPayrollMoney, formatPayrollPoints } from "@/lib/payroll"; import { formatPayrollWorkMetric, isAttendancePayrollWorkRecord } from "@/lib/payroll-attendance"; import { approvalStatusMeta, @@ -96,13 +96,12 @@ export function PayrollInputHistory({ error: string | null; }) { const [expanded, setExpanded] = useState(false); - const visibleEvents = expanded ? events : events.slice(0, 5); - const canToggleHistory = !loading && !error && events.length > 5; - const toggleLabel = expanded ? "Свернуть историю" : `Показать всю историю (${events.length})`; + const canToggleHistory = !loading && !error && events.length > 0; + const toggleLabel = expanded ? "Скрыть историю" : `Показать историю (${events.length})`; return ( -
-
+
+
@@ -121,21 +120,20 @@ export function PayrollInputHistory({ {expanded ? "Скрыть" : "Показать все"} ) : null} - {!loading && !error ? {events.length} : null}
- {loading ? ( + {expanded && loading ? (
Загрузка истории…
- ) : error ? ( + ) : expanded && error ? (
{error}
- ) : events.length === 0 ? ( + ) : expanded && events.length === 0 ? (
История пока пуста.
- ) : ( + ) : expanded ? ( <>
- {visibleEvents.map((event) => ( + {events.map((event) => (
@@ -152,18 +150,9 @@ export function PayrollInputHistory({
))}
- {canToggleHistory ? ( - - ) : null} + - )} + ) : null}
); } @@ -538,7 +527,7 @@ export function PayrollApprovalQueue({ }) { const items: ApprovalQueueItem[] = [ ...rates.filter((item) => item.status === "draft").map((item) => ({ key: `rate-${item.id}`, group: "rate" as const, title: item.employee.display_name, subtitle: "Базовая ставка", value: formatPayrollMoney(item.amount, item.currency), createdById: item.created_by.id, raw: item })), - ...workRecords.filter((item) => item.status === "draft").map((item) => ({ key: `work-${item.id}`, group: "work" as const, title: item.employee.display_name, subtitle: `Выработка: ${item.actual_points} из ${item.target_points} баллов`, value: "", createdById: item.created_by.id, raw: item })), + ...workRecords.filter((item) => item.status === "draft").map((item) => ({ key: `work-${item.id}`, group: "work" as const, title: item.employee.display_name, subtitle: `Выработка: ${formatPayrollPoints(item.actual_points)} из ${formatPayrollPoints(item.target_points)} баллов`, value: "", createdById: item.created_by.id, raw: item })), ...inputLines.filter((item) => item.status === "draft").map((item) => ({ key: `input-${item.id}`, group: "input" as const, title: item.employee.display_name, subtitle: item.component.name, value: formatPayrollMoney(item.amount), createdById: item.created_by.id, raw: item })), ]; diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index d44346df..0e53ce62 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -42,7 +42,11 @@ import { PayrollInputCopyModal } from "@/components/finance/admin/PayrollInputCo import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal"; import { PayrollCarryoversModal } from "@/components/finance/admin/PayrollCarryoversModal"; import { PayrollManagementAccessModal } from "@/components/finance/admin/PayrollManagementAccessModal"; +import { PayrollPeriodMenu } from "@/components/finance/admin/PayrollPeriodMenu"; +import { CompactFilterMenu } from "@/components/ui/CompactFilterMenu"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; import { CompactSortMenu, type CompactSortOption } from "@/components/ui/CompactSortMenu"; +import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction"; import { ViewportAwareMenu } from "@/components/shared/ViewportAwareMenu"; import { PayrollWorkbookImportModal } from "@/components/finance/admin/PayrollWorkbookImportModal"; @@ -93,7 +97,7 @@ import { type PayrollRunAction, } from "@/lib/payroll-admin"; import { buildPayrollAttendanceApplyNotice } from "@/lib/payroll-attendance"; -import { formatPayrollDate, formatPayrollDateTime, formatPayrollMoney, getPayrollPeriodRange } from "@/lib/payroll"; +import { formatPayrollDate, formatPayrollDateTime, formatPayrollMoney, formatPayrollPoints, getPayrollPeriodRange, normalizePayrollPoints } from "@/lib/payroll"; type PendingAction = { title: string; @@ -149,11 +153,14 @@ function SummaryCards({ workspace }: { workspace: WorkspacePayload }) { { label: "К выплате", value: summary?.payable_total != null ? formatPayrollMoney(summary.payable_total, workspace.selected_period?.currency) : hiddenValue, icon: Calculator }, ]; return ( -
+
{values.map(({ label, value, icon: Icon }) => ( -
-
{label}
-

{value}

+
+ + + {label} + {value} +
))}
@@ -281,6 +288,7 @@ function TableToolbar({ addLabel: string; }) { const [addMenuOpen, setAddMenuOpen] = useState(false); + const [filtersOpen, setFiltersOpen] = useState(false); const addMenuRef = useRef(null); const hasAdditionalActions = Boolean( onFillFromAttendance @@ -322,10 +330,38 @@ function TableToolbar({ onChange={onSort} ariaLabel="Сортировка записей" /> + setFiltersOpen((current) => !current)} + activeCount={status ? 1 : 0} + ariaLabel="Фильтры записей" + width={280} + > +
+ Статус + + {status ? ( + + ) : null} +
+
- {onAdd ? (
apiClient.approvePayrollAdminPayRate(record.id, record.lock_version), }); const openApproveWork = (record: PayrollAdminWorkRecord) => setPendingAction({ - title: "Утвердить выработку", description: `${record.employee.display_name}: ${record.actual_points} из ${record.target_points} баллов.`, confirmLabel: "Утвердить", warning: "Проверьте норму и фактическую выработку перед утверждением.", successMessage: "Выработка утверждена.", execute: () => apiClient.approvePayrollAdminWorkRecord(record.id, record.lock_version), + title: "Утвердить выработку", description: `${record.employee.display_name}: ${formatPayrollPoints(record.actual_points)} из ${formatPayrollPoints(record.target_points)} баллов.`, confirmLabel: "Утвердить", warning: "Проверьте норму и фактическую выработку перед утверждением.", successMessage: "Выработка утверждена.", execute: () => apiClient.approvePayrollAdminWorkRecord(record.id, record.lock_version), }); const openApproveInput = (record: PayrollAdminInputLine) => setPendingAction({ title: "Утвердить операцию", description: `${record.component.name} для ${record.employee.display_name}: ${formatPayrollMoney(record.amount, selectedPeriod?.currency)}.`, confirmLabel: "Утвердить", warning: "Утверждённая операция станет входом следующего расчёта.", successMessage: "Операция утверждена.", execute: () => apiClient.approvePayrollAdminInputLine(record.id, record.lock_version), @@ -909,6 +945,7 @@ export function PayrollAdminWorkspace({ if (field === "target_points" && normalized && (!Number.isFinite(Number(normalized)) || Number(normalized) <= 0)) { throw new Error("Норма должна быть больше нуля или оставлена пустой для автоматического расчёта."); } + const rounded = normalized ? normalizePayrollPoints(normalized) : ""; const record = workRecords .filter((item) => item.employee.id === row.employee.id && item.status !== "voided") .sort((left, right) => right.revision - left.revision || right.id - left.id)[0]; @@ -918,15 +955,15 @@ export function PayrollAdminWorkspace({ } if (draft) { await apiClient.updatePayrollAdminWorkRecord(draft.id, { - [field]: field === "target_points" ? normalized || null : normalized, + [field]: field === "target_points" ? rounded || null : rounded, expected_lock_version: draft.lock_version, }); } else { await apiClient.createPayrollAdminWorkRecord({ period_id: selectedPeriod.id, employee_id: row.employee.id, - target_points: field === "target_points" ? normalized || null : null, - actual_points: field === "actual_points" ? normalized : "0", + target_points: field === "target_points" ? rounded || null : null, + actual_points: field === "actual_points" ? rounded : "0.00", expected_point_amount: null, expected_gross: null, expected_recalculated_gross: null, @@ -1000,10 +1037,13 @@ export function PayrollAdminWorkspace({ ) : null} {workspace.periods.length ? ( -
- +
+ void loadWorkspace(periodId, true)} + /> {selectedPeriod?.status === "open" && permissions.manage_inputs && !workspace.current_run ? (
@@ -1039,19 +1080,20 @@ export function PayrollAdminWorkspace({ ) : ( - {accessLabel} + {desktopWideMode ? accessLabel : null} ); const headerTarget = headerTargetId && typeof document !== "undefined" @@ -1085,26 +1127,25 @@ export function PayrollAdminWorkspace({

Без периода нельзя ввести ставки, выработку и начисления.

{permissions.manage_inputs ? : null}
) : ( <> -
- -
-
+
+ {visibleTabs.map((item) => { const count = item.value === "summary" ? periodTable?.summary.employee_count ?? workspace.employees.length : item.value === "approval" ? workspace.pending_approvals.rates + workspace.pending_approvals.work_records + workspace.pending_approvals.input_lines : 0; - return ; + return ; })} -
-
+ +
{tab === "readiness" ? ( -
-
+
+
+
{[ { @@ -1214,26 +1255,29 @@ export function PayrollAdminWorkspace({ {tab === "inputs" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} {tab === "summary" ? (
- -
+
+
+

Итоговая таблица

+
- setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} /> + setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
) : null} {tab === "approval" ? ( diff --git a/frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx b/frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx index 59ac39cb..0088f929 100644 --- a/frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx +++ b/frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx @@ -32,7 +32,7 @@ import { getPayrollAttendanceModeSummary, payrollAttendanceActionMeta, } from "@/lib/payroll-attendance"; -import { formatPayrollDateTime, getPayrollPeriodRange } from "@/lib/payroll"; +import { formatPayrollDateTime, formatPayrollPoints, getPayrollPeriodRange } from "@/lib/payroll"; type PayrollAttendanceWorkModalProps = { isOpen: boolean; @@ -144,9 +144,9 @@ function PreviewTable({
Выработка: -

Норма: {item.target_points} балл.

-

Факт: {item.actual_points} балл.

-

Дневная норма: {item.daily_target_points}

+

Норма: {formatPayrollPoints(item.target_points)} балл.

+

Факт: {formatPayrollPoints(item.actual_points)} балл.

+

Дневная норма: {formatPayrollPoints(item.daily_target_points)}

Из посещаемости @@ -303,7 +303,7 @@ export function PayrollAttendanceWorkModal({

- Полный рабочий день = {preview.policy.daily_target_points} балл. + Полный рабочий день = {formatPayrollPoints(preview.policy.daily_target_points)} балл.

{preview.policy.description || preview.policy.label} diff --git a/frontend/src/components/finance/admin/PayrollPeriodMenu.tsx b/frontend/src/components/finance/admin/PayrollPeriodMenu.tsx new file mode 100644 index 00000000..2d0594ff --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollPeriodMenu.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { CalendarCheck2 } from "lucide-react"; + +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; +import type { PayrollAdminPeriod } from "@/lib/api/finance"; + +export function PayrollPeriodMenu({ + expanded, + onChange, + periods, + selectedPeriodId, +}: { + expanded: boolean; + onChange: (periodId: number) => void; + periods: PayrollAdminPeriod[]; + selectedPeriodId?: number | null; +}) { + const firstPeriod = periods[0]; + if (!firstPeriod) return null; + + return ( + ({ + value: period.id, + label: period.name || period.code, + icon: , + }))} + onChange={onChange} + ariaLabel="Расчётный период" + variant={expanded ? "field" : "compact"} + className={expanded ? "min-w-52 max-w-72 flex-1" : ""} + /> + ); +} diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index ea53eddb..d7caf5a4 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -16,7 +16,7 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, X } from "lucide-react"; +import { ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, Table2, X } from "lucide-react"; import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; @@ -32,7 +32,7 @@ import type { PayrollPeriodTableRow, PayrollPeriodTableStatus, } from "@/lib/api/finance"; -import { formatPayrollMoney } from "@/lib/payroll"; +import { formatPayrollMoney, formatPayrollPoints } from "@/lib/payroll"; import { getPayrollAdminError } from "@/lib/payroll-admin"; import { movePayrollColumn, normalizePayrollColumnOrder } from "@/lib/payroll-columns"; import { comparePayrollProjection, type PayrollProjectionDifference } from "@/lib/payroll-projection"; @@ -564,10 +564,7 @@ function componentCalculationHelp(component: PayrollPeriodTableComponent): Calcu } function formatPoints(value: string | null): string { - if (value == null || value === "") return "—"; - const numeric = Number(value); - if (!Number.isFinite(numeric)) return value; - return numeric.toLocaleString("ru-RU", { maximumFractionDigits: 4 }); + return formatPayrollPoints(value); } function formatMoney(value: string | null, currency: string): string { @@ -812,6 +809,8 @@ function commentCellKey(employeeId: number, columnId: string): string { return `${employeeId}:${columnId}`; } +type CompactPeriodPanel = "summary" | "table" | "help"; + export function PayrollPeriodTableView({ data, loading, @@ -828,6 +827,7 @@ export function PayrollPeriodTableView({ onSaveComponent, onOpenCarryovers, onSetPaymentConfirmed, + compact = false, }: { data: PayrollPeriodTable | null; loading: boolean; @@ -844,6 +844,7 @@ export function PayrollPeriodTableView({ onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise; onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void; onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise; + compact?: boolean; }) { const searchedRows = useMemo(() => { const needle = search.trim().toLocaleLowerCase("ru-RU"); @@ -863,6 +864,7 @@ export function PayrollPeriodTableView({ } | null>(null); const [columnsMenuOpen, setColumnsMenuOpen] = useState(false); const [rowsMenuOpen, setRowsMenuOpen] = useState(false); + const [compactPanel, setCompactPanel] = useState("table"); const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState>(new Set()); const [paymentConfirmationError, setPaymentConfirmationError] = useState(null); const columnsMenuRef = useRef(null); @@ -1008,6 +1010,18 @@ export function PayrollPeriodTableView({ if (error) return

{error}
; if (!data) return null; + const showSummary = !compact || compactPanel === "summary"; + const showTable = !compact || compactPanel === "table"; + const showHelp = !compact || compactPanel === "help"; + const toggleCompactPanel = (panel: CompactPeriodPanel) => { + const nextPanel = compactPanel === panel ? null : panel; + if (nextPanel !== "table") { + setColumnsMenuOpen(false); + setRowsMenuOpen(false); + } + setCompactPanel(nextPanel); + }; + const tableWidth = 150 + visibleColumns.reduce((width, column) => width + column.width, 0); const helps = calculationHelps(data.calculation_rules.point_policy); const total = (field: keyof PayrollPeriodTableRow) => sumRows(rows, (row) => { @@ -1337,8 +1351,41 @@ export function PayrollPeriodTableView({ return (
-
-
+ {compact ? ( +
+ + + +
+ ) : null} + + {showSummary || showTable ? ( +
+ {showSummary ?
Сотрудников {data.summary.employee_count} Рассчитано {data.summary.calculated_count} Готово {data.summary.ready_count} @@ -1346,8 +1393,8 @@ export function PayrollPeriodTableView({ Не заполнено {data.summary.incomplete_count} {data.summary.preliminary_count ? Предварительно {data.summary.preliminary_count} : null} {data.run ? Ревизия {data.run.revision} : null} -
-
+
: null} + {showTable ?
-
+
: null}
+ ) : null} {paymentConfirmationError ?
{paymentConfirmationError}
: null} - {data.summary.preliminary_count ? ( + {showHelp && data.summary.preliminary_count ? (
Значения рассчитаны предварительно по текущим данным. Они обновляются после сохранения ячеек и станут официальными после запуска расчёта.
) : null} -
+ {showHelp ?
{canEdit ? Enter или переход в другую ячейку — сохранить, Esc — отменить. : null} @@ -1499,9 +1547,9 @@ export function PayrollPeriodTableView({ Знак вопроса — логика и формула расчёта +Плюс — добавить комментарий; К:N — открыть обсуждение -
+
: null} - + {showTable ?
-
+
: null} {activeHelp ? setActiveHelp(null)} /> : null} {breakdownEmployee ? ( = red: "bg-red-500/15 text-red-300", }; -function formatPoints(value: string | null): string { - if (value == null || value === "") return "—"; - const numeric = Number(value); - return Number.isFinite(numeric) - ? numeric.toLocaleString("ru-RU", { maximumFractionDigits: 4 }) - : value; -} - function dateParts(value: string): { date: string; weekday: string } { const parsed = new Date(`${value}T00:00:00`); return { @@ -84,7 +77,7 @@ function differenceTitle( day: PayrollPointBreakdownDay, difference: PayrollProjectionDifference | null, ): string { - if (!difference) return `${row.label}: ${formatPoints(day[row.id])}`; + if (!difference) return `${row.label}: ${formatPayrollPoints(day[row.id])}`; const direction = difference.direction === "higher" ? "выше" : "ниже"; const amount = difference.percentage == null ? "выработка равна нулю" @@ -159,7 +152,7 @@ export function PayrollPointBreakdownModal({ detailLines: [ `Отработано: ${record.work_hours ?? "—"} ч.`, `План: ${record.expected_hours ?? "—"} ч.`, - `Баллы: ${formatPoints(attendanceDay.attendance_points)}`, + `Баллы: ${formatPayrollPoints(attendanceDay.attendance_points)}`, ], issues: record.issues, isManuallyEdited: record.is_manually_edited, @@ -216,7 +209,7 @@ export function PayrollPointBreakdownModal({

- Норма за период: {formatPoints(data.target_points)} · выработка: {formatPoints(data.actual_points)} + Норма за период: {formatPayrollPoints(data.target_points)} · выработка: {formatPayrollPoints(data.actual_points)}

{data.work_points_mode === "daily_entries" @@ -316,10 +309,10 @@ export function PayrollPointBreakdownModal({ onClick={() => openDayDetail(row, day)} aria-label={`${row.label}, ${formatDate(day.date)}. Открыть исходную запись`} > - {formatPoints(day[row.id])} + {formatPayrollPoints(day[row.id])} ) : ( - {formatPoints(day[row.id])} + {formatPayrollPoints(day[row.id])} )} ); @@ -335,7 +328,7 @@ export function PayrollPointBreakdownModal({ className="min-h-10 w-full cursor-pointer px-1 py-3 text-center transition-colors hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent-primary)]" onClick={() => setUndatedWorkOpen(true)} > - {formatPoints(data.undated_work_points)} + {formatPayrollPoints(data.undated_work_points)} ) : —} diff --git a/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx b/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx index 459f7ade..d8e5fd39 100644 --- a/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx +++ b/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx @@ -4,6 +4,7 @@ import { BriefcaseBusiness, CalendarDays, FileText, UserRoundCog } from "lucide- import { Modal } from "@/components/ui"; import type { PayrollPointBreakdownDay } from "@/lib/api/finance"; +import { formatPayrollPoints } from "@/lib/payroll"; function formatDate(value: string | null): string { if (!value) return "—"; @@ -21,11 +22,7 @@ function formatDateTime(value: string): string { } function formatPoints(value: string | null): string { - if (value == null || value === "") return "—"; - const number = Number(value); - return Number.isFinite(number) - ? number.toLocaleString("ru-RU", { maximumFractionDigits: 4 }) - : value; + return formatPayrollPoints(value); } function requestStatusClass(status: string): string { diff --git a/frontend/src/components/finance/admin/PayrollWorkbookImportModal.tsx b/frontend/src/components/finance/admin/PayrollWorkbookImportModal.tsx index 73a847a4..78bdb2a6 100644 --- a/frontend/src/components/finance/admin/PayrollWorkbookImportModal.tsx +++ b/frontend/src/components/finance/admin/PayrollWorkbookImportModal.tsx @@ -12,7 +12,7 @@ import type { PayrollWorkbookImportResult, } from "@/lib/api/finance"; import { getPayrollAdminError, isPayrollAdminStaleConflict } from "@/lib/payroll-admin"; -import { getPayrollPeriodRange } from "@/lib/payroll"; +import { formatPayrollPoints, getPayrollPeriodRange } from "@/lib/payroll"; type Props = { isOpen: boolean; @@ -203,7 +203,7 @@ export function PayrollWorkbookImportModal({ isOpen, period, onClose, onApplied, {orderedEmployees.map((employee) => )}

-

{row.entry_count} дней · {row.points_total} балл.

+

{row.entry_count} дней · {formatPayrollPoints(row.points_total)} балл.

{row.existing_count ?

{row.existing_period_record ? "Есть итог за период" : `Совпадений по датам: ${row.existing_count}`}

: null}
diff --git a/frontend/src/components/layout/CalendarModals.tsx b/frontend/src/components/layout/CalendarModals.tsx index 19634b9a..8b5c08c7 100644 --- a/frontend/src/components/layout/CalendarModals.tsx +++ b/frontend/src/components/layout/CalendarModals.tsx @@ -27,6 +27,7 @@ export function CalendarModals(props: CalendarModalsProps) { canManageParticipants={props.participantsCalendar.can_manage_participants} calendarType={props.participantsCalendar.type} contextType={props.participantsCalendar.context_type} + initialAccessScope={props.participantsCalendar.access_scope} /> )} diff --git a/frontend/src/components/messages/MessagesChatListControls.tsx b/frontend/src/components/messages/MessagesChatListControls.tsx index 1fac89ae..c9aa2013 100644 --- a/frontend/src/components/messages/MessagesChatListControls.tsx +++ b/frontend/src/components/messages/MessagesChatListControls.tsx @@ -5,6 +5,7 @@ import { Search } from "lucide-react"; import { CompactFilterMenu } from "@/components/ui/CompactFilterMenu"; import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; import { MESSAGE_CHAT_TYPE_OPTIONS, type MessagePinnedFilter, @@ -58,12 +59,26 @@ export default function MessagesChatListControls({ ariaLabel="Дополнительные фильтры чатов" >
- - + + {filterUnread !== "all" || filterPinned !== "all" ? ( ) : null} diff --git a/frontend/src/components/requests/RequestListControls.tsx b/frontend/src/components/requests/RequestListControls.tsx index 275fda04..89b11459 100644 --- a/frontend/src/components/requests/RequestListControls.tsx +++ b/frontend/src/components/requests/RequestListControls.tsx @@ -9,6 +9,7 @@ import { CompactSortMenu } from "@/components/ui/CompactSortMenu"; import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu"; import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; import { displayUserName } from "@/lib/shared"; import type { User } from "@/types/api"; import { Filter, Loader2, Maximize2, Minimize2, Plus, Search, Zap } from "lucide-react"; @@ -200,55 +201,50 @@ export function RequestListControls({ ) : null}
- + onChange={actions.setView} + ariaLabel="Область заявлений" + options={[ + { value: "", label: "Все заявления" }, + { value: "mine", label: "Мои заявления" }, + { value: "addressed", label: "Адресованные мне" }, + { value: "decision", label: "Требуют решения" }, + ] satisfies { value: RequestQuickScope; label: string }[]} + /> - + onChange={actions.setTypeFilter} + ariaLabel="Тип заявления" + options={[ + { value: "", label: "Все типы" }, + ...Object.entries(requestTypeLabels).map(([value, label]) => ({ value, label })), + ]} + /> - + onChange={actions.setStatusFilter} + ariaLabel="Статус заявления" + options={[ + { value: "", label: "Все статусы" }, + ...Object.entries(statusMeta).map(([value, meta]) => ({ value, label: meta.label })), + ]} + /> - + onChange={actions.setEmployeeFilter} + ariaLabel="Сотрудник" + options={[ + { value: "", label: "Все сотрудники" }, + ...state.employees.map((employee) => ({ + value: String(employee.id), + label: displayUserName(employee), + })), + ]} + />
diff --git a/frontend/src/components/tasks/TaskBoardNavigator.tsx b/frontend/src/components/tasks/TaskBoardNavigator.tsx index 649d9567..f8835a00 100644 --- a/frontend/src/components/tasks/TaskBoardNavigator.tsx +++ b/frontend/src/components/tasks/TaskBoardNavigator.tsx @@ -34,6 +34,7 @@ import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader"; import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu"; import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; import { displayUserName } from "@/lib/shared"; import type { TaskBoardGroup, TaskBoardSummary } from "@/types/api"; @@ -607,26 +608,26 @@ export default function TaskBoardNavigator({ ariaLabel="Фильтры досок" >
- - + onChange={setAccessFilter} + ariaLabel="Фильтр досок по доступу" + options={[ + { value: "all", label: "Любой доступ" }, + { value: "private", label: "Только личные" }, + { value: "shared", label: "Только общие" }, + ] satisfies { value: BoardAccessFilter; label: string }[]} + /> {activeFiltersCount > 0 ? ( + + {open + ? createPortal( + , + document.body, + ) + : null} +
+ ); +} diff --git a/frontend/src/hooks/usePayrollDesktopWideMode.ts b/frontend/src/hooks/usePayrollDesktopWideMode.ts index d6af8cf6..77b122c0 100644 --- a/frontend/src/hooks/usePayrollDesktopWideMode.ts +++ b/frontend/src/hooks/usePayrollDesktopWideMode.ts @@ -35,11 +35,30 @@ export function usePayrollDesktopWideMode(userId?: number | null) { () => false, ); + const subscribeDesktopViewport = useCallback((notify: () => void) => { + const query = window.matchMedia("(min-width: 1024px)"); + query.addEventListener("change", notify); + return () => query.removeEventListener("change", notify); + }, []); + const getDesktopViewportSnapshot = useCallback( + () => window.matchMedia("(min-width: 1024px)").matches, + [], + ); + const desktopViewport = useSyncExternalStore( + subscribeDesktopViewport, + getDesktopViewportSnapshot, + () => false, + ); + const changeDesktopWideMode = useCallback((enabled: boolean) => { if (!userId) return; window.localStorage.setItem(storageKey(userId), enabled ? "1" : "0"); window.dispatchEvent(new Event(PAYROLL_DESKTOP_WIDE_MODE_EVENT)); }, [userId]); - return [desktopWideMode, changeDesktopWideMode] as const; + return [ + desktopWideMode, + changeDesktopWideMode, + desktopWideMode && desktopViewport, + ] as const; } diff --git a/frontend/src/lib/api/calendar.ts b/frontend/src/lib/api/calendar.ts index 26c56a4c..c6e212a5 100644 --- a/frontend/src/lib/api/calendar.ts +++ b/frontend/src/lib/api/calendar.ts @@ -42,6 +42,8 @@ export function createCalendarApi(request: RequestFn, requestRaw: RawRequestFn) getCalendar: (id: number): Promise => request(`/api/v1/schedule/calendars/${id}/`), createCalendar: (data: { name: string; slug?: string }): Promise => request('/api/v1/schedule/calendars/', { method: 'POST', body: JSON.stringify(data) }), updateCalendar: (id: number, data: { name?: string; slug?: string }): Promise => request(`/api/v1/schedule/calendars/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }), + updateCalendarAccessScope: (id: number, accessScope: "all" | "restricted"): Promise => + request(`/api/v1/schedule/calendars/${id}/access-scope/`, { method: 'PATCH', body: JSON.stringify({ access_scope: accessScope }) }), deleteCalendar: (id: number): Promise => request(`/api/v1/schedule/calendars/${id}/`, { method: 'DELETE' }), // Events getEvent: (id: number): Promise => request(`/api/v1/schedule/events/${id}/`), @@ -52,7 +54,7 @@ export function createCalendarApi(request: RequestFn, requestRaw: RawRequestFn) // Participants getCalendarParticipants: (calendarId: number): Promise => request(`/api/v1/schedule/calendars/${calendarId}/participants/`), addCalendarParticipant: (calendarId: number, userId: number, distinction: string = 'viewer') => - request(`/api/v1/schedule/calendars/${calendarId}/add-participant/`, { method: 'POST', body: JSON.stringify({ user_id: userId, distinction }) }), + request(`/api/v1/schedule/calendars/${calendarId}/add-participant/`, { method: 'POST', body: JSON.stringify({ user_id: userId, role: distinction }) }), removeCalendarParticipant: (calendarId: number, userId: number): Promise => request(`/api/v1/schedule/calendars/${calendarId}/remove-participant/${userId}/`, { method: 'DELETE' }), getEventParticipants: (eventId: number) => request(`/api/v1/schedule/relations/?event=${eventId}`), diff --git a/frontend/src/lib/calendar/ui.ts b/frontend/src/lib/calendar/ui.ts index f07ba52e..f110ca22 100644 --- a/frontend/src/lib/calendar/ui.ts +++ b/frontend/src/lib/calendar/ui.ts @@ -17,6 +17,7 @@ export interface CalendarParticipantsTarget { can_manage_participants?: boolean; type?: string | null; context_type?: string | null; + access_scope?: "all" | "restricted"; } function parsePositiveInt(value: string | null): number | null { @@ -110,5 +111,6 @@ export function toParticipantsTarget( can_manage_participants: calendar.can_manage_participants, type: calendar.type, context_type: calendar.context_type, + access_scope: calendar.access_scope, }; } diff --git a/frontend/src/lib/payroll-attendance.test.js b/frontend/src/lib/payroll-attendance.test.js index 71113090..a2fcdca9 100644 --- a/frontend/src/lib/payroll-attendance.test.js +++ b/frontend/src/lib/payroll-attendance.test.js @@ -8,6 +8,7 @@ import { getPayrollAttendanceModeSummary, isAttendancePayrollWorkRecord, } from "./payroll-attendance.ts"; +import { formatPayrollPoints, normalizePayrollPoints } from "./payroll.ts"; test("attendance warnings use the correct employee form", () => { assert.equal(formatPayrollAttendanceEmployeeCount(1), "1 сотрудника"); @@ -18,8 +19,15 @@ test("attendance warnings use the correct employee form", () => { test("attendance work metrics are explicitly displayed as points", () => { assert.equal(isAttendancePayrollWorkRecord("attendance"), true); assert.equal(isAttendancePayrollWorkRecord("manual"), false); - assert.equal(formatPayrollWorkMetric("95.3125", "attendance"), "95.3125 балл."); - assert.equal(formatPayrollWorkMetric("110.0000", "manual"), "110.0000"); + assert.equal(formatPayrollWorkMetric("95.3125", "attendance"), "95,31 балл."); + assert.equal(formatPayrollWorkMetric("110.0000", "manual"), "110"); +}); + +test("payroll points are rounded to hundredths", () => { + assert.equal(formatPayrollPoints("95.3125"), "95,31"); + assert.equal(formatPayrollPoints("5.0000"), "5"); + assert.equal(normalizePayrollPoints("95.3125"), "95.31"); + assert.equal(normalizePayrollPoints("5.0000"), "5.00"); }); test("attendance preview selects the summary for the chosen import mode", () => { diff --git a/frontend/src/lib/payroll-attendance.ts b/frontend/src/lib/payroll-attendance.ts index ed3bd3ce..88988bbe 100644 --- a/frontend/src/lib/payroll-attendance.ts +++ b/frontend/src/lib/payroll-attendance.ts @@ -6,6 +6,7 @@ import type { PayrollAttendanceWorkModeSummary, PayrollAttendanceWorkPreview, } from "@/lib/api/finance"; +import { formatPayrollPoints } from "@/lib/payroll"; export const payrollAttendanceActionMeta: Record< PayrollAttendanceWorkAction, @@ -35,7 +36,7 @@ export function isAttendancePayrollWorkRecord(source?: string | null): boolean { } export function formatPayrollWorkMetric(value: string, source?: string | null): string { - return `${value}${isAttendancePayrollWorkRecord(source) ? " балл." : ""}`; + return `${formatPayrollPoints(value)}${isAttendancePayrollWorkRecord(source) ? " балл." : ""}`; } function pluralize(count: number, one: string, few: string, many: string): string { diff --git a/frontend/src/lib/payroll.ts b/frontend/src/lib/payroll.ts index 03a4a7d1..9fae52f2 100644 --- a/frontend/src/lib/payroll.ts +++ b/frontend/src/lib/payroll.ts @@ -44,6 +44,30 @@ export function formatPayrollMoney(value: string | number, currency = "RUB"): st } } +export function formatPayrollPoints( + value: string | number | null | undefined, + emptyValue = "—", +): string { + if (value == null || value === "") return emptyValue; + const points = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(points)) return String(value); + return new Intl.NumberFormat("ru-RU", { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(points); +} + +export function normalizePayrollPoints( + value: string | number | null | undefined, + emptyValue = "", +): string { + if (value == null || value === "") return emptyValue; + const normalized = typeof value === "string" ? value.trim().replace(",", ".") : value; + const points = Number(normalized); + if (!Number.isFinite(points)) return String(value); + return (Math.round((points + Number.EPSILON) * 100) / 100).toFixed(2); +} + export function formatPayrollDate(value: string | null | undefined): string { if (!value) return "—"; const date = parseDateOnly(value) || new Date(value); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d52c5d93..745d0eb0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1781,6 +1781,7 @@ export interface Calendar { context_object_id?: number | null; context_type?: string | null; flags?: Record; + access_scope?: "all" | "restricted"; user_role?: string | null; can_create_events?: boolean; can_edit_calendar?: boolean;