From e9da8d7d4aa6338513023f84c34c159780931cf8 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 25 Dec 2025 19:03:47 -0600 Subject: [PATCH 1/5] improved reconcile tab --- .../reconcile-tab/ReconcileStudyCard.jsx | 106 ----------- .../reconcile-tab/ReconcileStudyRow.jsx | 174 ++++++++++++++++++ .../project-ui/reconcile-tab/ReconcileTab.jsx | 13 +- 3 files changed, 183 insertions(+), 110 deletions(-) delete mode 100644 packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyCard.jsx create mode 100644 packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyRow.jsx diff --git a/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyCard.jsx b/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyCard.jsx deleted file mode 100644 index c5c90f4ec..000000000 --- a/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyCard.jsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * ReconcileStudyCard - Displays a study card specifically for the Ready to Reconcile tab - * Shows status tag for waiting/ready state and enables reconciliation when both reviewers complete - */ - -import { Show } from 'solid-js'; -import { CgFileDocument } from 'solid-icons/cg'; -import { BsFileDiff } from 'solid-icons/bs'; -import { CHECKLIST_STATUS } from '@/constants/checklist-status.js'; -import { isReconciledChecklist } from '@/lib/checklist-domain.js'; -import ReconcileStatusTag from './ReconcileStatusTag.jsx'; - -export default function ReconcileStudyCard(props) { - // Check if study has PDFs - const hasPdfs = () => props.study.pdfs && props.study.pdfs.length > 0; - const firstPdf = () => (hasPdfs() ? props.study.pdfs[0] : null); - - // Get the individual reviewer checklists awaiting reconciliation - const awaitingReconcileChecklists = () => { - return (props.study.checklists || []).filter( - c => !isReconciledChecklist(c) && c.status === CHECKLIST_STATUS.AWAITING_RECONCILE, - ); - }; - - // Check if ready for reconciliation (both reviewers have completed their checklists) - const isReady = () => awaitingReconcileChecklists().length === 2; - - // Start reconciliation - directly compare the two checklists awaiting reconciliation - const startReconciliation = () => { - const [checklist1, checklist2] = awaitingReconcileChecklists(); - if (checklist1 && checklist2) { - props.onReconcile?.(checklist1.id, checklist2.id); - } - }; - - // Get reviewer name for a checklist - const getReviewerName = checklist => { - if (!checklist.assignedTo) return 'Unknown'; - return props.getAssigneeName?.(checklist.assignedTo) || 'Unknown'; - }; - - return ( -
- {/* Study Header */} -
-
-
-

{props.study.name}

- -

- {props.study.firstAuthor || 'Unknown'} - {props.study.publicationYear && ` (${props.study.publicationYear})`} - - - - {props.study.journal} - -

-
-
-
- - - -
-
-
- - {/* Reviewers and Action */} -
-
- - -
- - {checklist => {getReviewerName(checklist())}} - - vs - - {checklist => {getReviewerName(checklist())}} - -
-
-
- -
-
- ); -} diff --git a/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyRow.jsx b/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyRow.jsx new file mode 100644 index 000000000..5cb9b621b --- /dev/null +++ b/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyRow.jsx @@ -0,0 +1,174 @@ +/** + * ReconcileStudyRow - Compact study row for the reconcile tab + * + * Displays study info, reconciliation status, and collapsible PDF section. + * Clicking whitespace on the header row toggles the PDF section. + */ + +import { For, Show, createMemo, createSignal } from 'solid-js'; +import { BiRegularChevronRight } from 'solid-icons/bi'; +import { BsFileDiff } from 'solid-icons/bs'; +import { Collapsible } from '@corates/ui'; +import { CHECKLIST_STATUS } from '@/constants/checklist-status.js'; +import { isReconciledChecklist } from '@/lib/checklist-domain.js'; +import PdfListItem from '@/components/checklist-ui/pdf/PdfListItem.jsx'; +import ReconcileStatusTag from './ReconcileStatusTag.jsx'; + +export default function ReconcileStudyRow(props) { + // props.study: Study object with pdfs and checklists arrays + // props.onReconcile: (checklist1Id, checklist2Id) => void + // props.onViewPdf: (pdf) => void + // props.onDownloadPdf: (pdf) => void + // props.getAssigneeName: (userId) => string + + const [expanded, setExpanded] = createSignal(false); + + const study = () => props.study; + + // Get PDFs sorted: primary first, then protocol, then secondary + const sortedPdfs = createMemo(() => { + const pdfs = study().pdfs || []; + return [...pdfs].sort((a, b) => { + const tagOrder = { primary: 0, protocol: 1, secondary: 2 }; + const tagA = tagOrder[a.tag] ?? 2; + const tagB = tagOrder[b.tag] ?? 2; + if (tagA !== tagB) return tagA - tagB; + return (b.uploadedAt || 0) - (a.uploadedAt || 0); + }); + }); + + const hasPdfs = () => sortedPdfs().length > 0; + const pdfCount = () => sortedPdfs().length; + + // Citation line from study or primary PDF + const citationLine = () => { + const primaryPdf = sortedPdfs().find(p => p.tag === 'primary') || sortedPdfs()[0]; + const author = primaryPdf?.firstAuthor || study().firstAuthor; + const year = primaryPdf?.publicationYear || study().publicationYear; + if (!author && !year) return null; + return `${author || 'Unknown'}${year ? ` (${year})` : ''}`; + }; + + // Get the individual reviewer checklists awaiting reconciliation + const awaitingReconcileChecklists = () => { + return (study().checklists || []).filter( + c => !isReconciledChecklist(c) && c.status === CHECKLIST_STATUS.AWAITING_RECONCILE, + ); + }; + + // Check if ready for reconciliation (both reviewers have completed their checklists) + const isReady = () => awaitingReconcileChecklists().length === 2; + + // Start reconciliation - directly compare the two checklists awaiting reconciliation + const startReconciliation = () => { + const [checklist1, checklist2] = awaitingReconcileChecklists(); + if (checklist1 && checklist2) { + props.onReconcile?.(checklist1.id, checklist2.id); + } + }; + + // Get reviewer name for a checklist + const getReviewerName = checklist => { + if (!checklist.assignedTo) return 'Unknown'; + return props.getAssigneeName?.(checklist.assignedTo) || 'Unknown'; + }; + + return ( +
+ { + // Only toggle if there are PDFs to show + if (hasPdfs()) { + setExpanded(open); + } + }} + trigger={ +
+ {/* Chevron indicator (only if has PDFs) */} + +
+ +
+
+ + {/* Study info */} +
+
+ {study().name} +
+ {/* Citation line - selectable */} + +

+ {citationLine()} + + · {pdfCount()} PDFs + +

+
+ +

{pdfCount()} PDFs

+
+
+ + {/* Reconciliation status tag */} + + + {/* Reviewer info when ready */} + +
+ + {checklist => {getReviewerName(checklist())}} + + vs + + {checklist => {getReviewerName(checklist())}} + +
+
+ + {/* Reconcile button */} + +
+ } + > + {/* Expanded PDF Section */} + +
+ + {pdf => ( + props.onViewPdf?.(pdf)} + onDownload={() => props.onDownloadPdf?.(pdf)} + readOnly={true} + /> + )} + +
+
+
+
+ ); +} diff --git a/packages/web/src/components/project-ui/reconcile-tab/ReconcileTab.jsx b/packages/web/src/components/project-ui/reconcile-tab/ReconcileTab.jsx index ca122360a..3128c939c 100644 --- a/packages/web/src/components/project-ui/reconcile-tab/ReconcileTab.jsx +++ b/packages/web/src/components/project-ui/reconcile-tab/ReconcileTab.jsx @@ -1,7 +1,7 @@ import { For, Show, createMemo } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { CgArrowsExchange } from 'solid-icons/cg'; -import ReconcileStudyCard from './ReconcileStudyCard.jsx'; +import ReconcileStudyRow from './ReconcileStudyRow.jsx'; import projectStore from '@/stores/projectStore.js'; import projectActionsStore from '@/stores/projectActionsStore'; import { useProjectContext } from '../ProjectContext.jsx'; @@ -33,8 +33,12 @@ export default function ReconcileTab() { projectActionsStore.pdf.view(studyId, pdf); }; + const handleDownloadPdf = (studyId, pdf) => { + projectActionsStore.pdf.download(studyId, pdf); + }; + return ( -
+
0} fallback={ @@ -48,15 +52,16 @@ export default function ReconcileTab() {
} > -
+
{study => ( - openReconciliation(study.id, checklist1Id, checklist2Id) } onViewPdf={pdf => handleViewPdf(study.id, pdf)} + onDownloadPdf={pdf => handleDownloadPdf(study.id, pdf)} getAssigneeName={getAssigneeName} /> )} From d276349b01e17e4ee4ee4cf0be36e531129d86a6 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 25 Dec 2025 19:13:13 -0600 Subject: [PATCH 2/5] improved completed tab --- packages/ui/src/components/Dialog.tsx | 4 +- .../completed-tab/CompletedStudyRow.jsx | 187 ++++++++++++++++ .../project-ui/completed-tab/CompletedTab.jsx | 44 ++-- .../completed-tab/PreviousReviewersView.jsx | 200 ++++++++++++++++++ .../project-ui/completed-tab/index.js | 2 + packages/web/src/lib/checklist-domain.js | 33 +++ 6 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx create mode 100644 packages/web/src/components/project-ui/completed-tab/PreviousReviewersView.jsx diff --git a/packages/ui/src/components/Dialog.tsx b/packages/ui/src/components/Dialog.tsx index 53a8eaab2..ac8942736 100644 --- a/packages/ui/src/components/Dialog.tsx +++ b/packages/ui/src/components/Dialog.tsx @@ -20,7 +20,7 @@ export interface DialogProps { /** Dialog content */ children?: JSX.Element; /** Dialog size */ - size?: 'sm' | 'md' | 'lg' | 'xl'; + size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; } /** @@ -47,6 +47,8 @@ const DialogComponent: Component = props => { return 'max-w-lg'; case 'xl': return 'max-w-xl'; + case '2xl': + return 'max-w-6xl'; default: return 'max-w-md'; } diff --git a/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx b/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx new file mode 100644 index 000000000..7c35dbcde --- /dev/null +++ b/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx @@ -0,0 +1,187 @@ +/** + * CompletedStudyRow - Compact study row for the completed tab + * + * Displays study info, completed checklist(s), collapsible PDF section, and + * button to view previous reviewer checklists (for dual-reviewer studies). + */ + +import { For, Show, createMemo, createSignal } from 'solid-js'; +import { BiRegularChevronRight } from 'solid-icons/bi'; +import { Collapsible } from '@corates/ui'; +import { getChecklistMetadata } from '@/checklist-registry'; +import PdfListItem from '@/components/checklist-ui/pdf/PdfListItem.jsx'; +import { getCompletedChecklists } from '@/lib/checklist-domain.js'; +import { getStatusLabel, getStatusStyle } from '@/constants/checklist-status.js'; +import PreviousReviewersView from './PreviousReviewersView.jsx'; + +export default function CompletedStudyRow(props) { + // props.study: Study object with pdfs and checklists arrays + // props.onOpenChecklist: (checklistId) => void + // props.onViewPdf: (pdf) => void + // props.onDownloadPdf: (pdf) => void + // props.reconciliationProgress: Object with checklist1Id and checklist2Id (optional) + // props.getAssigneeName: (userId) => string + + const [expanded, setExpanded] = createSignal(false); + const [showPreviousReviewers, setShowPreviousReviewers] = createSignal(false); + + const study = () => props.study; + + // Get PDFs sorted: primary first, then protocol, then secondary + const sortedPdfs = createMemo(() => { + const pdfs = study().pdfs || []; + return [...pdfs].sort((a, b) => { + const tagOrder = { primary: 0, protocol: 1, secondary: 2 }; + const tagA = tagOrder[a.tag] ?? 2; + const tagB = tagOrder[b.tag] ?? 2; + if (tagA !== tagB) return tagA - tagB; + return (b.uploadedAt || 0) - (a.uploadedAt || 0); + }); + }); + + const hasPdfs = () => sortedPdfs().length > 0; + const pdfCount = () => sortedPdfs().length; + + // Citation line from study or primary PDF + const citationLine = () => { + const primaryPdf = sortedPdfs().find(p => p.tag === 'primary') || sortedPdfs()[0]; + const author = primaryPdf?.firstAuthor || study().firstAuthor; + const year = primaryPdf?.publicationYear || study().publicationYear; + if (!author && !year) return null; + return `${author || 'Unknown'}${year ? ` (${year})` : ''}`; + }; + + // Get completed checklists + const completedChecklists = createMemo(() => { + return getCompletedChecklists(study()); + }); + + // Check if we have previous reviewers to show + const hasPreviousReviewers = () => { + return !!props.reconciliationProgress?.checklist1Id && !!props.reconciliationProgress?.checklist2Id; + }; + + return ( + <> +
+ { + // Only toggle if there are PDFs to show + if (hasPdfs()) { + setExpanded(open); + } + }} + trigger={ +
+ {/* Chevron indicator (only if has PDFs) */} + +
+ +
+
+ + {/* Study info */} +
+
+ {study().name} +
+ {/* Citation line - selectable */} + +

+ {citationLine()} + + · {pdfCount()} PDFs + +

+
+ +

{pdfCount()} PDFs

+
+
+ + {/* Checklist type badge - selectable */} + 0}> + + {getChecklistMetadata(completedChecklists()[0]?.type)?.name || 'Checklist'} + + + + {/* Checklist status badge */} + 0}> + + {getStatusLabel(completedChecklists()[0]?.status)} + + + + {/* View Previous Reviewers button (only for dual-reviewer studies) */} + + + + + {/* Open checklist button */} + 0}> + + +
+ } + > + {/* Expanded PDF Section */} + +
+ + {pdf => ( + props.onViewPdf?.(pdf)} + onDownload={() => props.onDownloadPdf?.(pdf)} + readOnly={true} + /> + )} + +
+
+
+
+ + {/* Previous Reviewers View Dialog */} + + setShowPreviousReviewers(false)} + /> + + + ); +} diff --git a/packages/web/src/components/project-ui/completed-tab/CompletedTab.jsx b/packages/web/src/components/project-ui/completed-tab/CompletedTab.jsx index 71db14410..033b96f2e 100644 --- a/packages/web/src/components/project-ui/completed-tab/CompletedTab.jsx +++ b/packages/web/src/components/project-ui/completed-tab/CompletedTab.jsx @@ -4,16 +4,18 @@ import { AiFillCheckCircle } from 'solid-icons/ai'; import projectStore from '@/stores/projectStore.js'; import projectActionsStore from '@/stores/projectActionsStore'; import { useProjectContext } from '@project-ui/ProjectContext.jsx'; -import { getStudiesForTab } from '@/lib/checklist-domain.js'; -import CompletedStudyCard from './CompletedStudyCard.jsx'; +import { getStudiesForTab, isDualReviewerStudy } from '@/lib/checklist-domain.js'; +import useProject from '@/primitives/useProject/index.js'; +import CompletedStudyRow from './CompletedStudyRow.jsx'; /** * CompletedTab - Shows studies that have completed review * Uses projectActionsStore directly for mutations. */ export default function CompletedTab() { - const { projectId } = useProjectContext(); + const { projectId, getAssigneeName } = useProjectContext(); const navigate = useNavigate(); + const { getReconciliationProgress } = useProject(projectId); const studies = () => projectStore.getStudies(projectId); @@ -30,8 +32,19 @@ export default function CompletedTab() { projectActionsStore.pdf.view(studyId, pdf); }; + const handleDownloadPdf = (studyId, pdf) => { + projectActionsStore.pdf.download(studyId, pdf); + }; + + // Get reconciliation progress for a study + const getReconciliationProgressForStudy = study => { + // Only get reconciliation progress for dual-reviewer studies + if (!isDualReviewerStudy(study)) return null; + return getReconciliationProgress(study.id); + }; + return ( -
+
0} fallback={ @@ -44,17 +57,18 @@ export default function CompletedTab() {
} > -
- - {study => ( - openChecklist(study.id, checklistId)} - onViewPdf={pdf => handleViewPdf(study.id, pdf)} - /> - )} - -
+ + {study => ( + openChecklist(study.id, checklistId)} + onViewPdf={pdf => handleViewPdf(study.id, pdf)} + onDownloadPdf={pdf => handleDownloadPdf(study.id, pdf)} + reconciliationProgress={getReconciliationProgressForStudy(study)} + getAssigneeName={getAssigneeName} + /> + )} +
); diff --git a/packages/web/src/components/project-ui/completed-tab/PreviousReviewersView.jsx b/packages/web/src/components/project-ui/completed-tab/PreviousReviewersView.jsx new file mode 100644 index 000000000..9de2d9279 --- /dev/null +++ b/packages/web/src/components/project-ui/completed-tab/PreviousReviewersView.jsx @@ -0,0 +1,200 @@ +/** + * PreviousReviewersView - Dialog to display original reviewer checklists + * + * Shows the checklists from each reviewer that were reconciled to create the final version. + * Displays checklists in readonly mode with tabs to switch between reviewers. + */ + +import { Show, createMemo, createSignal, createEffect } from 'solid-js'; +import { Dialog, Tabs } from '@corates/ui'; +import { useProjectContext } from '@project-ui/ProjectContext.jsx'; +import useProject from '@/primitives/useProject/index.js'; +import { getOriginalReviewerChecklists } from '@/lib/checklist-domain.js'; +import { getChecklistMetadata } from '@/checklist-registry'; +import GenericChecklist from '@/components/checklist-ui/GenericChecklist.jsx'; + +export default function PreviousReviewersView(props) { + // props.study: Study object + // props.reconciliationProgress: Object with checklist1Id and checklist2Id + // props.getAssigneeName: (userId) => string + // props.onClose: () => void + + const { projectId } = useProjectContext(); + const { getChecklistData, getQuestionNote } = useProject(projectId); + + const [checklist1Data, setChecklist1Data] = createSignal(null); + const [checklist2Data, setChecklist2Data] = createSignal(null); + const [loading, setLoading] = createSignal(true); + const [activeTab, setActiveTab] = createSignal('reviewer1'); + + // Get original reviewer checklists + const originalChecklists = createMemo(() => { + if (!props.study || !props.reconciliationProgress) return []; + return getOriginalReviewerChecklists(props.study, props.reconciliationProgress); + }); + + // Load checklist data + createEffect(() => { + const checklists = originalChecklists(); + if (checklists.length === 0) { + setLoading(false); + setChecklist1Data(null); + setChecklist2Data(null); + return; + } + + setLoading(true); + + try { + // Load data for both checklists (synchronous) + const data1 = checklists[0] ? getChecklistData(props.study.id, checklists[0].id) : null; + const data2 = checklists[1] ? getChecklistData(props.study.id, checklists[1].id) : null; + + if (data1) { + setChecklist1Data({ + id: checklists[0].id, + name: props.study?.name || 'Checklist', + reviewerName: props.getAssigneeName(checklists[0].assignedTo), + createdAt: checklists[0].createdAt, + type: checklists[0].type, + ...data1.answers, + }); + } else { + setChecklist1Data(null); + } + + if (data2) { + setChecklist2Data({ + id: checklists[1].id, + name: props.study?.name || 'Checklist', + reviewerName: props.getAssigneeName(checklists[1].assignedTo), + createdAt: checklists[1].createdAt, + type: checklists[1].type, + ...data2.answers, + }); + } else { + setChecklist2Data(null); + } + } catch (err) { + console.error('Failed to load checklist data:', err); + setChecklist1Data(null); + setChecklist2Data(null); + } finally { + setLoading(false); + } + }); + + const checklists = () => originalChecklists(); + const hasData = () => !loading() && (checklist1Data() || checklist2Data()); + + // Build tabs for reviewers + const reviewerTabs = createMemo(() => { + const tabs = []; + const lists = checklists(); + + if (lists[0] && checklist1Data()) { + tabs.push({ + value: 'reviewer1', + label: props.getAssigneeName(lists[0].assignedTo), + }); + } + + if (lists[1] && checklist2Data()) { + tabs.push({ + value: 'reviewer2', + label: props.getAssigneeName(lists[1].assignedTo), + }); + } + + return tabs; + }); + + // Get current checklist data based on active tab + const currentChecklistData = createMemo(() => { + if (activeTab() === 'reviewer1') return checklist1Data(); + if (activeTab() === 'reviewer2') return checklist2Data(); + return null; + }); + + const currentChecklistType = createMemo(() => { + const lists = checklists(); + if (activeTab() === 'reviewer1' && lists[0]) return lists[0].type; + if (activeTab() === 'reviewer2' && lists[1]) return lists[1].type; + return null; + }); + + const currentChecklistId = createMemo(() => { + const lists = checklists(); + if (activeTab() === 'reviewer1' && lists[0]) return lists[0].id; + if (activeTab() === 'reviewer2' && lists[1]) return lists[1].id; + return null; + }); + + // Set default tab when data loads + createEffect(() => { + if (!loading() && reviewerTabs().length > 0 && !currentChecklistData()) { + setActiveTab(reviewerTabs()[0].value); + } + }); + + return ( + { + if (!open) { + props.onClose(); + } + }} + title='Original Reviewer Appraisals' + description='The original appraisals from each reviewer that were reconciled to create the final version.' + size='2xl' + > +
+ +
+ {loading() ? 'Loading appraisals...' : 'No previous reviewer appraisals found.'} +
+
+ } + > + 1}> +
+ +
+
+ +
+ +
+
+

+ {currentChecklistData()?.reviewerName || 'Reviewer'} +

+ + {currentChecklistType() ? + getChecklistMetadata(currentChecklistType())?.name || currentChecklistType() + : ''} + +
+
+ {}} + getQuestionNote={questionKey => { + const checklistId = currentChecklistId(); + if (!checklistId) return null; + return getQuestionNote(props.study.id, checklistId, questionKey); + }} + /> +
+
+ +
+ + ); +} diff --git a/packages/web/src/components/project-ui/completed-tab/index.js b/packages/web/src/components/project-ui/completed-tab/index.js index 5cec5dd70..d0ec6ae62 100644 --- a/packages/web/src/components/project-ui/completed-tab/index.js +++ b/packages/web/src/components/project-ui/completed-tab/index.js @@ -1,3 +1,5 @@ export { default as CompletedStudyCard } from './CompletedStudyCard.jsx'; +export { default as CompletedStudyRow } from './CompletedStudyRow.jsx'; export { default as CompletedChecklistRow } from './CompletedChecklistRow.jsx'; export { default as CompletedTab } from './CompletedTab.jsx'; +export { default as PreviousReviewersView } from './PreviousReviewersView.jsx'; diff --git a/packages/web/src/lib/checklist-domain.js b/packages/web/src/lib/checklist-domain.js index ab7627451..a5709f08a 100644 --- a/packages/web/src/lib/checklist-domain.js +++ b/packages/web/src/lib/checklist-domain.js @@ -202,3 +202,36 @@ export function getInProgressReconciledChecklists(study) { c => isReconciledChecklist(c) && c.status !== CHECKLIST_STATUS.COMPLETED, ); } + +/** + * Determines if a study has dual reviewers + * @param {Object} study - The study object + * @returns {boolean} True if study has both reviewer1 and reviewer2 + */ +export function isDualReviewerStudy(study) { + if (!study) return false; + return !!(study.reviewer1 && study.reviewer2); +} + +/** + * Gets the original reviewer checklists that were reconciled + * @param {Object} study - The study object + * @param {Object} reconciliationProgress - Reconciliation progress data with checklist1Id and checklist2Id + * @returns {Array} Array of original reviewer checklists (metadata only) + */ +export function getOriginalReviewerChecklists(study, reconciliationProgress) { + if (!study || !study.checklists || !reconciliationProgress) return []; + + const { checklist1Id, checklist2Id } = reconciliationProgress; + if (!checklist1Id || !checklist2Id) return []; + + const checklists = study.checklists || []; + const checklist1 = checklists.find(c => c.id === checklist1Id); + const checklist2 = checklists.find(c => c.id === checklist2Id); + + const result = []; + if (checklist1) result.push(checklist1); + if (checklist2) result.push(checklist2); + + return result; +} From ced2f3b4c8f1be7c33ed4c7e2d10604d91af7208 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 26 Dec 2025 01:13:44 +0000 Subject: [PATCH 3/5] Apply Prettier formatting --- .../components/project-ui/completed-tab/CompletedStudyRow.jsx | 4 +++- .../components/project-ui/reconcile-tab/ReconcileStudyRow.jsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx b/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx index 7c35dbcde..e2f70caa6 100644 --- a/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx +++ b/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx @@ -58,7 +58,9 @@ export default function CompletedStudyRow(props) { // Check if we have previous reviewers to show const hasPreviousReviewers = () => { - return !!props.reconciliationProgress?.checklist1Id && !!props.reconciliationProgress?.checklist2Id; + return ( + !!props.reconciliationProgress?.checklist1Id && !!props.reconciliationProgress?.checklist2Id + ); }; return ( diff --git a/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyRow.jsx b/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyRow.jsx index 5cb9b621b..d498ca54c 100644 --- a/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyRow.jsx +++ b/packages/web/src/components/project-ui/reconcile-tab/ReconcileStudyRow.jsx @@ -141,7 +141,7 @@ export default function ReconcileStudyRow(props) { startReconciliation(); }} disabled={!isReady()} - class={`shrink-0 flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ + class={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ isReady() ? 'bg-blue-600 text-white hover:bg-blue-700' : 'cursor-not-allowed bg-gray-200 text-gray-500' From 80c1ac811498c011119909952c873faf82c7e665 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 25 Dec 2025 19:34:45 -0600 Subject: [PATCH 4/5] add ark to vscode mcp --- .vscode/mcp.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 0d42f80ed..61e711fab 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -4,6 +4,10 @@ "type": "stdio", "command": "node", "args": ["${workspaceFolder}/packages/mcp/dist/server.js"] + }, + "ark-ui": { + "command": "npx", + "args": ["-y", "@ark-ui/mcp"] } // "stripe": { // "type": "http", From 61eb37027a2ee3b40db521f7f10ac790b1bcc782 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 25 Dec 2025 19:48:13 -0600 Subject: [PATCH 5/5] fix pdf preview panel --- .../components/project-ui/PdfPreviewPanel.jsx | 20 +-- .../components/project-ui/SlidingPanel.jsx | 141 ++++++++++++++++++ 2 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 packages/web/src/components/project-ui/SlidingPanel.jsx diff --git a/packages/web/src/components/project-ui/PdfPreviewPanel.jsx b/packages/web/src/components/project-ui/PdfPreviewPanel.jsx index 2612f87c2..ccb938948 100644 --- a/packages/web/src/components/project-ui/PdfPreviewPanel.jsx +++ b/packages/web/src/components/project-ui/PdfPreviewPanel.jsx @@ -1,20 +1,18 @@ /** - * PdfPreviewPanel - Slide-in drawer for previewing PDFs + * PdfPreviewPanel - Slide-in panel for previewing PDFs * * Reads from pdfPreviewStore to show/hide and display PDF content. * Used across project views to preview PDFs without leaving context. */ import { Show } from 'solid-js'; -import { Drawer } from '@corates/ui'; +import SlidingPanel from './SlidingPanel.jsx'; import PdfViewer from '@/components/checklist-ui/pdf/PdfViewer.jsx'; import pdfPreviewStore from '@/stores/pdfPreviewStore.js'; export default function PdfPreviewPanel() { - const handleOpenChange = open => { - if (!open) { - pdfPreviewStore.closePreview(); - } + const handleClose = () => { + pdfPreviewStore.closePreview(); }; // Format title with filename @@ -25,13 +23,11 @@ export default function PdfPreviewPanel() { }; return ( -
@@ -70,6 +66,6 @@ export default function PdfPreviewPanel() { />
-
+ ); } diff --git a/packages/web/src/components/project-ui/SlidingPanel.jsx b/packages/web/src/components/project-ui/SlidingPanel.jsx new file mode 100644 index 000000000..f90a5b12b --- /dev/null +++ b/packages/web/src/components/project-ui/SlidingPanel.jsx @@ -0,0 +1,141 @@ +/** + * SlidingPanel - A lightweight, GPU-accelerated sliding panel + * + * Optimized for smooth animations without the overhead of Portal/Dialog. + * Uses CSS transforms and will-change for 60fps animations. + */ + +import { Show, createSignal, createEffect, onCleanup } from 'solid-js'; +import { FiX } from 'solid-icons/fi'; + +/** + * @param {Object} props + * @param {boolean} props.open - Whether panel is open + * @param {(open: boolean) => void} props.onClose - Close handler + * @param {string} [props.title] - Panel title + * @param {'sm' | 'md' | 'lg' | 'xl' | '2xl'} [props.size='xl'] - Panel width + * @param {boolean} [props.closeOnOutsideClick=true] - Close when clicking outside + * @param {import('solid-js').JSX.Element} props.children - Panel content + */ +export default function SlidingPanel(props) { + const [mounted, setMounted] = createSignal(false); + const [visible, setVisible] = createSignal(false); + + let panelRef = null; + + const size = () => props.size ?? 'xl'; + const closeOnOutsideClick = () => props.closeOnOutsideClick ?? true; + + const getSizeClass = () => { + switch (size()) { + case 'sm': + return 'w-80'; + case 'md': + return 'w-96'; + case 'lg': + return 'w-[32rem]'; + case 'xl': + return 'w-[40rem]'; + case '2xl': + return 'w-[48rem]'; + default: + return 'w-[40rem]'; + } + }; + + // Handle open/close with proper mount/unmount timing + createEffect(() => { + if (props.open) { + // Mount first, then animate in + setMounted(true); + // Use RAF to ensure DOM is ready before animating + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setVisible(true); + }); + }); + } else { + // Animate out first, then unmount + setVisible(false); + } + }); + + // Handle unmount after close animation + const handleTransitionEnd = e => { + if (e.propertyName === 'transform' && !props.open) { + setMounted(false); + } + }; + + // Handle escape key + createEffect(() => { + if (!props.open) return; + + const handleKeyDown = e => { + if (e.key === 'Escape') { + props.onClose?.(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); + }); + + // Handle click outside + createEffect(() => { + if (!props.open || !closeOnOutsideClick()) return; + + const handleClickOutside = e => { + if (panelRef && !panelRef.contains(e.target)) { + props.onClose?.(false); + } + }; + + // Delay adding listener to avoid catching the opening click + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 10); + + onCleanup(() => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }); + }); + + return ( + + {/* Panel container - fixed position, no portal needed */} + + + ); +}