diff --git a/client/src/components/task-detail/NotionFieldRow.tsx b/client/src/components/task-detail/NotionFieldRow.tsx new file mode 100644 index 0000000..1824375 --- /dev/null +++ b/client/src/components/task-detail/NotionFieldRow.tsx @@ -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(null); + const lastTapRef = useRef(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 ( +
+ {/* Label column — right-aligned on desktop */} +
+ {required && ( + * + )} + + {label} + +
+ + {/* Value column */} +
+ {isEditing && editor ? ( +
e.stopPropagation()}> + {editor} +
+ ) : ( +
+
+ {children} +
+ {editable && showEditHint && ( + + )} +
+ )} +
+
+ ); +} diff --git a/client/src/components/task-detail/StickyActionBar.tsx b/client/src/components/task-detail/StickyActionBar.tsx new file mode 100644 index 0000000..c62587d --- /dev/null +++ b/client/src/components/task-detail/StickyActionBar.tsx @@ -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 ( +
+
+ {/* Left: Attach button */} +
+ {onAttach && ( + + )} + + Есть несохраненные изменения + +
+ + {/* Right: Cancel + Save */} +
+ + +
+
+
+ ); +} diff --git a/client/src/components/task-detail/useDirtyFields.ts b/client/src/components/task-detail/useDirtyFields.ts new file mode 100644 index 0000000..0ea6fd9 --- /dev/null +++ b/client/src/components/task-detail/useDirtyFields.ts @@ -0,0 +1,34 @@ +import { useState, useCallback } from 'react'; + +export function useDirtyFields() { + const [dirtyFields, setDirtyFields] = useState>(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, + }; +} diff --git a/client/src/components/task-detail/useInlineEdit.ts b/client/src/components/task-detail/useInlineEdit.ts new file mode 100644 index 0000000..2bd7035 --- /dev/null +++ b/client/src/components/task-detail/useInlineEdit.ts @@ -0,0 +1,71 @@ +import { useState, useCallback, useRef } from 'react'; + +interface UseInlineEditOptions { + initialValue: T; + onSave: (value: T) => void | Promise; +} + +export function useInlineEdit({ initialValue, onSave }: UseInlineEditOptions) { + const [isEditing, setIsEditing] = useState(false); + const [draftValue, setDraftValue] = useState(initialValue); + const [isSaving, setIsSaving] = useState(false); + const originalValueRef = useRef(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, + }; +} diff --git a/client/src/index.css b/client/src/index.css index c4ae28a..b984763 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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)); + } +} diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index b1f9ed5..2984370 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -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,94 +1256,79 @@ function FieldValueRow({ const fieldUrl = `/api/tasks/${taskId}/field-values/${field.id}`; const isFieldPending = isPending ? isPending(fieldUrl) : false; + // Build status indicator for label + const statusIndicator = isFieldPending ? ( + + + + ) : fieldState.immutable ? ( + + + + ) : fieldState.required ? ( + * + ) : null; + return ( <> -
- {/* Левая колонка: индикаторы обязательности/неизменяемости */} -
- {isFieldPending ? ( - - - - ) : fieldState.immutable ? ( - - ) : fieldState.required ? ( - - * - - ) : null} + {/* Read view */} +
+ {statusIndicator} + {(() => { + const readViewNode = renderReadValue(); + const editorNode = renderEditor(); + const canShowInlineEditor = isGlobalEditMode && isEditable && !fieldState.immutable && !readOnly && editorNode != null; + return canShowInlineEditor ? ( + + ) : readViewNode; + })()}
- {/* Контент поля */} -
-
- - {field.name}: - - {(() => { - 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 ? ( - - ) : readViewNode; - })()} -
- {isFieldPending && !isGlobalEditMode && ( - - - ожидает - - )} -
-
+ {/* Pending indicator below value */} + {isFieldPending && !isGlobalEditMode && ( + + + ожидает + + )} + + {/* Validation error */} + {errorMessage && ( + {errorMessage} + )} + + { // Глобальный режим редактирования задачи const [isTaskEditMode, setIsTaskEditMode] = useState(false); const [draftFieldValues, setDraftFieldValues] = useState>({}); + + // 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 && ( -
- Режим редактирования -
- - -
-
- ); + // 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 = () => {
)} - {editModeBar} + {/* REMOVED: editModeBar (top bar) — replaced with StickyActionBar at bottom */} {/* Мобильный таб-бар — только Задача / Чат */} {isMobile && chatEnabled && ( @@ -4254,6 +4224,22 @@ const TaskDetail = () => { {/* (debug overlay removed) */} + + {/* Notion-style sticky bottom action bar */} + { + if (isTaskEditMode) { + handleSaveTask(); + } + clearAllDirty(); + }} + onCancel={() => { + exitTaskEditMode(); + clearAllDirty(); + }} + /> ); }; diff --git a/package.json b/package.json index 3816c5a..c8f9bfc 100644 --- a/package.json +++ b/package.json @@ -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": {