From b6bd4b47be5eae19602687efa918597776e94fce Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 30 Apr 2026 18:49:23 +0330 Subject: [PATCH] improvement: dashboard UX improved --- .../migration.sql | 24 +++ backend/prisma/seed.ts | 18 ++- backend/src/common/permissions.ts | 11 +- backend/src/modules/auth/auth.service.ts | 15 +- .../staff/components/staffPermissions.ts | 7 +- frontend/src/app/(dashboard)/staff/page.tsx | 138 ++++++++++++++++-- .../staff/staff-permission-form.ts | 26 +++- .../src/app/(dashboard)/treatment/page.tsx | 10 ++ frontend/src/components/ui/common/Sidebar.tsx | 23 ++- frontend/src/shared/permissions.ts | 5 +- 10 files changed, 232 insertions(+), 45 deletions(-) create mode 100644 backend/prisma/migrations/20260430184000_add_treatment_permissions/migration.sql create mode 100644 frontend/src/app/(dashboard)/treatment/page.tsx diff --git a/backend/prisma/migrations/20260430184000_add_treatment_permissions/migration.sql b/backend/prisma/migrations/20260430184000_add_treatment_permissions/migration.sql new file mode 100644 index 0000000..9d7e4fd --- /dev/null +++ b/backend/prisma/migrations/20260430184000_add_treatment_permissions/migration.sql @@ -0,0 +1,24 @@ +-- Ensure Treatment feature and permissions exist for existing databases. +WITH treatment_feature AS ( + INSERT INTO "features" ("id", "name") + VALUES (md5(random()::text || clock_timestamp()::text), 'Treatment') + ON CONFLICT ("name") DO UPDATE SET "name" = EXCLUDED."name" + RETURNING "id" +), +selected_feature AS ( + SELECT "id" FROM treatment_feature + UNION ALL + SELECT f."id" FROM "features" f WHERE f."name" = 'Treatment' LIMIT 1 +) +INSERT INTO "permissions" ("id", "name", "featureId") +SELECT md5(random()::text || clock_timestamp()::text), 'TAB_TREATMENT_READ', sf."id" +FROM selected_feature sf +ON CONFLICT ("name") DO NOTHING; + +WITH treatment_feature AS ( + SELECT "id" FROM "features" WHERE "name" = 'Treatment' LIMIT 1 +) +INSERT INTO "permissions" ("id", "name", "featureId") +SELECT md5(random()::text || clock_timestamp()::text), 'TAB_TREATMENT_EDIT', tf."id" +FROM treatment_feature tf +ON CONFLICT ("name") DO NOTHING; diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 20e1131..a27fa08 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -69,21 +69,25 @@ async function main() { name: 'Today', permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'], }, + { + name: 'Staff', + permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'], + }, + { + name: 'Labs / Clinics', + permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'], + }, { name: 'Patients', permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'], }, { - name: 'Appointments', + name: 'Appointment', permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'], }, { - name: 'Staff Management', - permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'], - }, - { - name: 'Lab Management', - permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'], + name: 'Treatment', + permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'], }, { name: 'Billing', diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts index 2165d51..afebf47 100644 --- a/backend/src/common/permissions.ts +++ b/backend/src/common/permissions.ts @@ -2,14 +2,16 @@ export const ALL_TAB_PERMISSIONS = [ 'TAB_TODAY_READ', 'TAB_TODAY_EDIT', - 'TAB_PATIENTS_READ', - 'TAB_PATIENTS_EDIT', - 'TAB_APPOINTMENTS_READ', - 'TAB_APPOINTMENTS_EDIT', 'TAB_STAFF_READ', 'TAB_STAFF_EDIT', 'TAB_LAB_READ', 'TAB_LAB_EDIT', + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + 'TAB_APPOINTMENTS_READ', + 'TAB_APPOINTMENTS_EDIT', + 'TAB_TREATMENT_READ', + 'TAB_TREATMENT_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', @@ -37,6 +39,7 @@ const EDIT_TO_READ: Record = { TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ', TAB_STAFF_EDIT: 'TAB_STAFF_READ', TAB_LAB_EDIT: 'TAB_LAB_READ', + TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ', TAB_BILLING_EDIT: 'TAB_BILLING_READ', TAB_REPORTS_EDIT: 'TAB_REPORTS_READ', }; diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 4162cf9..3dccbf0 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -18,14 +18,16 @@ import { JwtPayload } from './interfaces/jwt-payload.interface'; const ALL_PERMISSIONS = [ 'TAB_TODAY_READ', 'TAB_TODAY_EDIT', - 'TAB_PATIENTS_READ', - 'TAB_PATIENTS_EDIT', - 'TAB_APPOINTMENTS_READ', - 'TAB_APPOINTMENTS_EDIT', 'TAB_STAFF_READ', 'TAB_STAFF_EDIT', 'TAB_LAB_READ', 'TAB_LAB_EDIT', + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + 'TAB_APPOINTMENTS_READ', + 'TAB_APPOINTMENTS_EDIT', + 'TAB_TREATMENT_READ', + 'TAB_TREATMENT_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', @@ -34,10 +36,11 @@ const ALL_PERMISSIONS = [ const READ_ONLY_PERMISSIONS = [ 'TAB_TODAY_READ', - 'TAB_PATIENTS_READ', - 'TAB_APPOINTMENTS_READ', 'TAB_STAFF_READ', 'TAB_LAB_READ', + 'TAB_PATIENTS_READ', + 'TAB_APPOINTMENTS_READ', + 'TAB_TREATMENT_READ', 'TAB_BILLING_READ', 'TAB_REPORTS_READ', ]; diff --git a/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts b/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts index bc30c97..c4e7799 100644 --- a/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts +++ b/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts @@ -1,10 +1,11 @@ /** Feature groups for staff invite/edit UI — matches backend seed */ export const STAFF_FEATURE_GROUPS = [ { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, + { label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, + { label: 'Labs / Clinics', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' }, { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, - { label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, - { label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, - { label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' }, + { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, + { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, ] as const; diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 41a7afa..e7c7a6d 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -12,6 +12,7 @@ import { permissionNamesFromFeatureState, emptyFeaturePermissionState, featureStateFromPermissionNames, + resolveStaffFeatureLabel, formatAccessSummary, type FeaturePermState, } from './staff-permission-form'; @@ -23,6 +24,33 @@ import { Input } from '@/components/ui/common/Input'; import { Checkbox } from '@/components/ui/common/Checkbox'; import type { ApiError } from '@/types/api'; +type StoredInviteLink = { + membershipId: string; + email: string; + invitationUrl: string; +}; + +function inviteLinksStorageKey(orgId: string): string { + return `staffInviteLinks:${orgId}`; +} + +function readStoredInviteLinks(orgId: string): Record { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeStoredInviteLinks(orgId: string, links: Record) { + if (typeof window === 'undefined') return; + window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); +} + function formatApiMessage(err: unknown): string { if (!err || typeof err !== 'object') return 'Something went wrong'; const m = (err as ApiError).message; @@ -35,10 +63,12 @@ function PermissionGrid({ state, onChange, disabled, + organizationType, }: { state: FeaturePermState; onChange: (next: FeaturePermState) => void; disabled?: boolean; + organizationType?: 'CLINIC' | 'LAB'; }) { const setRead = (editKey: string, read: boolean) => { const cur = state[editKey] ?? { read: false, edit: false }; @@ -65,7 +95,9 @@ function PermissionGrid({ key={g.edit} className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3" > - {g.label} + + {resolveStaffFeatureLabel(g, organizationType)} +
emptyFeaturePermissionState()); const [inviteLoading, setInviteLoading] = useState(false); - const [copiedInviteLink, setCopiedInviteLink] = useState(false); + const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState(null); const [lastInviteInfo, setLastInviteInfo] = useState<{ + membershipId: string; name: string; email: string; invitationUrl: string | null; invitationStatus: 'PENDING' | 'ACCEPTED'; } | null>(null); + const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); const [editing, setEditing] = useState(null); const [editName, setEditName] = useState(''); @@ -140,6 +174,34 @@ export default function StaffPage() { } }, []); + useEffect(() => { + if (!currentOrganization?.id) return; + setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id)); + }, [currentOrganization?.id]); + + useEffect(() => { + if (!currentOrganization?.id || loading) return; + + const activeMemberIds = new Set( + members + .filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE') + .map((m) => m.id), + ); + + let changed = false; + const nextLinks: Record = { ...pendingInviteLinks }; + for (const memberId of Object.keys(nextLinks)) { + if (activeMemberIds.has(memberId)) { + delete nextLinks[memberId]; + changed = true; + } + } + if (!changed) return; + + setPendingInviteLinks(nextLinks); + writeStoredInviteLinks(currentOrganization.id, nextLinks); + }, [currentOrganization?.id, loading, members, pendingInviteLinks]); + useEffect(() => { void load(); }, [load]); @@ -171,11 +233,24 @@ export default function StaffPage() { permissionNames, }); setLastInviteInfo({ + membershipId: res.data.membershipId, name: displayName, email: res.data.email, invitationUrl: res.data.invitationUrl, invitationStatus: res.data.invitationStatus, }); + if (currentOrganization?.id && res.data.invitationUrl) { + const nextLinks = { + ...pendingInviteLinks, + [res.data.membershipId]: { + membershipId: res.data.membershipId, + email: res.data.email, + invitationUrl: res.data.invitationUrl, + }, + }; + setPendingInviteLinks(nextLinks); + writeStoredInviteLinks(currentOrganization.id, nextLinks); + } setSuccess(''); setInviteOpen(false); setInviteEmail(''); @@ -328,15 +403,21 @@ export default function StaffPage() { onClick={async () => { try { await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string); - setCopiedInviteLink(true); - setTimeout(() => setCopiedInviteLink(false), 1500); + setCopiedInviteMembershipId(lastInviteInfo.membershipId); + setTimeout(() => setCopiedInviteMembershipId(null), 1500); } catch { setError('Could not copy invitation link'); } }} > - {copiedInviteLink ? : } - {copiedInviteLink ? 'Copied' : 'Copy link'} + {copiedInviteMembershipId === lastInviteInfo.membershipId ? ( + + ) : ( + + )} + + {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'} +

@@ -364,7 +445,7 @@ export default function StaffPage() { {members.map((m) => ( - + {m.name} {m.email} @@ -374,7 +455,7 @@ export default function StaffPage() { Staff )} - + {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( Active @@ -395,13 +476,36 @@ export default function StaffPage() { All features ) : ( - {formatAccessSummary(m.permissions)} + {formatAccessSummary(m.permissions, currentOrganization?.type)} )} - + {!m.isOwner && ( -

+
+ {m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && ( + + )}