/* ===================================================================
KabelFlux v5 — Review tab panel (views-review.jsx)
Exposes window.ReviewTab — rendered inside LayoutView's right panel.
=================================================================== */
const StatusBadge = ({ status }) => {
const map = {
draft: { label:'Draft', color:'var(--info)', bg:'rgba(74,158,255,.12)' },
in_review: { label:'In Review', color:'var(--warning)', bg:'rgba(245,166,35,.12)' },
approved: { label:'Approved', color:'var(--success)', bg:'rgba(76,175,80,.12)' },
};
const s = map[status] || map.draft;
return (
{s.label}
);
};
const CommentCard = ({ cmt, idx, canResolve, onResolve, onDelete, replyTo, replyText, setReplyTo, setReplyText, onReply, currentUser, isOwner }) => {
const pinColor = cmt.resolved ? 'var(--success)' : ['#ef4e4e','#f5a623','#4a9eff','#9c6cd1','#4caf50'][idx % 5];
return (
{cmt.x != null && (
{cmt.resolved ? '✓' : idx + 1}
)}
{cmt.author} · {new Date(cmt.created_at).toLocaleDateString()}
{(isOwner || cmt.user_id === currentUser?.id) && !cmt.resolved && (
✕
)}
{cmt.text}
{/* Replies */}
{(cmt.replies || []).map(r => (
{r.author}: {r.text}
))}
{/* Actions */}
{!cmt.resolved && (
setReplyTo(replyTo === cmt.id ? null : cmt.id)} style={{ fontSize:9, color:'var(--t-3)', background:'none', border:'none', cursor:'pointer', padding:'1px 4px' }}>Reply
{canResolve && (
Resolve
)}
)}
{/* Reply input */}
{replyTo === cmt.id && (
setReplyText(e.target.value)}
placeholder="Reply…" style={{ flex:1, fontSize:11 }}
onKeyDown={e => e.key === 'Enter' && onReply(cmt.id)}/>
onReply(cmt.id)}>Post
)}
);
};
const ReviewTab = ({ project, currentUser, reviewData, loadReview }) => {
const toast = useToast();
const [replyTo, setReplyTo] = useState(null);
const [replyText, setReplyText] = useState('');
const [commentText, setCommentText] = useState('');
const [addingComment, setAddingComment] = useState(false);
const myApproval = reviewData?.approvals?.find(a => a.user_id === currentUser?.id);
const isOwner = project?.owner_id === currentUser?.id || project?.ownerId === currentUser?.id;
const canVote = myApproval !== undefined;
const canComment = isOwner || canVote;
const status = reviewData?.status || 'draft';
const handleSubmit = async () => {
try {
await kfxFetch('/projects/' + project.realId + '/review/submit', { method: 'POST', body: {} });
toast.ok('Submitted for review');
await loadReview();
} catch(e) { toast.err('Failed: ' + (e?.message || e)); }
};
const handleClose = async () => {
if (!window.confirm('Close review and revert to Draft?')) return;
try {
await kfxFetch('/projects/' + project.realId + '/review/close', { method: 'POST', body: {} });
toast.warn('Review closed — back to Draft');
await loadReview();
} catch(e) { toast.err('Failed: ' + (e?.message || e)); }
};
const handleVote = async (verdict) => {
try {
await kfxFetch('/projects/' + project.realId + '/review/vote', { method: 'POST', body: { verdict } });
await loadReview();
} catch(e) { toast.err('Failed: ' + (e?.message || e)); }
};
const handleAddComment = async () => {
if (!commentText.trim()) return;
try {
await kfxFetch('/projects/' + project.realId + '/review/comments', { method: 'POST', body: { text: commentText } });
setCommentText(''); setAddingComment(false);
await loadReview();
} catch(e) { toast.err('Failed: ' + (e?.message || e)); }
};
const handleResolve = async (cmt) => {
try {
await kfxFetch('/review/comments/' + cmt.id, { method: 'PUT', body: { resolved: !cmt.resolved } });
await loadReview();
} catch(e) { toast.err('Failed: ' + (e?.message || e)); }
};
const handleDeleteComment = async (cid) => {
if (!window.confirm('Delete this comment?')) return;
try {
await kfxFetch('/review/comments/' + cid, { method: 'DELETE' });
await loadReview();
} catch(e) { toast.err('Failed: ' + (e?.message || e)); }
};
const handleReply = async (parentId) => {
if (!replyText.trim()) return;
try {
await kfxFetch('/review/comments/' + parentId + '/replies', { method: 'POST', body: { text: replyText } });
setReplyText(''); setReplyTo(null);
await loadReview();
} catch(e) { toast.err('Failed: ' + (e?.message || e)); }
};
return (
{/* Status banner */}
Status
{/* spacer */}
{/* Owner: submit or close */}
{isOwner && status === 'draft' && (
Submit for review
)}
{isOwner && status === 'in_review' && (
Close review
)}
{/* Scrollable body */}
{/* Draft state for non-owners */}
{status === 'draft' && !isOwner && (
Waiting for the project owner to submit for review.
)}
{/* Sign-off section (shown when in_review or approved) */}
{(status === 'in_review' || status === 'approved') && reviewData?.approvals?.length > 0 && (
Sign-off
{reviewData.approvals.map(a => (
{a.name}
{a.verdict === 'approved' ? 'Approved' : a.verdict === 'changes_requested' ? 'Changes requested' : 'Pending'}
))}
{/* My vote buttons */}
{canVote && status === 'in_review' && (
handleVote('approved')} style={{ flex:1 }}>✓ Approve
handleVote('changes_requested')} style={{ flex:1 }}>✗ Request changes
)}
)}
{/* Approved banner */}
{status === 'approved' && (
✓ All reviewers approved
)}
{/* Comment thread */}
{(status === 'in_review' || status === 'approved') && (
Comments · {(reviewData?.comments || []).filter(c => !c.resolved).length} open
{canComment && status === 'in_review' && (
setAddingComment(true)}>+ General
)}
{/* Add general comment form */}
{addingComment && (
)}
{/* Comment list */}
{(reviewData?.comments || []).length === 0 && (
No comments yet
)}
{(reviewData?.comments || []).map((cmt, idx) => (
handleResolve(cmt)}
onDelete={() => handleDeleteComment(cmt.id)}
replyTo={replyTo} replyText={replyText}
setReplyTo={setReplyTo} setReplyText={setReplyText}
onReply={handleReply}
currentUser={currentUser}
isOwner={isOwner}/>
))}
)}
);
};
window.ReviewTab = ReviewTab;