Nasaq

ClientPortal

What a customer sees of their project. A progress ring and hours against the budget, a read-only board, requests with a form, hours per week, sent invoices and an activity feed.

PreviewOpen ↗

Code

import { ClientPortal } from "@fadymondy/nasaq/web";export const Portal = (p: { data: PortalData }) => (  <ClientPortal    project={{ name: "New booking platform", client: "Tamkeen Co." }}    tasks={p.data.tasks}    requests={p.data.requests}    weeks={p.data.weeks}    budgetHours={240}    invoices={p.data.invoices}    currency="SAR"    activity={p.data.activity}    openIssues={4}    onRequest={(r) => api.createRequest(r)}  />);

CRM · beta

Live examples and controls: ClientPortal in the lab.

Install

npx shadcn@latest add https://docs.nasaqui.com/r/client-portal.json

The page you share with a customer. It shows aggregates and sent documents only: how many tasks are done, hours used against the budget, hours per week, how many issues are open and how many requests wait. It never lists internal issues, single time entries, internal notes, or an invoice that is still a draft.

Six tabs: Overview (progress ring, hour tiles, weekly bars), Board (four read-only columns), Requests (a form and the list), Time (totals and a table by week), Invoices (the InvoiceList with pay and download) and Activity (a Timeline).

When to use

  • A customer, client or stakeholder needs to follow a project without an account in your tool.

When not to use

  • Your own team's view of a project: use ProjectView.
  • Anything with internal comments or cost rates: keep it out of the props.

Import

import { ClientPortal } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"

Quick start

import { ClientPortal } from "@fadymondy/nasaq/web";

export const Portal = (p: { data: PortalData }) => (
  <ClientPortal
    project={{ name: "New booking platform", client: "Tamkeen Co." }}
    tasks={p.data.tasks}
    requests={p.data.requests}
    weeks={p.data.weeks}
    budgetHours={240}
    invoices={p.data.invoices}
    currency="SAR"
    activity={p.data.activity}
    openIssues={4}
    onRequest={(r) => api.createRequest(r)}
  />
);

Anatomy

ClientPortal                 data-slot="client-portal"
├─ header                    project, client, summary, due date (replaceable with `header`)
└─ Tabs
   ├─ Overview               Ring (data-slot="portal-ring"), StatGrid, budget Progress, weekly bars
   ├─ Board                  four sections of read-only cards  data-slot="portal-task"
   ├─ Requests               form (data-slot="portal-request-form") and list  data-slot="portal-request"
   ├─ Time                   StatGrid, budget Progress, DataTable by week
   ├─ Invoices               InvoiceList (drafts removed)
   └─ Activity               Timeline

API

PropTypeDefaultDescription
projectPortalProjectrequired{ name, client?, summary?, due? }.
tasksPortalTask[]required{ id, title, status: "todo" | "doing" | "review" | "done", assignee?, due? }.
openIssues?number0Only the count.
requestsPortalRequest[]required{ id, title, description?, status, createdAt, by?, reply? }.
weeksPortalWeek[]required{ week, hours }. The total and the chart come from these.
budgetHours?number00 means no budget: the budget bar is hidden.
invoicesInvoiceSummary[]requiredDrafts are removed before they reach the list.
currencystringrequiredISO 4217.
activityPortalActivity[]required{ id, actor?, title, description?, at }.
onRequest?({ title, description }) => void | Promise<void>noneShows the request form. Reject to show the message.
onOpenInvoice? / onPayInvoice? / onDownloadInvoice?nonePassed to InvoiceList.
taskActions? / requestActions? / activityActions?(item) => ContextMenuAction[]noneMenus on context-click, long-press, Shift+F10 or the Menu key. Items with actions become focusable.
weekActions?(week) => DataTableRowAction[]noneRow menu and context menu of the weekly table.
tab? / defaultTab? / onTabChange?ClientPortalTab"overview"
chartWeeks?number8Weeks drawn on Overview.
header?ReactNodeproject headerFor a logo or sign-out.
locale? / labels?provider localeEvery string in Arabic and English.

Logic (exported, pure)

portalProgress(tasks), portalBudget(budget, used) (warns from 80 percent, danger when over), portalTotalHours(weeks), portalWeeksNewestFirst(weeks), portalPendingRequests(requests), portalVisibleInvoices(invoices), portalRing(percent, radius). Use portalVisibleInvoices on your server too, so drafts are never sent to the browser.

Examples

<ClientPortal
  {...data}
  taskActions={(task) => [{ id: "ask", label: "Ask a question", onSelect: () => ask(task.id) }]}
/>

Control the tab from the URL

<ClientPortal {...data} tab={params.tab as ClientPortalTab} onTabChange={(tab) => router.replace(`?tab=${tab}`)} />

Accessibility

  • The ring is an image with the percent in its name; the weekly bars are one image named "Hours in the last 8 weeks" and every number is also in the Time table.
  • Board columns are named sections and lists; cards with a menu take focus so the keyboard opens it.
  • Status is always a word with an icon, never colour alone. Errors on the form are text under the field.

RTL & i18n

  • All text has Arabic and English defaults, and dates and numbers follow the locale.
  • Layout uses logical spacing; the tab bar scrolls sideways on narrow screens.

Styling & tokens

Cards, tabs, status and progress use the standard tokens; the ring uses the primary token on a muted track. The board is one column on phones, two from sm, four from xl.

Do / Don't

  • Do build the props on the server from aggregates, and filter drafts there.
  • Do keep replies to requests short and specific.
  • Don't pass single time entries, internal comments or rates.

Lab

https://docs.nasaqui.com/?path=/docs/components-crm-client-portal--docs

On this page