Refactor application workflows and update UI components

This commit is contained in:
lizerk 2026-08-19 14:05:02 +03:00
parent a96b0fcdce
commit d4edea855c
40 changed files with 1135 additions and 621 deletions

View File

@ -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):

View File

@ -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):
"""Добавить участника к календарю.

View File

@ -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)

View File

@ -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="Доступ",
),
),
]

View File

@ -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,

View File

@ -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:

View File

@ -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()

View File

@ -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 может добавить участника к календарю."""

View File

@ -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() {
<CompactFilterPanel open={filtersOpen} anchorId="equipment-filter-trigger" onClose={() => setFiltersOpen(false)} ariaLabel="Фильтры оборудования">
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="">Все статусы</option>
{Object.entries(statusMeta).map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="">Все категории</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="">Все отделы</option>
{departments.map((dep) => (
<option key={dep.id} value={dep.id}>{dep.name}</option>
))}
</select>
<select value={responsibleFilter} onChange={(e) => setResponsibleFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="">Все сотрудники</option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>{displayUserName(emp)}</option>
))}
</select>
<ProjectSelectMenu
value={statusFilter}
onChange={setStatusFilter}
ariaLabel="Статус оборудования"
options={[{ value: "", label: "Все статусы" }, ...Object.entries(statusMeta).map(([value, meta]) => ({ value, label: meta.label }))]}
/>
<ProjectSelectMenu
value={categoryFilter}
onChange={setCategoryFilter}
ariaLabel="Категория оборудования"
options={[{ value: "", label: "Все категории" }, ...categories.map((category) => ({ value: String(category.id), label: category.name }))]}
/>
<ProjectSelectMenu
value={departmentFilter}
onChange={setDepartmentFilter}
ariaLabel="Отдел оборудования"
options={[{ value: "", label: "Все отделы" }, ...departments.map((department) => ({ value: String(department.id), label: department.name }))]}
/>
<ProjectSelectMenu
value={responsibleFilter}
onChange={setResponsibleFilter}
ariaLabel="Ответственный сотрудник"
options={[{ value: "", label: "Все сотрудники" }, ...employees.map((employee) => ({ value: String(employee.id), label: displayUserName(employee) }))]}
/>
</div>
<div className="flex items-center gap-2">
<span className="app-text-muted shrink-0 text-xs">Дата покупки:</span>

View File

@ -84,7 +84,7 @@ export default function FinancesPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<AppShell
desktopWideMode={activeTab === "management" && desktopWideMode}
desktopWideMode={activeTab === "management" && effectiveDesktopWideMode}
onDesktopWideModeChange={changeDesktopWideMode}
>
<div className={`min-w-0 space-y-6 ${activeTab === "management" && desktopWideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""}`}>
<div className={`min-w-0 space-y-6 ${activeTab === "management" && effectiveDesktopWideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""}`}>
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="flex min-w-0 flex-nowrap items-start justify-between gap-3">
<p className="app-card-caption shrink-0">Финансы</p>
{activeTab === "management" && showPayrollAdmin ? (
<div id="finance-management-actions" className="ml-auto min-w-0 max-w-full shrink" />
) : activeTab === "work" ? (
<div id="finance-work-period-selector" className="ml-auto min-w-0 flex-1 sm:w-auto sm:flex-none" />
<div id="finance-work-period-selector" className="ml-auto flex min-w-0 flex-1 justify-end sm:w-auto sm:flex-none" />
) : null}
</div>
@ -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}
/>

View File

@ -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;

View File

@ -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({
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Статус</span>
<select
<ProjectSelectMenu
value={status}
onChange={(event) => onStatusChange(event.target.value as "" | GuestVisitStatus)}
className="app-select h-10 w-full rounded-lg px-3 text-sm"
>
{statusOptions.map((option) => (
<option key={option.value || "all"} value={option.value}>{option.label}</option>
))}
</select>
onChange={onStatusChange}
ariaLabel="Статус заявки гостя"
options={statusOptions}
/>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Доступ с</span>
@ -1707,15 +1705,11 @@ function GuestVisitControls({
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Бессрочный</span>
<select value={unlimitedFilter} onChange={(event) => onUnlimitedFilterChange(event.target.value)} className="app-select h-10 w-full rounded-lg px-3 text-sm">
<option value="">Любой</option><option value="true">Да</option><option value="false">Нет</option>
</select>
<ProjectSelectMenu value={unlimitedFilter} onChange={onUnlimitedFilterChange} ariaLabel="Бессрочный доступ" options={[{ value: "", label: "Любой" }, { value: "true", label: "Да" }, { value: "false", label: "Нет" }]} />
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Статус гостя</span>
<select value={placementFilter} onChange={(event) => onPlacementFilterChange(event.target.value)} className="app-select h-10 w-full rounded-lg px-3 text-sm">
<option value="">Любой</option><option value="true">Доступ есть</option><option value="false">Нет доступа</option>
</select>
<ProjectSelectMenu value={placementFilter} onChange={onPlacementFilterChange} ariaLabel="Статус гостя" options={[{ value: "", label: "Любой" }, { value: "true", label: "Доступ есть" }, { value: "false", label: "Нет доступа" }]} />
</label>
<div className="flex min-w-0 items-end">
<button type="button" onClick={onClearFilters} className="app-action-secondary h-10 w-full rounded-lg px-3 text-sm font-medium">Сбросить</button>
@ -3498,15 +3492,11 @@ function GuestRegistryControls({
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Черный список</span>
<select value={blacklistFilter} onChange={(event) => onBlacklistFilterChange(event.target.value)} className="app-select h-10 w-full rounded-lg px-3 text-sm">
<option value="">Любой</option><option value="false">Не в черном списке</option><option value="true">В черном списке</option>
</select>
<ProjectSelectMenu value={blacklistFilter} onChange={onBlacklistFilterChange} ariaLabel="Черный список" options={[{ value: "", label: "Любой" }, { value: "false", label: "Не в черном списке" }, { value: "true", label: "В черном списке" }]} />
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Доступ</span>
<select value={placementFilter} onChange={(event) => onPlacementFilterChange(event.target.value)} className="app-select h-10 w-full rounded-lg px-3 text-sm">
<option value="">Любой</option><option value="true">Доступ есть</option><option value="false">Нет доступа</option>
</select>
<ProjectSelectMenu value={placementFilter} onChange={onPlacementFilterChange} ariaLabel="Доступ гостя" options={[{ value: "", label: "Любой" }, { value: "true", label: "Доступ есть" }, { value: "false", label: "Нет доступа" }]} />
</label>
<div className="flex min-w-0 items-end">
<button type="button" onClick={onClearFilters} className="app-action-secondary h-10 w-full rounded-lg px-3 text-sm font-medium">Сбросить</button>

View File

@ -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="Фильтры уведомлений"
>
<div className="flex flex-col gap-2">
<select
<ProjectSelectMenu
value={filterRead}
onChange={(e) => setFilterRead(e.target.value as NotificationReadFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="all">Все уведомления</option>
<option value="unread">Только непрочитанные</option>
<option value="read">Только прочитанные</option>
</select>
onChange={setFilterRead}
ariaLabel="Фильтр по прочтению"
options={[
{ value: 'all', label: 'Все уведомления' },
{ value: 'unread', label: 'Только непрочитанные' },
{ value: 'read', label: 'Только прочитанные' },
] satisfies { value: NotificationReadFilter; label: string }[]}
/>
<select
<ProjectSelectMenu
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все категории</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
onChange={setFilterCategory}
ariaLabel="Категория уведомлений"
options={[
{ value: '', label: 'Все категории' },
...categories.map((category) => ({ value: category, label: category })),
]}
/>
{(filterRead !== 'all' || filterCategory) && (
<button

View File

@ -8,6 +8,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 { useUser } from "@/contexts/UserContext";
import Link from "next/link";
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
@ -1099,75 +1100,48 @@ export default function ProcurementPage() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Срочность</span>
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все уровни срочности</option>
{Object.entries(urgencyMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<ProjectSelectMenu value={urgencyFilter} onChange={setUrgencyFilter} ariaLabel="Срочность" options={[{ value: "", label: "Все уровни срочности" }, ...Object.entries(urgencyMeta).map(([value, meta]) => ({ value, label: meta.label }))]} />
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Статус исполнения</span>
<select value={fulfillmentFilter} onChange={(e) => setFulfillmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все статусы исполнения</option>
{fulfillmentStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<ProjectSelectMenu value={fulfillmentFilter} onChange={setFulfillmentFilter} ariaLabel="Статус исполнения" options={[{ value: "", label: "Все статусы исполнения" }, ...fulfillmentStatusOptions]} />
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Отдел-заказчик</span>
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все отделы-заказчики</option>
{departments.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
<ProjectSelectMenu value={departmentFilter} onChange={setDepartmentFilter} ariaLabel="Отдел-заказчик" options={[{ value: "", label: "Все отделы-заказчики" }, ...departments.map((department) => ({ value: String(department.id), label: department.name }))]} />
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Отдел-исполнитель</span>
<select value={processingDepartmentFilter} onChange={(e) => setProcessingDepartmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все отделы-исполнители</option>
{processingDepartmentOptions.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
<ProjectSelectMenu value={processingDepartmentFilter} onChange={setProcessingDepartmentFilter} ariaLabel="Отдел-исполнитель" options={[{ value: "", label: "Все отделы-исполнители" }, ...processingDepartmentOptions.map((department) => ({ value: String(department.id), label: department.name }))]} />
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Заказчик</span>
<select value={requestorFilter} onChange={(e) => setRequestorFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все заказчики</option>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
<ProjectSelectMenu value={requestorFilter} onChange={setRequestorFilter} ariaLabel="Заказчик" options={[{ value: "", label: "Все заказчики" }, ...employees.map((employee) => ({ value: String(employee.id), label: displayUserName(employee) }))]} />
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Исполнитель</span>
<select value={executorFilter} onChange={(e) => setExecutorFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все исполнители</option>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
<ProjectSelectMenu value={executorFilter} onChange={setExecutorFilter} ariaLabel="Исполнитель" options={[{ value: "", label: "Все исполнители" }, ...employees.map((employee) => ({ value: String(employee.id), label: displayUserName(employee) }))]} />
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Быстрый период</span>
<select
<ProjectSelectMenu
value={periodFilter}
onChange={(e) => {
setPeriodFilter(e.target.value);
if (e.target.value) {
onChange={(value) => {
setPeriodFilter(value);
if (value) {
setDateFromFilter("");
setDateToFilter("");
}
}}
className="app-select w-full rounded-lg px-3 py-2 text-sm"
>
{periodOptions.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
</select>
ariaLabel="Быстрый период"
options={periodOptions}
/>
</label>
<label className="min-w-0">

View File

@ -8,13 +8,14 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { apiClient } from "@/lib/api";
import { useCalendar } from "@/contexts/CalendarContext";
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
import { Plus, ChevronDown, ChevronLeft, ChevronRight, Users, Pencil, Trash2 } from "lucide-react";
import { Plus, CalendarDays, ChevronLeft, ChevronRight, Users, Pencil, Trash2 } from "lucide-react";
import { CalendarModal } from "@/components/CalendarModal";
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
import { EventModal } from "@/components/EventModal";
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
import { ViewportAwareMenu } from "@/components/shared/ViewportAwareMenu";
import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
import {
buildCalendarOptions,
@ -126,19 +127,15 @@ const CustomToolbar = ({ onNavigate, onView, view, date }: ToolbarProps<Calendar
</div>
{/* Выбор вида */}
<div className="relative">
<select
value={view}
onChange={(e) => onView(e.target.value as View)}
className="app-select w-full appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
>
<option value="month">{viewLabels.month}</option>
<option value="week">{viewLabels.week}</option>
<option value="day">{viewLabels.day}</option>
<option value="agenda">{viewLabels.agenda}</option>
</select>
<ChevronDown size={16} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
</div>
<ProjectSelectMenu
value={view}
options={(["month", "week", "day", "agenda"] as const).map((value) => ({
value,
label: viewLabels[value],
}))}
onChange={onView}
ariaLabel="Вид календаря"
/>
</div>
);
};
@ -623,24 +620,17 @@ export function BigCalendar() {
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-3 lg:flex-row lg:items-center">
<label className="app-text-muted shrink-0 text-sm font-medium">Календарь</label>
<div className="relative min-w-0 flex-1">
<select
value={selectedCalendarId === null ? "" : selectedCalendarId}
onChange={(e) => {
const value = e.target.value;
setSelectedCalendarId(value === "" ? null : Number(value));
}}
className="app-select w-full min-w-0 appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
>
<option value="">📅 Все события</option>
{calendarOptions.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</select>
<ChevronDown size={16} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
</div>
<ProjectSelectMenu
className="min-w-0 flex-1"
value={selectedCalendarId === null ? "" : selectedCalendarId}
onChange={(value) => setSelectedCalendarId(value === "" ? null : Number(value))}
ariaLabel="Календарь"
triggerIcon={<CalendarDays className="h-4 w-4" />}
options={[
{ value: "", label: "Все события" },
...calendarOptions.map((calendar) => ({ value: calendar.id, label: calendar.name })),
]}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
@ -779,6 +769,7 @@ export function BigCalendar() {
canManageParticipants={selectedCalendar.can_manage_participants}
calendarType={selectedCalendar.type}
contextType={selectedCalendar.context_type}
initialAccessScope={selectedCalendar.access_scope}
/>
)}
</>

View File

@ -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<CalendarParticipant[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
@ -34,6 +47,9 @@ export default function CalendarParticipantsModal({
const [selectedRole, setSelectedRole] = useState<string>('viewer');
const [selectedDepartment, setSelectedDepartment] = useState<string>('');
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 (
<Modal isOpen={isOpen} onClose={onClose} size="md" noPadding noHeader>
<div className="overflow-y-auto">
<div className="app-divider flex items-center justify-between border-b px-4 py-3 sm:px-6 sm:py-4">
<div>
<h2 className="text-base font-semibold text-[var(--foreground)] sm:text-lg">Участники календаря</h2>
<p className="app-text-muted text-xs sm:text-sm">{calendarName}</p>
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Участники календаря"
size="md"
closeOnClickOutside
footer={<div className="flex justify-end pb-4"><button type="button" onClick={handleClose} className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium">Закрыть</button></div>}
>
<div className="space-y-4 pb-2">
<div className="app-surface-muted flex items-center gap-3 rounded-xl p-3">
<div className="app-avatar-fallback flex h-11 w-11 shrink-0 items-center justify-center rounded-xl">
<CalendarDays size={20} />
</div>
<button
onClick={onClose}
className="app-action-ghost rounded-lg p-2 transition"
>
<X size={20} className="app-text-muted" />
</button>
</div>
<div className="space-y-6 p-6">
{isDepartmentCalendar && (
<div className="app-surface-muted rounded-lg p-4">
<p className="app-text-muted text-sm">
Состав участников календаря отдела синхронизируется автоматически
из состава отдела и назначенных ролей. Управление через эту
модалку отключено для всех пользователей, включая администраторов.
</p>
</div>
)}
{/* Add Participant Section */}
{canManage && (
<div className="app-surface-muted space-y-3 rounded-lg p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-[var(--foreground)]">
Добавить участников
{selectedUserIds.length > 0 && (
<span className="app-accent-text ml-2">
(выбрано: {selectedUserIds.length})
</span>
)}
</h3>
<div className="flex gap-2">
<button
onClick={selectAll}
disabled={availableEmployees.length === 0}
className="app-link-accent text-xs disabled:opacity-50"
>
Выбрать всех
</button>
{selectedDepartment && (
<button
onClick={selectAllFromDepartment}
className="app-link-accent text-xs"
>
Добавить весь отдел
</button>
)}
<button
onClick={deselectAll}
disabled={selectedUserIds.length === 0}
className="app-text-muted text-xs hover:text-[var(--foreground)] disabled:opacity-50"
>
Снять выбор
</button>
</div>
</div>
<div className="flex gap-2">
<input
type="text"
placeholder="Поиск сотрудника..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
<select
value={selectedDepartment}
onChange={(e) => {
setSelectedDepartment(e.target.value);
setSelectedUserIds([]); // Сбрасываем выбор при смене отдела
}}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все отделы</option>
{departments.map(dept => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
{/* Employee list with checkboxes */}
{availableEmployees.length > 0 && (
<div className="app-surface max-h-60 overflow-y-auto rounded-lg">
{availableEmployees.map(emp => (
<label
key={emp.id}
className="flex cursor-pointer items-center gap-3 px-3 py-2 transition hover:bg-[var(--surface-secondary)]"
>
<input
type="checkbox"
checked={selectedUserIds.includes(emp.id)}
onChange={() => toggleUserSelection(emp.id)}
className="h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
/>
{emp.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(emp.avatar)}
alt={`${emp.first_name} ${emp.last_name}`.trim() || 'Сотрудник'}
className="app-avatar-frame h-9 w-9 shrink-0 rounded-full object-cover"
/>
) : (
<div className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<span className="text-xs font-medium">
{emp.first_name?.[0] || emp.last_name?.[0] || '?'}
</span>
</div>
)}
<div className="min-w-0">
<div className="truncate text-sm text-[var(--foreground)]">
{emp.first_name} {emp.last_name}
</div>
{emp.email && (
<div className="truncate app-text-muted text-xs">{emp.email}</div>
)}
</div>
</label>
))}
</div>
)}
{availableEmployees.length === 0 && (
<p className="app-text-muted py-4 text-center text-sm">
{searchQuery ? 'Сотрудники не найдены' : 'Все сотрудники уже добавлены'}
</p>
)}
<div className="flex gap-2">
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className="app-select flex-1 rounded-lg px-3 py-2 text-sm"
>
<option value="viewer">Просмотр</option>
<option value="editor">Редактор</option>
<option value="owner">Владелец</option>
</select>
<button
onClick={handleAddParticipants}
disabled={selectedUserIds.length === 0 || loading}
className="app-action-primary flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
<UserPlus size={16} />
{loading ? 'Добавление...' : `Добавить (${selectedUserIds.length})`}
</button>
</div>
</div>
)}
{/* Participants List */}
<div>
<h3 className="mb-3 text-sm font-medium text-[var(--foreground)]">
Текущие участники ({participants.length})
</h3>
<div className="space-y-2">
{participants.length === 0 ? (
<p className="app-text-muted py-4 text-center text-sm">Нет участников</p>
) : (
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 (
<div
key={participant.id}
className="app-surface-elevated flex items-center justify-between rounded-lg px-4 py-3 transition hover:bg-[var(--surface-secondary)]"
>
<div className="flex items-center gap-3">
{user?.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(user.avatar)}
alt={`${firstName} ${lastName}`.trim() || 'Сотрудник'}
className="app-avatar-frame h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="app-avatar-fallback flex h-10 w-10 items-center justify-center rounded-full">
<span className="text-sm font-medium">
{firstName[0]}{lastName[0]}
</span>
</div>
)}
<div>
<div className="text-sm font-medium text-[var(--foreground)]">
{firstName} {lastName}
</div>
{secondaryLabel ? (
<div className="app-text-muted text-xs">{secondaryLabel}</div>
) : null}
</div>
</div>
<div className="flex items-center gap-3">
<span className={`rounded-full px-3 py-1 text-xs font-medium ${
participant.distinction === 'owner'
? 'app-badge'
: participant.distinction === 'editor'
? 'app-selected app-accent-text'
: 'app-badge'
}`}>
{participant.distinction === 'owner' ? 'Владелец' :
participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'}
</span>
{canManage && participant.distinction !== 'owner' && (
<button
onClick={() => {
if (participant.user) {
void handleRemoveParticipant(participant.user.id);
}
}}
disabled={loading}
className="app-action-danger rounded-lg p-2 transition disabled:opacity-50"
title="Удалить участника"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
);
})
)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{calendarName}</p>
<p className="app-text-muted mt-0.5 text-xs">
{isDepartmentCalendar ? 'Состав определяется отделом' : accessScope === 'all' ? 'Доступ для всей компании' : 'Доступ выбранным участникам'}
</p>
</div>
</div>
<div className="app-divider flex justify-end gap-3 border-t px-6 py-4">
<button
onClick={onClose}
className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium"
>
Закрыть
</button>
</div>
{notice ? <div className={`${notice.tone === 'success' ? 'app-feedback-success' : 'app-feedback-danger'} rounded-xl p-3 text-sm`}>{notice.text}</div> : null}
{isDepartmentCalendar ? (
<div className="app-surface-muted flex items-start gap-3 rounded-xl p-3">
<Building2 size={18} className="app-accent-text mt-0.5 shrink-0" />
<p className="app-text-muted text-sm">Участники синхронизируются автоматически из состава отдела и назначенных ролей.</p>
</div>
) : canManage ? (
<section>
<p className="app-card-caption mb-2">Доступ</p>
<div className="grid gap-2 sm:grid-cols-2">
<button type="button" onClick={() => void handleAccessScopeChange('all')} disabled={accessUpdating} className={`${accessScope === 'all' ? 'app-selected border-[var(--accent-primary)]' : 'border-[var(--border-subtle)] hover:border-[var(--border-strong)]'} flex items-start gap-3 rounded-xl border p-3 text-left transition disabled:opacity-60`}>
<Users size={18} className="app-accent-text mt-0.5 shrink-0" />
<span><span className="block text-sm font-semibold">Вся компания</span><span className="app-text-muted mt-1 block text-xs">Просмотр доступен всем сотрудникам</span></span>
{accessScope === 'all' ? <Check size={16} className="app-accent-text ml-auto shrink-0" /> : null}
</button>
<button type="button" onClick={() => void handleAccessScopeChange('restricted')} disabled={accessUpdating} className={`${accessScope === 'restricted' ? 'app-selected border-[var(--accent-primary)]' : 'border-[var(--border-subtle)] hover:border-[var(--border-strong)]'} flex items-start gap-3 rounded-xl border p-3 text-left transition disabled:opacity-60`}>
<UserRoundCheck size={18} className="app-accent-text mt-0.5 shrink-0" />
<span><span className="block text-sm font-semibold">Выборочно</span><span className="app-text-muted mt-1 block text-xs">Только указанные участники</span></span>
{accessScope === 'restricted' ? <Check size={16} className="app-accent-text ml-auto shrink-0" /> : null}
</button>
</div>
</section>
) : null}
{canManage && accessScope === 'restricted' ? (
<section className="app-surface-muted space-y-3 rounded-xl p-3 sm:p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold">Добавить участников</p>
<p className="app-text-muted mt-0.5 text-xs">Выбрано: {selectedUserIds.length}</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={selectAll} disabled={availableEmployees.length === 0} className="app-badge rounded-full px-2.5 py-1 text-xs disabled:opacity-50">Выбрать всех</button>
{selectedDepartment ? <button type="button" onClick={selectAllFromDepartment} className="app-badge rounded-full px-2.5 py-1 text-xs">Весь отдел</button> : null}
<button type="button" onClick={deselectAll} disabled={selectedUserIds.length === 0} className="app-badge rounded-full px-2.5 py-1 text-xs disabled:opacity-50">Снять выбор</button>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(11rem,0.45fr)]">
<label className="relative min-w-0">
<Search size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input type="search" placeholder="Поиск сотрудников" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} className="app-input w-full rounded-xl py-2 pl-9 pr-3 text-sm" />
</label>
<ProjectSelectMenu
value={selectedDepartment}
onChange={(value) => { setSelectedDepartment(value); setSelectedUserIds([]); }}
ariaLabel="Отдел участников"
options={[{ value: '', label: 'Все отделы' }, ...departments.map((department) => ({ value: String(department.id), label: department.name }))]}
/>
</div>
<div className="tasks-column-scroll app-surface max-h-60 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-1">
{availableEmployees.length ? availableEmployees.map((employee) => {
const selected = selectedUserIds.includes(employee.id);
const fullName = `${employee.first_name || ''} ${employee.last_name || ''}`.trim() || employee.username || 'Сотрудник';
return <label key={employee.id} className={`${selected ? 'app-selected' : 'hover:bg-[var(--surface-secondary)]'} flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition`}>
<input type="checkbox" checked={selected} onChange={() => toggleUserSelection(employee.id)} className="h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]" />
{employee.avatar ? <Image src={resolveMediaUrl(employee.avatar)} alt={fullName} width={36} height={36} unoptimized className="app-avatar-frame h-9 w-9 shrink-0 rounded-full object-cover" /> : <div className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium">{employee.first_name?.[0] || employee.last_name?.[0] || '?'}</div>}
<span className="min-w-0"><span className="block truncate text-sm font-medium">{fullName}</span>{employee.email ? <span className="app-text-muted block truncate text-xs">{employee.email}</span> : null}</span>
</label>;
}) : <p className="app-text-muted px-3 py-6 text-center text-sm">{searchQuery ? 'Сотрудники не найдены' : 'Все сотрудники уже добавлены'}</p>}
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<ProjectSelectMenu
value={selectedRole}
onChange={setSelectedRole}
ariaLabel="Роль участника"
options={[{ value: 'viewer', label: 'Просмотр' }, { value: 'editor', label: 'Редактор' }, { value: 'owner', label: 'Владелец' }]}
/>
<button type="button" onClick={() => void handleAddParticipants()} disabled={selectedUserIds.length === 0 || loading} className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-50"><UserPlus size={16} />{loading ? 'Добавление…' : `Добавить (${selectedUserIds.length})`}</button>
</div>
</section>
) : null}
{accessScope === 'all' && !isDepartmentCalendar ? (
<div className="app-selected flex items-start gap-3 rounded-xl border p-3">
<Users size={18} className="app-accent-text mt-0.5 shrink-0" />
<div><p className="text-sm font-semibold">Вся компания</p><p className="app-text-muted mt-1 text-xs">Все действующие и новые сотрудники получают просмотр календаря автоматически.</p></div>
</div>
) : null}
<section>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="app-card-caption">{accessScope === 'all' ? 'Индивидуальные роли' : 'Текущие участники'}</p>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">{visibleParticipants.length}</span>
</div>
<div className="tasks-column-scroll max-h-72 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-1">
{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 <div key={participant.id} className="flex min-w-0 items-center gap-3 rounded-lg px-2.5 py-2 hover:bg-[var(--surface-secondary)]">
{user?.avatar ? <Image src={resolveMediaUrl(user.avatar)} alt={fullName} width={36} height={36} unoptimized className="app-avatar-frame h-9 w-9 shrink-0 rounded-full object-cover" /> : <div className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium">{user?.first_name?.[0] || user?.last_name?.[0] || '?'}</div>}
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{fullName}</p>{secondaryLabel ? <p className="app-text-muted truncate text-xs">{secondaryLabel}</p> : null}</div>
<span className={`${participant.distinction === 'editor' ? 'app-selected' : 'app-badge'} shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium`}>{participant.distinction === 'owner' ? 'Владелец' : participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'}</span>
{canManage && participant.distinction !== 'owner' ? <button type="button" onClick={() => participant.user && void handleRemoveParticipant(participant.user.id)} disabled={loading} className="app-icon-button shrink-0 rounded-lg p-2 text-rose-500 disabled:opacity-50" title="Удалить участника"><Trash2 size={15} /></button> : null}
</div>;
}) : <p className="app-text-muted px-3 py-6 text-center text-sm">Индивидуальные участники не добавлены</p>}
</div>
</section>
</div>
</Modal>
);

View File

@ -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 = (
<select
className="app-select w-full min-w-0 rounded-lg px-3 py-2.5 text-sm sm:w-auto sm:min-w-56"
value={selectedPeriodId || ""}
onChange={(event) => void loadWorkspace(Number(event.target.value))}
aria-label="Расчётный период выработки"
>
{workspace.periods.map((period) => (
<option key={period.id} value={period.id}>{period.name || period.code}</option>
))}
</select>
<ProjectSelectMenu
value={selectedPeriodId || workspace.periods[0].id}
options={workspace.periods.map((period) => ({
value: period.id,
label: period.name || period.code,
icon: <CalendarDays className="h-4 w-4" />,
}))}
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 ? "Изменить запись за день" : "Добавить запись за день"}
</h2>
</div>
<div className="mt-4 grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-4">
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
<label>
<span className="app-field-label">Дата</span>
<input
@ -228,7 +224,7 @@ export function PayrollDailyWorkSection({
<input
type="text"
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
value={formatPoints(selectedEntry?.target_points || workspace.daily_target_points)}
value={formatPayrollPoints(selectedEntry?.target_points || workspace.daily_target_points, "0")}
disabled
readOnly
/>
@ -240,11 +236,12 @@ export function PayrollDailyWorkSection({
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
inputMode="decimal"
min="0"
step="0.0001"
step="0.01"
value={actualPoints}
disabled={!selectedPeriod?.editable}
required
onChange={(event) => setActualPoints(event.target.value)}
onBlur={() => setActualPoints((value) => normalizePayrollPoints(value))}
/>
</label>
</div>
@ -294,7 +291,7 @@ export function PayrollDailyWorkSection({
</div>
<div className="app-surface-muted rounded-xl p-3">
<p className="app-text-muted text-xs">Норма за период</p>
<p className="mt-1 text-lg font-semibold text-[var(--foreground)]">{formatPoints(summary?.target_points)}</p>
<p className="mt-1 text-lg font-semibold text-[var(--foreground)]">{formatPayrollPoints(summary?.target_points, "0")}</p>
<p className="app-text-muted mt-1 text-[11px]">
{summary?.target_source === "saved_record" ? "Из сохранённой записи" : `По графику: ${summary?.workdays_count || 0} дн.`}
</p>
@ -304,7 +301,7 @@ export function PayrollDailyWorkSection({
<div className="min-w-0 grow basis-24">
<p className="app-text-muted text-xs">Фактически за период</p>
<p className="mt-1 text-lg font-semibold text-[var(--foreground)]">
{formatPoints(summary?.actual_points)} / {formatPoints(summary?.target_points)}
{formatPayrollPoints(summary?.actual_points, "0")} / {formatPayrollPoints(summary?.target_points, "0")}
</p>
</div>
{aggregateStatus ? <span className={`app-status-pill shrink-0 whitespace-nowrap ${aggregateStatus.className}`}>{aggregateStatus.label}</span> : null}
@ -334,8 +331,8 @@ export function PayrollDailyWorkSection({
</span>
<span className="flex shrink-0 items-center gap-3">
<span className="text-right">
<span className="block text-sm font-semibold text-[var(--foreground)]">{formatPoints(entry.actual_points)}</span>
<span className="app-text-muted block text-[11px]">из {formatPoints(entry.target_points)}</span>
<span className="block text-sm font-semibold text-[var(--foreground)]">{formatPayrollPoints(entry.actual_points, "0")}</span>
<span className="app-text-muted block text-[11px]">из {formatPayrollPoints(entry.target_points, "0")}</span>
</span>
{selectedPeriod?.editable ? <Pencil className="app-text-muted" size={15} /> : null}
</span>

View File

@ -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))}
/>
</label>
<label>
@ -676,7 +678,7 @@ export function PayrollBulkTargetPointsModal({
<span className="app-text-muted mt-0.5 block truncate text-xs">{employee.position || employee.department || "Сотрудник"}</span>
</span>
<span className="shrink-0 text-right">
<span className="block text-xs font-medium text-[var(--foreground)]">{current ? current.target_points : "Автоматически"}</span>
<span className="block text-xs font-medium text-[var(--foreground)]">{current ? formatPayrollPoints(current.target_points) : "Автоматически"}</span>
<span className="app-text-muted mt-0.5 block text-[11px]">
{mode === "automatic" ? "Будет рассчитана по графику" : current?.status === "draft" ? "Обновится черновик" : current ? "Новая версия" : "Новый черновик"}
</span>
@ -945,8 +947,8 @@ export function PayrollWorkRecordFormModal({
const fromAttendance = record?.source === "attendance";
const [form, setForm] = useState(() => ({
employee_id: record ? String(record.employee.id) : "",
target_points: record?.target_points_overridden ? record.target_points : "",
actual_points: record?.actual_points || "",
target_points: record?.target_points_overridden ? normalizePayrollPoints(record.target_points) : "",
actual_points: normalizePayrollPoints(record?.actual_points),
expected_point_amount: record?.expected_point_amount || "",
expected_gross: record?.expected_gross || "",
expected_recalculated_gross: record?.expected_recalculated_gross || "",
@ -962,8 +964,8 @@ export function PayrollWorkRecordFormModal({
void submitModalForm(() => onSubmit({
period_id: period.id,
employee_id: Number(form.employee_id),
target_points: form.target_points.trim() || null,
actual_points: form.actual_points,
target_points: form.target_points.trim() ? normalizePayrollPoints(form.target_points) : null,
actual_points: normalizePayrollPoints(form.actual_points),
expected_point_amount: optionalMoney(form.expected_point_amount),
expected_gross: optionalMoney(form.expected_gross),
expected_recalculated_gross: optionalMoney(form.expected_recalculated_gross),
@ -991,11 +993,11 @@ export function PayrollWorkRecordFormModal({
</label>
<label>
<span className="app-field-label">{fromAttendance ? "Норма, часы" : "Норма баллов"}</span>
<input type="number" className={inputClass} inputMode="decimal" min="0.0001" step="0.0001" placeholder="Рассчитается автоматически" value={form.target_points} onChange={(event) => setForm({ ...form, target_points: event.target.value })} />
<input type="number" className={inputClass} inputMode="decimal" min="0.01" step="0.01" placeholder="Рассчитается автоматически" value={form.target_points} onChange={(event) => setForm({ ...form, target_points: event.target.value })} onBlur={() => setForm((current) => ({ ...current, target_points: normalizePayrollPoints(current.target_points) }))} />
</label>
<label>
<span className="app-field-label">{fromAttendance ? "Отработано, часы" : "Фактические баллы"}</span>
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.0001" required value={form.actual_points} onChange={(event) => setForm({ ...form, actual_points: event.target.value })} />
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.01" required value={form.actual_points} onChange={(event) => setForm({ ...form, actual_points: event.target.value })} onBlur={() => setForm((current) => ({ ...current, actual_points: normalizePayrollPoints(current.actual_points) }))} />
</label>
</div>

View File

@ -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 (
<section className="app-surface-muted mb-4 rounded-xl">
<div className={`flex items-center gap-3 rounded-t-xl bg-[var(--surface-secondary)] px-3 py-2.5 sm:px-4 ${expanded ? "sticky top-0 z-30 border-b border-[var(--border-subtle)] shadow-[var(--shadow-card)]" : ""}`}>
<section className="app-surface-muted mb-4 overflow-hidden rounded-xl">
<div className={`flex items-center gap-3 bg-[var(--surface-secondary)] px-3 py-2.5 sm:px-4 ${expanded ? "sticky top-0 z-30 border-b border-[var(--border-subtle)] shadow-[var(--shadow-card)]" : "rounded-xl"}`}>
<span className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<History size={15} />
</span>
@ -121,21 +120,20 @@ export function PayrollInputHistory({
<span className="md:hidden">{expanded ? "Скрыть" : "Показать все"}</span>
</button>
) : null}
{!loading && !error ? <span className="app-counter">{events.length}</span> : null}
</div>
{loading ? (
{expanded && loading ? (
<div className="app-text-muted flex items-center gap-2 border-t border-[var(--border-subtle)] px-4 py-3 text-xs">
<Loader2 className="animate-spin" size={14} /> Загрузка истории…
</div>
) : error ? (
) : expanded && error ? (
<div className="app-feedback-danger border-t border-[var(--border-subtle)] px-4 py-3 text-xs">{error}</div>
) : events.length === 0 ? (
) : expanded && events.length === 0 ? (
<div className="app-text-muted border-t border-[var(--border-subtle)] px-4 py-3 text-xs">История пока пуста.</div>
) : (
) : expanded ? (
<>
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
{visibleEvents.map((event) => (
{events.map((event) => (
<div key={event.id} className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-[var(--accent-primary)]" />
<div className="min-w-0 flex-1">
@ -152,18 +150,9 @@ export function PayrollInputHistory({
</div>
))}
</div>
{canToggleHistory ? (
<button
type="button"
className="app-action-secondary m-3 inline-flex h-8 items-center rounded-lg px-3 text-xs font-medium sm:mx-4"
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
>
{toggleLabel}
</button>
) : null}
<button type="button" className="app-action-secondary m-3 inline-flex h-8 items-center rounded-lg px-3 text-xs font-medium sm:mx-4" onClick={() => setExpanded(false)} aria-expanded={expanded}>Скрыть историю</button>
</>
)}
) : null}
</section>
);
}
@ -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 })),
];

View File

@ -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 (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="payroll-readiness-summary mb-4 grid grid-cols-2 gap-2">
{values.map(({ label, value, icon: Icon }) => (
<div key={label} className="app-surface-muted rounded-xl p-3.5">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]"><Icon size={15} /><span className="text-xs">{label}</span></div>
<p className="mt-2 truncate text-lg font-semibold text-[var(--foreground)]" title={value}>{value}</p>
<div key={label} className="app-surface-muted flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-2">
<Icon className="app-text-muted shrink-0" size={14} />
<span className="min-w-0">
<span className="app-text-muted block truncate text-[10px]">{label}</span>
<span className="block truncate text-sm font-semibold text-[var(--foreground)]" title={value}>{value}</span>
</span>
</div>
))}
</div>
@ -281,6 +288,7 @@ function TableToolbar({
addLabel: string;
}) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const addMenuRef = useRef<HTMLDivElement | null>(null);
const hasAdditionalActions = Boolean(
onFillFromAttendance
@ -322,10 +330,38 @@ function TableToolbar({
onChange={onSort}
ariaLabel="Сортировка записей"
/>
<CompactFilterMenu
open={filtersOpen}
onToggle={() => setFiltersOpen((current) => !current)}
activeCount={status ? 1 : 0}
ariaLabel="Фильтры записей"
width={280}
>
<div className="flex flex-col gap-2">
<span className="app-field-label mb-0">Статус</span>
<ProjectSelectMenu
value={status}
onChange={onStatus}
ariaLabel="Статус записи"
options={[
{ value: "", label: "Все статусы" },
{ value: "draft", label: "Черновики" },
{ value: "approved", label: "Утверждённые" },
{ value: "voided", label: "Аннулированные" },
] satisfies { value: "" | PayrollApprovalStatus; label: string }[]}
/>
{status ? (
<button
type="button"
className="app-action-secondary mt-1 rounded-lg px-3 py-2 text-sm font-medium"
onClick={() => onStatus("")}
>
Сбросить фильтры
</button>
) : null}
</div>
</CompactFilterMenu>
</div>
<select className="payroll-admin-toolbar-status app-select min-w-0 rounded-lg px-3 py-2.5 text-sm" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
<option value="">Все статусы</option><option value="draft">Черновики</option><option value="approved">Утверждённые</option><option value="voided">Аннулированные</option>
</select>
{onAdd ? (
<div ref={addMenuRef} className="payroll-admin-toolbar-actions relative flex min-w-0 items-center gap-1">
<ModulePrimaryAction
@ -688,7 +724,7 @@ export function PayrollAdminWorkspace({
title: "Утвердить ставку", description: `${record.employee.display_name}: ${formatPayrollMoney(record.amount, record.currency)}.`, confirmLabel: "Утвердить", warning: "После утверждения ставку нельзя редактировать — только создать новую версию.", successMessage: "Ставка утверждена.", execute: () => 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({
</button>
) : null}
{workspace.periods.length ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<select className="app-select min-w-0 flex-1 rounded-lg px-3 py-2.5 text-sm sm:min-w-52" value={selectedPeriod?.id || ""} onChange={(event) => void loadWorkspace(Number(event.target.value), true)} aria-label="Расчётный период">
{workspace.periods.map((period) => <option value={period.id} key={period.id}>{period.name || period.code}</option>)}
</select>
<div className={`flex items-center gap-2 ${desktopWideMode ? "min-w-0 flex-1" : "shrink-0"}`}>
<PayrollPeriodMenu
expanded={desktopWideMode}
periods={workspace.periods}
selectedPeriodId={selectedPeriod?.id}
onChange={(periodId) => void loadWorkspace(periodId, true)}
/>
{selectedPeriod?.status === "open" && permissions.manage_inputs && !workspace.current_run ? (
<button
type="button"
@ -1028,6 +1068,7 @@ export function PayrollAdminWorkspace({
}}
icon={<Plus size={16} />}
label="Новый период"
className={desktopWideMode ? "" : "w-10 px-0 [&>span:last-child]:hidden"}
/>
) : null}
</div>
@ -1039,19 +1080,20 @@ export function PayrollAdminWorkspace({
<button
type="button"
onClick={() => setAccessModalOpen(true)}
className="app-action-ghost inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium"
className={`app-action-ghost inline-flex h-8 items-center justify-center rounded-lg text-xs font-medium ${desktopWideMode ? "gap-1.5 px-2.5" : "w-8"}`}
title="Настроить доступ к управлению финансами"
aria-label={desktopWideMode ? undefined : accessLabel}
>
<ShieldCheck size={14} />
{accessLabel}
{desktopWideMode ? accessLabel : null}
</button>
) : (
<span
className="app-text-muted inline-flex h-8 items-center gap-1.5 px-2.5 text-xs"
className={`app-text-muted inline-flex h-8 items-center justify-center text-xs ${desktopWideMode ? "gap-1.5 px-2.5" : "w-8"}`}
title="Доступ к управлению финансами"
>
<ShieldCheck size={14} />
{accessLabel}
{desktopWideMode ? accessLabel : null}
</span>
);
const headerTarget = headerTargetId && typeof document !== "undefined"
@ -1085,26 +1127,25 @@ export function PayrollAdminWorkspace({
<section className="app-surface rounded-2xl p-8 text-center"><CalendarEmpty /><p className="app-text-muted mx-auto mt-2 max-w-md text-sm">Без периода нельзя ввести ставки, выработку и начисления.</p>{permissions.manage_inputs ? <button type="button" className="app-action-primary mt-5 inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold" onClick={() => setPeriodModalOpen(true)}><Plus size={16} /> Создать период</button> : null}</section>
) : (
<>
<section className="app-surface rounded-2xl p-4 sm:p-5"><SummaryCards workspace={workspace} /></section>
<section className="app-surface rounded-2xl p-2 sm:p-3">
<div className="flex gap-1 overflow-x-auto" role="tablist" aria-label="Разделы управления зарплатой">
<div role="tablist" aria-label="Разделы управления зарплатой">
<HorizontalChipScroller ariaLabel="Разделы управления зарплатой">
{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 <button type="button" role="tab" aria-selected={tab === item.value} key={item.value} className={`${tab === item.value ? "app-chip-active" : "app-chip"} inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium sm:text-sm`} onClick={() => { setTab(item.value); setSearch(""); setStatus(""); }}>{item.label}{count > 0 ? <span className={`${tab === item.value ? "bg-white/20 text-white" : "app-counter"} inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px]`}>{count}</span> : null}</button>;
return <button type="button" role="tab" aria-selected={tab === item.value} key={item.value} className={`${tab === item.value ? "app-pill-active" : "app-pill"} inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition`} onClick={() => { setTab(item.value); setSearch(""); setStatus(""); }}>{item.label}{count > 0 ? <span className={`${tab === item.value ? "app-pill-count-active" : "app-pill-count"} app-badge px-1.5 py-0.5 text-[10px] font-bold`}>{count}</span> : null}</button>;
})}
</div>
</section>
</HorizontalChipScroller>
</div>
{tab === "readiness" ? (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
<section className="payroll-readiness-responsive app-surface rounded-2xl p-4 sm:p-5">
<div className="payroll-readiness-layout grid gap-5">
<div>
<SectionIntro title="Готовность к расчёту" description="Система запускает расчёт только по утверждённым данным и показывает, что ещё нужно подготовить." />
<SummaryCards workspace={workspace} />
<div className="space-y-2">
{[
{
@ -1214,26 +1255,29 @@ export function PayrollAdminWorkspace({
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
{tab === "summary" ? (
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
<SectionIntro title="Итоговая таблица" description="Сводные исходные данные, начисления, удержания и выплаты по всем сотрудникам за выбранный период." />
<div className="mb-4 flex justify-end">
<div className="mb-4 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--foreground)]">Итоговая таблица</h2>
</div>
<button
type="button"
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold"
className="app-action-secondary relative inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
onClick={() => {
setCarryoversEmployeeId(null);
setCarryoversModalOpen(true);
}}
aria-label="Переходящие остатки"
title="Переходящие остатки"
>
<WalletCards size={16} />
Переходящие остатки
{(workspace.carryovers?.incoming || 0) + (workspace.carryovers?.outgoing || 0) ? (
<span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">
<span className="app-counter absolute -right-1 -top-1 inline-flex h-5 min-w-5 px-1 text-[10px]">
{(workspace.carryovers?.incoming || 0) + (workspace.carryovers?.outgoing || 0)}
</span>
) : null}
</button>
</div>
<PayrollPeriodTableView data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => 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} />
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => 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} />
</section>
) : null}
{tab === "approval" ? (

View File

@ -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({
</div>
<div className="text-xs text-[var(--foreground)]">
<span className="app-text-muted mr-1 md:hidden">Выработка:</span>
<p>Норма: <b>{item.target_points} балл.</b></p>
<p className="mt-1">Факт: <b>{item.actual_points} балл.</b></p>
<p className="app-text-muted mt-1 text-[11px]">Дневная норма: {item.daily_target_points}</p>
<p>Норма: <b>{formatPayrollPoints(item.target_points)} балл.</b></p>
<p className="mt-1">Факт: <b>{formatPayrollPoints(item.actual_points)} балл.</b></p>
<p className="app-text-muted mt-1 text-[11px]">Дневная норма: {formatPayrollPoints(item.daily_target_points)}</p>
<span className="app-selected mt-2 inline-flex rounded-full px-2 py-1 text-[10px] font-semibold leading-none">
Из посещаемости
</span>
@ -303,7 +303,7 @@ export function PayrollAttendanceWorkModal({
<CalendarCheck2 className="app-accent-text mt-0.5 shrink-0" size={19} />
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
Полный рабочий день = {preview.policy.daily_target_points} балл.
Полный рабочий день = {formatPayrollPoints(preview.policy.daily_target_points)} балл.
</p>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
{preview.policy.description || preview.policy.label}

View File

@ -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 (
<ProjectSelectMenu
value={selectedPeriodId ?? firstPeriod.id}
options={periods.map((period) => ({
value: period.id,
label: period.name || period.code,
icon: <CalendarCheck2 className="h-4 w-4" />,
}))}
onChange={onChange}
ariaLabel="Расчётный период"
variant={expanded ? "field" : "compact"}
className={expanded ? "min-w-52 max-w-72 flex-1" : ""}
/>
);
}

View File

@ -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<void>;
onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void;
onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise<void>;
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<CompactPeriodPanel | null>("table");
const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState<Set<number>>(new Set());
const [paymentConfirmationError, setPaymentConfirmationError] = useState<string | null>(null);
const columnsMenuRef = useRef<HTMLDivElement>(null);
@ -1008,6 +1010,18 @@ export function PayrollPeriodTableView({
if (error) return <div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{error}</div>;
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 (
<div className="min-w-0 max-w-full">
<div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
{compact ? (
<div className="mb-3 grid grid-cols-3 gap-2" aria-label="Разделы итоговой таблицы">
<button
type="button"
className={`${compactPanel === "summary" ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium`}
onClick={() => toggleCompactPanel("summary")}
aria-pressed={compactPanel === "summary"}
>
<ChartNoAxesColumn size={14} />
<span className="truncate">Сводка</span>
</button>
<button
type="button"
className={`${compactPanel === "table" ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium`}
onClick={() => toggleCompactPanel("table")}
aria-pressed={compactPanel === "table"}
>
<Table2 size={14} />
<span className="truncate">Таблица</span>
</button>
<button
type="button"
className={`${compactPanel === "help" ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium`}
onClick={() => toggleCompactPanel("help")}
aria-pressed={compactPanel === "help"}
>
<CircleHelp size={14} />
<span className="truncate">Справка</span>
</button>
</div>
) : null}
{showSummary || showTable ? (
<div className={`${compact ? "" : "flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between"} mb-3`}>
{showSummary ? <div className="flex flex-wrap gap-2">
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Сотрудников <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.employee_count}</span></span>
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Рассчитано <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.calculated_count}</span></span>
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Готово <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.ready_count}</span></span>
@ -1346,8 +1393,8 @@ export function PayrollPeriodTableView({
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Не заполнено <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.incomplete_count}</span></span>
{data.summary.preliminary_count ? <span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Предварительно <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.preliminary_count}</span></span> : null}
{data.run ? <span className="app-selected inline-flex items-center rounded-full px-3 py-1.5 text-xs">Ревизия {data.run.revision}</span> : null}
</div>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
</div> : null}
{showTable ? <div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative block min-w-0 sm:w-72">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input
@ -1478,17 +1525,18 @@ export function PayrollPeriodTableView({
</div>
) : null}
</div>
</div>
</div> : null}
</div>
) : null}
{paymentConfirmationError ? <div className="app-feedback-danger mb-3 rounded-lg px-3 py-2.5 text-sm">{paymentConfirmationError}</div> : null}
{data.summary.preliminary_count ? (
{showHelp && data.summary.preliminary_count ? (
<div className="app-feedback-warning mb-3 rounded-lg px-3 py-2.5 text-sm">
Значения рассчитаны предварительно по текущим данным. Они обновляются после сохранения ячеек и станут официальными после запуска расчёта.
</div>
) : null}
<div className="app-text-muted mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
{showHelp ? <div className="app-text-muted mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
{canEdit ? <span>Enter или переход в другую ячейку — сохранить, Esc — отменить.</span> : null}
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[var(--muted-foreground)]" aria-hidden="true" />Серый текст — заполнено автоматически</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-emerald-300" aria-hidden="true" />Зелёный — значение выше выработки больше чем на 25%</span>
@ -1499,9 +1547,9 @@ export function PayrollPeriodTableView({
<span className="inline-flex items-center gap-1.5"><span className="h-3 border-l-2 border-[var(--border-strong)]" aria-hidden="true" />Толстая линия — граница блока</span>
<span className="inline-flex items-center gap-1.5"><CircleHelp className="app-accent-text" size={13} />Знак вопроса — логика и формула расчёта</span>
<span className="inline-flex items-center gap-1.5"><span className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-1 text-[10px] leading-3">+</span>Плюс — добавить комментарий; К:N — открыть обсуждение</span>
</div>
</div> : null}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
{showTable ? <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
<div
ref={tableScrollRef}
className="app-table-scroll max-h-[min(70vh,760px)] w-full max-w-full overflow-auto overscroll-contain rounded-xl border border-[var(--border-subtle)]"
@ -1587,7 +1635,7 @@ export function PayrollPeriodTableView({
) : null}
</table>
</div>
</DndContext>
</DndContext> : null}
{activeHelp ? <CalculationHelpModal help={activeHelp} rules={data.calculation_rules} onClose={() => setActiveHelp(null)} /> : null}
{breakdownEmployee ? (
<PayrollPointBreakdownModal

View File

@ -19,6 +19,7 @@ import type {
PayrollPointBreakdownDay,
} from "@/lib/api/finance";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import { formatPayrollPoints } from "@/lib/payroll";
import {
compareDailyPayrollProjection,
type PayrollProjectionDifference,
@ -48,14 +49,6 @@ const differenceClasses: Record<PayrollProjectionDifference["color"], string> =
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({
<CalendarDays className="app-accent-text mt-0.5 shrink-0" size={15} />
<div>
<p className="text-[var(--foreground)]">
Норма за период: <b>{formatPoints(data.target_points)}</b> · выработка: <b>{formatPoints(data.actual_points)}</b>
Норма за период: <b>{formatPayrollPoints(data.target_points)}</b> · выработка: <b>{formatPayrollPoints(data.actual_points)}</b>
</p>
<p className="app-text-muted mt-1">
{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])}
</button>
) : (
<span className="inline-block px-1 py-3">{formatPoints(day[row.id])}</span>
<span className="inline-block px-1 py-3">{formatPayrollPoints(day[row.id])}</span>
)}
</td>
);
@ -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)}
</button>
) : <span className="inline-block px-1 py-3">—</span>}
</td>

View File

@ -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 {

View File

@ -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) => <option key={employee.id} value={employee.id}>{candidateIds.has(employee.id) ? "★ " : ""}{employee.display_name}{employee.is_active ? "" : " (неактивен)"}</option>)}
</select>
<div className="text-xs text-[var(--muted-foreground)] md:text-right">
<p><b className="text-[var(--foreground)]">{row.entry_count}</b> дней · <b className="text-[var(--foreground)]">{row.points_total}</b> балл.</p>
<p><b className="text-[var(--foreground)]">{row.entry_count}</b> дней · <b className="text-[var(--foreground)]">{formatPayrollPoints(row.points_total)}</b> балл.</p>
{row.existing_count ? <p className="mt-1 text-[var(--warning-foreground)]">{row.existing_period_record ? "Есть итог за период" : `Совпадений по датам: ${row.existing_count}`}</p> : null}
</div>
</div>

View File

@ -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}
/>
)}

View File

@ -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="Дополнительные фильтры чатов"
>
<div className="flex flex-col gap-2">
<select value={filterUnread} onChange={(event) => setFilterUnread(event.target.value as MessageUnreadFilter)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="all">Все чаты</option><option value="unread">Только с непрочитанными</option><option value="read">Только прочитанные</option>
</select>
<select value={filterPinned} onChange={(event) => setFilterPinned(event.target.value as MessagePinnedFilter)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="all">Все чаты</option><option value="pinned">Только закрепленные</option><option value="unpinned">Только незакрепленные</option>
</select>
<ProjectSelectMenu
value={filterUnread}
options={[
{ value: "all", label: "Все чаты" },
{ value: "unread", label: "Только с непрочитанными" },
{ value: "read", label: "Только прочитанные" },
] satisfies { value: MessageUnreadFilter; label: string }[]}
onChange={setFilterUnread}
ariaLabel="Фильтр по прочтению"
/>
<ProjectSelectMenu
value={filterPinned}
options={[
{ value: "all", label: "Все чаты" },
{ value: "pinned", label: "Только закреплённые" },
{ value: "unpinned", label: "Только незакреплённые" },
] satisfies { value: MessagePinnedFilter; label: string }[]}
onChange={setFilterPinned}
ariaLabel="Фильтр по закреплению"
/>
{filterUnread !== "all" || filterPinned !== "all" ? (
<button type="button" onClick={() => { setFilterUnread("all"); setFilterPinned("all"); }} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Очистить фильтры</button>
) : null}

View File

@ -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({
</button>
) : null}
</div>
<select
<ProjectSelectMenu
value={state.view}
onChange={(event) => actions.setView(event.target.value as RequestQuickScope)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все заявления</option>
<option value="mine">Мои заявления</option>
<option value="addressed">Адресованные мне</option>
<option value="decision">Требуют решения</option>
</select>
onChange={actions.setView}
ariaLabel="Область заявлений"
options={[
{ value: "", label: "Все заявления" },
{ value: "mine", label: "Мои заявления" },
{ value: "addressed", label: "Адресованные мне" },
{ value: "decision", label: "Требуют решения" },
] satisfies { value: RequestQuickScope; label: string }[]}
/>
<select
<ProjectSelectMenu
value={state.typeFilter}
onChange={(event) => actions.setTypeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Тип заявления</option>
{Object.entries(requestTypeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
onChange={actions.setTypeFilter}
ariaLabel="Тип заявления"
options={[
{ value: "", label: "Все типы" },
...Object.entries(requestTypeLabels).map(([value, label]) => ({ value, label })),
]}
/>
<select
<ProjectSelectMenu
value={state.statusFilter}
onChange={(event) => actions.setStatusFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Статус заявления</option>
{Object.entries(statusMeta).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
onChange={actions.setStatusFilter}
ariaLabel="Статус заявления"
options={[
{ value: "", label: "Все статусы" },
...Object.entries(statusMeta).map(([value, meta]) => ({ value, label: meta.label })),
]}
/>
<select
<ProjectSelectMenu
value={state.employeeFilter}
onChange={(event) => actions.setEmployeeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все сотрудники</option>
{state.employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
onChange={actions.setEmployeeFilter}
ariaLabel="Сотрудник"
options={[
{ value: "", label: "Все сотрудники" },
...state.employees.map((employee) => ({
value: String(employee.id),
label: displayUserName(employee),
})),
]}
/>
<div className="min-w-0 space-y-1.5 sm:col-span-2">
<label className="app-text-muted px-1 text-xs font-medium">Дата создания</label>

View File

@ -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="Фильтры досок"
>
<div className="flex flex-col gap-2">
<select
<ProjectSelectMenu
value={unreadFilter}
onChange={(event) => setUnreadFilter(event.target.value as BoardUnreadFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
aria-label="Фильтр досок по уведомлениям"
>
<option value="all">Все доски</option>
<option value="unread">Только с непрочитанными</option>
<option value="read">Без непрочитанных</option>
</select>
<select
onChange={setUnreadFilter}
ariaLabel="Фильтр досок по уведомлениям"
options={[
{ value: "all", label: "Все доски" },
{ value: "unread", label: "Только с непрочитанными" },
{ value: "read", label: "Без непрочитанных" },
] satisfies { value: BoardUnreadFilter; label: string }[]}
/>
<ProjectSelectMenu
value={accessFilter}
onChange={(event) => setAccessFilter(event.target.value as BoardAccessFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
aria-label="Фильтр досок по доступу"
>
<option value="all">Любой доступ</option>
<option value="private">Только личные</option>
<option value="shared">Только общие</option>
</select>
onChange={setAccessFilter}
ariaLabel="Фильтр досок по доступу"
options={[
{ value: "all", label: "Любой доступ" },
{ value: "private", label: "Только личные" },
{ value: "shared", label: "Только общие" },
] satisfies { value: BoardAccessFilter; label: string }[]}
/>
{activeFiltersCount > 0 ? (
<button
type="button"

View File

@ -50,6 +50,7 @@ export function CompactFilterMenu({
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (target instanceof Element && target.closest("[data-app-popover-root]")) return;
if (
!containerRef.current?.contains(target) &&
!menuRef.current?.contains(target)
@ -175,6 +176,7 @@ export function CompactFilterPanel({
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (target instanceof Element && target.closest("[data-app-popover-root]")) return;
const anchor = document.getElementById(anchorId);
if (!anchor?.contains(target) && !menuRef.current?.contains(target)) onClose();
};

View File

@ -7,6 +7,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
@ -57,19 +58,49 @@ export function HorizontalChipScroller({
);
}, []);
useEffect(() => {
const revealActiveChip = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const frame = window.requestAnimationFrame(updateScrollHints);
const resizeObserver = new ResizeObserver(updateScrollHints);
const activeChip = container.querySelector<HTMLElement>(
'[aria-selected="true"], [aria-pressed="true"], [aria-current]:not([aria-current="false"]), [data-active="true"], [data-selected="true"], .app-pill-active, .app-chip-active, .app-selected',
);
if (!activeChip) return;
const containerRect = container.getBoundingClientRect();
const chipRect = activeChip.getBoundingClientRect();
const edgePadding = 8;
let nextScrollLeft = container.scrollLeft;
if (chipRect.left < containerRect.left + edgePadding) {
nextScrollLeft -= containerRect.left + edgePadding - chipRect.left;
} else if (chipRect.right > containerRect.right - edgePadding) {
nextScrollLeft += chipRect.right - (containerRect.right - edgePadding);
}
if (Math.abs(nextScrollLeft - container.scrollLeft) > 1) {
container.scrollTo({ left: nextScrollLeft, behavior: "auto" });
}
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const syncScrollState = () => {
revealActiveChip();
updateScrollHints();
};
syncScrollState();
const frame = window.requestAnimationFrame(syncScrollState);
const resizeObserver = new ResizeObserver(syncScrollState);
resizeObserver.observe(container);
return () => {
window.cancelAnimationFrame(frame);
resizeObserver.disconnect();
};
}, [children, updateScrollHints]);
}, [children, revealActiveChip, updateScrollHints]);
useEffect(() => {
const container = containerRef.current;

View File

@ -0,0 +1,230 @@
"use client";
import { Check, ChevronDown, ListFilter } from "lucide-react";
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
export type ProjectSelectOption<T extends string | number = string> = {
value: T;
label: string;
icon?: ReactNode;
disabled?: boolean;
};
type ProjectSelectMenuProps<T extends string | number> = {
value: T;
options: readonly ProjectSelectOption<T>[];
onChange: (value: T) => void;
ariaLabel: string;
variant?: "field" | "compact" | "responsive";
triggerIcon?: ReactNode;
disabled?: boolean;
className?: string;
menuWidth?: number;
};
type MenuPosition = {
left: number;
top: number;
width: number;
};
export function ProjectSelectMenu<T extends string | number>({
value,
options,
onChange,
ariaLabel,
variant = "field",
triggerIcon,
disabled = false,
className = "",
menuWidth,
}: ProjectSelectMenuProps<T>) {
const [open, setOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuId = useId();
const activeOption = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (!containerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
setOpen(false);
setMenuPosition(null);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
setMenuPosition(null);
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const margin = 12;
const gap = 8;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const preferredWidth = menuWidth ?? (variant === "compact" ? 240 : Math.max(224, triggerRect.width));
const width = Math.min(preferredWidth, window.innerWidth - margin * 2);
const left = Math.min(
Math.max(triggerRect.right - width, margin),
window.innerWidth - width - margin,
);
const fitsBelow = triggerRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(margin, triggerRect.top - gap - menuRect.height);
setMenuPosition({ left, top, width });
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [menuWidth, open, variant]);
if (!activeOption) return null;
const activeIcon = activeOption.icon ?? triggerIcon ?? <ListFilter className="h-4 w-4" aria-hidden="true" />;
return (
<div
ref={containerRef}
className={`${
variant === "compact"
? "relative shrink-0"
: variant === "responsive"
? "relative shrink-0 sm:min-w-52 sm:max-w-72"
: "relative min-w-0"
} ${className}`}
>
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={() => {
setMenuPosition(null);
setOpen((current) => !current);
}}
aria-label={`${ariaLabel}: ${activeOption.label}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
title={variant !== "field" ? activeOption.label : undefined}
className={`${
open
? "app-selected app-accent-text"
: variant === "compact"
? "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
: variant === "responsive"
? "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
: "app-input app-text-primary hover:bg-[var(--surface-tertiary)]"
} ${
variant === "compact"
? "flex h-[42px] w-[42px] items-center justify-center rounded-lg"
: variant === "responsive"
? "flex h-[42px] w-[42px] items-center justify-center rounded-lg sm:w-full sm:min-w-52 sm:justify-start sm:gap-2 sm:px-3 sm:text-sm"
: "flex h-[42px] w-full min-w-0 items-center gap-2 rounded-lg px-3 text-sm"
} transition disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent-primary)]`}
>
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden="true">
{activeIcon}
</span>
{variant !== "compact" ? (
<>
<span className={`${variant === "responsive" ? "hidden sm:block" : "block"} min-w-0 flex-1 truncate text-left`}>{activeOption.label}</span>
<ChevronDown
className={`${variant === "responsive" ? "hidden sm:block" : "block"} app-text-muted h-4 w-4 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</>
) : null}
</button>
{open
? createPortal(
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={ariaLabel}
data-app-popover-root="select-menu"
className="app-card fixed z-[110] max-h-[calc(100dvh-1.5rem)] overflow-y-auto rounded-xl p-1.5 shadow-xl"
style={{
left: menuPosition?.left ?? 0,
top: menuPosition?.top ?? 0,
width: menuPosition?.width ?? menuWidth ?? 224,
visibility: menuPosition ? "visible" : "hidden",
}}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={String(option.value)}
type="button"
role="menuitemradio"
aria-checked={selected}
disabled={option.disabled}
onClick={() => {
if (!selected) onChange(option.value);
setOpen(false);
setMenuPosition(null);
triggerRef.current?.focus();
}}
className={`${
selected
? "app-selected app-accent-text"
: "app-text-muted hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"
} flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-40`}
>
{option.icon ?? triggerIcon ? (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden="true">
{option.icon ?? triggerIcon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden="true" /> : null}
</button>
);
})}
</div>,
document.body,
)
: null}
</div>
);
}

View File

@ -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;
}

View File

@ -42,6 +42,8 @@ export function createCalendarApi(request: RequestFn, requestRaw: RawRequestFn)
getCalendar: (id: number): Promise<Calendar> => request(`/api/v1/schedule/calendars/${id}/`),
createCalendar: (data: { name: string; slug?: string }): Promise<Calendar> => request('/api/v1/schedule/calendars/', { method: 'POST', body: JSON.stringify(data) }),
updateCalendar: (id: number, data: { name?: string; slug?: string }): Promise<Calendar> => request(`/api/v1/schedule/calendars/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
updateCalendarAccessScope: (id: number, accessScope: "all" | "restricted"): Promise<Calendar> =>
request(`/api/v1/schedule/calendars/${id}/access-scope/`, { method: 'PATCH', body: JSON.stringify({ access_scope: accessScope }) }),
deleteCalendar: (id: number): Promise<void> => request(`/api/v1/schedule/calendars/${id}/`, { method: 'DELETE' }),
// Events
getEvent: (id: number): Promise<CalendarEvent> => request(`/api/v1/schedule/events/${id}/`),
@ -52,7 +54,7 @@ export function createCalendarApi(request: RequestFn, requestRaw: RawRequestFn)
// Participants
getCalendarParticipants: (calendarId: number): Promise<CalendarParticipant[]> => 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<void> =>
request(`/api/v1/schedule/calendars/${calendarId}/remove-participant/${userId}/`, { method: 'DELETE' }),
getEventParticipants: (eventId: number) => request(`/api/v1/schedule/relations/?event=${eventId}`),

View File

@ -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,
};
}

View File

@ -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", () => {

View File

@ -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 {

View File

@ -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);

View File

@ -1781,6 +1781,7 @@ export interface Calendar {
context_object_id?: number | null;
context_type?: string | null;
flags?: Record<string, unknown>;
access_scope?: "all" | "restricted";
user_role?: string | null;
can_create_events?: boolean;
can_edit_calendar?: boolean;