Add inline payroll entry editing and refine date picker styling
This commit is contained in:
parent
5ca165ee03
commit
f7f7d3316d
@ -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 ? "Сохранить изменения" : "Добавить выработку"}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user