import { useLocation, useNavigate } from 'react-router-dom'; import { Spinner } from "../../../components/ui/spinner"; import { Item, ItemGroup } from "../../../components/ui/item-group"; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { Flex, FlexItem } from '../../../components/ui/flex'; import { Text } from '../../../components/ui/text'; import { Spacer } from '../../../components/ui/spacer'; import { Modal } from '../../../components/ui/wp-modal-shim'; import { DropdownMenu, DropdownMenuItem } from '../../../components/ui/dropdown-menu'; import { Check, ChevronDown, Copy, Globe, Link2, MoreHorizontal, PlusCircle, Trash2, TriangleAlert, } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../../components/ui/tabs'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../../components/ui/dialog'; import { Questionnaire } from 'fhir/r4'; import QuestionnairePreview from './QuestionnairePage/QuestionnairePreview/QuestionnairePreview.tsx'; import { useQuestionnaireServiceContext, usePaginatorServiceContext } from '../../../services'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import QuestionnaireAssignmentModal from './QuestionnaireAssignmentModal/QuestionnaireAssignmentModal.tsx'; import QuestionnairePackets from './QuestionnairePackets/QuestionnairePackets'; import { isWpAdminMount } from '../../../utils/mountContext'; import { isPublicFormDraft } from '../../../utils/publicFormKind'; import { deletePublicForm } from '../../../utils/wpAdminApi.ts'; import { isHostedMount, PUBLIC_FORMS_NEED_WORDPRESS } from '../../../utils/hostedMount'; import { usePublicFormServiceContext, type PublishedPublicForm } from '../../../services/PublicFormService.tsx'; import { useFhirQuestionnaires } from '../../../hooks/useFhirQuestionnaires'; import { groupFormsByKind, formsNotServedOnHost, type FormRow } from '../../../utils/formsSections'; import FormSubmissionNotificationsSettings from '../FormSubmissionNotificationsPage/FormSubmissionNotificationsSettings.tsx'; import { describeWriteFailure } from '../../../utils/fetchFailureMessage'; type FormsTab = 'public' | 'clinical'; interface QuestionnairesPageProps { allowPatientAssignment?: boolean; } const QuestionnairesPage = ({ allowPatientAssignment = false }: QuestionnairesPageProps) => { const navigate = useNavigate(); const { transformFromFhirQuestionnaire, deleteQuestionnaire, templateBundle, templateList, fetchQuestionnaireTemplatesWithPolling } = useQuestionnaireServiceContext(); const { listPublicForms, deactivatePublicForm, updatePublicFormDomains } = usePublicFormServiceContext(); /* * THE WHOLE LIST, NOT A PAGE - and this is the reason the paginator no longer wraps the rows. * Grouping and counting a PAGE produces a number that reads as a measurement and is not one, which is * this header's entire history: an absent count rendering as zero (214), a count describing a set the * reader cannot see (252), a count including public forms while naming them clinical (469). * It is affordable because the fetch already reads every page (`fetchAllPagesAsBundle`) - the * paginator was slicing a complete bundle client-side, so nothing new is being asked of the server. */ const { questionnaireList, questionnaireBundle, loading: questionnairesLoading, error: questionnairesError, loadQuestionnaires, } = useFhirQuestionnaires({ pageSize: 100 }); // The library is unknown until its read answers; a failed or pending read is never "no forms yet". const libraryUnread = !questionnaireBundle && !questionnairesError; const { actions } = usePaginatorServiceContext(); const { deleteItem } = actions; // - `usage` is no longer read here. The header count it fed is gone: a single number // cannot describe two kinds of thing, and each section now counts exactly the rows under it. // The three defects that line carried are recorded on the section headers below, because the // warnings still bind: never a numeric fallback for an absent value (214), never a count of a // set the reader cannot see (252), never a count that mixes the two kinds (469). const [loading, setLoading] = useState(false); const [createStep, setCreateStep] = useState<'choose' | 'templates' | null>(null); const [deleting, setDeleting] = useState>({}); const [error, setError] = useState(); const [showDeleteModal, setShowDeleteModal] = useState(false); const [questionnaireToDelete, setQuestionnaireToDelete] = useState<{ questionnaire: Questionnaire, index: number } | null>(null); const [showPreviewModal, setShowPreviewModal] = useState(false); const [selectedQuestionnaire, setSelectedQuestionnaire] = useState(null); const [showAssignmentModal, setShowAssignmentModal] = useState(false); /** * - THE PUBLISHED FORMS, FROM THE RECORD. Previously a WordPress option. * * The option was never the registry - it was a duplicate CACHE of one, and the duplication is what * hurt: **akashic.health has no `wp_options` to read**, so the standalone client could never list a * published form, and the org setup checklist's public-form step was unresolvable without a WordPress * admin session - so it read *not done* while a live, CAPTCHA-protected form was published. * Building this screen against that source would bake the WordPress-shaped divergence into the very * screen built to clear up the confusion it caused. item 3 made the record listable first, * and this card was held until it landed. */ const [publicForms, setPublicForms] = useState([]); const [unpublishing, setUnpublishing] = useState>({}); const [formToUnpublish, setFormToUnpublish] = useState(null); // - the form whose authorized domains are being edited, and the text being edited. const [formToRepoint, setFormToRepoint] = useState(null); const [domainsDraft, setDomainsDraft] = useState(''); const [domainsSaving, setDomainsSaving] = useState(false); const [domainsError, setDomainsError] = useState(null); /** * - WHETHER WE KNOW WHAT KIND EACH ROW IS. Three states, not two. * * The "Akashic Public Form" badge has shipped since 2026-07-29, and the read behind it swallowed every * failure into an empty catch. **A failed read and a practice with no public forms produce the * identical screen** - every row unbadged - so the one signal telling a public contact form apart from * clinical intake disappears silently, and the row keeps a `Delete` that takes down a page on the * practice's public website. * Best-effort is right for the BADGE (a missing badge must not break the list). It is wrong for the * CLAIM the list makes by omission. * * AND DO NOT WRITE A COMMENTED-OUT CODE SAMPLE ANYWHERE IN THIS FILE. The guard beside it strips * comments with a regex whose first pass matches a braced comment block; a sample containing that * shape inside a comment matched THROUGH to the next brace and silently deleted 12,000 characters of * the stripped source, so three assertions failed against code that was present all along. */ const [publicFormsRead, setPublicFormsRead] = useState<'loading' | 'ready' | 'failed'>('loading'); const [publicFormsAttempt, setPublicFormsAttempt] = useState(0); // A builder or LOINC search returns to the library it was opened from. const location = useLocation(); const [tab, setTab] = useState(() => (location.state as { formsTab?: FormsTab } | null)?.formsTab ?? 'public'); const [showNotifications, setShowNotifications] = useState(false); const [copiedHandle, setCopiedHandle] = useState(null); const spacerMargin = '24px'; const methods = useForm>({ defaultValues: {} }); const { reset, watch } = methods; const loadData = useCallback(async () => { setLoading(true); try { await fetchQuestionnaireTemplatesWithPolling(); } catch (error) { console.error('Failed to load questionnaire templates:', error); setError(`Could not load questionnaire templates. Try again.`); } finally { setLoading(false); } }, [fetchQuestionnaireTemplatesWithPolling]); useEffect(() => { if (!templateBundle) { loadData(); } }, [loadData, templateBundle]); useEffect(() => { let cancelled = false; setPublicFormsRead('loading'); (async () => { try { const forms = await listPublicForms(); if (!cancelled) { setPublicForms(forms); setPublicFormsRead('ready'); } } catch (readError) { // - STILL best-effort for the list, but no longer SILENT. Without the state // below, a failed read renders exactly the same screen as a practice that has published // no public forms, and the reader has no way to tell "none" from "unknown". // raised the stakes: the kind now decides which SECTION a row appears in, so a // failed read means the page must not split the list at all - see `sections.grouped`. console.warn('Failed to read published public forms; row kinds are unknown.', readError); if (!cancelled) setPublicFormsRead('failed'); } })(); return () => { cancelled = true; }; }, [listPublicForms, publicFormsAttempt]); const viewQuestionnaire = (questionnaire: Questionnaire) => { navigate(questionnaire.id as string, { relative: 'path', state: { questionnaire: questionnaire, template: null } }); }; /** * CREATION DECIDES THE KIND - Spencer: *"building a form under 'public forms' is automatically set as * a public form and the list of those remain there. Building a form under 'clinical questionnaires' is * built as a regular questionnaire."* * * THIS IS WHAT MAKES THE SEPARATION HOLD. Publication is not a toggle applied later, so a form never * appears in two places and nothing has to move between lists. Both buttons open the **same builder** * - one primitive, one engine; the difference is only what is registered when it is saved. */ const createQuestionnaire = (template?: Questionnaire | undefined, publicForm = false) => { navigate(template ? `templates/${template.id}/new` : 'new', { relative: 'path', state: { questionnaire: null, template: template, publicForm, } }); }; const addFromLoinc = () => { navigate('/dashboard/questionnaires/search'); }; const confirmDelete = (questionnaire: Questionnaire, index: number) => { setQuestionnaireToDelete({ questionnaire, index }); setShowDeleteModal(true); }; const handleDeleteQuestionnaire = async () => { if (!questionnaireToDelete) return; const { questionnaire } = questionnaireToDelete; setDeleting(prev => ({ ...prev, [questionnaire.id!]: true })); setShowDeleteModal(false); try { await deleteQuestionnaire(questionnaire); deleteItem(questionnaire.id!); } catch (error) { console.error('Failed to delete questionnaire:', error); setError('Failed to delete questionnaire. Please try again.'); } finally { setDeleting(prev => ({ ...prev, [questionnaire.id!]: false })); setQuestionnaireToDelete(null); } }; const confirmUnpublish = (form: PublishedPublicForm) => { setFormToUnpublish(form); }; /** * UNPUBLISHING IS REVERSIBLE, AND THE CONFIRM SAYS WHAT IT DOES rather than asking "are you sure". * The registration flips inactive - the public handlers gate on `active` - so the form stops being * reachable and the questionnaire itself is untouched. */ const handleUnpublish = async () => { if (!formToUnpublish) return; const { formHandle: handle, questionnaireId } = formToUnpublish; setUnpublishing((prev) => ({ ...prev, [handle]: true })); setFormToUnpublish(null); // A new attempt answers for itself; an earlier refusal must not outlive a later success. setError(undefined); try { await deactivatePublicForm(handle); // Updated locally rather than re-fetched, so the row reads as unpublished at once rather than // offering Unpublish for a form that is already off the website. setPublicForms((prev) => prev.map((form) => ( form.formHandle === handle ? { ...form, active: false } : form ))); // Unpublished either way; a failed WordPress cleanup is said as that, not as a failed unpublish. if (isWpAdminMount()) { try { await deletePublicForm(questionnaireId); } catch (cleanupError) { console.error('Unpublished, but WordPress kept its record of the form:', cleanupError); setError('The form is unpublished and no longer accepts submissions, but WordPress could not remove its record.'); } } } catch (unpublishError) { console.error('Failed to unpublish a public form:', unpublishError); setError('We could not unpublish that form. It is still on your website.'); } finally { setUnpublishing((prev) => ({ ...prev, [handle]: false })); } }; /** * - CHANGE WHICH DOMAINS A PUBLISHED FORM ANSWERS ON. * * THE SCENARIO IS THE NORMAL BUILD SEQUENCE, NOT AN EDGE CASE: a site is built on a staging host, * the forms are published there, and the practice switches to their real domain on go-live day. The * fence is enforced at the token mint, the submission and the framed render **every published form * stops working at once**, and it looks like a broken website rather than a wrong setting. * * ADD-AND-REMOVE, WHICH IS WHY THIS IS A LIST AND NOT A SINGLE FIELD. A cutover is not * instantaneous - both hosts run for a while - so the honest middle state is both, and the old one is * dropped afterwards. A single-value control would make the switch a coin flip on the one day it must * not be. */ const repoint = (form: PublishedPublicForm) => { setDomainsError(null); setDomainsDraft((form.authorizedDomains ?? []).join('\n')); setFormToRepoint(form); }; const handleSaveDomains = async () => { if (!formToRepoint) return; const handle = formToRepoint.formHandle; const domains = domainsDraft.split(/[\n,]/).map((value) => value.trim()).filter(Boolean); setDomainsSaving(true); setDomainsError(null); try { // The SERVER's answer is what is stored, not the draft. It normalizes what was typed // (`https://example.com/` `example.com`) and de-duplicates, so echoing the draft back into // the row would show the practice something other than the fence now in force. const saved = await updatePublicFormDomains(handle, domains); setPublicForms((prev) => prev.map((form) => ( form.formHandle === handle ? { ...form, authorizedDomains: saved.authorizedDomains } : form ))); setFormToRepoint(null); } catch (repointError: any) { console.error('Failed to update a public form\'s domains:', repointError); // The dialog stays OPEN and keeps what they typed. This is the screen a practice reaches on // go-live day, and losing their list to a validation error would be its own small outage. setDomainsError(describeWriteFailure(repointError, 'We could not save those domains')); } finally { setDomainsSaving(false); } }; const assign = (questionnaire: Questionnaire) => { setSelectedQuestionnaire(questionnaire); setShowAssignmentModal(true); }; const handleCloseAssignmentModal = () => { setShowAssignmentModal(false); setSelectedQuestionnaire(null); }; const preview = (event: React.MouseEvent, questionnaire: Questionnaire) => { event.stopPropagation(); reset(transformFromFhirQuestionnaire(questionnaire)); setShowPreviewModal(true); }; /* * - THE SPLIT, and `grouped` is the field the whole screen turns on. * * The kind of a row is not a property of the Questionnaire; it is the answer to *"has this been * published as a public form?"*, which is a SEPARATE read. If that read failed we do not know which * section anything belongs in, and two labelled groups would file a live contact form under * **"Clinical questionnaires"** with a plain Delete beside it. */ const sections = groupFormsByKind(questionnaireList ?? [], publicForms, publicFormsRead); /* * - the published forms this WordPress site cannot serve. Empty everywhere but wp-admin: * only there is the browser's host the practice's own, and comparing against ours would flag everything. */ const brokenOnThisSite = formsNotServedOnHost( sections.publicForms, isWpAdminMount() ? window.location.host : null, ); // A dialog opened from a row menu keeps focus; the closing menu must not pull it back to its trigger. const dialogFromMenu = useRef(false); const fromMenu = (open: () => void) => { dialogFromMenu.current = true; // After the menu has closed, so its focus trap is gone before the dialog sets its own. window.setTimeout(open, 0); }; const keepDialogFocus = (event: Event) => { if (!dialogFromMenu.current) return; dialogFromMenu.current = false; event.preventDefault(); }; const publicLocked = isHostedMount(); const renderActions = (row: FormRow, index: number, kind: 'public' | 'clinical' | 'unknown') => { const { questionnaire, publicForm } = row; const locked = publicLocked && kind === 'public'; const title = questionnaire.title || 'Unnamed'; const busy = deleting[questionnaire.id!] || (publicForm ? unpublishing[publicForm.formHandle] : false); return ( {busy ? : ( )} ); }; const statusLabel = (row: FormRow, kind: 'public' | 'clinical' | 'unknown') => { if (kind === 'public') return row.publicForm ? (row.publicForm.active ? 'Published' : 'Unpublished') : 'Draft'; const status = row.questionnaire.status ?? 'unknown'; return status.charAt(0).toUpperCase() + status.slice(1); }; const copyShortcode = async (handle: string) => { try { await navigator.clipboard.writeText('[akashere_public_form handle="' + handle + '"]'); setCopiedHandle(handle); } catch (copyError) { console.warn('Could not copy the shortcode.', copyError); setCopiedHandle(null); } }; const renderList = (rows: FormRow[], kind: 'public' | 'clinical' | 'unknown') => { return ( {rows.map((row, index) => { const { questionnaire, publicForm } = row; const openable = !(publicLocked && kind === 'public'); // The row is not a click target: its title opens it, beside Edit, Preview and More, so the // mouse and the keyboard reach the same controls. return (
{statusLabel(row, kind)}
{kind === 'clinical' && (

{questionnaire.status === 'active' ? 'Available for patient assignment' : 'Not available for patient assignment'}

)} {publicForm && !publicForm.active && (

Not on your website

)}
{renderActions(row, index, kind)}
{publicForm?.active && publicLocked && (

Embedding controls

)} {publicForm?.active && !publicLocked && ( // The block leads because an editor that curls the shortcode quotes breaks it. The wrapper only // keeps presses inside the panel from opening the card it sits on.
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
Embedding controls In WordPress, add the Public Form block and choose this form. Or use its shortcode: [akashere_public_form handle="{publicForm.formHandle}"] Publishing makes the form available for embedding. It does not create or publish a WordPress page.
)}
); })}
); }; const createMenu = loading ? : ( ); const createChoice = (title: string, help: string, onClick: () => void, disabled = false) => ( ); const createDialog = ( { if (!open) setCreateStep(null); }}> {createStep === 'templates' ? 'Use a saved template' : 'Create questionnaire'}
{createStep === 'templates' ? (
{templateList.map((template: Questionnaire) => ( {createChoice(template.title || 'Untitled template', template.description || 'Saved template', () => createQuestionnaire(template))} ))}
) : (
{createChoice('Start blank', 'Write your own clinical questions.', () => createQuestionnaire())} {createChoice('Use a saved template', templateList.length !== 0 ? 'Select from templates available to this practice.' : 'No saved templates are available to this practice yet.', () => setCreateStep('templates'), templateList.length === 0)} {createChoice('Import from LOINC', 'Search standardized questionnaires, preview, then import.', addFromLoinc)}
)}
{createStep === 'templates' && ( )}
); const publicSection = (

Public forms

For website visitors. No account required. Submissions go to the front desk.

{publicLocked && ( <> {PUBLIC_FORMS_NEED_WORDPRESS} )} {sections.publicForms.length > 0 ? renderList(sections.publicForms, 'public') : ( No public forms yet. )}

{/* A count is a section length, never a fallback such as ?? 0 on a value that did not load. */} {sections.publicForms.length} public form{sections.publicForms.length === 1 ? '' : 's'} · “Published” does not confirm placement on a live website.

); const clinicalSection = (

Clinical questionnaires

For patients to complete in their portal. Answers become part of their record.

{createMenu}
{sections.clinical.length > 0 ? renderList(sections.clinical, 'clinical') : ( No clinical questionnaires yet. )}

{sections.clinical.length} questionnaire{sections.clinical.length === 1 ? '' : 's'} · Create these for clinical questions, not to recapture information already in a patient's profile.

row.questionnaire)} onPreview={(questionnaire) => { reset(transformFromFhirQuestionnaire(questionnaire)); setShowPreviewModal(true); }}/>
); return (

Forms

Build and manage forms. Submitted answers are handled in the front desk or clinical workspace.

{!sections.grouped && questionnaireBundle && ( {createMenu} )}
{error && ( <> setError(undefined)}> {error} )} {/* 🚨 CARD 469 — SAY SO WHEN THE KIND IS UNKNOWN, rather than showing an unbadged list. A failed read of the published public forms and a practice that has published none produce the SAME screen: every row unbadged. ⛔ That is not a cosmetic gap — the row's `Delete` takes down a page on the practice's public website for one kind and affects patient intake for the other, and the badge is the only thing on the row that says which. ⚖️ Deliberately NOT an error and NOT blocking: the questionnaires themselves loaded fine and every other action on this page still works. This withholds a CLAIM the screen was making by omission — the same distinction card 473 landed for the front desk's all-clear the same day. */} {publicFormsRead === 'failed' && ( <> We couldn't check which of these are public forms, so none are marked as one and none can be deleted until that is known. {' '} )} {/* * 🚨 CARD 518 — THE FORMS THAT ARE BROKEN ON THIS SITE RIGHT NOW. * * A practice builds their site on a staging host, publishes their forms there, and switches to * their real domain. The authorized-domain fence is enforced at the token mint, the submission * and the framed render ⇒ every published form stops working AT ONCE, silently, and to a * visitor it reads as a broken website rather than a wrong setting. Nobody is watching for it, * because it happens on the day everybody is watching something else. * * ⭐ WP-ADMIN ONLY, AND THAT IS THE WHOLE REASON THIS CAN BE TRUSTED. Here the address bar IS * the practice's own domain, so comparing against it means something. ⛔ On akashic.health the * host is OURS — the same check there would report every form of every practice as broken, * which is the cry-wolf failure that gets a warning ignored permanently. */} {isWpAdminMount() && brokenOnThisSite.length > 0 && ( <> {' '} {brokenOnThisSite.length === 1 ? 'One public form will not work on this website.' : `${brokenOnThisSite.length} public forms will not work on this website.`} {' '}They are set to answer on a different domain, so they will not load and will not accept submissions here. If you have moved this site to{' '} {window.location.host}, add it to each form's domains:{' '} {brokenOnThisSite .map((row) => row.questionnaire.title || row.publicForm?.formHandle) .join(', ')}. )} {/* * 🧩 CARD 486 — TWO SECTIONS, EACH STATING ITS CONSEQUENCE. * * 🗣️ The headers deliberately say what HAPPENS rather than what the thing is called: * *"Submissions go to the front desk — not to a patient's record"* against *"Answers save * straight to their record."* ⭐ The category labels alone do not carry the difference that * matters, and this is the sentence a practice needs BEFORE putting a form on the public * internet. * * ⛔ AND WHEN WE CANNOT TELL THE KINDS APART, THERE ARE NO SECTIONS. Splitting on a failed * read would file a live contact form under "Clinical questionnaires" — with a Delete that * takes a page off the practice's public website — and the reader would have no way to know. */} {questionnairesError && !questionnaireBundle ? (
We could not load your forms. Your forms have not changed.
) : libraryUnread || (questionnairesLoading && !questionnaireBundle) ? ( Loading forms… ) : sections.grouped ? ( setTab(value as FormsTab)}> Public forms Clinical questionnaires {publicSection} {clinicalSection} ) : ( renderList(sections.all, 'unknown') )}
{/* A destructive confirm says what deleting does and offers a way back, amber beside red. */} {showDeleteModal && questionnaireToDelete && ( { if (!open) setShowDeleteModal(false); }}> Delete {questionnaireToDelete.questionnaire.title?.replace(/[.?!\s]+$/, '') || 'this form'}?
{isPublicFormDraft(questionnaireToDelete.questionnaire) ? (

It leaves your forms library. Submissions already received are not deleted.

) : (

It leaves your forms library and can no longer be assigned, by hand or automatically. Answers patients have already given are not deleted.

)}
)} {/* * ⭐ CARD 486 — THE CONFIRM NAMES THE CONSEQUENCE. Confirm-on-delete is already the house * rule; what a public form needs is a sentence saying what stops working, because the * thing being changed is on the practice's public website and the reader may not know it. */} {formToUnpublish && ( { if (!open) setFormToUnpublish(null); }}> Unpublish {formToUnpublish.title?.replace(/[.?!\s]+$/, '') || 'this form'}?

Visitors will no longer be able to submit this form from your website. Existing submissions remain available to authorized front-desk staff.

You can publish it again later. Its WordPress placement may still be present.

)} {/* * 🌐 CARD 518 — THE DIALOG A PRACTICE REACHES ON GO-LIVE DAY. * * ⚖️ ONE PER LINE, AND MORE THAN ONE IS EXPECTED. A cutover is add-and-remove: both hosts run * for a while, so the correct middle state is both listed, and the old one deleted afterwards. * ⛔ A single-value field would force the switch to happen in one instant, which is not how a * site goes live. */} {formToRepoint && ( setFormToRepoint(null)}> This form only loads, and only accepts submissions, on the domains listed here. One per line. Moving to a new domain? Add the new one before you switch, keep both while the old site is still up, then remove the old one. A form with no domains will not work anywhere.