1179 lines
52 KiB
TypeScript
1179 lines
52 KiB
TypeScript
"use client";
|
||
|
||
import { AlertTriangle, Check, Loader2, Search } from "lucide-react";
|
||
import { useMemo, useState, type FormEvent } from "react";
|
||
|
||
import { Modal } from "@/components/ui";
|
||
import type {
|
||
PayrollAdminEmployee,
|
||
PayrollAdminInputLine,
|
||
PayrollAdminPayRate,
|
||
PayrollAdminPeriod,
|
||
PayrollAdminWorkRecord,
|
||
PayrollBulkPayRateResult,
|
||
PayrollBulkPayRateWrite,
|
||
PayrollBulkPointRateMode,
|
||
PayrollBulkPointRateResult,
|
||
PayrollBulkPointRateWrite,
|
||
PayrollBulkTargetPointsMode,
|
||
PayrollBulkTargetPointsResult,
|
||
PayrollBulkTargetPointsWrite,
|
||
PayrollComponent,
|
||
PayrollInputLineWrite,
|
||
PayrollPayRateWrite,
|
||
PayrollPeriodWrite,
|
||
PayrollWorkRecordWrite,
|
||
} from "@/lib/api/finance";
|
||
import {
|
||
getDefaultPayrollPeriodForm,
|
||
getPayrollAdminError,
|
||
isPayrollAdminStaleConflict,
|
||
} from "@/lib/payroll-admin";
|
||
|
||
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";
|
||
|
||
function ModalActions({
|
||
busy,
|
||
disabled = false,
|
||
onClose,
|
||
submitLabel,
|
||
}: {
|
||
busy: boolean;
|
||
disabled?: boolean;
|
||
onClose: () => void;
|
||
submitLabel: string;
|
||
}) {
|
||
return (
|
||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||
<button
|
||
type="button"
|
||
className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium"
|
||
disabled={busy}
|
||
onClick={onClose}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold disabled:opacity-60"
|
||
disabled={busy || disabled}
|
||
>
|
||
{busy ? <Loader2 className="animate-spin" size={16} /> : <Check size={16} />}
|
||
{submitLabel}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FormError({ message }: { message: string | null }) {
|
||
if (!message) return null;
|
||
return <div className="app-feedback-danger mt-4 rounded-lg px-3 py-2.5 text-sm">{message}</div>;
|
||
}
|
||
|
||
async function submitModalForm(
|
||
operation: () => Promise<unknown>,
|
||
options: {
|
||
fallback: string;
|
||
setBusy: (busy: boolean) => void;
|
||
setError: (message: string | null) => void;
|
||
onSaved: () => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
},
|
||
) {
|
||
options.setBusy(true);
|
||
options.setError(null);
|
||
try {
|
||
await operation();
|
||
await options.onSaved();
|
||
} catch (error) {
|
||
if (isPayrollAdminStaleConflict(error) && options.onStale) {
|
||
await options.onStale();
|
||
return;
|
||
}
|
||
options.setError(getPayrollAdminError(error, options.fallback));
|
||
} finally {
|
||
options.setBusy(false);
|
||
}
|
||
}
|
||
|
||
export function PayrollPeriodFormModal({
|
||
isOpen,
|
||
period,
|
||
periods,
|
||
onClose,
|
||
onSubmit,
|
||
onSaved,
|
||
onStale,
|
||
}: {
|
||
isOpen: boolean;
|
||
period?: PayrollAdminPeriod | null;
|
||
periods: PayrollAdminPeriod[];
|
||
onClose: () => void;
|
||
onSubmit: (payload: PayrollPeriodWrite) => Promise<unknown>;
|
||
onSaved: () => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
}) {
|
||
const [form, setForm] = useState<PayrollPeriodWrite>(() => period ? {
|
||
code: period.code,
|
||
name: period.name,
|
||
date_from: period.date_from,
|
||
date_to: period.date_to,
|
||
pay_date: period.pay_date,
|
||
currency: period.currency,
|
||
} : getDefaultPayrollPeriodForm(periods));
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
void submitModalForm(() => onSubmit(form), {
|
||
fallback: "Не удалось сохранить расчётный период.",
|
||
setBusy,
|
||
setError,
|
||
onSaved,
|
||
onStale,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} title={period ? "Параметры периода" : "Новый расчётный период"} size="md">
|
||
<form onSubmit={submit}>
|
||
<p className="app-text-muted mb-4 text-sm leading-relaxed">
|
||
Период задаёт границы расчёта и дату, которую сотрудники увидят в листке.
|
||
</p>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<label>
|
||
<span className="app-field-label">Название</span>
|
||
<input className={inputClass} value={form.name} placeholder="Июль 2026" required onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Код периода</span>
|
||
<input className={inputClass} value={form.code} placeholder="2026-07" required disabled={Boolean(period)} onChange={(event) => setForm({ ...form, code: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Начало</span>
|
||
<input type="date" className={inputClass} value={form.date_from} required onChange={(event) => setForm({ ...form, date_from: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Окончание</span>
|
||
<input type="date" className={inputClass} value={form.date_to} required onChange={(event) => setForm({ ...form, date_to: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Дата выплаты</span>
|
||
<input type="date" className={inputClass} value={form.pay_date || ""} onChange={(event) => setForm({ ...form, pay_date: event.target.value || null })} />
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Валюта</span>
|
||
<select className={selectClass} value={form.currency} onChange={(event) => setForm({ ...form, currency: event.target.value })}>
|
||
<option value="RUB">RUB — российский рубль</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<FormError message={error} />
|
||
<ModalActions busy={busy} onClose={onClose} submitLabel={period ? "Сохранить" : "Создать период"} />
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function PayrollRateFormModal({
|
||
isOpen,
|
||
period,
|
||
employees,
|
||
rate,
|
||
onClose,
|
||
onSubmit,
|
||
onSaved,
|
||
onStale,
|
||
}: {
|
||
isOpen: boolean;
|
||
period: PayrollAdminPeriod;
|
||
employees: PayrollAdminEmployee[];
|
||
rate?: PayrollAdminPayRate | null;
|
||
onClose: () => void;
|
||
onSubmit: (payload: PayrollPayRateWrite) => Promise<unknown>;
|
||
onSaved: () => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
}) {
|
||
const [employeeId, setEmployeeId] = useState(() => rate ? String(rate.employee.id) : "");
|
||
const [amount, setAmount] = useState(() => rate?.amount || "");
|
||
const [pointRate, setPointRate] = useState(() => rate?.point_rate ?? "");
|
||
const [effectiveFrom, setEffectiveFrom] = useState(() => rate?.effective_from || period.date_from);
|
||
const [reason, setReason] = useState(() => rate?.reason || "");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
void submitModalForm(() => onSubmit({
|
||
employee_id: Number(employeeId),
|
||
rate_code: "BASE",
|
||
amount,
|
||
point_rate: pointRate.trim() || null,
|
||
currency: period.currency,
|
||
effective_from: effectiveFrom,
|
||
reason,
|
||
}), {
|
||
fallback: "Не удалось сохранить ставку.",
|
||
setBusy,
|
||
setError,
|
||
onSaved,
|
||
onStale,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} title={rate ? "Изменить черновик ставки" : "Новая ставка"} size="md">
|
||
<form onSubmit={submit}>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<label className="sm:col-span-2">
|
||
<span className="app-field-label">Сотрудник</span>
|
||
<select className={selectClass} required value={employeeId} disabled={Boolean(rate)} onChange={(event) => setEmployeeId(event.target.value)}>
|
||
<option value="">Выберите сотрудника</option>
|
||
{employees.map((employee) => <option value={employee.id} key={employee.id}>{employee.display_name}{employee.position ? ` — ${employee.position}` : ""}</option>)}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Оклад за период</span>
|
||
<input type="number" className={inputClass} inputMode="decimal" min="0.01" step="0.01" value={amount} required onChange={(event) => setAmount(event.target.value)} />
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Цена балла сверх нормы</span>
|
||
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.01" value={pointRate} placeholder="Как в норме" onChange={(event) => setPointRate(event.target.value)} />
|
||
<span className="app-text-muted mt-1 block text-xs">Если оставить пустым, каждый балл сверх нормы оплачивается по цене балла в норме.</span>
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Действует с</span>
|
||
<input type="date" className={inputClass} value={effectiveFrom} required onChange={(event) => setEffectiveFrom(event.target.value)} />
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Валюта</span>
|
||
<input className={inputClass} value={period.currency} disabled />
|
||
</label>
|
||
<label className="sm:col-span-2">
|
||
<span className="app-field-label">Основание</span>
|
||
<textarea className={`${inputClass} min-h-20 resize-y`} value={reason} placeholder="Приказ, договор или пояснение к изменению" onChange={(event) => setReason(event.target.value)} />
|
||
</label>
|
||
</div>
|
||
<FormError message={error} />
|
||
<ModalActions busy={busy} onClose={onClose} submitLabel="Сохранить черновик" />
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function PayrollBulkPointRateModal({
|
||
isOpen,
|
||
period,
|
||
rates,
|
||
onClose,
|
||
onSubmit,
|
||
onSaved,
|
||
onStale,
|
||
}: {
|
||
isOpen: boolean;
|
||
period: PayrollAdminPeriod;
|
||
rates: PayrollAdminPayRate[];
|
||
onClose: () => void;
|
||
onSubmit: (payload: PayrollBulkPointRateWrite) => Promise<PayrollBulkPointRateResult>;
|
||
onSaved: (result: PayrollBulkPointRateResult) => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
}) {
|
||
const employeeOptions = useMemo(() => {
|
||
const grouped = new Map<number, PayrollAdminPayRate[]>();
|
||
for (const rate of rates) {
|
||
if (rate.status === "voided") continue;
|
||
const records = grouped.get(rate.employee.id) || [];
|
||
records.push(rate);
|
||
grouped.set(rate.employee.id, records);
|
||
}
|
||
return Array.from(grouped.values()).map((records) => {
|
||
const current = [...records].sort((left, right) => {
|
||
const dateOrder = right.effective_from.localeCompare(left.effective_from);
|
||
return dateOrder || right.revision - left.revision || right.id - left.id;
|
||
})[0];
|
||
const pointRates = new Set(records.map((record) => record.point_rate));
|
||
return {
|
||
employee: current.employee,
|
||
pointRate: pointRates.size === 1 ? current.point_rate : null,
|
||
mixedPointRates: pointRates.size > 1,
|
||
hasDraft: records.some((record) => record.status === "draft"),
|
||
rateCount: new Set(records.map((record) => record.effective_from)).size,
|
||
};
|
||
}).sort((left, right) => (
|
||
left.employee.display_name.localeCompare(right.employee.display_name, "ru")
|
||
));
|
||
}, [rates]);
|
||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<number>>(
|
||
() => new Set(employeeOptions.map((option) => option.employee.id)),
|
||
);
|
||
const [search, setSearch] = useState("");
|
||
const [mode, setMode] = useState<PayrollBulkPointRateMode>("fixed");
|
||
const [pointRate, setPointRate] = useState("");
|
||
const [reason, setReason] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const filteredOptions = useMemo(() => {
|
||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||
if (!needle) return employeeOptions;
|
||
return employeeOptions.filter(({ employee }) => (
|
||
`${employee.display_name} ${employee.position || ""} ${employee.department || ""}`
|
||
.toLocaleLowerCase("ru-RU")
|
||
.includes(needle)
|
||
));
|
||
}, [employeeOptions, search]);
|
||
const allSelected = employeeOptions.length > 0
|
||
&& employeeOptions.every(({ employee }) => selectedEmployeeIds.has(employee.id));
|
||
const numericPointRate = Number(pointRate);
|
||
const fixedValueValid = pointRate.trim() !== ""
|
||
&& Number.isFinite(numericPointRate)
|
||
&& numericPointRate >= 0;
|
||
const canSubmit = selectedEmployeeIds.size > 0
|
||
&& (mode === "in_norm" || fixedValueValid)
|
||
&& reason.trim().length > 0;
|
||
|
||
const toggleEmployee = (employeeId: number) => {
|
||
setSelectedEmployeeIds((current) => {
|
||
const next = new Set(current);
|
||
if (next.has(employeeId)) next.delete(employeeId);
|
||
else next.add(employeeId);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (!canSubmit) return;
|
||
let result: PayrollBulkPointRateResult | null = null;
|
||
void submitModalForm(async () => {
|
||
result = await onSubmit({
|
||
employee_ids: Array.from(selectedEmployeeIds),
|
||
mode,
|
||
point_rate: mode === "fixed" ? pointRate.trim() : null,
|
||
reason: reason.trim(),
|
||
});
|
||
}, {
|
||
fallback: "Не удалось массово задать цену балла.",
|
||
setBusy,
|
||
setError,
|
||
onSaved: async () => {
|
||
if (result) await onSaved(result);
|
||
},
|
||
onStale,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} title="Цена балла сверх нормы" size="lg">
|
||
<form onSubmit={submit}>
|
||
<div className="app-selected rounded-xl px-4 py-3">
|
||
<p className="text-sm font-semibold text-[var(--foreground)]">{period.name || period.code}</p>
|
||
<p className="app-text-muted mt-1 text-xs leading-relaxed">
|
||
Черновики будут обновлены. Для утверждённых ставок система создаст новые черновые версии.
|
||
</p>
|
||
</div>
|
||
|
||
<fieldset className="mt-4">
|
||
<legend className="app-field-label">Как заполнить цену сверх нормы</legend>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${mode === "fixed" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
|
||
<span className="flex items-start gap-2.5">
|
||
<input type="radio" name="point-rate-mode" value="fixed" checked={mode === "fixed"} onChange={() => setMode("fixed")} />
|
||
<span>
|
||
<span className="block text-sm font-semibold text-[var(--foreground)]">Указать одну цену</span>
|
||
<span className="app-text-muted mt-1 block text-xs">Одинаковое значение для всех выбранных сотрудников.</span>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${mode === "in_norm" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
|
||
<span className="flex items-start gap-2.5">
|
||
<input type="radio" name="point-rate-mode" value="in_norm" checked={mode === "in_norm"} onChange={() => setMode("in_norm")} />
|
||
<span>
|
||
<span className="block text-sm font-semibold text-[var(--foreground)]">Как в пределах нормы</span>
|
||
<span className="app-text-muted mt-1 block text-xs">Для каждого сотрудника: (оклад + премия) ÷ его норма баллов.</span>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||
<label>
|
||
<span className="app-field-label">Цена сверх нормы {mode === "fixed" ? "*" : ""}</span>
|
||
<input
|
||
type="number"
|
||
className={inputClass}
|
||
inputMode="decimal"
|
||
min="0"
|
||
step="0.01"
|
||
value={pointRate}
|
||
required={mode === "fixed"}
|
||
disabled={mode === "in_norm"}
|
||
placeholder={mode === "in_norm" ? "Как цена в норме" : "0"}
|
||
onChange={(event) => setPointRate(event.target.value)}
|
||
/>
|
||
{mode === "fixed" ? <span className="app-text-muted mt-1 block text-xs">Введите 0, если доплата сверх нормы не нужна.</span> : null}
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Выбрано сотрудников</span>
|
||
<div className="app-input flex min-h-10 items-center rounded-lg px-3 text-sm font-medium">
|
||
{selectedEmployeeIds.size} из {employeeOptions.length}
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<span className="app-field-label mb-0">Сотрудники</span>
|
||
<button
|
||
type="button"
|
||
className="app-action-ghost rounded-md px-2 py-1 text-xs"
|
||
onClick={() => setSelectedEmployeeIds(
|
||
allSelected ? new Set() : new Set(employeeOptions.map((option) => option.employee.id)),
|
||
)}
|
||
>
|
||
{allSelected ? "Снять выбор" : "Выбрать всех"}
|
||
</button>
|
||
</div>
|
||
<label className="relative block">
|
||
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
|
||
<input
|
||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||
value={search}
|
||
placeholder="Поиск по сотрудникам"
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="app-surface-muted mt-2 max-h-64 overflow-y-auto rounded-xl border border-[var(--border-subtle)]">
|
||
{filteredOptions.length ? filteredOptions.map((option) => (
|
||
<label
|
||
key={option.employee.id}
|
||
className="flex cursor-pointer items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0 hover:bg-[var(--surface-secondary)]"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedEmployeeIds.has(option.employee.id)}
|
||
onChange={() => toggleEmployee(option.employee.id)}
|
||
/>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
|
||
{option.employee.display_name}
|
||
</span>
|
||
<span className="app-text-muted mt-0.5 block truncate text-xs">
|
||
{option.employee.position || option.employee.department || "Сотрудник"}
|
||
</span>
|
||
</span>
|
||
<span className="shrink-0 text-right">
|
||
<span className="block text-xs font-medium text-[var(--foreground)]">
|
||
{option.mixedPointRates
|
||
? `${option.rateCount} ставки`
|
||
: option.pointRate === null
|
||
? "Автоматически"
|
||
: `${option.pointRate} ${period.currency}`}
|
||
</span>
|
||
<span className="app-text-muted mt-0.5 block text-[11px]">
|
||
{mode === "in_norm" ? "Будет считаться по цене в норме" : option.hasDraft ? "Обновится черновик" : "Новая версия"}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
)) : (
|
||
<p className="app-text-muted px-4 py-8 text-center text-sm">Сотрудники не найдены.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<label className="mt-4 block">
|
||
<span className="app-field-label">Основание изменения *</span>
|
||
<textarea
|
||
className={`${inputClass} min-h-20 resize-y`}
|
||
value={reason}
|
||
required
|
||
placeholder="Укажите основание для массового изменения"
|
||
onChange={(event) => setReason(event.target.value)}
|
||
/>
|
||
</label>
|
||
<FormError message={error} />
|
||
<ModalActions
|
||
busy={busy}
|
||
disabled={!canSubmit}
|
||
onClose={onClose}
|
||
submitLabel={`Применить к ${selectedEmployeeIds.size}`}
|
||
/>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function PayrollBulkTargetPointsModal({
|
||
isOpen,
|
||
period,
|
||
employees,
|
||
records,
|
||
onClose,
|
||
onSubmit,
|
||
onSaved,
|
||
onStale,
|
||
}: {
|
||
isOpen: boolean;
|
||
period: PayrollAdminPeriod;
|
||
employees: PayrollAdminEmployee[];
|
||
records: PayrollAdminWorkRecord[];
|
||
onClose: () => void;
|
||
onSubmit: (payload: PayrollBulkTargetPointsWrite) => Promise<PayrollBulkTargetPointsResult>;
|
||
onSaved: (result: PayrollBulkTargetPointsResult) => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
}) {
|
||
const employeeOptions = useMemo(() => employees.map((employee) => {
|
||
const current = records
|
||
.filter((record) => record.employee.id === employee.id && record.status !== "voided")
|
||
.sort((left, right) => right.revision - left.revision || right.id - left.id)[0];
|
||
return { employee, current };
|
||
}).sort((left, right) => (
|
||
left.employee.display_name.localeCompare(right.employee.display_name, "ru")
|
||
)), [employees, records]);
|
||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<number>>(
|
||
() => new Set(employeeOptions.map((option) => option.employee.id)),
|
||
);
|
||
const [search, setSearch] = useState("");
|
||
const [mode, setMode] = useState<PayrollBulkTargetPointsMode>("fixed");
|
||
const [targetPoints, setTargetPoints] = useState("");
|
||
const [reason, setReason] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const filteredOptions = useMemo(() => {
|
||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||
if (!needle) return employeeOptions;
|
||
return employeeOptions.filter(({ employee }) => (
|
||
`${employee.display_name} ${employee.position || ""} ${employee.department || ""}`
|
||
.toLocaleLowerCase("ru-RU")
|
||
.includes(needle)
|
||
));
|
||
}, [employeeOptions, search]);
|
||
const allSelected = employeeOptions.length > 0
|
||
&& employeeOptions.every(({ employee }) => selectedEmployeeIds.has(employee.id));
|
||
const numericTarget = Number(targetPoints);
|
||
const fixedValueValid = targetPoints.trim() !== ""
|
||
&& Number.isFinite(numericTarget)
|
||
&& numericTarget > 0;
|
||
const canSubmit = selectedEmployeeIds.size > 0
|
||
&& (mode === "automatic" || fixedValueValid)
|
||
&& reason.trim().length > 0;
|
||
|
||
const toggleEmployee = (employeeId: number) => {
|
||
setSelectedEmployeeIds((current) => {
|
||
const next = new Set(current);
|
||
if (next.has(employeeId)) next.delete(employeeId);
|
||
else next.add(employeeId);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (!canSubmit) return;
|
||
let result: PayrollBulkTargetPointsResult | null = null;
|
||
void submitModalForm(async () => {
|
||
result = await onSubmit({
|
||
employee_ids: Array.from(selectedEmployeeIds),
|
||
mode,
|
||
target_points: mode === "fixed" ? targetPoints.trim() : null,
|
||
reason: reason.trim(),
|
||
});
|
||
}, {
|
||
fallback: "Не удалось массово задать норму баллов.",
|
||
setBusy,
|
||
setError,
|
||
onSaved: async () => {
|
||
if (result) await onSaved(result);
|
||
},
|
||
onStale,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} title="Массовое заполнение нормы баллов" size="lg">
|
||
<form onSubmit={submit}>
|
||
<div className="app-selected rounded-xl px-4 py-3">
|
||
<p className="text-sm font-semibold text-[var(--foreground)]">{period.name || period.code}</p>
|
||
<p className="app-text-muted mt-1 text-xs leading-relaxed">
|
||
Черновики будут обновлены. Для утверждённой выработки система создаст новую черновую версию.
|
||
</p>
|
||
</div>
|
||
|
||
<fieldset className="mt-4">
|
||
<legend className="app-field-label">Как заполнить норму баллов</legend>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${mode === "fixed" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
|
||
<span className="flex items-start gap-2.5">
|
||
<input type="radio" name="target-points-mode" value="fixed" checked={mode === "fixed"} onChange={() => setMode("fixed")} />
|
||
<span>
|
||
<span className="block text-sm font-semibold text-[var(--foreground)]">Указать одну норму</span>
|
||
<span className="app-text-muted mt-1 block text-xs">Одинаковое значение для выбранных сотрудников.</span>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${mode === "automatic" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
|
||
<span className="flex items-start gap-2.5">
|
||
<input type="radio" name="target-points-mode" value="automatic" checked={mode === "automatic"} onChange={() => setMode("automatic")} />
|
||
<span>
|
||
<span className="block text-sm font-semibold text-[var(--foreground)]">Вернуть автоматическую</span>
|
||
<span className="app-text-muted mt-1 block text-xs">По рабочему графику каждого сотрудника.</span>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||
<label>
|
||
<span className="app-field-label">Норма баллов {mode === "fixed" ? "*" : ""}</span>
|
||
<input
|
||
type="number"
|
||
className={inputClass}
|
||
inputMode="decimal"
|
||
min="0.0001"
|
||
step="0.0001"
|
||
value={targetPoints}
|
||
required={mode === "fixed"}
|
||
disabled={mode === "automatic"}
|
||
placeholder={mode === "automatic" ? "По рабочему графику" : "115"}
|
||
onChange={(event) => setTargetPoints(event.target.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Выбрано сотрудников</span>
|
||
<div className="app-input flex min-h-10 items-center rounded-lg px-3 text-sm font-medium">
|
||
{selectedEmployeeIds.size} из {employeeOptions.length}
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<span className="app-field-label mb-0">Сотрудники</span>
|
||
<button
|
||
type="button"
|
||
className="app-action-ghost rounded-md px-2 py-1 text-xs"
|
||
onClick={() => setSelectedEmployeeIds(
|
||
allSelected ? new Set() : new Set(employeeOptions.map((option) => option.employee.id)),
|
||
)}
|
||
>
|
||
{allSelected ? "Снять выбор" : "Выбрать всех"}
|
||
</button>
|
||
</div>
|
||
<label className="relative block">
|
||
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
|
||
<input className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" value={search} placeholder="Поиск по сотрудникам" onChange={(event) => setSearch(event.target.value)} />
|
||
</label>
|
||
<div className="app-surface-muted mt-2 max-h-64 overflow-y-auto rounded-xl border border-[var(--border-subtle)]">
|
||
{filteredOptions.length ? filteredOptions.map(({ employee, current }) => (
|
||
<label key={employee.id} className="flex cursor-pointer items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0 hover:bg-[var(--surface-secondary)]">
|
||
<input type="checkbox" checked={selectedEmployeeIds.has(employee.id)} onChange={() => toggleEmployee(employee.id)} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-medium text-[var(--foreground)]">{employee.display_name}</span>
|
||
<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="app-text-muted mt-0.5 block text-[11px]">
|
||
{mode === "automatic" ? "Будет рассчитана по графику" : current?.status === "draft" ? "Обновится черновик" : current ? "Новая версия" : "Новый черновик"}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
)) : <p className="app-text-muted px-4 py-8 text-center text-sm">Сотрудники не найдены.</p>}
|
||
</div>
|
||
</div>
|
||
|
||
<label className="mt-4 block">
|
||
<span className="app-field-label">Основание изменения *</span>
|
||
<textarea className={`${inputClass} min-h-20 resize-y`} value={reason} required placeholder="Укажите основание массового изменения" onChange={(event) => setReason(event.target.value)} />
|
||
</label>
|
||
<FormError message={error} />
|
||
<ModalActions busy={busy} disabled={!canSubmit} onClose={onClose} submitLabel={`Применить к ${selectedEmployeeIds.size}`} />
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function PayrollBulkPayRateModal({
|
||
isOpen,
|
||
period,
|
||
employees,
|
||
rates,
|
||
onClose,
|
||
onSubmit,
|
||
onSaved,
|
||
onStale,
|
||
}: {
|
||
isOpen: boolean;
|
||
period: PayrollAdminPeriod;
|
||
employees: PayrollAdminEmployee[];
|
||
rates: PayrollAdminPayRate[];
|
||
onClose: () => void;
|
||
onSubmit: (payload: PayrollBulkPayRateWrite) => Promise<PayrollBulkPayRateResult>;
|
||
onSaved: (result: PayrollBulkPayRateResult) => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
}) {
|
||
const employeeOptions = useMemo(() => employees.map((employee) => {
|
||
const relevantRates = rates
|
||
.filter((rate) => (
|
||
rate.employee.id === employee.id
|
||
&& rate.status !== "voided"
|
||
&& rate.effective_from <= period.date_to
|
||
))
|
||
.sort((left, right) => {
|
||
const dateOrder = right.effective_from.localeCompare(left.effective_from);
|
||
return dateOrder || right.revision - left.revision || right.id - left.id;
|
||
});
|
||
return { employee, rates: relevantRates };
|
||
}).sort((left, right) => (
|
||
left.employee.display_name.localeCompare(right.employee.display_name, "ru")
|
||
)), [employees, period.date_to, rates]);
|
||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<number>>(
|
||
() => new Set(employeeOptions.map((option) => option.employee.id)),
|
||
);
|
||
const [search, setSearch] = useState("");
|
||
const [amount, setAmount] = useState("");
|
||
const [effectiveFrom, setEffectiveFrom] = useState(period.date_from);
|
||
const [reason, setReason] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const filteredOptions = useMemo(() => {
|
||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||
if (!needle) return employeeOptions;
|
||
return employeeOptions.filter(({ employee }) => (
|
||
`${employee.display_name} ${employee.position || ""} ${employee.department || ""}`
|
||
.toLocaleLowerCase("ru-RU")
|
||
.includes(needle)
|
||
));
|
||
}, [employeeOptions, search]);
|
||
const allSelected = employeeOptions.length > 0
|
||
&& employeeOptions.every(({ employee }) => selectedEmployeeIds.has(employee.id));
|
||
const numericAmount = Number(amount);
|
||
const canSubmit = selectedEmployeeIds.size > 0
|
||
&& amount.trim() !== ""
|
||
&& Number.isFinite(numericAmount)
|
||
&& numericAmount > 0
|
||
&& effectiveFrom >= period.date_from
|
||
&& effectiveFrom <= period.date_to
|
||
&& reason.trim().length > 0;
|
||
|
||
const toggleEmployee = (employeeId: number) => {
|
||
setSelectedEmployeeIds((current) => {
|
||
const next = new Set(current);
|
||
if (next.has(employeeId)) next.delete(employeeId);
|
||
else next.add(employeeId);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (!canSubmit) return;
|
||
let result: PayrollBulkPayRateResult | null = null;
|
||
void submitModalForm(async () => {
|
||
result = await onSubmit({
|
||
employee_ids: Array.from(selectedEmployeeIds),
|
||
amount,
|
||
effective_from: effectiveFrom,
|
||
reason: reason.trim(),
|
||
});
|
||
}, {
|
||
fallback: "Не удалось массово добавить ставку.",
|
||
setBusy,
|
||
setError,
|
||
onSaved: async () => {
|
||
if (result) await onSaved(result);
|
||
},
|
||
onStale,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} title="Массовое добавление ставки" size="lg">
|
||
<form onSubmit={submit}>
|
||
<div className="app-selected rounded-xl px-4 py-3">
|
||
<p className="text-sm font-semibold text-[var(--foreground)]">{period.name || period.code}</p>
|
||
<p className="app-text-muted mt-1 text-xs leading-relaxed">
|
||
Новые ставки сохранятся черновиками. Для утверждённой ставки на выбранную дату будет создана новая версия.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||
<label>
|
||
<span className="app-field-label">Ставка / оклад *</span>
|
||
<input
|
||
type="number"
|
||
className={inputClass}
|
||
inputMode="decimal"
|
||
min="0.01"
|
||
step="0.01"
|
||
value={amount}
|
||
required
|
||
onChange={(event) => setAmount(event.target.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Действует с *</span>
|
||
<input
|
||
type="date"
|
||
className={inputClass}
|
||
min={period.date_from}
|
||
max={period.date_to}
|
||
value={effectiveFrom}
|
||
required
|
||
onChange={(event) => setEffectiveFrom(event.target.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<span className="app-field-label mb-0">
|
||
Сотрудники · {selectedEmployeeIds.size} из {employeeOptions.length}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="app-action-ghost rounded-md px-2 py-1 text-xs"
|
||
onClick={() => setSelectedEmployeeIds(
|
||
allSelected ? new Set() : new Set(employeeOptions.map((option) => option.employee.id)),
|
||
)}
|
||
>
|
||
{allSelected ? "Снять выбор" : "Выбрать всех"}
|
||
</button>
|
||
</div>
|
||
<label className="relative block">
|
||
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
|
||
<input
|
||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||
value={search}
|
||
placeholder="Поиск по сотрудникам"
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="app-surface-muted mt-2 max-h-64 overflow-y-auto rounded-xl border border-[var(--border-subtle)]">
|
||
{filteredOptions.length ? filteredOptions.map((option) => {
|
||
const relevant = option.rates.filter((rate) => rate.effective_from <= effectiveFrom);
|
||
const exact = relevant.find((rate) => rate.effective_from === effectiveFrom);
|
||
const inherited = relevant[0];
|
||
const action = exact?.status === "draft"
|
||
? "Обновится черновик"
|
||
: exact?.status === "approved"
|
||
? "Новая версия"
|
||
: "Новая ставка";
|
||
return (
|
||
<label
|
||
key={option.employee.id}
|
||
className="flex cursor-pointer items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0 hover:bg-[var(--surface-secondary)]"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedEmployeeIds.has(option.employee.id)}
|
||
onChange={() => toggleEmployee(option.employee.id)}
|
||
/>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
|
||
{option.employee.display_name}
|
||
</span>
|
||
<span className="app-text-muted mt-0.5 block truncate text-xs">
|
||
{option.employee.position || option.employee.department || "Сотрудник"}
|
||
</span>
|
||
</span>
|
||
<span className="shrink-0 text-right">
|
||
<span className="block text-xs font-medium text-[var(--foreground)]">
|
||
{inherited?.point_rate == null
|
||
? "Сверх нормы: автоматически"
|
||
: `${inherited.point_rate} ${period.currency} / балл сверх нормы`}
|
||
</span>
|
||
<span className="app-text-muted mt-0.5 block text-[11px]">{action}</span>
|
||
</span>
|
||
</label>
|
||
);
|
||
}) : (
|
||
<p className="app-text-muted px-4 py-8 text-center text-sm">Сотрудники не найдены.</p>
|
||
)}
|
||
</div>
|
||
<p className="app-text-muted mt-2 text-xs">
|
||
Цена балла сверх нормы наследуется из последней ставки сотрудника. Пустое значение означает автоматический расчёт по цене балла в норме.
|
||
</p>
|
||
</div>
|
||
|
||
<label className="mt-4 block">
|
||
<span className="app-field-label">Основание изменения *</span>
|
||
<textarea
|
||
className={`${inputClass} min-h-20 resize-y`}
|
||
value={reason}
|
||
required
|
||
placeholder="Укажите основание для массового добавления"
|
||
onChange={(event) => setReason(event.target.value)}
|
||
/>
|
||
</label>
|
||
<FormError message={error} />
|
||
<ModalActions
|
||
busy={busy}
|
||
disabled={!canSubmit}
|
||
onClose={onClose}
|
||
submitLabel={`Применить к ${selectedEmployeeIds.size}`}
|
||
/>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function PayrollWorkRecordFormModal({
|
||
isOpen,
|
||
period,
|
||
employees,
|
||
record,
|
||
onClose,
|
||
onSubmit,
|
||
onSaved,
|
||
onStale,
|
||
}: {
|
||
isOpen: boolean;
|
||
period: PayrollAdminPeriod;
|
||
employees: PayrollAdminEmployee[];
|
||
record?: PayrollAdminWorkRecord | null;
|
||
onClose: () => void;
|
||
onSubmit: (payload: PayrollWorkRecordWrite) => Promise<unknown>;
|
||
onSaved: () => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
}) {
|
||
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 || "",
|
||
expected_point_amount: record?.expected_point_amount || "",
|
||
expected_gross: record?.expected_gross || "",
|
||
expected_recalculated_gross: record?.expected_recalculated_gross || "",
|
||
expected_payable: record?.expected_payable || "",
|
||
reason: record?.reason || "",
|
||
}));
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const optionalMoney = (value: string) => value.trim() || null;
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
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,
|
||
expected_point_amount: optionalMoney(form.expected_point_amount),
|
||
expected_gross: optionalMoney(form.expected_gross),
|
||
expected_recalculated_gross: optionalMoney(form.expected_recalculated_gross),
|
||
expected_payable: optionalMoney(form.expected_payable),
|
||
reason: form.reason,
|
||
}), {
|
||
fallback: "Не удалось сохранить выработку.",
|
||
setBusy,
|
||
setError,
|
||
onSaved,
|
||
onStale,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} title={record ? "Изменить черновик выработки" : "Добавить выработку"} size="lg">
|
||
<form onSubmit={submit}>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<label className="sm:col-span-2">
|
||
<span className="app-field-label">Сотрудник</span>
|
||
<select className={selectClass} required value={form.employee_id} disabled={Boolean(record)} onChange={(event) => setForm({ ...form, employee_id: event.target.value })}>
|
||
<option value="">Выберите сотрудника</option>
|
||
{employees.map((employee) => <option value={employee.id} key={employee.id}>{employee.display_name}{employee.position ? ` — ${employee.position}` : ""}</option>)}
|
||
</select>
|
||
</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 })} />
|
||
</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 })} />
|
||
</label>
|
||
</div>
|
||
|
||
<details className="app-surface-muted mt-4 rounded-xl p-3 sm:p-4">
|
||
<summary className="cursor-pointer text-sm font-semibold text-[var(--foreground)]">Контрольные суммы из Excel</summary>
|
||
<p className="app-text-muted mt-2 text-xs leading-relaxed">Не участвуют в формуле, но заблокируют расчёт при расхождении.</p>
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||
<label><span className="app-field-label">Выработка по баллам</span><input type="number" className={inputClass} inputMode="decimal" step="0.01" value={form.expected_point_amount} onChange={(event) => setForm({ ...form, expected_point_amount: event.target.value })} /></label>
|
||
<label><span className="app-field-label">Итого начислено</span><input type="number" className={inputClass} inputMode="decimal" step="0.01" value={form.expected_gross} onChange={(event) => setForm({ ...form, expected_gross: event.target.value })} /></label>
|
||
<label><span className="app-field-label">Перерасчёт</span><input type="number" className={inputClass} inputMode="decimal" step="0.01" value={form.expected_recalculated_gross} onChange={(event) => setForm({ ...form, expected_recalculated_gross: event.target.value })} /></label>
|
||
<label><span className="app-field-label">К выплате</span><input type="number" className={inputClass} inputMode="decimal" step="0.01" value={form.expected_payable} onChange={(event) => setForm({ ...form, expected_payable: event.target.value })} /></label>
|
||
</div>
|
||
</details>
|
||
|
||
<label className="mt-4 block">
|
||
<span className="app-field-label">Основание изменения</span>
|
||
<textarea className={`${inputClass} min-h-20 resize-y`} value={form.reason} placeholder="Обязательно для новой ревизии" onChange={(event) => setForm({ ...form, reason: event.target.value })} />
|
||
</label>
|
||
<FormError message={error} />
|
||
<ModalActions busy={busy} onClose={onClose} submitLabel="Сохранить черновик" />
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function PayrollInputLineFormModal({
|
||
isOpen,
|
||
period,
|
||
periods,
|
||
employees,
|
||
components,
|
||
line,
|
||
onClose,
|
||
onSubmit,
|
||
onSaved,
|
||
onStale,
|
||
}: {
|
||
isOpen: boolean;
|
||
period: PayrollAdminPeriod;
|
||
periods: PayrollAdminPeriod[];
|
||
employees: PayrollAdminEmployee[];
|
||
components: PayrollComponent[];
|
||
line?: PayrollAdminInputLine | null;
|
||
onClose: () => void;
|
||
onSubmit: (payload: PayrollInputLineWrite) => Promise<unknown>;
|
||
onSaved: () => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
}) {
|
||
const [form, setForm] = useState(() => ({
|
||
employee_id: line ? String(line.employee.id) : "",
|
||
component_code: line?.component_code || "",
|
||
amount: line?.amount || "",
|
||
relates_to_period_id: line?.relates_to_period_id ? String(line.relates_to_period_id) : "",
|
||
reason: line?.reason || "",
|
||
}));
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const component = useMemo(() => components.find((item) => item.code === form.component_code), [components, form.component_code]);
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
void submitModalForm(() => onSubmit({
|
||
period_id: period.id,
|
||
employee_id: Number(form.employee_id),
|
||
component_code: form.component_code,
|
||
amount: form.amount,
|
||
relates_to_period_id: form.relates_to_period_id ? Number(form.relates_to_period_id) : null,
|
||
reason: form.reason,
|
||
}), {
|
||
fallback: "Не удалось сохранить начисление.",
|
||
setBusy,
|
||
setError,
|
||
onSaved,
|
||
onStale,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} title={line ? "Изменить строку" : "Новое начисление или выплата"} size="md">
|
||
<form onSubmit={submit}>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<label className="sm:col-span-2">
|
||
<span className="app-field-label">Сотрудник</span>
|
||
<select className={selectClass} required disabled={Boolean(line)} value={form.employee_id} onChange={(event) => setForm({ ...form, employee_id: event.target.value })}>
|
||
<option value="">Выберите сотрудника</option>
|
||
{employees.map((employee) => <option value={employee.id} key={employee.id}>{employee.display_name}{employee.position ? ` — ${employee.position}` : ""}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="sm:col-span-2">
|
||
<span className="app-field-label">Вид операции</span>
|
||
<select className={selectClass} required disabled={Boolean(line)} value={form.component_code} onChange={(event) => setForm({ ...form, component_code: event.target.value })}>
|
||
<option value="">Выберите вид</option>
|
||
{components.filter((item) => item.is_active).map((item) => <option value={item.code} key={item.code}>{item.name}</option>)}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Сумма</span>
|
||
<input type="number" className={inputClass} inputMode="decimal" min="0.01" step="0.01" required value={form.amount} onChange={(event) => setForm({ ...form, amount: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
<span className="app-field-label">Относится к периоду</span>
|
||
<select className={selectClass} value={form.relates_to_period_id} onChange={(event) => setForm({ ...form, relates_to_period_id: event.target.value })}>
|
||
<option value="">Текущий период</option>
|
||
{periods.filter((item) => item.id !== period.id).map((item) => <option key={item.id} value={item.id}>{item.name || item.code}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="sm:col-span-2">
|
||
<span className="app-field-label">Основание{component?.requires_reason || form.relates_to_period_id ? " *" : ""}</span>
|
||
<textarea className={`${inputClass} min-h-20 resize-y`} required={Boolean(component?.requires_reason || form.relates_to_period_id)} value={form.reason} placeholder="Премия, приказ, причина корректировки" onChange={(event) => setForm({ ...form, reason: event.target.value })} />
|
||
</label>
|
||
</div>
|
||
<FormError message={error} />
|
||
<ModalActions busy={busy} onClose={onClose} submitLabel="Сохранить черновик" />
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function PayrollConfirmModal({
|
||
isOpen,
|
||
title,
|
||
description,
|
||
confirmLabel,
|
||
reasonLabel,
|
||
reasonRequired = false,
|
||
warning,
|
||
onClose,
|
||
onConfirm,
|
||
onDone,
|
||
onStale,
|
||
}: {
|
||
isOpen: boolean;
|
||
title: string;
|
||
description: string;
|
||
confirmLabel: string;
|
||
reasonLabel?: string;
|
||
reasonRequired?: boolean;
|
||
warning?: string;
|
||
onClose: () => void;
|
||
onConfirm: (reason: string) => Promise<unknown>;
|
||
onDone: () => void | Promise<void>;
|
||
onStale?: () => void | Promise<void>;
|
||
}) {
|
||
const [reason, setReason] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
void submitModalForm(() => onConfirm(reason), {
|
||
fallback: "Не удалось выполнить действие.",
|
||
setBusy,
|
||
setError,
|
||
onSaved: onDone,
|
||
onStale,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||
<form onSubmit={submit}>
|
||
<p className="app-text-muted text-sm leading-relaxed">{description}</p>
|
||
{warning ? (
|
||
<div className="app-feedback-warning mt-4 flex items-start gap-2 rounded-lg px-3 py-2.5 text-sm">
|
||
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
|
||
<span>{warning}</span>
|
||
</div>
|
||
) : null}
|
||
{reasonLabel ? (
|
||
<label className="mt-4 block">
|
||
<span className="app-field-label">{reasonLabel}</span>
|
||
<textarea className={`${inputClass} min-h-24 resize-y`} required={reasonRequired} value={reason} onChange={(event) => setReason(event.target.value)} />
|
||
</label>
|
||
) : null}
|
||
<FormError message={error} />
|
||
<ModalActions busy={busy} onClose={onClose} submitLabel={confirmLabel} />
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|