Refactor application workflows and update UI components
This commit is contained in:
parent
a96b0fcdce
commit
d4edea855c
@ -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):
|
||||
|
||||
@ -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):
|
||||
"""Добавить участника к календарю.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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="Доступ",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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 может добавить участника к календарю."""
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 })),
|
||||
];
|
||||
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
@ -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}
|
||||
|
||||
36
frontend/src/components/finance/admin/PayrollPeriodMenu.tsx
Normal file
36
frontend/src/components/finance/admin/PayrollPeriodMenu.tsx
Normal 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" : ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
230
frontend/src/components/ui/ProjectSelectMenu.tsx
Normal file
230
frontend/src/components/ui/ProjectSelectMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}`),
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user