cancado-crm
v0.0.1
Published
CRM components for React — agenda, data tables, dashboards, pipelines and record pages, styled after iOS 26
Maintainers
Readme
cancado-crm
CRM components for React — agenda, data tables, dashboards, pipelines and record pages — styled after iOS 26.
Built to be vertical-neutral: nothing in the API names a domain, so the same components serve a dental clinic, a law firm, a physiotherapist or a shop. A "record" is a patient, a client, a matter or a customer; a calendar event is a consultation, a hearing, a fitting or a delivery slot.
pnpm add cancado-crmQuick start
import { AppShell, DataTable, FilterBar, useDataTable, Tag } from 'cancado-crm';
import type { Column } from 'cancado-crm';
const columns: Column<Client>[] = [
{ id: 'name', header: 'Client', accessor: (r) => r.name, width: 'minmax(200px, 1.4fr)' },
{ id: 'city', header: 'City', accessor: (r) => r.city, hideBelow: 900 },
{
id: 'status',
header: 'Status',
accessor: (r) => r.status,
cell: (r) => <Tag tone="success" dot>{r.status}</Tag>,
},
];
function Clients({ clients }: { clients: Client[] }) {
const table = useDataTable({ rows: clients, columns, pageSize: 25 });
return (
<DataTable
rows={table.rows}
columns={columns}
getRowId={(r) => r.id}
sort={table.query.sort}
onSortChange={table.toggleSort}
page={{ ...table.query.page, count: table.pageCount, total: table.total }}
onPageChange={table.setPageIndex}
toolbar={
<FilterBar
search={table.query.search}
onSearchChange={table.setSearch}
filters={[{ columnId: 'status', label: 'Status', options: table.optionsFor('status') }]}
applied={table.query.filters}
onFilterChange={table.setFilter}
onClear={table.clearFilters}
/>
}
/>
);
}Components
| Component | What it is |
| --- | --- |
| AppShell | Sidebar nav + top bar + content. Drawer under 900px, bottom tab bar under 640px. |
| DataTable | Sortable, selectable, paginated list. Collapses to labelled cards on a phone. |
| FilterBar | Search, dropdown filters, removable filter chips, page actions. |
| Agenda | Month / week / day scheduling, with optional per-resource columns. |
| StatCard · LineChart · BarChart · DonutChart · Sparkline | Dashboard tiles and charts. |
| Pipeline | Drag-and-drop stage board — funnel, case phases, order flow. |
| RecordDetail · RecordHeader · RecordTabs · Section · DefinitionList | Record pages. |
| ActivityTimeline | The history thread: calls, notes, payments, status changes. |
| StockCard · StockGrid · StockSummary · StockTake · MovementList · QuantityStepper | Stock control — see below. |
| Dashboard · Widget · WidgetPicker | A dashboard the user arranges — see below. |
Plus the primitives: Button, IconButton, Card, Tag, Avatar,
AvatarGroup, TextInput, TextArea, Select, SearchInput, Segmented,
Stack, Toolbar, Heading, Text, EmptyState, Skeleton.
Architecture
Four layers, each importable on its own.
| Layer | Path | What it is |
| --- | --- | --- |
| core/ | cancado-crm/core | Pure TypeScript: table pipeline, chart geometry, agenda maths, pipeline moves, formatters. No React, no DOM, no CSS. |
| hooks/ | cancado-crm/hooks | React bindings: useDataTable, useAgenda, useMediaQuery, useDebouncedValue, useDisclosure, useDismiss. |
| primitives/ | cancado-crm/primitives | The atoms. |
| components/ | cancado-crm/DataTable, /Agenda, … | The CRM surfaces. All controlled. |
Behaviour lives in core as pure functions, so a 50-column table's sorting and
filtering is testable without rendering one:
import { runQuery, emptyQuery } from 'cancado-crm/core';
const result = runQuery(rows, columns, {
...emptyQuery(25),
search: 'jose', // accent-insensitive: matches "José"
sort: { columnId: 'amount', direction: 'desc' },
});useDataTable is a thin binding over it. Pass manual to take over
search/sort/paging server-side while keeping the same toolbar and header.
Configurable dashboard
Dashboard lets the user arrange their own view: drag tiles to reorder, resize
them, and choose which widgets appear at all.
import { Dashboard } from 'cancado-crm';
import type { WidgetDefinition, DashboardLayout } from 'cancado-crm';
const widgets: WidgetDefinition[] = [
{
id: 'revenue',
title: 'Revenue',
description: 'Monthly revenue and how it is trending.',
icon: '₿',
category: 'Metrics',
defaultSize: 'sm',
render: ({ detail }) =>
detail === 'rich' ? <LineChart data={months} /> : <StatCard … />,
},
];
<Dashboard
widgets={widgets}
layout={saved} // from your backend or localStorage
onLayoutChange={persist} // serialize and store it
/>Resizing changes the detail level, not just the footprint. Each widget's
render receives { size, detail } and decides what to show:
| Size | Grid | Detail | A KPI widget shows |
| --- | --- | --- | --- |
| sm | 1×1 | compact | the number and its trend |
| md | 2×1 | normal | …plus a sparkline |
| lg | 2×2 | rich | …plus the full chart with axes |
| xl | 4×2 | rich | the same, full width |
Declare sizes: ['md', 'lg'] to restrict what a widget allows — the − / +
controls clamp to it.
Layouts survive deploys. reconcileLayout runs on every change: widgets the
user arranged keep their place and size, types you removed from the catalogue
are dropped, and a size a widget no longer supports snaps to one it does.
Nothing is auto-added — a dashboard growing new tiles by itself after a deploy
is worse than one missing an option the user can add.
Drive it from your own chrome with useDashboard() and the controller prop,
or set hideToolbar and supply your own arrange button.
Stock control
Inventory for any vertical — dental materials, legal supplies, retail SKUs,
restaurant stock. Counts are plain numbers in whatever unit the item
declares, so discrete boxes and measured millilitres share one model.
import { useStock, StockSummary, StockGrid, StockCard } from 'cancado-crm';
const stock = useStock({ items, movements }); // movements optional
<StockSummary items={stock.items} currency="BRL" />
<StockGrid
items={stock.items}
renderItem={(item) => (
<StockCard
item={item}
editable
onQuantityChange={(q) => stock.setQuantity(item.id, q)}
onReorder={placeOrder}
/>
)}
/>Two ways to track the current count. Pass only items and their
quantity is the truth — the simple case most apps start with. Pass
movements too and counts derive from the ledger, which is what you want once
someone asks where a box of gloves went. Same components either way.
Six states, ordered by what to act on first: out → expired → low →
expiring → over → ok. You cannot use what you do not have, then you must
discard what has expired, then reorder what is running low.
The level bar marks the reorder point on the same scale as the fill. A bare percentage tells you nothing actionable; a bar you can see has dropped below its threshold does.
StockTake freezes expected counts when the session starts, so a movement
someone else records mid-count can't silently change what you're counting
against. Finishing emits adjustment movements for the variances only —
counting an item and finding it correct shouldn't pollute the ledger.
stockColumns() is a DataTable preset rather than a bespoke table, so
sorting, filtering, selection and the mobile card layout all come from the
component you already have.
Pure helpers live in cancado-crm/core: stockStatus, summarize,
reorderList, quantityFromMovements, quantityAt, countVariances,
movementsFromCount.
Liquid Glass
Controls follow iOS 26's material rather than the flat-filled convention: they are translucent capsules that float above content, not solid blocks of colour. A primary action signals itself through a tinted glass fill and an accent-coloured label — not by being a saturated rectangle.
Every control shares one material, built from four parts:
| Part | Token | Role |
| --- | --- | --- |
| Fill | --glass-fill | Low-opacity background; content shows through |
| Edge | --glass-edge | Bright hairline border |
| Specular | --glass-specular | Highlight along the top edge — what makes it read as glass rather than flat translucency |
| Lift | --glass-shadow | Separation from the surface below |
Button variants differ only in how much accent bleeds through:
<Button variant="primary">Book appointment</Button> {/* accent-tinted glass */}
<Button variant="secondary">Message</Button> {/* neutral glass */}
<Button variant="ghost">Today</Button> {/* capsule forms on hover */}
<Button variant="outline">Export</Button> {/* defined edge, for busy backgrounds */}
<Button variant="danger">Delete</Button> {/* red-tinted glass */}
<Button variant="solid">Confirm</Button> {/* the deliberate exception: opaque fill */}Two details worth knowing:
- Label contrast is derived, not hand-set. The raw accent on light glass
sits near 3.5:1, under the text minimum, so
--color-accent-labeldarkens it withcolor-mix. Swap--color-accentto any hue and the label stays legible. IconButtonhas no blur at rest. A table can hold dozens of them, and dozens of live blur layers is a real scroll cost; the capsule forms on interaction, which is when the affordance matters. Passfloatingto keep it visible over imagery or a chart.
Both fall back cleanly: @supports rules swap in an opaque surface where
backdrop-filter or color-mix is unsupported.
Theming
The whole kit is CSS custom properties (src/styles/tokens.css), following the
iOS 26 system palette.
Light and dark work with no setup — light is the default and the OS
preference picks dark. Set data-theme="light" | "dark" on <html> to pin it:
<html data-theme="dark" data-accent="indigo">Accent — six built-ins (blue, indigo, purple, pink, orange,
green) via data-accent, or override directly:
:root {
--color-accent: #b8336a;
--radius-lg: 20px;
}Status and chart colours are separate token families, so re-branding the accent doesn't disturb "overdue" red or a chart's categorical series.
Responsive
CRMs get used at a desk and on a phone between appointments, so every layout has a compact form:
AppShell→ under 900px the sidebar and its toggle disappear entirely and navigation becomes a native-style bottom tab bar: the first four destinations plus a More button opening a glass sheet with the rest. There is no hamburger and no side drawer on a phone. Mark itemsprimaryto choose the four yourself; otherwise it takes the first four in order.DataTable→ rows become labelled cards;hideBelowdrops columns by widthAgenda→ week view narrows from 7 columns to 3DashboardGrid→auto-fittiles, no breakpoints needed
Styles
Component subpaths inject their own CSS automatically. For everything at once:
import 'cancado-crm/styles'; // all components
import 'cancado-crm/tokens'; // just the variablesDevelopment
pnpm install
pnpm storybook # full CRM demo app + component stories
pnpm typecheck
pnpm buildLicense
MIT
