Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
194
src/components/shared/notes-list.tsx
Normal file
194
src/components/shared/notes-list.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Lock, Pencil, Trash2, Send, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName?: string;
|
||||
isLocked: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface NotesListProps {
|
||||
entityType: 'clients' | 'interests';
|
||||
entityId: string;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export function NotesList({ entityType, entityId, currentUserId }: NotesListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [newNote, setNewNote] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
|
||||
const endpoint = `/api/v1/${entityType}/${entityId}/notes`;
|
||||
const queryKey = [entityType, entityId, 'notes'];
|
||||
|
||||
const { data: notes = [], isLoading } = useQuery<Note[]>({
|
||||
queryKey,
|
||||
queryFn: () => apiFetch<{ data: Note[] }>(endpoint).then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) =>
|
||||
apiFetch(endpoint, { method: 'POST', body: { content } }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
setNewNote('');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
|
||||
apiFetch(`${endpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
setEditingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (noteId: string) =>
|
||||
apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
});
|
||||
|
||||
function canEdit(note: Note): boolean {
|
||||
if (note.authorId !== currentUserId) return false;
|
||||
if (note.isLocked) return false;
|
||||
const elapsed = Date.now() - new Date(note.createdAt).getTime();
|
||||
return elapsed < NOTE_EDIT_WINDOW_MS;
|
||||
}
|
||||
|
||||
function getTimeRemaining(note: Note): string | null {
|
||||
const elapsed = Date.now() - new Date(note.createdAt).getTime();
|
||||
const remaining = NOTE_EDIT_WINDOW_MS - elapsed;
|
||||
if (remaining <= 0) return null;
|
||||
const mins = Math.ceil(remaining / 60000);
|
||||
return `${mins}m left to edit`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Create note form */}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="Add a note..."
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!newNote.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate(newNote.trim())}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes list */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Loading notes...</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No notes yet</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => (
|
||||
<div key={note.id} className="flex gap-3 p-3 rounded-lg border bg-card">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className="text-xs">
|
||||
{(note.authorName ?? 'U').charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{note.authorName ?? 'User'}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
{note.isLocked && (
|
||||
<Lock className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{canEdit(note) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getTimeRemaining(note)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{editingId === note.id ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!editContent.trim() || updateMutation.isPending}
|
||||
onClick={() =>
|
||||
updateMutation.mutate({ noteId: note.id, content: editContent.trim() })
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap">{note.content}</p>
|
||||
)}
|
||||
</div>
|
||||
{canEdit(note) && editingId !== note.id && (
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
setEditingId(note.id);
|
||||
setEditContent(note.content);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => deleteMutation.mutate(note.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user