194 خطوط
13 KiB
JavaScript
194 خطوط
13 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { Link, useParams } from 'react-router-dom';
|
||
import { ArrowRight, Ban, CheckCircle2, ClipboardList, FileText, Play, Plus, Save, ShieldAlert, Users } from 'lucide-react';
|
||
import toast from 'react-hot-toast';
|
||
import api from '../services/api';
|
||
import Modal from '../components/Modal';
|
||
import { formatJalaliDate } from '../utils/date';
|
||
|
||
const stages = [['before', 'قبل از جلسه'], ['during', 'حین جلسه'], ['after', 'بعد از جلسه']];
|
||
|
||
export default function MeetingWorkspace() {
|
||
const { id } = useParams();
|
||
const [meeting, setMeeting] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [stage, setStage] = useState('before');
|
||
const [saving, setSaving] = useState(false);
|
||
const [form, setForm] = useState({ objective: '', agenda: '', notes: '', summary: '' });
|
||
const [quickForm, setQuickForm] = useState(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await api.get(`/meetings/${id}/workspace`);
|
||
setMeeting(data.data);
|
||
setForm({ objective: data.data.objective || '', agenda: data.data.agenda || '', notes: data.data.notes || '', summary: data.data.summary || '' });
|
||
setStage(data.data.status === 'completed' ? 'after' : data.data.status === 'in_progress' ? 'during' : 'before');
|
||
setError('');
|
||
} catch {
|
||
setError('دریافت فضای کاری جلسه ناموفق بود.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [id]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const saveNotes = async () => {
|
||
setSaving(true);
|
||
try {
|
||
await api.patch(`/meetings/${id}`, form);
|
||
toast.success('اطلاعات جلسه ذخیره شد');
|
||
await load();
|
||
} catch {
|
||
toast.error('ذخیره جلسه ناموفق بود');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const startMeeting = async () => {
|
||
try {
|
||
await api.post(`/meetings/${id}/start`);
|
||
toast.success('جلسه شروع شد');
|
||
await load();
|
||
} catch (err) {
|
||
toast.error(err.response?.data?.message || 'شروع جلسه ناموفق بود');
|
||
}
|
||
};
|
||
|
||
const completeMeeting = async () => {
|
||
try {
|
||
await api.post(`/meetings/${id}/complete`, { summary: form.summary });
|
||
toast.success('جلسه تکمیل شد');
|
||
await load();
|
||
} catch (err) {
|
||
toast.error(err.response?.data?.message || 'تکمیل جلسه ناموفق بود');
|
||
}
|
||
};
|
||
|
||
if (loading) return <div className="page-container workspace-page"><div className="workspace-loading">{Array.from({ length: 7 }).map((_, index) => <div className="skeleton" key={index} />)}</div></div>;
|
||
if (error || !meeting) return <div className="page-container workspace-page"><div className="monitor-error"><ShieldAlert size={34} /><h2>{error}</h2><button className="btn btn-primary" onClick={load}>تلاش دوباره</button></div></div>;
|
||
|
||
return (
|
||
<div className="page-container workspace-page meeting-workspace">
|
||
<header className="workspace-hero">
|
||
<div className="workspace-breadcrumb"><Link to="/meetings"><ArrowRight size={16} /> جلسات</Link><span>/</span><span>{meeting.title}</span></div>
|
||
<div className="workspace-title-row"><h1>{meeting.title}</h1><span className={`meeting-status status-${meeting.status}`}>{meeting.status}</span></div>
|
||
<p>{meeting.objective || 'هدف جلسه هنوز ثبت نشده است.'}</p>
|
||
<div className="workspace-context"><span>{formatJalaliDate(meeting.date)}</span><span>{meeting.start_time?.slice(0, 5) || 'بدون ساعت'} تا {meeting.end_time?.slice(0, 5) || 'نامشخص'}</span><span>{meeting.project?.title || 'جلسه عمومی'}</span>{meeting.sprint?.title && <Link to={`/sprints/${meeting.sprint_id}/workspace`}>{meeting.sprint.title}</Link>}</div>
|
||
</header>
|
||
|
||
<nav className="meeting-stage-tabs">
|
||
{stages.map(([key, label]) => <button type="button" key={key} className={stage === key ? 'active' : ''} onClick={() => setStage(key)}>{label}</button>)}
|
||
</nav>
|
||
|
||
<div className="meeting-workspace-layout">
|
||
<main>
|
||
{stage === 'before' && (
|
||
<div className="meeting-stage-content">
|
||
<section className="workspace-card">
|
||
<header><div><ClipboardList size={18} /><h2>آمادهسازی جلسه</h2></div></header>
|
||
<label className="form-group"><span className="form-label">هدف جلسه</span><textarea className="form-textarea" value={form.objective} onChange={(event) => setForm({ ...form, objective: event.target.value })} /></label>
|
||
<label className="form-group"><span className="form-label">دستور جلسه</span><textarea className="form-textarea meeting-agenda-input" value={form.agenda} onChange={(event) => setForm({ ...form, agenda: event.target.value })} /></label>
|
||
</section>
|
||
<section className="workspace-card"><header><div><Users size={18} /><h2>شرکتکنندگان</h2></div><span>{meeting.participants?.length || 0} نفر</span></header>
|
||
<div className="participant-grid">{(meeting.participants || []).map((person) => <div key={person.id}><span className="avatar avatar-sm">{person.name?.[0]}</span><strong>{person.name}</strong></div>)}</div>
|
||
{(meeting.participants || []).length === 0 && <div className="workspace-empty">شرکتکنندهای اضافه نشده است.</div>}
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{stage === 'during' && (
|
||
<div className="meeting-stage-content">
|
||
<section className="workspace-card live-notes-card">
|
||
<header><div><FileText size={18} /><h2>یادداشت زنده</h2></div><span className="live-indicator"><i /> جلسه در جریان</span></header>
|
||
<textarea className="form-textarea" value={form.notes} onChange={(event) => setForm({ ...form, notes: event.target.value })} placeholder="نکات، گفتگوها و جمعبندیهای مهم را ثبت کنید..." />
|
||
</section>
|
||
<div className="meeting-capture-grid">
|
||
<button type="button" onClick={() => setQuickForm({ type: 'decision', title: '', description: '' })}><CheckCircle2 size={22} /><strong>ثبت تصمیم</strong><span>نتیجه تصمیمگیری را ثبت کنید</span></button>
|
||
<button type="button" onClick={() => setQuickForm({ type: 'action', title: '', owner_id: '', due_date: '', priority: 'medium' })}><Plus size={22} /><strong>ثبت اقدام</strong><span>Owner و سررسید مشخص کنید</span></button>
|
||
<button type="button" onClick={() => setQuickForm({ type: 'blocker', title: '', description: '', severity: 'medium' })}><Ban size={22} /><strong>ثبت مانع</strong><span>مانع را به Sprint بازگردانید</span></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{stage === 'after' && (
|
||
<div className="meeting-stage-content">
|
||
<section className="workspace-card">
|
||
<header><div><FileText size={18} /><h2>خلاصه و صورتجلسه</h2></div></header>
|
||
<textarea className="form-textarea meeting-summary-input" value={form.summary} onChange={(event) => setForm({ ...form, summary: event.target.value })} placeholder="خلاصه ساختاریافته جلسه..." />
|
||
</section>
|
||
<ResultSection title="تصمیمها" items={meeting.structured_decisions} />
|
||
<ResultSection title="اقدامها" items={meeting.structured_action_items} />
|
||
<ResultSection title="موانع" items={meeting.blockers} />
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
<aside className="meeting-context-panel">
|
||
<section className="workspace-card"><h3>اطلاعات جلسه</h3>
|
||
<dl><div><dt>نوع</dt><dd>{meeting.type?.name || meeting.meeting_type}</dd></div><div><dt>Owner</dt><dd>{meeting.owner?.name || meeting.creator?.name || '—'}</dd></div><div><dt>Facilitator</dt><dd>{meeting.facilitator?.name || '—'}</dd></div><div><dt>مکان</dt><dd>{meeting.location || '—'}</dd></div></dl>
|
||
</section>
|
||
<section className="workspace-card"><h3>خروجی فعلی</h3><dl><div><dt>تصمیم</dt><dd>{meeting.structured_decisions?.length || 0}</dd></div><div><dt>اقدام</dt><dd>{meeting.structured_action_items?.length || meeting.action_items?.length || 0}</dd></div><div><dt>مانع</dt><dd>{meeting.blockers?.length || 0}</dd></div><div><dt>پیوست</dt><dd>{meeting.files?.length || 0}</dd></div></dl></section>
|
||
</aside>
|
||
</div>
|
||
|
||
<div className="meeting-sticky-controls">
|
||
<button type="button" className="btn btn-outline" onClick={saveNotes} disabled={saving}><Save size={16} /> {saving ? 'در حال ذخیره...' : 'ذخیره'}</button>
|
||
{meeting.status !== 'in_progress' && meeting.status !== 'completed' && <button type="button" className="btn btn-primary" onClick={startMeeting}><Play size={16} /> شروع جلسه</button>}
|
||
{meeting.status === 'in_progress' && <button type="button" className="btn btn-success" onClick={completeMeeting}><CheckCircle2 size={16} /> پایان جلسه</button>}
|
||
</div>
|
||
|
||
{quickForm && <QuickCapture meetingId={meeting.id} form={quickForm} setForm={setQuickForm} onClose={() => setQuickForm(null)} onSaved={load} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ResultSection({ title, items = [] }) {
|
||
return <section className="workspace-card"><header><div><CheckCircle2 size={18} /><h2>{title}</h2></div><span>{items?.length || 0}</span></header>{!items?.length ? <div className="workspace-empty">موردی ثبت نشده است.</div> : items.map((item) => <div className="workspace-data-row" key={item.id}><div><strong>{item.title}</strong><small>{item.description || item.status}</small></div><span className="badge badge-primary">{item.status || item.severity}</span></div>)}</section>;
|
||
}
|
||
|
||
function QuickCapture({ meetingId, form, setForm, onClose, onSaved }) {
|
||
const [users, setUsers] = useState([]);
|
||
const [saving, setSaving] = useState(false);
|
||
useEffect(() => {
|
||
if (form.type === 'action') api.get('/users', { params: { per_page: 100 } }).then(({ data }) => setUsers(data.data || [])).catch(() => {});
|
||
}, [form.type]);
|
||
|
||
const config = useMemo(() => ({
|
||
decision: { title: 'ثبت تصمیم', endpoint: 'decisions' },
|
||
action: { title: 'ثبت اقدام', endpoint: 'structured-action-items' },
|
||
blocker: { title: 'ثبت مانع', endpoint: 'blockers' },
|
||
})[form.type], [form.type]);
|
||
|
||
const save = async () => {
|
||
if (!form.title?.trim()) { toast.error('عنوان الزامی است'); return; }
|
||
if (form.type === 'action' && (!form.owner_id || !form.due_date)) { toast.error('Owner و سررسید الزامی است'); return; }
|
||
setSaving(true);
|
||
try {
|
||
const payload = { ...form }; delete payload.type;
|
||
await api.post(`/meetings/${meetingId}/${config.endpoint}`, payload);
|
||
toast.success('ثبت شد');
|
||
onClose();
|
||
await onSaved();
|
||
} catch (err) {
|
||
toast.error(err.response?.data?.message || 'ثبت مورد ناموفق بود');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal title={config.title} onClose={onClose} footer={<><button className="btn btn-secondary" onClick={onClose}>انصراف</button><button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'در حال ثبت...' : 'ثبت'}</button></>}>
|
||
<div className="compact-modal-form">
|
||
<label className="form-group"><span className="form-label">عنوان</span><input className="form-input" value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></label>
|
||
{form.type !== 'action' && <label className="form-group"><span className="form-label">توضیحات</span><textarea className="form-textarea" value={form.description || ''} onChange={(event) => setForm({ ...form, description: event.target.value })} /></label>}
|
||
{form.type === 'action' && <><label className="form-group"><span className="form-label">Owner</span><select className="form-select" value={form.owner_id} onChange={(event) => setForm({ ...form, owner_id: event.target.value })}><option value="">انتخاب</option>{users.map((user) => <option key={user.id} value={user.id}>{user.name}</option>)}</select></label><label className="form-group"><span className="form-label">سررسید</span><input type="date" className="form-input" value={form.due_date} onChange={(event) => setForm({ ...form, due_date: event.target.value })} /></label></>}
|
||
{form.type === 'blocker' && <label className="form-group"><span className="form-label">شدت</span><select className="form-select" value={form.severity} onChange={(event) => setForm({ ...form, severity: event.target.value })}><option value="low">کم</option><option value="medium">متوسط</option><option value="high">زیاد</option><option value="critical">بحرانی</option></select></label>}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|