Apps templates

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 #4a6b00 for 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.startViewTransition cross 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.

NameSizeUsed for
team-maren700 x 700Maren Oakes, head of sales (admin)
team-tom700 x 700Tom Reyes, account executive (the default sales rep)
team-aisha700 x 700Aisha Karim, account executive
team-jonah700 x 700Jonah Pell, business development
contact-priya700 x 700Priya Venkat, Saltbush Analytics
contact-alistair700 x 700Alistair Hollis, Fenwick Hollis Partners
contact-carla700 x 700Carla Mendes, Quayside Hotel Group
studio1600 x 1211Welcome 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)
PipelineEvery deal; filter by any ownerOnly their own deals; owner filter locked
Deal pageAny dealOwn deals; anyone else's shows "Not your deal"
Contacts, companiesRead and edit all; delete anyRead and edit all; delete the ones they own
TasksMine or EveryoneTheir own, plus tasks on their deals
ReportsWhole team or one ownerTheir own numbers
SettingsEdit stages, team, fields, workspace nameRead 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

PartStatus
Deals, contacts, companies, activity, tasksSample, 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 screenDemo sign in: picks who you are. The real app signs people in with an email link
PhotosSample portraits and a studio photograph (see Photo list). New people get initials
"Viewing as" switchDemo only. The real app signs people in and reads their role from members
SavingThis 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 memberAdded at once. The real app sends an invite email from your server
CSV exportBuilt in the browser. Some previews block downloads; the toast says so
Email, calendar sync, remindersNot 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:

TableHoldsWho can read / write
workspacesname, currencymembers read; admins rename
membersname, email, role (admin or member), avatar_url, user_id once claimedmembers read; admins add (unclaimed), change others, remove others
pipeline_stagesname, position, default probability, kind (open, won, lost)members read; admins write
custom_fieldsentity, label, type, optionsmembers read; admins write
companies, contactsthe shared address book (contacts have an optional avatar_url); custom values in custom jsonbmembers read and write; owner or admin deletes
dealsvalue in cents, stage, probability override, expected close, closed date, lost reasonadmins all; reps only rows they own, and cannot hand one away
deal_contactswho is on which deal, with a rolefollows the deal
activitiescall, email, meeting, note, stage changefollows the deal; you log as yourself; stage history cannot be edited
taskstitle, due date, assignee, done timeadmins 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 members row, email a sign in link, and on first sign in set user_id for 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

FileWhat it holds
index.htmlShell: sidebar, phone top bar and tab bar, demo bar, a loading skeleton, one dialog, toast, chart tooltip, live region
styles.cssTokens (light, dark), layout, components, the board, tables that become cards, charts, welcome, motion
script.jsUtilities, sample data, store, roles, router, each screen, drag and drop, CSV, charts, settings
schema.sqlTables, RLS, helper functions, guards, grants, the seed (same rows as demo mode), VERIFY block
template.jsonCatalogue entry
media.jsonThe 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.