b9a4c4a353
- DashboardPage, Projects, RequestsPage, ProjectDetailPage, RequestDetail: each now handles team/external/client in one file via role flags — removed 10 old role-specific sub-files - Layout: client Company nav link goes directly to /company/:id when user has a single company - FileBrowser: replace emoji icons with colored extension-text badges (square); folder icon stays 📁; Adobe/Figma/design-tool colors for design files - CompaniesPage: merged team Companies + client company routing (single-company redirect, multi-company list) - FileSharing: integrated FileBrowser component - Removed: seafile API + lib, old ServerStatus, TaskDetail, role-split page files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
195 lines
6.8 KiB
React
195 lines
6.8 KiB
React
import { useEffect, useState } from 'react';
|
|
import Layout from '../../components/Layout';
|
|
import LoadingButton from '../../components/LoadingButton';
|
|
import { supabase } from '../../lib/supabase';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
|
|
function emptyForm() {
|
|
return {
|
|
title: '',
|
|
attendees: '',
|
|
meeting_at: new Date().toISOString().slice(0, 16),
|
|
notes: '',
|
|
};
|
|
}
|
|
|
|
function formatMeetingDate(value) {
|
|
if (!value) return 'Unknown date';
|
|
return new Date(value).toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
export default function MeetingNotes() {
|
|
const { currentUser } = useAuth();
|
|
const [notes, setNotes] = useState([]);
|
|
const [form, setForm] = useState(emptyForm());
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deletingId, setDeletingId] = useState('');
|
|
const [status, setStatus] = useState('');
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
async function loadInitialNotes() {
|
|
const { data, error } = await supabase
|
|
.from('meeting_notes')
|
|
.select('*')
|
|
.order('meeting_at', { ascending: false })
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (!isMounted) return;
|
|
|
|
if (error) {
|
|
setStatus(`Failed to load meeting notes: ${error.message}`);
|
|
setNotes([]);
|
|
} else {
|
|
setNotes(data || []);
|
|
setStatus('');
|
|
}
|
|
setLoading(false);
|
|
}
|
|
|
|
loadInitialNotes();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (!form.title.trim() || !form.notes.trim()) {
|
|
setStatus('Meeting title and notes are required.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
const payload = {
|
|
title: form.title.trim(),
|
|
attendees: form.attendees.trim(),
|
|
meeting_at: form.meeting_at ? new Date(form.meeting_at).toISOString() : new Date().toISOString(),
|
|
notes: form.notes.trim(),
|
|
created_by: currentUser?.id || null,
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
const { data, error } = await supabase
|
|
.from('meeting_notes')
|
|
.insert(payload)
|
|
.select('*')
|
|
.single();
|
|
|
|
setSaving(false);
|
|
if (error) {
|
|
setStatus(`Failed to save note: ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
setNotes(prev => [data, ...prev]);
|
|
setForm(emptyForm());
|
|
setStatus('Meeting note added.');
|
|
};
|
|
|
|
const handleDelete = async (entry) => {
|
|
if (!window.confirm(`Delete meeting note "${entry.title}"?`)) return;
|
|
setDeletingId(entry.id);
|
|
const { error } = await supabase.from('meeting_notes').delete().eq('id', entry.id);
|
|
setDeletingId('');
|
|
if (error) {
|
|
setStatus(`Failed to delete note: ${error.message}`);
|
|
return;
|
|
}
|
|
setNotes(prev => prev.filter(note => note.id !== entry.id));
|
|
setStatus('Meeting note deleted.');
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="page-header">
|
|
<div>
|
|
<div className="page-title">Meeting Notes</div>
|
|
<div className="page-subtitle">Internal team timeline for meeting recaps, decisions, and follow-ups.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gap: 18 }}>
|
|
<section className="card">
|
|
<div className="card-title">Add Note</div>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="grid-2">
|
|
<div className="form-group">
|
|
<label>Meeting Title</label>
|
|
<input type="text" value={form.title} onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))} placeholder="Weekly team sync" />
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Meeting Date</label>
|
|
<input type="datetime-local" value={form.meeting_at} onChange={(e) => setForm(prev => ({ ...prev, meeting_at: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Attendees</label>
|
|
<input type="text" value={form.attendees} onChange={(e) => setForm(prev => ({ ...prev, attendees: e.target.value }))} placeholder="Team, client, subcontractor" />
|
|
</div>
|
|
<div className="form-group" style={{ marginBottom: 12 }}>
|
|
<label>Notes</label>
|
|
<textarea value={form.notes} onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))} placeholder="Key decisions, next steps, blockers, and follow-up items..." style={{ minHeight: 180 }} />
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{status || 'Newest notes appear first in the timeline.'}</div>
|
|
<LoadingButton className="btn btn-primary" loading={saving} loadingText="Saving...">Save Note</LoadingButton>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="card">
|
|
<div className="card-title">Timeline</div>
|
|
{loading ? (
|
|
<div style={{ color: 'var(--text-muted)' }}>Loading meeting notes...</div>
|
|
) : notes.length === 0 ? (
|
|
<div className="empty-state" style={{ padding: '36px 12px' }}>
|
|
<h3>No meeting notes yet</h3>
|
|
<p>Add the first entry to start the internal timeline.</p>
|
|
</div>
|
|
) : (
|
|
<div className="meeting-timeline">
|
|
{notes.map((entry) => (
|
|
<article key={entry.id} className="meeting-note-card">
|
|
<div className="meeting-note-marker" aria-hidden="true" />
|
|
<div className="meeting-note-content">
|
|
<div className="meeting-note-header">
|
|
<div>
|
|
<div className="meeting-note-title">{entry.title}</div>
|
|
<div className="meeting-note-meta">
|
|
<span>{formatMeetingDate(entry.meeting_at)}</span>
|
|
{entry.attendees ? <span>Attendees: {entry.attendees}</span> : null}
|
|
</div>
|
|
</div>
|
|
<LoadingButton
|
|
className="btn-icon btn-icon-danger"
|
|
loading={deletingId === entry.id}
|
|
disabled={Boolean(deletingId)}
|
|
loadingText="..."
|
|
title="Delete"
|
|
onClick={() => handleDelete(entry)}
|
|
>
|
|
✕
|
|
</LoadingButton>
|
|
</div>
|
|
<div className="meeting-note-body">{entry.notes}</div>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|