Browse Source

refactor(dashboard): Extract history timeline into reusable components

Michael Bromley 10 months ago
parent
commit
06fbb629ca

+ 116 - 0
packages/dashboard/src/components/shared/history-timeline/history-entry.tsx

@@ -0,0 +1,116 @@
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { Separator } from '@/components/ui/separator.js';
+import { useLocalFormat } from '@/hooks/use-local-format.js';
+import { Trans } from '@lingui/react/macro';
+import { MoreVerticalIcon, PencilIcon, TrashIcon } from 'lucide-react';
+import { useCallback } from 'react';
+import { useHistoryTimeline } from './history-timeline.js';
+
+export interface HistoryEntryItem {
+    id: string;
+    type: string;
+    createdAt: string;
+    isPublic: boolean;
+    administrator?: {
+        id: string;
+        firstName: string;
+        lastName: string;
+    } | null;
+    data: any;
+}
+
+interface HistoryEntryProps {
+    entry: HistoryEntryItem;
+    isNoteEntry: boolean;
+    timelineIcon: React.ReactNode;
+    title: string | React.ReactNode;
+    children: React.ReactNode;
+}
+
+export function HistoryEntry({
+    entry,
+    isNoteEntry,
+    timelineIcon,
+    title,
+    children,
+}: HistoryEntryProps) {
+    const { formatDate } = useLocalFormat();
+    const { editNote, deleteNote } = useHistoryTimeline();
+
+
+    const formatDateTime = useCallback((date: string) => {
+        return formatDate(date, {
+            year: 'numeric',
+            month: 'long',
+            day: 'numeric',
+            hour: 'numeric',
+            minute: 'numeric',
+            second: 'numeric',
+        });
+    }, [formatDate]);
+
+    return (
+        <div key={entry.id} className="relative mb-4 pl-11">
+            <div className="absolute left-0 w-10 flex items-center justify-center">
+                <div className={`rounded-full flex items-center justify-center h-6 w-6`}>
+                    <div
+                        className={`rounded-full bg-gray-200 text-muted-foreground flex items-center justify-center h-6 w-6`}
+                    >
+                        {timelineIcon}
+                    </div>
+                </div>
+            </div>
+
+            <div className="bg-white px-4 rounded-md">
+                <div className="mt-2 text-sm text-gray-500 flex items-center">
+                    <span>{formatDateTime(entry.createdAt)}</span>
+                    {entry.administrator && (
+                        <span className="ml-2">
+                            {entry.administrator.firstName} {entry.administrator.lastName}
+                        </span>
+                    )}
+                </div>
+                <div className="flex items-start justify-between">
+                    <div>
+                        <div className="font-medium text-sm">{title}</div>
+                        {children}
+                    </div>
+
+                    {isNoteEntry && (
+                        <DropdownMenu>
+                            <DropdownMenuTrigger asChild>
+                                <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
+                                    <MoreVerticalIcon className="h-4 w-4" />
+                                </Button>
+                            </DropdownMenuTrigger>
+                            <DropdownMenuContent align="end">
+                                <DropdownMenuItem
+                                    onClick={() => editNote(entry.id, entry.data.note, !entry.isPublic)}
+                                    className="cursor-pointer"
+                                >
+                                    <PencilIcon className="mr-2 h-4 w-4" />
+                                    <Trans>Edit</Trans>
+                                </DropdownMenuItem>
+                                <Separator className="my-1" />
+                                <DropdownMenuItem
+                                    onClick={() => deleteNote(entry.id)}
+                                    className="cursor-pointer text-red-600 focus:text-red-600"
+                                >
+                                    <TrashIcon className="mr-2 h-4 w-4" />
+                                    <span>Delete</span>
+                                </DropdownMenuItem>
+                            </DropdownMenuContent>
+                        </DropdownMenu>
+                    )}
+                </div>
+            </div>
+            <div className="border-b border-muted my-4 mx-4"></div>
+        </div>
+    );
+}

+ 28 - 0
packages/dashboard/src/components/shared/history-timeline/history-note-checkbox.tsx

@@ -0,0 +1,28 @@
+import { Checkbox } from '@/components/ui/checkbox.js';
+import { Trans } from '@lingui/react/macro';
+
+interface HistoryNoteCheckboxProps {
+    value: boolean;
+    onChange: (value: boolean) => void;
+}
+
+export function HistoryNoteCheckbox({ value, onChange }: HistoryNoteCheckboxProps) {
+    return (
+        <div className="flex items-center space-x-2">
+            <Checkbox
+                id="note-private"
+                checked={value}
+                onCheckedChange={checked => onChange(checked as boolean)}
+            />
+            <label
+                htmlFor="note-private"
+                className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+            >
+                <Trans>Note is private</Trans>
+            </label>
+            <span className={value ? 'text-gray-500 text-xs' : 'text-green-600 text-xs'}>
+                {value ? <Trans>Visible to admins only</Trans> : <Trans>Visible to customer</Trans>}
+            </span>
+        </div>
+    );
+}

+ 15 - 2
packages/dashboard/src/routes/_authenticated/_orders/components/order-history/note-editor.tsx → packages/dashboard/src/components/shared/history-timeline/history-note-editor.tsx

@@ -10,6 +10,7 @@ import {
 import { Textarea } from '@/components/ui/textarea.js';
 import { Trans } from '@lingui/react/macro';
 import { useState } from 'react';
+import { HistoryNoteCheckbox } from './history-note-checkbox.js';
 
 interface NoteEditorProps {
     note: string;
@@ -20,10 +21,18 @@ interface NoteEditorProps {
     isPrivate: boolean;
 }
 
-export function NoteEditor({ open, onOpenChange, note, onNoteChange, noteId, isPrivate }: NoteEditorProps) {
+export function HistoryNoteEditor({
+    open,
+    onOpenChange,
+    note,
+    onNoteChange,
+    noteId,
+    isPrivate,
+}: NoteEditorProps) {
     const [value, setValue] = useState(note);
+    const [noteIsPrivate, setNoteIsPrivate] = useState(isPrivate);
     const handleSave = () => {
-        onNoteChange(noteId, value, isPrivate);
+        onNoteChange(noteId, value, noteIsPrivate);
         onOpenChange(false);
     };
 
@@ -34,8 +43,12 @@ export function NoteEditor({ open, onOpenChange, note, onNoteChange, noteId, isP
                     <DialogTitle>
                         <Trans>Edit Note</Trans>
                     </DialogTitle>
+                    <DialogDescription>
+                        <Trans>Update the note content or visibility</Trans>
+                    </DialogDescription>
                 </DialogHeader>
                 <Textarea value={value} onChange={e => setValue(e.target.value)} />
+                <HistoryNoteCheckbox value={noteIsPrivate} onChange={setNoteIsPrivate} />
                 <DialogFooter>
                     <Button onClick={() => handleSave()}>
                         <Trans>Save</Trans>

+ 39 - 0
packages/dashboard/src/components/shared/history-timeline/history-note-input.tsx

@@ -0,0 +1,39 @@
+import { Button } from '@/components/ui/button.js';
+import { Textarea } from '@/components/ui/textarea.js';
+import { useState } from 'react';
+import { HistoryNoteCheckbox } from './history-note-checkbox.js';
+
+interface HistoryNoteInputProps {
+    onAddNote: (note: string, isPrivate: boolean) => void;
+}
+
+export function HistoryNoteInput({ onAddNote }: HistoryNoteInputProps) {
+    const [note, setNote] = useState('');
+    const [noteIsPrivate, setNoteIsPrivate] = useState(true);
+
+    const handleAddNote = () => {
+        if (note.trim()) {
+            onAddNote(note, noteIsPrivate);
+            setNote('');
+        }
+    };
+
+    return (
+        <div className="border rounded-md p-4 bg-gray-50">
+            <div className="flex flex-col space-y-4">
+                <Textarea
+                    placeholder="Add a note..."
+                    value={note}
+                    onChange={e => setNote(e.target.value)}
+                    className="min-h-[80px] resize-none"
+                />
+                <div className="flex items-center justify-between">
+                    <HistoryNoteCheckbox value={noteIsPrivate} onChange={setNoteIsPrivate} />
+                    <Button onClick={handleAddNote} disabled={!note.trim()} size="sm">
+                        Add note
+                    </Button>
+                </div>
+            </div>
+        </div>
+    );
+} 

+ 56 - 0
packages/dashboard/src/components/shared/history-timeline/history-timeline.tsx

@@ -0,0 +1,56 @@
+import { ScrollArea } from '@/components/ui/scroll-area.js';
+import { useState, createContext, useContext } from 'react';
+import { HistoryNoteEditor } from './history-note-editor.js';
+
+interface HistoryTimelineProps {
+    children: React.ReactNode;
+    onEditNote?: (entryId: string, note: string, isPublic: boolean) => void;
+    onDeleteNote?: (entryId: string) => void;
+}
+
+// Use context to make the note editing functions available to the child
+// HistoryEntry component
+const HistoryTimelineContext = createContext<{
+    editNote: (noteId: string, note: string, isPrivate: boolean) => void;
+    deleteNote: (noteId: string) => void;
+}>({ editNote: () => {}, deleteNote: () => {} });
+
+type NoteEditorNote = { noteId: string; note: string; isPrivate: boolean };
+
+export function useHistoryTimeline() {
+    return useContext(HistoryTimelineContext);
+}
+
+export function HistoryTimeline({ children, onEditNote, onDeleteNote }: HistoryTimelineProps) {
+    const [noteEditorOpen, setNoteEditorOpen] = useState(false);
+    const [noteEditorNote, setNoteEditorNote] = useState<NoteEditorNote>({ noteId: '', note: '', isPrivate: true });
+
+    const editNote = (noteId: string, note: string, isPrivate: boolean) => {
+        setNoteEditorNote({ noteId, note, isPrivate });
+        setNoteEditorOpen(true);
+    }
+
+    const deleteNote = (noteId: string) => {
+        setNoteEditorNote({ noteId, note: '', isPrivate: true });
+    }
+
+    return (
+        <HistoryTimelineContext.Provider value={{ editNote, deleteNote }}>
+            <ScrollArea className=" pr-4">
+                <div className="relative">
+                    <div className="absolute left-5 top-0 bottom-[44px] w-0.5 bg-gray-200" />
+                {children}
+                </div>
+            </ScrollArea>
+            <HistoryNoteEditor
+                key={noteEditorNote.noteId}
+                note={noteEditorNote.note}
+                onNoteChange={(...args) => onEditNote?.(...args)}
+                open={noteEditorOpen}
+                onOpenChange={setNoteEditorOpen}
+                noteId={noteEditorNote.noteId}
+                isPrivate={noteEditorNote.isPrivate}
+            />
+        </HistoryTimelineContext.Provider>
+    );
+}

+ 42 - 238
packages/dashboard/src/routes/_authenticated/_orders/components/order-history/order-history.tsx

@@ -1,28 +1,9 @@
 import { Badge } from '@/components/ui/badge.js';
-import { Button } from '@/components/ui/button.js';
-import { Checkbox } from '@/components/ui/checkbox.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu.js';
-import { ScrollArea } from '@/components/ui/scroll-area.js';
-import { Separator } from '@/components/ui/separator.js';
-import { Textarea } from '@/components/ui/textarea.js';
-import { useLocalFormat } from '@/hooks/use-local-format.js';
 import { Trans } from '@lingui/react/macro';
-import {
-    ArrowRightToLine,
-    CheckIcon,
-    CreditCardIcon,
-    MoreVerticalIcon,
-    PencilIcon,
-    SquarePen,
-    TrashIcon,
-} from 'lucide-react';
-import { useState } from 'react';
-import { NoteEditor } from './note-editor.js';
+import { ArrowRightToLine, CheckIcon, CreditCardIcon, SquarePen } from 'lucide-react';
+import { HistoryEntry, HistoryEntryItem } from '@/components/shared/history-timeline/history-entry.js';
+import { HistoryNoteInput } from '@/components/shared/history-timeline/history-note-input.js';
+import { HistoryTimeline } from '@/components/shared/history-timeline/history-timeline.js';
 
 interface OrderHistoryProps {
     order: {
@@ -30,41 +11,13 @@ interface OrderHistoryProps {
         createdAt: string;
         currencyCode: string;
     };
-    historyEntries: Array<{
-        id: string;
-        type: string;
-        createdAt: string;
-        isPublic: boolean;
-        administrator?: {
-            id: string;
-            firstName: string;
-            lastName: string;
-        } | null;
-        data: any;
-    }>;
+    historyEntries: Array<HistoryEntryItem>;
     onAddNote: (note: string, isPrivate: boolean) => void;
     onUpdateNote?: (entryId: string, note: string, isPrivate: boolean) => void;
     onDeleteNote?: (entryId: string) => void;
 }
 
-// Helper function to get initials from a name
-const getInitials = (firstName: string, lastName: string) => {
-    return `${firstName.charAt(0)}${lastName.charAt(0)}`;
-};
-
-export function OrderHistory({
-    order,
-    historyEntries,
-    onAddNote,
-    onUpdateNote,
-    onDeleteNote,
-}: OrderHistoryProps) {
-    const [note, setNote] = useState('');
-    const [noteIsPrivate, setNoteIsPrivate] = useState(true);
-    const { formatDate } = useLocalFormat();
-    const [noteEditorOpen, setNoteEditorOpen] = useState(false);
-      const [noteEditorNote, setNoteEditorNote] = useState<{ noteId: string; note: string; isPrivate: boolean }>({ noteId: '', note: '', isPrivate: true });
-
+export function OrderHistory({ historyEntries, onAddNote, onUpdateNote, onDeleteNote }: OrderHistoryProps) {
     const getTimelineIcon = (entry: OrderHistoryProps['historyEntries'][0]) => {
         switch (entry.type) {
             case 'ORDER_PAYMENT_TRANSITION':
@@ -85,6 +38,9 @@ export function OrderHistory({
             case 'ORDER_NOTE':
                 return <Trans>Note added</Trans>;
             case 'ORDER_STATE_TRANSITION': {
+                if (entry.data.from === 'Created') {
+                    return <Trans>Order created</Trans>;
+                }
                 if (entry.data.to === 'Delivered') {
                     return <Trans>Order fulfilled</Trans>;
                 }
@@ -98,195 +54,43 @@ export function OrderHistory({
         }
     };
 
-    const handleAddNote = () => {
-        if (note.trim()) {
-            onAddNote(note, noteIsPrivate);
-            setNote('');
-        }
-    };
-
-    const formatDateTime = (date: string) => {
-        return formatDate(date, {
-            year: 'numeric',
-            month: 'long',
-            day: 'numeric',
-            hour: 'numeric',
-            minute: 'numeric',
-            second: 'numeric',
-        });
-    };
-
-    const onEditNote = (noteId: string, note: string, isPrivate: boolean) => {
-        setNoteEditorNote({ noteId, note, isPrivate });
-        setNoteEditorOpen(true);
-    };
-
-    const onEditNoteSave = (noteId: string, note: string, isPrivate: boolean) => {
-        onUpdateNote?.(noteId, note, isPrivate);
-        setNoteEditorOpen(false);
-    };
-
     return (
-        <div className="space-y-4">
-            <div className="space-y-4">
-                {/* Add Note Section */}
-                <div className="border rounded-md p-4 bg-gray-50">
-                    <div className="flex flex-col space-y-4">
-                        <Textarea
-                            placeholder="Add a note..."
-                            value={note}
-                            onChange={e => setNote(e.target.value)}
-                            className="min-h-[80px] resize-none"
-                        />
-                        <div className="flex items-center justify-between">
+        <div className="">
+            <div className="mb-4">
+                <HistoryNoteInput onAddNote={onAddNote} />
+            </div>
+            <HistoryTimeline onEditNote={onUpdateNote} onDeleteNote={onDeleteNote}>
+                {historyEntries.map(entry => (
+                    <HistoryEntry
+                        key={entry.id}
+                        entry={entry}
+                        isNoteEntry={entry.type === 'ORDER_NOTE'}
+                        timelineIcon={getTimelineIcon(entry)}
+                        title={getTitle(entry)}
+                    >
+                        {entry.type === 'ORDER_NOTE' && (
                             <div className="flex items-center space-x-2">
-                                <Checkbox
-                                    id="note-private"
-                                    checked={noteIsPrivate}
-                                    onCheckedChange={checked => setNoteIsPrivate(checked as boolean)}
-                                />
-                                <label
-                                    htmlFor="note-private"
-                                    className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
-                                >
-                                    Note is private
-                                </label>
-                                <span
-                                    className={
-                                        noteIsPrivate ? 'text-gray-500 text-xs' : 'text-green-600 text-xs'
-                                    }
-                                >
-                                    {noteIsPrivate ? 'Visible to admins only' : 'Visible to customer'}
-                                </span>
+                                <Badge variant={entry.isPublic ? 'outline' : 'secondary'} className="text-xs">
+                                    {entry.isPublic ? 'Public' : 'Private'}
+                                </Badge>
+                                <span>{entry.data.note}</span>
                             </div>
-                            <Button onClick={handleAddNote} disabled={!note.trim()} size="sm">
-                                Add note
-                            </Button>
+                        )}
+                        <div className="text-sm text-muted-foreground">
+                            {entry.type === 'ORDER_STATE_TRANSITION' && entry.data.from !== 'Created' && (
+                                <Trans>
+                                    From {entry.data.from} to {entry.data.to}
+                                </Trans>
+                            )}
+                            {entry.type === 'ORDER_PAYMENT_TRANSITION' && (
+                                <Trans>
+                                    Payment #{entry.data.paymentId} transitioned to {entry.data.to}
+                                </Trans>
+                            )}
                         </div>
-                    </div>
-                </div>
-
-                {/* Timeline */}
-                <ScrollArea className=" pr-4">
-                    <div className="relative">
-                        <div className="absolute left-5 top-0 bottom-[44px] w-0.5 bg-gray-200" />
-
-                        {/* History entries */}
-                        {historyEntries.map((entry, index) => (
-                            <div key={entry.id} className="relative mb-4 pl-11">
-                                <div className="absolute left-0 w-10 flex items-center justify-center">
-                                    <div className={`rounded-full flex items-center justify-center h-6 w-6`}>
-                                        <div
-                                            className={`rounded-full bg-gray-200 text-muted-foreground flex items-center justify-center h-6 w-6`}
-                                        >
-                                            {getTimelineIcon(entry)}
-                                        </div>
-                                    </div>
-                                </div>
-
-                                <div className="bg-white px-4 rounded-md">
-                                    <div className="mt-2 text-sm text-gray-500 flex items-center">
-                                        <span>{formatDateTime(entry.createdAt)}</span>
-                                        {entry.administrator && (
-                                            <span className="ml-2">
-                                                {entry.administrator.firstName} {entry.administrator.lastName}
-                                            </span>
-                                        )}
-                                    </div>
-                                    <div className="flex items-start justify-between">
-                                        <div>
-                                            <div className="font-medium text-sm">{getTitle(entry)}</div>
-
-                                            {entry.type === 'ORDER_NOTE' && (
-                                                <div className="flex items-center space-x-2">
-                                                    <Badge
-                                                        variant={entry.isPublic ? 'outline' : 'secondary'}
-                                                        className="text-xs"
-                                                    >
-                                                        {entry.isPublic ? 'Public' : 'Private'}
-                                                    </Badge>
-                                                    <span>{entry.data.note}</span>
-                                                </div>
-                                            )}
-                                            <div className="text-sm text-muted-foreground">
-                                                {entry.type === 'ORDER_STATE_TRANSITION' && (
-                                                    <Trans>
-                                                        From {entry.data.from} to {entry.data.to}
-                                                    </Trans>
-                                                )}
-                                                {entry.type === 'ORDER_PAYMENT_TRANSITION' && (
-                                                    <Trans>
-                                                        Payment #{entry.data.paymentId} transitioned to{' '}
-                                                        {entry.data.to}
-                                                    </Trans>
-                                                )}
-                                            </div>
-                                        </div>
-
-                                        {entry.type === 'ORDER_NOTE' && (
-                                            <DropdownMenu>
-                                                <DropdownMenuTrigger asChild>
-                                                    <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
-                                                        <MoreVerticalIcon className="h-4 w-4" />
-                                                    </Button>
-                                                </DropdownMenuTrigger>
-                                                <DropdownMenuContent align="end">
-                                                    <DropdownMenuItem
-                                                        onClick={() =>
-                                                            onEditNote?.(
-                                                                entry.id,
-                                                                entry.data.note,
-                                                                !entry.isPublic,
-                                                            )
-                                                        }
-                                                        className="cursor-pointer"
-                                                    >
-                                                        <PencilIcon className="mr-2 h-4 w-4" />
-                                                        <Trans>Edit</Trans>
-                                                    </DropdownMenuItem>
-                                                    <Separator className="my-1" />
-                                                    <DropdownMenuItem
-                                                        onClick={() => onDeleteNote?.(entry.id)}
-                                                        className="cursor-pointer text-red-600 focus:text-red-600"
-                                                    >
-                                                        <TrashIcon className="mr-2 h-4 w-4" />
-                                                        <span>Delete</span>
-                                                    </DropdownMenuItem>
-                                                </DropdownMenuContent>
-                                            </DropdownMenu>
-                                        )}
-                                    </div>
-                                </div>
-                                <div className="border-b border-muted my-4 mx-4"></div>
-                            </div>
-                        ))}
-
-                        {/* Order created entry - always shown last */}
-                        <div className="relative mb-4 pl-11">
-                            <div className="absolute left-0 w-10 flex items-center justify-center">
-                                <div className="h-6 w-6 rounded-full flex items-center justify-center bg-green-100">
-                                    <CheckIcon className="h-4 w-4" />
-                                </div>
-                            </div>
-                            <div className="bg-white px-4 rounded-md">
-                                <div className="mt-2 text-sm text-gray-500">
-                                    {formatDateTime(order.createdAt)}
-                                </div>
-                                <div className="font-medium">Order created</div>
-                            </div>
-                        </div>
-                    </div>
-                </ScrollArea>
-                <NoteEditor
-                    key={noteEditorNote.noteId}
-                    note={noteEditorNote.note}
-                    onNoteChange={onEditNoteSave}
-                    open={noteEditorOpen}
-                    onOpenChange={setNoteEditorOpen}
-                    noteId={noteEditorNote.noteId}
-                    isPrivate={noteEditorNote.isPrivate}
-                />
-            </div>
+                    </HistoryEntry>
+                ))}
+            </HistoryTimeline>
         </div>
     );
 }