Add inline payroll entry editing and refine date picker styling

This commit is contained in:
admin i 2026-09-01 16:11:49 +03:00
parent 5ca165ee03
commit f7f7d3316d
2 changed files with 169 additions and 22 deletions

View File

@ -7,6 +7,7 @@ import {
Loader2,
Pencil,
Save,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { createPortal } from "react-dom";
@ -70,6 +71,11 @@ export function PayrollDailyWorkSection({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [editingEntryId, setEditingEntryId] = useState<number | null>(null);
const [inlineActualPoints, setInlineActualPoints] = useState("");
const [inlineNote, setInlineNote] = useState("");
const [inlineSaving, setInlineSaving] = useState(false);
const [inlineError, setInlineError] = useState<string | null>(null);
const fillForm = useCallback((
payload: PayrollOwnDailyWorkWorkspace,
@ -135,6 +141,8 @@ export function PayrollDailyWorkSection({
);
const selectWorkDate = (date: string) => {
setEditingEntryId(null);
setInlineError(null);
setWorkDate(date);
const entry = currentEntryForDate(workspace?.entries || [], date);
setActualPoints(normalizePayrollPoints(entry?.actual_points));
@ -180,6 +188,58 @@ export function PayrollDailyWorkSection({
}
};
const startInlineEdit = (entry: PayrollOwnDailyWorkEntry) => {
selectWorkDate(entry.work_date);
setEditingEntryId(entry.id);
setInlineActualPoints(normalizePayrollPoints(entry.actual_points));
setInlineNote(entry.note || "");
setInlineError(null);
};
const cancelInlineEdit = () => {
setEditingEntryId(null);
setInlineError(null);
};
const submitInlineEdit = async (
event: FormEvent<HTMLFormElement>,
entry: PayrollOwnDailyWorkEntry,
) => {
event.preventDefault();
if (!selectedPeriod?.editable || inlineSaving) return;
const numericInlineActual = Number(inlineActualPoints);
if (
!inlineActualPoints.trim()
|| !Number.isFinite(numericInlineActual)
|| numericInlineActual < 0
) {
setInlineError("Укажите фактические баллы: число не меньше нуля.");
return;
}
setInlineSaving(true);
setInlineError(null);
setError(null);
setNotice(null);
try {
await apiClient.saveMyPayrollDailyWorkEntry({
period_id: selectedPeriod.id,
work_date: entry.work_date,
actual_points: normalizePayrollPoints(inlineActualPoints),
note: inlineNote.trim(),
expected_lock_version: entry.lock_version,
});
await loadWorkspace(selectedPeriod.id, entry.work_date);
setEditingEntryId(null);
setNotice("Выработка за день обновлена.");
} catch (saveError) {
setInlineError(getPayrollAdminError(saveError, "Не удалось сохранить ежедневную выработку."));
} finally {
setInlineSaving(false);
}
};
if (loading && !workspace) {
return (
<section className={`${embedded ? "app-surface-muted min-h-40 rounded-xl" : "app-surface min-h-64 rounded-2xl"} flex items-center justify-center`}>
@ -210,7 +270,7 @@ export function PayrollDailyWorkSection({
selectedPeriod={selectedPeriod}
selectedDate={workDate}
entries={workspace.entries}
disabled={loading || saving}
disabled={loading || saving || inlineSaving}
onSelectDate={selectAvailableWorkDate}
onSelectPeriod={(periodId) => void loadWorkspace(periodId)}
/>
@ -265,25 +325,106 @@ export function PayrollDailyWorkSection({
</div>
{workspace.entries.length ? (
<div className="space-y-2">
{workspace.entries.map((entry) => (
<button
type="button"
{workspace.entries.map((entry) => editingEntryId === entry.id ? (
<form
key={entry.id}
className={`app-surface-muted grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl p-3 text-left transition hover:border-[var(--border-strong)] ${entry.work_date === workDate ? "app-selected" : ""}`}
onClick={() => selectWorkDate(entry.work_date)}
className="app-selected rounded-xl p-3"
onSubmit={(event) => void submitInlineEdit(event, entry)}
>
<span className="min-w-0">
<span className="block text-sm font-semibold text-[var(--foreground)]">{formatPayrollDate(entry.work_date)}</span>
<span className="app-text-muted mt-1 block truncate text-xs">{entry.note || "Без комментария"}</span>
</span>
<span className="flex shrink-0 items-center gap-3">
<span className="text-right">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">{formatPayrollDate(entry.work_date)}</p>
<p className="app-text-muted mt-0.5 text-[11px]">Норма: {formatPayrollPoints(entry.target_points, "0")}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
className="app-action-ghost app-text-muted flex h-8 w-8 items-center justify-center rounded-lg"
onClick={cancelInlineEdit}
disabled={inlineSaving}
title="Отменить редактирование"
aria-label={`Отменить редактирование за ${formatPayrollDate(entry.work_date)}`}
>
<X size={15} />
</button>
<button
type="submit"
className="app-action-primary flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
disabled={inlineSaving || !inlineActualPoints.trim()}
title="Сохранить запись"
aria-label={`Сохранить выработку за ${formatPayrollDate(entry.work_date)}`}
>
{inlineSaving ? <Loader2 size={15} className="animate-spin" /> : <Save size={15} />}
</button>
</div>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-[minmax(8rem,0.35fr)_minmax(0,1fr)]">
<label>
<span className="app-field-label text-xs">Фактические баллы</span>
<input
type="number"
className="app-input w-full rounded-lg px-3 py-2 text-sm"
inputMode="decimal"
min="0"
step="0.01"
value={inlineActualPoints}
disabled={inlineSaving}
required
autoFocus
onChange={(event) => setInlineActualPoints(event.target.value)}
onBlur={() => setInlineActualPoints((value) => normalizePayrollPoints(value))}
/>
</label>
<label>
<span className="app-field-label text-xs">Комментарий</span>
<input
type="text"
className="app-input w-full rounded-lg px-3 py-2 text-sm"
value={inlineNote}
disabled={inlineSaving}
placeholder="Без комментария"
onChange={(event) => setInlineNote(event.target.value)}
/>
</label>
</div>
{inlineError ? (
<div className="app-feedback-danger mt-3 rounded-lg px-3 py-2 text-xs">{inlineError}</div>
) : null}
</form>
) : (
<div
key={entry.id}
className={`app-surface-muted flex w-full items-center gap-2 rounded-xl p-3 transition hover:border-[var(--border-strong)] ${entry.work_date === workDate ? "app-selected" : ""}`}
>
<button
type="button"
className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-left"
onClick={() => selectWorkDate(entry.work_date)}
>
<span className="min-w-0">
<span className="block text-sm font-semibold text-[var(--foreground)]">{formatPayrollDate(entry.work_date)}</span>
<span className="app-text-muted mt-1 block truncate text-xs">{entry.note || "Без комментария"}</span>
</span>
<span className="shrink-0 text-right">
<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>
</button>
</button>
{selectedPeriod?.editable ? (
<button
type="button"
className="app-action-ghost app-text-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg hover:text-[var(--foreground)]"
onClick={() => startInlineEdit(entry)}
disabled={inlineSaving}
title="Редактировать запись"
aria-label={`Редактировать выработку за ${formatPayrollDate(entry.work_date)}`}
>
<Pencil size={15} />
</button>
) : null}
</div>
))}
</div>
) : (
@ -339,7 +480,7 @@ export function PayrollDailyWorkSection({
min="0"
step="0.01"
value={actualPoints}
disabled={!selectedPeriod?.editable}
disabled={!selectedPeriod?.editable || inlineSaving}
required
onChange={(event) => setActualPoints(event.target.value)}
onBlur={() => setActualPoints((value) => normalizePayrollPoints(value))}
@ -351,7 +492,7 @@ export function PayrollDailyWorkSection({
<textarea
className="app-input min-h-20 w-full resize-y rounded-lg px-3 py-2.5 text-sm"
value={note}
disabled={!selectedPeriod?.editable}
disabled={!selectedPeriod?.editable || inlineSaving}
onChange={(event) => setNote(event.target.value)}
/>
</label>
@ -368,7 +509,7 @@ export function PayrollDailyWorkSection({
<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:cursor-not-allowed disabled:opacity-50"
disabled={!canSubmit || saving}
disabled={!canSubmit || saving || inlineSaving}
>
{saving ? <Loader2 className="animate-spin" size={16} /> : selectedEntry ? <Save size={16} /> : <Check size={16} />}
{selectedEntry ? "Сохранить изменения" : "Добавить выработку"}

View File

@ -80,19 +80,25 @@ export function PayrollWorkDatePicker({
}, [open]);
return (
<div className={`relative max-w-full ${compact ? "w-40" : "w-44"}`}>
<div className={compact ? "relative min-w-0 shrink-0" : "relative w-44 max-w-full"}>
<button
type="button"
className={`${compact ? "app-action-secondary h-9 rounded-full text-xs" : "app-input h-11 rounded-lg text-sm"} flex w-full items-center gap-2 px-3 font-medium`}
className={`${compact
? `${open
? "app-badge app-badge-interactive app-selected app-accent-text"
: "app-badge app-badge-interactive app-text-muted hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"
} flex h-8 max-w-full flex-nowrap items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-xs`
: "app-input flex h-11 w-full items-center gap-2 rounded-lg px-3 text-sm"
} font-medium 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)]`}
disabled={disabled || !selectedPeriod}
aria-label="Дата выработки"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((value) => !value)}
>
<CalendarDays className="app-accent-text shrink-0" size={16} />
<CalendarDays className={`${compact ? "" : "app-accent-text"} shrink-0`} size={compact ? 14 : 16} />
<span className="min-w-0 flex-1 truncate text-left">{compactDate(selectedDate)}</span>
<ChevronDown className={`app-text-muted shrink-0 transition ${open ? "rotate-180" : ""}`} size={15} />
<ChevronDown className={`app-text-muted shrink-0 transition ${open ? "rotate-180" : ""}`} size={compact ? 16 : 15} />
</button>
{open && selectedPeriod ? (