From b977b8230bee51cd5446a9b75ed654d4abcc66b2 Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Mon, 6 Jul 2026 20:28:10 +0100 Subject: [PATCH 1/2] feat: CEO user management + MARKETER login role - Owner-only Users tab (/admin/users): list staff accounts, suspend/ reactivate, delete, grant/revoke operator roles. Self and owner protected. - MARKETER role: AppRole enum value (migration 0048), demo login, guards, role-filtered nav, marketer "My Dashboard" with assigned leads/ inspections and click-to-chat. Granting Marketer auto-provisions a StaffProfile. Co-Authored-By: Claude Sonnet 4.6 --- COMMIT-PROMPT.md | 47 --- .../0048_marketer_role/migration.sql | 2 + .../migration.sql | 1 + src/app/(admin)/admin/marketer/page.tsx | 20 ++ src/app/(admin)/admin/users/page.tsx | 20 ++ src/app/api/dev/session/route.ts | 1 + src/components/admin/marketer-dashboard.tsx | 101 +++++++ src/components/admin/users-management.tsx | 270 ++++++++++++++++++ .../marketing/floating-whatsapp.tsx | 32 +++ src/components/shared/dev-access.ts | 5 + src/lib/auth/dev-users.ts | 17 ++ src/lib/auth/roles.ts | 1 + src/lib/auth/session.ts | 28 +- src/modules/admin/user-actions.ts | 111 +++++++ src/modules/admin/users.ts | 82 ++++++ src/modules/marketer/dashboard.ts | 115 ++++++++ 16 files changed, 803 insertions(+), 50 deletions(-) delete mode 100644 COMMIT-PROMPT.md create mode 100644 prisma/migrations/0048_marketer_role/migration.sql create mode 100644 prisma/migrations/20260701000111_applies_migration_0044_regenerates_the_prisma_client/migration.sql create mode 100644 src/app/(admin)/admin/marketer/page.tsx create mode 100644 src/app/(admin)/admin/users/page.tsx create mode 100644 src/components/admin/marketer-dashboard.tsx create mode 100644 src/components/admin/users-management.tsx create mode 100644 src/components/marketing/floating-whatsapp.tsx create mode 100644 src/modules/admin/user-actions.ts create mode 100644 src/modules/admin/users.ts create mode 100644 src/modules/marketer/dashboard.ts diff --git a/COMMIT-PROMPT.md b/COMMIT-PROMPT.md deleted file mode 100644 index 991ce38..0000000 --- a/COMMIT-PROMPT.md +++ /dev/null @@ -1,47 +0,0 @@ -# Prompt for Claude Code — commit & push all updates - -Copy everything in the fenced block below and paste it into Claude Code, run from the -repository root (`C:\Users\HP\Desktop\Realestate saas`). - -``` -Commit and push all current work in this repository. Follow these steps carefully and stop if any gate fails. - -CONTEXT -- Branch should be `feat/design-system-v2`, tracking `origin/feat/design-system-v2`. -- There is a large amount of uncommitted work (~120 modified files, ~70 new files), including: - - Prisma migrations 0042–0047 (site-content CMS, team staff-code unique, front-desk visitor/call log, property invoices, in-app messaging, announcements). - - New features: in-app messaging, announcements/broadcast banner, property invoices, front-desk logbook + dashboard, CEO executive overview, finance board, reservations board, site-content CMS. - - Design-system v2: tokens, dark mode, page transitions, command palette, loading/empty primitives, scroll-aware header, marketing/tenant redesigns. - - WhatsApp click-to-chat: a shared `WhatsAppButton` wired into clients, front-desk visitor log, leads/inquiries, inspections, schedule, team, transactions, and payment requests. Two of these (transactions, payment requests) also plumb a `buyerPhone` field through the queries in `src/modules/admin/operations.ts` and `src/modules/admin/control-center.ts`. - -STEPS -1. Run `git status` and `git rev-parse --abbrev-ref HEAD`. Confirm the branch is `feat/design-system-v2`. If it is not, stop and tell me before doing anything else. -2. Make sure no stray build artifacts are staged or present: `tc.out`, `tc_result.log`, `tsconfig.scope.json`, or any files matching `_wa_*`, `_cav*`, `_orig*`, `*_test.tsx` scratch files. If any exist, delete them. (`RECAP.md` is intentional — keep it.) -3. Run the full verification gate: `npm run check`. This runs test + typecheck + lint + build. Do NOT commit if it fails — instead, show me the failing output and stop. -4. If the check passes, stage everything: `git add -A`. -5. Show me `git status` and `git diff --cached --stat` so I can see exactly what will be committed. -6. Commit with this message (a single commit is fine): - - feat: WhatsApp click-to-chat across operator surfaces + session feature set - - - Add shared WhatsAppButton (wa.me click-to-chat, no wallet/credentials) and wire it into - clients, front-desk visitor log, leads/inquiries, inspections, schedule, team, - transactions, and payment requests. Plumb buyerPhone through transactions and - payment-request queries (company-guarded). - - In-app messaging, announcements/broadcast banner, property invoices, front-desk - logbook + dashboard, CEO executive overview, finance board, reservations board, - site-content CMS (migrations 0042–0047). - - Design-system v2: tokens, dark mode, page transitions, command palette, loading/empty - primitives, scroll-aware header, marketing + tenant redesigns. - -7. Push: `git push origin feat/design-system-v2`. If the branch has no upstream yet, use `git push -u origin feat/design-system-v2`. -8. Report the final commit hash and confirm the push succeeded. - -Do not open a pull request unless I ask. Do not rebase or force-push. -``` - -## Notes -- The database-touching tests only pass on your Windows machine (Prisma Client is generated - for Windows). That's why `npm run check` is the right gate to run there. -- Migrations 0046 and 0047 were already applied per the session recap; committing them just - records the migration files in git. diff --git a/prisma/migrations/0048_marketer_role/migration.sql b/prisma/migrations/0048_marketer_role/migration.sql new file mode 100644 index 0000000..7cb7504 --- /dev/null +++ b/prisma/migrations/0048_marketer_role/migration.sql @@ -0,0 +1,2 @@ +-- Add MARKETER to the AppRole enum so marketers can have their own login role. +ALTER TYPE "AppRole" ADD VALUE IF NOT EXISTS 'MARKETER'; diff --git a/prisma/migrations/20260701000111_applies_migration_0044_regenerates_the_prisma_client/migration.sql b/prisma/migrations/20260701000111_applies_migration_0044_regenerates_the_prisma_client/migration.sql new file mode 100644 index 0000000..0241c17 --- /dev/null +++ b/prisma/migrations/20260701000111_applies_migration_0044_regenerates_the_prisma_client/migration.sql @@ -0,0 +1 @@ +-- Reconciliation placeholder for a migration already applied to the dev database. diff --git a/src/app/(admin)/admin/marketer/page.tsx b/src/app/(admin)/admin/marketer/page.tsx new file mode 100644 index 0000000..83ac8c8 --- /dev/null +++ b/src/app/(admin)/admin/marketer/page.tsx @@ -0,0 +1,20 @@ +import { DashboardShell } from "@/components/portal/dashboard-shell"; +import { MarketerDashboardView } from "@/components/admin/marketer-dashboard"; +import { requireAdminSession } from "@/lib/auth/guards"; +import { rolesForAdminPath } from "@/lib/auth/admin-sections"; +import { getMarketerDashboard } from "@/modules/marketer/dashboard"; + +export default async function AdminMarketerPage() { + const tenant = await requireAdminSession(rolesForAdminPath("/admin/marketer")); + const data = await getMarketerDashboard(tenant); + + return ( + + + + ); +} diff --git a/src/app/(admin)/admin/users/page.tsx b/src/app/(admin)/admin/users/page.tsx new file mode 100644 index 0000000..44f0104 --- /dev/null +++ b/src/app/(admin)/admin/users/page.tsx @@ -0,0 +1,20 @@ +import { DashboardShell } from "@/components/portal/dashboard-shell"; +import { UsersManagement } from "@/components/admin/users-management"; +import { requireAdminSession } from "@/lib/auth/guards"; +import { rolesForAdminPath } from "@/lib/auth/admin-sections"; +import { getCompanyUsers } from "@/modules/admin/users"; + +export default async function AdminUsersPage() { + const tenant = await requireAdminSession(rolesForAdminPath("/admin/users")); + const users = await getCompanyUsers(tenant); + + return ( + + + + ); +} diff --git a/src/app/api/dev/session/route.ts b/src/app/api/dev/session/route.ts index 5f86c35..655a324 100644 --- a/src/app/api/dev/session/route.ts +++ b/src/app/api/dev/session/route.ts @@ -17,6 +17,7 @@ const ALLOWED_ROLES = new Set([ "admin", "finance", "frontdesk", + "marketer", "superadmin", ]); diff --git a/src/components/admin/marketer-dashboard.tsx b/src/components/admin/marketer-dashboard.tsx new file mode 100644 index 0000000..cee49f3 --- /dev/null +++ b/src/components/admin/marketer-dashboard.tsx @@ -0,0 +1,101 @@ +import { CalendarClock, Users2, Flame } from "lucide-react"; + +import { AdminEmptyState, AdminStateBanner, StatCard } from "@/components/admin/admin-ui"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { WhatsAppButton } from "@/components/shared/whatsapp-button"; +import type { MarketerDashboard } from "@/modules/marketer/dashboard"; + +export function MarketerDashboardView({ data }: { data: MarketerDashboard }) { + return ( +
+
+ + + +
+ + {!data.hasProfile ? ( + + ) : null} + + +

+ My assigned leads +

+
+ {data.leads.length === 0 ? ( + + ) : ( + data.leads.map((lead) => ( +
+
+
{lead.name}
+
{lead.propertyTitle}
+
{lead.when}
+
+
+ {lead.status} + {lead.phone ? ( + + ) : null} +
+
+ )) + )} +
+
+ + +

+ My inspections +

+
+ {data.inspections.length === 0 ? ( + + ) : ( + data.inspections.map((item) => ( +
+
+
{item.name}
+
{item.propertyTitle}
+
+ {item.when} +
+
+
+ {item.status} + {item.phone ? ( + + ) : null} +
+
+ )) + )} +
+
+
+ ); +} diff --git a/src/components/admin/users-management.tsx b/src/components/admin/users-management.tsx new file mode 100644 index 0000000..105ba8e --- /dev/null +++ b/src/components/admin/users-management.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { ChevronDown, Mail, Phone, ShieldCheck, Trash2, UserCog } from "lucide-react"; + +import { AdminEmptyState, StatCard } from "@/components/admin/admin-ui"; +import { Avatar } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { WhatsAppButton } from "@/components/shared/whatsapp-button"; +import { deleteUserAction, setUserActiveAction, setUserRoleAction } from "@/modules/admin/user-actions"; +import { ASSIGNABLE_ROLES, ROLE_LABELS, type CompanyUserRow } from "@/modules/admin/users"; +import type { AppRole } from "@prisma/client"; + +export function UsersManagement({ users }: { users: CompanyUserRow[] }) { + const router = useRouter(); + const [expandedId, setExpandedId] = useState(null); + const [pendingId, setPendingId] = useState(null); + const [confirmingId, setConfirmingId] = useState(null); + const [, startTransition] = useTransition(); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return users; + return users.filter( + (u) => + u.name.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + u.roles.some((r) => r.label.toLowerCase().includes(q)), + ); + }, [users, query]); + + const activeCount = users.filter((u) => u.isActive).length; + const suspendedCount = users.length - activeCount; + + function run(action: () => Promise<{ ok: boolean; error?: string }>, id: string, successMsg: string) { + setPendingId(id); + startTransition(async () => { + try { + const result = await action(); + if (!result.ok) { + toast.error(result.error ?? "Action failed."); + } else { + toast.success(successMsg); + router.refresh(); + } + } catch { + toast.error("Something went wrong. Please try again."); + } finally { + setPendingId(null); + } + }); + } + + function handleToggleActive(user: CompanyUserRow) { + const next = !user.isActive; + run(() => setUserActiveAction(user.id, next), user.id, next ? "User reactivated." : "User suspended."); + } + + function handleDelete(user: CompanyUserRow) { + setConfirmingId(null); + run(() => deleteUserAction(user.id), user.id, "User deleted."); + } + + function handleToggleRole(user: CompanyUserRow, roleName: AppRole, grant: boolean) { + run(() => setUserRoleAction(user.id, roleName, grant), user.id, grant ? "Role granted." : "Role removed."); + } + + if (users.length === 0) { + return ( + + ); + } + + return ( +
+
+ + + +
+ + setQuery(event.target.value)} + placeholder="Search by name, email, or role" + aria-label="Search staff" + className="admin-focus w-full rounded-[var(--radius-md)] border border-[var(--line)] bg-white px-4 py-2.5 text-sm text-[var(--ink-900)] placeholder:text-[var(--ink-400)]" + /> + +
+ {filtered.map((user) => { + const isExpanded = expandedId === user.id; + const busy = pendingId === user.id; + const lockReason = user.isSelf ? "This is your account" : user.isOwner ? "Owner account" : null; + + return ( + +
+
+ +
+
+ {user.name} + {user.isSelf ? ( + + You + + ) : null} + + {user.isActive ? "Active" : "Suspended"} + +
+
{user.email}
+
+ {user.roles.map((role) => ( + + {role.label} + + ))} +
+
+
+ +
+ + + {confirmingId === user.id ? ( + + + + + ) : ( + + )} +
+
+ + {isExpanded ? ( +
+ + + r.label).join(", ")} /> + + + + {user.phone ? ( +
+ +
+ ) : null} +
+
Team roles
+
+ {ASSIGNABLE_ROLES.map((roleName) => { + const hasRole = user.roles.some((role) => role.name === roleName); + return ( + + ); + })} +
+

+ {user.isSelf + ? "You cannot change your own roles." + : "Tap a role to grant or revoke it. Granting Marketer also makes them assignable to leads."} +

+
+ {lockReason ? ( +

+ {user.isSelf + ? "You cannot suspend or delete your own account." + : "Owner accounts are protected. Suspend or delete is disabled for owners."} +

+ ) : null} +
+ ) : null} +
+ ); + })} + {filtered.length === 0 ? ( +

No staff match “{query}”.

+ ) : null} +
+
+ ); +} + +function DetailRow({ + icon: Icon, + label, + value, +}: { + icon: typeof Mail; + label: string; + value: string; +}) { + return ( +
+ + + +
+
{label}
+
{value}
+
+
+ ); +} diff --git a/src/components/marketing/floating-whatsapp.tsx b/src/components/marketing/floating-whatsapp.tsx new file mode 100644 index 0000000..cf3a5b8 --- /dev/null +++ b/src/components/marketing/floating-whatsapp.tsx @@ -0,0 +1,32 @@ +import { buildWhatsAppHref } from "@/modules/team/contact"; + +/** + * Site-wide floating "Chat with us on WhatsApp" bubble for tenant public pages. + * Opens wa.me for the company's WhatsApp number with a prefilled buyer message. + * Renders nothing when the number is missing/invalid. Server-safe (no client JS). + */ +export function FloatingWhatsApp({ + phone, + message = "Hi, I'd like to know more about your properties.", +}: { + phone: string | null | undefined; + message?: string; +}) { + const base = buildWhatsAppHref(phone); + if (!base) return null; + const href = `${base}?text=${encodeURIComponent(message)}`; + + return ( + + + + ); +} diff --git a/src/components/shared/dev-access.ts b/src/components/shared/dev-access.ts index f1c1232..e11214b 100644 --- a/src/components/shared/dev-access.ts +++ b/src/components/shared/dev-access.ts @@ -37,6 +37,11 @@ export const DEV_ACCESS_PRESETS = [ role: "frontdesk", href: DEV_ACCESS_ROUTES.admin, }, + { + label: "Marketer", + role: "marketer", + href: "/admin/marketer", + }, { label: "Superadmin", role: "superadmin", diff --git a/src/lib/auth/dev-users.ts b/src/lib/auth/dev-users.ts index d7d4d78..ff41541 100644 --- a/src/lib/auth/dev-users.ts +++ b/src/lib/auth/dev-users.ts @@ -43,6 +43,13 @@ const devUsers: Record< lastName: "Lawal", roles: ["STAFF"], }, + marketer: { + clerkUserId: "demo-marketer", + email: "marketer@acmerealty.dev", + firstName: "Kunle", + lastName: "Bello", + roles: ["MARKETER"], + }, superadmin: { clerkUserId: "demo-superadmin", email: "owner@estateos.dev", @@ -185,6 +192,16 @@ export async function ensureDevSessionUser(role: DemoSessionRole) { } } + if (role === "marketer") { + // Give the demo marketer a StaffProfile so they are assignable in the Leads + // workflow and their personal dashboard can resolve assigned work. + await prisma.staffProfile.upsert({ + where: { userId: user.id }, + update: { isAssignable: true, title: "Marketer" }, + create: { userId: user.id, isAssignable: true, title: "Marketer" }, + }); + } + return { userId: user.id, companyId, diff --git a/src/lib/auth/roles.ts b/src/lib/auth/roles.ts index e16d7b7..150ca38 100644 --- a/src/lib/auth/roles.ts +++ b/src/lib/auth/roles.ts @@ -6,6 +6,7 @@ export const adminRoles: AppRole[] = [ "ADMIN", "LEGAL", "FINANCE", + "MARKETER", ]; export function hasRequiredRole( diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index d0d74f4..0c795bd 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -25,7 +25,7 @@ export type AppSession = { mode: "clerk" | "demo"; }; -export type DemoSessionRole = "buyer" | "admin" | "superadmin" | "finance" | "frontdesk"; +export type DemoSessionRole = "buyer" | "admin" | "superadmin" | "finance" | "frontdesk" | "marketer"; export const DEV_SESSION_COOKIE = "estateos_dev_role"; export const DEV_SESSION_COMPANY_ID_COOKIE = "estateos_dev_company_id"; export const DEV_SESSION_COMPANY_SLUG_COOKIE = "estateos_dev_company_slug"; @@ -87,6 +87,18 @@ const demoFrontdesk: AppSession = { mode: "demo", }; +const demoMarketer: AppSession = { + userId: "demo-marketer", + dbUserId: "demo-marketer", + email: "marketer@acmerealty.dev", + firstName: "Kunle", + lastName: "Bello", + roles: ["MARKETER"], + companyId: demoCompany.companyId, + companySlug: demoCompany.companySlug, + branchId: demoCompany.branchId, + mode: "demo", +}; const demoSuperAdmin: AppSession = { userId: "demo-superadmin", dbUserId: "demo-superadmin", @@ -112,7 +124,8 @@ function isDemoSessionRole(value: string | undefined | null): value is DemoSessi value === "admin" || value === "superadmin" || value === "finance" || - value === "frontdesk" + value === "frontdesk" || + value === "marketer" ); } @@ -156,6 +169,15 @@ export function buildDemoSession( }; } + if (role === "marketer") { + return { + ...demoMarketer, + companyId: company.companyId, + companySlug: company.companySlug, + branchId: company.branchId, + }; + } + return { ...demoBuyer, companyId: company.companyId, @@ -291,7 +313,7 @@ export async function resolveDemoSessionRole( if (cookieRole === "superadmin") return "superadmin"; // Honor the operator-role presets (accountant / front desk) so each role-based // dashboard can be previewed in dev. Any other value falls back to the owner. - if (cookieRole === "finance" || cookieRole === "frontdesk") return cookieRole; + if (cookieRole === "finance" || cookieRole === "frontdesk" || cookieRole === "marketer") return cookieRole; return "admin"; } diff --git a/src/modules/admin/user-actions.ts b/src/modules/admin/user-actions.ts new file mode 100644 index 0000000..ec4df50 --- /dev/null +++ b/src/modules/admin/user-actions.ts @@ -0,0 +1,111 @@ +"use server"; + +import type { AppRole } from "@prisma/client"; +import { revalidatePath } from "next/cache"; + +import { prisma } from "@/lib/db/prisma"; +import { requireAdminSession } from "@/lib/auth/guards"; +import { rolesForAdminPath } from "@/lib/auth/admin-sections"; +import { ASSIGNABLE_ROLES, OWNER_ROLES, ROLE_LABELS } from "@/modules/admin/users"; + +const PATH = "/admin/users"; + +export type UserActionResult = { ok: boolean; error?: string }; + +async function loadTargetUser(companyId: string, userId: string) { + return prisma.user.findFirst({ + where: { id: userId, companyId }, + select: { id: true, roles: { select: { role: { select: { name: true } } } } }, + }); +} + +function isOwner(roles: Array<{ role: { name: AppRole } }>) { + return roles.some((entry) => OWNER_ROLES.includes(entry.role.name)); +} + +/** Suspend or reactivate a staff account. Owners and self are protected. */ +export async function setUserActiveAction(userId: string, isActive: boolean): Promise { + const tenant = await requireAdminSession(rolesForAdminPath(PATH)); + if (!tenant.companyId) return { ok: false, error: "No company context." }; + if (userId === tenant.userId) return { ok: false, error: "You cannot change your own status." }; + + const target = await loadTargetUser(tenant.companyId, userId); + if (!target) return { ok: false, error: "User not found in your company." }; + if (!isActive && isOwner(target.roles)) return { ok: false, error: "Owner accounts cannot be suspended." }; + + await prisma.user.update({ where: { id: target.id }, data: { isActive } }); + revalidatePath(PATH); + return { ok: true }; +} + +/** Grant or revoke an operator role for a staff account. Owner roles and self are protected. */ +export async function setUserRoleAction( + userId: string, + roleName: AppRole, + grant: boolean, +): Promise { + const tenant = await requireAdminSession(rolesForAdminPath(PATH)); + if (!tenant.companyId) return { ok: false, error: "No company context." }; + if (userId === tenant.userId) return { ok: false, error: "You cannot change your own roles." }; + if (!ASSIGNABLE_ROLES.includes(roleName)) { + return { ok: false, error: "That role cannot be changed here." }; + } + + const target = await prisma.user.findFirst({ + where: { id: userId, companyId: tenant.companyId }, + select: { id: true }, + }); + if (!target) return { ok: false, error: "User not found in your company." }; + + const role = + (await prisma.role.findFirst({ + where: { companyId: tenant.companyId, name: roleName }, + select: { id: true }, + })) ?? + (await prisma.role.create({ + data: { companyId: tenant.companyId, name: roleName, label: ROLE_LABELS[roleName] ?? roleName }, + select: { id: true }, + })); + + if (grant) { + const existing = await prisma.userRole.findFirst({ + where: { userId: target.id, roleId: role.id, companyId: tenant.companyId }, + select: { id: true }, + }); + if (!existing) { + await prisma.userRole.create({ + data: { userId: target.id, roleId: role.id, companyId: tenant.companyId }, + }); + } + // A marketer needs a StaffProfile to be assignable and to see their dashboard. + if (roleName === "MARKETER") { + await prisma.staffProfile.upsert({ + where: { userId: target.id }, + update: { isAssignable: true }, + create: { userId: target.id, isAssignable: true, title: "Marketer" }, + }); + } + } else { + await prisma.userRole.deleteMany({ + where: { userId: target.id, roleId: role.id, companyId: tenant.companyId }, + }); + } + + revalidatePath(PATH); + return { ok: true }; +} + +/** Permanently delete a staff account. Owners and self are protected. */ +export async function deleteUserAction(userId: string): Promise { + const tenant = await requireAdminSession(rolesForAdminPath(PATH)); + if (!tenant.companyId) return { ok: false, error: "No company context." }; + if (userId === tenant.userId) return { ok: false, error: "You cannot delete your own account." }; + + const target = await loadTargetUser(tenant.companyId, userId); + if (!target) return { ok: false, error: "User not found in your company." }; + if (isOwner(target.roles)) return { ok: false, error: "Owner accounts cannot be deleted here." }; + + await prisma.user.delete({ where: { id: target.id } }); + revalidatePath(PATH); + return { ok: true }; +} diff --git a/src/modules/admin/users.ts b/src/modules/admin/users.ts new file mode 100644 index 0000000..195b4e6 --- /dev/null +++ b/src/modules/admin/users.ts @@ -0,0 +1,82 @@ +import type { AppRole } from "@prisma/client"; + +import { prisma } from "@/lib/db/prisma"; +import { featureFlags } from "@/lib/env"; +import type { TenantContext } from "@/lib/tenancy/context"; +import { formatDate } from "@/lib/utils"; + +/** Roles that make a user a company operator (i.e. staff, not a buyer). */ +export const OPERATOR_ROLES: AppRole[] = ["ADMIN", "SUPER_ADMIN", "STAFF", "FINANCE", "LEGAL", "MARKETER"]; +/** Owner roles that are protected from suspend/delete by other admins. */ +export const OWNER_ROLES: AppRole[] = ["ADMIN", "SUPER_ADMIN"]; +/** Operator roles a CEO may grant/revoke from the Users tab (owner roles excluded). */ +export const ASSIGNABLE_ROLES: AppRole[] = ["STAFF", "FINANCE", "LEGAL", "MARKETER"]; +/** Display labels for assignable roles. */ +export const ROLE_LABELS: Record = { + STAFF: "Staff", + FINANCE: "Finance", + LEGAL: "Legal", + MARKETER: "Marketer", +}; + +export type CompanyUserRow = { + id: string; + name: string; + email: string; + phone: string | null; + isActive: boolean; + isOwner: boolean; + isSelf: boolean; + roles: Array<{ name: AppRole; label: string }>; + title: string | null; + staffCode: string | null; + joinedLabel: string; +}; + +/** + * All operator (staff) accounts for the owner's company, with their login roles + * and active status. Buyers are excluded. Degrades to an empty list without a DB. + */ +export async function getCompanyUsers(context: TenantContext): Promise { + if (!featureFlags.hasDatabase || !context.companyId) { + return []; + } + + const users = await prisma.user.findMany({ + where: { companyId: context.companyId }, + orderBy: { createdAt: "asc" }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + isActive: true, + createdAt: true, + roles: { select: { role: { select: { name: true, label: true } } } }, + staffProfile: { select: { title: true, staffCode: true } }, + }, + }); + + return users + .map((user) => { + const roles = user.roles.map((entry) => ({ + name: entry.role.name, + label: entry.role.label, + })); + return { + id: user.id, + name: `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email, + email: user.email, + phone: user.phone, + isActive: user.isActive, + isOwner: roles.some((role) => OWNER_ROLES.includes(role.name)), + isSelf: context.userId === user.id, + roles, + title: user.staffProfile?.title ?? null, + staffCode: user.staffProfile?.staffCode ?? null, + joinedLabel: formatDate(user.createdAt, "PPP"), + }; + }) + .filter((user) => user.roles.some((role) => OPERATOR_ROLES.includes(role.name))); +} diff --git a/src/modules/marketer/dashboard.ts b/src/modules/marketer/dashboard.ts new file mode 100644 index 0000000..3ed7411 --- /dev/null +++ b/src/modules/marketer/dashboard.ts @@ -0,0 +1,115 @@ +import { prisma } from "@/lib/db/prisma"; +import { featureFlags } from "@/lib/env"; +import type { TenantContext } from "@/lib/tenancy/context"; +import { formatDate } from "@/lib/utils"; + +export type MarketerLead = { + id: string; + name: string; + phone: string | null; + status: string; + propertyTitle: string; + when: string; +}; + +export type MarketerInspection = { + id: string; + name: string; + phone: string | null; + status: string; + propertyTitle: string; + when: string; +}; + +export type MarketerDashboard = { + hasProfile: boolean; + stats: { assignedLeads: number; openLeads: number; upcomingInspections: number }; + leads: MarketerLead[]; + inspections: MarketerInspection[]; +}; + +const CLOSED_LEAD_STATUSES = new Set(["CONVERTED", "CLOSED", "LOST"]); + +const EMPTY: MarketerDashboard = { + hasProfile: false, + stats: { assignedLeads: 0, openLeads: 0, upcomingInspections: 0 }, + leads: [], + inspections: [], +}; + +/** + * Personal cockpit for a logged-in marketer: the leads and inspections assigned + * to them, scoped by their StaffProfile. Degrades to an empty (no-profile) state + * when there is no DB, company, or linked staff profile. + */ +export async function getMarketerDashboard(context: TenantContext): Promise { + if (!featureFlags.hasDatabase || !context.companyId || !context.userId) { + return EMPTY; + } + + const staff = await prisma.staffProfile.findFirst({ + where: { + user: { is: { OR: [{ id: context.userId }, { clerkUserId: context.userId }] } }, + }, + select: { id: true }, + }); + if (!staff) { + return EMPTY; + } + + const [leads, inspections] = await Promise.all([ + prisma.inquiry.findMany({ + where: { companyId: context.companyId, assignedStaffId: staff.id }, + orderBy: { createdAt: "desc" }, + take: 12, + select: { + id: true, + fullName: true, + phone: true, + status: true, + createdAt: true, + property: { select: { title: true } }, + }, + }), + prisma.inspectionBooking.findMany({ + where: { companyId: context.companyId, assignedStaffId: staff.id }, + orderBy: { scheduledFor: "asc" }, + take: 12, + select: { + id: true, + fullName: true, + phone: true, + status: true, + scheduledFor: true, + property: { select: { title: true } }, + }, + }), + ]); + + const now = Date.now(); + const openLeads = leads.filter((lead) => !CLOSED_LEAD_STATUSES.has(lead.status)).length; + const upcomingInspections = inspections.filter( + (item) => new Date(item.scheduledFor).getTime() >= now, + ).length; + + return { + hasProfile: true, + stats: { assignedLeads: leads.length, openLeads, upcomingInspections }, + leads: leads.map((lead) => ({ + id: lead.id, + name: lead.fullName, + phone: lead.phone, + status: lead.status.replaceAll("_", " "), + propertyTitle: lead.property?.title ?? "General inquiry", + when: formatDate(lead.createdAt, "PPP"), + })), + inspections: inspections.map((item) => ({ + id: item.id, + name: item.fullName, + phone: item.phone, + status: item.status.replaceAll("_", " "), + propertyTitle: item.property?.title ?? "Property", + when: formatDate(item.scheduledFor, "PPP"), + })), + }; +} From 9fd777bfd581f0bd8ea3e613b048ff631fed4b9b Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Mon, 6 Jul 2026 20:28:52 +0100 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20platform=20modernization=20?= =?UTF-8?q?=E2=80=94=20CSP=20enforcement,=20UI=20system,=20PWA,=20realtime?= =?UTF-8?q?,=20payments=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security - Enforce nonce-based CSP ('strict-dynamic', no unsafe-eval in prod) via proxy; hash-pinned theme bootstrap; ESTATEOS_CSP_REPORT_ONLY escape hatch. - Paystack webhook idempotency: unique WebhookEvent index (migration 0049), atomic in-transaction dedup, first-success gating so duplicate success events can never double-decrement balances; never downgrade SUCCESS. UI system - Grouped collapsible sidebar nav (admin clusters + buyer portal groups), shared between desktop sidebar and mobile drawer. - New primitives: Dialog (portal, focus trap), Select (styled native), Tabs, DropdownMenu, DataTable (TanStack: sort/search/pagination). - 9 screens on DataTable (payments, invoices x2, contracts x2, marketers, audit logs, documents, transactions); all 33 raw {transactions.map((tx) => ( @@ -92,7 +92,7 @@ export function GenerateContractForm({ {tx.label} ))} - +
- - )} - {row.status === "ACTIVE" && ( - Awaiting acceptance - )} - {row.status === "COMPLETED" && ( - - Accepted {row.acceptedByIp ? `· ${row.acceptedByIp}` : ""} - - )} - - - ); +function toGeneratedContractRow(row: GeneratedContractRow) { + return { + id: row.id, + contractNumber: row.contractNumber, + buyer: + [row.buyer.firstName, row.buyer.lastName].filter(Boolean).join(" ") || + row.buyer.email || + "Buyer", + property: row.property?.title ?? "Unlinked", + status: row.status, + versionLabel: `v${row.version} - ${formatDate(row.generatedAt, "PP")}`, + templateNote: row.templateVersion ? `Template v${row.templateVersion}` : null, + documentId: row.documentId, + transactionId: row.transactionId, + templateId: row.templateId, + canRegenerate: row.status !== "REGENERATED", + }; } // ─── Upload form for a transaction without a contract ───────────────────────── @@ -99,10 +84,9 @@ function UploadContractForm({ transactions }: { transactions: TransactionWithout - +

All contracts

- {contracts.length === 0 ? ( -
- No contracts uploaded yet. Use the form above to link a contract PDF to a transaction. -
- ) : ( -
- - - - {["Reference", "Buyer", "Property", "File", "Status", "Date", ""].map((h) => ( - - ))} - - - - {contracts.map((row) => ( - - ))} - -
- {h} -
-
- )} +
); diff --git a/src/app/(admin)/admin/documents/page.tsx b/src/app/(admin)/admin/documents/page.tsx index 6f09378..4e4e6b0 100644 --- a/src/app/(admin)/admin/documents/page.tsx +++ b/src/app/(admin)/admin/documents/page.tsx @@ -25,6 +25,7 @@ export default async function AdminDocumentsPage() { title="Document register" columns={["File", "Owner", "Type", "Status"]} rows={rows} + searchPlaceholder="Search documents…" /> diff --git a/src/app/(admin)/admin/marketers/page.tsx b/src/app/(admin)/admin/marketers/page.tsx index 7095a5f..f6ee52d 100644 --- a/src/app/(admin)/admin/marketers/page.tsx +++ b/src/app/(admin)/admin/marketers/page.tsx @@ -8,6 +8,8 @@ import { Input } from "@/components/ui/input"; import { requireAdminSession } from "@/lib/auth/guards"; import { formatCurrency, formatDate } from "@/lib/utils"; import { getAdminMarketerPerformanceDashboard } from "@/modules/team/performance"; +import { MarketerRankingTable } from "@/components/admin/marketer-ranking-table"; +import { Select } from "@/components/ui/select"; const SORT_OPTIONS = [ ["score", "Score"], @@ -93,28 +95,26 @@ export default async function AdminMarketersPage({
- - {SORT_OPTIONS.map(([value, label]) => ( ))} - +
@@ -215,69 +215,29 @@ export default async function AdminMarketersPage({ description={`${dashboard.rows.length} marketer${dashboard.rows.length === 1 ? "" : "s"} in this tenant workspace.`} className="px-0 py-0" > - {dashboard.rows.length > 0 ? ( -
- - - - {["Rank", "Marketer", "Score", "Stars", "Weekly revenue", "Monthly revenue", "Lifetime revenue", "Commission earned", "Deals", "Payments", "Inspections", "Reservations", "Trend"].map((column) => ( - - ))} - - - - {dashboard.rows.map((row) => ( - - - - - - - - - - - - - - - - ))} - -
{column}
#{row.rank} -
- -
-
{row.fullName}
-
{row.title}
-
- {!row.isActive ? Inactive : null} - {!row.isPublished ? Private : null} -
-
-
-
{row.score}{row.starRating.toFixed(1)}{formatCurrency(row.revenue.weekly)}{formatCurrency(row.revenue.monthly)}{formatCurrency(row.revenue.lifetime)} -
{formatCurrency(row.commissionTotal)}
- {row.commissionPending > 0 && ( -
- {formatCurrency(row.commissionPending)} pending -
- )} -
{row.metrics.completedDeals}{row.metrics.successfulPayments}{row.metrics.inspectionsHandled}{row.metrics.reservations}{trendLabel(row.trend)}
-
- ) : ( -
- -
- )} + ({ + id: row.id, + rank: row.rank, + fullName: row.fullName, + title: row.title, + avatarUrl: row.avatarUrl, + isActive: row.isActive, + isPublished: row.isPublished, + score: row.score, + starRating: row.starRating, + revenueWeekly: row.revenue.weekly, + revenueMonthly: row.revenue.monthly, + revenueLifetime: row.revenue.lifetime, + commissionTotal: row.commissionTotal, + commissionPending: row.commissionPending, + completedDeals: row.metrics.completedDeals, + successfulPayments: row.metrics.successfulPayments, + inspectionsHandled: row.metrics.inspectionsHandled, + reservations: row.metrics.reservations, + trendLabel: trendLabel(row.trend), + }))} + /> ); diff --git a/src/app/(admin)/admin/payments/page.tsx b/src/app/(admin)/admin/payments/page.tsx index f473c61..8408556 100644 --- a/src/app/(admin)/admin/payments/page.tsx +++ b/src/app/(admin)/admin/payments/page.tsx @@ -1,8 +1,7 @@ -import Link from "next/link"; - import { DashboardShell } from "@/components/portal/dashboard-shell"; import { StatCard } from "@/components/admin/admin-ui"; import { PaymentRequestManagement } from "@/components/admin/payment-request-management"; +import { PaymentsRegisterTable } from "@/components/admin/payments-register-table"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { requireAdminSession } from "@/lib/auth/guards"; @@ -35,39 +34,7 @@ export default async function AdminPaymentsPage() {

Deal payment register

-
- - - - {["Reference", "Buyer", "Marketer", "Payment state", "Stage", "Outstanding", "Next due", "Receipt"].map((column) => ( - - ))} - - - - {monitoring.rows.map((row) => ( - - - - - - - - - - - ))} - -
{column}
{row.reference}{row.buyer}{row.marketer}{row.paymentStatus}{row.stage}{row.outstandingBalance}{row.nextDueAt} - {row.receiptId ? ( - - Download - - ) : ( - "Pending" - )} -
-
+ Fee type - +
@@ -92,16 +92,15 @@ export default async function CommissionRulesPage() { - +

Leave as "All" for a company-wide default.

diff --git a/src/app/(admin)/admin/testimonials/[testimonialId]/page.tsx b/src/app/(admin)/admin/testimonials/[testimonialId]/page.tsx index bdcbf01..fb34ca2 100644 --- a/src/app/(admin)/admin/testimonials/[testimonialId]/page.tsx +++ b/src/app/(admin)/admin/testimonials/[testimonialId]/page.tsx @@ -6,6 +6,7 @@ import { DashboardShell } from "@/components/portal/dashboard-shell"; import { Card } from "@/components/ui/card"; import { requireAdminSession } from "@/lib/auth/guards"; import { getAdminTestimonialDetail } from "@/modules/testimonials/service"; +import { OptimizedImage } from "@/components/media/optimized-image"; export default async function AdminTestimonialDetailPage({ params, @@ -37,8 +38,7 @@ export default async function AdminTestimonialDetailPage({
{testimonial.avatarUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${testimonial.displayName} + ) : ( testimonial.displayName.slice(0, 2).toUpperCase() )} diff --git a/src/app/(admin)/admin/testimonials/page.tsx b/src/app/(admin)/admin/testimonials/page.tsx index 7ab6d90..f6510ad 100644 --- a/src/app/(admin)/admin/testimonials/page.tsx +++ b/src/app/(admin)/admin/testimonials/page.tsx @@ -4,6 +4,7 @@ import { DashboardShell } from "@/components/portal/dashboard-shell"; import { Card } from "@/components/ui/card"; import { requireAdminSession } from "@/lib/auth/guards"; import { getAdminTestimonials, testimonialStatusLabels } from "@/modules/testimonials/service"; +import { Select } from "@/components/ui/select"; export default async function AdminTestimonialsPage({ searchParams, @@ -34,22 +35,22 @@ export default async function AdminTestimonialsPage({ placeholder="Search testimonials" className="admin-focus rounded-[var(--radius-md)] border border-[var(--line)] px-3 py-2 text-sm" /> - {Object.entries(testimonialStatusLabels).map(([value, label]) => ( ))} - - + + diff --git a/src/app/(admin)/admin/transactions/page.tsx b/src/app/(admin)/admin/transactions/page.tsx index f7e56a7..ffc6b08 100644 --- a/src/app/(admin)/admin/transactions/page.tsx +++ b/src/app/(admin)/admin/transactions/page.tsx @@ -74,6 +74,7 @@ export default async function AdminTransactionsPage() { title="Transactions register" columns={["Reference", "Property", "Buyer", "Marketer", "Stage", "Balance"]} rows={rows} + searchPlaceholder="Search transactions…" />
diff --git a/src/app/(marketing)/properties/[slug]/page.tsx b/src/app/(marketing)/properties/[slug]/page.tsx index ba997df..32fb39d 100644 --- a/src/app/(marketing)/properties/[slug]/page.tsx +++ b/src/app/(marketing)/properties/[slug]/page.tsx @@ -6,6 +6,7 @@ import { InspectionForm } from "@/components/marketing/inspection-form"; import { MapSection } from "@/components/marketing/map-section"; import { NearbyAmenitiesSection } from "@/components/marketing/nearby-amenities-section"; import { PropertyActions } from "@/components/marketing/property-actions"; +import { WhatsAppButton } from "@/components/shared/whatsapp-button"; import { PropertyCountdown } from "@/components/marketing/property-countdown"; import { Container } from "@/components/shared/container"; import { EmptyState } from "@/components/shared/empty-state"; @@ -18,6 +19,7 @@ import { getPublicPropertyDetailBySlug, } from "@/modules/properties/queries"; import { getVisibleTeamMembers } from "@/modules/team/queries"; +import { getTenantAdminSettings } from "@/modules/settings/service"; function formatLandOptionLabel(option: { label?: string; @@ -51,9 +53,10 @@ export default async function PropertyDetailPage({ }) { const { slug } = await params; const tenant = await getPublicPropertiesContext(); - const [property, marketers] = await Promise.all([ + const [property, marketers, settings] = await Promise.all([ getPublicPropertyDetailBySlug(slug, tenant), getVisibleTeamMembers(tenant), + getTenantAdminSettings(tenant), ]); const isLand = property.type.toUpperCase() === "LAND"; @@ -188,6 +191,14 @@ export default async function PropertyDetailPage({ kind: plan.kind, }))} /> + {settings.whatsappNumber ? ( + + ) : null} {property.brochureUrl ? (