ui: Notion-style inline editing for task detail fields
- Add useInlineEdit hook for per-field editing (double-click to edit) - Add useDirtyFields hook for tracking unsaved changes - Add NotionFieldRow component with CSS Grid layout (140px label + 1fr value) - Add StickyActionBar component (fixed bottom bar with Save/Cancel) - Remove editModeBar from top (was shifting content down) - Replace field flex layout with notion-field-row grid - Add unified input styles (h-8, centered text) in CSS - Add safe-area-bottom for iOS PWA - Buttons now appear at bottom only when fields are dirty Refs: TaskTitan CRM UI refactor
This commit is contained in:
107
client/src/components/task-detail/NotionFieldRow.tsx
Normal file
107
client/src/components/task-detail/NotionFieldRow.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
||||
interface NotionFieldRowProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
editable?: boolean;
|
||||
isEditing?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
editor?: React.ReactNode;
|
||||
onEnterEdit?: () => void;
|
||||
showEditHint?: boolean;
|
||||
}
|
||||
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
|
||||
function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || tag === 'button' || tag === 'a') return true;
|
||||
if (target.closest('button, a, input, textarea, select, [role="button"], [role="listbox"]')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function NotionFieldRow({
|
||||
label,
|
||||
required = false,
|
||||
editable = true,
|
||||
isEditing = false,
|
||||
className,
|
||||
children,
|
||||
editor,
|
||||
onEnterEdit,
|
||||
showEditHint = true,
|
||||
}: NotionFieldRowProps) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
if (!editable || isEditing) return;
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
e.stopPropagation();
|
||||
onEnterEdit?.();
|
||||
}, [editable, isEditing, onEnterEdit]);
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (!editable || isEditing) return;
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
const now = Date.now();
|
||||
if (now - lastTapRef.current < DOUBLE_TAP_MS) {
|
||||
e.stopPropagation();
|
||||
onEnterEdit?.();
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
}, [editable, isEditing, onEnterEdit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
// Mobile: single column, label on top
|
||||
// Desktop: grid with fixed label column
|
||||
"grid gap-x-3 gap-y-1 py-1.5 px-2 -mx-2 rounded transition-colors",
|
||||
"grid-cols-1",
|
||||
"md:grid-cols-[minmax(120px,140px)_1fr] md:items-start",
|
||||
editable && !isEditing && "hover:bg-muted/40 cursor-pointer group/field",
|
||||
className
|
||||
)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
data-editable={editable}
|
||||
>
|
||||
{/* Label column — right-aligned on desktop */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-1",
|
||||
"md:justify-end md:text-right md:pt-1"
|
||||
)}>
|
||||
{required && (
|
||||
<span className="text-destructive text-[10px] font-bold leading-none">*</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground font-medium leading-tight truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Value column */}
|
||||
<div className="min-w-0">
|
||||
{isEditing && editor ? (
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
{editor}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 min-h-[22px]">
|
||||
<div className="flex-1 min-w-0 text-[13px] leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
{editable && showEditHint && (
|
||||
<Pencil className="w-3 h-3 text-muted-foreground/0 group-hover/field:text-muted-foreground/40 transition-all shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
client/src/components/task-detail/StickyActionBar.tsx
Normal file
74
client/src/components/task-detail/StickyActionBar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Save, X, Paperclip } from 'lucide-react';
|
||||
|
||||
interface StickyActionBarProps {
|
||||
visible: boolean;
|
||||
isSaving?: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onAttach?: () => void;
|
||||
}
|
||||
|
||||
export function StickyActionBar({
|
||||
visible,
|
||||
isSaving = false,
|
||||
onSave,
|
||||
onCancel,
|
||||
onAttach,
|
||||
}: StickyActionBarProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky bottom-0 z-40 bg-background/95 backdrop-blur-sm",
|
||||
"border-t shadow-[0_-2px_10px_rgba(0,0,0,0.05)]",
|
||||
"safe-area-bottom"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3">
|
||||
{/* Left: Attach button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onAttach && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={onAttach}
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Прикрепить файл</span>
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
Есть несохраненные изменения
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Cancel + Save */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-4 text-xs"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<X className="w-3.5 h-3.5 mr-1" />
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-4 text-xs bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 mr-1" />
|
||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
client/src/components/task-detail/useDirtyFields.ts
Normal file
34
client/src/components/task-detail/useDirtyFields.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useDirtyFields() {
|
||||
const [dirtyFields, setDirtyFields] = useState<Set<number>>(new Set());
|
||||
|
||||
const markDirty = useCallback((fieldId: number) => {
|
||||
setDirtyFields(prev => {
|
||||
if (prev.has(fieldId)) return prev;
|
||||
return new Set(prev).add(fieldId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearDirty = useCallback((fieldId: number) => {
|
||||
setDirtyFields(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(fieldId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllDirty = useCallback(() => {
|
||||
setDirtyFields(new Set());
|
||||
}, []);
|
||||
|
||||
const isDirty = dirtyFields.size > 0;
|
||||
|
||||
return {
|
||||
dirtyFields,
|
||||
isDirty,
|
||||
markDirty,
|
||||
clearDirty,
|
||||
clearAllDirty,
|
||||
};
|
||||
}
|
||||
71
client/src/components/task-detail/useInlineEdit.ts
Normal file
71
client/src/components/task-detail/useInlineEdit.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface UseInlineEditOptions<T> {
|
||||
initialValue: T;
|
||||
onSave: (value: T) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function useInlineEdit<T>({ initialValue, onSave }: UseInlineEditOptions<T>) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftValue, setDraftValue] = useState<T>(initialValue);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const originalValueRef = useRef<T>(initialValue);
|
||||
const isDirtyRef = useRef(false);
|
||||
|
||||
const enterEdit = useCallback(() => {
|
||||
setDraftValue(originalValueRef.current);
|
||||
isDirtyRef.current = false;
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const exitEdit = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setDraftValue(originalValueRef.current);
|
||||
isDirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const saveEdit = useCallback(async () => {
|
||||
if (!isDirtyRef.current) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(draftValue);
|
||||
originalValueRef.current = draftValue;
|
||||
isDirtyRef.current = false;
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
// Error handled by caller (toast)
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draftValue, onSave]);
|
||||
|
||||
const updateDraft = useCallback((value: T) => {
|
||||
setDraftValue(value);
|
||||
isDirtyRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
saveEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
exitEdit();
|
||||
}
|
||||
}, [saveEdit, exitEdit]);
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
draftValue,
|
||||
isSaving,
|
||||
isDirty: () => isDirtyRef.current,
|
||||
setDraftValue: updateDraft,
|
||||
enterEdit,
|
||||
exitEdit,
|
||||
saveEdit,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -431,3 +431,87 @@ td.formula-highlight {
|
||||
::selection {
|
||||
background-color: var(--tw-background-transparent-secondary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Notion-Style Task Detail — Inline Edit System
|
||||
Added: 2026-07-16
|
||||
============================================================ */
|
||||
|
||||
@layer components {
|
||||
/* --- Unified field grid: label + value --- */
|
||||
.notion-field-row {
|
||||
@apply grid gap-x-3 gap-y-1 py-1.5 px-2 -mx-2 rounded transition-colors;
|
||||
grid-template-columns: 1fr; /* mobile: stacked */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.notion-field-row {
|
||||
grid-template-columns: minmax(120px, 140px) 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Editable hover state */
|
||||
.notion-field-row[data-editable="true"]:not([data-editing="true"]):hover {
|
||||
@apply bg-muted/40;
|
||||
}
|
||||
|
||||
/* --- Label column --- */
|
||||
.notion-field-label {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.notion-field-label {
|
||||
@apply justify-end text-right;
|
||||
padding-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Value column --- */
|
||||
.notion-field-value {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
/* --- Sticky bottom action bar --- */
|
||||
.sticky-action-bar {
|
||||
@apply sticky bottom-0 z-40 bg-background/95 backdrop-blur-sm border-t;
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* --- Inline editor inputs (unified height) --- */
|
||||
.inline-input {
|
||||
@apply border border-input rounded-md bg-background px-2 text-[13px];
|
||||
@apply focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary;
|
||||
height: 32px;
|
||||
line-height: 30px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.inline-textarea {
|
||||
@apply border border-input rounded-md bg-background px-2 py-1.5 text-[13px];
|
||||
@apply focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary;
|
||||
min-height: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.inline-select {
|
||||
@apply inline-input w-full appearance-none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- PWA Safe Area --- */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.safe-area-bottom {
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ import ContactField from '@/components/ContactField';
|
||||
import { UserRoleRenderer, UserRoleEditor } from '@/components/fields';
|
||||
import { InlineField } from '@/components/fields/InlineField';
|
||||
import { TaskFieldSelect } from '@/components/task-form-fields';
|
||||
import { NotionFieldRow } from '@/components/task-detail/NotionFieldRow';
|
||||
import { StickyActionBar } from '@/components/task-detail/StickyActionBar';
|
||||
import { useDirtyFields } from '@/components/task-detail/useDirtyFields';
|
||||
import { TaskDocumentButton } from '@/components/tasks/TaskDocumentButton';
|
||||
import { TaskQrButton } from '@/components/TaskQrButton';
|
||||
import { DocumentTemplateField } from '@/components/fields/DocumentTemplateField';
|
||||
@@ -1253,52 +1256,36 @@ function FieldValueRow({
|
||||
const fieldUrl = `/api/tasks/${taskId}/field-values/${field.id}`;
|
||||
const isFieldPending = isPending ? isPending(fieldUrl) : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="group/field flex items-start gap-1 py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/20"
|
||||
onDoubleClick={handleEnterEdit}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Левая колонка: индикаторы обязательности/неизменяемости */}
|
||||
<div className="w-6 h-5 shrink-0 flex items-center justify-center">
|
||||
{isFieldPending ? (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5" title="Изменение ожидает синхронизации">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 animate-pulse" />
|
||||
// Build status indicator for label
|
||||
const statusIndicator = isFieldPending ? (
|
||||
<span title="Изменение ожидает синхронизации">
|
||||
<Clock className="w-3 h-3 text-amber-500 dark:text-amber-400 animate-pulse" />
|
||||
</span>
|
||||
) : fieldState.immutable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnterEdit}
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50 cursor-not-allowed"
|
||||
title="Редактирование запрещено в текущем статусе"
|
||||
>
|
||||
<PencilOff className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : fieldState.required ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-5 h-5 text-red-500 dark:text-red-400 font-bold"
|
||||
title="Обязательное поле"
|
||||
>
|
||||
*
|
||||
<span title="Редактирование запрещено">
|
||||
<PencilOff className="w-3 h-3 text-red-400" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : fieldState.required ? (
|
||||
<span className="text-destructive text-[10px] font-bold">*</span>
|
||||
) : null;
|
||||
|
||||
{/* Контент поля */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-1 flex-wrap">
|
||||
<strong className={cn('shrink-0', errorMessage && 'text-destructive')}>
|
||||
{field.name}:
|
||||
</strong>
|
||||
return (
|
||||
<>
|
||||
<NotionFieldRow
|
||||
label={field.name}
|
||||
required={fieldState.required}
|
||||
editable={isEditable && !readOnly && !fieldState.immutable}
|
||||
isEditing={isGlobalEditMode && isEditable && !fieldState.immutable && !readOnly}
|
||||
onEnterEdit={handleEnterEdit}
|
||||
className={isFieldPending && !isGlobalEditMode ? 'opacity-80' : undefined}
|
||||
>
|
||||
{/* Read view */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusIndicator}
|
||||
{(() => {
|
||||
const readViewNode = renderReadValue();
|
||||
const editorNode = renderEditor();
|
||||
const canShowInlineEditor = isGlobalEditMode && isEditable && !fieldState.immutable && !readOnly && editorNode != null;
|
||||
const isContractNumberField = field.type === 'contract-number';
|
||||
const isDateWithProlongation = (field.type === 'date' || field.type === 'datetime') && field.autoProlongation;
|
||||
const needsWideOverlay = isContractNumberField || isDateWithProlongation;
|
||||
return canShowInlineEditor ? (
|
||||
<InlineField
|
||||
readView={readViewNode}
|
||||
@@ -1306,23 +1293,17 @@ function FieldValueRow({
|
||||
isEditing={true}
|
||||
editable={isEditable && !readOnly && !fieldState.immutable}
|
||||
onEnterEdit={handleEnterEdit}
|
||||
fitOverlay={!needsWideOverlay && field.type !== 'textarea' && field.type !== 'rich-text'}
|
||||
fitOverlay={field.type !== 'textarea' && field.type !== 'rich-text'}
|
||||
overlayClassName={
|
||||
needsWideOverlay
|
||||
? 'overflow-visible'
|
||||
: field.type === 'textarea' || field.type === 'rich-text'
|
||||
field.type === 'textarea' || field.type === 'rich-text'
|
||||
? 'flex flex-col overflow-hidden'
|
||||
: ''
|
||||
}
|
||||
overlayMaxHeight={field.type === 'textarea' || field.type === 'rich-text' ? 'min(85vh, 700px)' : undefined}
|
||||
overlayMinWidth={
|
||||
isContractNumberField
|
||||
? 320
|
||||
: isDateWithProlongation
|
||||
? 280
|
||||
: field.type === 'textarea' || field.type === 'rich-text'
|
||||
field.type === 'textarea' || field.type === 'rich-text'
|
||||
? '100%'
|
||||
: 180
|
||||
: 200
|
||||
}
|
||||
overlayWidth={field.type === 'textarea' || field.type === 'rich-text' ? '100%' : undefined}
|
||||
className={field.type === 'textarea' || field.type === 'rich-text' ? 'items-start min-h-[24px] w-full' : 'items-center min-h-[28px]'}
|
||||
@@ -1330,17 +1311,24 @@ function FieldValueRow({
|
||||
) : readViewNode;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Pending indicator below value */}
|
||||
{isFieldPending && !isGlobalEditMode && (
|
||||
<span
|
||||
className="ml-1.5 inline-flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium"
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium mt-0.5"
|
||||
title="Изменение будет отправлено при восстановлении соединения"
|
||||
>
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
ожидает
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation error */}
|
||||
{errorMessage && (
|
||||
<span className="text-[10px] text-destructive mt-0.5 block">{errorMessage}</span>
|
||||
)}
|
||||
</NotionFieldRow>
|
||||
|
||||
<GeoNavigationPicker
|
||||
geo={navGeo}
|
||||
open={!!navGeo}
|
||||
@@ -2665,6 +2653,9 @@ const TaskDetail = () => {
|
||||
// Глобальный режим редактирования задачи
|
||||
const [isTaskEditMode, setIsTaskEditMode] = useState(false);
|
||||
const [draftFieldValues, setDraftFieldValues] = useState<Record<number, string | number | boolean | string[] | number[] | null | undefined>>({});
|
||||
|
||||
// Notion-style dirty fields tracking (new sticky bottom bar)
|
||||
const { isDirty, markDirty, clearAllDirty } = useDirtyFields();
|
||||
const [draftDueDate, setDraftDueDate] = useState('');
|
||||
const [draftDueTime, setDraftDueTime] = useState('');
|
||||
const [draftTitle, setDraftTitle] = useState('');
|
||||
@@ -3493,30 +3484,9 @@ const TaskDetail = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const editModeBar = isTaskEditMode && (
|
||||
<div className="shrink-0 border-b bg-muted/50 px-3 py-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">Режим редактирования</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={exitTaskEditMode}
|
||||
disabled={isSavingBatch}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={handleSaveTask}
|
||||
disabled={isSavingBatch}
|
||||
>
|
||||
{isSavingBatch ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// REMOVED: editModeBar (top bar) — replaced with StickyActionBar at bottom
|
||||
// See StickyActionBar component at the bottom of the page layout
|
||||
const editModeBar = null;
|
||||
|
||||
// Мобильный accordion — все разделы задачи в виде сворачиваемых панелей
|
||||
// Важно: не объявляем как компонент внутри TaskDetail, чтобы избежать remount полей.
|
||||
@@ -3939,7 +3909,7 @@ const TaskDetail = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editModeBar}
|
||||
{/* REMOVED: editModeBar (top bar) — replaced with StickyActionBar at bottom */}
|
||||
|
||||
{/* Мобильный таб-бар — только Задача / Чат */}
|
||||
{isMobile && chatEnabled && (
|
||||
@@ -4254,6 +4224,22 @@ const TaskDetail = () => {
|
||||
</Dialog>
|
||||
|
||||
{/* (debug overlay removed) */}
|
||||
|
||||
{/* Notion-style sticky bottom action bar */}
|
||||
<StickyActionBar
|
||||
visible={isDirty || isTaskEditMode}
|
||||
isSaving={isSavingBatch}
|
||||
onSave={() => {
|
||||
if (isTaskEditMode) {
|
||||
handleSaveTask();
|
||||
}
|
||||
clearAllDirty();
|
||||
}}
|
||||
onCancel={() => {
|
||||
exitTaskEditMode();
|
||||
clearAllDirty();
|
||||
}}
|
||||
/>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "5.6.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user