Full design · Apps
CRM
A sales CRM for small teams: a pipeline board you drag deals across with value totals per stage, deal pages with activity, notes and tasks, shared contacts and companies with CSV import and export, a task list,...
Live preview. Click around, it works.
About this template
A sales CRM for small teams: a pipeline board you drag deals across with value totals per stage, deal pages with activity, notes and tasks, shared contacts and companies with CSV import and export, a task list, reports on the pipeline, win rate and a weighted forecast, and settings for stages, team roles and custom fields. Admin and sales rep views, with the database rules to match.
What the agents are asked
The first message sent when you use this template. You can edit the plan before anything is built.
Adapt this CRM starting point to my business: keep the six screens (Pipeline, Deal page, People, Tasks, Reports, Settings), the admin and sales rep roles, drag and drop with the keyboard alternative, and CSV import and export. Change the brand, the pipeline stages and their probabilities, the custom fields, the currency if I am not in Australia, and the sample deals, contacts and companies so they look like my kind of sales. Keep the sample data clearly marked and removable in one step. Where I want real accounts or a shared database for the team, wire in sign in and the tables in schema.sql as the README describes, keeping row level security on every table and every key on the server.
A sales CRM for small teams. A pipeline board you drag deals across, a page per deal with its activity, notes, tasks and contacts, shared contacts and companies with CSV import and export, a task list, reports drawn as SVG charts, and settings for stages, the team and custom fields. Two roles: admins see everything; sales reps see and edit their own deals.
"Wayfold" and "Fernwork Studio", the Melbourne workplace design studio in the sample data, are fictional. Every company, person, email address (.example domains) and phone number (from the range the Australian regulator reserves for fiction) in the sample is invented. This template is not modelled on any real product.
- Plain HTML, CSS and one ES module. No dependencies, no build step. Fonts: Hubot Sans (headings), Mona Sans (text and large figures) and IBM Plex Mono (money in lists), with system fallbacks. See "Art direction" below.
- Light and dark themes follow the device; the moon or sun button, or Settings > Appearance, picks one and remembers it. Every colour is a token at the top of
styles.css. - Phone first: bottom tab bar under 900px, sidebar above. Tables turn into cards under 680px; the board scrolls sideways with snap points.
- Money is in AUD, stored as integer cents.
Art direction
The benchmark is a modern sales tool, not a marketing site: the product UI is the visual, the palette stays neutral, and the realism comes from believable data and real faces.
- Palette. Three solid colours, all tokens at the top of
styles.css: graphite#131416(ink, and the dark ground), paper#f3f4f3(a cool neutral, not cream) and signal chartreuse#c6f04d. Chartreuse is only ever a fill with graphite on it (primary buttons, the active nav rail, Won); as text on a light ground it becomes olive#4a6b00for contrast. Open pipeline stages step from pale grey to graphite, so a deal visibly darkens as it progresses and turns chartreuse when won. One status red marks overdue dates. No gradients, no glow. - Type. Hubot Sans at a slightly wide setting, 650 weight and tight tracking, for page titles and the welcome headline. Mona Sans for the interface at 13.5px, and for large figures (Hubot slashes its zeros, which reads as code on money). IBM Plex Mono for amounts inside cards and tables, emails and phone numbers. Scale ratio about 1.3.
- Photography. Eight images from
media.json, one shoot: late morning window light from the left, neutral 5200K, an 85mm portrait lens (35mm for the wide), natural skin. Portraits of the four team members and three key contacts are the avatars everywhere (cards, tables, timeline, the sidebar, reports, settings) and appear large on the deal page (the lead contact) and in contact records. The studio photograph is the welcome screen, the workspace card in Settings, and every whole-screen empty state. People without a photo get initials; companies get a letter mark, because the sample companies have no logos and inventing some would mislead. - Layout. A dense, quiet shell (sidebar, hairline borders, 7 to 12px radii) with one asymmetric move per screen: the welcome screen splits 5 to 7 with the photograph sticky on the right; Reports leads with one oversized forecast figure; deal pages keep a sticky side column with the lead contact's portrait; records slide in from the right.
- Motion. One job each, all off under
prefers-reduced-motion: - first load: the shell and first screen settle in a short stagger, once;
- route changes:
document.startViewTransitioncross fade, feature detected, skipped when the tab is hidden; - scroll: report and settings cards rise in with
animation-timeline: view()inside@supports, with an IntersectionObserver fallback at a 0.2 threshold; - photos: a
clip-path: inset()reveal with a slight settle, once per photo, then faded in on load over a solid fallback colour (and a shimmer while loading); - the orchestrated moment: when a deal changes stage (drag, arrow buttons), every card glides from its old position to its new one (FLIP with the Web Animations API), the moved card lifts while it travels, both column totals count to their new values, and a deal landing in Won gets its chartreuse rail and a brief glow;
- Reports figures count up once per visit; skeleton bars hold the charts and the first screen while loading.
Photo list
Generated with node tools/generate-imagery.mjs --template crm from media.json and served from /template-media/crm/<name>.webp. Project creation makes these paths absolute.
| Name | Size | Used for |
|---|---|---|
team-maren | 700 x 700 | Maren Oakes, head of sales (admin) |
team-tom | 700 x 700 | Tom Reyes, account executive (the default sales rep) |
team-aisha | 700 x 700 | Aisha Karim, account executive |
team-jonah | 700 x 700 | Jonah Pell, business development |
contact-priya | 700 x 700 | Priya Venkat, Saltbush Analytics |
contact-alistair | 700 x 700 | Alistair Hollis, Fenwick Hollis Partners |
contact-carla | 700 x 700 | Carla Mendes, Quayside Hotel Group |
studio | 1600 x 1211 | Welcome screen, Settings workspace card, empty states |
Roles and demo mode
The first visit opens a welcome screen (#/welcome): choose to continue as Maren (admin) or Tom (sales rep). The real app replaces it with its sign in page. "Sign in screen" in the demo bar opens it again.
The bar at the top reads Viewing as: Admin / Sales rep. It exists for the preview only.
| Admin (Maren Oakes) | Sales rep (Tom Reyes by default) | |
|---|---|---|
| Pipeline | Every deal; filter by any owner | Only their own deals; owner filter locked |
| Deal page | Any deal | Own deals; anyone else's shows "Not your deal" |
| Contacts, companies | Read and edit all; delete any | Read and edit all; delete the ones they own |
| Tasks | Mine or Everyone | Their own, plus tasks on their deals |
| Reports | Whole team or one owner | Their own numbers |
| Settings | Edit stages, team, fields, workspace name | Read only, with a notice |
With more than one rep, a picker appears next to the switch. These rules live in the can object in script.js and mirror the row level security in schema.sql, so what a rep sees in the demo is what the database would return.
Screens
Pipeline (#/pipeline)
- One column per stage with its deal count, total value, weighted value (value times probability) and the stage's default probability. Won and Lost columns can be hidden.
- Filters: search (deal or company), owner, and close date (passed, this month, next 30 days, this quarter).
- Drag and drop with pointer events. With a mouse the whole card drags; on touch the grip handle does, so a finger can still scroll the board. The board and page scroll when the pointer nears an edge. Escape cancels.
- Keyboard: every card has back and forward buttons labelled with the destination stage ("Move Taproom expansion on to Proposal sent"). Focus stays on the moved card.
- Every move writes a stage history entry and offers Undo. Moving to Lost also offers "Add a reason".
- A card shows value, probability, close date (red with a side rule when it has passed) and the owner.
Deal page (#/deal/<id>)
- Stage stepper (click any stage, including Won and Lost; Lost asks for a reason).
- Value, probability (stage default or a per deal override), weighted value, expected close, and the deal's custom fields.
- Tabs: Activity (log a call, email, meeting or note with a date and a contact; the timeline includes stage changes), Notes, Tasks.
- Linked contacts with their role on the deal (add from the deal's company first, remove with Undo), and a company card.
- Edit and Delete (in page confirmation).
People (#/contacts, #/companies)
- Table with search, sortable columns (
aria-sort), filters by owner and by tag or industry, custom field columns. - Bulk select (row and "select all shown") with a bar to assign an owner, add a tag, export the selection, or delete (reps can only delete rows they own; the confirmation says how many will stay).
- CSV import: upload a file or paste, choose Contacts or Companies, then a mapping step guesses each column from its header, shows the first value, and previews the first six rows with the result for each (new, adds company, already exists, email looks wrong). Duplicates match on email (contacts) or name (companies). Companies named in a contact import are created. Handles quotes, commas in fields, CRLF, semicolon and tab separated files; up to 2,000 rows per import. "Paste an example" loads a file with a duplicate and a bad row to show the checks.
- CSV export of the filtered list or the selection. Cells that start with
=,+,-or@get a leading apostrophe so spreadsheets do not run them as formulas. - Clicking a name opens the record for editing, with its deals and recent activity.
Tasks (#/tasks)
- Add a task (title, due date, deal, assignee; reps assign to themselves).
- Lists: My tasks (grouped into overdue, today, next 7 days, later), Due today, Overdue, Done. Admins switch between Mine and Everyone. Tick to finish, with Undo.
Reports (#/reports)
- Tiles: open pipeline, weighted forecast, won this quarter, win rate this quarter (by count).
- Charts, inline SVG drawn at the card's width: pipeline by stage (bars), won and lost this quarter (columns per month), forecast by expected close (weighted value stacked under the rest of the deal value, this month plus the next three and "Later"; overdue deals fold into this month and the subtitle says how many), activity per rep over the last 30 days (stacked by type).
- Hover or keyboard focus on any bar shows its numbers; every chart has a "Show as table" view. The four categorical chart colours were checked for colour vision deficiency separation in both themes.
Settings (#/settings)
- Workspace name. Pipeline stages: rename, set the default probability, reorder, add, delete (a stage with deals asks where to move them). Won and Lost are fixed at the end.
- Team: add members (name, email, role), change roles, remove members (their deals, contacts, companies and open tasks move to you). You cannot change your own role, and a workspace keeps at least one admin.
- Custom fields for deals, contacts or companies: text, number, date or a choice list. They appear on deal pages, records, table columns, CSV import mapping and export.
- Appearance, and Demo controls: remove the sample data in one step (your own records stay), bring it back, or reset everything.
What is demo or simulated
| Part | Status |
|---|---|
| Deals, contacts, companies, activity, tasks | Sample, marked "Sample", dated relative to today. A returning visitor's stale sample rows are replaced on load (own records stay) when SAMPLE_VERSION changes |
| Welcome screen | Demo sign in: picks who you are. The real app signs people in with an email link |
| Photos | Sample portraits and a studio photograph (see Photo list). New people get initials |
| "Viewing as" switch | Demo only. The real app signs people in and reads their role from members |
| Saving | This browser's localStorage (keys wayfold:v1 and wayfold:prefs), every access in try/catch. Sample rows carry SAMPLE_VERSION; a stored version that is missing or older is reseeded on load |
| Adding a team member | Added at once. The real app sends an invite email from your server |
| CSV export | Built in the browser. Some previews block downloads; the toast says so |
| Email, calendar sync, reminders | Not included |
Data model
schema.sql defines the tables, row level security, grants and the seed. The browser keeps the same tables with the same column names in store.data:
| Table | Holds | Who can read / write |
|---|---|---|
workspaces | name, currency | members read; admins rename |
members | name, email, role (admin or member), avatar_url, user_id once claimed | members read; admins add (unclaimed), change others, remove others |
pipeline_stages | name, position, default probability, kind (open, won, lost) | members read; admins write |
custom_fields | entity, label, type, options | members read; admins write |
companies, contacts | the shared address book (contacts have an optional avatar_url); custom values in custom jsonb | members read and write; owner or admin deletes |
deals | value in cents, stage, probability override, expected close, closed date, lost reason | admins all; reps only rows they own, and cannot hand one away |
deal_contacts | who is on which deal, with a role | follows the deal |
activities | call, email, meeting, note, stage change | follows the deal; you log as yourself; stage history cannot be edited |
tasks | title, due date, assignee, done time | admins all; your own; tasks on your deals. Delete: creator or admin |
Every child row references its parent through (workspace_id, id), so a client cannot link a row to another workspace's data. crm_create_workspace(name, your_name) creates a workspace with the caller as admin and the default stages. The file ends with a VERIFY block: rehearse it as a rep, a stranger and an admin inside a transaction, count rows (RLS refuses updates silently, with zero rows), then roll back. It has not been run yet.
avatar_url (members and contacts) is optional: an https URL or a root relative path, checked in the table and again before the browser renders it. The seed sets it for the sample team and three contacts with update ... where avatar_url is null, and alter table ... add column if not exists adds it to a database created before it existed. To let people upload photos, store them in a storage bucket from a server function and save the public URL here.
Derived, never stored: effective probability (deal.probability ?? stage.probability, 100 for Won, 0 for Lost), weighted value, last contacted, and all report figures.
Moving from localStorage to Supabase
The screens only read store.data and only write through store.insert, store.update and store.remove. Replace load() and persist() in section 3 of script.js; nothing else needs to know.
// 1. Sign in first, then load the workspace. The anon key is public by design; RLS protects the rows.
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
store.load = async function () {
const results = await Promise.all(TABLES.map((t) => supabase.from(t).select('*')));
const failed = results.find((r) => r.error);
if (failed) throw failed.error; // show "Could not load your workspace" and a Retry button
this.data = { version: 1, ...Object.fromEntries(TABLES.map((t, i) => [t, results[i].data])) };
};
// 2. Writes stay optimistic: update the screen, then send the change, and undo it if the server says no.
const _insert = store.insert.bind(store);
store.insert = function (table, row) {
const saved = _insert(table, row);
supabase.from(table).insert(saved).select().then(({ data, error }) => {
if (error || !data?.length) { this.remove(table, (r) => r === saved); renderView(); toast('That could not be saved.'); }
});
return saved;
};
// update and remove follow the same pattern. Always ask for the affected rows back (.select())
// and treat zero rows as a refusal: row level security refuses UPDATE and DELETE without an error.
store.persist = () => {};Then: make boot() await store.load() (the skeleton in index.html shows until the first render); replace me() with the members row whose user_id is the signed in user (and delete the "Viewing as" bar and buildSample()); and drop is_sample handling if you do not seed. For live updates between teammates, subscribe to supabase.channel(...) on deals and activities.
Server side functions you will need
- Invite a member: insert the
membersrow, email a sign in link, and on first sign in setuser_idfor the row whose email matches the confirmed address. This runs with the service role and must check the email is confirmed. - Large imports: above a few thousand rows, upload the CSV to storage and import it in a server function in batches, reporting progress.
- Email and calendar sync, if added, belong in server functions with the provider tokens stored server side, never in the browser.
There are no payments and no AI in this template.
Files
| File | What it holds |
|---|---|
index.html | Shell: sidebar, phone top bar and tab bar, demo bar, a loading skeleton, one dialog, toast, chart tooltip, live region |
styles.css | Tokens (light, dark), layout, components, the board, tables that become cards, charts, welcome, motion |
script.js | Utilities, sample data, store, roles, router, each screen, drag and drop, CSV, charts, settings |
schema.sql | Tables, RLS, helper functions, guards, grants, the seed (same rows as demo mode), VERIFY block |
template.json | Catalogue entry |
media.json | The eight photo prompts, one shared art direction |
Accessibility
Skip link; headings take focus on navigation; aria-current on navigation; labelled inputs with inline errors tied by aria-describedby; keyboard alternatives to dragging; aria-sort on sortable columns; tabs with arrow key support; confirmations inside the page with focus on the safe choice; toasts and moves announced through a polite live region; chart marks focusable with full text labels and a table view; visible focus rings; motion is short, settles, and is switched off under prefers-reduced-motion.