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:
agent
2026-07-16 01:16:50 +08:00
parent a4a3a9ca18
commit 2331dc00a5
7 changed files with 465 additions and 109 deletions

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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,
};
}

View File

@@ -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));
}
}

View File

@@ -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>
);
};

View File

@@ -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": {